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.
- package/RELEASES.md +292 -17
- package/bin/quilt.js +72 -5
- package/desktop/main.js +16 -2
- package/package.json +3 -1
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +21 -10
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +39 -0
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +46 -12
- package/src/api/supabase-store.js +53 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +39 -9
- package/src/control.js +139 -13
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +25 -1
- package/src/mcp.js +93 -7
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +172 -8
- package/src/relay-outbound.js +1 -1
- package/src/relay-prune.js +4 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1767 -33
- package/src/session-follow.js +515 -0
- package/src/session-layout3.js +466 -0
- package/src/session-migrate.js +123 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +284 -0
- package/src/session.js +899 -46
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +507 -35
- package/src/source-live/live-square.js +17 -6
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -449
- package/src/quilts/client.js +0 -128
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -266
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -160
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-git.js +0 -291
- package/src/quilts/import.js +0 -134
- package/src/quilts/index.js +0 -72
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -486
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -195
- package/src/quilts/routes.js +0 -126
- package/src/quilts/service.js +0 -923
- package/src/quilts/setup.js +0 -34
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -683
- package/src/quilts/store-rpc.js +0 -79
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -375
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -119
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Quilt's side of Seam: what making a session a quilt would do, without doing it
|
|
2
|
+
// (Seam's plans/quilt-source-control/16, "How a session becomes a quilt", "Dry run and check").
|
|
3
|
+
// Pure: it reads a snapshot of the room (Room.quiltSnapshot in server.js) and returns the
|
|
4
|
+
// report the owner reads before the real migration. Seam (src/quilts/) never imports this.
|
|
5
|
+
//
|
|
6
|
+
// Snapshot:
|
|
7
|
+
// { room, owner, defaultKey, hasRemote,
|
|
8
|
+
// branches: [{ key, by, files: { path: { hash, bytes } }, history: [entry], merges: [{ id, state }] }],
|
|
9
|
+
// claims: [{ pattern, branch, by, byId, queue }], tasks: [{ id, pr }], commitRequests: n }
|
|
10
|
+
// Options:
|
|
11
|
+
// tip: { path: hash } the imported main's tree, when the room has a remote and its import exists.
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
|
|
14
|
+
export const fileHash = (text) => createHash('sha256').update(String(text)).digest('hex')
|
|
15
|
+
|
|
16
|
+
const sumBytes = (files) => Object.values(files || {}).reduce((n, f) => n + (Number(f && f.bytes) || 0), 0)
|
|
17
|
+
|
|
18
|
+
/** The paths whose content differs between a branch document's files and a tree, both `{ path: hash }`-like. */
|
|
19
|
+
export function differingPaths (files, tree) {
|
|
20
|
+
const out = []
|
|
21
|
+
for (const [p, f] of Object.entries(files || {})) if (tree[p] !== (f && f.hash)) out.push(p)
|
|
22
|
+
for (const p of Object.keys(tree || {})) if (!(p in (files || {}))) out.push(p)
|
|
23
|
+
return out.sort()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The dry run's report. `mode` is `with_history` for a room with a git remote (the import and
|
|
28
|
+
* its follow come first; `needs_import` until a `tip` is given), else `without_history` (the
|
|
29
|
+
* default branch document is sealed as patch 1).
|
|
30
|
+
*/
|
|
31
|
+
export function planSessionQuilt (snap, { tip = null } = {}) {
|
|
32
|
+
const withHistory = !!snap.hasRemote
|
|
33
|
+
const defaultKey = snap.defaultKey
|
|
34
|
+
const owner = snap.owner || ''
|
|
35
|
+
const branches = (snap.branches || []).filter((b) => b && typeof b.key === 'string')
|
|
36
|
+
const def = branches.find((b) => b.key === defaultKey) || null
|
|
37
|
+
const squares = []
|
|
38
|
+
const report = {
|
|
39
|
+
room: snap.room,
|
|
40
|
+
mode: withHistory ? 'with_history' : 'without_history',
|
|
41
|
+
needs_import: withHistory && !tip,
|
|
42
|
+
first_patch: null,
|
|
43
|
+
squares,
|
|
44
|
+
claims: { moved: 0, opened_for: [], without_square: [] },
|
|
45
|
+
tasks: { matched: [], unmatched: [] },
|
|
46
|
+
terms: 0,
|
|
47
|
+
history: { kept: 0, dropped: 0 },
|
|
48
|
+
commit_requests: Number(snap.commitRequests) || 0,
|
|
49
|
+
bytes: 0
|
|
50
|
+
}
|
|
51
|
+
if (def && !withHistory) {
|
|
52
|
+
report.first_patch = { from: def.key, message: `Session ${snap.room} as of <date>`, files: Object.keys(def.files || {}).length, bytes: sumBytes(def.files) }
|
|
53
|
+
}
|
|
54
|
+
if (def && withHistory && tip) {
|
|
55
|
+
const paths = differingPaths(def.files, tip)
|
|
56
|
+
if (paths.length) {
|
|
57
|
+
squares.push({ from: def.key, title: `Uncommitted work from ${snap.room}`, base: 'import:main', holder: owner, adoption: true, files: paths.length, bytes: paths.reduce((n, p) => n + (Number(def.files[p] && def.files[p].bytes) || 0), 0) })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const squareOf = new Map()
|
|
61
|
+
for (const b of branches) {
|
|
62
|
+
if (b.key === defaultKey) continue
|
|
63
|
+
const at = /^@([0-9a-f]{7,64})$/.exec(b.key)
|
|
64
|
+
const base = withHistory ? (at ? `commit:${at[1]}` : `import:${b.key}`) : 'patch:1'
|
|
65
|
+
const sq = { from: b.key, title: b.key, base, holder: b.by || owner, adoption: false, files: Object.keys(b.files || {}).length, bytes: sumBytes(b.files) }
|
|
66
|
+
squares.push(sq)
|
|
67
|
+
squareOf.set(b.key, sq)
|
|
68
|
+
}
|
|
69
|
+
const adoption = squares.find((s) => s.adoption) || null
|
|
70
|
+
const opened = new Set()
|
|
71
|
+
for (const c of snap.claims || []) {
|
|
72
|
+
const branch = c.branch || defaultKey
|
|
73
|
+
const holder = c.byId || c.by || ''
|
|
74
|
+
if (branch === defaultKey) {
|
|
75
|
+
if (adoption) { report.claims.moved++; continue }
|
|
76
|
+
if (!def) { report.claims.without_square.push({ pattern: c.pattern, branch, holder }); continue }
|
|
77
|
+
// No square holds the default branch's work: one is opened for the claim's holder.
|
|
78
|
+
if (!opened.has(holder)) { opened.add(holder); report.claims.opened_for.push(holder) }
|
|
79
|
+
report.claims.moved++
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (squareOf.has(branch)) report.claims.moved++
|
|
83
|
+
else report.claims.without_square.push({ pattern: c.pattern, branch, holder })
|
|
84
|
+
}
|
|
85
|
+
for (const t of snap.tasks || []) {
|
|
86
|
+
if (!t || !t.pr) continue
|
|
87
|
+
const b = t.pr.branch
|
|
88
|
+
if (b && squareOf.has(b)) report.tasks.matched.push({ id: t.id, square_from: b })
|
|
89
|
+
else report.tasks.unmatched.push({ id: t.id, pr: t.pr.number || null })
|
|
90
|
+
}
|
|
91
|
+
for (const b of branches) {
|
|
92
|
+
report.terms += (b.merges || []).filter((m) => m && (m.state === 'open' || m.state === 'editing')).length
|
|
93
|
+
for (const h of b.history || []) {
|
|
94
|
+
if (withHistory && h && (h.pulled || h.seed)) report.history.dropped++
|
|
95
|
+
else report.history.kept++
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
report.bytes = squares.reduce((n, s) => n + s.bytes, 0) + (report.first_patch ? report.first_patch.bytes : 0)
|
|
99
|
+
report.problems = [
|
|
100
|
+
...report.claims.without_square.map((c) => `claim on ${c.pattern} (${c.branch}) has no square`),
|
|
101
|
+
...report.tasks.unmatched.map((t) => `task ${t.id}'s pull request${t.pr ? ` #${t.pr}` : ''} has no square; it becomes a task comment`)
|
|
102
|
+
]
|
|
103
|
+
return report
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const MIGRATE_SESSION_DESCRIPTION =
|
|
107
|
+
'What making this session a quilt (Quilt\'s own source control, Seam) would do, read only: the first patch or the import, ' +
|
|
108
|
+
'which branches become squares, how claims and tasks with pull requests move, and anything that would not carry over. ' +
|
|
109
|
+
'Only the session\'s owner (or, in a session without one, anyone who may change files) can ask. Nothing changes.'
|
|
110
|
+
|
|
111
|
+
/** Why `a` (the asker's access) may not see the dry run, or null when they may. */
|
|
112
|
+
export function quiltPlanRefusal ({ controlled, access }) {
|
|
113
|
+
if (controlled ? !(access && access.owner) : !!(access && access.role === 'viewer')) return 'only the session\'s owner can see what making it a quilt would do'
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const kb = (n) => n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`
|
|
118
|
+
|
|
119
|
+
/** The dry run's report as plain text, for an agent or a person. */
|
|
120
|
+
export function describeQuiltPlan (p) {
|
|
121
|
+
const out = [`Making session ${p.room} a quilt (dry run, nothing changed):`]
|
|
122
|
+
if (p.mode === 'with_history') {
|
|
123
|
+
out.push(p.needs_import
|
|
124
|
+
? '- The session has a git remote: its history comes in by an import that follows the remote first (seam import), then the session moves.'
|
|
125
|
+
: '- History comes from the import of the session\'s git remote.')
|
|
126
|
+
} else if (p.first_patch) {
|
|
127
|
+
out.push(`- Patch 1 is the ${p.first_patch.from} branch as it is now: ${p.first_patch.files} file${p.first_patch.files === 1 ? '' : 's'}, ${kb(p.first_patch.bytes)}.`)
|
|
128
|
+
} else {
|
|
129
|
+
out.push('- The session has no default branch document: patch 1 starts empty.')
|
|
130
|
+
}
|
|
131
|
+
if (p.squares.length) {
|
|
132
|
+
out.push(`- ${p.squares.length} square${p.squares.length === 1 ? '' : 's'}:`)
|
|
133
|
+
for (const s of p.squares) out.push(` - ${s.title} from ${s.from}, on ${s.base}, held by ${s.holder || 'nobody'}: ${s.files} file${s.files === 1 ? '' : 's'}`)
|
|
134
|
+
} else out.push('- No squares: there is no other branch with work on it.')
|
|
135
|
+
out.push(`- Claims: ${p.claims.moved} move to their squares${p.claims.opened_for.length ? ` (a square is opened for ${p.claims.opened_for.join(', ')})` : ''}.`)
|
|
136
|
+
if (p.tasks.matched.length) out.push(`- ${p.tasks.matched.length} task${p.tasks.matched.length === 1 ? '' : 's'} with a pull request link${p.tasks.matched.length === 1 ? 's' : ''} to a square.`)
|
|
137
|
+
if (p.terms) out.push(`- ${p.terms} open merge${p.terms === 1 ? '' : 's'} become${p.terms === 1 ? 's' : ''} terms on their squares.`)
|
|
138
|
+
out.push(`- History: ${p.history.kept} entr${p.history.kept === 1 ? 'y' : 'ies'} kept${p.history.dropped ? `, ${p.history.dropped} dropped (they are in the import)` : ''}.`)
|
|
139
|
+
if (p.commit_requests) out.push(`- ${p.commit_requests} commit request${p.commit_requests === 1 ? '' : 's'} must be done or withdrawn first.`)
|
|
140
|
+
if (p.problems && p.problems.length) {
|
|
141
|
+
out.push('Would not carry over:')
|
|
142
|
+
for (const x of p.problems) out.push(`- ${x}`)
|
|
143
|
+
}
|
|
144
|
+
return out.join('\n')
|
|
145
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Quilt's side of Seam for agents (Seam's plans/quilt-source-control/12 and 16): in a session that is a
|
|
2
|
+
// quilt, an agent works through Seam (squares, commits, merges), never GitHub. The same for every
|
|
3
|
+
// AI tool: the session's own MCP server (`quilt mcp` in a folder, the relay's for hosted agents)
|
|
4
|
+
// serves Seam's everyday tools itself, acting on the session's quilt and the folder's square with
|
|
5
|
+
// the member's own Quilt sign-in (seam-access.js), so nothing is set up by hand. There, everything
|
|
6
|
+
// an AI is told names only Seam (Daniel, 2026-10-31): the git-era tools are hidden (GIT_TOOLS) or
|
|
7
|
+
// refuse with the Seam tool to use, the guidance leaves git out (seamOnly), a push that still
|
|
8
|
+
// reaches the hook is refused, and the relay gives no GitHub token to an agent's git there.
|
|
9
|
+
//
|
|
10
|
+
// A session is a quilt when the relay is on Seam (QUILT_SOURCE_URL) and its room names its quilt
|
|
11
|
+
// (meta.quilt_id, set by the migration with layout 3 and cleared when a failed check reverts it).
|
|
12
|
+
// Every other session keeps its GitHub path exactly as it is, and offers no Seam tools.
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
// Seam's tools, their descriptions and answers come from Seam's client package (@heyquilt/seam).
|
|
15
|
+
import { registerSeamTools, SEAM_INPUTS, SEAM_DESCRIPTIONS } from '@heyquilt/seam/lib/quilts/tools.js'
|
|
16
|
+
import { describeWrite } from '@heyquilt/seam/lib/quilts/describe.js'
|
|
17
|
+
import { squareShort } from '@heyquilt/seam/lib/quilts/errors.js'
|
|
18
|
+
|
|
19
|
+
/** The quilt a room's session is, or null while it is a GitHub-backed (or plain) session. */
|
|
20
|
+
export function quiltOf (meta, env = process.env) {
|
|
21
|
+
if (!env || !env.QUILT_SOURCE_URL) return null
|
|
22
|
+
if (!meta) return null
|
|
23
|
+
return typeof meta.quilt_id === 'string' && meta.quilt_id ? meta.quilt_id : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Seam's tools a session that is a quilt serves, the same for a folder's AI sessions and hosted
|
|
28
|
+
* agents: the everyday ones (Seam's 'core' profile) an agent in a session needs. A folder's
|
|
29
|
+
* square is the session's, so cloning, pulling, listing quilts and abandoning are left out.
|
|
30
|
+
*/
|
|
31
|
+
export const SEAM_SESSION_TOOLS = ['seam_square_status', 'seam_commit', 'seam_merge', 'seam_rebase', 'seam_resolve', 'seam_diff', 'seam_log', 'seam_show', 'seam_cat', 'seam_ls', 'seam_grep', 'seam_blame', 'seam_checks', 'seam_events', 'seam_reviews', 'seam_review', 'seam_interdiff', 'seam_squares', 'seam_open_square']
|
|
32
|
+
|
|
33
|
+
// Each GitHub tool, and the Seam tool an agent uses for it in a session that is a quilt.
|
|
34
|
+
export const SEAM_FOR = {
|
|
35
|
+
quilt_git_commit: 'seam_commit (commits your square)',
|
|
36
|
+
quilt_git_status: 'seam_square_status (your square and what changed in it)',
|
|
37
|
+
quilt_git_log: 'seam_log',
|
|
38
|
+
quilt_git_diff: 'seam_diff',
|
|
39
|
+
quilt_git_revert: 'seam_cat (the line\'s copy of a file, to write back)',
|
|
40
|
+
quilt_git_pull_requests: 'seam_squares (the squares on a line) and seam_checks',
|
|
41
|
+
quilt_git_merge: 'seam_merge (merges your square onto main once its checks pass)',
|
|
42
|
+
quilt_git_branch: 'seam_square_status (your folder\'s square)',
|
|
43
|
+
quilt_sync_branch: 'seam_square_status (a square follows its line: seam_rebase moves it onto the tip)',
|
|
44
|
+
quilt_sync_status: 'seam_square_status',
|
|
45
|
+
quilt_github_token: null, // nothing to use instead: the owner's export to GitHub has its own credential
|
|
46
|
+
quilt_request_commit: 'seam_commit, then seam_merge once its checks pass (or ask a person to)',
|
|
47
|
+
quilt_agent_commits: 'seam_log',
|
|
48
|
+
quilt_commit_status: 'seam_square_status',
|
|
49
|
+
quilt_commit_request_done: 'seam_square_status',
|
|
50
|
+
quilt_github_sync: 'seam_square_status'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** How work lands in a session that is a quilt, for an AI with a folder (its files are its square's live work). */
|
|
54
|
+
export const SEAM_GUIDE = 'Edit files in your folder as always: they are your square\'s live work, shared at once with everyone on it. ' +
|
|
55
|
+
'Commit the square with seam_commit (it commits everything the square holds now, every writer\'s, so say why in the message), and merge it onto main with seam_merge once its checks pass (seam_checks; seam_events with wait: 30 waits for a verdict). ' +
|
|
56
|
+
'A conflict comes back as terms in your square: read them with seam_square_status and settle each with seam_resolve (or edit the file and commit), then merge again. ' +
|
|
57
|
+
'seam_diff, seam_log, seam_show and seam_blame read the history. Work lands only through these tools.'
|
|
58
|
+
|
|
59
|
+
/** The same for a hosted agent, which has no folder. */
|
|
60
|
+
export const SEAM_GUIDE_HOSTED = 'You have no folder: open a square of your own with seam_open_square, read files with seam_cat and seam_ls (rev: main or your square), and commit their new contents with seam_commit (files, square: your square). ' +
|
|
61
|
+
'Merge it onto main with seam_merge once its checks pass (seam_checks; seam_events with wait: 30 waits for a verdict). ' +
|
|
62
|
+
'A conflict comes back as terms in your square: read them with seam_square_status and settle each with seam_resolve (or commit the file without markers), then merge again. ' +
|
|
63
|
+
'Work lands only through these tools.'
|
|
64
|
+
|
|
65
|
+
const guideFor = ({ hosted = false } = {}) => hosted ? SEAM_GUIDE_HOSTED : SEAM_GUIDE
|
|
66
|
+
|
|
67
|
+
// How an agent hears of Seam: only as where the session's code is committed and merged. Chat, tasks and
|
|
68
|
+
// the session itself stay in Quilt; nobody reaches a session through Seam.
|
|
69
|
+
const codeIn = (quiltId) => `This session's code is committed and merged in Seam (the quilt ${quiltId})`
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Why a git-era tool is refused in a quilt session: just the Seam tool to use (or, for one with no
|
|
73
|
+
* counterpart, how work lands there); null when it isn't. An AI whose tool list is from before the
|
|
74
|
+
* move may still call one that is hidden now.
|
|
75
|
+
*/
|
|
76
|
+
export function seamRefusal (tool, quiltId, { hosted = false } = {}) {
|
|
77
|
+
if (!quiltId) return null
|
|
78
|
+
if (Object.hasOwn(SEAM_FOR, tool)) return `${codeIn(quiltId)}: ${SEAM_FOR[tool] ? `use ${SEAM_FOR[tool]} instead.` : 'Seam\'s tools need nothing set up.'}`
|
|
79
|
+
return GIT_TOOLS.has(tool) ? `${codeIn(quiltId)}, where ${tool} is not used. ${guideFor({ hosted })}` : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The git-era tools a session that is a quilt does not offer (each of SEAM_FOR, and those about
|
|
84
|
+
* branches, boxes and merges with git): hidden from the tool list while it is one, so nothing an
|
|
85
|
+
* AI reads there names git or GitHub.
|
|
86
|
+
*/
|
|
87
|
+
export const GIT_TOOLS = new Set([...Object.keys(SEAM_FOR), 'quilt_git_status', 'quilt_git_log', 'quilt_git_diff', 'quilt_git_revert', 'quilt_git_pull_requests', 'quilt_git_merge', 'quilt_unpack', 'quilt_merges', 'quilt_resolve_merge', 'quilt_adopt_work', 'quilt_branches', 'quilt_clash', 'quilt_migrate_session'])
|
|
88
|
+
|
|
89
|
+
// A sentence of guidance about git: branches, boxes, pushes, pull requests or GitHub.
|
|
90
|
+
const GITTY = /\bgit\b|GitHub|pull request|\bpush(es|ed|ing)?\b|\bbox(es|ed)?\b|quilt_git_|quilt_unpack|quilt_branches|quilt_sync_|quilt_github|quilt_request_commit|quilt_commit_|quilt_agent_commits|quilt_merges|quilt_resolve_merge|quilt_adopt_work|quilt_clash|\bbranch(es)?\b/i
|
|
91
|
+
|
|
92
|
+
/** Guidance with every sentence about git left out, for a session that is a quilt. */
|
|
93
|
+
export function seamOnly (text) {
|
|
94
|
+
return String(text || '').split(/(?<=[.!?])\s+(?=[A-Z@"'(*0-9])/).filter((x) => !GITTY.test(x)).join(' ')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Status sections that are git's alone (status.js), and the lines elsewhere that speak of git.
|
|
98
|
+
const GIT_SECTION = /^## (Branches|Commit requests|Pulling|Boxed work|Waiting to go in)\b/
|
|
99
|
+
const GIT_LINE = /\bgit\b|GitHub|pull request/i
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A session's status (status.js markdown) for a session that is a quilt: git's sections left out,
|
|
103
|
+
* and lines about git elsewhere too; what people wrote (the messages and chat sections) stays as it is.
|
|
104
|
+
*/
|
|
105
|
+
export function seamStatus (md) {
|
|
106
|
+
const out = []
|
|
107
|
+
let skip = false
|
|
108
|
+
let said = false
|
|
109
|
+
for (const line of String(md || '').split('\n')) {
|
|
110
|
+
if (line.startsWith('## ')) { skip = GIT_SECTION.test(line); said = /messages|chat/i.test(line) }
|
|
111
|
+
if (skip) continue
|
|
112
|
+
if (!said && GIT_LINE.test(line)) continue
|
|
113
|
+
out.push(line)
|
|
114
|
+
}
|
|
115
|
+
return out.join('\n')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Seam's own description of a tool, without its comparisons to git commands. */
|
|
119
|
+
const seamWords = (d) => String(d || '').replace(/\s*\(git [^)]*\)/g, '').replace(/,? as git [^.,;]* does/g, '').replace(/, git:<sha>/g, '')
|
|
120
|
+
|
|
121
|
+
/** The task workflow (agent-task-workflow.js) for a session that is a quilt: its git steps left out, the Seam one in. */
|
|
122
|
+
export function seamWorkflow (workflow, { hosted = false } = {}) {
|
|
123
|
+
return `${seamOnly(workflow)} Before QA, commit the task's work with seam_commit${hosted ? ' in your square' : ''}, saying what the task is; it reaches main with seam_merge once its checks pass.`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A hosted agent's file tool at layout 3, where the session's files are in squares, not branches. */
|
|
127
|
+
export function seamFileRefusal (quiltId) {
|
|
128
|
+
return quiltId ? `${codeIn(quiltId)}: its files are in squares, which Seam's tools read and write. ${SEAM_GUIDE_HOSTED}` : null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What a push that still reaches the folder's hook hears in a session that is a quilt: the Seam way, nothing else. */
|
|
132
|
+
export function pushRefusal (quiltId) {
|
|
133
|
+
return quiltId ? `${codeIn(quiltId)}, so nothing is sent from here. ${SEAM_GUIDE}` : null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The line a session's status answers with, in a quilt session; '' otherwise. */
|
|
137
|
+
export function seamNote (quiltId, { hosted = false } = {}) {
|
|
138
|
+
return quiltId ? `${codeIn(quiltId)}; chat, tasks and the session stay in Quilt. ${guideFor({ hosted })}` : ''
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Why the relay gives no GitHub token to a member's git here; null when it may. People keep theirs. */
|
|
142
|
+
export function gitTokenRefusal (quiltId, kind) {
|
|
143
|
+
if (!quiltId || kind !== 'agent') return null
|
|
144
|
+
return `this session's code is committed and merged in Seam (the quilt ${quiltId}): agents use seam_commit and seam_merge`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A folder's MCP server (src/mcp.js), in a session that is a quilt: wraps `registerTool` so each
|
|
149
|
+
* GitHub tool refuses with the Seam tool to use. `quiltNow()` is the session's link as the relay
|
|
150
|
+
* last said ({ id, slug } or null), asked at call time, so a session that becomes a quilt (or
|
|
151
|
+
* stops being one) changes the answer without a restart. Every other tool is untouched.
|
|
152
|
+
*/
|
|
153
|
+
export function seamGate (registerTool, quiltNow) {
|
|
154
|
+
return (name, spec, handler) => registerTool(name, spec, GIT_TOOLS.has(name)
|
|
155
|
+
? async (...args) => {
|
|
156
|
+
const q = await quiltNow()
|
|
157
|
+
const no = q && seamRefusal(name, q.slug || q.id)
|
|
158
|
+
return no ? { content: [{ type: 'text', text: no }], isError: true } : handler(...args)
|
|
159
|
+
}
|
|
160
|
+
: handler)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// What the session adds to Seam's own description of a tool.
|
|
164
|
+
const IN_SESSION = {
|
|
165
|
+
seam_commit: 'In this Quilt session, with no files: commits your folder\'s square (everything its live work holds now). ',
|
|
166
|
+
seam_merge: 'In this Quilt session: merges your folder\'s square onto main (committing its live work first); the folder carries on in a new square. ',
|
|
167
|
+
seam_square_status: 'In this Quilt session: your folder\'s square, with its live work not committed yet. ',
|
|
168
|
+
seam_open_square: 'In this Quilt session your folder works in a square of its own already (seam_square_status names it): edit there and seam_commit. '
|
|
169
|
+
}
|
|
170
|
+
const IN_SESSION_HOSTED = { seam_commit: 'You have no folder: give the files\' new contents (files) and your square. ' }
|
|
171
|
+
|
|
172
|
+
/** An MCP tool spec for one of SEAM_SESSION_TOOLS: Seam's description and input, quilt and square optional (the session's). */
|
|
173
|
+
export function seamToolSpec (name, { hosted = false } = {}) {
|
|
174
|
+
const lead = (hosted ? IN_SESSION_HOSTED[name] : IN_SESSION[name]) || ''
|
|
175
|
+
const shape = Object.fromEntries(Object.entries(SEAM_INPUTS[name]).map(([k, v]) => [k, v && v.description && seamWords(v.description) !== v.description ? v.describe(seamWords(v.description)) : v]))
|
|
176
|
+
return { description: lead + seamWords(SEAM_DESCRIPTIONS[name]), inputSchema: z.looseObject(shape) }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const SQUARE_INPUT = SEAM_INPUTS.seam_square_status.square
|
|
180
|
+
// The tools whose answer can move the folder's square on Seam (its live document follows).
|
|
181
|
+
const MOVES_HEAD = new Set(['seam_rebase', 'seam_resolve', 'seam_merge', 'seam_commit'])
|
|
182
|
+
|
|
183
|
+
/** Seam's own handler for one tool (registerSeamTools on a stand-in server), with `client` for this call. */
|
|
184
|
+
function seamHandler (name, client, hosted) {
|
|
185
|
+
let handler = null
|
|
186
|
+
registerSeamTools({ registerTool: (n, spec, fn) => { handler = fn } }, { client: () => client, tools: [name], hosted })
|
|
187
|
+
return handler
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const textOf = (r) => ((r && r.content) || []).map((c) => c.text || '').join('\n')
|
|
191
|
+
const answer = (text, extra = {}) => ({ content: [{ type: 'text', text }], ...extra })
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Runs one of SEAM_SESSION_TOOLS for a member of a session that is a quilt: Seam's own handler,
|
|
195
|
+
* with the session's quilt (and the folder's square) filled in where the call names none.
|
|
196
|
+
* `live` is the folder's live block on that square (source-live/live-block.js), or null: with it,
|
|
197
|
+
* seam_commit commits the live work the relay holds, seam_merge commits it first, the status says
|
|
198
|
+
* what is uncommitted, and a square whose head Seam moved has its live document follow.
|
|
199
|
+
* `next()` is asked after the folder's square merged, to move the branch to its next square.
|
|
200
|
+
* Returns an MCP tool result.
|
|
201
|
+
*/
|
|
202
|
+
export async function runSessionSeamTool (name, args = {}, { client, quilt, square = null, live = null, hosted = false, next = null, branch = '' }) {
|
|
203
|
+
if (!SEAM_SESSION_TOOLS.includes(name)) return answer(`${name} is not one of this session's Seam tools (${SEAM_SESSION_TOOLS.join(', ')}).`, { isError: true })
|
|
204
|
+
const shape = SEAM_INPUTS[name]
|
|
205
|
+
const a = { ...(args || {}) }
|
|
206
|
+
if (Object.hasOwn(shape, 'quilt') && !a.quilt) a.quilt = quilt
|
|
207
|
+
// A call that names a square other than the folder's acts on that one, on Seam alone.
|
|
208
|
+
const ref = String(a.square || '').replace(/^sq:/, '')
|
|
209
|
+
const mine = !ref || !!(square && ref.length >= 4 && String(square).startsWith(ref))
|
|
210
|
+
if (square && shape.square === SQUARE_INPUT && !a.square) a.square = square
|
|
211
|
+
if (square && name === 'seam_log' && !a.rev) a.rev = `sq:${square}`
|
|
212
|
+
if (square && name === 'seam_diff' && !a.from && !a.to) { a.from = 'main'; a.to = `sq:${square}` }
|
|
213
|
+
const onFolder = !!(live && square && mine && a.quilt === quilt)
|
|
214
|
+
const run = (x) => seamHandler(name, client, hosted)(x)
|
|
215
|
+
// A square opened beside the folder's is in no folder: its work could only go in as file contents
|
|
216
|
+
// (Seam's agent trial 2026-10-31, finding 10). The folder's square is where work goes.
|
|
217
|
+
if (live && square && name === 'seam_open_square') return answer(`This folder is on square ${squareShort(square)} already, and work goes there: edit the files in your folder and commit them with seam_commit, then seam_merge merges the square onto main (and the folder carries on in a new one).`, { isError: true })
|
|
218
|
+
try {
|
|
219
|
+
if (onFolder && name === 'seam_commit' && !a.files && !a.delete && !a.amend) {
|
|
220
|
+
if (!a.message) return answer('Say why: message.')
|
|
221
|
+
const r = await live.commit({ message: a.message })
|
|
222
|
+
return r && r.nothing ? answer('Nothing to commit: the square holds no live work since its last commit.') : answer(describeWrite(r, 'Committed'), { structuredContent: r })
|
|
223
|
+
}
|
|
224
|
+
if (onFolder && name === 'seam_merge' && !a.from && !a.dry_run) {
|
|
225
|
+
// The relay holds the square's live work: it is committed first, so the merge has all of it.
|
|
226
|
+
const c = await live.commit({ message: a.message || 'Work in progress' })
|
|
227
|
+
if (!a.message) { const t = await patchTitle(client, a.quilt, a.square, branch).catch(() => null); if (t) a.message = t }
|
|
228
|
+
const r = await run({ ...a, commit_live: false })
|
|
229
|
+
const rec = r.structuredContent || {}
|
|
230
|
+
const sealed = c && !c.nothing ? `Committed the square's live work first: ${describeWrite(c, 'committed')}\n` : ''
|
|
231
|
+
if (rec.ok && rec.merged && next) {
|
|
232
|
+
let moved = ''
|
|
233
|
+
try { const n = await next(); if (n && n.moved) moved = `\nThe folder carries on in a new square, ${squareShort(n.square)}, on main's new tip: keep editing as before.` } catch (err) { moved = `\nThe folder's branch did not move to a new square yet (${err.message}): ask again with seam_square_status.` }
|
|
234
|
+
return { ...r, content: [{ type: 'text', text: sealed + textOf(r) + moved }] }
|
|
235
|
+
}
|
|
236
|
+
if (rec.ok === false) await followHead(live, rec)
|
|
237
|
+
return { ...r, content: [{ type: 'text', text: sealed + textOf(r) + inChat(r) }] }
|
|
238
|
+
}
|
|
239
|
+
if (name === 'seam_merge' && !a.message && !a.from && !a.dry_run && mine) { const t = await patchTitle(client, a.quilt, a.square, branch).catch(() => null); if (t) a.message = t }
|
|
240
|
+
const r = await run(a)
|
|
241
|
+
if (name === 'seam_merge') return { ...r, content: [{ type: 'text', text: textOf(r) + inChat(r) }] }
|
|
242
|
+
if (onFolder && name === 'seam_square_status') {
|
|
243
|
+
const work = live.status ? live.status() : []
|
|
244
|
+
const lines = work.length ? `Live work not committed yet (seam_commit commits it):\n${work.map((w) => ` ${w.status} ${w.path}`).join('\n')}` : 'No live work since the last commit.'
|
|
245
|
+
return { ...r, content: [{ type: 'text', text: `${textOf(r)}\n${lines}` }] }
|
|
246
|
+
}
|
|
247
|
+
if (onFolder && MOVES_HEAD.has(name) && !r.isError) await followHead(live, r.structuredContent || {})
|
|
248
|
+
return r
|
|
249
|
+
} catch (err) {
|
|
250
|
+
return answer(err.message, { isError: true })
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Seam tells an agent whose merge waits for an approval to ask for a review (Seam's agent trial
|
|
255
|
+
// 2026-10-31, finding 5), which reaches people only in Seam: in a Quilt session they hear it in chat.
|
|
256
|
+
const ASKS_REVIEW = /waits for someone else[\s\S]*seam_review with kind request/
|
|
257
|
+
const inChat = (r) => ASKS_REVIEW.test(textOf(r)) ? '\nIn this Quilt session, also ask them in chat (quilt_message, naming the square): Seam\'s review request is not shown in Quilt, and they approve with seam_review (kind approve) from their own AI session. Then wait for their answer and merge again.' : ''
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The title a merge gives its patch when the call names none: Seam takes the square's title, and a square
|
|
261
|
+
* the move into Seam opened for a branch is titled with the branch's name, so every patch from main was
|
|
262
|
+
* called "main". Then the square's own commits name it: its one message, or "N commits: first; second; …".
|
|
263
|
+
* Null (Seam's title stands) for a square named otherwise, or with no message of its own.
|
|
264
|
+
*/
|
|
265
|
+
export async function patchTitle (client, quilt, square, branch) {
|
|
266
|
+
if (!branch || !square || !client || !client.square || !client.log) return null
|
|
267
|
+
const s = await client.square(quilt, square)
|
|
268
|
+
if (!s || String(s.title || '').trim() !== String(branch).trim()) return null
|
|
269
|
+
const log = await client.log(quilt, { rev: `sq:${s.id || square}`, limit: 50 })
|
|
270
|
+
const own = ((log && log.commits) || []).filter((c) => c && c.square_id === (s.id || square) && c.message)
|
|
271
|
+
.map((c) => String(c.message).split('\n')[0].trim()).filter((m) => m && m !== 'Work in progress').reverse()
|
|
272
|
+
if (!own.length) return null
|
|
273
|
+
if (own.length === 1) return own[0].slice(0, 200)
|
|
274
|
+
const all = `${own.length} commits: ${own.join('; ')}`
|
|
275
|
+
return all.length > 200 ? `${all.slice(0, 199)}…` : all
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The square's head after a Seam answer that moved it, and the live document following it. */
|
|
279
|
+
async function followHead (live, rec) {
|
|
280
|
+
const own = (rec.square && rec.square.head) || rec.head || null
|
|
281
|
+
// A square with no commit of its own (a rebase moved its base only): its live work is relative to its base.
|
|
282
|
+
const base = !own && rec.square && rec.square.base ? rec.square.base : null
|
|
283
|
+
if ((own || base) && live.moveTo) await live.moveTo(own || base, { base: !!base }).catch(() => {})
|
|
284
|
+
}
|