-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.mjs
More file actions
224 lines (207 loc) · 10.2 KB
/
Copy pathserver.mjs
File metadata and controls
224 lines (207 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
// Riddle Diary companion server.
// The tldraw document script POSTs canvas turns here; we invoke headless
// Claude Code (`claude -p`, uses your existing login — no API key) and relay
// its shape JSON back to the canvas.
//
// Run: node ~/Documents/mywork/riddle-diary/server.mjs
import http from 'node:http'
import { spawn } from 'node:child_process'
import { writeFileSync, mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const PORT = 7877
// Which Claude model answers canvas turns. Sonnet is the speed/quality sweet
// spot here; override with e.g. WRITEBACK_MODEL=claude-opus-4-8
const MODEL = process.env.WRITEBACK_MODEL || 'claude-sonnet-5'
const WORKDIR = join(tmpdir(), 'riddle-diary')
mkdirSync(WORKDIR, { recursive: true })
const PERSONA = `You are a canvas assistant embedded in an infinite whiteboard. The user writes or sketches on the canvas; you respond by adding shapes and text in exactly the right place. Be utility-focused and concise — no persona, no flourish.
What you do:
- Solve what is written or drawn: math problems, labeled diagrams, fill-in-the-blank marks, questions. If the user drew a right triangle with sides labeled "1" and "2" and a "?" on the third side, write the value (e.g. "√5 ≈ 2.24") right at the "?".
- Annotate diagrams: label parts, add measurements, complete missing pieces.
- Draw when drawing is the better answer: use geo shapes, lines, arrows, and freehand "draw" strokes to sketch diagrams, graphs, or illustrations the user asks for.
- Answer written questions with short text placed near the question.`
const CONTRACT = `You must reply with ONLY a JSON object (no markdown fences, no prose outside it):
{"shapes": [ ...one or more shape objects... ]}
Allowed shape objects (page coordinates, y grows downward):
- {"kind":"text","x":N,"y":N,"text":"...","color":"black","size":"m"} // handwritten reply text. size: s|m|l
- {"kind":"geo","geo":"rectangle"|"ellipse"|"cloud"|"star","x":N,"y":N,"w":N,"h":N,"text":"optional","color":"black"}
- {"kind":"note","x":N,"y":N,"text":"...","color":"yellow"}
- {"kind":"line","x1":N,"y1":N,"x2":N,"y2":N,"color":"black"}
- {"kind":"arrow","x1":N,"y1":N,"x2":N,"y2":N,"text":"optional","color":"black"}
- {"kind":"draw","color":"black","points":[[x,y],[x,y],...]} // freehand stroke, 10-80 points, page coords
Colors: black, grey, red, blue, green, orange, violet, light-violet, yellow, white.
Placement rules (this is the most important part — placement must be contextually CORRECT):
- Nothing on the canvas is ever removed. Your shapes are added alongside the user's ink and all previous replies, so pick empty space and never overlap existing shapes.
- If the user marked a "?" or left an obvious blank, put the answer AT that spot (right next to the "?" mark), sized to match the neighboring labels — study the screenshot and the shape coordinates carefully to find the exact page position.
- For a labeled diagram (e.g. triangle sides), the value goes ON the relevant side/part, like a label the user would have written themselves.
- For a written question, put the answer just below or beside the question text, left-aligned with it.
- Text lines: keep each text shape under ~60 characters; stack multiple text shapes ~44px apart vertically for multi-line answers.
- Prefer a small precise answer over a paragraph. Draw diagrams with geo/line/arrow/draw shapes when the user asks for a drawing or a visual completes the answer.
- Reply with at most 12 shapes.`
// Incrementally pulls completed {...} objects out of the streamed `"shapes": [...`
// array so each shape can be forwarded to the canvas before the reply finishes.
class ShapeStreamParser {
constructor(onShape) {
this.buf = ''
this.started = false
this.count = 0
this.onShape = onShape
}
push(text) {
this.buf += text
if (!this.started) {
const m = this.buf.match(/"shapes"\s*:\s*\[/)
if (!m) return
this.buf = this.buf.slice(m.index + m[0].length)
this.started = true
}
for (;;) {
const s = this.buf.indexOf('{')
if (s === -1) return
let depth = 0, inStr = false, esc = false, end = -1
for (let i = s; i < this.buf.length; i++) {
const c = this.buf[i]
if (esc) { esc = false; continue }
if (inStr) {
if (c === '\\') esc = true
else if (c === '"') inStr = false
continue
}
if (c === '"') inStr = true
else if (c === '{') depth++
else if (c === '}') { depth--; if (depth === 0) { end = i; break } }
}
if (end === -1) return
const raw = this.buf.slice(s, end + 1)
this.buf = this.buf.slice(end + 1)
try {
this.onShape(JSON.parse(raw))
this.count++
} catch (e) { /* malformed fragment; skip */ }
}
}
}
// Runs claude in streaming mode; calls onShape as each reply shape completes.
function streamClaude(prompt, onShape) {
return new Promise((resolve, reject) => {
const child = spawn(
'claude',
['-p', '--output-format', 'stream-json', '--include-partial-messages', '--verbose', '--allowedTools', 'Read', '--model', MODEL],
{ cwd: WORKDIR, stdio: ['pipe', 'pipe', 'pipe'] }
)
const parser = new ShapeStreamParser(onShape)
let fullText = '', lineBuf = '', err = '', resultError = null
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('claude timed out')) }, 240_000)
child.stdout.on('data', (d) => {
lineBuf += d
let idx
while ((idx = lineBuf.indexOf('\n')) !== -1) {
const line = lineBuf.slice(0, idx)
lineBuf = lineBuf.slice(idx + 1)
if (!line.trim()) continue
let msg
try { msg = JSON.parse(line) } catch (e) { continue }
if (msg.type === 'stream_event') {
const delta = msg.event && msg.event.delta
if (delta && delta.type === 'text_delta' && typeof delta.text === 'string') {
fullText += delta.text
parser.push(delta.text)
}
} else if (msg.type === 'result') {
if (msg.is_error) resultError = String(msg.result).slice(0, 300)
else if (typeof msg.result === 'string') fullText = fullText || msg.result
}
}
})
child.stderr.on('data', (d) => (err += d))
child.on('error', reject)
child.on('close', (code) => {
clearTimeout(timer)
if (resultError) return reject(new Error(`claude error: ${resultError}`))
if (code !== 0) return reject(new Error(`claude exited ${code}: ${err.slice(0, 500)}`))
// Safety net: if streaming missed shapes (e.g. no deltas), recover them
// from the full reply text and emit the remainder.
try {
const start = fullText.indexOf('{')
const end = fullText.lastIndexOf('}')
const parsed = JSON.parse(fullText.slice(start, end + 1))
const all = Array.isArray(parsed.shapes) ? parsed.shapes : []
for (let i = parser.count; i < all.length; i++) onShape(all[i])
resolve(Math.max(parser.count, all.length))
} catch (e) {
if (parser.count > 0) resolve(parser.count)
else reject(new Error('no shapes in reply'))
}
})
child.stdin.write(prompt)
child.stdin.end()
})
}
async function handleTurn(body, emit) {
const { shapes = [], userInput = {}, screenshotDataUrl, history = [] } = body
let screenshotLine = '(no screenshot available — rely on the shape data)'
if (screenshotDataUrl && screenshotDataUrl.startsWith('data:image/')) {
const b64 = screenshotDataUrl.slice(screenshotDataUrl.indexOf(',') + 1)
const ext = screenshotDataUrl.includes('image/png') ? 'png' : 'jpg'
const file = join(WORKDIR, `canvas-${Date.now()}.${ext}`)
writeFileSync(file, Buffer.from(b64, 'base64'))
screenshotLine = `First, use the Read tool to look at the canvas screenshot: ${file}\nIt shows everything on the page, including any handwriting or scribbles the shape data cannot capture.`
}
const historyLines = history
.slice(-30)
.map((h) => `${h.role === 'diary' ? 'Assistant' : 'User'}: ${h.text}`)
.join('\n')
const prompt = [
PERSONA,
'',
screenshotLine,
'',
'Current shapes on the page (page coordinates):',
JSON.stringify(shapes).slice(0, 12_000),
'',
`The writer's NEW input occupies bounds: ${JSON.stringify(userInput.bounds ?? null)}.`,
userInput.text ? `Text they wrote: ${JSON.stringify(userInput.text)}` : 'They scribbled/drew something rather than typing — read it from the screenshot.',
'',
historyLines ? `Conversation so far:\n${historyLines}` : 'This is the first interaction on this canvas.',
'',
CONTRACT,
].join('\n')
let sent = 0
return streamClaude(prompt, (shape) => {
if (sent >= 12) return
sent++
emit(shape)
})
}
const server = http.createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'content-type')
if (req.method === 'OPTIONS') { res.writeHead(204); return res.end() }
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'content-type': 'application/json' })
return res.end(JSON.stringify({ ok: true }))
}
if (req.method === 'POST' && req.url === '/turn') {
let data = ''
req.on('data', (c) => { data += c; if (data.length > 30_000_000) req.destroy() })
req.on('end', async () => {
// NDJSON stream: {"type":"shape",...} per shape as Claude produces it,
// then {"type":"done"}. Errors after headers go on the stream too.
res.writeHead(200, { 'content-type': 'application/x-ndjson', 'cache-control': 'no-cache' })
const emit = (shape) => res.write(JSON.stringify({ type: 'shape', shape }) + '\n')
try {
const count = await handleTurn(JSON.parse(data), emit)
console.log(`[turn] ok, ${count} shapes`)
res.end(JSON.stringify({ type: 'done', count }) + '\n')
} catch (e) {
console.error('[turn]', e.message)
res.end(JSON.stringify({ type: 'error', error: e.message }) + '\n')
}
})
return
}
res.writeHead(404); res.end()
})
server.listen(PORT, '127.0.0.1', () => console.log(`writeback server on http://127.0.0.1:${PORT} (model: ${MODEL})`))