@heyquilt/seam 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,199 @@
1
+ // Quilt source control's MCP tools (plans/quilt-source-control/12), served by `seam mcp` (seam/),
2
+ // not by the Quilt app's MCP: one description and one schema per tool, answered by the same
3
+ // server routes the CLI uses, so Claude Code, Cursor, Codex and every other tool get the same
4
+ // behaviour and the same sentences.
5
+ //
6
+ // Locally, in a folder made by `seam checkout`, the quilt and square come from the folder and
7
+ // seam_commit seals the folder's changes; anywhere else (and always for hosted agents) name the
8
+ // quilt and square, and seam_commit takes the files' new contents.
9
+ import { z } from 'zod'
10
+ import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents } from './describe.js'
11
+ import { status as blockStatus, commitFolder, syncTo } from './block.js'
12
+
13
+ export const SEAM_TOOLS = ['seam_quilts', 'seam_open_square', 'seam_squares', 'seam_log', 'seam_show', 'seam_diff', 'seam_blame', 'seam_cat', 'seam_ls', 'seam_commit', 'seam_merge', 'seam_rebase', 'seam_revert', 'seam_cherry_pick', 'seam_resolve', 'seam_square_status', 'seam_square_handoff', 'seam_abandon', 'seam_reopen', 'seam_fold', 'seam_split', 'seam_events', 'seam_events_webhook']
14
+
15
+ /** What every agent is told about source control, in each MCP's instructions. */
16
+ export const SEAM_GUIDE = 'Quilt source control (quilts): a quilt is a repository on the Quilt server; you work in a square (your own branch and working copy), commit save points in it, and merge it into main, where it becomes a patch. ' +
17
+ 'Open a square with seam_open_square, commit with seam_commit, merge with seam_merge (into: "main"). A merge never stops half way: a real conflict is refused, kept in your square as conflict markers, and you settle it (edit the file and commit it without the markers, or seam_resolve) and merge again. ' +
18
+ 'Read history with seam_log, seam_show, seam_diff, seam_blame and seam_cat; revisions are main, main~3, patch:12, sq:<id>, a commit id or git:<sha>. ' +
19
+ 'In a folder checked out from a square (it holds .seam/block.json), edit the files with your own tools and check them with seam_square_status.'
20
+
21
+ const QUILT = z.string().max(100).optional().describe('The quilt (its slug or id); in a folder made by seam checkout, that folder\'s quilt')
22
+ const SQUARE = z.string().max(100).optional().describe('The square (sq:<id>); in a block folder, that folder\'s square')
23
+ const REV = z.string().max(200).optional()
24
+ const PATHS = z.array(z.string().max(1000)).max(200).optional().describe('Only these paths (files or directories)')
25
+
26
+ export const SEAM_DESCRIPTIONS = {
27
+ seam_quilts: 'Lists the quilts (repositories) you can reach, or makes a new one (new: its slug).',
28
+ seam_open_square: 'Opens a square: your own unit of work, branched from the tip of main (or line). Returns its id (sq:…). Agents open squares, never lines.',
29
+ seam_squares: 'The open squares of a quilt, with holder, head, how far behind main each is and its conflicted paths. all: true for merged and abandoned ones too.',
30
+ seam_square_status: 'One square as the server sees it (head, behind_by, conflicted paths), the patches merged under it that changed its files, and which files would conflict if merged now; in a block folder also the files changed since the last commit.',
31
+ seam_square_handoff: 'Hands your square to another member (to), with a note on what is done and what is left. You stop being able to commit to it.',
32
+ seam_abandon: 'Abandons a square whose work is not wanted. Its commits stay readable and seam_reopen brings it back.',
33
+ seam_reopen: 'Reopens an abandoned square; you become its holder.',
34
+ seam_events: 'What happened to your work since you last looked: patch.merged (a patch changed files your open square changed, and whether your merge would now conflict), conflict.added (a conflict was kept in your square), square.handoff. Pass after (the cursor from the last answer) to get only new ones.',
35
+ seam_events_webhook: 'Has the quilt server POST your events to a URL (signed with HMAC-SHA256 over <timestamp>.<body>, retried 3 times), so you are woken instead of polling: url, events (default all), secret (made for you if left out). url: "off" removes it.',
36
+ seam_fold: 'Folds a square (square) into another square you hold (into): its work becomes one commit there and the square closes. Overlapping changes become conflict markers in the target.',
37
+ seam_split: 'Splits your square by paths: groups: [[paths], ...], each group moves to a new square (same base) as one commit; the largest group, and paths no group names, stay in this square.',
38
+ seam_log: 'History, newest first: rev (default main, or your square), paths to limit it, limit (default 20). Each patch on main is one line\'s merge.',
39
+ seam_show: 'One commit or patch: who, when, why, which square, and its diff. rev: patch:12, main~1, sq:…, a commit id, git:<sha>. format: unified (default), stat or names.',
40
+ seam_diff: 'The difference between two revisions (from, to); with only to, against its parent (for a square: against where it branched). format: unified, stat or names.',
41
+ seam_blame: 'Each line of a file at a revision, with the commit and patch that last changed it and who made it. Computed by the server.',
42
+ seam_cat: 'A file\'s content at any revision (path, rev). To change a file, read it from your own square (rev sq:<id>), not from main: main moves while you work, and a commit built from a different version of main than your square started from can undo work that merged in between. A conflicted file comes back with its conflict markers: format diff (default: one side in full after +++++++, each other side as a diff from its base after %%%%%%%), snapshot (every side in full) or git (<<<<<<< ======= >>>>>>>, two sides only).',
43
+ seam_ls: 'The files at a revision, under paths (directories).',
44
+ seam_commit: 'Seals your square\'s changes as a commit (a save point). In a block folder: the folder\'s changed files (paths: only these). Elsewhere: files: [{ path, content }] (text; base64: true for bytes) and delete: [paths]. message says why. Committing a conflicted file without its markers settles the conflict.',
45
+ seam_merge: 'Merges your square into a line (into: "main") or, with from, merges another square or revision into your square (a two-parent commit; conflicts stay in your square as markers). Into a line: the server rebases it onto the tip as one commit and moves main, retrying lost races itself. The result is a patch. A real conflict is refused: the square is rebased with the conflict kept in it (your folder gets the markers); settle it and merge again. dry_run: true says what would happen.',
46
+ seam_rebase: 'Moves your square onto the tip of its line (or onto a revision). Conflicts become conflict markers in the square, never a stop.',
47
+ seam_revert: 'Undoes a patch (or commit): a new square from the tip whose one commit inverts it. Merge that square with seam_merge.',
48
+ seam_cherry_pick: 'Applies one commit\'s change (from) to your square as a new commit.',
49
+ seam_resolve: 'Settles conflicted files in your square by choosing a side: entries [{ path, choose: ours | theirs | base | suggestion | delete }]; suggestion takes the word-level merge a conflict offers (its suggestion, shown when the merge is refused). (Or edit the file and commit it without the markers.)'
50
+ }
51
+
52
+ export const SEAM_INPUTS = {
53
+ seam_quilts: { new: z.string().max(63).optional().describe('Make a quilt with this slug') },
54
+ seam_open_square: { quilt: QUILT, title: z.string().max(200).describe('What the work is'), line: z.string().max(100).optional(), task_id: z.string().max(64).optional() },
55
+ seam_squares: { quilt: QUILT, all: z.boolean().optional(), mine: z.boolean().optional() },
56
+ seam_square_status: { quilt: QUILT, square: SQUARE },
57
+ seam_log: { quilt: QUILT, rev: REV, paths: PATHS, limit: z.number().int().min(1).max(500).optional() },
58
+ seam_show: { quilt: QUILT, rev: REV, format: z.enum(['unified', 'stat', 'names']).optional(), paths: PATHS },
59
+ seam_diff: { quilt: QUILT, from: REV, to: REV, format: z.enum(['unified', 'stat', 'names']).optional(), paths: PATHS },
60
+ seam_blame: { quilt: QUILT, path: z.string().max(1000), rev: REV },
61
+ seam_cat: { quilt: QUILT, path: z.string().max(1000), rev: REV, format: z.enum(['diff', 'snapshot', 'git']).optional().describe('How a conflicted file is written (default diff)') },
62
+ seam_ls: { quilt: QUILT, rev: REV, paths: PATHS },
63
+ seam_commit: {
64
+ quilt: QUILT, square: SQUARE, message: z.string().max(65536).describe('Why'),
65
+ files: z.array(z.object({ path: z.string().max(1000), content: z.string(), base64: z.boolean().optional(), mode: z.enum(['file', 'exec']).optional() })).max(2000).optional(),
66
+ delete: z.array(z.string().max(1000)).max(2000).optional(),
67
+ paths: PATHS
68
+ },
69
+ seam_square_handoff: { quilt: QUILT, square: SQUARE, to: z.string().max(200).describe('The member to hand it to'), note: z.string().max(2000).optional() },
70
+ seam_abandon: { quilt: QUILT, square: SQUARE },
71
+ seam_reopen: { quilt: QUILT, square: SQUARE },
72
+ seam_events: { quilt: QUILT, after: z.number().int().min(0).optional(), kinds: z.array(z.enum(['patch.merged', 'conflict.added', 'square.handoff'])).optional() },
73
+ seam_events_webhook: { quilt: QUILT, url: z.string().max(2000), events: z.array(z.enum(['patch.merged', 'conflict.added', 'square.handoff'])).optional(), secret: z.string().min(16).max(200).optional() },
74
+ seam_fold: { quilt: QUILT, square: SQUARE, into: z.string().max(100).describe('The square (sq:…) that takes the work'), message: z.string().max(65536).optional() },
75
+ seam_split: { quilt: QUILT, square: SQUARE, groups: z.array(z.array(z.string().max(1000)).min(1).max(2000)).min(1).max(20).describe('Paths (files or directories) per new square'), titles: z.array(z.string().max(200)).max(20).optional() },
76
+ seam_merge: { quilt: QUILT, square: SQUARE, from: z.string().max(200).optional().describe('Merge this square (sq:…) or revision into your square instead of merging your square into a line'), into: z.string().max(100).optional().describe('The line, default main'), message: z.string().max(65536).optional(), dry_run: z.boolean().optional(), on_conflict: z.enum(['rebase', 'leave']).optional() },
77
+ seam_rebase: { quilt: QUILT, square: SQUARE, onto: REV },
78
+ seam_revert: { quilt: QUILT, rev: z.string().max(200).describe('patch:12, a commit id, …'), line: z.string().max(100).optional() },
79
+ seam_cherry_pick: { quilt: QUILT, square: SQUARE, from: z.string().max(200) },
80
+ seam_resolve: { quilt: QUILT, square: SQUARE, entries: z.array(z.object({ path: z.string().max(1000), choose: z.union([z.enum(['ours', 'theirs', 'base', 'suggestion', 'delete', 'keep_edit', 'keep_delete']), z.number().int().min(0).max(8)]) })).min(1).max(500) }
81
+ }
82
+
83
+ /**
84
+ * Runs one tool. `client` is a quilt client (client.js) acting as the agent; `found` is the
85
+ * block folder the local MCP runs in ({ dir, block }) or null. Returns the text to answer.
86
+ */
87
+ export async function runSeamTool (name, args, { client, found = null }) {
88
+ const block = found?.block || null
89
+ const quilt = args.quilt || block?.quilt
90
+ const square = args.square || block?.square
91
+ const needQuilt = () => { if (!quilt) throw new Error('Name the quilt (quilt: its slug or id); seam_quilts lists yours.'); return quilt }
92
+ const needSquare = () => { if (!square) throw new Error('Name the square (square: sq:…); seam_open_square opens one, seam_squares lists them.'); return square }
93
+ const inFolder = found && block && square === block.square && quilt === block.quilt
94
+ const sync = async (head, sq) => { if (inFolder && head && head !== block.head) { const s = await syncTo(client, found.dir, block, head, { square: sq }); return `\nThe folder now holds the square's files (${s.written} updated).` } return '' }
95
+ try {
96
+ switch (name) {
97
+ case 'seam_quilts': {
98
+ if (args.new) { const q = await client.createQuilt({ slug: args.new }); return `Made quilt ${q.slug} (${q.id}).` }
99
+ const list = await client.quilts()
100
+ return list.length ? list.map((q) => `${q.slug} (${q.id}), ${q.level}`).join('\n') : 'No quilts yet.'
101
+ }
102
+ case 'seam_open_square': {
103
+ const sq = await client.openSquare(needQuilt(), { title: args.title, line: args.line, task_id: args.task_id })
104
+ return `Opened ${describeSquare(sq)}.\nCommit in it with seam_commit (square: "${sq.short}") and merge it with seam_merge.`
105
+ }
106
+ case 'seam_squares': {
107
+ const list = await client.squares(needQuilt(), { state: args.all ? undefined : 'open', mine: !!args.mine })
108
+ return list.length ? list.map(describeSquare).join('\n') : 'No open squares.'
109
+ }
110
+ case 'seam_square_status': {
111
+ if (inFolder) return describeStatus(await blockStatus(client, found.dir, block))
112
+ return describeSquareStatus(await client.square(needQuilt(), needSquare()))
113
+ }
114
+ case 'seam_log': return describeLog(await client.log(needQuilt(), { rev: args.rev || (block?.square ? block.square_short : 'main'), paths: args.paths, limit: args.limit }), { oneline: true })
115
+ case 'seam_show': return describeShow(await client.show(needQuilt(), args.rev || 'main', { format: args.format || 'unified', paths: args.paths }), { format: args.format || 'unified' })
116
+ case 'seam_diff': return describeDiff(await client.diff(needQuilt(), { from: args.from, to: args.to, paths: args.paths, format: args.format || 'unified' }), { format: args.format || 'unified' })
117
+ case 'seam_blame': return describeBlame(await client.blame(needQuilt(), args.rev || 'main', args.path))
118
+ case 'seam_cat': { const r = await client.cat(needQuilt(), args.rev || 'main', args.path, { format: args.format || 'diff' }); return Buffer.from(r.content, 'base64').toString('utf8') }
119
+ case 'seam_ls': { const r = await client.tree(needQuilt(), args.rev || 'main', args.paths); return r.entries.map((e) => `${e.mode}\t${e.path}`).join('\n') || 'No files.' }
120
+ case 'seam_commit': {
121
+ if (inFolder && !args.files && !args.delete) {
122
+ const r = await commitFolder(client, found.dir, block, { message: args.message, paths: (args.paths || []).map((p) => `${found.dir}/${p}`) })
123
+ return r.nothing ? 'Nothing to commit: no file in this folder changed since the last commit.' : describeWrite(r, 'Committed')
124
+ }
125
+ const changes = [
126
+ ...(args.files || []).map((f) => ({ path: f.path, op: 'put', content: f.base64 ? f.content : Buffer.from(f.content, 'utf8').toString('base64'), ...(f.mode ? { mode: f.mode } : {}) })),
127
+ ...(args.delete || []).map((p) => ({ path: p, op: 'delete' }))
128
+ ]
129
+ if (!changes.length) return 'Nothing to commit: give files (and delete), or run in a block folder.'
130
+ const sq = await client.square(needQuilt(), needSquare())
131
+ return describeWrite(await client.commit(quilt, sq.id, { expect_head: sq.head, message: args.message, changes }), 'Committed')
132
+ }
133
+ case 'seam_square_handoff': { const sq = await client.handoff(needQuilt(), needSquare(), { to: args.to, note: args.note }); return `Handed ${sq.short} to ${sq.holder}.` }
134
+ case 'seam_abandon': { const sq = await client.abandon(needQuilt(), needSquare(), inFolder ? { expect_head: block.head } : {}); return `Abandoned ${sq.short}; seam_reopen brings it back.` }
135
+ case 'seam_reopen': { const sq = await client.reopen(needQuilt(), needSquare()); return `Reopened ${describeSquare(sq)}.` }
136
+ case 'seam_events': return describeEvents(await client.events(needQuilt(), { after: args.after, kinds: args.kinds }))
137
+ case 'seam_events_webhook': {
138
+ if (args.url === 'off') { await client.subscribe(needQuilt(), { url: null }); return 'Webhook removed.' }
139
+ const r = await client.subscribe(needQuilt(), { url: args.url, events: args.events, secret: args.secret })
140
+ return `Events (${r.events.join(', ')}) now POST to ${r.url}.${r.secret ? ` Signing secret (shown once): ${r.secret}` : ''}`
141
+ }
142
+ case 'seam_fold': {
143
+ const r = await client.fold(needQuilt(), needSquare(), { into: args.into, message: args.message, ...(inFolder ? { expect_head: block.head } : {}) })
144
+ return describeWrite(r, `Folded ${r.folded.short} into ${r.square.short}:`)
145
+ }
146
+ case 'seam_split': {
147
+ const r = await client.split(needQuilt(), needSquare(), { by: 'paths', groups: args.groups, titles: args.titles, ...(inFolder ? { expect_head: block.head } : {}) })
148
+ return [`Kept in ${r.square.short}: ${r.kept.join(', ') || '(nothing)'}`, ...r.squares.map((q) => `New ${describeSquare(q)}: ${q.paths.join(', ')}`)].join('\n') + await sync(r.square.head, r.square)
149
+ }
150
+ case 'seam_merge': {
151
+ if (args.from) {
152
+ const r = await client.mergeSquare(needQuilt(), needSquare(), { from: args.from, message: args.message, ...(inFolder ? { expect_head: block.head } : {}) })
153
+ return r.noop ? `Already up to date: ${args.from} is in this square.` : describeWrite(r, `Merged ${args.from} in:`) + await sync(r.square.head, r.square)
154
+ }
155
+ const r = await client.merge(needQuilt(), args.into || block?.line || 'main', { square: needSquare(), ...(inFolder ? { expect_head: block.head } : {}), message: args.message, dry_run: !!args.dry_run, on_conflict: args.on_conflict })
156
+ return describeMerge(r) + (!r.ok && r.head ? await sync(r.head, r.square) : '')
157
+ }
158
+ case 'seam_rebase': {
159
+ const r = await client.rebase(needQuilt(), needSquare(), { ...(inFolder ? { expect_head: block.head } : {}), onto: args.onto })
160
+ return (r.noop ? 'Already on the tip.' : describeWrite(r, 'Rebased')) + (r.square ? await sync(r.square.head || r.square.base, r.square) : '')
161
+ }
162
+ case 'seam_revert': {
163
+ const r = await client.revert(needQuilt(), { rev: args.rev, line: args.line })
164
+ return describeWrite(r, 'Reverted in a new square') + `\nMerge it with seam_merge (square: "${r.square.short}").`
165
+ }
166
+ case 'seam_cherry_pick': {
167
+ const r = await client.cherryPick(needQuilt(), needSquare(), { from: args.from, ...(inFolder ? { expect_head: block.head } : {}) })
168
+ return describeWrite(r, 'Picked') + await sync(r.square.head, r.square)
169
+ }
170
+ case 'seam_resolve': {
171
+ const r = await client.resolve(needQuilt(), needSquare(), { ...(inFolder ? { expect_head: block.head } : {}), entries: args.entries })
172
+ return describeWrite(r, 'Resolved') + await sync(r.square.head, r.square)
173
+ }
174
+ }
175
+ return `Unknown tool ${name}.`
176
+ } catch (e) {
177
+ if (e.code === 'head_moved') return `${e.message}: the square's head is now ${String(e.head || '').slice(0, 12)}. Look again (seam_square_status) and retry.`
178
+ throw e
179
+ }
180
+ }
181
+
182
+
183
+ /**
184
+ * Registers every tool on an MCP server. `client()` makes the quilt client for a call (it may
185
+ * throw when the caller is not signed in); `found()` the block folder, or null.
186
+ */
187
+ export function registerSeamTools (server, { client, found = () => null, extraInput = {} }) {
188
+ for (const name of SEAM_TOOLS) {
189
+ server.registerTool(name, { description: SEAM_DESCRIPTIONS[name], inputSchema: { ...SEAM_INPUTS[name], ...extraInput } }, async (args = {}) => {
190
+ try {
191
+ const { session, ...rest } = args
192
+ void session
193
+ return { content: [{ type: 'text', text: await runSeamTool(name, rest, { client: client(), found: found() }) }] }
194
+ } catch (e) {
195
+ return { content: [{ type: 'text', text: e.message }], isError: true }
196
+ }
197
+ })
198
+ }
199
+ }
@@ -0,0 +1,435 @@
1
+ // A port of git's xdiff line diff (Myers with xdiff's heuristics, record cleanup, and change
2
+ // compaction with the indent heuristic), so `blame` and `diff` choose the same lines git does.
3
+ //
4
+ // Ported from git's xdiff/xprepare.c, xdiff/xdiffi.c and xdiff-interface.c (trim_common_tail).
5
+ // LibXDiff by Davide Libenzi, Copyright (C) 2003, LGPL-2.1-or-later; git's changes are under the
6
+ // same terms. Structure and names follow the C so the two can be compared line by line.
7
+
8
+ const XDL_KPDIS_RUN = 4
9
+ const XDL_MAX_EQLIMIT = 1024
10
+ const XDL_SIMSCAN_WINDOW = 100
11
+ const XDL_MAX_COST_MIN = 256
12
+ const XDL_HEUR_MIN_COST = 256
13
+ const XDL_LINE_MAX = Number.MAX_SAFE_INTEGER
14
+ const XDL_SNAKE_CNT = 20
15
+ const XDL_K_HEUR = 4
16
+ const DISCARD = 0; const KEEP = 1; const INVESTIGATE = 2
17
+
18
+ function bogosqrt (n) { let i = 1; for (; n > 0; n = Math.floor(n / 4)) i *= 2; return i }
19
+
20
+ /** git's trim_common_tail: the byte length to drop from both ends' tails (blame only). */
21
+ function commonTail (a, b) {
22
+ const blk = 1024
23
+ let trimmed = 0; let recovered = 0
24
+ const smaller = Math.min(a.length, b.length)
25
+ while (blk + trimmed <= smaller &&
26
+ a.compare(b, b.length - trimmed - blk, b.length - trimmed, a.length - trimmed - blk, a.length - trimmed) === 0) trimmed += blk
27
+ const ap = a.length - trimmed
28
+ while (recovered < trimmed) if (a[ap + recovered++] === 10) break
29
+ return trimmed - recovered
30
+ }
31
+
32
+ /** Records of a buffer: [start, end) per line, each with its \n. */
33
+ function records (buf, size) {
34
+ const starts = []
35
+ let s = 0
36
+ while (s < size) {
37
+ let e = buf.indexOf(10, s)
38
+ e = e < 0 || e >= size ? size : e + 1
39
+ starts.push(s, e)
40
+ s = e
41
+ }
42
+ return starts
43
+ }
44
+
45
+ function prepare (buf, size, classes, counts, pass) {
46
+ const r = records(buf, size)
47
+ const n = r.length / 2
48
+ const mph = new Int32Array(n)
49
+ for (let i = 0; i < n; i++) {
50
+ const key = buf.toString('latin1', r[2 * i], r[2 * i + 1])
51
+ let id = classes.get(key)
52
+ if (id === undefined) { id = classes.size; classes.set(key, id); counts.push(0, 0) }
53
+ counts[2 * id + pass]++
54
+ mph[i] = id
55
+ }
56
+ return { buf, r, nrec: n, mph, changed: new Uint8Array(n + 2), ref: null, nreff: 0, dstart: 0, dend: n - 1 }
57
+ }
58
+
59
+ function cleanMmatch (action, i, len) {
60
+ let s = 0; let e = len - 1
61
+ if (i - s > XDL_SIMSCAN_WINDOW) s = i - XDL_SIMSCAN_WINDOW
62
+ if (e - i > XDL_SIMSCAN_WINDOW) e = i + XDL_SIMSCAN_WINDOW
63
+ let r; let rdis0 = 0; let rpdis0 = 1; let rdis1 = 0; let rpdis1 = 1
64
+ for (r = 1; i - r >= s; r++) {
65
+ const a = action[i - r]
66
+ if (a === DISCARD) rdis0++
67
+ else if (a === INVESTIGATE) rpdis0++
68
+ else break
69
+ }
70
+ if (rdis0 === 0) return false
71
+ for (r = 1; i + r <= e; r++) {
72
+ const a = action[i + r]
73
+ if (a === DISCARD) rdis1++
74
+ else if (a === INVESTIGATE) rpdis1++
75
+ else break
76
+ }
77
+ if (rdis1 === 0) return false
78
+ rdis1 += rdis0
79
+ rpdis1 += rpdis0
80
+ return rpdis1 * XDL_KPDIS_RUN < rpdis1 + rdis1
81
+ }
82
+
83
+ function trimEnds (x1, x2) {
84
+ const lim = Math.min(x1.nrec, x2.nrec)
85
+ let i = 0
86
+ while (i < lim && x1.mph[i] === x2.mph[i]) i++
87
+ x1.dstart = x2.dstart = i
88
+ const lim2 = lim - i
89
+ let j = 0
90
+ while (j < lim2 && x1.mph[x1.nrec - 1 - j] === x2.mph[x2.nrec - 1 - j]) j++
91
+ x1.dend = x1.nrec - j - 1
92
+ x2.dend = x2.nrec - j - 1
93
+ }
94
+
95
+ function cleanupRecords (counts, x1, x2) {
96
+ const off = x1.dstart
97
+ const len1 = x1.dend - off + 1
98
+ const len2 = x2.dend - off + 1
99
+ const one = (x, len, other) => {
100
+ const action = new Uint8Array(Math.max(len, 0))
101
+ let mlim = bogosqrt(x.nrec)
102
+ if (mlim > XDL_MAX_EQLIMIT) mlim = XDL_MAX_EQLIMIT
103
+ for (let i = 0; i < len; i++) {
104
+ const nm = counts[2 * x.mph[i + off] + other]
105
+ action[i] = nm === 0 ? DISCARD : nm < mlim ? KEEP : INVESTIGATE
106
+ }
107
+ x.ref = new Int32Array(Math.max(len, 0))
108
+ x.nreff = 0
109
+ for (let i = 0; i < len; i++) {
110
+ let a = action[i]
111
+ if (a === INVESTIGATE) a = cleanMmatch(action, i, len) ? DISCARD : KEEP
112
+ if (a === KEEP) x.ref[x.nreff++] = i + off
113
+ else x.changed[i + off + 1] = 1
114
+ }
115
+ }
116
+ one(x1, len1, 1)
117
+ one(x2, len2, 0)
118
+ }
119
+
120
+ function doDiff (x1, x2) {
121
+ const h1 = new Int32Array(x1.nreff); for (let i = 0; i < x1.nreff; i++) h1[i] = x1.mph[x1.ref[i]]
122
+ const h2 = new Int32Array(x2.nreff); for (let i = 0; i < x2.nreff; i++) h2[i] = x2.mph[x2.ref[i]]
123
+ const ndiags = x1.nreff + x2.nreff + 3
124
+ const kvd = new Float64Array(2 * ndiags + 2)
125
+ const F = x2.nreff + 1
126
+ const B = ndiags + x2.nreff + 1
127
+ let mxcost = bogosqrt(ndiags)
128
+ if (mxcost < XDL_MAX_COST_MIN) mxcost = XDL_MAX_COST_MIN
129
+ const snakeCnt = XDL_SNAKE_CNT; const heurMin = XDL_HEUR_MIN_COST
130
+ const spl = { i1: 0, i2: 0, minLo: 0, minHi: 0 }
131
+
132
+ function split (off1, lim1, off2, lim2, needMin) {
133
+ const dmin = off1 - lim2; const dmax = lim1 - off2
134
+ const fmid = off1 - off2; const bmid = lim1 - lim2
135
+ const odd = (fmid - bmid) & 1
136
+ let fmin = fmid; let fmax = fmid; let bmin = bmid; let bmax = bmid
137
+ let i1, i2, prev1, best, dd, v, k, d
138
+ kvd[F + fmid] = off1
139
+ kvd[B + bmid] = lim1
140
+ for (let ec = 1; ; ec++) {
141
+ let gotSnake = false
142
+ if (fmin > dmin) kvd[F + (--fmin) - 1] = -1; else ++fmin
143
+ if (fmax < dmax) kvd[F + (++fmax) + 1] = -1; else --fmax
144
+ for (d = fmax; d >= fmin; d -= 2) {
145
+ if (kvd[F + d - 1] >= kvd[F + d + 1]) i1 = kvd[F + d - 1] + 1
146
+ else i1 = kvd[F + d + 1]
147
+ prev1 = i1
148
+ i2 = i1 - d
149
+ while (i1 < lim1 && i2 < lim2 && h1[i1] === h2[i2]) { i1++; i2++ }
150
+ if (i1 - prev1 > snakeCnt) gotSnake = true
151
+ kvd[F + d] = i1
152
+ if (odd && bmin <= d && d <= bmax && kvd[B + d] <= i1) {
153
+ spl.i1 = i1; spl.i2 = i2; spl.minLo = spl.minHi = 1
154
+ return
155
+ }
156
+ }
157
+ if (bmin > dmin) kvd[B + (--bmin) - 1] = XDL_LINE_MAX; else ++bmin
158
+ if (bmax < dmax) kvd[B + (++bmax) + 1] = XDL_LINE_MAX; else --bmax
159
+ for (d = bmax; d >= bmin; d -= 2) {
160
+ if (kvd[B + d - 1] < kvd[B + d + 1]) i1 = kvd[B + d - 1]
161
+ else i1 = kvd[B + d + 1] - 1
162
+ prev1 = i1
163
+ i2 = i1 - d
164
+ while (i1 > off1 && i2 > off2 && h1[i1 - 1] === h2[i2 - 1]) { i1--; i2-- }
165
+ if (prev1 - i1 > snakeCnt) gotSnake = true
166
+ kvd[B + d] = i1
167
+ if (!odd && fmin <= d && d <= fmax && i1 <= kvd[F + d]) {
168
+ spl.i1 = i1; spl.i2 = i2; spl.minLo = spl.minHi = 1
169
+ return
170
+ }
171
+ }
172
+ if (needMin) continue
173
+ if (gotSnake && ec > heurMin) {
174
+ for (best = 0, d = fmax; d >= fmin; d -= 2) {
175
+ dd = d > fmid ? d - fmid : fmid - d
176
+ i1 = kvd[F + d]
177
+ i2 = i1 - d
178
+ v = (i1 - off1) + (i2 - off2) - dd
179
+ if (v > XDL_K_HEUR * ec && v > best && off1 + snakeCnt <= i1 && i1 < lim1 && off2 + snakeCnt <= i2 && i2 < lim2) {
180
+ for (k = 1; h1[i1 - k] === h2[i2 - k]; k++) {
181
+ if (k === snakeCnt) { best = v; spl.i1 = i1; spl.i2 = i2; break }
182
+ }
183
+ }
184
+ }
185
+ if (best > 0) { spl.minLo = 1; spl.minHi = 0; return }
186
+ for (best = 0, d = bmax; d >= bmin; d -= 2) {
187
+ dd = d > bmid ? d - bmid : bmid - d
188
+ i1 = kvd[B + d]
189
+ i2 = i1 - d
190
+ v = (lim1 - i1) + (lim2 - i2) - dd
191
+ if (v > XDL_K_HEUR * ec && v > best && off1 < i1 && i1 <= lim1 - snakeCnt && off2 < i2 && i2 <= lim2 - snakeCnt) {
192
+ for (k = 0; h1[i1 + k] === h2[i2 + k]; k++) {
193
+ if (k === snakeCnt - 1) { best = v; spl.i1 = i1; spl.i2 = i2; break }
194
+ }
195
+ }
196
+ }
197
+ if (best > 0) { spl.minLo = 0; spl.minHi = 1; return }
198
+ }
199
+ if (ec >= mxcost) {
200
+ let fbest = -1; let fbest1 = -1
201
+ for (d = fmax; d >= fmin; d -= 2) {
202
+ i1 = Math.min(kvd[F + d], lim1)
203
+ i2 = i1 - d
204
+ if (lim2 < i2) { i1 = lim2 + d; i2 = lim2 }
205
+ if (fbest < i1 + i2) { fbest = i1 + i2; fbest1 = i1 }
206
+ }
207
+ let bbest = XDL_LINE_MAX; let bbest1 = XDL_LINE_MAX
208
+ for (d = bmax; d >= bmin; d -= 2) {
209
+ i1 = Math.max(off1, kvd[B + d])
210
+ i2 = i1 - d
211
+ if (i2 < off2) { i1 = off2 + d; i2 = off2 }
212
+ if (i1 + i2 < bbest) { bbest = i1 + i2; bbest1 = i1 }
213
+ }
214
+ if ((lim1 + lim2) - bbest < fbest - (off1 + off2)) {
215
+ spl.i1 = fbest1; spl.i2 = fbest - fbest1; spl.minLo = 1; spl.minHi = 0
216
+ } else {
217
+ spl.i1 = bbest1; spl.i2 = bbest - bbest1; spl.minLo = 0; spl.minHi = 1
218
+ }
219
+ return
220
+ }
221
+ }
222
+ }
223
+
224
+ // xdl_recs_cmp, with its recursion on an explicit stack (depth can reach thousands).
225
+ const stack = [[0, x1.nreff, 0, x2.nreff, 0]]
226
+ while (stack.length) {
227
+ let [off1, lim1, off2, lim2, needMin] = stack.pop()
228
+ while (off1 < lim1 && off2 < lim2 && h1[off1] === h2[off2]) { off1++; off2++ }
229
+ while (off1 < lim1 && off2 < lim2 && h1[lim1 - 1] === h2[lim2 - 1]) { lim1--; lim2-- }
230
+ if (off1 === lim1) {
231
+ for (; off2 < lim2; off2++) x2.changed[x2.ref[off2] + 1] = 1
232
+ } else if (off2 === lim2) {
233
+ for (; off1 < lim1; off1++) x1.changed[x1.ref[off1] + 1] = 1
234
+ } else {
235
+ spl.i1 = spl.i2 = 0
236
+ split(off1, lim1, off2, lim2, needMin)
237
+ const { i1, i2, minLo, minHi } = spl
238
+ // Low half first, as the C recursion does (it only marks, so order matters not for the
239
+ // result, but keep it for fidelity).
240
+ stack.push([i1, lim1, i2, lim2, minHi])
241
+ stack.push([off1, i1, off2, i2, minLo])
242
+ }
243
+ }
244
+ }
245
+
246
+ // --- change compaction (xdl_change_compact) with the indent heuristic ---
247
+
248
+ const MAX_INDENT = 200
249
+ const MAX_BLANKS = 20
250
+ const START_OF_FILE_PENALTY = 1
251
+ const END_OF_FILE_PENALTY = 21
252
+ const TOTAL_BLANK_WEIGHT = -30
253
+ const POST_BLANK_WEIGHT = 6
254
+ const RELATIVE_INDENT_PENALTY = -4
255
+ const RELATIVE_INDENT_WITH_BLANK_PENALTY = 10
256
+ const RELATIVE_OUTDENT_PENALTY = 24
257
+ const RELATIVE_OUTDENT_WITH_BLANK_PENALTY = 17
258
+ const RELATIVE_DEDENT_PENALTY = 23
259
+ const RELATIVE_DEDENT_WITH_BLANK_PENALTY = 17
260
+ const INDENT_WEIGHT = 60
261
+ const INDENT_HEURISTIC_MAX_SLIDING = 100
262
+
263
+ function getIndent (x, i) {
264
+ let ret = 0
265
+ const buf = x.buf
266
+ for (let p = x.r[2 * i], e = x.r[2 * i + 1]; p < e; p++) {
267
+ const c = buf[p]
268
+ // isspace: space, \t, \n, \v, \f, \r
269
+ if (!(c === 32 || (c >= 9 && c <= 13))) return ret
270
+ else if (c === 32) ret += 1
271
+ else if (c === 9) ret += 8 - ret % 8
272
+ if (ret >= MAX_INDENT) return MAX_INDENT
273
+ }
274
+ return -1
275
+ }
276
+
277
+ function measureSplit (x, split) {
278
+ const m = { eof: 0, indent: -1, preBlank: 0, preIndent: -1, postBlank: 0, postIndent: -1 }
279
+ if (split >= x.nrec) { m.eof = 1; m.indent = -1 } else m.indent = getIndent(x, split)
280
+ for (let i = split - 1; i >= 0; i--) {
281
+ m.preIndent = getIndent(x, i)
282
+ if (m.preIndent !== -1) break
283
+ m.preBlank += 1
284
+ if (m.preBlank === MAX_BLANKS) { m.preIndent = 0; break }
285
+ }
286
+ for (let i = split + 1; i < x.nrec; i++) {
287
+ m.postIndent = getIndent(x, i)
288
+ if (m.postIndent !== -1) break
289
+ m.postBlank += 1
290
+ if (m.postBlank === MAX_BLANKS) { m.postIndent = 0; break }
291
+ }
292
+ return m
293
+ }
294
+
295
+ function scoreAddSplit (m, s) {
296
+ if (m.preIndent === -1 && m.preBlank === 0) s.penalty += START_OF_FILE_PENALTY
297
+ if (m.eof) s.penalty += END_OF_FILE_PENALTY
298
+ const postBlank = m.indent === -1 ? 1 + m.postBlank : 0
299
+ const totalBlank = m.preBlank + postBlank
300
+ s.penalty += TOTAL_BLANK_WEIGHT * totalBlank
301
+ s.penalty += POST_BLANK_WEIGHT * postBlank
302
+ const indent = m.indent !== -1 ? m.indent : m.postIndent
303
+ const anyBlanks = totalBlank !== 0
304
+ s.effectiveIndent += indent
305
+ if (indent === -1 || m.preIndent === -1) {
306
+ // no adjustment
307
+ } else if (indent > m.preIndent) {
308
+ s.penalty += anyBlanks ? RELATIVE_INDENT_WITH_BLANK_PENALTY : RELATIVE_INDENT_PENALTY
309
+ } else if (indent === m.preIndent) {
310
+ // same level
311
+ } else if (m.postIndent !== -1 && m.postIndent > indent) {
312
+ s.penalty += anyBlanks ? RELATIVE_OUTDENT_WITH_BLANK_PENALTY : RELATIVE_OUTDENT_PENALTY
313
+ } else {
314
+ s.penalty += anyBlanks ? RELATIVE_DEDENT_WITH_BLANK_PENALTY : RELATIVE_DEDENT_PENALTY
315
+ }
316
+ }
317
+
318
+ function scoreCmp (s1, s2) {
319
+ const c = (s1.effectiveIndent > s2.effectiveIndent) - (s1.effectiveIndent < s2.effectiveIndent)
320
+ return INDENT_WEIGHT * c + (s1.penalty - s2.penalty)
321
+ }
322
+
323
+ // `changed` is offset by one: changed[i + 1] is record i; changed[0] and changed[nrec + 1] are 0.
324
+ function changeCompact (x, xo) {
325
+ const ch = x.changed; const cho = xo.changed
326
+ const C = (i) => ch[i + 1]
327
+ const CO = (i) => cho[i + 1]
328
+ const g = { start: 0, end: 0 }; const go = { start: 0, end: 0 }
329
+ while (C(g.end)) g.end++
330
+ while (CO(go.end)) go.end++
331
+ const next = (xx, c, gg) => {
332
+ if (gg.end === xx.nrec) return -1
333
+ gg.start = gg.end + 1
334
+ for (gg.end = gg.start; c(gg.end); gg.end++);
335
+ return 0
336
+ }
337
+ const previous = (c, gg) => {
338
+ if (gg.start === 0) return -1
339
+ gg.end = gg.start - 1
340
+ for (gg.start = gg.end; c(gg.start - 1); gg.start--);
341
+ return 0
342
+ }
343
+ const slideDown = () => {
344
+ if (g.end < x.nrec && x.mph[g.start] === x.mph[g.end]) {
345
+ ch[g.start++ + 1] = 0
346
+ ch[g.end++ + 1] = 1
347
+ while (C(g.end)) g.end++
348
+ return 0
349
+ }
350
+ return -1
351
+ }
352
+ const slideUp = () => {
353
+ if (g.start > 0 && x.mph[g.start - 1] === x.mph[g.end - 1]) {
354
+ ch[--g.start + 1] = 1
355
+ ch[--g.end + 1] = 0
356
+ while (C(g.start - 1)) g.start--
357
+ return 0
358
+ }
359
+ return -1
360
+ }
361
+ const bug = (s) => { throw new Error('xdiff: ' + s) }
362
+ for (;;) {
363
+ if (g.end !== g.start) {
364
+ let groupsize, earliestEnd, endMatchingOther
365
+ do {
366
+ groupsize = g.end - g.start
367
+ endMatchingOther = -1
368
+ while (!slideUp()) if (previous(CO, go)) bug('group sync broken sliding up')
369
+ earliestEnd = g.end
370
+ if (go.end > go.start) endMatchingOther = g.end
371
+ for (;;) {
372
+ if (slideDown()) break
373
+ if (next(xo, CO, go)) bug('group sync broken sliding down')
374
+ if (go.end > go.start) endMatchingOther = g.end
375
+ }
376
+ } while (groupsize !== g.end - g.start)
377
+ if (g.end === earliestEnd) {
378
+ // no shifting possible
379
+ } else if (endMatchingOther !== -1) {
380
+ while (go.end === go.start) {
381
+ if (slideUp()) bug('match disappeared')
382
+ if (previous(CO, go)) bug('group sync broken sliding to match')
383
+ }
384
+ } else {
385
+ let shift = earliestEnd
386
+ let bestShift = -1; let bestScore = null
387
+ if (g.end - groupsize - 1 > shift) shift = g.end - groupsize - 1
388
+ if (g.end - INDENT_HEURISTIC_MAX_SLIDING > shift) shift = g.end - INDENT_HEURISTIC_MAX_SLIDING
389
+ for (; shift <= g.end; shift++) {
390
+ const score = { effectiveIndent: 0, penalty: 0 }
391
+ scoreAddSplit(measureSplit(x, shift), score)
392
+ scoreAddSplit(measureSplit(x, shift - groupsize), score)
393
+ if (bestShift === -1 || scoreCmp(score, bestScore) <= 0) { bestScore = score; bestShift = shift }
394
+ }
395
+ while (g.end > bestShift) {
396
+ if (slideUp()) bug('best shift unreached')
397
+ if (previous(CO, go)) bug('group sync broken sliding to blank line')
398
+ }
399
+ }
400
+ }
401
+ if (next(x, C, g)) break
402
+ if (next(xo, CO, go)) bug('group sync broken moving to next group')
403
+ }
404
+ }
405
+
406
+ /**
407
+ * git's diff of two byte buffers: hunks [{ a: [start, len], b: [start, len] }] over their lines
408
+ * (a final line without \n counts as different from the same line with one). `trimTail`
409
+ * reproduces what git does for a zero-context diff (blame): drop the common tail in 1 KiB blocks.
410
+ */
411
+ export function xdiff (a, b, { trimTail = false } = {}) {
412
+ let sa = a.length; let sb = b.length
413
+ if (trimTail) { const t = commonTail(a, b); sa -= t; sb -= t }
414
+ const classes = new Map(); const counts = []
415
+ const x1 = prepare(a, sa, classes, counts, 0)
416
+ const x2 = prepare(b, sb, classes, counts, 1)
417
+ trimEnds(x1, x2)
418
+ cleanupRecords(counts, x1, x2)
419
+ doDiff(x1, x2)
420
+ changeCompact(x1, x2)
421
+ changeCompact(x2, x1)
422
+ // xdl_build_script, forwards.
423
+ const out = []
424
+ const c1 = x1.changed; const c2 = x2.changed
425
+ let i1 = 0; let i2 = 0
426
+ while (i1 < x1.nrec || i2 < x2.nrec) {
427
+ if (c1[i1 + 1] || c2[i2 + 1]) {
428
+ const s1 = i1; const s2 = i2
429
+ while (c1[i1 + 1]) i1++
430
+ while (c2[i2 + 1]) i2++
431
+ out.push({ a: [s1, i1 - s1], b: [s2, i2 - s2] })
432
+ } else { i1++; i2++ }
433
+ }
434
+ return out
435
+ }
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@heyquilt/seam",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "seam: Quilt Source's command. Git's verbs for source control that runs on the server, built for many agents and people at once.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "bin": {
8
+ "seam": "bin/seam.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "scripts": {
19
+ "build": "node scripts/build.mjs",
20
+ "prepack": "node scripts/build.mjs"
21
+ },
22
+ "homepage": "https://seam.heyquilt.com",
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.30.1",
25
+ "@noble/hashes": "^2.4.0",
26
+ "ignore": "^7.0.10",
27
+ "zod": "^4.6.5"
28
+ }
29
+ }