@nynb/sandpaper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/bin/brain-inject.js +23 -0
- package/bin/brain-stamp-check.js +36 -0
- package/bin/cli.js +62 -0
- package/brain/README.md +99 -0
- package/brain/assets/brain.css +472 -0
- package/brain/assets/brain.js +189 -0
- package/brain/assets/theme.css +88 -0
- package/package.json +19 -0
- package/public/sp-markdown.js +172 -0
- package/public/toolbar.css +220 -0
- package/public/toolbar.js +564 -0
- package/skill/sandpaper/SKILL.md +114 -0
- package/skill/sandpaper/commands/canvas.md +31 -0
- package/skill/sandpaper/commands/decide.md +16 -0
- package/skill/sandpaper/commands/help.md +25 -0
- package/skill/sandpaper/commands/init.md +113 -0
- package/skill/sandpaper/commands/learn.md +11 -0
- package/skill/sandpaper/commands/log.md +9 -0
- package/skill/sandpaper/commands/open.md +15 -0
- package/skill/sandpaper/commands/plan.md +16 -0
- package/skill/sandpaper/commands/serve.md +8 -0
- package/skill/sandpaper/commands/stamp.md +25 -0
- package/skill/sandpaper/commands/sync.md +17 -0
- package/skill/sandpaper/commands/theme.md +12 -0
- package/src/claude.js +226 -0
- package/src/edit.js +113 -0
- package/src/server.js +327 -0
- package/src/setup.js +564 -0
package/src/claude.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// claude.js — the bridge to Claude Code.
|
|
2
|
+
// Spawns `claude -p` in stream-json mode against the document's folder, maps the
|
|
3
|
+
// event stream to a small set of UI status states, and persists the session id so
|
|
4
|
+
// every turn resumes the same conversation (context survives across turns).
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { dirname, basename, join } from 'node:path';
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
|
|
10
|
+
// Appended per turn instead of a repo-wide CLAUDE.md, so the contract is scoped to
|
|
11
|
+
// the editing job and doesn't pollute the project's own Claude guidance.
|
|
12
|
+
const CONTRACT = [
|
|
13
|
+
'You are the editing engine behind Sandpaper. A human is refining a single,',
|
|
14
|
+
'self-contained HTML document live in their browser.',
|
|
15
|
+
'- Edit the target HTML file in place with the Edit tool. Keep it valid and self-contained.',
|
|
16
|
+
'- Make the SMALLEST change that satisfies the request. Never regenerate unrelated parts.',
|
|
17
|
+
'- Preserve every existing data-cid attribute exactly; give new block elements unique ones.',
|
|
18
|
+
'- When told a specific element (by data-cid or CSS selector), change ONLY that element.',
|
|
19
|
+
"- Preserve the document's structure, style, and voice unless asked to change them.",
|
|
20
|
+
].join('\n');
|
|
21
|
+
|
|
22
|
+
const SESSION_TOOLS = ['Read', 'Edit', 'Write', 'MultiEdit'];
|
|
23
|
+
|
|
24
|
+
// ---- pure helpers (unit-tested in test/parse-test.js) ----
|
|
25
|
+
|
|
26
|
+
// Returns a session id if this event carries one (the init system event), else null.
|
|
27
|
+
export function getSessionId(ev) {
|
|
28
|
+
return ev && ev.type === 'system' && ev.subtype === 'init' && ev.session_id
|
|
29
|
+
? ev.session_id
|
|
30
|
+
: null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Summarize an Edit/Write/MultiEdit tool_use block (taken from a COMPLETE assistant
|
|
34
|
+
// message, where `input` is fully formed — never from partial input_json_delta) into a
|
|
35
|
+
// typed `edit` frame for the conversation surface's "what changed" card.
|
|
36
|
+
function summarizeEdit(block, docName) {
|
|
37
|
+
const input = block.input || {};
|
|
38
|
+
const name = block.name;
|
|
39
|
+
let hunks;
|
|
40
|
+
if (name === 'MultiEdit' && Array.isArray(input.edits)) {
|
|
41
|
+
hunks = input.edits.map((e) => ({ oldText: e.old_string || '', newText: e.new_string || '' }));
|
|
42
|
+
} else if (name === 'Write') {
|
|
43
|
+
hunks = [{ oldText: '', newText: input.content || '' }];
|
|
44
|
+
} else {
|
|
45
|
+
hunks = [{ oldText: input.old_string || '', newText: input.new_string || '' }];
|
|
46
|
+
}
|
|
47
|
+
let added = 0, removed = 0;
|
|
48
|
+
const cids = new Set();
|
|
49
|
+
for (const h of hunks) {
|
|
50
|
+
if (h.oldText) removed += h.oldText.split('\n').length;
|
|
51
|
+
if (h.newText) added += h.newText.split('\n').length;
|
|
52
|
+
for (const t of [h.oldText, h.newText]) {
|
|
53
|
+
for (const m of String(t).matchAll(/data-cid="([^"]+)"/g)) cids.add(m[1]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const file = input.file_path ? basename(input.file_path) : docName;
|
|
57
|
+
return { type: 'edit', tool: name, file, hunks, added, removed, cids: [...cids] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Map one stream-json event to an ARRAY of typed frames for the conversation surface:
|
|
61
|
+
// {type:'status', …} → drives the 7-state chip (unchanged behaviour)
|
|
62
|
+
// {type:'assistant_delta', …} → streamed reply text / thinking (previously DROPPED — the bug)
|
|
63
|
+
// {type:'edit', …} → a per-edit change summary
|
|
64
|
+
// Returns [] for events that carry nothing renderable.
|
|
65
|
+
export function mapEvents(ev, docName) {
|
|
66
|
+
if (!ev || !ev.type) return [];
|
|
67
|
+
|
|
68
|
+
if (ev.type === 'system' && ev.subtype === 'init') {
|
|
69
|
+
return [{ type: 'status', state: 'init', label: 'starting…' }];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (ev.type === 'stream_event' && ev.event) {
|
|
73
|
+
const e = ev.event;
|
|
74
|
+
if (e.type === 'message_start') {
|
|
75
|
+
return [{ type: 'status', state: 'thinking', label: 'thinking…' }];
|
|
76
|
+
}
|
|
77
|
+
if (e.type === 'content_block_start' && e.content_block && e.content_block.type === 'tool_use') {
|
|
78
|
+
const tool = e.content_block.name || 'tool';
|
|
79
|
+
const editing = tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit';
|
|
80
|
+
return [editing
|
|
81
|
+
? { type: 'status', state: 'editing', label: `editing ${docName}` }
|
|
82
|
+
: { type: 'status', state: 'tool_using', label: `${tool.toLowerCase()}…` }];
|
|
83
|
+
}
|
|
84
|
+
if (e.type === 'content_block_delta' && e.delta) {
|
|
85
|
+
if (e.delta.type === 'text_delta' && e.delta.text) {
|
|
86
|
+
return [{ type: 'assistant_delta', kind: 'text', text: e.delta.text }];
|
|
87
|
+
}
|
|
88
|
+
if (e.delta.type === 'thinking_delta' && e.delta.thinking) {
|
|
89
|
+
return [{ type: 'assistant_delta', kind: 'thinking', text: e.delta.thinking }];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A COMPLETE assistant message: pull edit summaries from it (text was already streamed
|
|
96
|
+
// via deltas, so we do NOT re-emit it here — that would double-render).
|
|
97
|
+
if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {
|
|
98
|
+
const edits = [];
|
|
99
|
+
for (const block of ev.message.content) {
|
|
100
|
+
if (block.type === 'tool_use' && (block.name === 'Edit' || block.name === 'Write' || block.name === 'MultiEdit')) {
|
|
101
|
+
edits.push(summarizeEdit(block, docName));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return edits;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (ev.type === 'result') {
|
|
108
|
+
if (ev.is_error || (ev.subtype && ev.subtype !== 'success')) {
|
|
109
|
+
return [{ type: 'status', state: 'error', label: 'turn failed', detail: String(ev.result || '').slice(0, 200) }];
|
|
110
|
+
}
|
|
111
|
+
// The toolbar derives "Replied" vs "Saved" from whether any edit frame arrived this turn,
|
|
112
|
+
// so the chip stays neutral here — no more hardcoded "Saved" on pure-discussion turns.
|
|
113
|
+
const cost = typeof ev.total_cost_usd === 'number' ? ev.total_cost_usd : null;
|
|
114
|
+
return [{ type: 'status', state: 'done', label: 'done', cost, done: true }];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- session persistence ----
|
|
121
|
+
|
|
122
|
+
function sessionPath(docDir) { return join(docDir, '.sandpaper', 'session.json'); }
|
|
123
|
+
|
|
124
|
+
function loadSession(docDir) {
|
|
125
|
+
try { return JSON.parse(readFileSync(sessionPath(docDir), 'utf8')).sessionId || null; }
|
|
126
|
+
catch { return null; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function saveSession(docDir, id) {
|
|
130
|
+
const dir = join(docDir, '.sandpaper');
|
|
131
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
132
|
+
writeFileSync(sessionPath(docDir), JSON.stringify({ sessionId: id }, null, 2));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- environment hygiene (mirrors amux _on_claude_plan / _claude_oneshot env scrub) ----
|
|
136
|
+
|
|
137
|
+
// True if the user is signed into a Claude subscription (Pro/Max) rather than API billing.
|
|
138
|
+
function onClaudePlan() {
|
|
139
|
+
try { return !!JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')).oauthAccount; }
|
|
140
|
+
catch { return false; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The child is a fresh, top-level `claude` run — not nested inside whatever process spawned
|
|
144
|
+
// us. Strip the markers that would make it think it's running inside Claude Code, and on a
|
|
145
|
+
// subscription drop any exported ANTHROPIC_API_KEY so turns bill the plan, not the API.
|
|
146
|
+
function childEnv() {
|
|
147
|
+
const env = { ...process.env };
|
|
148
|
+
delete env.CLAUDECODE;
|
|
149
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
150
|
+
if (onClaudePlan()) delete env.ANTHROPIC_API_KEY;
|
|
151
|
+
return env;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- the turn ----
|
|
155
|
+
|
|
156
|
+
// Run one refinement turn. `onStatus({state,label,detail?,done?})` is called as the
|
|
157
|
+
// stream advances. Returns the child process (or null if spawn failed synchronously).
|
|
158
|
+
export function runTurn(docPath, prompt, onStatus) {
|
|
159
|
+
const docDir = dirname(docPath);
|
|
160
|
+
const docName = basename(docPath);
|
|
161
|
+
const prior = loadSession(docDir);
|
|
162
|
+
|
|
163
|
+
const args = [
|
|
164
|
+
'-p', prompt,
|
|
165
|
+
'--output-format', 'stream-json', '--verbose', '--include-partial-messages',
|
|
166
|
+
'--permission-mode', 'acceptEdits',
|
|
167
|
+
'--allowedTools', SESSION_TOOLS.join(','),
|
|
168
|
+
'--append-system-prompt', CONTRACT,
|
|
169
|
+
];
|
|
170
|
+
if (prior) args.push('--resume', prior);
|
|
171
|
+
|
|
172
|
+
let child;
|
|
173
|
+
try {
|
|
174
|
+
// cwd = the document's folder: this is what makes --resume's directory-scoped
|
|
175
|
+
// session lookup reliable, and keeps Claude's relative paths anchored to the doc.
|
|
176
|
+
// stdin 'ignore' so a non-interactive `-p` run can never block waiting on input.
|
|
177
|
+
child = spawn('claude', args, { cwd: docDir, env: childEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
178
|
+
} catch (err) {
|
|
179
|
+
onStatus({ type: 'status', state: 'error', label: 'Could not start claude', detail: err.message });
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
onStatus({ type: 'status', state: 'init', label: 'starting…' });
|
|
184
|
+
|
|
185
|
+
let buf = '';
|
|
186
|
+
let errored = false;
|
|
187
|
+
|
|
188
|
+
const processLine = (raw) => {
|
|
189
|
+
const line = raw.trim();
|
|
190
|
+
if (!line) return;
|
|
191
|
+
let ev;
|
|
192
|
+
try { ev = JSON.parse(line); } catch { return; } // ignore non-JSON noise
|
|
193
|
+
const id = getSessionId(ev);
|
|
194
|
+
if (id) saveSession(docDir, id);
|
|
195
|
+
for (const frame of mapEvents(ev, docName)) onStatus(frame);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
child.stdout.on('data', (chunk) => {
|
|
199
|
+
buf += chunk.toString();
|
|
200
|
+
let nl;
|
|
201
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
202
|
+
processLine(buf.slice(0, nl));
|
|
203
|
+
buf = buf.slice(nl + 1);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
// Flush a final line that arrives without a trailing newline (else the `result`
|
|
207
|
+
// event — cost + "Saved" confirmation — can be silently dropped on abrupt exit).
|
|
208
|
+
child.stdout.on('end', () => { processLine(buf); buf = ''; });
|
|
209
|
+
|
|
210
|
+
let stderr = '';
|
|
211
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
212
|
+
child.on('error', (err) => {
|
|
213
|
+
errored = true;
|
|
214
|
+
onStatus({ type: 'status', state: 'error', label: 'claude not found — is it installed?', detail: err.message });
|
|
215
|
+
});
|
|
216
|
+
child.on('close', (code) => {
|
|
217
|
+
if (errored) return; // 'error' already surfaced the real failure — don't overwrite it with idle
|
|
218
|
+
if (code && code !== 0) {
|
|
219
|
+
onStatus({ type: 'status', state: 'error', label: `claude exited (${code})`, detail: stderr.slice(0, 300) });
|
|
220
|
+
} else {
|
|
221
|
+
onStatus({ type: 'status', state: 'idle', label: 'idle' });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return child;
|
|
226
|
+
}
|
package/src/edit.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// edit.js — direct (no-AI) in-place edits.
|
|
2
|
+
// The browser manipulates the rendered DOM of one element; these functions splice that change
|
|
3
|
+
// back into the SOURCE FILE around it, leaving the rest of the file byte-for-byte intact.
|
|
4
|
+
// Pure + dependency-free (Sandpaper ships zero runtime deps) so it can be unit-tested in isolation.
|
|
5
|
+
//
|
|
6
|
+
// All operations locate an element by its data-cid="…" attribute. The tricky parts, all handled in
|
|
7
|
+
// locate(): the literal string can appear in prose/comments (skip those), the element can contain
|
|
8
|
+
// NESTED same-name tags (count depth), and void/self-closing elements have no body (reject).
|
|
9
|
+
|
|
10
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
11
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
12
|
+
|
|
13
|
+
// Find the element carrying data-cid="<cid>" and return ALL its byte boundaries, or null:
|
|
14
|
+
// { tag, openStart, innerStart, innerEnd, closeEnd }
|
|
15
|
+
// openStart — the '<' of the opening tag
|
|
16
|
+
// innerStart — first byte after the opening tag's '>'
|
|
17
|
+
// innerEnd — the '<' of the matching close tag
|
|
18
|
+
// closeEnd — first byte after the matching '</tag>'
|
|
19
|
+
function locate(html, cid) {
|
|
20
|
+
const marker = 'data-cid="' + cid + '"';
|
|
21
|
+
let at = html.indexOf(marker);
|
|
22
|
+
while (at >= 0) {
|
|
23
|
+
const lt = html.lastIndexOf('<', at);
|
|
24
|
+
// genuine attribute ⇒ still INSIDE an opening tag: no '>' sits between '<' and the marker.
|
|
25
|
+
if (lt >= 0 && html.lastIndexOf('>', at) < lt) {
|
|
26
|
+
const name = /^<([a-zA-Z][\w:-]*)/.exec(html.slice(lt));
|
|
27
|
+
if (name) {
|
|
28
|
+
const tag = name[1];
|
|
29
|
+
const openEnd = html.indexOf('>', at);
|
|
30
|
+
if (openEnd >= 0 && html[openEnd - 1] !== '/' && !VOID.has(tag.toLowerCase())) {
|
|
31
|
+
const tok = new RegExp('<' + tag + '(?=[\\s/>])|</' + tag + '\\s*>', 'gi');
|
|
32
|
+
tok.lastIndex = openEnd + 1;
|
|
33
|
+
let depth = 1, m;
|
|
34
|
+
while ((m = tok.exec(html))) {
|
|
35
|
+
if (m[0][1] === '/') {
|
|
36
|
+
if (--depth === 0) {
|
|
37
|
+
return { tag, openStart: lt, innerStart: openEnd + 1, innerEnd: m.index, closeEnd: tok.lastIndex };
|
|
38
|
+
}
|
|
39
|
+
} else depth++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
at = html.indexOf(marker, at + marker.length); // this occurrence wasn't usable — try the next
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Range of the element's INNER content (between the tags).
|
|
50
|
+
export function innerRange(html, cid) {
|
|
51
|
+
const r = locate(html, cid);
|
|
52
|
+
return r ? { tag: r.tag, start: r.innerStart, end: r.innerEnd } : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Range of the WHOLE element (tags included).
|
|
56
|
+
export function outerRange(html, cid) {
|
|
57
|
+
const r = locate(html, cid);
|
|
58
|
+
return r ? { tag: r.tag, start: r.openStart, end: r.closeEnd } : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Replace the element's inner content. Returns the new file string, or null if not located.
|
|
62
|
+
export function replaceInner(html, cid, inner) {
|
|
63
|
+
const r = locate(html, cid);
|
|
64
|
+
return r ? html.slice(0, r.innerStart) + inner + html.slice(r.innerEnd) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// True if the bytes from a line start up to `pos` are only spaces/tabs (i.e. `pos` begins a line).
|
|
68
|
+
function leadingIndent(html, pos) {
|
|
69
|
+
const ls = html.lastIndexOf('\n', pos - 1) + 1; // 0 if no preceding newline
|
|
70
|
+
const lead = html.slice(ls, pos);
|
|
71
|
+
return /^[ \t]*$/.test(lead) ? { lineStart: ls, indent: lead } : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Remove the element entirely. Returns { html, removed } (removed = the element's outer HTML) or null.
|
|
75
|
+
// If the element sits alone on its line, the line's leading whitespace + the preceding newline go too,
|
|
76
|
+
// so no blank gap is left behind.
|
|
77
|
+
export function removeElement(html, cid) {
|
|
78
|
+
const r = outerRange(html, cid);
|
|
79
|
+
if (!r) return null;
|
|
80
|
+
const removed = html.slice(r.start, r.end);
|
|
81
|
+
let from = r.start, to = r.end;
|
|
82
|
+
const li = leadingIndent(html, r.start);
|
|
83
|
+
if (li) {
|
|
84
|
+
if (li.lineStart > 0) from = li.lineStart - 1; // consume the newline that ended the previous line
|
|
85
|
+
else if (html[to] === '\n') to++; // at file start: there is none — consume the trailing one
|
|
86
|
+
}
|
|
87
|
+
return { html: html.slice(0, from) + html.slice(to), removed };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Move the element to sit `mode` ('before' | 'after') the element with cid=target. Returns { html } or null.
|
|
91
|
+
// The moved element's own bytes are preserved verbatim; only the indentation around the two seams changes.
|
|
92
|
+
export function moveElement(html, cid, target, mode) {
|
|
93
|
+
if (!target || target === cid || (mode !== 'before' && mode !== 'after')) return null;
|
|
94
|
+
const r = outerRange(html, cid);
|
|
95
|
+
if (!r) return null;
|
|
96
|
+
const element = html.slice(r.start, r.end);
|
|
97
|
+
// lift the element out, taking its own line's leading whitespace + the preceding newline with it
|
|
98
|
+
let from = r.start;
|
|
99
|
+
const li = leadingIndent(html, r.start);
|
|
100
|
+
if (li) from = li.lineStart === 0 ? 0 : li.lineStart - 1;
|
|
101
|
+
const without = html.slice(0, from) + html.slice(r.end);
|
|
102
|
+
// locate the target in the reduced document (target must still exist — not a descendant of the moved node)
|
|
103
|
+
const t = outerRange(without, target);
|
|
104
|
+
if (!t) return null;
|
|
105
|
+
const tli = leadingIndent(without, t.start);
|
|
106
|
+
const indent = tli ? tli.indent : '';
|
|
107
|
+
// only break onto a new line when the target is block-indented; inline targets stay inline.
|
|
108
|
+
if (mode === 'before') {
|
|
109
|
+
const at = tli ? tli.lineStart : t.start;
|
|
110
|
+
return { html: without.slice(0, at) + indent + element + (indent ? '\n' : '') + without.slice(at) };
|
|
111
|
+
}
|
|
112
|
+
return { html: without.slice(0, t.end) + (indent ? '\n' + indent : '') + element + without.slice(t.end) };
|
|
113
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// server.js — the local bridge server.
|
|
2
|
+
// Serves a document OR a whole folder (e.g. the project brain), injecting the on-page
|
|
3
|
+
// toolbar at response time only, relaying page → Claude turns, streaming status over SSE,
|
|
4
|
+
// and live-reloading the affected page when its file changes.
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { readFile, writeFile, readFileSync, writeFileSync, watch, copyFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
|
|
7
|
+
import { join, dirname, basename, extname, normalize, sep } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
10
|
+
import { runTurn } from './claude.js';
|
|
11
|
+
import { replaceInner, removeElement, moveElement } from './edit.js';
|
|
12
|
+
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const PUBLIC = join(HERE, '..', 'public');
|
|
15
|
+
|
|
16
|
+
const MIME = {
|
|
17
|
+
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
|
|
18
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
19
|
+
'.svg': 'image/svg+xml', '.webp': 'image/webp', '.json': 'application/json',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// `target` is a file (single-doc mode) or a directory (folder/brain mode, opts.brain=true).
|
|
23
|
+
export function startServer(target, port, opts = {}) {
|
|
24
|
+
const isDir = !!opts.brain;
|
|
25
|
+
const root = isDir ? target : dirname(target); // the directory we serve from
|
|
26
|
+
const defaultDoc = isDir ? 'index.html' : basename(target); // what "/" resolves to
|
|
27
|
+
const clients = new Set();
|
|
28
|
+
const turnSnapshots = new Map(); // turnId -> the page file it snapshotted (so undo restores the right file)
|
|
29
|
+
let activeTurn = null;
|
|
30
|
+
let activeTurnPage = null; // the URL path the active turn is editing (for the reload frame)
|
|
31
|
+
let reloadPending = false; // a file change happened mid-turn; reload once it ends
|
|
32
|
+
let suppressReloadUntil = 0; // a direct in-place edit we just wrote — the browser already shows it
|
|
33
|
+
|
|
34
|
+
// One-level undo for direct (no-AI) edits: snapshot a page just before we mutate it.
|
|
35
|
+
const directSnapDir = join(root, '.sandpaper', 'snapshots', 'direct');
|
|
36
|
+
const directSnaps = new Map(); // pageFile -> its pre-edit snapshot file (recorded only after a successful write)
|
|
37
|
+
const takeDirectSnap = (pageFile) => { // copy the current file aside; return the snapshot path, or null
|
|
38
|
+
try {
|
|
39
|
+
if (!existsSync(directSnapDir)) mkdirSync(directSnapDir, { recursive: true });
|
|
40
|
+
const rel = pageFile.startsWith(root + sep) ? pageFile.slice(root.length + 1) : basename(pageFile);
|
|
41
|
+
const snap = join(directSnapDir, createHash('sha1').update(rel).digest('hex').slice(0, 16) + '.html');
|
|
42
|
+
copyFileSync(pageFile, snap);
|
|
43
|
+
return snap;
|
|
44
|
+
} catch { return null; } // best-effort; never block the edit
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Apply a direct (no-AI) edit ATOMICALLY: sync read → compute → write, with no await points in between
|
|
48
|
+
// (so two rapid edits can't interleave). compute(src) -> new HTML string, or null if not located.
|
|
49
|
+
// Refuses to write while an AI turn is editing the same page (it would clobber Claude's in-progress edit).
|
|
50
|
+
const applyDirect = (pageFile, compute) => {
|
|
51
|
+
if (activeTurn && (!isDir || resolveUnder(activeTurnPage) === pageFile)) return { code: 409, body: '{"error":"an AI turn is editing this page"}' };
|
|
52
|
+
let src;
|
|
53
|
+
try { src = readFileSync(pageFile, 'utf8'); } catch { return { code: 404, body: '{"error":"unreadable"}' }; }
|
|
54
|
+
const out = compute(src);
|
|
55
|
+
if (out == null) return { code: 409, body: '{"error":"element not found"}' };
|
|
56
|
+
if (out === src) return { code: 200, body: '{"ok":true,"noop":true}' };
|
|
57
|
+
const snap = takeDirectSnap(pageFile); // snapshot the pre-edit file
|
|
58
|
+
try { writeFileSync(pageFile, out); } catch { return { code: 500, body: '{"error":"write failed"}' }; }
|
|
59
|
+
if (snap) directSnaps.set(pageFile, snap); // record undo ONLY after the write succeeds
|
|
60
|
+
suppressReloadUntil = Date.now() + 800; // the browser already shows the change
|
|
61
|
+
return { code: 200, body: '{"ok":true}' };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const broadcast = (obj) => {
|
|
65
|
+
const frame = `data: ${JSON.stringify(obj)}\n\n`;
|
|
66
|
+
for (const res of clients) {
|
|
67
|
+
try { res.write(frame); } catch { clients.delete(res); }
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const injectToolbar = (html) => {
|
|
72
|
+
const tag =
|
|
73
|
+
'\n<link rel="stylesheet" href="/__sandpaper/toolbar.css">' +
|
|
74
|
+
'\n<script type="module" src="/__sandpaper/toolbar.js"></script>\n';
|
|
75
|
+
return html.includes('</body>') ? html.replace('</body>', tag + '</body>') : html + tag;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const serveFile = (file, res) => {
|
|
79
|
+
readFile(file, (err, data) => {
|
|
80
|
+
if (err) { res.writeHead(404); return res.end('not found'); }
|
|
81
|
+
res.writeHead(200, { 'Content-Type': MIME[extname(file)] || 'application/octet-stream' });
|
|
82
|
+
res.end(data);
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Resolve a URL path to an absolute file UNDER root, or null if it escapes (traversal guard).
|
|
87
|
+
const resolveUnder = (reqPath) => {
|
|
88
|
+
const rel = (reqPath === '/' || reqPath === '') ? defaultDoc : reqPath.replace(/^\/+/, '');
|
|
89
|
+
if (rel === '.git' || rel.startsWith('.git/')) return null; // never serve VCS internals
|
|
90
|
+
const file = normalize(join(root, rel));
|
|
91
|
+
if (file !== root && !file.startsWith(root + sep)) return null; // lexical traversal guard
|
|
92
|
+
// also resolve symlinks: a link inside root pointing outside must not escape.
|
|
93
|
+
try { const real = realpathSync(file); if (real !== root && !real.startsWith(root + sep)) return null; }
|
|
94
|
+
catch { /* path not yet on disk — the lexical guard already passed */ }
|
|
95
|
+
return file;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const server = createServer((req, res) => {
|
|
99
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
100
|
+
let path;
|
|
101
|
+
try { path = decodeURIComponent(url.pathname); }
|
|
102
|
+
catch { res.writeHead(400); return res.end('bad request'); }
|
|
103
|
+
|
|
104
|
+
// Root convenience: serving a repo whose brain lives in brain/ — send "/" to the cover so
|
|
105
|
+
// localhost:<port> just works, no need to know the /brain/index.html path.
|
|
106
|
+
if (isDir && (path === '/' || path === '') && !existsSync(join(root, 'index.html')) && existsSync(join(root, 'brain', 'index.html'))) {
|
|
107
|
+
res.writeHead(302, { Location: '/brain/index.html' });
|
|
108
|
+
return res.end();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- SSE status/reload channel ---
|
|
112
|
+
if (path === '/__sandpaper/events') {
|
|
113
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
114
|
+
res.write(`data: ${JSON.stringify({ type: 'status', state: 'idle', label: 'idle' })}\n\n`);
|
|
115
|
+
clients.add(res);
|
|
116
|
+
req.on('close', () => clients.delete(res));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- a refinement turn (page-aware) ---
|
|
121
|
+
if (path === '/__sandpaper/turn' && req.method === 'POST') {
|
|
122
|
+
if (activeTurn) {
|
|
123
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
124
|
+
return res.end('{"error":"a turn is already in progress"}');
|
|
125
|
+
}
|
|
126
|
+
let body = '';
|
|
127
|
+
req.on('data', (d) => { body += d; if (body.length > 1e6) req.destroy(); });
|
|
128
|
+
req.on('end', () => {
|
|
129
|
+
let payload = {};
|
|
130
|
+
try { payload = JSON.parse(body || '{}'); } catch {}
|
|
131
|
+
// Never trust the client page: re-resolve it under root and require an existing .html.
|
|
132
|
+
const turnPage = typeof payload.page === 'string' ? payload.page : '/';
|
|
133
|
+
const pageFile = resolveUnder(turnPage);
|
|
134
|
+
if (!pageFile || extname(pageFile) !== '.html' || !existsSync(pageFile)) {
|
|
135
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
136
|
+
return res.end('{"error":"unknown page"}');
|
|
137
|
+
}
|
|
138
|
+
const turnId = randomUUID();
|
|
139
|
+
snapshot(pageFile, root, turnId);
|
|
140
|
+
turnSnapshots.set(turnId, pageFile);
|
|
141
|
+
activeTurnPage = turnPage;
|
|
142
|
+
activeTurn = runTurn(pageFile, buildPrompt(payload, basename(pageFile)), (frame) => {
|
|
143
|
+
frame.turnId = turnId;
|
|
144
|
+
frame.page = activeTurnPage; // page-scope every frame so other-page windows ignore this turn
|
|
145
|
+
broadcast(frame);
|
|
146
|
+
const ended = frame.type === 'status' && (frame.done || frame.state === 'idle' || frame.state === 'error');
|
|
147
|
+
if (ended) {
|
|
148
|
+
activeTurn = null;
|
|
149
|
+
if (reloadPending) {
|
|
150
|
+
reloadPending = false;
|
|
151
|
+
broadcast(isDir ? { type: 'reload', page: activeTurnPage } : { type: 'reload' });
|
|
152
|
+
}
|
|
153
|
+
activeTurnPage = null;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
res.writeHead(202, { 'Content-Type': 'application/json' });
|
|
157
|
+
res.end(JSON.stringify({ ok: true, turnId }));
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- undo a turn's edits (restore its pre-turn snapshot to the page it edited) ---
|
|
163
|
+
if (path === '/__sandpaper/undo' && req.method === 'POST') {
|
|
164
|
+
let body = '';
|
|
165
|
+
req.on('data', (d) => { body += d; if (body.length > 1e5) req.destroy(); });
|
|
166
|
+
req.on('end', () => {
|
|
167
|
+
let p = {}; try { p = JSON.parse(body || '{}'); } catch {}
|
|
168
|
+
const snap = snapshotPath(root, p.turnId);
|
|
169
|
+
const pageFile = turnSnapshots.get(p.turnId); // the file THIS turn snapshotted — not the client's claim
|
|
170
|
+
if (snap && existsSync(snap) && pageFile && extname(pageFile) === '.html') {
|
|
171
|
+
try {
|
|
172
|
+
copyFileSync(snap, pageFile); // → watcher → reload
|
|
173
|
+
turnSnapshots.delete(p.turnId);
|
|
174
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
175
|
+
return res.end('{"ok":true}');
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
179
|
+
res.end('{"error":"no snapshot for that turn"}');
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- a direct (no-AI) in-place edit: the browser edited one element; persist it to the file ---
|
|
185
|
+
// The browser owns the new content; we splice ONLY that element's inner HTML back into the
|
|
186
|
+
// source by data-cid, leaving the rest of the file untouched. No Claude, no turn, no snapshot.
|
|
187
|
+
if (path === '/__sandpaper/write' && req.method === 'POST') {
|
|
188
|
+
let body = '';
|
|
189
|
+
req.on('data', (d) => { body += d; if (body.length > 2e6) req.destroy(); });
|
|
190
|
+
req.on('end', () => {
|
|
191
|
+
let p = {}; try { p = JSON.parse(body || '{}'); } catch {}
|
|
192
|
+
const pageFile = resolveUnder(typeof p.page === 'string' ? p.page : '/');
|
|
193
|
+
const cid = typeof p.cid === 'string' ? p.cid : '';
|
|
194
|
+
if (!pageFile || extname(pageFile) !== '.html' || !existsSync(pageFile) ||
|
|
195
|
+
!/^[\w:-]{1,64}$/.test(cid) || typeof p.html !== 'string') {
|
|
196
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
197
|
+
return res.end('{"error":"bad write request"}');
|
|
198
|
+
}
|
|
199
|
+
const r = applyDirect(pageFile, (src) => replaceInner(src, cid, p.html));
|
|
200
|
+
res.writeHead(r.code, { 'Content-Type': 'application/json' });
|
|
201
|
+
res.end(r.body);
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- a direct (no-AI) STRUCTURAL edit: delete or move an element by data-cid (the "Hands") ---
|
|
207
|
+
if (path === '/__sandpaper/dom' && req.method === 'POST') {
|
|
208
|
+
let body = '';
|
|
209
|
+
req.on('data', (d) => { body += d; if (body.length > 1e5) req.destroy(); });
|
|
210
|
+
req.on('end', () => {
|
|
211
|
+
let p = {}; try { p = JSON.parse(body || '{}'); } catch {}
|
|
212
|
+
const okCid = (c) => typeof c === 'string' && /^[\w:-]{1,64}$/.test(c);
|
|
213
|
+
const pageFile = resolveUnder(typeof p.page === 'string' ? p.page : '/');
|
|
214
|
+
if (!pageFile || extname(pageFile) !== '.html' || !existsSync(pageFile) || !okCid(p.cid) ||
|
|
215
|
+
(p.op !== 'delete' && p.op !== 'move') || (p.op === 'move' && !okCid(p.target))) {
|
|
216
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
217
|
+
return res.end('{"error":"bad dom request"}');
|
|
218
|
+
}
|
|
219
|
+
const mode = p.mode === 'after' ? 'after' : 'before';
|
|
220
|
+
const r = applyDirect(pageFile, (src) => {
|
|
221
|
+
const out = p.op === 'delete' ? removeElement(src, p.cid) : moveElement(src, p.cid, p.target, mode);
|
|
222
|
+
return out ? out.html : null;
|
|
223
|
+
});
|
|
224
|
+
res.writeHead(r.code, { 'Content-Type': 'application/json' });
|
|
225
|
+
res.end(r.body);
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- undo the LAST direct edit on a page (restore its pre-edit snapshot) ---
|
|
231
|
+
if (path === '/__sandpaper/undo-direct' && req.method === 'POST') {
|
|
232
|
+
let body = '';
|
|
233
|
+
req.on('data', (d) => { body += d; if (body.length > 1e4) req.destroy(); });
|
|
234
|
+
req.on('end', () => {
|
|
235
|
+
let p = {}; try { p = JSON.parse(body || '{}'); } catch {}
|
|
236
|
+
const pageFile = resolveUnder(typeof p.page === 'string' ? p.page : '/');
|
|
237
|
+
const snap = pageFile && directSnaps.get(pageFile);
|
|
238
|
+
if (snap && existsSync(snap)) {
|
|
239
|
+
try {
|
|
240
|
+
suppressReloadUntil = 0; // we WANT the restore to reload the page
|
|
241
|
+
copyFileSync(snap, pageFile); // → watcher → reload
|
|
242
|
+
directSnaps.delete(pageFile);
|
|
243
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
244
|
+
return res.end('{"ok":true}');
|
|
245
|
+
} catch { /* fall through to 404 */ }
|
|
246
|
+
}
|
|
247
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
248
|
+
res.end('{"error":"nothing to undo"}');
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --- toolbar assets (dev chrome, served from the package; never written to disk) ---
|
|
254
|
+
if (path.startsWith('/__sandpaper/') && (path.endsWith('.js') || path.endsWith('.css'))) {
|
|
255
|
+
return serveFile(join(PUBLIC, basename(path)), res); // toolbar.js / toolbar.css / sp-markdown.js
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- any file under root: .html gets the toolbar injected, everything else served raw ---
|
|
259
|
+
const file = resolveUnder(path);
|
|
260
|
+
if (!file) { res.writeHead(404); return res.end('not found'); }
|
|
261
|
+
if (extname(file) === '.html') {
|
|
262
|
+
return readFile(file, 'utf8', (err, html) => {
|
|
263
|
+
if (err) { res.writeHead(404); return res.end('not found'); }
|
|
264
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
265
|
+
res.end(injectToolbar(html));
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return serveFile(file, res);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Watch the served tree; reload the page whose .html changed.
|
|
272
|
+
let debounce = null;
|
|
273
|
+
watch(root, { persistent: true, recursive: isDir }, (_evt, fname) => {
|
|
274
|
+
if (!fname) { if (isDir) return; fname = defaultDoc; } // null filename: only act in single-doc mode
|
|
275
|
+
const rel = fname.split(sep).join('/');
|
|
276
|
+
if (rel.startsWith('.sandpaper')) return; // our own snapshots/session
|
|
277
|
+
if (!rel.endsWith('.html')) return; // only reload on document changes
|
|
278
|
+
if (!isDir && basename(rel) !== defaultDoc) return; // single-doc mode: just the doc
|
|
279
|
+
const page = (isDir && rel === defaultDoc) ? '/' : '/' + rel; // map the root doc back to '/' like the URL
|
|
280
|
+
clearTimeout(debounce);
|
|
281
|
+
debounce = setTimeout(() => {
|
|
282
|
+
// A direct in-place edit we just wrote: the editing browser already shows it — don't reload.
|
|
283
|
+
if (Date.now() < suppressReloadUntil) return;
|
|
284
|
+
// Defer ONLY the active turn's own edit (so its reply isn't cut mid-stream); reload any
|
|
285
|
+
// other (external) change immediately.
|
|
286
|
+
if (activeTurn && (!isDir || page === activeTurnPage)) { reloadPending = true; return; }
|
|
287
|
+
broadcast(isDir ? { type: 'reload', page } : { type: 'reload' });
|
|
288
|
+
}, 120);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Listen on `port`, or the next free port if it's taken — so several repos' Sandpapers
|
|
292
|
+
// can run at once without colliding on 4848. Resolves with the URL of the port we landed on.
|
|
293
|
+
return new Promise((resolve, reject) => {
|
|
294
|
+
let p = port, tries = 0;
|
|
295
|
+
server.on('error', (e) => {
|
|
296
|
+
if (e.code === 'EADDRINUSE' && tries++ < 50) { p++; setTimeout(() => server.listen(p, '127.0.0.1'), 0); }
|
|
297
|
+
else reject(e);
|
|
298
|
+
});
|
|
299
|
+
server.on('listening', () => resolve(`http://127.0.0.1:${p}/`));
|
|
300
|
+
server.listen(p, '127.0.0.1');
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Pre-turn snapshot of the edited page, so a turn's edits can be undone and recovered.
|
|
305
|
+
function snapshotPath(root, turnId) {
|
|
306
|
+
const safe = String(turnId || '').replace(/[^a-fA-F0-9-]/g, ''); // turnId is a uuid; reject anything else
|
|
307
|
+
return safe ? join(root, '.sandpaper', 'snapshots', safe + '.html') : null;
|
|
308
|
+
}
|
|
309
|
+
function snapshot(pageFile, root, turnId) {
|
|
310
|
+
try {
|
|
311
|
+
const dir = join(root, '.sandpaper', 'snapshots');
|
|
312
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
313
|
+
copyFileSync(pageFile, snapshotPath(root, turnId));
|
|
314
|
+
} catch { /* best-effort; never block a turn */ }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Turn a toolbar payload into a scoped prompt for Claude.
|
|
318
|
+
function buildPrompt(payload, docName) {
|
|
319
|
+
const { prompt = '', cid, selector, snippet } = payload;
|
|
320
|
+
if (cid || selector) {
|
|
321
|
+
const where = cid ? `the element with data-cid="${cid}"` : `the element matching CSS selector \`${selector}\``;
|
|
322
|
+
const ctx = snippet ? `\nFor reference, its current content begins: "${snippet}"` : '';
|
|
323
|
+
return `In ${docName}, edit ONLY ${where}.${ctx}\n\nRequested change: ${prompt}\n\n` +
|
|
324
|
+
'Make the smallest edit that satisfies this and leave the rest of the document unchanged.';
|
|
325
|
+
}
|
|
326
|
+
return `In ${docName}: ${prompt}\n\nMake the smallest edit that satisfies this; do not regenerate unrelated parts of the document.`;
|
|
327
|
+
}
|