cowrite-dev / server /collab.js
lvwerra's picture
lvwerra HF Staff
Close four holes in the share link, from review
830cb4d
Raw
History Blame Contribute Delete
67.9 kB
import { Hocuspocus } from '@hocuspocus/server'
import * as Y from 'yjs'
import { yDocToProsemirrorJSON } from 'y-prosemirror'
import { newId, b64encode, b64decode } from './util.js'
import * as store from './store.js'
import { pmToMarkdown, blockToMarkdown, markdownToBlocks } from './md.js'
import { sessionFromCookieHeader, userFromToken, BUILD_ID , SHARE_COOKIE } from './auth.js'
import { DOC_NAME_RE, projectIdOf, pageSlugOf, docNameFor, slugify, PAGE_SLUG_RE, parseCookies } from './util.js'
import { defaultStructureYaml, clearStructureCache, primeStructureCache, moveNodesInYaml } from './pages.js'
const FIELD = 'default'
const UNIFIED_PAGES_VERSION = 1
const CLAIM_TIMEOUT_MS = 5 * 60 * 1000
const MAX_ATTEMPTS = 3
// --- mention queue state (persisted via store) ---
const mentionState = store.getMentionState()
const waiters = [] // { handles:Set, resolve, timer, done }
const agentLastPoll = new Map() // handle -> ts
// Keyed by the document INSTANCE, not by name: two instances of the same
// project must never be able to unhook each other's observer (see attachWatcher).
const docWatchers = new Map() // document -> { un }
function pageField(docName) {
const slug = pageSlugOf(docName)
return slug === 'home' ? FIELD : `page:${slug}`
}
function threadsMap(document, docName) {
const slug = pageSlugOf(docName)
return document.getMap(slug === 'home' ? 'threads' : `threads:${slug}`)
}
function suggestionsMap(document, docName) {
const slug = pageSlugOf(docName)
return document.getMap(slug === 'home' ? 'suggestions' : `suggestions:${slug}`)
}
function projectPageNames(projectId) {
return [...new Set(['home', '_structure', ...Object.keys(store.pagesOf(projectId) || {})])].map(slug => docNameFor(projectId, slug))
}
function cloneYValue(value) {
if (value instanceof Y.XmlText) {
const out = new Y.XmlText()
out.applyDelta(value.toDelta())
return out
}
if (value instanceof Y.XmlElement) {
const out = new Y.XmlElement(value.nodeName)
for (const [key, val] of Object.entries(value.getAttributes())) out.setAttribute(key, val)
if (value.length) out.insert(0, value.toArray().map(cloneYValue))
return out
}
if (value instanceof Y.Array) {
const out = new Y.Array()
if (value.length) out.insert(0, value.toArray().map(cloneYValue))
return out
}
if (value instanceof Y.Map) {
const out = new Y.Map()
value.forEach((val, key) => out.set(key, cloneYValue(val)))
return out
}
return value == null || typeof value !== 'object' ? value : structuredClone(value)
}
function importLegacyPages(projectId, document) {
const projectMeta = document.getMap('projectMeta')
if ((projectMeta.get('unifiedPagesVersion') || 0) >= UNIFIED_PAGES_VERSION) return
document.transact(() => {
for (const name of projectPageNames(projectId)) {
const slug = pageSlugOf(name)
if (slug === 'home') continue
const state = store.loadLegacyPageState(projectId, slug)
if (!state) continue
const legacy = new Y.Doc()
try {
Y.applyUpdate(legacy, state)
const source = legacy.getXmlFragment(FIELD)
const target = document.getXmlFragment(pageField(name))
if (!target.length && source.length) target.insert(0, source.toArray().map(cloneYValue))
for (const base of ['threads', 'suggestions']) {
const sourceMap = legacy.getMap(base)
const targetMap = base === 'threads' ? threadsMap(document, name) : suggestionsMap(document, name)
if (!targetMap.size) sourceMap.forEach((value, key) => targetMap.set(key, cloneYValue(value)))
}
if (slug === '_structure') {
const sourceMap = legacy.getMap('pageProposals')
const targetMap = document.getMap('pageProposals')
sourceMap.forEach((value, key) => {
if (!targetMap.has(key)) targetMap.set(key, cloneYValue(value))
})
}
} finally {
legacy.destroy()
}
}
projectMeta.set('unifiedPagesVersion', UNIFIED_PAGES_VERSION)
})
}
function persistMentions() {
store.saveMentionState(mentionState)
}
// --- hocuspocus server ---
async function authenticate({ requestHeaders, token, documentName, connectionConfig }) {
if (documentName && !DOC_NAME_RE.test(documentName)) throw new Error('bad document name')
// Pages stopped being their own documents when they became fields of the
// project document. A page-scoped name would sync a second, isolated replica
// of the same project: edits made in it reach nobody. Fail loudly instead.
if (documentName && documentName !== projectIdOf(documentName)) {
throw new Error(`sync the project document ("${projectIdOf(documentName)}"), not a page — pages are fields of it`)
}
let user = null
if (token && token.startsWith('cookie')) {
// browser clients carry their bundle's build id ("cookie:<id>") — a stale
// tab holding a diverged replica must NOT be allowed to sync: it re-inserts
// long-deleted content into everyone's doc
const clientBuild = token.split(':')[1] || null
if (BUILD_ID !== 'dev' && clientBuild !== BUILD_ID) {
throw new Error('outdated client — reload the page to get the current version')
}
} else if (token) {
if (token.startsWith('ak_')) {
const agent = store.agentByKey(token)
if (agent) user = { username: agent.owner, name: agent.owner, avatar: null }
} else {
user = await userFromToken(token)
}
}
const cookie = requestHeaders?.cookie || requestHeaders?.get?.('cookie') || ''
if (!user) user = sessionFromCookieHeader(cookie)
const access = user && documentName ? store.canAccessDoc(documentName, user.username) : false
// A public link opens this ONE project, and only to read it. This is checked
// for signed-out AND signed-in visitors: being logged in to your own account
// is not a reason for someone else's link to stop working, and the REST side
// already lets that request through — the two must not disagree, or the page
// loads and then never syncs.
//
// readOnly is set on the connection itself, so the server drops any update or
// awareness message the client sends: the editor being non-editable in the UI
// is a courtesy, this is the rule.
if (!access && documentName) {
const shareToken = parseCookies(cookie)[SHARE_COOKIE]
const sharedDoc = shareToken ? store.docByShareToken(shareToken) : null
if (sharedDoc && projectIdOf(documentName) === sharedDoc) {
if (connectionConfig) connectionConfig.readOnly = true
return { user: { username: user?.username || null, name: user?.name || 'Viewer', avatar: user?.avatar || null, viewer: true } }
}
}
if (!user) throw new Error('unauthorized')
if (documentName && !access) throw new Error('no access to this document')
return { user }
}
export const hocuspocus = new Hocuspocus({
debounce: 2000,
maxDebounce: 10000,
async onAuthenticate(data) {
return authenticate(data)
},
async onLoadDocument({ documentName, document }) {
// A project's pages are fields of ONE document, so its state is stored under
// the project id. Loading by the raw name would resurrect the pre-migration
// per-page row — an instance frozen at whatever that page looked like before
// the migration, missing every page added since.
const state = store.loadDocState(projectIdOf(documentName))
if (state) Y.applyUpdate(document, state)
importLegacyPages(projectIdOf(documentName), document)
return document
},
async afterLoadDocument({ documentName, document }) {
const projectId = projectIdOf(documentName)
attachWatcher(projectId, document)
// catch up on anything added while the server was down
setTimeout(() => scanProjectForMentions(projectId, document), 0)
// one-time upgrade of pre-v2 suggestions to item-based anchors so their
// ranges stop drifting when neighbouring blocks are added. Must operate on
// the ALREADY-LOADED document — opening a direct connection here would
// re-load the doc after unload and loop forever.
if (!migratedAnchorDocs.has(projectId)) {
migratedAnchorDocs.add(projectId)
setTimeout(() => {
for (const name of projectPageNames(projectId)) {
try {
migrateLegacySuggestionAnchors(document, name)
} catch {}
try {
migrateCheckboxLists(document, name)
} catch {}
// structure pages are just the yaml field now — drop old heading/intro
try {
if (pageSlugOf(name) === '_structure' && !document.isDestroyed) {
const fragment = document.getXmlFragment(pageField(name))
let hasCode = false
for (let i = 0; i < fragment.length; i++) {
if (fragment.get(i) instanceof Y.XmlElement && fragment.get(i).nodeName === 'codeBlock') hasCode = true
}
if (hasCode) {
document.transact(() => {
for (let i = fragment.length - 1; i >= 0; i--) {
const el2 = fragment.get(i)
if (!(el2 instanceof Y.XmlElement) || el2.nodeName !== 'codeBlock') fragment.delete(i, 1)
}
})
}
}
} catch {}
}
}, 50)
}
},
async afterUnloadDocument({ documentName }) {
releaseWatcher(documentName, { onlyIfDestroyed: true })
},
async onStoreDocument({ documentName, document }) {
const projectId = projectIdOf(documentName)
// Only the canonical instance owns the project's row. A replica loaded under
// a page-scoped name holds a partial copy — letting it write would drop
// whatever the real instance has that the replica never saw.
if (documentName !== projectId) return
let homeMarkdown = null
for (const name of projectPageNames(projectId)) {
try {
const slug = pageSlugOf(name)
const pm = yDocToProsemirrorJSON(document, pageField(name))
if (slug === 'home') homeMarkdown = pmToMarkdown(pm)
// the subtitle only describes the project, so only home has one
store.touchProjectOnStore(projectId, slug, titleFromPm(pm), slug === 'home' ? subtitleFromPm(pm) : null)
} catch {}
}
store.saveDocState(projectId, Y.encodeStateAsUpdate(document), homeMarkdown)
},
})
// The plain text of a ProseMirror block, with the markup that markdown adds
// around it taken back off — what a title or a subtitle reads as, not how it is
// written. (blockPlainText below is the same idea for *parsed markdown* blocks.)
function pmBlockText(node) {
return (
blockToMarkdown(node)
?.replace(/^#+\s*/, '')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> their alt text, if any
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> their text
.replace(/[*_`]/g, '')
.trim() || ''
)
}
function titleFromPm(pm) {
for (const node of pm.content || []) {
const text = pmBlockText(node)
if (text) return text.slice(0, 80)
}
return null
}
// The project's one-line description: the first ordinary paragraph under the
// title, the way the page itself opens. Derived like the title — read-only, on
// store, never written back into the document. Section headings, code, lists and
// tables are not prose, so they are passed over rather than ending the search.
const SUBTITLE_MAX = 160
function subtitleFromPm(pm) {
let pastTitle = false
for (const node of pm.content || []) {
const text = pmBlockText(node)
if (!text) continue
if (!pastTitle) {
pastTitle = true // whatever it was, that block is the title
continue
}
if (node.type !== 'paragraph') continue
return text.slice(0, SUBTITLE_MAX).trim()
}
return null
}
// --- document watcher: detect new @mentions in comment threads ---
// Watch the LIVE document instance, not just the name. A document can be
// unloaded and re-loaded (every direct connection that disconnects can trigger
// it), and afterUnloadDocument for the old instance may fire *after*
// afterLoadDocument for the new one. Keying only by name meant the new instance
// could end up with no observer at all — and then @mentions in that document
// were silently never detected for the rest of the process's life.
//
// Hence the key is the instance itself. Keying by *project id* had the same
// silent-death failure through another door: a client connecting under a
// page-scoped name (`<id>::<slug>`) loads a SECOND instance of the same
// project, and attaching its watcher took the observer off the instance every
// browser is actually editing. One such connection and the project stopped
// noticing @mentions until the process restarted.
function attachWatcher(docName, document) {
if (docWatchers.has(document)) return
const projectId = projectIdOf(docName)
const observer = transaction => {
const changedPages = new Set()
for (const [name, type] of document.share.entries()) {
if (!transaction.changedParentTypes.has(type)) continue
if (name === 'threads') changedPages.add('home')
else if (name.startsWith('threads:')) changedPages.add(name.slice('threads:'.length))
}
for (const slug of changedPages) {
setTimeout(() => scanDocForMentions(docNameFor(projectId, slug), document), 0)
}
}
document.on('afterTransaction', observer)
docWatchers.set(document, { projectId, un: () => document.off('afterTransaction', observer) })
}
// Drop this project's watchers. afterUnloadDocument only tells us the name, and
// an unloaded instance is always destroy()ed first — so with onlyIfDestroyed a
// live replacement (or a same-project sibling instance) keeps its observer.
function releaseWatcher(docName, { onlyIfDestroyed = false } = {}) {
const projectId = projectIdOf(docName)
for (const [document, entry] of docWatchers) {
if (entry.projectId !== projectId) continue
if (onlyIfDestroyed && !document.isDestroyed) continue
try {
entry.un()
} catch {}
docWatchers.delete(document)
}
}
function scanProjectForMentions(projectId, document) {
for (const docName of projectPageNames(projectId)) scanDocForMentions(docName, document)
}
function scanDocForMentions(docName, document) {
if (document.isDestroyed) return
const threads = threadsMap(document, docName)
const agents = store.getAgents()
const created = []
const explicitHandles = text =>
[...new Set([...String(text || '').matchAll(/@([a-z0-9][a-z0-9_.-]{1,38})/g)].map(m => m[1]))].filter(h => agents[h])
threads.forEach((ythread, threadId) => {
const messages = ythread.get('messages')
if (!messages) return
const all = messages.toArray()
all.forEach((msg, msgIdx) => {
if (!msg?.id || msg.authorType === 'agent') return
const key = `${docName}:${threadId}:${msg.id}`
if (mentionState.processed[key]) return
mentionState.processed[key] = true
// an agent only receives work for docs its owner can access, and an
// UNSHARED agent only answers mentions written by its owner. Sharing is
// opt-in: a handle with no flag stored has never been shared.
const usable = h =>
store.canAccessDoc(docName, agents[h]?.owner) && (agents[h]?.shared === true || agents[h]?.owner === msg.author)
const explicit = explicitHandles(msg.text)
let handles = explicit.filter(usable)
let implicit = false
// implicit routing only when the author tagged nobody — an explicit
// mention of an unreachable agent must not be redirected to another one
if (!explicit.length) {
// No explicit tag: deliver to the ONE agent clearly involved in this
// thread (it authored the suggestion under discussion, replied earlier,
// or was mentioned earlier). Ambiguous (0 or 2+ candidates) => nobody.
const candidates = new Set()
const suggestionId = ythread.get('suggestionId')
if (suggestionId) {
const sugg = suggestionsMap(document, docName).get(suggestionId)
if (sugg?.authorType === 'agent' && agents[sugg.author]) candidates.add(sugg.author)
}
for (const prev of all.slice(0, msgIdx)) {
if (prev.authorType === 'agent' && agents[prev.author]) candidates.add(prev.author)
for (const h of explicitHandles(prev.text)) candidates.add(h)
}
if (candidates.size === 1) {
handles = [...candidates].filter(usable)
implicit = true
}
}
const chips = []
for (const handle of handles) {
const task = {
id: newId(12),
docId: docName,
threadId,
messageId: msg.id,
handle,
requestedBy: msg.author,
text: msg.text,
implicit,
status: 'pending',
attempts: 0,
ts: Date.now(),
}
mentionState.tasks[task.id] = task
created.push(task)
chips.push({ handle, mentionId: task.id, status: 'pending' })
}
if (chips.length) {
setTimeout(() => writeChips(docName, threadId, msg.id, chips), 0)
}
})
})
if (created.length) {
persistMentions()
wakeWaiters()
}
}
// The chip is the author's only sign that the mention was picked up, and the
// message is marked processed before it is written — so a write that loses the
// race with the client's own thread transaction must be retried, not dropped.
async function writeChips(docName, threadId, messageId, chips, attempt = 0) {
const ok = await updateMessage(docName, threadId, messageId, { mentions: chips }).catch(() => false)
if (ok || attempt >= 5) return
setTimeout(() => writeChips(docName, threadId, messageId, chips, attempt + 1), 150 * (attempt + 1))
}
// --- privileged Y.Doc writes ---
// Server-side writes/reads open a direct connection. Disconnecting one defaults
// to unloading the document *immediately* — and since every API call does this,
// it kept racing with browsers connecting to the same document: hocuspocus could
// drop the instance a client had just been attached to, leaving that tab talking
// to a Y.Doc the server no longer knows about. From then on server writes (agent
// replies, suggestions, mention chips) landed in a fresh instance and never
// reached the tab. `unloadImmediately: false` defers the unload to the store
// debounce, which re-checks the connection count, so an arriving client wins.
const KEEP_LOADED = { unloadImmediately: false }
async function withDoc(docName, fn) {
const conn = await hocuspocus.openDirectConnection(projectIdOf(docName), { user: { username: '__server__' } })
try {
let result
await conn.transact(document => {
result = fn(document)
})
return result
} finally {
await conn.disconnect(KEEP_LOADED)
}
}
async function readDoc(docName, fn) {
const conn = await hocuspocus.openDirectConnection(projectIdOf(docName), { user: { username: '__server__' } })
try {
return fn(conn.document)
} finally {
await conn.disconnect(KEEP_LOADED)
}
}
export async function updateMessage(docName, threadId, messageId, patch) {
return withDoc(docName, document => {
const ythread = threadsMap(document, docName).get(threadId)
const messages = ythread?.get('messages')
if (!messages) return false
const idx = messages.toArray().findIndex(m => m?.id === messageId)
if (idx === -1) return false
const updated = { ...messages.get(idx), ...patch }
messages.delete(idx, 1)
messages.insert(idx, [updated])
return true
})
}
export async function addMessage(docName, threadId, { author, authorType, text }) {
const msg = { id: newId(10), author, authorType, text, ts: Date.now() }
const ok = await withDoc(docName, document => {
const ythread = threadsMap(document, docName).get(threadId)
const messages = ythread?.get('messages')
if (!messages) return false
messages.push([msg])
return true
})
return ok ? msg : null
}
// Open a new comment thread. Anchored (block_index, or an anchor pair from GET
// /api/docs/:id blocks) marks the text it is about; with neither it is a
// page-level comment — no highlight, its card stacks after the anchored ones.
// Browsers write threads straight into the Yjs map; this is the same shape, so
// an agent can leave several small comments instead of one long reply.
export async function createThread(docName, { anchorStart, anchorEnd, blockIndex, excerpt, text, author, authorType, originThreadId }) {
return withDoc(docName, document => {
if (originThreadId && !threadsMap(document, docName).has(originThreadId)) {
return { error: 'origin_thread_id does not name a comment on this page' }
}
const fragment = document.getXmlFragment(pageField(docName))
if (blockIndex != null && (anchorStart == null || anchorEnd == null)) {
if (!fragment.length) return { error: 'this page has no blocks yet — post it without an anchor' }
const i = Math.max(0, Math.min(Number(blockIndex), fragment.length - 1))
anchorStart = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i)))
anchorEnd = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i + 1)))
}
let anchored = false
if (anchorStart != null && anchorEnd != null) {
const range = resolveBlockRange(document, fragment, anchorStart, anchorEnd)
if (!range) return { error: 'anchor could not be resolved against the current document' }
anchored = true
if (!excerpt) {
// the card shows the text it is about, exactly as a browser-made comment does
const pm = yDocToProsemirrorJSON(document, pageField(docName))
const target = (pm.content || []).slice(range.from, range.to).map(pmBlockText).filter(Boolean).join(' ')
excerpt = target.slice(0, 140) || null
}
}
const message = { id: newId(10), author, authorType, text: String(text).slice(0, 8000), ts: Date.now() }
const messages = new Y.Array()
messages.push([message])
const id = newId(10)
const ythread = new Y.Map()
ythread.set('id', id)
if (anchored) {
ythread.set('anchorStart', anchorStart)
ythread.set('anchorEnd', anchorEnd)
}
ythread.set('excerpt', excerpt || null)
ythread.set('createdBy', author)
ythread.set('createdAt', Date.now())
ythread.set('resolved', false)
if (originThreadId) ythread.set('originThreadId', originThreadId)
ythread.set('messages', messages)
threadsMap(document, docName).set(id, ythread)
return { threadId: id, messageId: message.id, anchored }
})
}
export async function setMentionChip(task, status) {
try {
const ythreadOk = await readDoc(task.docId, document => {
const ythread = threadsMap(document, task.docId).get(task.threadId)
const messages = ythread?.get('messages')
if (!messages) return false
return messages.toArray().some(m => m?.id === task.messageId)
})
if (!ythreadOk) return
await withDoc(task.docId, document => {
const messages = threadsMap(document, task.docId).get(task.threadId).get('messages')
const idx = messages.toArray().findIndex(m => m?.id === task.messageId)
if (idx === -1) return
const msg = { ...messages.get(idx) }
msg.mentions = (msg.mentions || []).map(c => (c.mentionId === task.id ? { ...c, status } : c))
messages.delete(idx, 1)
messages.insert(idx, [msg])
})
} catch {}
}
function setTaskStatus(task, status, extra = {}) {
Object.assign(task, { status, ...extra })
persistMentions()
setTimeout(() => setMentionChip(task, status), 0)
}
// --- mention polling (agents) ---
export function handlesOwnedBy(username) {
const agents = store.getAgents()
return Object.keys(agents).filter(h => agents[h].owner === username)
}
function claimable(handles) {
return Object.values(mentionState.tasks).filter(t => t.status === 'pending' && handles.includes(t.handle))
}
async function claimAndEnrich(tasks) {
const out = []
for (const task of tasks) {
setTaskStatus(task, 'claimed', { claimedAt: Date.now(), attempts: (task.attempts || 0) + 1 })
let context = null
try {
context = await readDoc(task.docId, document => {
const ythread = threadsMap(document, task.docId).get(task.threadId)
const suggestionId = ythread?.get('suggestionId') || null
const sugg = suggestionId ? suggestionsMap(document, task.docId).get(suggestionId) : null
return {
excerpt: ythread?.get('excerpt') || null,
anchor_kind: ythread?.get('anchorKind') === 'position' ? 'position' : 'span',
anchor_context: ythread?.get('anchorContext') || null,
origin_thread_id: ythread?.get('originThreadId') || sugg?.originThreadId || sugg?.threadId || null,
thread_messages: ythread?.get('messages')?.toArray() || [],
suggestion: sugg
? {
suggestion_id: sugg.id,
author: sugg.author,
status: sugg.status,
original_markdown: sugg.originalMarkdown,
replacement_markdown: sugg.replacementMarkdown,
rationale: sugg.rationale,
}
: null,
}
})
} catch {}
const registry = store.getRegistry()
const projectId = projectIdOf(task.docId)
const page = pageSlugOf(task.docId)
out.push({
mention_id: task.id,
doc_id: projectId,
page,
doc_title: registry[projectId]?.title || projectId,
page_title: page === 'home' ? null : store.pagesOf(projectId)?.[page]?.title || page,
thread_id: task.threadId,
// A request in a derived comment keeps pointing at the root request, so
// any further comments/suggestions join the same visual family.
origin_thread_id: context?.origin_thread_id || task.threadId,
handle: task.handle,
requested_by: task.requestedBy,
instruction: task.text,
// A position-anchored comment has no attached span, so anchored_text
// carries the paragraph it was left beside; anchor_kind says which of the
// two an agent is reading, so it does not answer as though the words were
// the subject.
anchored_text: context?.excerpt || context?.anchor_context || null,
anchor_kind: context?.anchor_kind || 'span',
thread_messages: (context?.thread_messages || []).map(m => ({ author: m.author, authorType: m.authorType, text: m.text })),
about_suggestion: context?.suggestion || null,
implicit_follow_up: !!task.implicit,
created_at: task.ts,
})
}
return out
}
function pollableHandles(username, onlyHandle, { includeManaged = false } = {}) {
let handles = handlesOwnedBy(username)
if (onlyHandle) {
// agent-key poll: exactly this handle
handles = handles.filter(h => h === onlyHandle)
} else {
// HF-token poll: only legacy handles without a dedicated key — once a key
// exists, possession of the key IS the agent, and owner-level polls must
// not steal its work
const agents = store.getAgents()
handles = handles.filter(h => !agents[h]?.keyHash)
}
if (!includeManaged) {
const agents = store.getAgents()
handles = handles.filter(h => !agents[h]?.managed)
}
return handles
}
const NO_HANDLES_ERROR = 'no agent handles registered for your account (handles with a key must poll with that key) — POST /api/agents first'
// Instant, non-claiming view of outstanding work — for debugging. Receiving
// (and claiming) work happens through the streaming poll.
export function snapshotMentions(username, onlyHandle = null) {
const handles = pollableHandles(username, onlyHandle)
if (!handles.length) return { error: NO_HANDLES_ERROR }
const mentions = Object.values(mentionState.tasks)
.filter(t => handles.includes(t.handle) && (t.status === 'pending' || t.status === 'claimed'))
.map(t => ({ mention_id: t.id, doc_id: t.docId, thread_id: t.threadId, handle: t.handle, status: t.status, instruction: t.text, created_at: t.ts }))
return { snapshot: true, mentions }
}
export function pollMentions(username, waitSeconds, onlyHandle = null, opts = {}) {
const handles = pollableHandles(username, onlyHandle, opts)
if (!handles.length) return Promise.resolve({ error: NO_HANDLES_ERROR })
for (const h of handles) agentLastPoll.set(h, Date.now())
const now = claimable(handles)
if (now.length || !waitSeconds) return claimAndEnrich(now).then(mentions => ({ mentions }))
const { maxWait = 55, registerCancel = null } = opts
return new Promise(resolve => {
const waiter = {
handles: new Set(handles),
done: false,
fire: async tasks => {
if (waiter.fired) return
waiter.fired = true
clearTimeout(waiter.timer)
const idx = waiters.indexOf(waiter)
if (idx !== -1) waiters.splice(idx, 1)
for (const h of handles) agentLastPoll.set(h, Date.now())
resolve({ mentions: await claimAndEnrich(tasks) })
},
}
waiter.timer = setTimeout(() => waiter.fire([]), Math.min(waitSeconds, maxWait) * 1000)
waiters.push(waiter)
// let the caller cancel when the client disconnects, so a parked waiter
// can't claim mentions into a dead socket
if (registerCancel) {
registerCancel(() => {
if (waiter.fired) return
waiter.fired = true
clearTimeout(waiter.timer)
const idx = waiters.indexOf(waiter)
if (idx !== -1) waiters.splice(idx, 1)
resolve({ mentions: [], cancelled: true })
})
}
})
}
function wakeWaiters() {
for (const waiter of [...waiters]) {
if (waiter.fired) continue
const tasks = claimable([...waiter.handles])
if (tasks.length) waiter.fire(tasks)
}
}
// Revoking a link has to reach the sockets already open on it. A viewer whose
// connection was authenticated before the token changed stays attached and keeps
// receiving edits otherwise — "revoked" would mean "no new visitors", which is
// not what the button says.
export function closeShareViewers(projectId) {
const document = hocuspocus.documents.get(projectId)
if (!document) return 0
let closed = 0
for (const connection of document.getConnections()) {
// readOnly + viewer is only ever a share-link connection in this app
if (!connection.readOnly || !connection.context?.user?.viewer) continue
connection.close({ reason: 'this share link was revoked' })
closed++
}
return closed
}
export function agentPresence() {
const agents = store.getAgents()
return Object.entries(agents).map(([handle, info]) => {
const hasWaiter = waiters.some(w => !w.fired && w.handles.has(handle))
const last = agentLastPoll.get(handle) || 0
return {
handle,
owner: info.owner,
shared: info.shared === true,
managed: info.managed || null,
model: info.model || null,
online: hasWaiter || Date.now() - last < 70 * 1000,
last_seen: last || null,
}
})
}
export function dismissMention(mentionId, username) {
const task = mentionState.tasks[mentionId]
if (!task) return { error: 'mention not found' }
const agents = store.getAgents()
if (agents[task.handle]?.owner !== username) return { error: 'not your mention' }
if (task.status !== 'pending' && task.status !== 'claimed') return { error: `mention already ${task.status}` }
setTaskStatus(task, 'done', { doneAt: Date.now(), dismissed: true })
return { ok: true }
}
// When an agent handle is removed, fail its outstanding work so chips don't hang
export function cancelAgentTasks(handle) {
for (const task of Object.values(mentionState.tasks)) {
if (task.handle === handle && (task.status === 'pending' || task.status === 'claimed')) {
setTaskStatus(task, 'failed')
}
}
}
export function completeMention(mentionId, { docId, threadId, handle } = {}) {
let completed = 0
for (const task of Object.values(mentionState.tasks)) {
const explicit = mentionId && task.id === mentionId
const implicit = !mentionId && docId && task.docId === docId && task.threadId === threadId && task.handle === handle
if ((explicit || implicit) && (task.status === 'pending' || task.status === 'claimed')) {
setTaskStatus(task, 'done', { doneAt: Date.now() })
completed++
}
}
return completed
}
// requeue stale claims, fail after MAX_ATTEMPTS
setInterval(() => {
let changed = false
for (const task of Object.values(mentionState.tasks)) {
if (task.status !== 'claimed' || Date.now() - (task.claimedAt || 0) < CLAIM_TIMEOUT_MS) continue
changed = true
if ((task.attempts || 0) >= MAX_ATTEMPTS) setTaskStatus(task, 'failed')
else setTaskStatus(task, 'pending')
}
if (changed) wakeWaiters()
}, 30 * 1000).unref()
// --- doc reading for agents ---
export async function getDocSnapshot(docName) {
return readDoc(docName, document => {
const pm = yDocToProsemirrorJSON(document, pageField(docName))
const fragment = document.getXmlFragment(pageField(docName))
const blocks = (pm.content || []).map((node, i) => ({
index: i,
anchor_start: b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i))),
anchor_end: b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i + 1))),
markdown: blockToMarkdown(node) ?? '',
}))
const threads = []
threadsMap(document, docName).forEach((ythread, id) => {
threads.push({
thread_id: id,
origin_thread_id: ythread.get('originThreadId') || null,
excerpt: ythread.get('excerpt') || null,
// A position-anchored comment sits BESIDE a paragraph and quotes nothing,
// so excerpt is null and anchor_context carries the paragraph's opening
// words instead. anchor_kind is what tells an agent which it is looking
// at — without it, a null excerpt is indistinguishable from a comment
// whose text was deleted.
anchor_kind: ythread.get('anchorKind') === 'position' ? 'position' : 'span',
anchor_context: ythread.get('anchorContext') || null,
resolved: !!ythread.get('resolved'),
messages: (ythread.get('messages')?.toArray() || []).map(m => ({ author: m.author, authorType: m.authorType, text: m.text })),
})
})
const suggestions = []
suggestionsMap(document, docName).forEach(s => suggestions.push(s))
return {
markdown: pmToMarkdown(pm),
blocks,
threads,
suggestions: suggestions.map(s => ({
id: s.id,
author: s.author,
status: s.status,
rationale: s.rationale,
origin_thread_id: s.originThreadId || s.threadId || null,
})),
}
})
}
// --- suggestions ---
export async function createSuggestion(docName, { anchorStart, anchorEnd, blockIndex, replacementMarkdown, rationale, author, authorType, threadId, originThreadId, supersedes }) {
return withDoc(docName, document => {
if (originThreadId && !threadsMap(document, docName).has(originThreadId)) {
return { error: 'origin_thread_id does not name a comment on this page' }
}
// supersedes only LINKS a revision to the original (and reuses its anchors);
// both stay open side by side — a human decides which, if any, to accept
let revises = null
let inheritedOrigin = null
if (supersedes) {
const old = suggestionsMap(document, docName).get(supersedes)
if (old && old.author === author) {
revises = supersedes
inheritedOrigin = old.originThreadId || old.threadId || null
if (anchorStart == null && blockIndex == null) {
anchorStart = old.anchorStart
anchorEnd = old.anchorEnd
}
}
}
const fragment = document.getXmlFragment(pageField(docName))
if (blockIndex != null && (anchorStart == null || anchorEnd == null)) {
const i = Math.max(0, Math.min(Number(blockIndex), fragment.length - 1))
anchorStart = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i)))
anchorEnd = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i + 1)))
}
const range = resolveBlockRange(document, fragment, anchorStart, anchorEnd)
if (!range) return { error: 'anchor could not be resolved against the current document' }
// A comment thread represents one requested edit. If its agent posts a
// fuller draft against the same target, update the existing proposal
// instead of leaving two independently acceptable copies behind. Keep
// unthreaded proposals independent: the playground and human review flow
// deliberately support competing alternatives there.
let existing = null
if (threadId) {
suggestionsMap(document, docName).forEach(s => {
if (existing || s.author !== author || s.threadId !== threadId || !['open', 'superseded'].includes(s.status)) return
const other = resolveBlockRange(document, fragment, s.anchorStart, s.anchorEnd, s.anchorEndIncl || null)
if (other && range.from < other.to && other.from < range.to) existing = s
})
}
if (existing) {
const updated = {
...existing,
replacementMarkdown: String(replacementMarkdown ?? ''),
rationale: rationale ? String(rationale) : null,
revises: revises || existing.revises || null,
updatedAt: Date.now(),
}
suggestionsMap(document, docName).set(existing.id, updated)
return { suggestion: updated, updated: true }
}
const pm = yDocToProsemirrorJSON(document, pageField(docName))
const original = (pm.content || []).slice(range.from, range.to).map(blockToMarkdown).join('\n\n')
const suggestion = {
id: newId(10),
anchorStart,
anchorEnd,
author,
authorType,
replacementMarkdown: String(replacementMarkdown ?? ''),
rationale: rationale ? String(rationale) : null,
originalMarkdown: original,
status: 'open',
revises,
threadId: threadId || null,
originThreadId: originThreadId || inheritedOrigin || threadId || null,
ts: Date.now(),
}
// re-anchor to the target block items themselves (see resolveBlockRange)
suggestion.anchorStart = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, range.from)))
suggestion.anchorEndIncl = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, Math.max(range.from, range.to - 1))))
// forward anchor: tracks the first stable block AFTER the range (or the
// end-of-doc sentinel), so phantom insertion panels get pushed down by
// content typed at the boundary
const afterIdx = stableAfterIndex(pm, range.to)
suggestion.anchorAfter = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, afterIdx ?? fragment.length)))
// At end-of-document, later appends are legitimate neighbours rather than
// evidence that the target expanded, so there is no stable gap to enforce.
suggestion.anchorAfterGap = afterIdx == null ? null : afterIdx - range.to
suggestionsMap(document, docName).set(suggestion.id, suggestion)
return { suggestion }
})
}
// First content-bearing block at or after `from` — empty paragraphs are skipped
// because y-prosemirror reuses them as shells when users type at boundaries,
// which would drag an anchor onto the newly typed text.
function stableAfterIndex(pm, from) {
const content = pm.content || []
for (let i = from; i < content.length; i++) {
const n = content[i]
const empty = n.type === 'paragraph' && !(n.content || []).some(c => (c.text || '').trim())
if (!empty) return i
}
return null
}
// anchorEndIncl (when present) references the LAST target block itself, so
// blocks inserted right after the suggestion stay outside the range. The
// legacy exclusive anchorEnd referenced the NEXT block (or the end-of-doc
// sentinel), which swallowed newly typed neighbours into the suggestion.
function resolveBlockRange(document, fragment, anchorStart, anchorEnd, anchorEndIncl = null) {
try {
const absStart = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(b64decode(anchorStart)), document)
const absEndRel = anchorEndIncl || anchorEnd
const absEnd = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(b64decode(absEndRel)), document)
if (!absStart || !absEnd || absStart.type !== fragment || absEnd.type !== fragment) return null
const from = Math.max(0, Math.min(absStart.index, fragment.length))
const endIdx = anchorEndIncl ? absEnd.index + 1 : absEnd.index
const to = Math.max(from, Math.min(Math.max(endIdx, from + 1), fragment.length))
return { from, to }
} catch {
return null
}
}
function resolveAnchorIndex(document, fragment, encoded) {
try {
const abs = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(b64decode(encoded)), document)
return abs && abs.type === fragment ? abs.index : null
} catch {
return null
}
}
const normalizedMarkdown = value => String(value || '').replace(/\r\n/g, '\n').trim()
// Suggestions remember both the target markdown and how far away the next
// stable block was. The latter catches the real duplicate-producing case: an
// accepted proposal expands one block into several while a queued proposal's
// item anchor remains attached to only the first replacement block.
function currentSuggestionRange(document, fragment, suggestion, field = FIELD) {
const range = resolveBlockRange(document, fragment, suggestion.anchorStart, suggestion.anchorEnd, suggestion.anchorEndIncl || null)
if (!range) return null
const pm = yDocToProsemirrorJSON(document, field)
const current = (pm.content || []).slice(range.from, range.to).map(blockToMarkdown).join('\n\n')
if (normalizedMarkdown(current) !== normalizedMarkdown(suggestion.originalMarkdown)) return null
if (Number.isInteger(suggestion.anchorAfterGap) && suggestion.anchorAfter) {
const after = resolveAnchorIndex(document, fragment, suggestion.anchorAfter)
if (after == null || after - range.to !== suggestion.anchorAfterGap) return null
}
return range
}
const migratedAnchorDocs = new Set()
function migrateLegacySuggestionAnchors(document, docName) {
if (document.isDestroyed) return
const suggMap = suggestionsMap(document, docName)
const field = pageField(docName)
const fragment = document.getXmlFragment(field)
let pm = { content: [] }
try {
pm = yDocToProsemirrorJSON(document, field)
} catch {}
const updates = []
suggMap.forEach((s, id) => {
if (!s || (s.anchorEndIncl && s.anchorAfter) || (s.status !== 'open' && s.status !== 'superseded')) return
const range = resolveBlockRange(document, fragment, s.anchorStart, s.anchorEnd, s.anchorEndIncl || null)
if (!range) return
updates.push([
id,
{
...s,
anchorStart: b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, range.from))),
anchorEndIncl: b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, Math.max(range.from, range.to - 1)))),
anchorAfter: b64encode(
Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, stableAfterIndex(pm, range.to) ?? fragment.length))
),
},
])
})
if (updates.length) {
document.transact(() => {
for (const [id, value] of updates) suggMap.set(id, value)
})
}
}
// Did the accepting client's local apply really reach this document? Two
// independent checks, because getting this wrong in either direction is bad:
// trusting a dead client loses the content, doubting a live one duplicates it.
// 1. presence — a synced editor publishes awareness (CollaborationCaret);
// a tab with a rejected/closed socket has none.
// 2. content — the anchored blocks already read as the replacement.
// Either one is enough to conclude the change is in the document.
function clientApplied(document, suggestion, username, field = FIELD, page = 'home') {
try {
for (const st of document.awareness.getStates().values()) {
if (st?.page === page && st?.user?.name === username) return true
}
} catch {}
try {
// Anchor-independent on purpose: a client that applied the replacement has
// already moved the anchor, so compare against the whole page instead. Each
// replacement block must appear in full — matching a prefix would mistake a
// shrinking edit (same opening, sentence removed) for one already applied.
const pm = yDocToProsemirrorJSON(document, field)
const flat = node =>
(node.text || '') + (node.content || []).map(flat).join(node.type === 'paragraph' ? '' : ' ')
const docText = norm((pm.content || []).map(flat).join(' '))
const blocks = markdownToBlocks(suggestion.replacementMarkdown)
if (!blocks.length) return false
return blocks.every(b => {
const want = norm(blockPlainText(b))
return !want || docText.includes(want)
})
} catch {
return false
}
}
const norm = s => String(s || '').replace(/\s+/g, ' ').trim()
// reader-visible text of a parsed block (marks and structure dropped)
function blockPlainText(b) {
const seg = ss => (ss || []).map(x => x.text || '').join('')
if (b.type === 'bulletList' || b.type === 'orderedList') return (b.items || []).map(seg).join(' ')
if (b.type === 'taskList') return (b.items || []).map(it => seg(it.inline)).join(' ')
if (b.type === 'table') return (b.rows || []).map(r => r.map(seg).join(' ')).join(' ')
if (b.type === 'codeBlock' || b.type === 'htmlBlock') return b.text || ''
if (b.type === 'image' || b.type === 'horizontalRule') return ''
return seg(b.inline)
}
export async function resolveSuggestionAction(docName, suggestionId, action, username, { markOnly = false, validateOnly = false } = {}) {
return withDoc(docName, document => {
const suggestions = suggestionsMap(document, docName)
const suggestion = suggestions.get(suggestionId)
if (!suggestion) return { error: 'suggestion not found' }
if (action === 'reopen') {
// undo of an accept: the text change was reverted client-side, bring the suggestion back
if (suggestion.status !== 'accepted') return { error: `cannot reopen a ${suggestion.status} suggestion` }
suggestions.set(suggestionId, { ...suggestion, status: 'open', resolvedBy: null, resolvedAt: null })
return { ok: true }
}
if (suggestion.status !== 'open' && suggestion.status !== 'superseded') return { error: `suggestion already ${suggestion.status}` }
if (action === 'reject') {
suggestions.set(suggestionId, { ...suggestion, status: 'rejected', resolvedBy: username, resolvedAt: Date.now() })
return { ok: true }
}
const field = pageField(docName)
const fragment = document.getXmlFragment(field)
if (validateOnly) {
if (!currentSuggestionRange(document, fragment, suggestion, field)) {
return { error: 'the addressed text changed after this suggestion was created; revise the suggestion before accepting it' }
}
return { ok: true }
}
// mark_only means "I already applied the replacement in my editor" (so it
// lands in that user's undo history). It is only true if that client's
// websocket actually delivered the change — a tab whose socket is dead
// (rejected as outdated, offline) still reaches this endpoint over HTTP, and
// trusting it there recorded accepts whose content was silently dropped.
if (markOnly && clientApplied(document, suggestion, username, field, pageSlugOf(docName))) {
suggestions.set(suggestionId, { ...suggestion, status: 'accepted', resolvedBy: username, resolvedAt: Date.now() })
return { ok: true }
}
const range = currentSuggestionRange(document, fragment, suggestion, field)
if (!range) {
return { error: 'the addressed text changed after this suggestion was created; revise the suggestion before accepting it' }
}
const nodes = markdownToBlocks(suggestion.replacementMarkdown)
fragment.delete(range.from, range.to - range.from)
fragment.insert(range.from, nodes.map(buildYBlock))
suggestions.set(suggestionId, { ...suggestion, status: 'accepted', resolvedBy: username, resolvedAt: Date.now() })
if (String(docName).endsWith('::_structure')) clearStructureCache(projectIdOf(docName))
return { ok: true }
})
}
// --- building Y nodes from markdown block descriptors ---
function yInlineText(segments) {
// Insert the full plain string first, then format() the marked ranges.
// Inserting formatted segments sequentially would make later unformatted
// segments inherit the previous segment's formatting (Yjs semantics).
const t = new Y.XmlText()
t.insert(0, (segments || []).map(s => s.text).join(''))
let pos = 0
for (const seg of segments || []) {
if (Object.keys(seg.attrs || {}).length) t.format(pos, seg.text.length, seg.attrs)
pos += seg.text.length
}
return t
}
// Inline content is usually one XmlText run, but a formula is an element, so a
// paragraph containing math becomes an alternating list of text runs and math
// elements. Each text run is built on its own — formatting must not leak across
// a formula, and the offsets above are per-run.
function yInlineChildren(segments) {
const out = []
let run = []
const flush = () => {
if (run.length) out.push(yInlineText(run))
run = []
}
for (const seg of segments || []) {
if (seg.math != null) {
flush()
const m = new Y.XmlElement('mathInline')
m.setAttribute('latex', seg.math)
out.push(m)
} else if (seg.text) {
run.push(seg)
}
}
flush()
// an empty paragraph still needs its text node, or the editor sees no content
return out.length ? out : [yInlineText([])]
}
function paragraphOf(segments) {
const p = new Y.XmlElement('paragraph')
p.insert(0, yInlineChildren(segments))
return p
}
export function buildYBlock(block) {
switch (block.type) {
case 'heading': {
const el = new Y.XmlElement('heading')
el.setAttribute('level', block.attrs?.level || 1)
el.insert(0, yInlineChildren(block.inline))
return el
}
case 'mathBlock': {
const el = new Y.XmlElement('mathBlock')
el.setAttribute('latex', block.attrs?.latex || '')
return el
}
case 'codeBlock': {
const el = new Y.XmlElement('codeBlock')
if (block.attrs?.language) el.setAttribute('language', block.attrs.language)
const t = new Y.XmlText()
t.insert(0, block.text || '')
el.insert(0, [t])
return el
}
case 'bulletList':
case 'orderedList': {
const el = new Y.XmlElement(block.type)
el.insert(0, (block.items || []).map(segments => {
const li = new Y.XmlElement('listItem')
li.insert(0, [paragraphOf(segments)])
return li
}))
return el
}
case 'taskList': {
const el = new Y.XmlElement('taskList')
el.insert(0, (block.items || []).map(it => {
const li = new Y.XmlElement('taskItem')
li.setAttribute('checked', !!it.checked)
li.insert(0, [paragraphOf(it.inline)])
return li
}))
return el
}
case 'table': {
const el = new Y.XmlElement('table')
el.insert(0, (block.rows || []).map((cells, r) => {
const row = new Y.XmlElement('tableRow')
row.insert(0, cells.map(segs => {
const cell = new Y.XmlElement(r === 0 ? 'tableHeader' : 'tableCell')
cell.setAttribute('colspan', 1)
cell.setAttribute('rowspan', 1)
cell.insert(0, [paragraphOf(segs)])
return cell
}))
return row
}))
return el
}
case 'blockquote': {
const el = new Y.XmlElement('blockquote')
el.insert(0, [paragraphOf(block.inline)])
return el
}
case 'horizontalRule':
return new Y.XmlElement('horizontalRule')
case 'htmlBlock': {
const el = new Y.XmlElement('htmlBlock')
el.setAttribute('html', block.text || '')
return el
}
case 'image': {
const el = new Y.XmlElement('image')
el.setAttribute('src', block.attrs?.src || '')
if (block.attrs?.alt) el.setAttribute('alt', block.attrs.alt)
return el
}
default:
return paragraphOf(block.inline)
}
}
export async function deleteDoc(id) {
try { hocuspocus.closeConnections(id) } catch {}
releaseWatcher(id)
for (const [taskId, task] of Object.entries(mentionState.tasks)) {
if (projectIdOf(task.docId) === id) delete mentionState.tasks[taskId]
}
persistMentions()
store.deleteDocFiles(id)
}
// --- doc creation ---
export async function createDoc(title, username) {
const id = newId(8)
store.upsertRegistry(id, { title: title || 'Untitled', createdAt: Date.now(), updatedAt: Date.now(), createdBy: username, pages: {} })
await withDoc(id, document => {
const fragment = document.getXmlFragment(FIELD)
if (fragment.length === 0) {
const h = new Y.XmlElement('heading')
h.setAttribute('level', 1)
const t = new Y.XmlText()
t.insert(0, title || 'Untitled')
h.insert(0, [t])
const p = new Y.XmlElement('paragraph')
fragment.insert(0, [h, p])
}
})
await ensureStructurePage(id)
return id
}
// --- pages ---
function structureCodeBlock(document, projectId) {
const fragment = document.getXmlFragment(pageField(docNameFor(projectId, '_structure')))
for (let i = 0; i < fragment.length; i++) {
const el = fragment.get(i)
if (el instanceof Y.XmlElement && el.nodeName === 'codeBlock') return el
}
return null
}
const ensuredStructure = new Set()
export async function ensureStructurePage(projectId) {
if (ensuredStructure.has(projectId)) return
const name = docNameFor(projectId, '_structure')
const slugs = Object.keys(store.pagesOf(projectId) || { home: {} })
await withDoc(name, document => {
const fragment = document.getXmlFragment(pageField(name))
if (fragment.length > 0) return
// just the yaml field — the explanation lives in the page chrome
const code = new Y.XmlElement('codeBlock')
code.setAttribute('language', 'yaml')
const ct = new Y.XmlText()
ct.insert(0, defaultStructureYaml(slugs))
code.insert(0, [ct])
fragment.insert(0, [code])
})
store.upsertPageMeta(projectId, '_structure', { title: 'Structure', createdAt: Date.now() })
ensuredStructure.add(projectId)
clearStructureCache(projectId)
}
// Apply a pure text mutation ({raw}|{error}) to the structure YAML inside the
// Yjs doc. The write is a minimal splice (common prefix/suffix kept) so a
// human concurrently editing another part of the yaml merges sanely instead
// of colliding with a full-text replace.
export async function editStructureYaml(projectId, mutate) {
await ensureStructurePage(projectId)
let result = null
await withDoc(docNameFor(projectId, '_structure'), document => {
const code = structureCodeBlock(document, projectId)
const text = code?.get(0)
if (!(text instanceof Y.XmlText)) {
result = { error: 'structure yaml not found' }
return
}
const current = text.toString()
const r = mutate(current)
if (r.error) {
result = r
return
}
if (r.raw !== current) {
const next = r.raw
let p = 0
while (p < current.length && p < next.length && current[p] === next[p]) p++
let endCur = current.length
let endNext = next.length
while (endCur > p && endNext > p && current[endCur - 1] === next[endNext - 1]) {
endCur--
endNext--
}
if (endCur > p) text.delete(p, endCur - p)
if (endNext > p) text.insert(p, next.slice(p, endNext))
}
result = { ok: true, raw: r.raw }
})
if (result?.raw != null) primeStructureCache(projectId, result.raw)
else clearStructureCache(projectId)
return result
}
export async function createPage(projectId, title, username, { slug: explicitSlug = null, contentMarkdown = null } = {}) {
title = String(title || '')
const pages = store.pagesOf(projectId) || {}
let slug
if (explicitSlug) {
// creating a page that the structure yaml references but doesn't exist yet
slug = String(explicitSlug)
if (!PAGE_SLUG_RE.test(slug) || slug === '_structure' || slug === 'home') return { error: 'bad page slug' }
if (pages[slug]) return { error: 'page already exists' }
} else {
const base = title.trim() ? slugify(title) : 'untitled'
slug = base === '_structure' ? 'page' : base
let n = 2
while (pages[slug] || slug === 'home') slug = `${base}-${n++}`.slice(0, 40)
if (!PAGE_SLUG_RE.test(slug) || slug === '_structure') return { error: 'bad page title' }
}
const name = docNameFor(projectId, slug)
await withDoc(name, document => {
const fragment = document.getXmlFragment(pageField(name))
if (fragment.length > 0) return
if (contentMarkdown != null && String(contentMarkdown).trim()) {
// seed with the proposed content (accepting a new-page suggestion)
fragment.insert(0, markdownToBlocks(contentMarkdown).map(buildYBlock))
} else {
const h = new Y.XmlElement('heading')
h.setAttribute('level', 1)
if (title.trim()) {
const t = new Y.XmlText()
t.insert(0, title)
h.insert(0, [t])
}
const p = new Y.XmlElement('paragraph')
fragment.insert(0, [h, p])
}
})
store.upsertPageMeta(projectId, slug, { title: title.trim() || 'Untitled', createdAt: Date.now(), createdBy: username })
await ensureStructurePage(projectId)
// append to the structure yaml so the new page shows in the tree right away
let newRaw = null
await withDoc(docNameFor(projectId, '_structure'), document => {
const code = structureCodeBlock(document, projectId)
if (!code) return
const text = code.get(0)
if (!(text instanceof Y.XmlText)) return
const current = text.toString()
if (new RegExp(`^\\s*-\\s*${slug}\\s*$`, 'm').test(current)) return
text.insert(current.length, `${current.endsWith('\n') || !current ? '' : '\n'}- ${slug}`)
newRaw = text.toString()
})
if (newRaw != null) primeStructureCache(projectId, newRaw)
else clearStructureCache(projectId)
return { slug }
}
// Rename by rewriting the page's first heading (creating one if the page
// starts without any) — the H1 IS the title, so the document, the tab, and
// the sidebar stay in sync. Renaming home renames the project.
export async function renamePage(projectId, slug, title) {
const clean = String(title || '').trim().slice(0, 120)
if (!clean) return { error: 'title required' }
if (slug === '_structure') return { error: 'this page cannot be renamed' }
if (slug !== 'home' && !store.pagesOf(projectId)?.[slug]) return { error: 'no such page' }
const name = docNameFor(projectId, slug)
await withDoc(name, document => {
const fragment = document.getXmlFragment(pageField(name))
// the title is derived from the first textblock with text (titleFromPm),
// so rewrite exactly that block when it's a heading — else prepend an H1
const hasText = el => {
for (let i = 0; i < el.length; i++) {
const child = el.get(i)
if (child instanceof Y.XmlText && child.length) return true
if (child instanceof Y.XmlElement && hasText(child)) return true
}
return false
}
let heading = null
for (let i = 0; i < fragment.length; i++) {
const el = fragment.get(i)
if (!(el instanceof Y.XmlElement) || !hasText(el)) continue
if (el.nodeName === 'heading') heading = el
break
}
if (!heading && fragment.length && fragment.get(0) instanceof Y.XmlElement && fragment.get(0).nodeName === 'heading' && !hasText(fragment.get(0))) {
// brand-new untitled page: an empty H1 sits at the top — fill it
heading = fragment.get(0)
}
if (heading) {
if (heading.length) heading.delete(0, heading.length)
const t = new Y.XmlText()
t.insert(0, clean)
heading.insert(0, [t])
} else {
const h = new Y.XmlElement('heading')
h.setAttribute('level', 1)
const t = new Y.XmlText()
t.insert(0, clean)
h.insert(0, [t])
fragment.insert(0, [h])
}
})
store.touchProjectOnStore(projectId, slug, clean)
clearStructureCache(projectId)
return { ok: true }
}
export async function deletePage(projectId, slug) {
if (slug === 'home' || slug === '_structure') return { error: 'this page cannot be deleted' }
const name = docNameFor(projectId, slug)
for (const [taskId, task] of Object.entries(mentionState.tasks)) {
if (task.docId === name) delete mentionState.tasks[taskId]
}
persistMentions()
await withDoc(name, document => {
const fragment = document.getXmlFragment(pageField(name))
if (fragment.length) fragment.delete(0, fragment.length)
threadsMap(document, name).clear()
suggestionsMap(document, name).clear()
})
store.removePageMeta(projectId, slug)
// drop its line from the structure yaml
let newRaw = null
try {
await withDoc(docNameFor(projectId, '_structure'), document => {
const code = structureCodeBlock(document, projectId)
const text = code?.get(0)
if (!(text instanceof Y.XmlText)) return
const lines = text.toString().split('\n')
const keep = lines.filter(l => !new RegExp(`^\\s*-\\s*${slug}\\s*:?\\s*$`).test(l))
if (keep.length !== lines.length) {
text.delete(0, text.length)
text.insert(0, keep.join('\n'))
}
newRaw = text.toString()
})
} catch {}
if (newRaw != null) primeStructureCache(projectId, newRaw)
else clearStructureCache(projectId)
return { ok: true }
}
// One-time upgrade of legacy bullet lists whose every item is a checkbox
// ("[ ] ..." / "[x] ...") — written before task-list support — into real task
// lists. Only converts a list when ALL items match, so mixed lists are left
// alone. Reuses the markdown round-trip (preserves inline formatting).
function migrateCheckboxLists(document, docName) {
if (document.isDestroyed) return
const field = pageField(docName)
const fragment = document.getXmlFragment(field)
let pm
try {
pm = yDocToProsemirrorJSON(document, field)
} catch {
return
}
const content = pm.content || []
const CHECKBOX = /^\[[ xX]?\]\s+\S/
const itemText = li => (li.content || []).map(b => (b.content || []).map(n => n.text || '').join('')).join(' ')
const updates = []
content.forEach((node, i) => {
if (node.type !== 'bulletList') return
const items = node.content || []
if (items.length && items.every(li => CHECKBOX.test(itemText(li)))) {
const blocks = markdownToBlocks(blockToMarkdown(node))
if (blocks.length === 1 && blocks[0].type === 'taskList') updates.push([i, blocks[0], itemText(items[0])])
}
})
if (!updates.length) return
document.transact(() => {
// apply high index first so earlier indices stay valid; re-verify each
// target is still the expected bullet list (guards against a concurrent
// edit having shifted the doc between the snapshot and this transaction)
for (const [i, block, firstText] of updates.reverse()) {
const el = i < fragment.length ? fragment.get(i) : null
if (!(el instanceof Y.XmlElement) || el.nodeName !== 'bulletList') continue
if (!el.toString().replace(/<[^>]+>/g, '').trim().startsWith(firstText.slice(0, 12))) continue
fragment.delete(i, 1)
fragment.insert(i, [buildYBlock(block)])
}
})
}
// --- new-page proposals -------------------------------------------------------
// A proposal for a whole new page (title + content) lives in the project's
// _structure doc, so it is project-level and visible before acceptance. On
// accept it becomes a real page (with content) added to the structure.
export async function createPageProposal(projectId, { title, contentMarkdown, rationale, author, authorType, parent = null, index = null }) {
const clean = String(title || '').trim().slice(0, 120)
if (!clean) return { error: 'title required' }
const pages = store.pagesOf(projectId) || {}
await ensureStructurePage(projectId)
const id = newId(10)
let existingSlugs = new Set(Object.keys(pages))
const result = await withDoc(docNameFor(projectId, '_structure'), document => {
const map = document.getMap('pageProposals')
map.forEach(p => {
if (p.status === 'open') existingSlugs.add(p.slug)
})
const base = slugify(clean)
let slug = base === '_structure' ? 'page' : base
let n = 2
while (existingSlugs.has(slug) || slug === 'home') slug = `${base}-${n++}`.slice(0, 40)
map.set(id, {
id,
kind: 'newPage',
slug,
title: clean,
contentMarkdown: String(contentMarkdown || ''),
rationale: rationale ? String(rationale) : null,
// where in the tree it is proposed to live: same {parent, index} refs the
// move endpoint uses (null parent = top level). Carried on the proposal so
// the sidebar can show it in place, before anyone accepts it.
parent: parent ?? null,
index: Number.isInteger(index) ? index : null,
author,
authorType,
status: 'open',
ts: Date.now(),
})
return { slug }
})
clearStructureCache(projectId)
return { id, slug: result.slug }
}
export async function getPageProposal(projectId, pid) {
return readDoc(docNameFor(projectId, '_structure'), document => {
const p = document.getMap('pageProposals').get(pid)
return p || null
})
}
export async function resolvePageProposal(projectId, pid, action, username) {
const proposal = await getPageProposal(projectId, pid)
if (!proposal) return { error: 'proposal not found' }
if (proposal.status !== 'open') return { error: `proposal already ${proposal.status}` }
if (action === 'reject') {
await withDoc(docNameFor(projectId, '_structure'), document => {
const map = document.getMap('pageProposals')
const p = map.get(pid)
if (p) map.set(pid, { ...p, status: 'rejected', resolvedBy: username, resolvedAt: Date.now() })
})
clearStructureCache(projectId)
return { ok: true }
}
// accept: create the page with its content, add to structure, mark accepted
const created = await createPage(projectId, proposal.title, username, {
slug: proposal.slug,
contentMarkdown: proposal.contentMarkdown,
})
if (created.error) return created
// createPage appends at the top level; a proposal that asked for a spot in the
// hierarchy gets moved there, in the same edit vocabulary as a drag would use.
// A parent that has since been dissolved or deleted is not an error — the page
// is already created, so it just stays where it landed.
if (proposal.parent != null || proposal.index != null) {
const placed = await editStructureYaml(projectId, raw => moveNodesInYaml(raw, [created.slug], proposal.parent ?? null, proposal.index ?? undefined))
if (placed?.error) console.log(`[pageProposal] ${pid} accepted but not placed under ${JSON.stringify(proposal.parent)}: ${placed.error}`)
}
await withDoc(docNameFor(projectId, '_structure'), document => {
const map = document.getMap('pageProposals')
const p = map.get(pid)
if (p) map.set(pid, { ...p, status: 'accepted', resolvedBy: username, resolvedAt: Date.now(), slug: created.slug })
})
clearStructureCache(projectId)
return { ok: true, slug: created.slug }
}