import { createBindingId, toRichText, type TLArrowShape, type TLBinding, type TLFrameShape, type TLNoteShape, type TLParentId, type TLRecord, type TLShape, type TLShapeId, } from '@tldraw/tlschema' import type { JsonObject } from '@tldraw/utils' import { z } from 'zod' import { NOTE_PIN_TYPE } from '../shared/bindings.ts' import { nodeLabel, normaliseType } from '../shared/display.ts' import { agentShapeId, clusterShapeId, linkShapeId, paperShapeId, slug, type LinkKind } from '../shared/ids.ts' import { isAgentShape, isHumanTouched } from '../shared/meta.ts' import type { PaperCardProps, PaperQuote } from '../shared/paperCard.ts' import { defaultPaperCardProps, type PaperCardShape } from '../shared/shapes.ts' import { boardInfo as readBoardInfo } from './boardDb.ts' import { recordEvent as recordFeedEvent } from './events.ts' import { centre, footprint, isFree, MARGIN, placeInside, placeNear, shapeBoxes, type Vec } from './layout.ts' import type { PapersClient } from './papersClient.ts' import { readRoom, writeRoom, writer, type Txn } from './rooms.ts' // The canvas MCP's tools (T02) as plain functions, so they can be tested without MCP. Each call // is one room transaction. Creates are insert-only and updates are merging patches (D20); the // human-touched rules are enforced here: an agent never moves a shape a person moved, never // changes props a person edited, never changes people's shapes (it may bind to them), never deletes. export interface ToolContext { boardId: string /** The member the agent works for, from the bearer. */ onBehalfOf: string /** X-Run-Id: one per prompt turn; null for external agents. */ runId: string | null /** X-Model, recorded per card field by set_card for the "AI" labels. */ model: string | null papers: PapersClient } /** A mistake the agent can fix; the MCP turns it into a tool error with this message. */ export class ToolError extends Error {} export interface RemovedLink { from: string to: string kind: LinkKind } export interface BoardInfo { question: string goals: string[] discardedIds: string[] removedLinks: RemovedLink[] } /** The board's question, goals (IDs), papers only discarded so far and links people removed, from T08's board DB. */ export const boardInfo = (boardId: string): BoardInfo => readBoardInfo(boardId) export interface BoardEvent { boardId: string actor: 'agent' onBehalfOf: string runId: string | null type: string payload: unknown } /** Stored in the activity feed and streamed over SSE (events.ts); never throws. */ export const recordEvent = (event: BoardEvent) => recordFeedEvent(event) export function changed(ctx: ToolContext, type: string, payload: unknown) { recordEvent({ boardId: ctx.boardId, actor: 'agent', onBehalfOf: ctx.onBehalfOf, runId: ctx.runId, type, payload }) } export const agentMeta = (ctx: ToolContext, extra: JsonObject = {}): JsonObject => ({ createdBy: 'agent', onBehalfOf: ctx.onBehalfOf, runId: ctx.runId, ...extra, }) export const isShape = (r: TLRecord | undefined): r is TLShape => r?.typeName === 'shape' export const isCard = (r: TLRecord | undefined): r is PaperCardShape => isShape(r) && r.type === 'paper-card' const isBinding = (r: TLRecord): r is TLBinding => r.typeName === 'binding' // slugs are lossy, so two paper IDs can share a shape ID; the card's own paperId decides export function findCard(txn: Txn, paperId: string): PaperCardShape | undefined { const r = txn.get(paperShapeId(paperId)) return isCard(r) && r.props.paperId === paperId ? r : undefined } function needCard(txn: Txn, paperId: string): PaperCardShape { const card = findCard(txn, paperId) if (!card) throw new ToolError(`No card for ${paperId} on this board; add it with add_papers first`) return card } /** Why the agent may not change this shape's props, or null if it may. */ export function propsLocked(shape: TLShape): string | null { if (!isAgentShape(shape.meta)) return 'made by a person' if (shape.meta.editedByHuman === true) return 'edited by a person' return null } const normalise = (text: string) => text.replace(/\s+/g, ' ').trim() /** Text of a TipTap doc: paragraphs on their own lines. */ function plainText(node: unknown): string { const n = node as { type?: string; text?: string; content?: unknown[] } | null if (n?.type === 'text') return n.text ?? '' return (n?.content ?? []).map(plainText).join(n?.type === 'doc' ? '\n' : '') } // Agents never delete, so a link ID with a tombstone was removed by a person. Tombstones are // pruned after a few thousand deletes; T08's board DB keeps removed links for good. function deletedLinkIds(txn: Txn): Set { const changes = txn.getChangesSince(0) if (!changes || changes.wipeAll) return new Set() return new Set(changes.diff.deletes.filter((id) => id.startsWith('shape:link-'))) } // ---- get_board const PAPER_CAP = 500 const LINK_CAP = 1000 const NOTE_CAP = 200 export function getBoard(ctx: ToolContext) { const info = boardInfo(ctx.boardId) return readRoom(ctx.boardId, (txn) => { const records = [...txn.values()] const byId = new Map(records.map((r) => [r.id, r])) const shapes = records.filter(isShape) const bindings = records.filter(isBinding) const cards = shapes.filter(isCard) const paperIdOf = (id: string) => { const r = byId.get(id) return isCard(r) ? r.props.paperId : null } const pinnedTo = new Map() const pinCount = new Map() const ends = new Map() for (const b of bindings) { if (b.type === NOTE_PIN_TYPE) { pinnedTo.set(b.fromId, paperIdOf(b.toId)) pinCount.set(b.toId, (pinCount.get(b.toId) ?? 0) + 1) } else if (b.type === 'arrow') { ends.set(b.fromId, { ...ends.get(b.fromId), [b.props.terminal]: paperIdOf(b.toId) }) } } const rank = (c: PaperCardShape) => (c.props.kind === 'suggestion' ? 1 : 0) cards.sort((a, b) => rank(a) - rank(b) || (a.index < b.index ? -1 : 1)) const papers = cards.slice(0, PAPER_CAP).map(({ id, parentId, meta, props: p }) => { const frame = byId.get(parentId) return { paperId: p.paperId, label: nodeLabel(p.authors, p.year), title: p.title, year: p.year, type: p.type, kind: p.kind, status: p.status, scores: Object.fromEntries( Object.entries(p.scores).map(([goal, s]) => [goal, { score: s.score, fit: s.fit }]) ), working: p.working, cluster: isShape(frame) && typeof frame.meta.clusterId === 'string' ? frame.meta.clusterId : null, humanTouched: isHumanTouched(meta), pinnedNotes: pinCount.get(id) ?? 0, } }) const links = [] for (const [arrowId, { start, end }] of ends) { const arrow = byId.get(arrowId) if (!start || !end || !isShape(arrow) || arrow.type !== 'arrow') continue const m = arrow.meta links.push({ from: start, to: end, kind: typeof m.kind === 'string' ? m.kind : 'user', label: (typeof m.label === 'string' && m.label) || plainText(arrow.props.richText), by: String(m.by ?? m.author ?? ''), ...(m.hiddenByHuman === true ? { hidden: true } : {}), }) } const notes = shapes .filter((s): s is TLNoteShape => s.type === 'note') .slice(0, NOTE_CAP) .map((n) => ({ shapeId: n.id, text: plainText(n.props.richText).slice(0, 500), author: typeof n.meta.author === 'string' ? n.meta.author : isAgentShape(n.meta) ? 'agent' : '', pinnedTo: pinnedTo.get(n.id) ?? null, })) // a cluster's papers are the cards inside its frame (`cluster` on each paper) const clusters = shapes .filter((s): s is TLFrameShape => clusterIdOf(s) !== null) .map((f) => ({ clusterId: f.meta.clusterId as string, label: f.props.name, labelGenerated: f.meta.labelGenerated === true, humanTouched: isHumanTouched(f.meta) || f.meta.renamedByHuman === true, })) const discarded = cards.filter((c) => c.props.status === 'discarded').map((c) => c.props.paperId) const bySlug = new Map(cards.map((c) => [slug(c.props.paperId), c.props.paperId])) const removed = [...deletedLinkIds(txn)].map((id) => parseLinkId(id, bySlug)).filter((l) => l !== null) return { boardId: ctx.boardId, question: info.question, goals: info.goals, papers, ...(cards.length > PAPER_CAP ? { truncated: cards.length - PAPER_CAP } : {}), links: links.slice(0, LINK_CAP), notes, clusters, discardedIds: [...new Set([...info.discardedIds, ...discarded])], removedLinks: [...info.removedLinks, ...removed], } }) } // `shape:link----` back to paper IDs; slugs may contain "--", so try each split function parseLinkId(id: string, bySlug: Map): RemovedLink | null { const m = id.match(/^shape:link-(cites|similar|user)-(.+)$/) if (!m) return null for (let i = m[2].indexOf('--'); i >= 0; i = m[2].indexOf('--', i + 1)) { const from = bySlug.get(m[2].slice(0, i)) const to = bySlug.get(m[2].slice(i + 2)) if (from && to) return { from, to, kind: m[1] as LinkKind } } return null } // ---- add_papers export const paperIdInput = z.string().min(1).max(200) const httpUrl = z.string().max(2000).regex(/^https?:\/\//, 'must be an http(s) URL') const paperInput = z.object({ paperId: paperIdInput, title: z.string().min(1).max(1000), authors: z.array(z.string().max(200)).max(200).nullish(), year: z.number().int().min(1000).max(2200).nullish(), venue: z.string().max(500).nullish(), url: httpUrl.nullish(), type: z.string().max(100).nullish(), peerReviewed: z.boolean().nullish(), citedBy: z.number().int().min(0).nullish(), retracted: z.boolean().nullish(), kind: z.enum(['seed', 'kept', 'suggestion']).nullish(), near: paperIdInput.nullish(), foundBy: z.array(z.string().max(200)).max(20).nullish(), }) const addPapersInput = { papers: z.array(paperInput).min(1).max(50) } const NEW_CARD_SIZE = 48 // null means unknown: it never overwrites what a card already says function cardFields(p: z.infer): Partial { const f: Partial = { title: p.title } if (p.authors) f.authors = p.authors if (p.year != null) f.year = p.year if (p.venue != null) f.venue = p.venue if (p.url != null) f.url = p.url if (p.type != null) Object.assign(f, { type: normaliseType(p.type), typeRaw: p.type }) if (p.peerReviewed != null) f.peerReviewed = p.peerReviewed if (p.citedBy != null) f.citedBy = p.citedBy if (p.foundBy) f.foundBy = p.foundBy return f } // The `near` card's centre; else the first card from this run, so a run's papers ring around one // point instead of running off in a row; else null (right of the content). function anchorFor(records: TLRecord[], txn: Txn, near: string | null | undefined, runId: string | null): Vec | null { const boxes = shapeBoxes(records) const nearCard = near ? findCard(txn, near) : undefined if (nearCard && boxes.has(nearCard.id)) return centre(boxes.get(nearCard.id)!) if (!runId) return null let first: PaperCardShape | undefined for (const r of records) if (isCard(r) && r.meta.runId === runId && (!first || r.index < first.index)) first = r return first && boxes.has(first.id) ? centre(boxes.get(first.id)!) : null } // ---- cluster frames: built-in frames with meta.clusterId (apply_clusters and label_cluster are in clusterTools.ts) const clusterIdOf = (r: TLRecord | undefined) => isShape(r) && r.type === 'frame' && typeof r.meta.clusterId === 'string' ? r.meta.clusterId : null /** The frame that holds cluster `clusterId`: ours by its ID, else one a person made (T14). */ export function findClusterFrame(txn: Txn, clusterId: string): TLFrameShape | undefined { const own = txn.get(clusterShapeId(clusterId)) if (clusterIdOf(own) === clusterId) return own as TLFrameShape for (const r of txn.values()) if (clusterIdOf(r) === clusterId) return r as TLFrameShape return undefined } /** * A free spot for a box of `size` inside `frame`, in the frame's own coordinates, nearest the * anchor (page space). If the frame is full and is the agent's, untouched by people, it grows * downwards when the space below it is free, else one card column to the right when that is free. * Null if there is no room. */ export function spotInFrame(txn: Txn, frameId: TLShapeId, size: { w: number; h: number }, anchor: Vec | null) { const frame = txn.get(frameId) as TLFrameShape const records = [...txn.values()] const box = shapeBoxes(records).get(frame.id)! const local = (at: Vec) => ({ x: at.x - box.x, y: at.y - box.y }) const at = placeInside(records, size, anchor, frame.id) if (at) return local(at) if (propsLocked(frame)) return null const room = footprint({ x: 0, y: 0, ...size }) const down = Math.max(room.w, room.h) + 2 * MARGIN const across = room.w + 2 * MARGIN const inFrame = new Set([frame.id]) for (const r of records) if (isShape(r) && inFrame.has(r.parentId)) inFrame.add(r.id) const outside = [...shapeBoxes(records)].filter(([id]) => !inFrame.has(id)).map(([, b]) => b) if (isFree(outside, { x: box.x, y: box.y + box.h, w: box.w, h: down })) { const grown = { ...frame, props: { ...frame.props, h: frame.props.h + down } } const below = placeInside(records.map((r) => (r.id === frame.id ? grown : r)), size, anchor, frame.id) if (below) { writer(txn).patchShape({ id: frame.id, props: { h: grown.props.h } }) return local(below) } } const column = { x: box.x + box.w, y: box.y, w: across, h: box.h } if (box.h < room.h + 2 * MARGIN || !isFree(outside, column)) return null const top = box.y + MARGIN + room.h / 2 const y = Math.min(Math.max((anchor ?? centre(box)).y, top), box.y + box.h - MARGIN - room.h / 2) const spot = { x: Math.round(column.x + across / 2 - size.w / 2), y: Math.round(y - size.h / 2) } const others = [...shapeBoxes(records)].filter(([id]) => id !== frame.id).map(([, b]) => b) if (!isFree(others, footprint({ ...spot, ...size }))) return null writer(txn).patchShape({ id: frame.id, props: { w: frame.props.w + across } }) return local(spot) } // A new card goes beside its `near` card, inside that card's cluster frame while it has room; // `near` may also name a cluster. Otherwise on the page, as placeNear does. function placeCard( txn: Txn, size: { w: number; h: number }, near: string | null | undefined, runId: string | null ): Vec & { parentId?: TLParentId } { const records = [...txn.values()] const nearCard = near ? findCard(txn, near) : undefined const container = nearCard ? txn.get(nearCard.parentId) : near ? findClusterFrame(txn, near) : undefined const frame = clusterIdOf(container) !== null ? (container as TLFrameShape) : undefined const anchor = frame && !nearCard ? centre(shapeBoxes(records).get(frame.id)!) : anchorFor(records, txn, near, runId) if (frame) { const spot = spotInFrame(txn, frame.id, size, anchor) if (spot) return { ...spot, parentId: frame.id } } return placeNear([...txn.values()], size, anchor) } export function addPapers(ctx: ToolContext, { papers }: z.infer>) { const result = writeRoom(ctx.boardId, (txn) => { const w = writer(txn) const added: string[] = [] const updated: string[] = [] const skipped: { paperId: string; reason: string }[] = [] for (const p of papers) { const skip = (reason: string) => skipped.push({ paperId: p.paperId, reason }) if (p.retracted) { skip('retracted') continue } const id = paperShapeId(p.paperId) as TLShapeId const current = txn.get(id) const card = findCard(txn, p.paperId) if (current && !card) { skip('another shape already has this paper’s shape ID') continue } if (card) { const reason = card.props.kind === 'seed' ? 'a seed already on the board' : (propsLocked(card) ?? (card.meta.pinnedByHuman === true ? 'moved by a person' : null)) if (reason) skip(reason) else { w.patchShape({ id, props: cardFields(p), meta: agentMeta(ctx, { paperId: p.paperId }) }) updated.push(p.paperId) } continue } const size = { w: NEW_CARD_SIZE, h: NEW_CARD_SIZE } w.createShape({ id, type: 'paper-card', ...placeCard(txn, size, p.near, ctx.runId), props: defaultPaperCardProps(p.paperId, { ...cardFields(p), ...size, kind: p.kind ?? 'suggestion', status: 'new', working: true, }), meta: agentMeta(ctx, { paperId: p.paperId }), }) added.push(p.paperId) } return { added, updated, skipped } }) const { added, updated } = result if (added.length || updated.length) changed(ctx, 'add_papers', { added, updated }) return result } // ---- set_scores const goalScore = z.object({ score: z.number().min(0).max(1), fit: z.enum(['low', 'medium', 'high']), why: z.string().max(200), }) const setScoresInput = { paperId: paperIdInput, scores: z.record(z.string().regex(/^[a-z0-9][a-z0-9-]{0,39}$/, 'goal IDs look like new-findings'), goalScore), } export function setScores(ctx: ToolContext, { paperId, scores }: z.infer>) { const { goals } = boardInfo(ctx.boardId) const unknown = Object.keys(scores).filter((g) => !goals.includes(g)) if (goals.length && unknown.length) { throw new ToolError(`Not a goal of this board: ${unknown.join(', ')}. Its goals are ${goals.join(', ')}`) } if (!Object.keys(scores).length) throw new ToolError('Give a score for at least one goal') const result = writeRoom(ctx.boardId, (txn) => { const card = needCard(txn, paperId) const locked = propsLocked(card) if (locked) return { ok: false, skipped: locked } writer(txn).patchShape({ id: card.id, props: { scores: { ...card.props.scores, ...scores } }, meta: agentMeta(ctx, { paperId }), }) return { ok: true } }) if (result.ok) changed(ctx, 'set_scores', { paperId, goals: Object.keys(scores) }) return result } // ---- set_card const quoteIdInput = z.string().min(1).max(40) const setCardInput = { paperId: paperIdInput, oneLine: z.string().max(400).optional(), claims: z .array(z.object({ text: z.string().min(1).max(200), evidence: z.array(quoteIdInput).min(1).max(6) })) .max(8) .optional(), glance: z .array( z.object({ label: z.string().min(1).max(40), text: z.string().min(1).max(200), quoteId: quoteIdInput.nullable(), relation: z.enum(['same', 'adds', 'other']).nullable(), }) ) .min(3) .max(4) .optional(), figure: z .object({ imageUrl: z.string().max(2000).regex(/^https:\/\//, 'must be an https URL'), label: z.string().max(40), caption: z.string().max(1500), captionByAi: z.boolean(), }) .nullable() .optional(), summary: z.string().max(1500).optional(), gaps: z.array(z.string().min(1).max(300)).max(8).optional(), } export function setCard(ctx: ToolContext, input: z.infer>) { const { paperId, oneLine, claims, glance, figure, summary, gaps } = input if ([oneLine, claims, glance, figure, summary, gaps].every((v) => v === undefined)) { throw new ToolError('Nothing to set: give at least one of oneLine, claims, glance, figure, summary, gaps') } const result = writeRoom(ctx.boardId, (txn) => { const card = needCard(txn, paperId) const locked = propsLocked(card) if (locked) return { ok: false, skipped: locked } const p = card.props const quoteIds = new Set(p.quotes.map((q) => q.id)) const missing = (ids: (string | null)[]) => ids.filter((id) => id !== null && id !== 'seeds' && !quoteIds.has(id)) const noQuote = (ids: (string | null)[]) => `${missing(ids).join(', ')}: no such quote on this card; add it with add_quote first ` + `(quotes so far: ${[...quoteIds].join(', ') || 'none'})` const line = oneLine ?? p.oneLine const props: Partial = {} if (oneLine !== undefined) props.oneLine = oneLine if (claims) { for (const c of claims) { if (!line.includes(c.text)) throw new ToolError(`Claim "${c.text}" must appear word for word in oneLine`) if (missing(c.evidence).length) throw new ToolError(`Claim "${c.text}" cites ${noQuote(c.evidence)}`) } props.claims = claims } else if (oneLine !== undefined && p.claims.some((c) => !line.includes(c.text))) { props.claims = p.claims.filter((c) => line.includes(c.text)) } if (glance) { const ids = glance.map((s) => s.quoteId) if (ids.includes('seeds') || missing(ids).length) throw new ToolError(`Glance step quote ${noQuote(ids)}`) props.glance = glance } if (figure !== undefined) props.figure = figure if (summary !== undefined) props.summary = summary if (gaps) props.gaps = gaps const written = Object.keys(props) const at = Date.now() props.provenance = { ...p.provenance, ...Object.fromEntries(written.map((k) => [k, { model: ctx.model, at }])) } writer(txn).patchShape({ id: card.id, props, meta: agentMeta(ctx, { paperId }) }) return { ok: true, written } }) if (result.ok) changed(ctx, 'set_card', { paperId, fields: result.written }) return result } // ---- add_quote const addQuoteInput = { paperId: paperIdInput, text: z.string().min(1).max(600), source: z.string().max(40).optional(), } // A text fragment (#:~:text=) so the link scrolls to the quote; long quotes use start,end. function quoteUrl(base: string, quote: string): string { if (!/^https?:\/\//.test(base)) return '' const enc = (s: string) => encodeURIComponent(s).replace(/-/g, '%2D') const words = quote.split(' ') const text = words.length > 10 ? `${enc(words.slice(0, 4).join(' '))},${enc(words.slice(-4).join(' '))}` : enc(quote) return `${base.split('#')[0]}#:~:text=${text}` } export async function addQuote(ctx: ToolContext, { paperId, text, source }: z.infer>) { const quote = normalise(text) // fail fast on a missing card before asking the papers MCP readRoom(ctx.boardId, (txn) => needCard(txn, paperId)) let abstract: string let paperUrl: string try { const paper = await ctx.papers.getPaper(paperId) abstract = paper.abstract ?? '' paperUrl = paper.url ?? '' } catch (e) { throw new ToolError(`${(e as Error).message}, so the quote can't be checked; nothing was written`) } if (!abstract) { throw new ToolError(`The papers MCP has no abstract for ${paperId}, so quotes can't be checked; nothing was written`) } if (!normalise(abstract).includes(quote)) { throw new ToolError(`That text isn't in the abstract of ${paperId} word for word; nothing was written`) } const result = writeRoom(ctx.boardId, (txn) => { const card = needCard(txn, paperId) const locked = propsLocked(card) if (locked) return { quoteId: null, skipped: locked } const quotes = card.props.quotes const same = quotes.find((q) => normalise(q.text) === quote) if (same) return { quoteId: same.id } const n = Math.max(0, ...quotes.map((q) => Number(q.id.match(/^q(\d+)$/)?.[1] ?? 0))) const added: PaperQuote = { id: `q${n + 1}`, text: quote, source: source ?? 'abstract', verified: true, url: quoteUrl(card.props.url || paperUrl, quote), } writer(txn).patchShape({ id: card.id, props: { quotes: [...quotes, added] }, meta: agentMeta(ctx, { paperId }) }) return { quoteId: added.id } }) if (result.quoteId) changed(ctx, 'add_quote', { paperId, quoteId: result.quoteId }) return result } // ---- link_papers const linkPapersInput = { links: z .array( z.object({ from: paperIdInput, to: paperIdInput, kind: z.enum(['cites', 'similar']), source: z.enum(['pubmed', 'openalex']).optional(), }) ) .min(1) .max(100), } const ARROW_PROPS = { kind: 'arc', elbowMidPoint: 0.5, dash: 'solid', size: 's', fill: 'none', color: 'grey', labelColor: 'black', bend: 0, start: { x: 0, y: 0 }, end: { x: 0, y: 0 }, arrowheadStart: 'none', arrowheadEnd: 'arrow', richText: toRichText(''), labelPosition: 0.5, font: 'draw', scale: 1, } satisfies TLArrowShape['props'] const arrowEnd = (terminal: 'start' | 'end') => ({ terminal, normalizedAnchor: { x: 0.5, y: 0.5 }, isExact: false, isPrecise: false, snap: 'none' as const, }) export function linkPapers(ctx: ToolContext, { links }: z.infer>) { const { removedLinks } = boardInfo(ctx.boardId) const result = writeRoom(ctx.boardId, (txn) => { const w = writer(txn) const deleted = deletedLinkIds(txn) const boxes = shapeBoxes(txn.values()) const touched = new Set() const linked: { from: string; to: string; kind: LinkKind }[] = [] const skipped: { from: string; to: string; kind: LinkKind; reason: string }[] = [] for (const link of links) { const { from, to, kind, source } = link const skip = (reason: string) => skipped.push({ ...link, reason }) if (from === to) { skip('a paper can’t link to itself') continue } const a = findCard(txn, from) const b = findCard(txn, to) if (!a || !b) { skip(`no card for ${!a ? from : to}`) continue } // a citation either way already joins them, and says more const cited = [linkShapeId(from, to, 'cites'), linkShapeId(to, from, 'cites')] if (kind === 'similar' && cited.some((id) => isShape(txn.get(id as TLShapeId)))) { skip('already linked by a citation') continue } // a similar link has no direction, so either way round is the same link const pairs = kind === 'similar' ? [[from, to], [to, from]] : [[from, to]] const ids = pairs.map(([f, t]) => linkShapeId(f, t, kind) as TLShapeId) const existing = ids.map((id) => txn.get(id)).find(isShape) if (existing) { if (existing.meta.hiddenByHuman === true) skip('hidden by a person') else linked.push(link) continue } if ( ids.some((id) => deleted.has(id)) || removedLinks.some((r) => r.kind === kind && pairs.some(([f, t]) => r.from === f && r.to === t)) ) { skip('removed by a person') continue } const start = centre(boxes.get(a.id)!) const end = centre(boxes.get(b.id)!) const arrow = w.createShape({ id: ids[0], type: 'arrow', x: start.x, y: start.y, props: { ...ARROW_PROPS, end: { x: end.x - start.x, y: end.y - start.y }, // a citation points from the citing paper; similarity has no direction ...(kind === 'similar' ? { dash: 'dashed', arrowheadEnd: 'none' } : {}), }, // who says they're linked: the citation's source, else PubMed for a citation and the agent for similarity meta: agentMeta(ctx, { kind, label: '', by: kind === 'cites' ? (source ?? 'pubmed') : 'agent', hiddenByHuman: false }), }) for (const [terminal, card] of [['start', a], ['end', b]] as const) { w.createBinding({ id: createBindingId(`${arrow.id.slice('shape:'.length)}-${terminal}`), type: 'arrow', fromId: arrow.id, toId: card.id, props: arrowEnd(terminal), meta: agentMeta(ctx), }) touched.add(card.id) } linked.push(link) } return { linked, skipped, created: touched.size > 0 } }) if (result.created) changed(ctx, 'link_papers', { linked: result.linked }) return { linked: result.linked, skipped: result.skipped } } // ---- mark_working const markWorkingInput = { paperIds: z.array(paperIdInput).min(1).max(50), on: z.boolean() } export function markWorking(ctx: ToolContext, { paperIds, on }: z.infer>) { const result = writeRoom(ctx.boardId, (txn) => { const w = writer(txn) const skipped: { paperId: string; reason: string }[] = [] for (const paperId of paperIds) { const card = findCard(txn, paperId) const reason = card ? propsLocked(card) : 'no card on this board' if (reason || !card) skipped.push({ paperId, reason: reason ?? '' }) else w.patchShape({ id: card.id, props: { working: on }, meta: agentMeta(ctx, { paperId }) }) } return { ok: true, skipped } }) if (result.skipped.length < paperIds.length) changed(ctx, 'mark_working', { paperIds, on }) return result } // ---- add_note const addNoteInput = { text: z.string().min(1).max(1000), paperId: paperIdInput.optional() } const NOTE_PROPS = { color: 'yellow', labelColor: 'black', size: 's', font: 'sans', fontSizeAdjustment: 1, align: 'start', verticalAlign: 'start', url: '', scale: 1, textLastEditedBy: null, } satisfies Omit // A browser sizes a note to its text only when someone edits it there, so estimate the growth // here (size s: about 24 characters a line, 26 px a line) to keep long notes readable. const noteGrowY = (text: string) => Math.max(0, text.split('\n').reduce((lines, p) => lines + Math.max(1, Math.ceil(p.length / 24)), 0) * 26 + 32 - 200) export function addNote(ctx: ToolContext, { text, paperId }: z.infer>) { const result = writeRoom(ctx.boardId, (txn) => { const card = paperId ? needCard(txn, paperId) : undefined const records = [...txn.values()] const growY = noteGrowY(text) const size = { w: 200, h: 200 + growY } let at: Vec if (card) { // beside the card as drawn, on its right, if that's free; else the nearest free spot around it const box = shapeBoxes(records).get(card.id)! const right = { x: Math.round(box.x + box.w + 24), y: Math.round(box.y) } at = isFree(shapeBoxes(records).values(), { ...right, ...size }) ? right : placeNear(records, size, centre(box)) } else { at = placeNear(records, size, anchorFor(records, txn, null, ctx.runId)) } const w = writer(txn) const note = w.createShape({ id: agentShapeId('note') as TLShapeId, type: 'note', ...at, props: { ...NOTE_PROPS, growY, richText: toRichText(text) }, meta: agentMeta(ctx, paperId ? { paperId } : {}), }) if (card) { // the pin's offset is from the card's node, not from the card drawn around it w.createBinding({ type: NOTE_PIN_TYPE, fromId: note.id, toId: card.id, props: { dx: at.x - card.x, dy: at.y - card.y }, meta: agentMeta(ctx, { paperId: paperId! }), }) } return { shapeId: note.id } }) changed(ctx, 'add_note', { shapeId: result.shapeId, paperId: paperId ?? null }) return result } // ---- the tool list the MCP registers export function tool( name: string, description: string, input: S, run: (ctx: ToolContext, input: z.infer>) => unknown ) { return { name, description, input, run: run as (ctx: ToolContext, input: unknown) => unknown } } export const canvasTools = [ tool( 'get_board', 'Read the shared board as compact JSON: the research question and "Looking for" goals; every paper card ' + '(paperId, label, title, year, type, kind seed/kept/suggestion, status new/kept/contested/discarded, ' + 'scores per goal, working, cluster, humanTouched, pinnedNotes); links between papers (from, to, kind, ' + 'label, by, hidden); notes (text, author, pinnedTo); clusters (clusterId, label, labelGenerated, ' + 'humanTouched); discarded paper IDs; and links people removed. ' + 'At most 500 papers (seeds and kept first; `truncated` counts the rest), 1000 links and 200 notes. ' + 'Changes nothing.', {}, (ctx) => getBoard(ctx) ), tool( 'add_papers', 'Add papers to the board as cards, or update the metadata of cards you added before; at most 50 per call. ' + 'Each paper needs paperId (as the papers MCP gives it, e.g. pmid:34290406) and title. Optional: authors ' + '(display names, first author first), year, venue, url (http or https), type (as discovery reports it), ' + 'peerReviewed, citedBy, retracted, foundBy (how it was found, e.g. "pubmed"); null means unknown and ' + 'never overwrites a known value. kind (seed, kept or suggestion; default suggestion) applies to new cards ' + 'only. Never give coordinates: put the most similar paper already on the board in `near` and the card is ' + 'placed beside it without overlapping anything, inside that paper\'s cluster if it is in one (`near` may ' + 'also be a clusterId from get_board); without `near` it goes on a ring around the first card of this ' + 'turn. New cards start with status new and the "Agent is reading…" ring on (turn it off with mark_working). ' + 'Skipped with a reason: retracted papers, seeds already on the board, and cards a person made, moved ' + 'or edited. Returns {added, updated, skipped: [{paperId, reason}]} with paper IDs.', addPapersInput, addPapers ), tool( 'set_scores', 'Set how well one paper fits each of the board\'s "Looking for" goals (`goals` in get_board); the node ' + 'colour and the ranked list follow it. scores maps a goal ID to {score 0 to 1, fit low/medium/high, why: ' + 'the ranked list\'s one line, at most 200 characters}. Goals left out keep their scores. Returns {ok}; ' + '{ok: false, skipped: reason} if a person made or edited the card. Errors if the paper has no card or a ' + 'goal isn\'t one of the board\'s.', setScoresInput, setScores ), tool( 'set_card', 'Fill in what an opened paper shows, any subset of fields per call; the card fills in live, section by ' + 'section. oneLine: "Why it matters to you", one or two sentences (at most 400 characters) for the ' + 'board\'s question. claims: phrases of oneLine to underline (each must appear in it word for word), ' + 'each with evidence: quote IDs from add_quote, or "seeds" when it compares with the board\'s seeds. ' + 'glance: "The paper at a glance", 3 or 4 steps such as Samples, Method, Readout, Finding: {label, short ' + 'text, quoteId of the quote it comes from or null, relation to the seeds: same, adds, other, or null for ' + 'no mark}. figure: a real figure from the paper, never a generated image: {imageUrl (https), label like ' + '"Fig. 3", caption, captionByAi: true if you wrote the caption}, or null to remove it. summary: at most ' + '1500 characters. gaps: what you couldn\'t check, at most 8. Add quotes first: every quote ID you cite ' + 'must already be on the card. Changing oneLine drops claims whose text is no longer in it. Returns {ok, ' + 'written}; {ok: false, skipped: reason} if a person made or edited the card.', setCardInput, setCard ), tool( 'add_quote', 'Add a quote to a paper\'s card: text copied word for word from the paper\'s abstract (whitespace may ' + 'differ), at most 600 characters. It is checked against the abstract from the papers MCP; if it isn\'t ' + 'there, or the papers MCP can\'t be reached, you get an error and nothing is written. Full-text quotes ' + 'aren\'t supported yet. source defaults to "abstract". Returns {quoteId} to cite in set_card\'s claims and ' + 'glance; the same text again returns the same ID. {quoteId: null, skipped: reason} if a person made or ' + 'edited the card.', addQuoteInput, addQuote ), tool( 'link_papers', 'Link pairs of papers on the board with an arrow; at most 100 links per call, and both papers need cards. ' + 'kind: cites (a citation: `from` cites `to`) or similar (your judgement that the two are closely ' + 'related; direction doesn\'t matter). source (optional): where a citation comes from, pubmed (the ' + 'default for cites) or openalex; the arrow records it, and records every similar link as the agent\'s. ' + 'Linking a pair again is a no-op. Skipped with a reason: a paper linked to itself, a missing ' + 'card, a similar link between papers a citation already joins, and links a person removed or hid; ' + 'never try to recreate those. Links can\'t be labelled or removed through this server. Returns ' + '{linked, skipped}.', linkPapersInput, linkPapers ), tool( 'mark_working', 'Turn the "Agent is reading…" ring on papers\' nodes on (on: true) when you start reading them and off ' + '(on: false) when their cards are filled in; at most 50 paper IDs per call. Returns {ok, skipped} with ' + 'cards that are missing or that a person made or edited.', markWorkingInput, markWorking ), tool( 'add_note', 'Add a sticky note to the board, written as the agent: a few sentences, at most 1000 characters. With ' + 'paperId it is pinned beside that paper\'s card, moves with it and shows in the paper\'s notes; ' + 'without, it goes near the first card of this turn. Returns {shapeId}. Notes can\'t be edited or ' + 'removed through this server.', addNoteInput, addNote ), ]