pinned / server /canvasTools.ts
igorktech's picture
Pinned demo: two recorded boards, agent off
6f91ce5 verified
Raw History Blame Contribute Delete
36.3 kB
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<string> {
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<string, TLRecord>(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<string, string | null>()
const pinCount = new Map<string, number>()
const ends = new Map<string, { start?: string | null; end?: string | null }>()
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-<kind>-<from>--<to>` back to paper IDs; slugs may contain "--", so try each split
function parseLinkId(id: string, bySlug: Map<string, string>): 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<typeof paperInput>): Partial<PaperCardProps> {
const f: Partial<PaperCardProps> = { 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<string>([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<z.ZodObject<typeof addPapersInput>>) {
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<z.ZodObject<typeof setScoresInput>>) {
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<z.ZodObject<typeof setCardInput>>) {
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<PaperCardProps> = {}
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<z.ZodObject<typeof addQuoteInput>>) {
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<z.ZodObject<typeof linkPapersInput>>) {
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<string>()
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<z.ZodObject<typeof markWorkingInput>>) {
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<TLNoteShape['props'], 'richText' | 'growY'>
// 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<z.ZodObject<typeof addNoteInput>>) {
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<S extends z.ZodRawShape>(
name: string,
description: string,
input: S,
run: (ctx: ToolContext, input: z.infer<z.ZodObject<S>>) => 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
),
]