heyquilt 0.3.122 ā 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 +273 -17
- package/bin/quilt.js +55 -9
- package/desktop/main.js +16 -2
- package/package.json +3 -2
- 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 +16 -9
- 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/session-migrate.js +7 -4
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +13 -7
- package/src/api/supabase-store.js +43 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +36 -9
- package/src/control.js +129 -15
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +13 -3
- package/src/mcp.js +87 -12
- 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 +151 -17
- package/src/relay-outbound.js +1 -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 +1517 -86
- package/src/session-follow.js +461 -27
- package/src/session-layout3.js +410 -4
- package/src/session-migrate.js +15 -11
- package/src/session-quilt.js +1 -1
- package/src/session-seam.js +233 -20
- package/src/session.js +848 -67
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +504 -33
- package/src/source-live/live-square.js +16 -5
- 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/accounts.js +0 -136
- package/src/quilts/affected.js +0 -132
- 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/checks.js +0 -308
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -536
- package/src/quilts/client.js +0 -137
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -296
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -171
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export-remote.js +0 -156
- 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-follow.js +0 -236
- package/src/quilts/import-git.js +0 -294
- package/src/quilts/import.js +0 -196
- package/src/quilts/index.js +0 -94
- package/src/quilts/keys.js +0 -186
- 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 -501
- package/src/quilts/oauth.js +0 -202
- 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 -208
- package/src/quilts/routes.js +0 -186
- package/src/quilts/runner.js +0 -95
- package/src/quilts/server.js +0 -144
- package/src/quilts/service.js +0 -963
- package/src/quilts/setup.js +0 -41
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -844
- package/src/quilts/store-rpc.js +0 -90
- 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 -403
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -121
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
package/src/mcp.js
CHANGED
|
@@ -46,7 +46,7 @@ import { CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
|
|
|
46
46
|
import { describeSecretNames, SECRET_NAMES_DESCRIPTION, RUN_GUIDE } from './secret-names.js'
|
|
47
47
|
import { appWorkspaces } from './app-workspaces.js'
|
|
48
48
|
import { TRANSCRIPT_DESCRIPTION, renderLines } from './voice.js'
|
|
49
|
-
import { seamGate, seamNote } from './session-seam.js'
|
|
49
|
+
import { seamGate, seamNote, seamStatus, seamToolSpec, seamOnly, seamWorkflow, seamRefusal, SEAM_SESSION_TOOLS, GIT_TOOLS } from './session-seam.js'
|
|
50
50
|
|
|
51
51
|
// The board's columns, from tasks.js, so this and the hosted MCP (relay-mcp.js) never drift apart.
|
|
52
52
|
const COLUMN_IDS_LIST = COLUMNS.map((c) => c.id)
|
|
@@ -119,11 +119,25 @@ export const MCP_INSTRUCTIONS =
|
|
|
119
119
|
MAIL_GUIDE + ' ' +
|
|
120
120
|
TASK_WORKFLOW
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The instructions for a folder in a session that is a quilt (session-seam.js): every sentence
|
|
124
|
+
* about git left out, and how work lands on Seam in its place. Nothing there names git or GitHub.
|
|
125
|
+
*/
|
|
126
|
+
export const mcpQuiltInstructions = (quilt) => `${seamOnly(MCP_INSTRUCTIONS)} ${seamNote(quilt)}`
|
|
127
|
+
|
|
128
|
+
/** The session this folder is in, as its app says, when it answers at once: whether it is a quilt picks the instructions. */
|
|
129
|
+
async function quiltAtStart () {
|
|
130
|
+
const d = findDaemon()
|
|
131
|
+
if (!d) return null
|
|
132
|
+
try { return (await rawCall(d, 'GET', '/quilt', undefined, { timeoutMs: 1500 })).quilt || null } catch { return null }
|
|
133
|
+
}
|
|
134
|
+
|
|
122
135
|
export async function runMcp () {
|
|
136
|
+
const startQuilt = await quiltAtStart()
|
|
123
137
|
const server = new McpServer(
|
|
124
138
|
{ name: 'quilt', version: '0.1.0' },
|
|
125
139
|
// The channel capability lets Claude Code (started with the quilt channel) take inbox events as turns.
|
|
126
|
-
{ instructions: MCP_INSTRUCTIONS, capabilities: { resources: { subscribe: true }, experimental: { 'claude/channel': {} } } }
|
|
140
|
+
{ instructions: startQuilt && startQuilt.id ? mcpQuiltInstructions(startQuilt.slug || startQuilt.id) : MCP_INSTRUCTIONS, capabilities: { resources: { subscribe: true }, experimental: { 'claude/channel': {} } } }
|
|
127
141
|
)
|
|
128
142
|
|
|
129
143
|
// This AI session, by an id of its own, so the app can tell it from the others working through
|
|
@@ -200,10 +214,19 @@ export async function runMcp () {
|
|
|
200
214
|
// When this person lets their AI pick up work by itself: the next task, said as it finishes one.
|
|
201
215
|
const nextUp = async (d) => { try { const n = renderNextTask((await call(d, 'GET', '/duties')).next); return n ? `\n\nā”ļø ${n}` : '' } catch { return '' } }
|
|
202
216
|
const gates = (...list) => async (d) => { for (const g of list) { const r = g && await g(d); if (r) return r } return '' }
|
|
217
|
+
// An AI session started before its session moved into Seam (or back to git) was told the other
|
|
218
|
+
// way in its instructions, which never change once given: the next answer says the way now, once.
|
|
219
|
+
let toldQuilt = !!(startQuilt && startQuilt.id)
|
|
220
|
+
const moved = async () => {
|
|
221
|
+
const q = await quiltNow()
|
|
222
|
+
if (q === undefined || !!q === toldQuilt) return ''
|
|
223
|
+
toldQuilt = !!q
|
|
224
|
+
return q ? `š§µ Since you started, ${seamNote(q.slug || q.id).replace(/^This/, 'this')} Leave aside what your instructions said about how work lands.\n\n` : 'š§µ Since you started, this session went back to its git branches: quilt_status says how work lands now.\n\n'
|
|
225
|
+
}
|
|
203
226
|
const withDaemon = async (fn, { inbox = true, gate = null } = {}) => {
|
|
204
227
|
const d = findDaemon(joined ? joined.dir : undefined)
|
|
205
228
|
if (!d) return { content: [{ type: 'text', text: NOT_RUNNING + stale() }], isError: true }
|
|
206
|
-
const before = async () => introduce() + (await notices(d)) + (inbox ? await arrivals(d) : '')
|
|
229
|
+
const before = async () => introduce() + (await moved()) + (await notices(d)) + (inbox ? await arrivals(d) : '')
|
|
207
230
|
const waiting = async () => { const n = renderQueueNotice(await queuedNow(d)); return n ? `\n\nš„ ${n}` : '' }
|
|
208
231
|
try {
|
|
209
232
|
const refused = gate ? await gate(d) : ''
|
|
@@ -222,14 +245,42 @@ export async function runMcp () {
|
|
|
222
245
|
return `${label ? `Assigned to ${label}.` : 'Unassigned.'}${files}`
|
|
223
246
|
}
|
|
224
247
|
|
|
225
|
-
// Seam (plans/quilt-source-control/12, 16): in a session that is a quilt, the GitHub tools
|
|
248
|
+
// Seam (Seam's plans/quilt-source-control/12, 16): in a session that is a quilt, the GitHub tools
|
|
226
249
|
// refuse with the Seam tool to use instead, the same for every AI tool (session-seam.js).
|
|
250
|
+
// undefined when the app can't say (none, or no answer): unknown, not "not a quilt".
|
|
227
251
|
const quiltNow = async () => {
|
|
228
252
|
const d = findDaemon(joined ? joined.dir : undefined)
|
|
229
|
-
if (!d) return
|
|
230
|
-
|
|
253
|
+
if (!d) return undefined
|
|
254
|
+
let q = null
|
|
255
|
+
try { q = (await call(d, 'GET', '/quilt')).quilt || null } catch { return undefined }
|
|
256
|
+
syncSeamTools(q)
|
|
257
|
+
return q
|
|
258
|
+
}
|
|
259
|
+
// The git-era tools are hidden while the session is a quilt (GIT_TOOLS), and refuse with the Seam tool if called.
|
|
260
|
+
const gitTools = new Map()
|
|
261
|
+
const gated = seamGate(server.registerTool.bind(server), quiltNow)
|
|
262
|
+
server.registerTool = (name, spec, handler) => {
|
|
263
|
+
const t = gated(name, spec, handler)
|
|
264
|
+
if (GIT_TOOLS.has(name)) gitTools.set(name, t)
|
|
265
|
+
return t
|
|
266
|
+
}
|
|
267
|
+
// Seam's everyday tools, served by this server itself while the session is a quilt (none
|
|
268
|
+
// otherwise): the app runs them as this folder's member, on its square (control.js /seam).
|
|
269
|
+
// The client is told when they come and go, as with the workspace library's tools.
|
|
270
|
+
const seamTools = new Map()
|
|
271
|
+
const seamCall = (name, args) => withDaemon(async (d) => {
|
|
272
|
+
await quiltNow() // the session may have stopped being a quilt: the tool list follows
|
|
273
|
+
const r = await call(d, 'POST', '/seam', { tool: name, args: args || {} })
|
|
274
|
+
const text = ((r && r.content) || []).map((c) => c.text || '').join('\n')
|
|
275
|
+
if (r && r.isError) throw new Error(text)
|
|
276
|
+
return text
|
|
277
|
+
}, { gate: name === 'seam_commit' || name === 'seam_merge' ? gateFor(name) : null })
|
|
278
|
+
const syncSeamTools = (q) => {
|
|
279
|
+
const on = !!(q && q.id)
|
|
280
|
+
if (on && !seamTools.size) for (const name of SEAM_SESSION_TOOLS) seamTools.set(name, server.registerTool(name, seamToolSpec(name), (args) => seamCall(name, args)))
|
|
281
|
+
for (const t of seamTools.values()) if (t.enabled !== on) { if (on) t.enable(); else t.disable() }
|
|
282
|
+
for (const t of gitTools.values()) if (t.enabled === on) { if (on) t.disable(); else t.enable() }
|
|
231
283
|
}
|
|
232
|
-
server.registerTool = seamGate(server.registerTool.bind(server), quiltNow)
|
|
233
284
|
|
|
234
285
|
server.registerTool('quilt_status', {
|
|
235
286
|
description: 'See who else is in the live session, what they are working on, which files they recently edited or claimed, recent messages, and which open tasks are assigned to you. Call this before starting work.',
|
|
@@ -237,7 +288,8 @@ export async function runMcp () {
|
|
|
237
288
|
}, () => withDaemon(async (d) => {
|
|
238
289
|
const st = await call(d, 'GET', '/status')
|
|
239
290
|
const q = await quiltNow()
|
|
240
|
-
|
|
291
|
+
const md = renderStatus(st, { asAi: taskReader(st).asAi, mentionYours: true })
|
|
292
|
+
return q ? `${seamNote(q.slug || q.id)}\n\n${seamStatus(md)}` : md
|
|
241
293
|
}))
|
|
242
294
|
|
|
243
295
|
server.registerTool('quilt_tasks', {
|
|
@@ -292,8 +344,11 @@ export async function runMcp () {
|
|
|
292
344
|
if (column === 'qa') patch.qaNotes = qaNotes
|
|
293
345
|
if (column === 'done') patch.verified = verified
|
|
294
346
|
const { task, merged, branch } = await call(d, 'POST', '/tasks/update', patch)
|
|
347
|
+
const q = column === 'doing' || column === 'qa' ? await quiltNow() : null
|
|
348
|
+
if (q && column === 'doing') return pickupBrief({ ...brief, task: { ...task, comments: brief.task.comments }, workflow: seamWorkflow(TASK_WORKFLOW) })
|
|
295
349
|
if (column === 'doing') return pickupBrief({ ...brief, task: { ...task, comments: brief.task.comments } }) + (branch ? `\nYour folder is on ${branch} now (Quilt made it for this task): commit only the files you change and push there; the pull request opens by itself. Move the task to QA when it is done and tested.` : '')
|
|
296
|
-
|
|
350
|
+
// In a quilt a pull request from before the switch is git's: the task's work lands with seam_merge.
|
|
351
|
+
if (column === 'qa') return `Moved "${task.title}" to QA. Notes: ${qaNotesLine(task)}` + (task.pr && !q ? ` Pull request #${task.pr.number} waits for a person to move the task to Done, which merges it.` : '') + await nextUp(d)
|
|
297
352
|
if (column === 'done') return `Moved "${task.title}" to Done.${merged ? ` Merged pull request #${merged.number} (${merged.head} ā ${merged.base}) as ${String(merged.sha).slice(0, 7)}.` : ''} Verified: ${verifiedLine(task)}` + await nextUp(d)
|
|
298
353
|
return `Moved "${task.title}" to ${columnName(task.column)}.`
|
|
299
354
|
}, { gate: gates(gateFor('quilt_move_task'), column === 'done' && queueGate('move the task again')) }))
|
|
@@ -730,6 +785,8 @@ export async function runMcp () {
|
|
|
730
785
|
readerOptions: { chatDir: process.cwd() },
|
|
731
786
|
onLog: (line) => { logs.push(line); if (logs.length > 50) logs.shift() },
|
|
732
787
|
onFatal: async (err) => { logs.push(`stopped: ${err.message}`); await leave() },
|
|
788
|
+
// A session this agent starts is a quilt from the start when the API says so (src/quilt-default.js).
|
|
789
|
+
newSession: starting && !inviteServer,
|
|
733
790
|
workspace
|
|
734
791
|
})
|
|
735
792
|
joined = { run, dir, invite: run.invite }
|
|
@@ -1052,8 +1109,8 @@ export async function runMcp () {
|
|
|
1052
1109
|
limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching changes (default 30)')
|
|
1053
1110
|
}
|
|
1054
1111
|
}, ({ path: p, by, since, task, with_diff, limit }) => withDaemon(async (d) => {
|
|
1055
|
-
const { entries } = await call(d, 'POST', '/history', { path: p, by, since, task, limit: limit || 30 })
|
|
1056
|
-
return formatHistory(entries, { withDiff: !!with_diff })
|
|
1112
|
+
const { entries, note } = await call(d, 'POST', '/history', { path: p, by, since, task, limit: limit || 30, with_diff: !!with_diff })
|
|
1113
|
+
return `${formatHistory(entries, { withDiff: !!with_diff })}${note ? `\n\n${note}` : ''}`
|
|
1057
1114
|
}))
|
|
1058
1115
|
|
|
1059
1116
|
server.registerTool('quilt_partner_feed', {
|
|
@@ -1122,12 +1179,30 @@ export async function runMcp () {
|
|
|
1122
1179
|
if (d && client && client.name) call(d, 'POST', '/agent', { client: client.name }).catch(() => {}) // says hello too
|
|
1123
1180
|
}
|
|
1124
1181
|
|
|
1125
|
-
|
|
1182
|
+
const transport = new StdioServerTransport()
|
|
1183
|
+
await server.connect(transport)
|
|
1184
|
+
// A git-era tool hidden while the session is a quilt, called from a tool list read before the
|
|
1185
|
+
// move (a client that missed the list's change): answered with the Seam way, not "disabled".
|
|
1186
|
+
const onmessage = transport.onmessage
|
|
1187
|
+
transport.onmessage = (msg, extra) => {
|
|
1188
|
+
const name = msg && msg.method === 'tools/call' && msg.params && msg.params.name
|
|
1189
|
+
const t = name && gitTools.get(name)
|
|
1190
|
+
if (!t || t.enabled) return onmessage(msg, extra)
|
|
1191
|
+
quiltNow().then((q) => {
|
|
1192
|
+
const no = q && seamRefusal(name, q.slug || q.id)
|
|
1193
|
+
if (!no) return onmessage(msg, extra)
|
|
1194
|
+
return transport.send({ jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: no }], isError: true } })
|
|
1195
|
+
}).catch(() => onmessage(msg, extra))
|
|
1196
|
+
}
|
|
1126
1197
|
// The workspace library tools arrive a moment later (the client is told the tool list
|
|
1127
1198
|
// changed), so startup never waits on the API.
|
|
1128
1199
|
// fromPath reads only from the project: the session's folder, or where the agent runs.
|
|
1129
1200
|
const projectDirs = () => [joined?.dir, findDaemon(joined ? joined.dir : undefined)?.dir, process.env.QUILT_DIR || process.cwd()].filter(Boolean)
|
|
1130
1201
|
addWorkspaceTools(server, { projectDirs }).catch(() => {})
|
|
1202
|
+
// In a session that is a quilt, Seam's tools come the same way; looked at again every half minute.
|
|
1203
|
+
quiltNow().catch(() => {})
|
|
1204
|
+
const seamTimer = setInterval(() => quiltNow().catch(() => {}), 30_000)
|
|
1205
|
+
seamTimer.unref()
|
|
1131
1206
|
}
|
|
1132
1207
|
|
|
1133
1208
|
/**
|
package/src/merge3.js
CHANGED
|
@@ -79,7 +79,8 @@ export function hasMarkers (text) {
|
|
|
79
79
|
/**
|
|
80
80
|
* merge3, plus the clashes where neither side lost anything, resolved the way a person would:
|
|
81
81
|
* - both sides only added lines in the same place (base had nothing there): ours, then theirs,
|
|
82
|
-
* with lines both start (or end) with written once
|
|
82
|
+
* with lines both start (or end) with written once (not a line of only brackets and
|
|
83
|
+
* punctuation such as `],`: each block keeps its own), so two new sections under the same
|
|
83
84
|
* heading become one heading with both sections' lines;
|
|
84
85
|
* - both sides changed the same one-line `import { ⦠} from 'ā¦'` of the same module: one import
|
|
85
86
|
* with every name either side has (a name either side removed stays removed).
|
|
@@ -100,12 +101,19 @@ export const mergeAdditive = eolAware(function mergeAdditive (base, ours, theirs
|
|
|
100
101
|
return { text: out.join('\n'), conflicts }
|
|
101
102
|
})
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Whether a line both added blocks start or end with can be written once: one with words in it, or a
|
|
106
|
+
* blank one. A line of only brackets and punctuation (`],`, `}`, `)`) closes each block, so writing
|
|
107
|
+
* it once drops one block's closer (two new entries in ci/policy.json came out as invalid JSON).
|
|
108
|
+
*/
|
|
109
|
+
const sharable = (line) => /\w/.test(line) || !line.trim()
|
|
110
|
+
|
|
103
111
|
/** Two blocks inserted at one place, as one: shared leading and trailing lines once, the middles in order. */
|
|
104
112
|
function addedBoth (a, b) {
|
|
105
113
|
let pre = 0
|
|
106
|
-
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
|
|
114
|
+
while (pre < a.length && pre < b.length && a[pre] === b[pre] && sharable(a[pre])) pre++
|
|
107
115
|
let post = 0
|
|
108
|
-
while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post]) post++
|
|
116
|
+
while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post] && sharable(a[a.length - 1 - post])) post++
|
|
109
117
|
return [...a.slice(0, pre), ...a.slice(pre, a.length - post), ...b.slice(pre, b.length - post), ...a.slice(a.length - post)]
|
|
110
118
|
}
|
|
111
119
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The website dashboard's summary of now (plans/dashboard ticket 9): for one person, what is going on in
|
|
2
|
+
// a session this relay holds. Who is in it and what each AI is doing, the board in brief, the latest
|
|
3
|
+
// file changes, held files and who waits for them, and what needs this person: messages to them not
|
|
4
|
+
// answered, tasks for them, files someone waits for from them, and, for the owner, commit requests and
|
|
5
|
+
// people asking to join. The accounts API asks for it (GET /v1/me/now, every 15 to 30 seconds from the
|
|
6
|
+
// page) and merges what each relay machine says; nothing here is streamed or stored.
|
|
7
|
+
import { waitingOn } from './duties.js'
|
|
8
|
+
import { readTasks, activeTasks } from './tasks.js'
|
|
9
|
+
|
|
10
|
+
export const NOW_LIMITS = { people: 24, recent: 8, detailRecent: 80, held: 24, needs: 24, text: 160 }
|
|
11
|
+
const EDIT_FRESH_MS = 4 * 60 * 1000 // a peer's edit counts as current this long (as relay-mcp.js)
|
|
12
|
+
|
|
13
|
+
const clip = (s, n = NOW_LIMITS.text) => {
|
|
14
|
+
const t = String(s || '').replace(/\s+/g, ' ').trim()
|
|
15
|
+
return t.length > n ? `${t.slice(0, n - 1)}ā¦` : t
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The name `account` ('person:<id>') has in `room`, or '' when it isn't the owner or a member. */
|
|
19
|
+
export function nameIn (room, account) {
|
|
20
|
+
const meta = room && room.meta
|
|
21
|
+
if (!meta || typeof account !== 'string') return ''
|
|
22
|
+
if (meta.ownerSub && meta.ownerSub === account) return meta.ownerName || 'owner'
|
|
23
|
+
const m = meta.members && Object.prototype.hasOwnProperty.call(meta.members, account) ? meta.members[account] : null
|
|
24
|
+
return m && !m.chat && typeof m.name === 'string' ? m.name : ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The files a peer edited in the last few minutes, newest first. */
|
|
28
|
+
function freshEdits (editing, now) {
|
|
29
|
+
return Object.entries(editing || {})
|
|
30
|
+
.filter(([, ts]) => Number.isFinite(ts) && now - ts < EDIT_FRESH_MS)
|
|
31
|
+
.sort((a, b) => b[1] - a[1])
|
|
32
|
+
.map(([p]) => p)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Who is in the room now, each with what their AI is doing. */
|
|
36
|
+
function peopleIn (room, now) {
|
|
37
|
+
const out = []
|
|
38
|
+
const seen = new Set()
|
|
39
|
+
const states = room.awareness && room.awareness.getStates ? [...room.awareness.getStates().values()] : []
|
|
40
|
+
for (const s of states) {
|
|
41
|
+
if (!s || typeof s.name !== 'string' || !s.name || seen.has(s.name)) continue
|
|
42
|
+
seen.add(s.name)
|
|
43
|
+
const ag = s.agent || {}
|
|
44
|
+
const working = ag.sharing !== false && (ag.status === 'working' || (s.work && s.work.state === 'working'))
|
|
45
|
+
out.push({
|
|
46
|
+
name: clip(s.name, 64),
|
|
47
|
+
kind: s.kind === 'agent' ? 'agent' : 'person',
|
|
48
|
+
tool: clip(ag.tool || s.tool || '', 40),
|
|
49
|
+
ai: ag.sharing === false ? 'paused' : working ? 'working' : (ag.tool ? 'idle' : ''),
|
|
50
|
+
focus: clip(s.focus || (s.work && s.work.state === 'working' ? s.work.note : '') || '', 120),
|
|
51
|
+
editing: freshEdits(s.editing, now).slice(0, 3)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
for (const h of room.hostedOnline ? room.hostedOnline() : []) {
|
|
55
|
+
if (!h || !h.name || seen.has(h.name)) continue
|
|
56
|
+
seen.add(h.name)
|
|
57
|
+
out.push({ name: clip(h.name, 64), kind: h.kind === 'agent' ? 'agent' : 'person', tool: 'hosted', ai: '', focus: '', editing: [] })
|
|
58
|
+
}
|
|
59
|
+
return out.slice(0, NOW_LIMITS.people)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The summary of `room` for `account`, or null when the account isn't in it. `now` is the time;
|
|
64
|
+
* `detail` (one session's page) keeps more of the latest file changes, for its Activity tab.
|
|
65
|
+
* The room is read through what Room already offers (server.js): meta, awareness, doc, claimList, pendingList.
|
|
66
|
+
*/
|
|
67
|
+
export function nowSummary (room, account, { now = Date.now(), detail = false } = {}) {
|
|
68
|
+
const me = nameIn(room, account)
|
|
69
|
+
if (!me) return null
|
|
70
|
+
const owner = !!(room.meta.ownerSub && room.meta.ownerSub === account)
|
|
71
|
+
const doc = room.doc
|
|
72
|
+
const chat = doc ? doc.getArray('chat').toArray() : []
|
|
73
|
+
const tasks = doc ? activeTasks(readTasks(doc.getMap('tasks'))) : []
|
|
74
|
+
const claims = room.claimList ? room.claimList() : []
|
|
75
|
+
const people = peopleIn(room, now)
|
|
76
|
+
|
|
77
|
+
const needs = []
|
|
78
|
+
for (const m of waitingOn(chat.filter((x) => x && (!x.to || x.to === me || x.by === me)), me, { now })) {
|
|
79
|
+
needs.push({ kind: m.kind, by: clip(m.by, 64), text: clip(m.text), at: m.ts })
|
|
80
|
+
}
|
|
81
|
+
for (const t of tasks) {
|
|
82
|
+
if (t.column === 'todo' && t.assignee === me && !t.forAi) needs.push({ kind: 'task', by: clip(t.by, 64), text: clip(t.title), at: t.ts, task: t.id })
|
|
83
|
+
}
|
|
84
|
+
for (const c of claims) {
|
|
85
|
+
if (c.by === me && c.queue && c.queue.length) needs.push({ kind: 'file', by: clip(c.queue[0].by, 64), text: clip(c.pattern, 200), at: c.queue[0].ts || c.ts, waiting: c.queue.length })
|
|
86
|
+
}
|
|
87
|
+
if (owner && doc) {
|
|
88
|
+
for (const r of doc.getMap('commitRequests').values()) {
|
|
89
|
+
if (r && r.id && r.state === 'open') needs.push({ kind: 'commit', by: clip(r.by, 64), text: clip(r.message), at: r.ts, files: Array.isArray(r.files) ? r.files.length : 0 })
|
|
90
|
+
}
|
|
91
|
+
for (const p of room.pendingList ? room.pendingList() : []) needs.push({ kind: 'join', by: clip(p.name, 64), text: p.kind === 'agent' ? 'An agent asks to join' : 'Asks to join', at: p.since, agent: p.kind === 'agent' })
|
|
92
|
+
}
|
|
93
|
+
needs.sort((a, b) => (b.at || 0) - (a.at || 0))
|
|
94
|
+
|
|
95
|
+
const byColumn = { todo: 0, doing: 0, qa: 0 }
|
|
96
|
+
for (const t of tasks) if (t.column in byColumn) byColumn[t.column]++
|
|
97
|
+
const recent = (doc ? doc.getArray('activity').toArray() : []).filter((x) => x && x.by).slice(-(detail ? NOW_LIMITS.detailRecent : NOW_LIMITS.recent)).reverse()
|
|
98
|
+
.map((x) => ({ by: clip(x.by, 64), kind: clip(x.kind, 20), path: clip(x.path || x.detail || '', 200), at: x.ts }))
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
room: room.name,
|
|
102
|
+
name: clip(room.meta.name || '', 80),
|
|
103
|
+
me,
|
|
104
|
+
owner,
|
|
105
|
+
people,
|
|
106
|
+
working: people.filter((p) => p.ai === 'working').length,
|
|
107
|
+
tasks: { open: byColumn.todo + byColumn.doing + byColumn.qa, ...byColumn, mine: tasks.filter((t) => t.column !== 'done' && t.assignee === me).length },
|
|
108
|
+
recent,
|
|
109
|
+
held: claims.slice(0, NOW_LIMITS.held).map((c) => ({ path: clip(c.pattern, 200), by: clip(c.by, 64), box: !!c.box, waiting: (c.queue || []).map((q) => clip(q.by, 64)) })),
|
|
110
|
+
needs: needs.slice(0, NOW_LIMITS.needs),
|
|
111
|
+
at: now
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/pathrules.js
CHANGED
|
@@ -20,6 +20,8 @@ export const CHAT_FILES = 'Chat files'
|
|
|
20
20
|
// copies of the project.
|
|
21
21
|
export const ALWAYS_IGNORED = [
|
|
22
22
|
'.git', '.quilt', '.cowove', 'node_modules', '.DS_Store', 'Thumbs.db', '.claude/worktrees',
|
|
23
|
+
// A Seam block's own record (a folder checked out with seam checkout): Seam's, never the session's.
|
|
24
|
+
'.seam',
|
|
23
25
|
`/${CHAT_FILES}`,
|
|
24
26
|
// Each person's own Claude Code settings (permissions, Quilt's hooks) stay on their machine.
|
|
25
27
|
'.claude/settings.local.json',
|
|
@@ -66,6 +68,16 @@ export function makeIgnore (texts = []) {
|
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
/** The folders a .gitmodules text names as submodules (`path = ā¦`), as safe relative paths. */
|
|
72
|
+
export function submodulePathsIn (text) {
|
|
73
|
+
const out = []
|
|
74
|
+
for (const m of String(text || '').matchAll(/^\s*path\s*=\s*(.+?)\s*$/gm)) {
|
|
75
|
+
const p = m[1].replace(/^"(.*)"$/, '$1').replace(/\/+$/, '')
|
|
76
|
+
if (isSafeRelPath(p)) out.push(p)
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
export function isIgnored (ig, rel) {
|
|
70
82
|
if (!rel || rel === '.') return false
|
|
71
83
|
// Check every ancestor directory so "dist/" also excludes "dist/a/b.js".
|
package/src/protocol.js
CHANGED
|
@@ -17,7 +17,7 @@ export const MSG_ADMIN = 14 // client (owner) -> relay: JSON { id, op: 'approve'
|
|
|
17
17
|
export const MSG_MEMBERS = 15 // relay -> client: JSON { members, sessionName, pending?, reply?: { id, ok, error } }
|
|
18
18
|
export const MSG_PASS = 16 // client -> relay: JSON { pass }: a fresh session pass, sent at least every 5 minutes
|
|
19
19
|
export const MSG_BRANCH = 17 // client -> relay: JSON { id, op: 'join'|'confirm'|'remove', branch, base?, adopt?, sv? }
|
|
20
|
-
export const MSG_BRANCHES = 18 // relay -> client: JSON { branches, reply?: { id, op, ok, error?, branch?, created?, base? } }
|
|
20
|
+
export const MSG_BRANCHES = 18 // relay -> client: JSON { branches, quilt?, shadow?, reply?: { id, op, ok, error?, branch?, created?, base? } }
|
|
21
21
|
|
|
22
22
|
// Every sync message names its document: the room's (chat, tasks, the feed, commit requests,
|
|
23
23
|
// activity) is '', a branch's (its files) is the branch key. Git never allows an empty branch name.
|
|
@@ -25,7 +25,15 @@ export const ROOM_DOC = ''
|
|
|
25
25
|
|
|
26
26
|
// What this app can do, sent when it connects. The relay turns away apps without what a session needs.
|
|
27
27
|
// branch-diffs: this app reads a branch document that holds only what differs from git (branch-view.js).
|
|
28
|
-
|
|
28
|
+
// quilts: this app works in a session whose files are in its squares' live documents (layout 3,
|
|
29
|
+
// Seam's plans/quilt-source-control/16 flag 4: sent by every app before anyone opens a block).
|
|
30
|
+
// quilt-folders: this app's folder follows its branch's square in place at layout 3 (session-follow.js);
|
|
31
|
+
// an app with `quilts` but not this (0.3.125) kept syncing to the read-only branch documents.
|
|
32
|
+
// quilt-moves: this app's folder follows a square whose base moves (main brought in): it re-projects onto
|
|
33
|
+
// the new head, says which head each edit was made on, and takes in only its own edits. The relay brings
|
|
34
|
+
// main into a square only when every folder on it can follow that (server.js outdatedOn); 0.3.133 and
|
|
35
|
+
// 0.3.134, which can but don't say so, are known by their asking to bring main in.
|
|
36
|
+
export const FEATURES = 'large-files,branches,branch-diffs,quilts,quilt-folders,quilt-moves'
|
|
29
37
|
|
|
30
38
|
// WebSocket close codes the relay uses to refuse a client for good.
|
|
31
39
|
export const CLOSE_AUTH_FAILED = 4401
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// New sessions are quilts from the start (Seam's plans/quilt-source-control/16, flags order 8,
|
|
2
|
+
// QUILT_SOURCE_DEFAULT=1 on the accounts API): the app that starts a session (the desktop app,
|
|
3
|
+
// `quilt start`, or an AI's quilt_start_session; every tool goes through the same Session) makes it
|
|
4
|
+
// a quilt without history as soon as the folder's files have reached the relay: patch 1 holds them
|
|
5
|
+
// (binaries and modes too), and the room moves to layout 3 with a square for the folder.
|
|
6
|
+
//
|
|
7
|
+
// Nothing is lost when it can't: a folder with a git remote is not made a quilt here (16: a room
|
|
8
|
+
// with a git remote becomes a quilt only with its history, which flag 9's "Make this a quilt"
|
|
9
|
+
// starts), and any failure leaves the session a working git-mode session, with the reason said to
|
|
10
|
+
// the owner and their AIs and `quilt session migrate` to try again by hand.
|
|
11
|
+
import { execFileSync } from 'node:child_process'
|
|
12
|
+
import { apiUrl } from './account.js'
|
|
13
|
+
|
|
14
|
+
const FEATURES_TIMEOUT_MS = 5000
|
|
15
|
+
const SYNC_TRIES = 20
|
|
16
|
+
const SYNC_WAIT_MS = 500
|
|
17
|
+
|
|
18
|
+
/** Whether the accounts API at `api` has new sessions start as quilts (GET /v1/features `quilt_source_default`). False when it can't say. */
|
|
19
|
+
export async function quiltDefaultOn ({ api = apiUrl(), fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetchImpl(`${String(api).replace(/\/+$/, '')}/v1/features`, { signal: AbortSignal.timeout(FEATURES_TIMEOUT_MS) })
|
|
22
|
+
if (!res.ok) return false
|
|
23
|
+
const f = await res.json()
|
|
24
|
+
return !!(f && f.quilt_source_default === true)
|
|
25
|
+
} catch { return false }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether the folder at `root` is a git repository with a remote (its history belongs in the quilt: 16 "With history"). */
|
|
29
|
+
export function folderHasGitRemote (root) {
|
|
30
|
+
try {
|
|
31
|
+
return execFileSync('git', ['--no-optional-locks', 'remote'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }).trim().length > 0
|
|
32
|
+
} catch { return false }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Makes the new session `session` a quilt (migrate, then the layout-3 switch) as its owner, once.
|
|
39
|
+
* Resolves to { done: true, quilt } or { done: false, why }; never throws.
|
|
40
|
+
*/
|
|
41
|
+
export async function startAsQuilt (session, { flagOn = quiltDefaultOn, credential, hasRemote = folderHasGitRemote, syncTries = SYNC_TRIES, syncWaitMs = SYNC_WAIT_MS } = {}) {
|
|
42
|
+
const skip = (why) => ({ done: false, why })
|
|
43
|
+
try {
|
|
44
|
+
if (!(await flagOn())) return skip('off')
|
|
45
|
+
if (session.stopped) return skip('stopped')
|
|
46
|
+
if (session.quilt) return skip('already a quilt')
|
|
47
|
+
if (session.root && hasRemote(session.root)) {
|
|
48
|
+
session.log('š§µ this folder has a git remote, so the new session stays on git: it becomes a quilt with its history later (Make this a quilt).')
|
|
49
|
+
return skip('git remote')
|
|
50
|
+
}
|
|
51
|
+
if (session.branchForm === 'diff') return skip('diff branch')
|
|
52
|
+
// The folder's files are its patch 1: they must all be on the relay first.
|
|
53
|
+
let synced = false
|
|
54
|
+
for (let i = 0; i < syncTries && !synced && !session.stopped; i++) {
|
|
55
|
+
if (session.ready && !session.seeding) {
|
|
56
|
+
try { session.flushPending?.() } catch {}
|
|
57
|
+
try { await session.conn.confirmBranch(); synced = true; break } catch {}
|
|
58
|
+
}
|
|
59
|
+
await wait(syncWaitMs)
|
|
60
|
+
}
|
|
61
|
+
if (session.stopped) return skip('stopped')
|
|
62
|
+
if (!synced) throw new Error("the folder's files had not all reached the relay")
|
|
63
|
+
if (session.quilt) return skip('already a quilt')
|
|
64
|
+
const token = await credential()
|
|
65
|
+
if (!token) throw new Error('no sign-in to reach Seam with')
|
|
66
|
+
const m = await session.quiltMigrate(token)
|
|
67
|
+
await session.quiltSwitch(token)
|
|
68
|
+
// The folder equals patch 1, so the switch opened no square for it: one is opened now, as the
|
|
69
|
+
// owner, and the folder follows it (16: a square for the session's first folder). Should that
|
|
70
|
+
// fail, the session is a quilt still, and the folder's first edit asks for its square (readOnly).
|
|
71
|
+
for (let i = 0; i < syncTries * 4 && !session.stopped && !(session.quilt && session.quilt.layout === 3); i++) await wait(syncWaitMs / 4)
|
|
72
|
+
const branch = typeof session.branchKey === 'function' ? session.branchKey() : session.branch
|
|
73
|
+
if (session.quilt && session.quilt.layout === 3 && !(session.quilt.live || {})[branch]) {
|
|
74
|
+
try { await session.openBranchSquare(branch, { quiet: false, token }) } catch (err) { session.log(`š§µ couldn't open a square for this folder yet (${err.message}); its first edit asks again.`) }
|
|
75
|
+
}
|
|
76
|
+
session.log(`š§µ new session: its code is in Seam from the start (quilt ${(m.quilt && m.quilt.slug) || ''}, patch 1).`)
|
|
77
|
+
return { done: true, quilt: m.quilt }
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const why = String((err && err.message) || err)
|
|
80
|
+
session.log(`š§µ this new session's code could not go into Seam (${why}); the session works as before, on git. The owner can try again with: quilt session migrate`)
|
|
81
|
+
if (session.notice) { try { session.notice(`This new session's code could not go into Seam (${why}), so it stays on git for now. The session owner can try again with \`quilt session migrate\`.`) } catch {} }
|
|
82
|
+
return skip(why)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// quilt_history in a session linked to a quilt (Seam's plans/quilt-source-control/16, step 1
|
|
2
|
+
// "Shadow", and after the session became a quilt): the chronology is Seam's log (the commits and
|
|
3
|
+
// patches, imported from git or made in Seam) plus the session's own live record of changes not
|
|
4
|
+
// committed yet (the branch document's `history`, history.js), oldest first, newest kept.
|
|
5
|
+
//
|
|
6
|
+
// Quilt reads Seam over its HTTP API (a client with the @heyquilt/seam quiltClient shape: `log`,
|
|
7
|
+
// `show`), as the member (Session.seamMember) or, on the relay, with the room's read token. When
|
|
8
|
+
// Seam can't be read, the session's own record is shown with a note, never an error: the tool
|
|
9
|
+
// worked before the link and keeps working without Seam. The same for every AI tool: this runs
|
|
10
|
+
// behind quilt_history in both MCP servers (mcp.js through the folder's daemon, relay-mcp.js).
|
|
11
|
+
import { MAX_DIFF_CHARS, queryHistory } from './history.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The quilt a room's chronology reads from: `{ id, slug, at?, shadow? }`, or null. A quilt the
|
|
15
|
+
* session became (meta.quilt_id), or the quilt it shadows while git is still the authority
|
|
16
|
+
* (meta.quiltShadow, 16 step 1: an import following the session's git remote). Only with Seam
|
|
17
|
+
* on (QUILT_SOURCE_URL), so every other session reads its own record exactly as before.
|
|
18
|
+
*/
|
|
19
|
+
export function historyQuilt (meta, env = process.env) {
|
|
20
|
+
if (!env || !env.QUILT_SOURCE_URL || !meta) return null
|
|
21
|
+
if (typeof meta.quilt_id === 'string' && meta.quilt_id) {
|
|
22
|
+
const q = meta.quilt || {}
|
|
23
|
+
return { id: meta.quilt_id, slug: q.slug || '', ...(q.at ? { at: q.at } : {}) }
|
|
24
|
+
}
|
|
25
|
+
const s = meta.quiltShadow
|
|
26
|
+
if (s && typeof s.id === 'string' && s.id) return { id: s.id, slug: s.slug || '', shadow: true }
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The part of a path or glob Seam's log can filter by: a file, or the folder before the first wildcard. */
|
|
31
|
+
export function seamPathOf (p) {
|
|
32
|
+
const s = String(p || '').replace(/\\/g, '/').replace(/^\.\//, '')
|
|
33
|
+
if (!s) return ''
|
|
34
|
+
const wild = s.search(/[*?[{]/)
|
|
35
|
+
if (wild < 0) return s.replace(/\/+$/, '')
|
|
36
|
+
const dir = s.slice(0, wild)
|
|
37
|
+
return dir.includes('/') ? dir.slice(0, dir.lastIndexOf('/')) : ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MAX_SHOWN = 10 // commits whose diffs are read when asked for them
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The quilt's log as chronology entries, oldest first: one per commit, `{ seam: true, commit,
|
|
44
|
+
* short, patch, by, ts, message, files, task, diff }`. `since` is a timestamp. Throws what Seam
|
|
45
|
+
* said when it can't be read.
|
|
46
|
+
*/
|
|
47
|
+
export async function seamHistory (seam, quilt, { path = '', by, since, task, limit = 30, withDiff = false, rev = 'main' } = {}) {
|
|
48
|
+
const p = seamPathOf(path)
|
|
49
|
+
const r = await seam.log(quilt, { rev, limit: Math.min(Number(limit) || 30, 200), ...(p ? { paths: [p] } : {}), ...(by ? { author: by } : {}), ...(typeof since === 'number' ? { since: String(Math.floor(since)) } : {}), ...(task ? { task } : {}) })
|
|
50
|
+
const out = []
|
|
51
|
+
for (const c of (r && r.commits) || []) {
|
|
52
|
+
if (!c || !c.id) continue
|
|
53
|
+
const a = c.author || {}
|
|
54
|
+
out.push({
|
|
55
|
+
id: `seam:${c.id}`,
|
|
56
|
+
seam: true,
|
|
57
|
+
commit: c.id,
|
|
58
|
+
short: c.short || c.id.slice(0, 12),
|
|
59
|
+
...(c.patch && c.patch.patch_no != null ? { patch: { line: c.patch.line || rev, no: c.patch.patch_no } } : {}),
|
|
60
|
+
by: String(a.name || a.member_id || ''),
|
|
61
|
+
ts: Number(c.at ?? c.authored_at) || 0,
|
|
62
|
+
message: String(c.message || '').split('\n')[0].slice(0, 200),
|
|
63
|
+
files: Number(c.files) || (Array.isArray(c.changes) ? c.changes.length : 0),
|
|
64
|
+
task: c.task_id ? { id: String(c.task_id), title: '' } : null,
|
|
65
|
+
path: p,
|
|
66
|
+
diff: ''
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
if (withDiff) {
|
|
70
|
+
for (const e of out.slice(0, MAX_SHOWN)) {
|
|
71
|
+
try {
|
|
72
|
+
const s = await seam.show(quilt, e.commit, { format: 'unified', ...(p ? { paths: [p] } : {}) })
|
|
73
|
+
const text = ((s && s.diff && s.diff.files) || []).map((f) => f.patch || (f.binary ? `${f.path}: binary` : '')).filter(Boolean).join('\n')
|
|
74
|
+
e.diff = text.length > MAX_DIFF_CHARS ? `${text.slice(0, MAX_DIFF_CHARS)}\n⦠(diff truncated)` : text
|
|
75
|
+
} catch { e.diff = '' }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out.sort((x, y) => x.ts - y.ts)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The chronology of a linked session: the quilt's log (`seam`, a client) and the session's own
|
|
83
|
+
* record (`entries`, history.js), filtered by `query` (path, by, since timestamp, task, limit,
|
|
84
|
+
* withDiff), oldest first, the newest `limit` kept. A change git pulled into the session is the
|
|
85
|
+
* log's (an imported patch) and is left out of the session's record when the log answered, so it
|
|
86
|
+
* is not counted twice (16: "entries with `pulled` or `seed` are dropped", here only `pulled`:
|
|
87
|
+
* a seed is the session's first copy, which the log has no entry for). Returns { list, note }:
|
|
88
|
+
* `note` says why the log is missing, when it is.
|
|
89
|
+
*/
|
|
90
|
+
export async function linkedHistory ({ seam, quilt, entries = [], query = {} }) {
|
|
91
|
+
const { path = '', by, since, task, limit = 30, withDiff = false } = query
|
|
92
|
+
let fromSeam = []
|
|
93
|
+
let note = ''
|
|
94
|
+
if (!seam) note = `Seam's log for ${quilt.slug || quilt.id} was not read: the session has no read token for it (its owner links it again); this is the session's own record.`
|
|
95
|
+
else {
|
|
96
|
+
try { fromSeam = await seamHistory(seam, quilt.id, { path, by, since, task, limit, withDiff }) } catch (err) { note = `Seam's log for ${quilt.slug || quilt.id} could not be read (${String(err.message || err).slice(0, 200)}); this is the session's own record.` }
|
|
97
|
+
}
|
|
98
|
+
return { list: mergeLinked(entries, fromSeam, { ...query, logRead: !note }), note }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The session's own entries (filtered by `query`; git's pulls left out when `logRead`) with the log's, oldest first, the newest `limit`. */
|
|
102
|
+
export function mergeLinked (entries, fromSeam, { path = '', by, since, task, limit = 30, logRead = true } = {}) {
|
|
103
|
+
const own = queryHistory(logRead ? entries.filter((e) => !(e && e.pulled)) : entries, { path, by, since, task, limit: 0 })
|
|
104
|
+
const list = [...own, ...(fromSeam || [])].sort((a, b) => a.ts - b.ts)
|
|
105
|
+
return limit && list.length > limit ? list.slice(-limit) : list
|
|
106
|
+
}
|