rewritable 0.1.0 → 0.3.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/README.md +2 -0
- package/bin/rwa.mjs +40 -4
- package/package.json +6 -3
- package/seeds/rewritable.html +3115 -140
- package/src/commands.mjs +121 -5
- package/src/import-claude.mjs +336 -0
- package/src/import-vision.mjs +156 -0
- package/src/import.mjs +289 -6
- package/src/seed.mjs +15 -4
package/seeds/rewritable.html
CHANGED
|
@@ -3,39 +3,112 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<meta name="rwa-bootstrap" content="0.9">
|
|
6
7
|
<title>re-writeable</title>
|
|
7
|
-
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
|
8
8
|
<style>
|
|
9
|
-
|
|
9
|
+
/* Palette + chrome modeled on playground.ikangai.com — neutral grayscale,
|
|
10
|
+
system fonts, white surface, 24px radius, subtle shadows. The legacy
|
|
11
|
+
--bg/--surf/--b1/--b2/--text/--muted/--accent/--blue aliases are
|
|
12
|
+
preserved so anything in INLINE_DOC that still references them keeps
|
|
13
|
+
rendering. */
|
|
14
|
+
:root{
|
|
15
|
+
--white:#ffffff;
|
|
16
|
+
--gray-50:#fafafa;--gray-100:#f5f5f5;--gray-200:#e5e5e5;--gray-300:#d4d4d4;
|
|
17
|
+
--gray-400:#a3a3a3;--gray-500:#737373;--gray-600:#525252;--gray-700:#404040;
|
|
18
|
+
--gray-800:#262626;--gray-900:#171717;
|
|
19
|
+
--green:#22c55e;--yellow:#eab308;--red:#ef4444;--blue:#3b82f6;
|
|
20
|
+
--radius:24px;--radius-sm:12px;
|
|
21
|
+
--bg:var(--white);--surf:var(--white);
|
|
22
|
+
--b1:var(--gray-100);--b2:var(--gray-200);
|
|
23
|
+
--text:var(--gray-900);--muted:var(--gray-500);--accent:var(--gray-900);
|
|
24
|
+
--font-ui:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
|
25
|
+
--font-mono:'SF Mono',Menlo,Monaco,ui-monospace,'Cascadia Mono',monospace;
|
|
26
|
+
}
|
|
10
27
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
|
11
|
-
|
|
28
|
+
[hidden]{display:none!important;}
|
|
29
|
+
body{font-family:var(--font-ui);background:var(--bg);color:var(--text);min-height:100dvh;padding-bottom:160px;line-height:1.5;-webkit-font-smoothing:antialiased;}
|
|
12
30
|
#rwa-set{position:fixed;top:12px;right:12px;display:flex;gap:6px;z-index:1000;}
|
|
13
|
-
.rwa-st-btn{background:var(--
|
|
14
|
-
.rwa-st-btn:hover{color:var(--
|
|
15
|
-
.rwa-st-btn.dirty{color:var(--
|
|
16
|
-
.rwa-st-btn.pri{background:var(--
|
|
17
|
-
.rwa-st-btn.
|
|
18
|
-
.rwa-st-btn.
|
|
19
|
-
.rwa-st-btn.
|
|
20
|
-
|
|
31
|
+
.rwa-st-btn{background:var(--white);border:1px solid var(--gray-200);color:var(--gray-500);font-family:var(--font-mono);font-size:10px;padding:6px 10px;border-radius:6px;cursor:pointer;letter-spacing:.5px;text-transform:uppercase;transition:color .15s,background .15s,border-color .15s;}
|
|
32
|
+
.rwa-st-btn:hover{color:var(--gray-900);border-color:var(--gray-300);background:var(--gray-50);}
|
|
33
|
+
.rwa-st-btn.dirty{color:var(--gray-900);border-color:var(--gray-300);background:var(--gray-100);}
|
|
34
|
+
.rwa-st-btn.pri{background:var(--gray-900);color:var(--white);border-color:var(--gray-900);}
|
|
35
|
+
.rwa-st-btn.pri:hover{background:var(--gray-700);border-color:var(--gray-700);color:var(--white);}
|
|
36
|
+
.rwa-st-btn.run{color:var(--gray-700);border-color:var(--gray-300);background:var(--gray-50);}
|
|
37
|
+
.rwa-st-btn.err{color:var(--red);border-color:var(--red);background:var(--white);}
|
|
38
|
+
.rwa-st-btn.ok{color:var(--green);border-color:var(--green);background:var(--white);}
|
|
39
|
+
#rwa-set-panel{position:fixed;top:50px;right:12px;background:var(--white);border:1px solid var(--gray-200);border-radius:var(--radius-sm);padding:14px;display:none;min-width:300px;z-index:999;box-shadow:0 4px 16px rgba(0,0,0,0.08);}
|
|
21
40
|
#rwa-set-panel.open{display:block;}
|
|
22
41
|
.rwa-set-row{display:flex;flex-direction:column;gap:4px;margin-bottom:10px;}
|
|
23
42
|
.rwa-set-row:last-child{margin-bottom:0;}
|
|
24
|
-
.rwa-set-row label{font-family:
|
|
25
|
-
.rwa-set-row input{background:var(--
|
|
26
|
-
|
|
43
|
+
.rwa-set-row label{font-family:var(--font-mono);font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--gray-500);}
|
|
44
|
+
.rwa-set-row input,.rwa-set-row select{background:var(--white);border:1px solid var(--gray-200);color:var(--gray-900);font-family:var(--font-ui);font-size:13px;padding:8px 10px;outline:none;border-radius:8px;transition:border-color .15s;}
|
|
45
|
+
.rwa-set-row input:focus,.rwa-set-row select:focus{border-color:var(--gray-400);}
|
|
46
|
+
.rwa-set-row select{appearance:none;-webkit-appearance:none;cursor:pointer;}
|
|
47
|
+
.rwa-set-base-url-line{display:flex;gap:6px;}
|
|
48
|
+
.rwa-set-base-url-line input{flex:1;}
|
|
49
|
+
.rwa-set-base-url-line button{background:var(--gray-100);border:1px solid var(--gray-200);border-radius:8px;padding:6px 12px;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--gray-700);}
|
|
50
|
+
.rwa-set-base-url-line button:hover{background:var(--gray-200);}
|
|
51
|
+
.rwa-set-hint{font-family:var(--font-mono);font-size:10px;line-height:1.5;color:var(--gray-500);}
|
|
52
|
+
.rwa-set-hint:empty{display:none;}
|
|
53
|
+
.rwa-set-hint code{background:var(--gray-100);padding:1px 4px;border-radius:3px;font-size:10px;}
|
|
54
|
+
.rwa-set-hint.ok{color:#15803d;}
|
|
55
|
+
.rwa-set-hint.err{color:#b91c1c;}
|
|
56
|
+
#rwa-pal{position:fixed;inset:0;display:none;align-items:flex-start;justify-content:center;padding-top:13vh;background:rgba(0,0,0,.3);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:99999;}
|
|
27
57
|
#rwa-pal.open{display:flex;}
|
|
28
|
-
#rwa-pal-box{width:min(540px,92vw);background:var(--
|
|
29
|
-
.rwa-pal-top{display:flex;align-items:center;border-bottom:1px solid var(--
|
|
30
|
-
.rwa-pal-sig{padding:0 14px;font-size:13px;color:var(--
|
|
31
|
-
#rwa-pal-inp{flex:1;background:transparent;border:none;outline:none;color:var(--
|
|
32
|
-
#rwa-pal-inp::placeholder{color
|
|
33
|
-
#rwa-pal-go{background:var(--
|
|
34
|
-
#rwa-pal-go:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
#rwa-pal-st
|
|
38
|
-
|
|
58
|
+
#rwa-pal-box{width:min(540px,92vw);background:var(--white);border:1px solid var(--gray-200);border-radius:var(--radius-sm);overflow:hidden;box-shadow:0 16px 48px rgba(0,0,0,0.12),0 4px 12px rgba(0,0,0,0.06);}
|
|
59
|
+
.rwa-pal-top{display:flex;align-items:center;border-bottom:1px solid var(--gray-100);}
|
|
60
|
+
.rwa-pal-sig{padding:0 14px;font-size:13px;color:var(--gray-400);font-family:var(--font-mono);letter-spacing:.5px;}
|
|
61
|
+
#rwa-pal-inp{flex:1;background:transparent;border:none;outline:none;color:var(--gray-900);font-family:var(--font-ui);font-size:16px;padding:14px 0;}
|
|
62
|
+
#rwa-pal-inp::placeholder{color:var(--gray-400);}
|
|
63
|
+
#rwa-pal-go{background:var(--gray-900);color:var(--white);border:none;border-radius:var(--radius-sm);font-family:var(--font-ui);font-size:13px;font-weight:500;padding:8px 16px;margin:8px 12px;cursor:pointer;transition:background .15s;}
|
|
64
|
+
#rwa-pal-go:hover{background:var(--gray-700);}
|
|
65
|
+
#rwa-pal-go:disabled{background:var(--gray-200);color:var(--gray-400);cursor:not-allowed;}
|
|
66
|
+
.rwa-pal-foot{border-top:1px solid var(--gray-100);padding:7px 14px;display:flex;justify-content:space-between;align-items:center;}
|
|
67
|
+
#rwa-pal-st{font-family:var(--font-mono);font-size:10px;color:var(--gray-500);}
|
|
68
|
+
#rwa-pal-st.run{color:var(--gray-700);}#rwa-pal-st.ok{color:var(--green);}#rwa-pal-st.err{color:var(--red);}
|
|
69
|
+
.rwa-pal-hint{font-family:var(--font-mono);font-size:10px;color:var(--gray-400);}
|
|
70
|
+
#rwa-lens{position:fixed;left:50%;transform:translateX(-50%);bottom:24px;width:calc(100% - 48px);max-width:680px;background:var(--white);border:1px solid var(--gray-200);border-radius:var(--radius);padding:12px 16px;z-index:10;display:flex;flex-direction:column;gap:8px;box-shadow:0 2px 8px rgba(0,0,0,0.04);transition:border-color .15s,box-shadow .15s;}
|
|
71
|
+
#rwa-lens:focus-within{border-color:var(--gray-300);box-shadow:0 4px 16px rgba(0,0,0,0.08);}
|
|
72
|
+
#rwa-lens[data-mode="command"]{border-color:var(--gray-900);box-shadow:0 4px 16px rgba(0,0,0,0.08);}
|
|
73
|
+
#rwa-lens-input{width:100%;resize:none;background:transparent;border:0;color:var(--gray-900);font-family:var(--font-ui);font-size:16px;line-height:1.5;outline:none;min-height:24px;max-height:200px;overflow-y:auto;}
|
|
74
|
+
#rwa-lens-input::placeholder{color:var(--gray-400);}
|
|
75
|
+
#rwa-lens-badge{display:inline-flex;gap:6px;align-items:center;background:var(--gray-50);border:1px solid var(--gray-200);padding:4px 10px;border-radius:100px;font-size:12px;align-self:flex-start;font-family:var(--font-mono);color:var(--gray-600);}
|
|
76
|
+
#rwa-lens-badge button{background:transparent;border:0;color:var(--gray-400);cursor:pointer;font:inherit;padding:0 0 0 4px;line-height:1;display:flex;}
|
|
77
|
+
#rwa-lens-badge button:hover{color:var(--gray-700);}
|
|
78
|
+
#rwa-lens-hint{font-family:var(--font-mono);font-size:11px;color:var(--gray-400);}
|
|
79
|
+
#rwa-lens-paste-hint{font-family:var(--font-ui);font-size:13px;color:var(--gray-700);background:#fff8e1;border:1px solid #e0a500;padding:8px 12px;border-radius:8px;}
|
|
80
|
+
#rwa-lens-hist-btn{position:absolute;top:10px;right:14px;background:transparent;border:0;color:var(--gray-400);cursor:pointer;font-size:14px;padding:4px 6px;line-height:1;border-radius:50%;width:28px;height:28px;display:flex;align-items:center;justify-content:center;transition:background .15s,color .15s;}
|
|
81
|
+
#rwa-lens-hist-btn:hover{color:var(--gray-700);background:var(--gray-100);}
|
|
82
|
+
#rwa-lens-hist-panel{position:fixed;top:0;right:0;bottom:0;width:min(420px,90vw);background:var(--white);border-left:1px solid var(--gray-200);z-index:1100;overflow-y:auto;padding:20px;font-family:var(--font-ui);font-size:13px;box-shadow:-8px 0 32px rgba(0,0,0,0.06);}
|
|
83
|
+
#rwa-lens-hist-panel h3{font-family:var(--font-ui);font-size:14px;font-weight:600;color:var(--gray-900);margin-bottom:16px;display:flex;justify-content:space-between;align-items:center;}
|
|
84
|
+
#rwa-lens-hist-panel h3 button{background:transparent;border:0;color:var(--gray-400);cursor:pointer;font:inherit;font-size:20px;line-height:1;padding:0 4px;}
|
|
85
|
+
#rwa-lens-hist-panel h3 button:hover{color:var(--gray-700);}
|
|
86
|
+
#rwa-lens-hist-panel .rwa-hist-row{padding:10px 0;border-bottom:1px solid var(--gray-100);}
|
|
87
|
+
#rwa-lens-hist-panel .rwa-hist-row:last-child{border-bottom:0;}
|
|
88
|
+
#rwa-lens-hist-panel .rwa-hist-meta{display:flex;gap:8px;align-items:center;margin-bottom:4px;color:var(--gray-400);font-size:11px;font-family:var(--font-mono);}
|
|
89
|
+
#rwa-lens-hist-panel .rwa-hist-surface{background:var(--gray-100);color:var(--gray-600);padding:2px 8px;border-radius:100px;font-size:9px;letter-spacing:.5px;text-transform:uppercase;font-family:var(--font-mono);}
|
|
90
|
+
#rwa-lens-hist-panel .rwa-hist-instr{color:var(--gray-900);word-wrap:break-word;}
|
|
91
|
+
#rwa-lens-hist-panel .rwa-hist-empty{color:var(--gray-400);font-style:italic;padding:12px 0;}
|
|
92
|
+
#rwa-lens[data-busy="true"]::after{content:'';position:absolute;top:16px;right:48px;width:8px;height:8px;border-radius:50%;background:var(--gray-400);animation:rwa-lens-pulse 1.2s ease-in-out infinite;}
|
|
93
|
+
@keyframes rwa-lens-pulse{0%,100%{opacity:.3;transform:scale(1);}50%{opacity:1;transform:scale(1.2);}}
|
|
94
|
+
.rwa-lens-toast{position:fixed;bottom:140px;left:50%;transform:translateX(-50%);background:var(--gray-900);color:var(--white);padding:10px 16px;border-radius:8px;z-index:11;font-size:13px;font-family:var(--font-ui);box-shadow:0 8px 24px rgba(0,0,0,0.12);}
|
|
95
|
+
#rwa-private-mode-banner{position:fixed;inset:0;background:rgba(255,255,255,0.98);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;font-family:var(--font-ui);}
|
|
96
|
+
#rwa-private-mode-banner .rwa-pm-card{max-width:480px;text-align:center;padding:32px;border:1px solid var(--gray-200);border-radius:var(--radius);background:var(--white);box-shadow:0 8px 32px rgba(0,0,0,0.08);}
|
|
97
|
+
#rwa-private-mode-banner h2{font-size:18px;font-weight:600;color:var(--gray-900);margin:0 0 12px;}
|
|
98
|
+
#rwa-private-mode-banner p{font-size:14px;color:var(--gray-700);margin:0 0 8px;line-height:1.5;}
|
|
99
|
+
#rwa-private-mode-banner .rwa-pm-detail{font-size:12px;color:var(--gray-500);font-family:var(--font-mono);}
|
|
100
|
+
.rwa-frag-pulse{animation:rwa-frag-pulse 1.6s ease-out;}
|
|
101
|
+
@keyframes rwa-frag-pulse{0%{background:rgba(59,130,246,0.22);}100%{background:transparent;}}
|
|
102
|
+
[data-rwa-anchored]{outline:2px solid var(--gray-900);outline-offset:2px;border-radius:4px;}
|
|
103
|
+
.rwa-locked{position:relative;background:rgba(239,68,68,0.04);border-left:3px solid var(--red);padding-left:8px;}
|
|
104
|
+
.rwa-locked::before{content:'\1F512';position:absolute;top:4px;right:8px;font-size:12px;opacity:.6;}
|
|
105
|
+
@media print{
|
|
106
|
+
/* Hide all runtime chrome: status indicator, settings panel, lens. */
|
|
107
|
+
#rwa-runtime{display:none!important;}
|
|
108
|
+
body{background:#fff!important;color:#000!important;min-height:0!important;padding-bottom:0!important;}
|
|
109
|
+
/* Let the document's own padding/margins control the page layout. */
|
|
110
|
+
#rwa-doc-mount{margin:0!important;padding:0!important;}
|
|
111
|
+
}
|
|
39
112
|
</style>
|
|
40
113
|
</head>
|
|
41
114
|
<body>
|
|
@@ -56,30 +129,94 @@ const DOC_UUID = '00000000-0000-0000-0000-000000000000';
|
|
|
56
129
|
// The document — a frozen snapshot, hydrated into IndexedDB on first open
|
|
57
130
|
// and rewritten in place on every commit.
|
|
58
131
|
const INLINE_DOC = `<style>
|
|
59
|
-
.hello{display:grid;place-items:center;min-height:100dvh;text-align:center;padding:24px;}
|
|
60
|
-
.hello h1{font-family:'
|
|
61
|
-
.hello p{font-family:
|
|
62
|
-
.hello kbd{font-family:
|
|
132
|
+
.hello{display:grid;place-items:center;min-height:calc(100dvh - 200px);text-align:center;padding:24px;}
|
|
133
|
+
.hello h1{font-family:Georgia,'Times New Roman',serif;font-style:italic;font-size:clamp(48px,8vw,96px);line-height:1.05;letter-spacing:-.02em;color:var(--gray-900);}
|
|
134
|
+
.hello p{font-family:var(--font-ui,sans-serif);font-size:17px;color:var(--gray-500);margin-top:20px;max-width:42ch;line-height:1.5;}
|
|
135
|
+
.hello kbd{font-family:var(--font-mono,monospace);font-size:12px;background:var(--gray-100);border:1px solid var(--gray-200);padding:2px 7px;border-radius:6px;color:var(--gray-700);}
|
|
63
136
|
</style>
|
|
64
137
|
<div class="hello">
|
|
65
138
|
<h1>Hello, world.</h1>
|
|
66
|
-
<p>This is a re-writeable document.
|
|
139
|
+
<p>This is a re-writeable document. Type into the lens below and tell it what to become.</p>
|
|
67
140
|
</div>`;
|
|
68
141
|
|
|
142
|
+
const ANCHORABLE_TAGS = new Set(['P','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','LI','FIGURE','PRE','ASIDE']);
|
|
143
|
+
window.ANCHORABLE_TAGS = ANCHORABLE_TAGS; // expose for tests
|
|
144
|
+
|
|
69
145
|
// ─── Storage ────────────────────────────────────────────────────────
|
|
70
146
|
const RWA = {
|
|
71
147
|
DB:'rwa_'+DOC_UUID, KEY:'self',
|
|
72
|
-
DOC:'rwa_doc', UNDO:'rwa_undo', HIST:'rwa_hist', FSA:'rwa_fsa',
|
|
73
|
-
UNDO_CAP:10, HIST_CAP:
|
|
74
|
-
K_API:'rwa_apikey', K_MODEL:'rwa_model',
|
|
148
|
+
DOC:'rwa_doc', UNDO:'rwa_undo', HIST:'rwa_hist', FSA:'rwa_fsa', STATE:'rwa_state',
|
|
149
|
+
UNDO_CAP:10, HIST_CAP:1000, NUDGE_THRESHOLD:5,
|
|
150
|
+
K_API:'rwa_apikey', K_MODEL:'rwa_model', K_BACKEND:'rwa_backend',
|
|
151
|
+
// Per-backend base-URL overrides for local OpenAI-compatible servers.
|
|
152
|
+
// Empty/unset falls back to DEFAULT_*_URL below.
|
|
153
|
+
K_BASE_URL_OLLAMA:'rwa_base_url_ollama', K_BASE_URL_LMSTUDIO:'rwa_base_url_lmstudio',
|
|
75
154
|
MODEL:'google/gemini-3-flash-preview',
|
|
76
155
|
FILE:'rewritable.html',
|
|
156
|
+
// OpenAI-compat localhost defaults — both Ollama and LM Studio ship the
|
|
157
|
+
// `/v1/chat/completions` shape and reuse the same multi-turn tool-use loop
|
|
158
|
+
// as OpenRouter. The only differences are the base URL and the absence of
|
|
159
|
+
// an Authorization header. CORS must be enabled on the local server:
|
|
160
|
+
// Ollama: `OLLAMA_ORIGINS=*` (or specific origin) before `ollama serve`
|
|
161
|
+
// LMStudio: Developer panel → "Enable CORS" toggle
|
|
162
|
+
// Browsers treat http://localhost as a Secure Context so mixed-content does
|
|
163
|
+
// not block these requests even when the container is served over HTTPS.
|
|
164
|
+
DEFAULT_OLLAMA_URL:'http://localhost:11434/v1',
|
|
165
|
+
DEFAULT_LMSTUDIO_URL:'http://localhost:1234/v1',
|
|
166
|
+
// Optional alternative agent backend: a localhost CLI bridge
|
|
167
|
+
// (https://github.com/martintreiber/web_cli_bridge style) that runs
|
|
168
|
+
// arbitrary shell commands. When the user picks "bridge" in settings, ⌘K
|
|
169
|
+
// shells out to `claude -p` instead of calling OpenRouter directly. Free
|
|
170
|
+
// for users with a Claude subscription; the price is a per-call subprocess
|
|
171
|
+
// (~5-10s startup) and a single-shot agent loop (no mid-stream tool_calls).
|
|
172
|
+
BRIDGE_URL:'http://127.0.0.1:8765/run',
|
|
77
173
|
};
|
|
174
|
+
|
|
175
|
+
// `rwa new -o` / `rwa import -o` can pass first-paint configuration via URL
|
|
176
|
+
// params so the user doesn't have to open ⚙ before pressing ⌘K. Supported:
|
|
177
|
+
// ?key=… → sessionStorage rwa_apikey (OpenRouter API key)
|
|
178
|
+
// ?backend=… → sessionStorage rwa_backend (openrouter|ollama|lmstudio|bridge)
|
|
179
|
+
// ?model=… → sessionStorage rwa_model (model name string)
|
|
180
|
+
// Each lifted param is removed from the URL via history.replaceState so the
|
|
181
|
+
// key (and any later bookmark/share of the URL) does not retain the value.
|
|
182
|
+
try {
|
|
183
|
+
const qs = new URLSearchParams(location.search);
|
|
184
|
+
const k = qs.get('key');
|
|
185
|
+
if (k) {
|
|
186
|
+
sessionStorage.setItem(RWA.K_API, k);
|
|
187
|
+
qs.delete('key');
|
|
188
|
+
}
|
|
189
|
+
const b = qs.get('backend');
|
|
190
|
+
if (b && ['openrouter','ollama','lmstudio','bridge'].includes(b)) {
|
|
191
|
+
sessionStorage.setItem(RWA.K_BACKEND, b);
|
|
192
|
+
qs.delete('backend');
|
|
193
|
+
}
|
|
194
|
+
const md = qs.get('model');
|
|
195
|
+
if (md) {
|
|
196
|
+
sessionStorage.setItem(RWA.K_MODEL, md);
|
|
197
|
+
qs.delete('model');
|
|
198
|
+
}
|
|
199
|
+
if (k || b || md) {
|
|
200
|
+
const tail = qs.toString();
|
|
201
|
+
history.replaceState(null, '', location.pathname + (tail ? '?' + tail : '') + location.hash);
|
|
202
|
+
}
|
|
203
|
+
} catch (_) { /* sandboxed contexts can throw on history.replaceState */ }
|
|
204
|
+
|
|
205
|
+
// LF canonicalization: doc text is stored, validated, and committed LF-only.
|
|
206
|
+
const canonLF = s => s == null ? '' : String(s).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
207
|
+
|
|
78
208
|
let _db;
|
|
79
|
-
const REQUIRED_STORES = [RWA.DOC, RWA.UNDO, RWA.HIST, RWA.FSA];
|
|
209
|
+
const REQUIRED_STORES = [RWA.DOC, RWA.UNDO, RWA.HIST, RWA.FSA, RWA.STATE];
|
|
210
|
+
// User-declared store registry. Persisted to RWA.STATE so declarations survive
|
|
211
|
+
// reload. The openDB() upgrade handler creates declared stores on every bump.
|
|
212
|
+
// Populated at bootstrap via loadUserStoreDecls(); mutated by runtime.db.open.
|
|
213
|
+
const userStoreDecls = new Map(); // name -> { autoIncrement?: boolean }
|
|
214
|
+
let _dbVersionBumpInFlight = null; // Promise<void> while a bump is queued
|
|
80
215
|
async function openDB() {
|
|
81
216
|
if (_db) return _db;
|
|
82
|
-
//
|
|
217
|
+
// Step 1: probe without a version to learn the current DB version. On first
|
|
218
|
+
// open this creates an empty v1 DB; subsequent opens see whatever the prior
|
|
219
|
+
// session left behind. We use this number to compute the next upgrade target.
|
|
83
220
|
const probe = await new Promise((res, rej) => {
|
|
84
221
|
const r = indexedDB.open(RWA.DB);
|
|
85
222
|
r.onsuccess = () => res(r.result);
|
|
@@ -88,24 +225,38 @@ async function openDB() {
|
|
|
88
225
|
});
|
|
89
226
|
// We expect every required store to use out-of-line keys (keyPath === null).
|
|
90
227
|
// If a store is missing — or exists with the wrong schema (in-line keys from
|
|
91
|
-
// an old prototype) — we need to (re)create it.
|
|
228
|
+
// an old prototype) — we need to (re)create it. The schema-recreate path is
|
|
229
|
+
// load-bearing for users coming from rwa-edit prototypes that stored docs
|
|
230
|
+
// with `keyPath: 'k'`; dropping it would brick their containers.
|
|
92
231
|
const toRecreate = [];
|
|
93
232
|
for (const name of REQUIRED_STORES) {
|
|
94
233
|
if (!probe.objectStoreNames.contains(name)) { toRecreate.push(name); continue; }
|
|
95
234
|
const store = probe.transaction(name, 'readonly').objectStore(name);
|
|
96
235
|
if (store.keyPath !== null) toRecreate.push(name);
|
|
97
236
|
}
|
|
98
|
-
if
|
|
237
|
+
// Also bump if any declared user-store is missing from the live schema —
|
|
238
|
+
// this is how Task 2's runtime.db.open triggers a version bump and how a
|
|
239
|
+
// reload re-instantiates declared stores from persisted user_stores.
|
|
240
|
+
const missingUserStores = [...userStoreDecls.keys()].filter(s => !probe.objectStoreNames.contains(s));
|
|
241
|
+
if (toRecreate.length === 0 && missingUserStores.length === 0) { _db = probe; return _db; }
|
|
99
242
|
const newVersion = probe.version + 1;
|
|
100
243
|
probe.close();
|
|
101
244
|
_db = await new Promise((res, rej) => {
|
|
102
245
|
const r = indexedDB.open(RWA.DB, newVersion);
|
|
103
246
|
r.onupgradeneeded = e => {
|
|
104
247
|
const db = e.target.result;
|
|
248
|
+
// Required-store schema-recreate (legacy in-line-key migration + first-open).
|
|
105
249
|
toRecreate.forEach(name => {
|
|
106
250
|
if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name);
|
|
107
251
|
db.createObjectStore(name); // out-of-line keys
|
|
108
252
|
});
|
|
253
|
+
// User-declared stores: create whatever isn't already present. We never
|
|
254
|
+
// delete user stores here — declarations are additive across sessions.
|
|
255
|
+
for (const [name, opts] of userStoreDecls) {
|
|
256
|
+
if (!db.objectStoreNames.contains(name)) {
|
|
257
|
+
db.createObjectStore(name, opts && opts.autoIncrement ? { autoIncrement: true } : undefined);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
109
260
|
};
|
|
110
261
|
r.onsuccess = () => res(r.result);
|
|
111
262
|
r.onerror = () => rej(r.error);
|
|
@@ -121,30 +272,445 @@ const idbPut = (s, v, k = RWA.KEY) => openDB().then(db => new Promise((res, rej)
|
|
|
121
272
|
const r = db.transaction(s, 'readwrite').objectStore(s).put(v, k);
|
|
122
273
|
r.onsuccess = () => res(); r.onerror = () => rej(r.error);
|
|
123
274
|
}));
|
|
275
|
+
const idbDel = (s, k = RWA.KEY) => openDB().then(db => new Promise((res, rej) => {
|
|
276
|
+
const r = db.transaction(s, 'readwrite').objectStore(s).delete(k);
|
|
277
|
+
r.onsuccess = () => res(); r.onerror = () => rej(r.error);
|
|
278
|
+
}));
|
|
279
|
+
|
|
280
|
+
// === Public runtime API (spec §7) ===========================================
|
|
281
|
+
// Exposed on window.runtime once the bootstrap IIFE successfully passes
|
|
282
|
+
// private-mode detection and openDB(). Documents read/write their own
|
|
283
|
+
// structured data through runtime.db.*, blobs through runtime.fs.*, drive the
|
|
284
|
+
// modify loop via runtime.modify/commit/undo, and observe state via
|
|
285
|
+
// runtime.status + runtime.on.
|
|
286
|
+
//
|
|
287
|
+
// Reserved-name enforcement: any store name matching ^rwa_ is rejected with
|
|
288
|
+
// RwaReservedError. The runtime owns rwa_doc/rwa_undo/rwa_hist/rwa_fsa/
|
|
289
|
+
// rwa_state and any future rwa_* (spec §5.3).
|
|
290
|
+
|
|
291
|
+
class RwaReservedError extends Error {
|
|
292
|
+
constructor(name) { super(`store name '${name}' is reserved (rwa_* is runtime-only)`); this.name = 'RwaReservedError'; }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function assertRuntimeDbStore(name) {
|
|
296
|
+
if (typeof name !== 'string' || !name) throw new TypeError('store name must be a non-empty string');
|
|
297
|
+
if (/^rwa_/.test(name)) throw new RwaReservedError(name);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Producer-side BroadcastChannel cache. Each store has one writer channel that
|
|
301
|
+
// runtime.db.{put,del} reuse to fan out events to subscribers. Subscribers
|
|
302
|
+
// open their OWN channel on the same name (see runtimeDbSubscribe) because
|
|
303
|
+
// BroadcastChannel does not deliver a message back to the channel that posted
|
|
304
|
+
// it (per spec: "Messages are received by all channels of the same name, in
|
|
305
|
+
// the same agent cluster, except the source channel"). Channel name is
|
|
306
|
+
// 'rwa_<DOC_UUID>:<store>' — namespaced to the container so cross-container
|
|
307
|
+
// noise is impossible even if two rwa files share an origin.
|
|
308
|
+
const runtimeDbChannels = new Map(); // store -> BroadcastChannel
|
|
309
|
+
function getStoreChannel(store) {
|
|
310
|
+
let ch = runtimeDbChannels.get(store);
|
|
311
|
+
if (!ch) {
|
|
312
|
+
ch = new BroadcastChannel('rwa_' + DOC_UUID + ':' + store);
|
|
313
|
+
runtimeDbChannels.set(store, ch);
|
|
314
|
+
}
|
|
315
|
+
return ch;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function runtimeDbGet(store, key) {
|
|
319
|
+
assertRuntimeDbStore(store);
|
|
320
|
+
return idbGet(store, key);
|
|
321
|
+
}
|
|
322
|
+
async function runtimeDbPut(store, key, value) {
|
|
323
|
+
assertRuntimeDbStore(store);
|
|
324
|
+
const decl = userStoreDecls.get(store);
|
|
325
|
+
// For autoIncrement stores, callers pass a null/undefined key — IDB assigns
|
|
326
|
+
// a monotonic integer. We route around idbPut() because that helper always
|
|
327
|
+
// forwards the (possibly null) key to objectStore.put(value, key), which IDB
|
|
328
|
+
// rejects with DataError when keyPath is null AND no out-of-line key is given.
|
|
329
|
+
let resolvedKey;
|
|
330
|
+
if ((key === null || key === undefined) && decl?.autoIncrement) {
|
|
331
|
+
const db = await openDB();
|
|
332
|
+
resolvedKey = await new Promise((res, rej) => {
|
|
333
|
+
const tx = db.transaction(store, 'readwrite');
|
|
334
|
+
const req = tx.objectStore(store).put(value);
|
|
335
|
+
req.onsuccess = () => res(req.result);
|
|
336
|
+
req.onerror = () => rej(req.error);
|
|
337
|
+
});
|
|
338
|
+
} else {
|
|
339
|
+
// Non-autoIncrement stores require an explicit key. Without this guard the
|
|
340
|
+
// call falls through to idbPut(store, value, null), which surfaces an opaque
|
|
341
|
+
// DataError from IDB; flag the API misuse up front so callers know to either
|
|
342
|
+
// pass a key or declare the store with {autoIncrement: true}.
|
|
343
|
+
if (key === null || key === undefined) {
|
|
344
|
+
throw new TypeError(`runtime.db.put: key is required for non-autoIncrement store '${store}' (declare with {autoIncrement: true} to omit keys)`);
|
|
345
|
+
}
|
|
346
|
+
await idbPut(store, value, key); // internal idbPut keeps (store, value, key) order
|
|
347
|
+
resolvedKey = key;
|
|
348
|
+
}
|
|
349
|
+
// Fan out AFTER the IDB write resolves so subscribers never observe a key
|
|
350
|
+
// that the store didn't actually commit. The resolved key matters for
|
|
351
|
+
// autoIncrement stores — subscribers shouldn't have to guess what IDB picked.
|
|
352
|
+
getStoreChannel(store).postMessage({ kind: 'put', key: resolvedKey });
|
|
353
|
+
return resolvedKey;
|
|
354
|
+
}
|
|
355
|
+
async function runtimeDbDel(store, key) {
|
|
356
|
+
assertRuntimeDbStore(store);
|
|
357
|
+
await idbDel(store, key);
|
|
358
|
+
getStoreChannel(store).postMessage({ kind: 'del', key });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function runtimeDbSubscribe(store, callback) {
|
|
362
|
+
assertRuntimeDbStore(store);
|
|
363
|
+
if (typeof callback !== 'function') throw new TypeError('callback must be a function');
|
|
364
|
+
// Open a fresh channel rather than reusing the producer's: a BroadcastChannel
|
|
365
|
+
// does not receive its own postMessage, so listening on the cached writer
|
|
366
|
+
// would silently swallow every local event. Same-name channels in the same
|
|
367
|
+
// agent cluster DO see each other, so this still works in-tab + cross-tab.
|
|
368
|
+
const ch = new BroadcastChannel('rwa_' + DOC_UUID + ':' + store);
|
|
369
|
+
const handler = (msg) => { try { callback(msg.data); } catch (_) { /* never let a buggy doc-side callback break the runtime */ } };
|
|
370
|
+
ch.addEventListener('message', handler);
|
|
371
|
+
return () => { ch.removeEventListener('message', handler); ch.close(); };
|
|
372
|
+
}
|
|
373
|
+
async function runtimeDbAll(store) {
|
|
374
|
+
assertRuntimeDbStore(store);
|
|
375
|
+
const db = await openDB();
|
|
376
|
+
return new Promise((res, rej) => {
|
|
377
|
+
const tx = db.transaction(store, 'readonly');
|
|
378
|
+
const os = tx.objectStore(store);
|
|
379
|
+
const out = [];
|
|
380
|
+
const req = os.openCursor();
|
|
381
|
+
req.onsuccess = () => {
|
|
382
|
+
const cur = req.result;
|
|
383
|
+
if (!cur) return res(out);
|
|
384
|
+
out.push({ key: cur.key, value: cur.value });
|
|
385
|
+
cur.continue();
|
|
386
|
+
};
|
|
387
|
+
req.onerror = () => rej(req.error);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// User-store declarations are persisted under rwa_state['user_stores'] so a
|
|
392
|
+
// reload re-instantiates them via openDB()'s upgrade path. The shape is
|
|
393
|
+
// { [name]: { autoIncrement?: boolean } } — must round-trip through structured
|
|
394
|
+
// clone, so plain JSON-shaped objects only.
|
|
395
|
+
async function loadUserStoreDecls() {
|
|
396
|
+
const stored = await idbGet(RWA.STATE, 'user_stores');
|
|
397
|
+
if (stored && typeof stored === 'object') {
|
|
398
|
+
for (const [name, opts] of Object.entries(stored)) userStoreDecls.set(name, opts || {});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function persistUserStoreDecls() {
|
|
402
|
+
const out = {};
|
|
403
|
+
for (const [name, opts] of userStoreDecls) out[name] = opts;
|
|
404
|
+
await idbPut(RWA.STATE, out, 'user_stores');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Serialize concurrent runtime.db.open calls: each one needs to close + reopen
|
|
408
|
+
// the connection, and IDB version-bumps can't overlap. The in-flight promise
|
|
409
|
+
// fans out, queues the next bump, then drops the latch.
|
|
410
|
+
async function bumpVersionAndCreateStore(name, opts) {
|
|
411
|
+
if (_dbVersionBumpInFlight) await _dbVersionBumpInFlight;
|
|
412
|
+
_dbVersionBumpInFlight = (async () => {
|
|
413
|
+
if (_db) { _db.close(); _db = null; }
|
|
414
|
+
userStoreDecls.set(name, opts || {});
|
|
415
|
+
await openDB();
|
|
416
|
+
await persistUserStoreDecls();
|
|
417
|
+
})();
|
|
418
|
+
try { await _dbVersionBumpInFlight; } finally { _dbVersionBumpInFlight = null; }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function runtimeDbOpen(name, opts = {}) {
|
|
422
|
+
assertRuntimeDbStore(name);
|
|
423
|
+
const db = await openDB();
|
|
424
|
+
// Idempotent on schema match: an already-present store is a no-op when the
|
|
425
|
+
// caller's opts agree with the live schema. We never re-create a store under
|
|
426
|
+
// the same name — that would silently drop user data — and we reject opts
|
|
427
|
+
// that contradict the live schema so authors aren't surprised by invisible
|
|
428
|
+
// drift (e.g. db.put(name, null, …) misfiring on a non-autoIncrement store
|
|
429
|
+
// the registry mistakenly thinks is autoIncrement).
|
|
430
|
+
if (db.objectStoreNames.contains(name)) {
|
|
431
|
+
const tx = db.transaction(name, 'readonly');
|
|
432
|
+
const liveAutoInc = !!tx.objectStore(name).autoIncrement;
|
|
433
|
+
tx.abort?.(); // explicit hygiene; readonly tx would auto-close anyway
|
|
434
|
+
const wantAutoInc = !!opts.autoIncrement;
|
|
435
|
+
if (liveAutoInc !== wantAutoInc) {
|
|
436
|
+
throw new Error(`runtime.db.open: store '${name}' already exists with autoIncrement=${liveAutoInc}, cannot change to ${wantAutoInc}. Pick a different name.`);
|
|
437
|
+
}
|
|
438
|
+
// Self-heal: registry may have drifted from a prior reload or a failed
|
|
439
|
+
// persist; rewrite it from the authoritative live schema.
|
|
440
|
+
const cur = userStoreDecls.get(name);
|
|
441
|
+
if (!cur || !!cur.autoIncrement !== liveAutoInc) {
|
|
442
|
+
userStoreDecls.set(name, { autoIncrement: liveAutoInc });
|
|
443
|
+
await persistUserStoreDecls();
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
await bumpVersionAndCreateStore(name, opts);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// === runtime.fs (OPFS-backed, per-container namespaced) =====================
|
|
451
|
+
// Spec §7: documents persist blobs via runtime.fs.{read,write,del,list}.
|
|
452
|
+
// Spec §5.7: each container's blobs live under `_<DOC_UUID>/` in the shared
|
|
453
|
+
// OPFS so two containers in the same origin can't shadow each other's paths
|
|
454
|
+
// even though OPFS is null-origin under file://.
|
|
455
|
+
//
|
|
456
|
+
// Documents see paths like "img/cat.png"; the runtime transparently rewrites
|
|
457
|
+
// them to "_<DOC_UUID>/img/cat.png" on the way in. The namespace prefix is
|
|
458
|
+
// reserved (the leading underscore + UUID is opaque to the doc) and the
|
|
459
|
+
// runtime rejects any user-supplied path that tries to address `_rwa/` or
|
|
460
|
+
// leading-slash absolutes — those would let a doc trample reserved
|
|
461
|
+
// runtime-only paths or escape the namespace.
|
|
462
|
+
const OPFS_NS = '_' + DOC_UUID + '/';
|
|
463
|
+
|
|
464
|
+
function assertUserFsPath(path) {
|
|
465
|
+
if (typeof path !== 'string') throw new TypeError('path must be a string');
|
|
466
|
+
// An empty path has no meaningful target — downstream behavior is undefined
|
|
467
|
+
// (the stub silently writes to `prefix + 'undefined'`; real OPFS throws a
|
|
468
|
+
// cryptic TypeError on the underlying getFileHandle('')). Reject early.
|
|
469
|
+
if (path === '') throw new Error('path must be non-empty');
|
|
470
|
+
// `_rwa/` is the reserved runtime path prefix (spec §5.3). Any user-supplied
|
|
471
|
+
// path that starts with it is rejected up front — a doc that smuggled a blob
|
|
472
|
+
// there could later collide with a future runtime-owned subtree.
|
|
473
|
+
if (path.startsWith('_rwa/')) throw new Error("path '_rwa/' is reserved (runtime-only)");
|
|
474
|
+
// OPFS doesn't have absolute paths; a leading slash usually means the author
|
|
475
|
+
// confused this for a filesystem-rooted path. Reject so the mistake surfaces
|
|
476
|
+
// at the call site instead of silently writing to a weird subdirectory.
|
|
477
|
+
if (path.startsWith('/')) throw new Error("path must be relative (no leading slash)");
|
|
478
|
+
// `.` / `..` segments aren't traversable in OPFS — real browsers throw a
|
|
479
|
+
// TypeError on getDirectoryHandle('..') / getFileHandle('.'). Reject early
|
|
480
|
+
// so the author sees a path-shaped error instead of an opaque DOM exception,
|
|
481
|
+
// and so we never accidentally let a doc try to escape its own namespace.
|
|
482
|
+
const segs = path.split('/');
|
|
483
|
+
if (segs.some(s => s === '.' || s === '..')) {
|
|
484
|
+
throw new Error("path segments '.' and '..' are not allowed");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Resolve the per-container OPFS root, creating it on first access. Throws a
|
|
489
|
+
// readable error on platforms that don't expose OPFS (older Safari, Firefox
|
|
490
|
+
// without the flag) so the doc can decide whether to degrade or surface to
|
|
491
|
+
// the user instead of seeing an opaque TypeError on `getDirectory`.
|
|
492
|
+
async function opfsRootForContainer() {
|
|
493
|
+
if (!navigator.storage || !navigator.storage.getDirectory) {
|
|
494
|
+
throw new Error('OPFS not supported in this environment');
|
|
495
|
+
}
|
|
496
|
+
let root;
|
|
497
|
+
try {
|
|
498
|
+
root = await navigator.storage.getDirectory();
|
|
499
|
+
} catch (e) {
|
|
500
|
+
// Chromium blocks OPFS under file:// origins with a SecurityError
|
|
501
|
+
// ("certain files are unsafe for access within a Web application").
|
|
502
|
+
// IDB works on file:// but OPFS does not — translate the cryptic native
|
|
503
|
+
// error into something a document author can act on.
|
|
504
|
+
if (e?.name === 'SecurityError') {
|
|
505
|
+
throw new Error(
|
|
506
|
+
"runtime.fs: OPFS is not available on file:// origins (Chromium policy). " +
|
|
507
|
+
"Host this container via HTTP — e.g. `node service/server.js` then open " +
|
|
508
|
+
"http://localhost:8080/ — or use runtime.db.* for storage."
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
throw e;
|
|
512
|
+
}
|
|
513
|
+
// OPFS_NS ends in '/' to make string concatenation in the stub-side
|
|
514
|
+
// bookkeeping unambiguous; the real OPFS API takes bare segment names, so
|
|
515
|
+
// strip the trailing slash before handing it to getDirectoryHandle.
|
|
516
|
+
return root.getDirectoryHandle(OPFS_NS.replace(/\/$/, ''), { create: true });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Walk slash-separated `path` to the terminal file handle. `create` is
|
|
520
|
+
// threaded into every directory hop AND the final file hop, so callers
|
|
521
|
+
// distinguish "write/create" from "read/must-exist" with a single flag.
|
|
522
|
+
async function walkToFile(path, { create } = {}) {
|
|
523
|
+
const parts = path.split('/').filter(Boolean);
|
|
524
|
+
const name = parts.pop();
|
|
525
|
+
let dir = await opfsRootForContainer();
|
|
526
|
+
for (const p of parts) dir = await dir.getDirectoryHandle(p, { create });
|
|
527
|
+
return dir.getFileHandle(name, { create });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function runtimeFsWrite(path, blob) {
|
|
531
|
+
assertUserFsPath(path);
|
|
532
|
+
const handle = await walkToFile(path, { create: true });
|
|
533
|
+
const writable = await handle.createWritable();
|
|
534
|
+
// Mirror the FSA write pattern used elsewhere in the bootstrap: close in a
|
|
535
|
+
// finally so a partial-write failure still flushes the writer (avoids
|
|
536
|
+
// dangling write streams that some browsers refuse to GC).
|
|
537
|
+
try { await writable.write(blob); } finally { await writable.close(); }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function runtimeFsRead(path) {
|
|
541
|
+
assertUserFsPath(path);
|
|
542
|
+
let handle;
|
|
543
|
+
try {
|
|
544
|
+
handle = await walkToFile(path);
|
|
545
|
+
} catch (e) {
|
|
546
|
+
// Real OPFS throws a bare NotFoundError with no path context. Documents
|
|
547
|
+
// that catch this can't tell which path failed without parsing the entire
|
|
548
|
+
// call site, so we rewrap with the originating path — matches the
|
|
549
|
+
// descriptive-error style used elsewhere on runtime.*.
|
|
550
|
+
if (e?.name === 'NotFoundError') throw new Error("runtime.fs.read: no file at '" + path + "'");
|
|
551
|
+
throw e;
|
|
552
|
+
}
|
|
553
|
+
return handle.getFile();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function runtimeFsDel(path) {
|
|
557
|
+
assertUserFsPath(path);
|
|
558
|
+
const parts = path.split('/').filter(Boolean);
|
|
559
|
+
const name = parts.pop();
|
|
560
|
+
let dir = await opfsRootForContainer();
|
|
561
|
+
try {
|
|
562
|
+
for (const p of parts) dir = await dir.getDirectoryHandle(p);
|
|
563
|
+
// `{ recursive: true }` lets del work on non-empty directories — real
|
|
564
|
+
// browsers reject a plain removeEntry on a non-empty dir with
|
|
565
|
+
// InvalidModificationError. Use recursive so the API is symmetric with
|
|
566
|
+
// write (which silently creates any intermediate dirs).
|
|
567
|
+
await dir.removeEntry(name, { recursive: true });
|
|
568
|
+
} catch (e) {
|
|
569
|
+
if (e?.name === 'NotFoundError') throw new Error("runtime.fs.del: no file at '" + path + "'");
|
|
570
|
+
throw e;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function runtimeFsList(prefix) {
|
|
575
|
+
// For list only, empty/undefined means "root of the container namespace" —
|
|
576
|
+
// it's the natural way to enumerate blobs without knowing subdir names.
|
|
577
|
+
// read/write/del keep the strict guard (they need a real path).
|
|
578
|
+
if (prefix === undefined || prefix === '') prefix = '';
|
|
579
|
+
else assertUserFsPath(prefix);
|
|
580
|
+
const parts = prefix.split('/').filter(Boolean);
|
|
581
|
+
let dir = await opfsRootForContainer();
|
|
582
|
+
try {
|
|
583
|
+
for (const p of parts) dir = await dir.getDirectoryHandle(p);
|
|
584
|
+
} catch (e) {
|
|
585
|
+
// A list against a never-written directory is not an error in document
|
|
586
|
+
// land — most authors will call list() defensively to discover what
|
|
587
|
+
// exists. Return [] for NotFoundError only; any other failure (a
|
|
588
|
+
// TypeError on an invalid name, a SecurityError under iframe sandboxing)
|
|
589
|
+
// is a real problem the doc should see, so re-throw it.
|
|
590
|
+
if (e?.name === 'NotFoundError') return [];
|
|
591
|
+
throw e;
|
|
592
|
+
}
|
|
593
|
+
const out = [];
|
|
594
|
+
for await (const [name, h] of dir.entries()) {
|
|
595
|
+
out.push({ name, kind: h.kind });
|
|
596
|
+
}
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// === Runtime event bus (spec §7) ============================================
|
|
601
|
+
// Minimal pub/sub for commit/modify/status. Callbacks are scoped per event
|
|
602
|
+
// name; an event name not in this whitelist throws on subscribe so a typo
|
|
603
|
+
// (or a future event a doc hopes already exists) fails loud at the wire-up
|
|
604
|
+
// site rather than silently never firing.
|
|
605
|
+
const runtimeEvents = {
|
|
606
|
+
commit: new Set(),
|
|
607
|
+
modify: new Set(),
|
|
608
|
+
status: new Set(),
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
function runtimeOn(event, callback) {
|
|
612
|
+
if (!Object.prototype.hasOwnProperty.call(runtimeEvents, event)) {
|
|
613
|
+
throw new Error("unknown event '" + event + "' (use 'commit', 'modify', or 'status')");
|
|
614
|
+
}
|
|
615
|
+
if (typeof callback !== 'function') throw new TypeError('callback must be a function');
|
|
616
|
+
runtimeEvents[event].add(callback);
|
|
617
|
+
return () => { runtimeEvents[event].delete(callback); };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Invoke every subscriber for `event`. Callback errors are caught so one buggy
|
|
621
|
+
// listener can't break delivery to the others, and so the synchronous emit
|
|
622
|
+
// inside e.g. setDirty(false) can never propagate an exception into the
|
|
623
|
+
// commit() / modify() success path (which would surface as a fake failure).
|
|
624
|
+
function emitRuntimeEvent(event, payload) {
|
|
625
|
+
// Snapshot the Set before iterating: a listener subscribing or unsubscribing
|
|
626
|
+
// during this emit must not perturb the current round (conventional pubsub
|
|
627
|
+
// semantics — subscribed-during-emit ⇒ skip until next emit).
|
|
628
|
+
for (const cb of [...runtimeEvents[event]]) {
|
|
629
|
+
try { cb(payload); } catch (_) { /* swallow user-cb errors */ }
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// === Status surface (spec §7) ==============================================
|
|
634
|
+
// FSA permission state. Tracked here rather than re-derived each call because
|
|
635
|
+
// the permission events that move it (showSaveFilePicker, queryPermission,
|
|
636
|
+
// requestPermission, lost-handle InvalidStateError) are scattered across the
|
|
637
|
+
// commit() path; centralizing the mutation keeps the status snapshot honest.
|
|
638
|
+
//
|
|
639
|
+
// Values:
|
|
640
|
+
// 'unsupported' — no showSaveFilePicker in the runtime (Firefox/Safari)
|
|
641
|
+
// 'prompt' — handle exists but the user hasn't granted yet this session
|
|
642
|
+
// 'granted' — user said yes; subsequent writes won't reprompt
|
|
643
|
+
// 'denied' — user said no
|
|
644
|
+
// 'lost' — a write threw InvalidStateError (handle no longer valid)
|
|
645
|
+
let _fsaState = (typeof window !== 'undefined' && 'showSaveFilePicker' in window) ? 'prompt' : 'unsupported';
|
|
646
|
+
// Last storage estimate captured by rwaCheckQuota. null until the first
|
|
647
|
+
// successful estimate() — keeps the shape testable on platforms that don't
|
|
648
|
+
// expose navigator.storage.estimate at all.
|
|
649
|
+
let _storageStat = null;
|
|
650
|
+
|
|
651
|
+
function getStatusSnapshot() {
|
|
652
|
+
return {
|
|
653
|
+
dirty: !!document.getElementById('rwa-st-commit')?.classList.contains('dirty'),
|
|
654
|
+
fsa: _fsaState,
|
|
655
|
+
storage: _storageStat,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// === Modify/commit/undo programmatic wrappers (spec §7) ====================
|
|
660
|
+
// Thin wrappers around the internal lifecycle. Wrappers (not direct exposure)
|
|
661
|
+
// so a doc-side caller can't accidentally shadow or swap the internal
|
|
662
|
+
// references that the lens/agent paths rely on.
|
|
663
|
+
async function runtimeModify(instruction) { return modify(instruction); }
|
|
664
|
+
async function runtimeCommit() { return commit(); }
|
|
665
|
+
async function runtimeUndo() { return undo(); }
|
|
124
666
|
|
|
125
667
|
async function getDoc() {
|
|
126
668
|
let d = await idbGet(RWA.DOC);
|
|
127
669
|
if (d == null) { d = INLINE_DOC; await idbPut(RWA.DOC, d); }
|
|
128
670
|
return d;
|
|
129
671
|
}
|
|
130
|
-
async function pushUndo(p) {
|
|
131
|
-
const s = (await idbGet(RWA.UNDO)) || [];
|
|
132
|
-
s.push(p); while (s.length > RWA.UNDO_CAP) s.shift();
|
|
133
|
-
await idbPut(RWA.UNDO, s);
|
|
134
|
-
}
|
|
135
672
|
async function popUndo() {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
673
|
+
// Read+write in one tx: rapid ⌘Z keypresses would otherwise interleave the
|
|
674
|
+
// get and the put, both observe the same array, both pop the same entry,
|
|
675
|
+
// and the user would see only one undo for two presses.
|
|
676
|
+
const db = await openDB();
|
|
677
|
+
return new Promise((res, rej) => {
|
|
678
|
+
const tx = db.transaction(RWA.UNDO, 'readwrite');
|
|
679
|
+
const store = tx.objectStore(RWA.UNDO);
|
|
680
|
+
const r = store.get(RWA.KEY);
|
|
681
|
+
let v = null;
|
|
682
|
+
r.onsuccess = () => {
|
|
683
|
+
const s = r.result || [];
|
|
684
|
+
if (!s.length) return;
|
|
685
|
+
v = s.pop();
|
|
686
|
+
store.put(s, RWA.KEY);
|
|
687
|
+
};
|
|
688
|
+
tx.oncomplete = () => res(v);
|
|
689
|
+
tx.onerror = () => rej(tx.error || new Error('popUndo tx error'));
|
|
690
|
+
tx.onabort = () => rej(tx.error || new Error('popUndo tx aborted'));
|
|
691
|
+
});
|
|
143
692
|
}
|
|
144
|
-
|
|
145
693
|
// ─── Render ─────────────────────────────────────────────────────────
|
|
146
694
|
function renderDoc(html) {
|
|
147
695
|
const m = document.getElementById('rwa-doc-mount');
|
|
696
|
+
// rwa-lens/1: if the lens has been re-parented into the mount (anchored
|
|
697
|
+
// state in Task 5.2), release it before we wipe innerHTML — otherwise the
|
|
698
|
+
// lens DOM disappears with the mount's children, leaving stale lensState.
|
|
699
|
+
// The source map is about to rebuild, so the held entry is invalid anyway.
|
|
700
|
+
if (typeof releaseAnchor === 'function') {
|
|
701
|
+
const lens = document.getElementById('rwa-lens');
|
|
702
|
+
if (lens && m.contains(lens)) releaseAnchor();
|
|
703
|
+
}
|
|
704
|
+
// Capture id-keyed form state so user-entered values survive an unrelated
|
|
705
|
+
// edit (the doc gets re-rendered via innerHTML, which would otherwise
|
|
706
|
+
// destroy them). Spec/benchmark APP-01/APP-02. Only id-keyed elements
|
|
707
|
+
// round-trip — anonymous inputs are inherently unmappable across renders.
|
|
708
|
+
const captured = new Map();
|
|
709
|
+
m.querySelectorAll('input,textarea,select,details').forEach(el => {
|
|
710
|
+
if (!el.id) return;
|
|
711
|
+
if (el.tagName === 'DETAILS') captured.set(el.id, { kind: 'details', open: el.open });
|
|
712
|
+
else captured.set(el.id, { kind: 'value', value: el.value });
|
|
713
|
+
});
|
|
148
714
|
m.innerHTML = html;
|
|
149
715
|
m.querySelectorAll('script').forEach(o => {
|
|
150
716
|
const s = document.createElement('script');
|
|
@@ -152,11 +718,55 @@ function renderDoc(html) {
|
|
|
152
718
|
s.textContent = o.textContent;
|
|
153
719
|
o.parentNode.replaceChild(s, o);
|
|
154
720
|
});
|
|
721
|
+
for (const [id, snap] of captured) {
|
|
722
|
+
const el = m.querySelector('#' + (typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id));
|
|
723
|
+
if (!el) continue;
|
|
724
|
+
if (snap.kind === 'details' && el.tagName === 'DETAILS') el.open = snap.open;
|
|
725
|
+
else if (snap.kind === 'value' && 'value' in el) el.value = snap.value;
|
|
726
|
+
}
|
|
727
|
+
// rwa-lens/1: rebuild the source-position map so anchored interactions stay
|
|
728
|
+
// aligned with the just-rendered doc. setSourceMap is declared further down
|
|
729
|
+
// in this script — JS hoisting (script-mode `function` declarations) makes
|
|
730
|
+
// it callable here even though renderDoc is defined first.
|
|
731
|
+
setSourceMap(html);
|
|
732
|
+
rebuildLockedRanges(html);
|
|
733
|
+
// rwa-lens/1: click-to-anchor (Task 5.1). renderDoc runs on every commit and
|
|
734
|
+
// on bootstrap; remove first so listeners don't multiply across renders. Same
|
|
735
|
+
// function reference, so removing the previous instance is safe.
|
|
736
|
+
m.removeEventListener('click', handleMountClick);
|
|
737
|
+
m.addEventListener('click', handleMountClick);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ─── URL fragment scroll (rwa-bootstrap 0.9) ───────────────────────
|
|
741
|
+
// Resolves `location.hash` against either a literal `id=` attribute or a
|
|
742
|
+
// runtime-assigned `data-rwa-id`. On hit, scrolls the target into view and
|
|
743
|
+
// pulses it with a blue-tint background fade so the location is obvious
|
|
744
|
+
// after the scroll. Idempotent — wired to both the initial load (called from
|
|
745
|
+
// the bootstrap IIFE) and `hashchange` (any in-page navigation).
|
|
746
|
+
function scrollToFragment() {
|
|
747
|
+
if (!location.hash || location.hash.length < 2) return;
|
|
748
|
+
let raw;
|
|
749
|
+
try { raw = decodeURIComponent(location.hash.slice(1)); } catch (_) { raw = location.hash.slice(1); }
|
|
750
|
+
if (!raw) return;
|
|
751
|
+
const mount = document.getElementById('rwa-doc-mount');
|
|
752
|
+
if (!mount) return;
|
|
753
|
+
let el = null;
|
|
754
|
+
try {
|
|
755
|
+
const esc = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(raw) : raw.replace(/(["\\])/g, '\\$1');
|
|
756
|
+
el = mount.querySelector('#' + esc + ', [data-rwa-id="' + esc + '"]');
|
|
757
|
+
} catch (_) {
|
|
758
|
+
el = mount.querySelector('[data-rwa-id="' + raw.replace(/"/g, '\\"') + '"]');
|
|
759
|
+
}
|
|
760
|
+
if (!el) return;
|
|
761
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
762
|
+
el.classList.add('rwa-frag-pulse');
|
|
763
|
+
setTimeout(() => { el.classList.remove('rwa-frag-pulse'); }, 1700);
|
|
155
764
|
}
|
|
765
|
+
window.scrollToFragment = scrollToFragment; // expose for tests
|
|
156
766
|
|
|
157
767
|
// ─── File rebuild ───────────────────────────────────────────────────
|
|
158
768
|
let FROZEN = '';
|
|
159
|
-
const escapeTL = s => s
|
|
769
|
+
const escapeTL = s => canonLF(s)
|
|
160
770
|
.replace(/\\/g, '\\\\')
|
|
161
771
|
.replace(/`/g, '\\`')
|
|
162
772
|
.replace(/\$\{/g, '\\${')
|
|
@@ -185,8 +795,11 @@ function buildUI() {
|
|
|
185
795
|
<button class="rwa-st-btn pri" id="rwa-st-commit">⌘S</button>
|
|
186
796
|
</div>
|
|
187
797
|
<div id="rwa-set-panel">
|
|
188
|
-
<div class="rwa-set-row"><label>
|
|
189
|
-
<div class="rwa-set-row"><label>
|
|
798
|
+
<div class="rwa-set-row"><label>Backend</label><select id="rwa-backend"><option value="openrouter">OpenRouter (API key)</option><option value="ollama">Ollama (localhost)</option><option value="lmstudio">LM Studio (localhost)</option><option value="bridge">Bridge (claude -p, localhost)</option></select></div>
|
|
799
|
+
<div class="rwa-set-row" id="rwa-set-row-key"><label>OpenRouter Key</label><input type="password" id="rwa-key" placeholder="sk-or-..." autocomplete="off"></div>
|
|
800
|
+
<div class="rwa-set-row" id="rwa-set-row-base-url" style="display:none;"><label>Base URL</label><div class="rwa-set-base-url-line"><input type="text" id="rwa-base-url" autocomplete="off" spellcheck="false"><button type="button" id="rwa-base-url-test">Test</button></div><div id="rwa-base-url-result" class="rwa-set-hint"></div></div>
|
|
801
|
+
<div class="rwa-set-row" id="rwa-set-row-model"><label>Model</label><input type="text" id="rwa-model" autocomplete="off" list="rwa-model-options"><datalist id="rwa-model-options"></datalist></div>
|
|
802
|
+
<div class="rwa-set-row" id="rwa-set-row-hint" style="display:none;"><div id="rwa-backend-hint" class="rwa-set-hint"></div></div>
|
|
190
803
|
</div>
|
|
191
804
|
<div id="rwa-pal">
|
|
192
805
|
<div id="rwa-pal-box">
|
|
@@ -200,7 +813,15 @@ function buildUI() {
|
|
|
200
813
|
<span class="rwa-pal-hint">⌘Z undo · ⌘S commit · esc close</span>
|
|
201
814
|
</div>
|
|
202
815
|
</div>
|
|
203
|
-
</div
|
|
816
|
+
</div>
|
|
817
|
+
<div id="rwa-lens" data-state="default">
|
|
818
|
+
<button type="button" id="rwa-lens-hist-btn" aria-label="show history" title="show history"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l3 2"/></svg></button>
|
|
819
|
+
<div id="rwa-lens-badge" hidden></div>
|
|
820
|
+
<textarea id="rwa-lens-input" rows="1" placeholder="Write, or describe what you want." spellcheck="false"></textarea>
|
|
821
|
+
<div id="rwa-lens-paste-hint" hidden></div>
|
|
822
|
+
<div id="rwa-lens-hint"></div>
|
|
823
|
+
</div>
|
|
824
|
+
<div id="rwa-lens-hist-panel" hidden></div>`;
|
|
204
825
|
|
|
205
826
|
const k = document.getElementById('rwa-key'), m = document.getElementById('rwa-model');
|
|
206
827
|
k.value = sessionStorage.getItem(RWA.K_API) || '';
|
|
@@ -208,6 +829,104 @@ function buildUI() {
|
|
|
208
829
|
k.oninput = e => sessionStorage.setItem(RWA.K_API, e.target.value.trim());
|
|
209
830
|
m.oninput = e => sessionStorage.setItem(RWA.K_MODEL, e.target.value.trim() || RWA.MODEL);
|
|
210
831
|
|
|
832
|
+
const backendEl = document.getElementById('rwa-backend');
|
|
833
|
+
const baseUrlEl = document.getElementById('rwa-base-url');
|
|
834
|
+
const baseUrlTestBtn = document.getElementById('rwa-base-url-test');
|
|
835
|
+
const baseUrlResultEl = document.getElementById('rwa-base-url-result');
|
|
836
|
+
const hintEl = document.getElementById('rwa-backend-hint');
|
|
837
|
+
const modelOptsEl = document.getElementById('rwa-model-options');
|
|
838
|
+
|
|
839
|
+
const BACKEND_META = {
|
|
840
|
+
openrouter: {
|
|
841
|
+
showKey: true, showBaseUrl: false, showModel: true, showHint: false,
|
|
842
|
+
hintHTML: '',
|
|
843
|
+
},
|
|
844
|
+
ollama: {
|
|
845
|
+
showKey: false, showBaseUrl: true, showModel: true, showHint: true,
|
|
846
|
+
hintHTML: 'Local Ollama needs CORS allowed. Before <code>ollama serve</code>, set <code>OLLAMA_ORIGINS=*</code> (or just your site origin). On macOS, persist with <code>launchctl setenv OLLAMA_ORIGINS "*"</code> and restart the Ollama app.',
|
|
847
|
+
defaultUrl: RWA.DEFAULT_OLLAMA_URL,
|
|
848
|
+
storageKey: RWA.K_BASE_URL_OLLAMA,
|
|
849
|
+
},
|
|
850
|
+
lmstudio: {
|
|
851
|
+
showKey: false, showBaseUrl: true, showModel: true, showHint: true,
|
|
852
|
+
hintHTML: 'In LM Studio, open the <strong>Developer</strong> tab → enable <code>CORS</code> → click <code>Start Server</code>. Without this, the browser blocks requests from this page.',
|
|
853
|
+
defaultUrl: RWA.DEFAULT_LMSTUDIO_URL,
|
|
854
|
+
storageKey: RWA.K_BASE_URL_LMSTUDIO,
|
|
855
|
+
},
|
|
856
|
+
bridge: {
|
|
857
|
+
showKey: false, showBaseUrl: false, showModel: false, showHint: false,
|
|
858
|
+
hintHTML: '',
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const syncBackendRows = () => {
|
|
863
|
+
const meta = BACKEND_META[backendEl.value] || BACKEND_META.openrouter;
|
|
864
|
+
document.getElementById('rwa-set-row-key').style.display = meta.showKey ? '' : 'none';
|
|
865
|
+
document.getElementById('rwa-set-row-base-url').style.display = meta.showBaseUrl ? '' : 'none';
|
|
866
|
+
document.getElementById('rwa-set-row-model').style.display = meta.showModel ? '' : 'none';
|
|
867
|
+
document.getElementById('rwa-set-row-hint').style.display = meta.showHint ? '' : 'none';
|
|
868
|
+
if (meta.showBaseUrl) {
|
|
869
|
+
baseUrlEl.placeholder = meta.defaultUrl;
|
|
870
|
+
baseUrlEl.value = sessionStorage.getItem(meta.storageKey) || '';
|
|
871
|
+
baseUrlResultEl.textContent = '';
|
|
872
|
+
baseUrlResultEl.className = 'rwa-set-hint';
|
|
873
|
+
}
|
|
874
|
+
if (meta.showHint) hintEl.innerHTML = meta.hintHTML;
|
|
875
|
+
};
|
|
876
|
+
backendEl.value = sessionStorage.getItem(RWA.K_BACKEND) || 'openrouter';
|
|
877
|
+
syncBackendRows();
|
|
878
|
+
backendEl.onchange = e => {
|
|
879
|
+
sessionStorage.setItem(RWA.K_BACKEND, e.target.value);
|
|
880
|
+
syncBackendRows();
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
// Persist base-URL edits to the per-backend storage key. Empty string clears
|
|
884
|
+
// the override so resolveBackendConfig() falls back to DEFAULT_*_URL.
|
|
885
|
+
baseUrlEl.oninput = e => {
|
|
886
|
+
const meta = BACKEND_META[backendEl.value];
|
|
887
|
+
if (!meta?.storageKey) return;
|
|
888
|
+
const v = e.target.value.trim();
|
|
889
|
+
if (v) sessionStorage.setItem(meta.storageKey, v);
|
|
890
|
+
else sessionStorage.removeItem(meta.storageKey);
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
// Test button: probe /v1/models. On success, populate the model datalist so
|
|
894
|
+
// the model input gets autocomplete from the live server. On failure, show a
|
|
895
|
+
// short diagnostic — the most common cause is CORS, which the inline hint
|
|
896
|
+
// already explains how to fix.
|
|
897
|
+
baseUrlTestBtn.onclick = async () => {
|
|
898
|
+
baseUrlResultEl.textContent = 'testing…';
|
|
899
|
+
baseUrlResultEl.className = 'rwa-set-hint';
|
|
900
|
+
try {
|
|
901
|
+
const cfg = resolveBackendConfig();
|
|
902
|
+
if (cfg.kind !== 'openai_compat') throw new Error('not an OpenAI-compatible backend');
|
|
903
|
+
const models = await listOpenAiCompatModels(cfg);
|
|
904
|
+
baseUrlResultEl.textContent = '✓ ' + models.length + ' model' + (models.length === 1 ? '' : 's') + ' available';
|
|
905
|
+
baseUrlResultEl.className = 'rwa-set-hint ok';
|
|
906
|
+
modelOptsEl.innerHTML = '';
|
|
907
|
+
for (const id of models) {
|
|
908
|
+
const opt = document.createElement('option');
|
|
909
|
+
opt.value = id;
|
|
910
|
+
modelOptsEl.appendChild(opt);
|
|
911
|
+
}
|
|
912
|
+
// If no model is set yet (or the current one isn't in the list), suggest the first.
|
|
913
|
+
const currentModel = (sessionStorage.getItem(RWA.K_MODEL) || '').trim();
|
|
914
|
+
const isLocal = backendEl.value === 'ollama' || backendEl.value === 'lmstudio';
|
|
915
|
+
const stuckOnDefault = isLocal && (currentModel === '' || currentModel === RWA.MODEL);
|
|
916
|
+
if (stuckOnDefault && models.length > 0) {
|
|
917
|
+
m.value = models[0];
|
|
918
|
+
sessionStorage.setItem(RWA.K_MODEL, models[0]);
|
|
919
|
+
}
|
|
920
|
+
} catch (err) {
|
|
921
|
+
const msg = (err && err.message) || String(err);
|
|
922
|
+
// Browsers surface CORS as a generic "Failed to fetch" with no detail; that's
|
|
923
|
+
// by far the most common cause from a webpage talking to a local server.
|
|
924
|
+
const isLikelyCors = /failed to fetch|network|load failed/i.test(msg);
|
|
925
|
+
baseUrlResultEl.textContent = isLikelyCors ? '✗ blocked (likely CORS — see hint below)' : ('✗ ' + msg.slice(0, 120));
|
|
926
|
+
baseUrlResultEl.className = 'rwa-set-hint err';
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
|
|
211
930
|
document.getElementById('rwa-st-cog').onclick = () => document.getElementById('rwa-set-panel').classList.toggle('open');
|
|
212
931
|
document.getElementById('rwa-st-commit').onclick = commit;
|
|
213
932
|
|
|
@@ -215,122 +934,2378 @@ function buildUI() {
|
|
|
215
934
|
pal.onclick = e => { if (e.target === pal) closePal(); };
|
|
216
935
|
inp.oninput = () => { go.disabled = !inp.value.trim(); };
|
|
217
936
|
inp.onkeydown = e => {
|
|
218
|
-
if (e.key === 'Enter' && !e.shiftKey && inp.value.trim()) {
|
|
937
|
+
if (e.key === 'Enter' && !e.shiftKey && inp.value.trim()) {
|
|
938
|
+
e.preventDefault();
|
|
939
|
+
modify(inp.value.trim()).catch(err => {
|
|
940
|
+
// concurrent_modify is already surfaced via setPalSt; suppress the
|
|
941
|
+
// unhandled rejection. Any other throw is a programming error.
|
|
942
|
+
if (err?.code !== 'concurrent_modify') console.error(err);
|
|
943
|
+
});
|
|
944
|
+
}
|
|
219
945
|
if (e.key === 'Escape') closePal();
|
|
220
946
|
};
|
|
221
|
-
go.onclick = () =>
|
|
947
|
+
go.onclick = () => {
|
|
948
|
+
if (!inp.value.trim()) return;
|
|
949
|
+
modify(inp.value.trim()).catch(err => {
|
|
950
|
+
if (err?.code !== 'concurrent_modify') console.error(err);
|
|
951
|
+
});
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
// rwa-lens/1: ⌘Enter (or Ctrl+Enter) submits; plain Enter remains a newline.
|
|
955
|
+
// Spec invariant 8: the lens textarea behaves like a normal multi-line input
|
|
956
|
+
// until the user explicitly commits with the platform-modifier+Enter chord.
|
|
957
|
+
const lensEl = document.getElementById('rwa-lens');
|
|
958
|
+
const lensInput = document.getElementById('rwa-lens-input');
|
|
959
|
+
lensInput.addEventListener('keydown', (e) => {
|
|
960
|
+
if (e.key !== 'Enter') return;
|
|
961
|
+
if (!(e.metaKey || e.ctrlKey)) return; // plain Enter remains newline
|
|
962
|
+
if (e.shiftKey) return; // shift+Enter is also newline (cross-platform safety)
|
|
963
|
+
e.preventDefault();
|
|
964
|
+
const text = lensInput.value;
|
|
965
|
+
if (typeof window.__lensSubmitHandler === 'function') {
|
|
966
|
+
window.__lensSubmitHandler(text);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (typeof submitLens !== 'function') return;
|
|
970
|
+
// Wrap submitLens in a catch so its rejection doesn't become an
|
|
971
|
+
// unhandled promise. submitLens itself preserves the input on error
|
|
972
|
+
// (it clears only on success), so the user can edit and retry.
|
|
973
|
+
submitLens(text).catch(err => {
|
|
974
|
+
if (err?.code !== 'concurrent_modify') console.error(err);
|
|
975
|
+
setStatus('err', '✗ ' + (err?.code || err?.message || 'lens command failed'));
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
// Auto-grow: the textarea expands as the user types newlines, capped by
|
|
980
|
+
// CSS max-height:200px (≈9 lines at 1.5 line-height) — past that the
|
|
981
|
+
// textarea scrolls internally. Pattern: collapse height to 'auto' so
|
|
982
|
+
// scrollHeight reflects the natural content size (not the previous
|
|
983
|
+
// stretched height), then set height = scrollHeight. The CSS min-height
|
|
984
|
+
// keeps the textarea at one line when empty. Exposed via window so
|
|
985
|
+
// submitLens can reset after clearing the value.
|
|
986
|
+
function autosizeLens() {
|
|
987
|
+
lensInput.style.height = 'auto';
|
|
988
|
+
const next = lensInput.scrollHeight;
|
|
989
|
+
if (next > 0) lensInput.style.height = next + 'px';
|
|
990
|
+
}
|
|
991
|
+
window.__lensAutosize = autosizeLens;
|
|
992
|
+
|
|
993
|
+
// rwa-lens/1: live mode indication (spec §6.1). The discriminator engages
|
|
994
|
+
// during typing — a leading slash flips the lens chrome into command mode,
|
|
995
|
+
// unless the user typed `\/` to escape (literal slash content).
|
|
996
|
+
lensInput.addEventListener('input', () => {
|
|
997
|
+
const v = lensInput.value;
|
|
998
|
+
const isCommand = v.startsWith('/') && !v.startsWith('\\/');
|
|
999
|
+
lensEl.dataset.mode = isCommand ? 'command' : 'text';
|
|
1000
|
+
autosizeLens();
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
// Task 10.1: paste-detection hint. When pasted content looks like file paths
|
|
1004
|
+
// or other slash-leading multi-line text (could be misinterpreted as a slash
|
|
1005
|
+
// command), surface a one-time session hint reminding the user that `\/`
|
|
1006
|
+
// escapes a leading slash. The hint shows once per session — pasteHintShown
|
|
1007
|
+
// is a closure-scoped flag.
|
|
1008
|
+
let pasteHintShown = false;
|
|
1009
|
+
if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
|
|
1010
|
+
window.__resetPasteHint = () => { pasteHintShown = false; };
|
|
1011
|
+
}
|
|
1012
|
+
lensInput.addEventListener('paste', (e) => {
|
|
1013
|
+
const text = e.clipboardData?.getData('text/plain') || '';
|
|
1014
|
+
const triggers = text.startsWith('/') && text.includes('\n') && (text.match(/\//g) || []).length > 1;
|
|
1015
|
+
if (triggers && !pasteHintShown) {
|
|
1016
|
+
pasteHintShown = true;
|
|
1017
|
+
const hint = document.getElementById('rwa-lens-paste-hint');
|
|
1018
|
+
if (hint) {
|
|
1019
|
+
hint.textContent = 'Looks like content — escape the leading slash with \\\\/ to insert literally.';
|
|
1020
|
+
hint.hidden = false;
|
|
1021
|
+
setTimeout(() => { hint.hidden = true; }, 5000);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// Task 9.3: history pane. Read-only view of rwa_hist entries — surface chip,
|
|
1027
|
+
// instruction, scope, timestamp. Toggle via the history button in the lens chrome.
|
|
1028
|
+
const histBtn = document.getElementById('rwa-lens-hist-btn');
|
|
1029
|
+
const histPanel = document.getElementById('rwa-lens-hist-panel');
|
|
1030
|
+
if (histBtn && histPanel) {
|
|
1031
|
+
histBtn.addEventListener('click', async () => {
|
|
1032
|
+
if (!histPanel.hidden) { histPanel.hidden = true; return; }
|
|
1033
|
+
await renderHistoryPanel(histPanel);
|
|
1034
|
+
histPanel.hidden = false;
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// rwa-lens/1: Esc releases the anchor when one is held. Listener is on
|
|
1039
|
+
// `window` so Esc anywhere works (including with focus outside the lens
|
|
1040
|
+
// input — e.g. on the doc itself after a click).
|
|
1041
|
+
window.addEventListener('keydown', (e) => {
|
|
1042
|
+
if (e.key === 'Escape' && lensState.anchor !== null) {
|
|
1043
|
+
releaseAnchor();
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
222
1046
|
}
|
|
223
1047
|
const setStatus = (cls, msg) => { const e = document.getElementById('rwa-st-status'); if (e) { e.className = 'rwa-st-btn ' + (cls || ''); e.textContent = msg; } };
|
|
224
1048
|
const setPalSt = (cls, msg) => { const e = document.getElementById('rwa-pal-st'); if (e) { e.className = cls || ''; e.textContent = msg; } };
|
|
225
1049
|
const setDirty = d => { const e = document.getElementById('rwa-st-commit'); if (e) e.classList.toggle('dirty', d); };
|
|
226
|
-
const openPal = () => { document.getElementById('rwa-pal').classList.add('open'); requestAnimationFrame(() => document.getElementById('rwa-pal-inp').focus()); setPalSt('', '● ready'); };
|
|
227
|
-
const closePal = () => { document.getElementById('rwa-pal').classList.remove('open'); document.getElementById('rwa-pal-inp').value = ''; document.getElementById('rwa-pal-go').disabled = true; };
|
|
228
1050
|
|
|
229
|
-
//
|
|
230
|
-
|
|
1051
|
+
// Spec §5.6: dirty-state nudge. Counter survives reload by living in IDB
|
|
1052
|
+
// under the rwa_state store at key 'dirty_count'. When the count crosses
|
|
1053
|
+
// NUDGE_THRESHOLD a singleton toast appears; on commit the count resets
|
|
1054
|
+
// and the toast clears.
|
|
1055
|
+
async function rwaGetDirtyCount() {
|
|
1056
|
+
return (await idbGet(RWA.STATE, 'dirty_count')) || 0;
|
|
1057
|
+
}
|
|
1058
|
+
async function rwaSetDirtyCount(n) {
|
|
1059
|
+
await idbPut(RWA.STATE, n, 'dirty_count');
|
|
1060
|
+
if (n >= RWA.NUDGE_THRESHOLD) showCommitNudge(n);
|
|
1061
|
+
else clearCommitNudge();
|
|
1062
|
+
}
|
|
1063
|
+
async function rwaBumpDirtyCount() {
|
|
1064
|
+
const n = (await rwaGetDirtyCount()) + 1;
|
|
1065
|
+
await rwaSetDirtyCount(n);
|
|
1066
|
+
return n;
|
|
1067
|
+
}
|
|
1068
|
+
async function rwaResetDirtyCount() { await rwaSetDirtyCount(0); }
|
|
1069
|
+
// rwaResetOnCommit is a named commit-hook seam: keep separate from
|
|
1070
|
+
// rwaResetDirtyCount so future commit-only logic (e.g., last_commit_ts) lands here.
|
|
1071
|
+
async function rwaResetOnCommit() { await rwaResetDirtyCount(); }
|
|
231
1072
|
|
|
232
|
-
|
|
1073
|
+
function showCommitNudge(n) {
|
|
1074
|
+
let t = document.querySelector('.rwa-lens-toast[data-kind="commit-nudge"]');
|
|
1075
|
+
if (!t) {
|
|
1076
|
+
t = document.createElement('div');
|
|
1077
|
+
t.className = 'rwa-lens-toast';
|
|
1078
|
+
t.setAttribute('data-kind', 'commit-nudge');
|
|
1079
|
+
document.body.appendChild(t);
|
|
1080
|
+
}
|
|
1081
|
+
t.textContent = `You have ${n} uncommitted changes. ⌘S to commit.`;
|
|
1082
|
+
}
|
|
1083
|
+
function clearCommitNudge() {
|
|
1084
|
+
document.querySelector('.rwa-lens-toast[data-kind="commit-nudge"]')?.remove();
|
|
1085
|
+
}
|
|
233
1086
|
|
|
234
|
-
|
|
1087
|
+
// Expose for tests only — production code uses the hooks below.
|
|
1088
|
+
window.rwaGetDirtyCount = rwaGetDirtyCount;
|
|
1089
|
+
window.rwaBumpDirtyCount = rwaBumpDirtyCount;
|
|
1090
|
+
window.rwaResetDirtyCount = rwaResetDirtyCount;
|
|
1091
|
+
window.rwaResetOnCommit = rwaResetOnCommit;
|
|
235
1092
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (!
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
1093
|
+
// Spec §5.3: quota awareness. Surface a warning above 80% usage.
|
|
1094
|
+
function showQuotaWarning(usedMB, quotaMB) {
|
|
1095
|
+
let t = document.querySelector('.rwa-lens-toast[data-kind="quota-warn"]');
|
|
1096
|
+
if (!t) {
|
|
1097
|
+
t = document.createElement('div');
|
|
1098
|
+
t.className = 'rwa-lens-toast';
|
|
1099
|
+
t.setAttribute('data-kind', 'quota-warn');
|
|
1100
|
+
document.body.appendChild(t);
|
|
244
1101
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
} catch (e) {
|
|
267
|
-
setStatus('err', '✗ ' + e.message);
|
|
268
|
-
console.error(e);
|
|
1102
|
+
t.textContent = `storage ${usedMB}/${quotaMB} MB (>80%) — commit & close idle tabs`;
|
|
1103
|
+
}
|
|
1104
|
+
function clearQuotaWarning() {
|
|
1105
|
+
document.querySelector('.rwa-lens-toast[data-kind="quota-warn"]')?.remove();
|
|
1106
|
+
}
|
|
1107
|
+
async function rwaCheckQuota() {
|
|
1108
|
+
if (!navigator.storage || !navigator.storage.estimate) return;
|
|
1109
|
+
let est;
|
|
1110
|
+
try { est = await navigator.storage.estimate(); } catch (_) { return; }
|
|
1111
|
+
if (!est || !est.quota || !est.usage) return;
|
|
1112
|
+
// Snapshot for runtime.status (spec §7). Captured before the threshold
|
|
1113
|
+
// branch so the status surface stays current even when usage is well below
|
|
1114
|
+
// the warning line.
|
|
1115
|
+
_storageStat = { usage: est.usage, quota: est.quota };
|
|
1116
|
+
const ratio = est.usage / est.quota;
|
|
1117
|
+
if (ratio > 0.8) {
|
|
1118
|
+
const usedMB = Math.round(est.usage / (1024 * 1024));
|
|
1119
|
+
const quotaMB = Math.round(est.quota / (1024 * 1024));
|
|
1120
|
+
showQuotaWarning(usedMB, quotaMB);
|
|
1121
|
+
} else {
|
|
1122
|
+
clearQuotaWarning(); // self-clear if the user freed quota since the last check
|
|
269
1123
|
}
|
|
270
1124
|
}
|
|
1125
|
+
// Expose for tests only.
|
|
1126
|
+
window.rwaCheckQuota = rwaCheckQuota;
|
|
271
1127
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (!
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
1128
|
+
// Spec §9.1: private/incognito mode is unsupported.
|
|
1129
|
+
async function rwaDetectPrivateMode() {
|
|
1130
|
+
if (!navigator.storage || !navigator.storage.estimate) return false;
|
|
1131
|
+
let est;
|
|
1132
|
+
try { est = await navigator.storage.estimate(); } catch (_) { return false; }
|
|
1133
|
+
if (!est || !est.quota) return false;
|
|
1134
|
+
// iOS Safari private mode caps quota at single-digit MB.
|
|
1135
|
+
// 50 MB threshold catches private while leaving real low-disk devices alone.
|
|
1136
|
+
return est.quota < 50 * 1024 * 1024;
|
|
279
1137
|
}
|
|
280
1138
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
1139
|
+
function rwaShowPrivateModeBanner() {
|
|
1140
|
+
if (document.getElementById('rwa-private-mode-banner')) return;
|
|
1141
|
+
const banner = document.createElement('div');
|
|
1142
|
+
banner.id = 'rwa-private-mode-banner';
|
|
1143
|
+
banner.setAttribute('role', 'alert');
|
|
1144
|
+
banner.innerHTML = `
|
|
1145
|
+
<div class="rwa-pm-card">
|
|
1146
|
+
<h2>re-write-able requires normal browsing mode</h2>
|
|
1147
|
+
<p>Your browser is in private or incognito mode. Storage is severely limited and may be cleared at any time without warning.</p>
|
|
1148
|
+
<p>Reopen this document in a normal browser window to continue.</p>
|
|
1149
|
+
<p class="rwa-pm-detail">Your work-in-progress is safe in the file on disk — nothing has been lost.</p>
|
|
1150
|
+
</div>`;
|
|
1151
|
+
document.body.appendChild(banner);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Expose for tests only.
|
|
1155
|
+
window.rwaDetectPrivateMode = rwaDetectPrivateMode;
|
|
1156
|
+
window.rwaShowPrivateModeBanner = rwaShowPrivateModeBanner;
|
|
1157
|
+
|
|
1158
|
+
const openPal = () => { document.getElementById('rwa-pal').classList.add('open'); requestAnimationFrame(() => document.getElementById('rwa-pal-inp').focus()); setPalSt('', '● ready'); };
|
|
1159
|
+
const closePal = () => { document.getElementById('rwa-pal').classList.remove('open'); document.getElementById('rwa-pal-inp').value = ''; document.getElementById('rwa-pal-go').disabled = true; };
|
|
1160
|
+
|
|
1161
|
+
// Task 9.3: read-only render of rwa_hist into the side panel. Read once on
|
|
1162
|
+
// open — the panel is a snapshot, not a live view. Newest first matches the
|
|
1163
|
+
// existing convention (commitDoc unshifts onto the array).
|
|
1164
|
+
async function renderHistoryPanel(panel) {
|
|
1165
|
+
const fmtTs = (ts) => {
|
|
1166
|
+
try { return new Date(ts).toLocaleString(); } catch { return String(ts); }
|
|
1167
|
+
};
|
|
1168
|
+
const escHtml = (s) => String(s)
|
|
1169
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1170
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
1171
|
+
const fmtScope = (scope) => {
|
|
1172
|
+
if (!scope) return '';
|
|
1173
|
+
if (scope.type === 'eof') return 'eof';
|
|
1174
|
+
if (scope.type === 'block') return 'block @' + (scope.block_id || '?');
|
|
1175
|
+
return scope.type || '';
|
|
1176
|
+
};
|
|
1177
|
+
let entries = [];
|
|
1178
|
+
try { entries = (await idbGet(RWA.HIST)) || []; } catch { entries = []; }
|
|
1179
|
+
const header = '<h3>History <button type="button" id="rwa-lens-hist-close" aria-label="close">×</button></h3>';
|
|
1180
|
+
if (!entries.length) {
|
|
1181
|
+
panel.innerHTML = header + '<div class="rwa-hist-empty">No history yet — make an edit and it will appear here.</div>';
|
|
1182
|
+
} else {
|
|
1183
|
+
const rows = entries.map(e => {
|
|
1184
|
+
const surface = e.surface || e.kind || 'edit';
|
|
1185
|
+
const scopeStr = fmtScope(e.scope);
|
|
1186
|
+
const instr = e.instruction || (e.kind === 'replace_document' ? (e.reason || '(replace_document)') : '(no instruction recorded)');
|
|
1187
|
+
return '<div class="rwa-hist-row">'
|
|
1188
|
+
+ '<div class="rwa-hist-meta">'
|
|
1189
|
+
+ '<span class="rwa-hist-surface">' + escHtml(surface) + '</span>'
|
|
1190
|
+
+ (scopeStr ? '<span>' + escHtml(scopeStr) + '</span>' : '')
|
|
1191
|
+
+ '<span>' + escHtml(fmtTs(e.ts)) + '</span>'
|
|
1192
|
+
+ '</div>'
|
|
1193
|
+
+ '<div class="rwa-hist-instr">' + escHtml(instr) + '</div>'
|
|
1194
|
+
+ '</div>';
|
|
1195
|
+
}).join('');
|
|
1196
|
+
panel.innerHTML = header + rows;
|
|
1197
|
+
}
|
|
1198
|
+
const closeBtn = panel.querySelector('#rwa-lens-hist-close');
|
|
1199
|
+
if (closeBtn) closeBtn.addEventListener('click', () => { panel.hidden = true; });
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// ─── Agent (rwa-edit/1) ─────────────────────────────────────────────
|
|
1203
|
+
// Spec: rwa-edit-spec.md (v1.4). The agent edits the doc via tool calls,
|
|
1204
|
+
// not by emitting a full rewritten document. unchanged regions are byte-
|
|
1205
|
+
// identical because the model never re-emits them.
|
|
1206
|
+
|
|
1207
|
+
const SYSTEM_PROMPT = `You are editing a rewritable HTML document. Apply the user's request as a small set of surgical edits via tool calls.
|
|
1208
|
+
|
|
1209
|
+
You have three tools:
|
|
1210
|
+
• apply_dsl_plan — preferred for STRUCTURAL transforms. Submit a sequence of typed ops (replace, insert, delete, set_attr) compiled deterministically to anchored edits. The runtime guarantees bytes outside change regions stay byte-identical. Use for: insert/delete elements, mass rename, wrap/unwrap, change a class or attribute, reorder.
|
|
1211
|
+
• apply_edits — preferred for CONTENT transforms. Submit (find, replace) pairs. Each find must be a non-empty literal substring that appears exactly once in the doc. Use for: prose rewrites, value updates, fine-grained text changes, translations, typo fixes.
|
|
1212
|
+
• replace_document — escape hatch. Use only for scaffolding a fresh document, or when the user explicitly asked for a wholesale redesign.
|
|
1213
|
+
|
|
1214
|
+
Preference: structural change → apply_dsl_plan. Content change → apply_edits. Wholesale redesign → replace_document. The two surgical tools cover different kinds of work; pick the one that fits the change.
|
|
1215
|
+
|
|
1216
|
+
Rules for apply_dsl_plan:
|
|
1217
|
+
• version is "rwa-edit-dsl/1". ops is a non-empty array.
|
|
1218
|
+
• Each op is one of: replace { find, replace, region?, all? }, insert { content, after | before }, delete { target }, set_attr { anchor, attr, value }, replace_document { doc, reason } (sole-op only).
|
|
1219
|
+
• Anchors and find/target/anchor strings must be uniquely locatable in the doc (extend with surrounding context if not).
|
|
1220
|
+
• set_attr.anchor MUST start with < and end BEFORE the closing > of the target element's opening tag (e.g. \`<p class="callout"\` — no trailing >). The compiler reads up to the next > to determine the full opening tag.
|
|
1221
|
+
• For repeated patterns (e.g. wrap each card in a section), emit one op per match with disambiguating context, OR use replace with all=true.
|
|
1222
|
+
• Frozen zones are off-limits — see below.
|
|
1223
|
+
|
|
1224
|
+
Rules for apply_edits:
|
|
1225
|
+
• Copy anchors from the doc verbatim. Do not retype them.
|
|
1226
|
+
• Whitespace and line endings in find must match the doc exactly.
|
|
1227
|
+
• If your natural anchor is not unique, extend it with surrounding context until it is.
|
|
1228
|
+
• Never include the substrings rwa:frozen:begin, rwa:frozen:end, the HTML/CSS/JS comment prefixes that start with rwa: (HTML <!--, CSS /*, JS //), or the attribute name data-rwa-frozen in find or replace. Frozen zones are listed in the user message and are off-limits.
|
|
1229
|
+
• Do not add or remove <script> or <style> tags via apply_edits — that requires replace_document.
|
|
1230
|
+
• If your edit's anchor would be longer than the changed region itself, replace_document is probably more appropriate.
|
|
1231
|
+
|
|
1232
|
+
Rules for replace_document:
|
|
1233
|
+
• Frozen-zone marker pairs and data-rwa-frozen elements must appear in the new doc with byte-identical content. They are author-declared invariants.
|
|
1234
|
+
• If the doc carries class-declared locked regions (.rwa-locked) that are NOT entirely covered by marker-form frozen zones, replace_document will be rejected. In that case, prefer apply_edits or apply_dsl_plan. The user message lists any such regions.
|
|
1235
|
+
• Provide a reason explaining why a smaller tool was not appropriate.
|
|
1236
|
+
|
|
1237
|
+
If the user's input is itself substantial content (a long block of prose, an outline, a list of items), they want it rendered into the document, not summarized. When no surgical anchor exists for substantial content insertion, use replace_document.
|
|
1238
|
+
|
|
1239
|
+
Stable block identifiers (data-rwa-id):
|
|
1240
|
+
• Many block-level elements in the doc (p, h1–h6, blockquote, li, figure, pre, aside) carry a \`data-rwa-id="…"\` attribute the runtime assigns. The value is opaque (8 lower-base32 chars).
|
|
1241
|
+
• Preserve these attributes verbatim when editing. If you replace the surrounding text of a block, copy the existing data-rwa-id into your replace string. They are the stable name of that block; URLs link to them.
|
|
1242
|
+
• Never invent new data-rwa-id values. The runtime backfills any block you produce without one. Inventing duplicates would break fragment links to existing blocks.
|
|
1243
|
+
|
|
1244
|
+
Always call a tool. Respond with text-only only when you genuinely need to ask for clarification before editing.`;
|
|
1245
|
+
|
|
1246
|
+
const TOOL_SCHEMAS = [
|
|
1247
|
+
{
|
|
1248
|
+
type: 'function',
|
|
1249
|
+
function: {
|
|
1250
|
+
name: 'apply_dsl_plan',
|
|
1251
|
+
description: 'Apply a structural transform plan. A small DSL of named ops (replace/insert/delete/set_attr) compiled deterministically to apply_edits. Prefer for structural work — bytes outside change regions stay byte-identical by construction. Frozen zones (rwa:frozen markers and data-rwa-frozen elements) are off-limits.',
|
|
1252
|
+
parameters: {
|
|
1253
|
+
type: 'object',
|
|
1254
|
+
required: ['version', 'ops'],
|
|
1255
|
+
properties: {
|
|
1256
|
+
version: { type: 'string', enum: ['rwa-edit-dsl/1'] },
|
|
1257
|
+
ops: {
|
|
1258
|
+
type: 'array', minItems: 1,
|
|
1259
|
+
items: {
|
|
1260
|
+
oneOf: [
|
|
1261
|
+
{
|
|
1262
|
+
type: 'object', required: ['op', 'find', 'replace'],
|
|
1263
|
+
properties: {
|
|
1264
|
+
op: { const: 'replace' },
|
|
1265
|
+
find: { type: 'string' },
|
|
1266
|
+
replace: { type: 'string' },
|
|
1267
|
+
region: { type: 'string' },
|
|
1268
|
+
all: { type: 'boolean' }
|
|
1269
|
+
}
|
|
1270
|
+
},
|
|
1271
|
+
{
|
|
1272
|
+
type: 'object', required: ['op', 'content'],
|
|
1273
|
+
properties: {
|
|
1274
|
+
op: { const: 'insert' },
|
|
1275
|
+
content: { type: 'string' },
|
|
1276
|
+
after: { type: 'string' },
|
|
1277
|
+
before: { type: 'string' }
|
|
1278
|
+
}
|
|
1279
|
+
},
|
|
1280
|
+
{
|
|
1281
|
+
type: 'object', required: ['op', 'target'],
|
|
1282
|
+
properties: {
|
|
1283
|
+
op: { const: 'delete' },
|
|
1284
|
+
target: { type: 'string' }
|
|
1285
|
+
}
|
|
1286
|
+
},
|
|
1287
|
+
{
|
|
1288
|
+
type: 'object', required: ['op', 'anchor', 'attr', 'value'],
|
|
1289
|
+
properties: {
|
|
1290
|
+
op: { const: 'set_attr' },
|
|
1291
|
+
anchor: { type: 'string' },
|
|
1292
|
+
attr: { type: 'string' },
|
|
1293
|
+
value: { type: 'string' }
|
|
1294
|
+
}
|
|
1295
|
+
},
|
|
1296
|
+
{
|
|
1297
|
+
type: 'object', required: ['op', 'doc', 'reason'],
|
|
1298
|
+
properties: {
|
|
1299
|
+
op: { const: 'replace_document' },
|
|
1300
|
+
doc: { type: 'string' },
|
|
1301
|
+
reason: { type: 'string' }
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
]
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
295
1307
|
}
|
|
296
|
-
const w = await h.createWritable(); await w.write(text); await w.close();
|
|
297
|
-
setDirty(false); setStatus('ok', '✓ committed'); return;
|
|
298
|
-
} catch (e) {
|
|
299
|
-
if (e.name === 'AbortError') { setStatus('', 'cancelled'); return; }
|
|
300
|
-
console.warn('FSA failed, falling back to download:', e);
|
|
301
1308
|
}
|
|
302
1309
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
1310
|
+
},
|
|
1311
|
+
{
|
|
1312
|
+
type: 'function',
|
|
1313
|
+
function: {
|
|
1314
|
+
name: 'apply_edits',
|
|
1315
|
+
description: 'Apply anchor-based string edits. Each edit specifies a literal substring to find (unique in the doc) and a replacement. Edits are applied in order, atomically. Frozen zones (rwa:frozen markers and data-rwa-frozen elements) are off-limits. <script>/<style> tag counts must not change.',
|
|
1316
|
+
parameters: {
|
|
1317
|
+
type: 'object',
|
|
1318
|
+
required: ['version', 'edits'],
|
|
1319
|
+
properties: {
|
|
1320
|
+
version: { type: 'string', enum: ['rwa-edit/1'] },
|
|
1321
|
+
reason: { type: 'string' },
|
|
1322
|
+
edits: {
|
|
1323
|
+
type: 'array', minItems: 1,
|
|
1324
|
+
items: {
|
|
1325
|
+
type: 'object',
|
|
1326
|
+
required: ['find', 'replace'],
|
|
1327
|
+
properties: {
|
|
1328
|
+
find: { type: 'string', minLength: 1 },
|
|
1329
|
+
replace: { type: 'string' },
|
|
1330
|
+
reason: { type: 'string' }
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
},
|
|
1338
|
+
{
|
|
1339
|
+
type: 'function',
|
|
1340
|
+
function: {
|
|
1341
|
+
name: 'replace_document',
|
|
1342
|
+
description: 'Wholesale-replace the document. Use only for scaffolding a fresh document or for a wholesale redesign the user explicitly requested. Frozen zones (rwa:frozen markers and data-rwa-frozen elements) must be preserved byte-identically.',
|
|
1343
|
+
parameters: {
|
|
1344
|
+
type: 'object',
|
|
1345
|
+
required: ['version', 'doc', 'reason'],
|
|
1346
|
+
properties: {
|
|
1347
|
+
version: { type: 'string', enum: ['rwa-edit/1'] },
|
|
1348
|
+
doc: { type: 'string' },
|
|
1349
|
+
reason: { type: 'string', minLength: 1 }
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
];
|
|
1355
|
+
|
|
1356
|
+
// ─── Validator (rwa-edit/1) ─────────────────────────────────────────
|
|
1357
|
+
class RwaEditError extends Error {
|
|
1358
|
+
constructor(code, editIndex = null, context = {}) {
|
|
1359
|
+
super(code);
|
|
1360
|
+
this.code = code;
|
|
1361
|
+
this.editIndex = editIndex;
|
|
1362
|
+
this.context = context;
|
|
311
1363
|
}
|
|
312
1364
|
}
|
|
313
1365
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
});
|
|
1366
|
+
const RWA_EDIT = {
|
|
1367
|
+
MAX_REPLACE: 8 * 1024, // per-edit replace cap (spec §13 rule 5)
|
|
1368
|
+
MAX_DOC: 1024 * 1024, // whole-doc cap
|
|
1369
|
+
RETRIES: 3, // §9.2
|
|
1370
|
+
RESERVED: ['rwa:frozen:begin', 'rwa:frozen:end', '<' + '!-- rwa:', '/*' + ' rwa:', '//' + ' rwa:', 'data-rwa-frozen'],
|
|
1371
|
+
};
|
|
321
1372
|
|
|
322
|
-
|
|
1373
|
+
function containsReservedMarker(s) {
|
|
1374
|
+
if (!s) return false;
|
|
1375
|
+
for (const m of RWA_EDIT.RESERVED) if (s.includes(m)) return true;
|
|
1376
|
+
return false;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function countOccurrences(haystack, needle) {
|
|
1380
|
+
if (!needle) return 0;
|
|
1381
|
+
let n = 0, i = 0;
|
|
1382
|
+
while ((i = haystack.indexOf(needle, i)) !== -1) { n++; i += needle.length; }
|
|
1383
|
+
return n;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function nearbySnippets(haystack, needle, max = 3, ctx = 40) {
|
|
1387
|
+
const out = []; let i = 0;
|
|
1388
|
+
while ((i = haystack.indexOf(needle, i)) !== -1 && out.length < max) {
|
|
1389
|
+
const a = Math.max(0, i - ctx);
|
|
1390
|
+
const b = Math.min(haystack.length, i + needle.length + ctx);
|
|
1391
|
+
out.push({ pos: i, before: haystack.slice(a, i), after: haystack.slice(i + needle.length, b) });
|
|
1392
|
+
i += needle.length;
|
|
1393
|
+
}
|
|
1394
|
+
return out;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1398
|
+
|
|
1399
|
+
// Extract frozen zones. Three marker forms:
|
|
1400
|
+
// <!-- rwa:frozen:begin <name> --> ... <!-- rwa:frozen:end <name> -->
|
|
1401
|
+
// /* rwa:frozen:begin <name> */ ... /* rwa:frozen:end <name> */
|
|
1402
|
+
// // rwa:frozen:begin <name>(\n) ... (\n)// rwa:frozen:end <name>(\n|$)
|
|
1403
|
+
// Returns array of {name, inner} or {name, error} on unterminated/duplicate.
|
|
1404
|
+
function extractFrozenZones(doc) {
|
|
1405
|
+
const zones = [];
|
|
1406
|
+
const seen = new Set();
|
|
1407
|
+
const beginRe = /(<!--|\/\*|\/\/)\s*rwa:frozen:begin\s+([A-Za-z0-9_-]+)\s*(-->|\*\/|(?=\r?\n|$))/g;
|
|
1408
|
+
let m;
|
|
1409
|
+
while ((m = beginRe.exec(doc)) !== null) {
|
|
1410
|
+
const opener = m[1];
|
|
1411
|
+
const name = m[2];
|
|
1412
|
+
let innerStart = m.index + m[0].length;
|
|
1413
|
+
if (opener === '//') {
|
|
1414
|
+
// Line-comment form: skip to and past the newline.
|
|
1415
|
+
while (innerStart < doc.length && doc[innerStart] !== '\n') innerStart++;
|
|
1416
|
+
if (innerStart < doc.length) innerStart++;
|
|
1417
|
+
}
|
|
1418
|
+
let endRe;
|
|
1419
|
+
if (opener === '<!--') {
|
|
1420
|
+
endRe = new RegExp('<!--\\s*rwa:frozen:end\\s+' + escapeRegex(name) + '\\s*-->', 'g');
|
|
1421
|
+
} else if (opener === '/*') {
|
|
1422
|
+
endRe = new RegExp('\\/\\*\\s*rwa:frozen:end\\s+' + escapeRegex(name) + '\\s*\\*\\/', 'g');
|
|
1423
|
+
} else {
|
|
1424
|
+
endRe = new RegExp('\\/\\/\\s*rwa:frozen:end\\s+' + escapeRegex(name) + '(?=\\r?\\n|$)', 'g');
|
|
1425
|
+
}
|
|
1426
|
+
endRe.lastIndex = innerStart;
|
|
1427
|
+
const e = endRe.exec(doc);
|
|
1428
|
+
if (!e) { zones.push({ name, error: 'unterminated' }); continue; }
|
|
1429
|
+
if (seen.has(name)) { zones.push({ name, error: 'duplicate' }); continue; }
|
|
1430
|
+
seen.add(name);
|
|
1431
|
+
zones.push({ name, inner: doc.slice(innerStart, e.index) });
|
|
1432
|
+
}
|
|
1433
|
+
return zones;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function frozenZonesIntact(before, after) {
|
|
1437
|
+
if (before.some(z => z.error) || after.some(z => z.error)) return false;
|
|
1438
|
+
if (before.length !== after.length) return false;
|
|
1439
|
+
const beforeMap = new Map(before.map(z => [z.name, z.inner]));
|
|
1440
|
+
if (beforeMap.size !== before.length) return false;
|
|
1441
|
+
const afterMap = new Map(after.map(z => [z.name, z.inner]));
|
|
1442
|
+
if (afterMap.size !== after.length) return false;
|
|
1443
|
+
for (const [name, inner] of beforeMap) {
|
|
1444
|
+
if (!afterMap.has(name)) return false;
|
|
1445
|
+
if (afterMap.get(name) !== inner) return false;
|
|
1446
|
+
}
|
|
1447
|
+
return true;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function parseHtmlFragment(doc) {
|
|
323
1451
|
try {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (
|
|
327
|
-
|
|
1452
|
+
const parsed = new DOMParser().parseFromString('<!DOCTYPE html><html><body>' + doc + '</body></html>', 'text/html');
|
|
1453
|
+
const errs = parsed.querySelectorAll('parsererror');
|
|
1454
|
+
if (errs.length) return { ok: false, error: errs[0].textContent.slice(0, 200) };
|
|
1455
|
+
return { ok: true, doc: parsed };
|
|
1456
|
+
} catch (e) {
|
|
1457
|
+
return { ok: false, error: e.message };
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// rwa-lens/1: source-position map (spec §5.5).
|
|
1462
|
+
// Walks `doc` (LF-canonical document text) and returns one entry per anchorable
|
|
1463
|
+
// block in source order: { tag, start, end, node }. `doc.slice(start, end)`
|
|
1464
|
+
// equals the source-form bytes of the block (preserving original attribute
|
|
1465
|
+
// quoting/order — which the browser's outerHTML would normalize). Nested
|
|
1466
|
+
// anchorables inside an outer anchorable are NOT separately listed (outer
|
|
1467
|
+
// wins). Non-anchorable wrappers (e.g. <ul>) are transparent — their
|
|
1468
|
+
// anchorable children (e.g. <li>) appear at the top level of the map.
|
|
1469
|
+
//
|
|
1470
|
+
// Robustness: <script>/<style> bodies and <!--...--> comments are masked out
|
|
1471
|
+
// before scanning so their literal contents (which may contain anchorable-
|
|
1472
|
+
// looking markup) are not mistaken for real blocks. Mask preserves byte
|
|
1473
|
+
// offsets so recorded [start, end] ranges remain valid against the ORIGINAL
|
|
1474
|
+
// doc (invariant 11). Slice operations therefore return original bytes, not
|
|
1475
|
+
// mask spaces.
|
|
1476
|
+
function maskRawTextAndComments(doc) {
|
|
1477
|
+
let masked = doc;
|
|
1478
|
+
// Mask <script>...<\/script> body bytes (keep tags themselves intact so the
|
|
1479
|
+
// scanner still sees them as non-anchorable opens and skips correctly).
|
|
1480
|
+
// The `<\/` escapes in regex/string literals avoid the HTML parser closing
|
|
1481
|
+
// THIS bootstrap <script> tag prematurely.
|
|
1482
|
+
masked = masked.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => {
|
|
1483
|
+
const opening = m.length - body.length - '<\/script>'.length;
|
|
1484
|
+
return m.slice(0, opening) + ' '.repeat(body.length) + m.slice(opening + body.length);
|
|
1485
|
+
});
|
|
1486
|
+
// Same for <style>.
|
|
1487
|
+
masked = masked.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (m, body) => {
|
|
1488
|
+
const opening = m.length - body.length - '<\/style>'.length;
|
|
1489
|
+
return m.slice(0, opening) + ' '.repeat(body.length) + m.slice(opening + body.length);
|
|
1490
|
+
});
|
|
1491
|
+
// Mask comments entirely (including <!-- and --> delimiters) so a stray
|
|
1492
|
+
// close-tag substring inside a comment does not derail close-tag matching.
|
|
1493
|
+
masked = masked.replace(/<!--[\s\S]*?-->/g, m => ' '.repeat(m.length));
|
|
1494
|
+
return masked;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function buildSourcePositionMap(doc) {
|
|
1498
|
+
const map = [];
|
|
1499
|
+
const parsed = new DOMParser().parseFromString('<!DOCTYPE html><html><body>' + doc + '</body></html>', 'text/html');
|
|
1500
|
+
// Collect anchorable nodes in document order, but stop descending past an
|
|
1501
|
+
// anchorable (outer wins — matches the source-scanner's lastIndex skip).
|
|
1502
|
+
const nodesInOrder = [];
|
|
1503
|
+
(function walk(el) {
|
|
1504
|
+
for (const child of el.children) {
|
|
1505
|
+
if (ANCHORABLE_TAGS.has(child.tagName)) {
|
|
1506
|
+
nodesInOrder.push(child);
|
|
1507
|
+
} else {
|
|
1508
|
+
walk(child);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
})(parsed.body);
|
|
1512
|
+
|
|
1513
|
+
// Scan against the masked string so script/style/comment internals do not
|
|
1514
|
+
// contribute false anchorables. Recorded [start, end] indices are still
|
|
1515
|
+
// valid offsets into the ORIGINAL doc (mask preserves byte length).
|
|
1516
|
+
const masked = maskRawTextAndComments(doc);
|
|
1517
|
+
const tagOpen = /<([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g;
|
|
1518
|
+
let m;
|
|
1519
|
+
while ((m = tagOpen.exec(masked)) !== null) {
|
|
1520
|
+
const tag = m[1].toUpperCase();
|
|
1521
|
+
if (!ANCHORABLE_TAGS.has(tag)) continue;
|
|
1522
|
+
const start = m.index;
|
|
1523
|
+
const end = findCloseTagEnd(masked, tag, m.index + m[0].length);
|
|
1524
|
+
if (end < 0) continue;
|
|
1525
|
+
map.push({ tag, start, end, node: null });
|
|
1526
|
+
// Outer-wins: skip past the close so nested anchorables of any type are
|
|
1527
|
+
// not separately recorded.
|
|
1528
|
+
tagOpen.lastIndex = end;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// Attach DOM nodes by ordinal. If counts disagree, the parser and scanner
|
|
1532
|
+
// disagree about block structure (e.g. auto-closed <p>One<p>Two</p>) —
|
|
1533
|
+
// pairing by ordinal would silently mis-align nodes with source slices, so
|
|
1534
|
+
// clear ALL entries' node refs. Consumers must then rely on [start, end]
|
|
1535
|
+
// and treat the source-form content as authoritative.
|
|
1536
|
+
if (map.length !== nodesInOrder.length) {
|
|
1537
|
+
console.warn('rwa-lens: source-position map desync; map=' + map.length +
|
|
1538
|
+
' nodes=' + nodesInOrder.length + '; clearing all node references');
|
|
1539
|
+
for (const entry of map) entry.node = null;
|
|
1540
|
+
} else {
|
|
1541
|
+
for (let i = 0; i < map.length; i++) map[i].node = nodesInOrder[i];
|
|
1542
|
+
}
|
|
1543
|
+
return map;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function findCloseTagEnd(doc, tag, from) {
|
|
1547
|
+
const re = new RegExp('<(\\/?)' + tag + '\\b[^>]*>', 'gi');
|
|
1548
|
+
re.lastIndex = from;
|
|
1549
|
+
let depth = 1, m;
|
|
1550
|
+
while ((m = re.exec(doc)) !== null) {
|
|
1551
|
+
if (m[1] === '/') {
|
|
1552
|
+
depth--;
|
|
1553
|
+
if (depth === 0) return m.index + m[0].length;
|
|
1554
|
+
} else {
|
|
1555
|
+
depth++;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
return -1;
|
|
1559
|
+
}
|
|
1560
|
+
window.buildSourcePositionMap = buildSourcePositionMap; // expose for tests
|
|
1561
|
+
window.maskRawTextAndComments = maskRawTextAndComments; // expose for tests
|
|
1562
|
+
|
|
1563
|
+
// rwa-lens/1: source-position map state. Ephemeral, in-memory, rebuilt after
|
|
1564
|
+
// every successful commit; never persisted (spec invariant 11 + lifetime
|
|
1565
|
+
// rule §5.5). The state is module-scoped — callers go through setSourceMap /
|
|
1566
|
+
// getSourceMap, not direct globals. renderDoc() is the single rebuild point:
|
|
1567
|
+
// every commit path eventually re-renders, so wiring there gives us implicit
|
|
1568
|
+
// rebuild on commit, undo, and bootstrap restore without scattering calls.
|
|
1569
|
+
let sourceMap = null;
|
|
1570
|
+
let currentDocCache = null; // consumed by Task 1.5+ anchor resolution
|
|
1571
|
+
function setSourceMap(doc) {
|
|
1572
|
+
currentDocCache = doc;
|
|
1573
|
+
sourceMap = buildSourcePositionMap(doc);
|
|
1574
|
+
}
|
|
1575
|
+
function getSourceMap() { return sourceMap; }
|
|
1576
|
+
window.getSourceMap = getSourceMap;
|
|
1577
|
+
|
|
1578
|
+
// rwa-lens/1: class-declared locks (spec §7).
|
|
1579
|
+
let lockedRanges = [];
|
|
1580
|
+
function lockedRangesIn(doc) {
|
|
1581
|
+
if (!doc) return [];
|
|
1582
|
+
const opening = /<([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\bclass\s*=\s*("([^"]*)"|'([^']*)')[^>]*>/g;
|
|
1583
|
+
const out = [];
|
|
1584
|
+
let m;
|
|
1585
|
+
while ((m = opening.exec(doc)) !== null) {
|
|
1586
|
+
const cls = (m[3] || m[4] || '');
|
|
1587
|
+
if (!/\brwa-locked\b/.test(cls)) continue;
|
|
1588
|
+
const tag = m[1];
|
|
1589
|
+
const start = m.index;
|
|
1590
|
+
const end = findCloseTagEnd(doc, tag, m.index + m[0].length);
|
|
1591
|
+
if (end > 0) out.push([start, end]);
|
|
1592
|
+
}
|
|
1593
|
+
return out;
|
|
1594
|
+
}
|
|
1595
|
+
// rwa-lens/1: marker-form frozen-zone byte ranges (spec §7 coverage check).
|
|
1596
|
+
// Mirrors extractFrozenZones (~L586) but returns [start, end] source-byte
|
|
1597
|
+
// ranges including the marker fences themselves — markers are unique
|
|
1598
|
+
// substrings and their byte content is invariant, so the fences are part of
|
|
1599
|
+
// the protected range. Used by replaceDocument's class-lock coverage check
|
|
1600
|
+
// to verify .rwa-locked ranges are entirely contained within marker zones.
|
|
1601
|
+
function markerZoneRangesIn(doc) {
|
|
1602
|
+
if (!doc) return [];
|
|
1603
|
+
const out = [];
|
|
1604
|
+
// Comment-fence markers: <!-- rwa:frozen:begin <name> --> ... <!-- rwa:frozen:end <name> -->
|
|
1605
|
+
// Same regex shape as extractFrozenZones; record byte ranges instead of inner text.
|
|
1606
|
+
const beginRe = /(<!--|\/\*|\/\/)\s*rwa:frozen:begin\s+([A-Za-z0-9_-]+)\s*(-->|\*\/|(?=\r?\n|$))/g;
|
|
1607
|
+
let m;
|
|
1608
|
+
while ((m = beginRe.exec(doc)) !== null) {
|
|
1609
|
+
const opener = m[1];
|
|
1610
|
+
const name = m[2];
|
|
1611
|
+
const startOfBegin = m.index;
|
|
1612
|
+
let innerStart = m.index + m[0].length;
|
|
1613
|
+
if (opener === '//') {
|
|
1614
|
+
while (innerStart < doc.length && doc[innerStart] !== '\n') innerStart++;
|
|
1615
|
+
if (innerStart < doc.length) innerStart++;
|
|
1616
|
+
}
|
|
1617
|
+
let endRe;
|
|
1618
|
+
if (opener === '<!--') {
|
|
1619
|
+
endRe = new RegExp('<!--\\s*rwa:frozen:end\\s+' + escapeRegex(name) + '\\s*-->', 'g');
|
|
1620
|
+
} else if (opener === '/*') {
|
|
1621
|
+
endRe = new RegExp('\\/\\*\\s*rwa:frozen:end\\s+' + escapeRegex(name) + '\\s*\\*\\/', 'g');
|
|
1622
|
+
} else {
|
|
1623
|
+
endRe = new RegExp('\\/\\/\\s*rwa:frozen:end\\s+' + escapeRegex(name) + '(?=\\r?\\n|$)', 'g');
|
|
1624
|
+
}
|
|
1625
|
+
endRe.lastIndex = innerStart;
|
|
1626
|
+
const e = endRe.exec(doc);
|
|
1627
|
+
if (!e) continue; // unterminated — skip
|
|
1628
|
+
const endOfEnd = e.index + e[0].length;
|
|
1629
|
+
out.push([startOfBegin, endOfEnd]);
|
|
1630
|
+
}
|
|
1631
|
+
// data-rwa-frozen elements: scan for opening tags carrying that attribute.
|
|
1632
|
+
const fzAttr = /<([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\bdata-rwa-frozen\b[^>]*>/g;
|
|
1633
|
+
while ((m = fzAttr.exec(doc)) !== null) {
|
|
1634
|
+
const tag = m[1];
|
|
1635
|
+
const start = m.index;
|
|
1636
|
+
const end = findCloseTagEnd(doc, tag, m.index + m[0].length);
|
|
1637
|
+
if (end > 0) out.push([start, end]);
|
|
1638
|
+
}
|
|
1639
|
+
return out;
|
|
1640
|
+
}
|
|
1641
|
+
function rebuildLockedRanges(doc) {
|
|
1642
|
+
lockedRanges = lockedRangesIn(doc);
|
|
1643
|
+
}
|
|
1644
|
+
function getLockedRanges() { return lockedRanges; }
|
|
1645
|
+
window.getLockedRanges = getLockedRanges;
|
|
1646
|
+
function isWithinLockedRange(start, end) {
|
|
1647
|
+
return lockedRanges.some(([ls, le]) => start >= ls && end <= le);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// rwa-lens/1: anchor uniqueness extension (spec §5.5).
|
|
1651
|
+
// Given a map entry, return { find, replacePrefix, replaceSuffix } such that
|
|
1652
|
+
// `find` appears exactly once in the current doc and contains the entry's
|
|
1653
|
+
// source range. Expands outward through map siblings until uniqueness, or
|
|
1654
|
+
// returns null if even full-document context fails to disambiguate.
|
|
1655
|
+
function resolveAnchorFind(entry) {
|
|
1656
|
+
const doc = currentDocCache;
|
|
1657
|
+
if (!doc || !sourceMap) return null;
|
|
1658
|
+
const idx = sourceMap.indexOf(entry);
|
|
1659
|
+
if (idx < 0) return null;
|
|
1660
|
+
|
|
1661
|
+
let lo = idx, hi = idx;
|
|
1662
|
+
while (true) {
|
|
1663
|
+
const start = sourceMap[lo].start;
|
|
1664
|
+
const end = sourceMap[hi].end;
|
|
1665
|
+
const find = doc.slice(start, end);
|
|
1666
|
+
if (countOccurrences(doc, find) === 1) {
|
|
1667
|
+
return {
|
|
1668
|
+
find,
|
|
1669
|
+
replacePrefix: doc.slice(start, entry.start),
|
|
1670
|
+
replaceSuffix: doc.slice(entry.end, end),
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
// Expand: grow whichever side is currently less extended from the anchor
|
|
1674
|
+
// (alternating expansion, biased toward earlier siblings on ties). Keeps
|
|
1675
|
+
// the find window near-symmetric around the anchor — minimizes find-string
|
|
1676
|
+
// length and the eventual `replace` length on average.
|
|
1677
|
+
const canGrowLo = lo > 0;
|
|
1678
|
+
const canGrowHi = hi < sourceMap.length - 1;
|
|
1679
|
+
if (!canGrowLo && !canGrowHi) return null;
|
|
1680
|
+
if (canGrowLo && (!canGrowHi || (idx - lo) <= (hi - idx))) {
|
|
1681
|
+
lo--;
|
|
1682
|
+
} else {
|
|
1683
|
+
hi++;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
window.resolveAnchorFind = resolveAnchorFind;
|
|
1688
|
+
|
|
1689
|
+
// rwa-lens/1: EOF anchor resolution. Returns resolveAnchorFind for the last
|
|
1690
|
+
// anchorable block in the source-position map. Locked blocks are excluded
|
|
1691
|
+
// (Task 8.3 wires the skip).
|
|
1692
|
+
function resolveEofAnchor() {
|
|
1693
|
+
const map = sourceMap;
|
|
1694
|
+
if (!map || map.length === 0) return null;
|
|
1695
|
+
for (let i = map.length - 1; i >= 0; i--) {
|
|
1696
|
+
if (isWithinLockedRange(map[i].start, map[i].end)) continue;
|
|
1697
|
+
return resolveAnchorFind(map[i]);
|
|
1698
|
+
}
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
window.resolveEofAnchor = resolveEofAnchor;
|
|
1702
|
+
|
|
1703
|
+
// rwa-lens/1: bounded context window for anchored slash commands (spec §10).
|
|
1704
|
+
// Heading-relative heuristic: blocks between the nearest preceding heading
|
|
1705
|
+
// (any of H1-H6) and the next heading. Excludes the target.
|
|
1706
|
+
function buildAnchoredContextWindow(anchor) {
|
|
1707
|
+
const map = sourceMap, doc = currentDocCache;
|
|
1708
|
+
if (!map || !doc) return { target: '', context: '' };
|
|
1709
|
+
const idx = map.indexOf(anchor);
|
|
1710
|
+
if (idx < 0) return { target: doc.slice(anchor.start, anchor.end), context: '' };
|
|
1711
|
+
const isHeading = e => /^H[1-6]$/.test(e.tag);
|
|
1712
|
+
let lo = idx;
|
|
1713
|
+
while (lo > 0 && !isHeading(map[lo - 1])) lo--;
|
|
1714
|
+
if (lo > 0) lo--; // include the heading itself
|
|
1715
|
+
let hi = idx;
|
|
1716
|
+
while (hi < map.length - 1 && !isHeading(map[hi + 1])) hi++;
|
|
1717
|
+
const blocks = [];
|
|
1718
|
+
for (let i = lo; i <= hi; i++) {
|
|
1719
|
+
if (i === idx) continue;
|
|
1720
|
+
blocks.push(doc.slice(map[i].start, map[i].end));
|
|
1721
|
+
}
|
|
1722
|
+
return {
|
|
1723
|
+
target: doc.slice(anchor.start, anchor.end),
|
|
1724
|
+
context: blocks.join('\n'),
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
window.buildAnchoredContextWindow = buildAnchoredContextWindow;
|
|
1728
|
+
|
|
1729
|
+
// rwa-lens/1: anchored agent prompt (spec §10).
|
|
1730
|
+
function buildAnchoredPrompt(target, context, instruction) {
|
|
1731
|
+
// Detect parent-type constraint by inspecting the target's outer element.
|
|
1732
|
+
const parsed = new DOMParser().parseFromString(`<body>${target}</body>`, 'text/html');
|
|
1733
|
+
const root = parsed.body.firstElementChild;
|
|
1734
|
+
const targetTag = root ? root.tagName : '';
|
|
1735
|
+
const parentConstraint = (targetTag === 'LI')
|
|
1736
|
+
? '\n\nThe target is an <li>. Every top-level element of your response must also be <li> — never <p> or other types. Lists may not contain <p> children.'
|
|
1737
|
+
: '';
|
|
1738
|
+
return [
|
|
1739
|
+
'You are editing a single block of an HTML document.',
|
|
1740
|
+
'',
|
|
1741
|
+
'<TARGET>',
|
|
1742
|
+
target,
|
|
1743
|
+
'</TARGET>',
|
|
1744
|
+
'',
|
|
1745
|
+
'<CONTEXT>',
|
|
1746
|
+
context || '(no surrounding context — the target is in a section by itself)',
|
|
1747
|
+
'</CONTEXT>',
|
|
1748
|
+
'',
|
|
1749
|
+
`User instruction: ${instruction}`,
|
|
1750
|
+
'',
|
|
1751
|
+
'Return a replacement for the target block, of any length.',
|
|
1752
|
+
'Output naked HTML markup only — no markdown fences, no commentary, no preamble or explanation.',
|
|
1753
|
+
'The first character of your response must be the first character of the replacement block.' + parentConstraint,
|
|
1754
|
+
].join('\n');
|
|
1755
|
+
}
|
|
1756
|
+
window.buildAnchoredPrompt = buildAnchoredPrompt;
|
|
1757
|
+
|
|
1758
|
+
// rwa-lens/1: validate anchored response against parent context (spec §10).
|
|
1759
|
+
// v1: only constrains <ul>/<ol> parent → <li> children.
|
|
1760
|
+
function validateAnchoredResponse(response, anchor) {
|
|
1761
|
+
const parsed = new DOMParser().parseFromString(`<body>${response}</body>`, 'text/html');
|
|
1762
|
+
const tops = Array.from(parsed.body.children);
|
|
1763
|
+
if (tops.length === 0) {
|
|
1764
|
+
// Empty response = deletion path. Accepted.
|
|
1765
|
+
return { ok: true };
|
|
1766
|
+
}
|
|
1767
|
+
// Use the live mount to determine parent (anchor.node is from throwaway DOMParser doc).
|
|
1768
|
+
const liveNode = liveNodeForEntry(anchor);
|
|
1769
|
+
const parentTag = liveNode && liveNode.parentNode ? liveNode.parentNode.tagName : '';
|
|
1770
|
+
if (parentTag === 'UL' || parentTag === 'OL') {
|
|
1771
|
+
for (const el of tops) {
|
|
1772
|
+
if (el.tagName !== 'LI') {
|
|
1773
|
+
return { ok: false, reason: `parent <${parentTag.toLowerCase()}> requires <li> children; got <${el.tagName.toLowerCase()}>` };
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
return { ok: true };
|
|
1778
|
+
}
|
|
1779
|
+
window.validateAnchoredResponse = validateAnchoredResponse;
|
|
1780
|
+
|
|
1781
|
+
// rwa-lens/1: wrap direct text per spec §5.3 wrapping table.
|
|
1782
|
+
function wrapDirectText(text, anchorTag) {
|
|
1783
|
+
const wrapper = (anchorTag === 'LI') ? 'li' : 'p';
|
|
1784
|
+
const chunks = text.split(/\n\s*\n/).map(s => s.trim()).filter(Boolean);
|
|
1785
|
+
return chunks.map(c => `<${wrapper}>${escapeHtml(c)}</${wrapper}>`).join('\n');
|
|
1786
|
+
}
|
|
1787
|
+
function escapeHtml(s) {
|
|
1788
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
1789
|
+
}
|
|
1790
|
+
window.wrapDirectText = wrapDirectText;
|
|
1791
|
+
|
|
1792
|
+
// rwa-lens/1: lens state.
|
|
1793
|
+
const lensState = { anchor: null }; // null = default; otherwise sourceMap entry
|
|
1794
|
+
// Test seam (jsdom only) for inspecting lens state in tests.
|
|
1795
|
+
if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
|
|
1796
|
+
window.__lensState = lensState;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// rwa-lens/1: click-to-anchor (spec §5.1, §5.5).
|
|
1800
|
+
// Walks up from click target to the nearest ANCHORABLE_TAGS ancestor inside
|
|
1801
|
+
// the mount, then maps that live DOM node to its sourceMap entry.
|
|
1802
|
+
//
|
|
1803
|
+
// The source-position map's `node` refs point into a DOMParser document built
|
|
1804
|
+
// inside buildSourcePositionMap — they are NOT the live mount nodes. So we
|
|
1805
|
+
// can't match by reference equality. Instead we use ordinal position: the
|
|
1806
|
+
// scanner records anchorables in document order (outer-wins descent), and the
|
|
1807
|
+
// live mount produces the same order via the same outer-wins traversal — so
|
|
1808
|
+
// the i-th live anchorable corresponds to map[i].
|
|
1809
|
+
function handleMountClick(e) {
|
|
1810
|
+
const mount = document.getElementById('rwa-doc-mount');
|
|
1811
|
+
if (!mount) return;
|
|
1812
|
+
// Phase 8: reject the click outright if any ancestor up to the mount carries
|
|
1813
|
+
// .rwa-locked. The class-lock applies to the whole subtree (spec §8), so a
|
|
1814
|
+
// click on an inner <p> inside a locked <section> must not anchor — even
|
|
1815
|
+
// though the <p> itself is anchorable. Walk to the mount checking lock first,
|
|
1816
|
+
// then re-walk for the nearest anchorable ancestor.
|
|
1817
|
+
let el = e.target;
|
|
1818
|
+
while (el && el !== mount) {
|
|
1819
|
+
if (el.classList && el.classList.contains('rwa-locked')) {
|
|
1820
|
+
showAffordance('this region is locked');
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
el = el.parentNode;
|
|
1824
|
+
}
|
|
1825
|
+
el = e.target;
|
|
1826
|
+
while (el && el !== mount) {
|
|
1827
|
+
if (ANCHORABLE_TAGS.has(el.tagName)) {
|
|
1828
|
+
const ord = anchorableOrdinal(mount, el);
|
|
1829
|
+
const entry = (ord >= 0 && sourceMap) ? sourceMap[ord] : null;
|
|
1830
|
+
if (entry) anchorTo(entry);
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
el = el.parentNode;
|
|
1834
|
+
}
|
|
1835
|
+
// No anchorable ancestor — no-op.
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// Outer-wins anchorable descent: matches buildSourcePositionMap's walk so
|
|
1839
|
+
// ordinals line up with the source-position map.
|
|
1840
|
+
function anchorableOrdinal(root, target) {
|
|
1841
|
+
let i = 0, found = -1;
|
|
1842
|
+
(function walk(el) {
|
|
1843
|
+
if (found >= 0) return;
|
|
1844
|
+
for (const child of el.children) {
|
|
1845
|
+
if (found >= 0) return;
|
|
1846
|
+
if (ANCHORABLE_TAGS.has(child.tagName)) {
|
|
1847
|
+
if (child === target) { found = i; return; }
|
|
1848
|
+
i++;
|
|
1849
|
+
// Outer-wins: don't descend into anchorables.
|
|
1850
|
+
} else {
|
|
1851
|
+
walk(child);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
})(root);
|
|
1855
|
+
return found;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// Reverse of anchorableOrdinal: given a sourceMap entry, walk the live mount
|
|
1859
|
+
// in document order (outer-wins) and return the live node at the same ordinal.
|
|
1860
|
+
// The sourceMap's entry.node refs point into a throwaway DOMParser doc, so we
|
|
1861
|
+
// can't follow them — but ordinal position is stable across the two walks.
|
|
1862
|
+
function liveNodeForEntry(entry) {
|
|
1863
|
+
const map = sourceMap;
|
|
1864
|
+
if (!map || !entry) return null;
|
|
1865
|
+
const ord = map.indexOf(entry);
|
|
1866
|
+
if (ord < 0) return null;
|
|
1867
|
+
const mount = document.getElementById('rwa-doc-mount');
|
|
1868
|
+
if (!mount) return null;
|
|
1869
|
+
let i = 0;
|
|
1870
|
+
let found = null;
|
|
1871
|
+
(function walk(el) {
|
|
1872
|
+
for (const child of el.children) {
|
|
1873
|
+
if (found) return;
|
|
1874
|
+
if (ANCHORABLE_TAGS.has(child.tagName)) {
|
|
1875
|
+
if (i === ord) { found = child; return; }
|
|
1876
|
+
i++;
|
|
1877
|
+
} else {
|
|
1878
|
+
walk(child);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
})(mount);
|
|
1882
|
+
return found;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function anchorTo(entry) {
|
|
1886
|
+
lensState.anchor = entry;
|
|
1887
|
+
const lens = document.getElementById('rwa-lens');
|
|
1888
|
+
if (!lens) return;
|
|
1889
|
+
lens.dataset.state = 'anchored';
|
|
1890
|
+
// Move lens to sit just after the anchored block's live node, if findable.
|
|
1891
|
+
const liveNode = liveNodeForEntry(entry);
|
|
1892
|
+
if (liveNode && liveNode.parentNode) {
|
|
1893
|
+
liveNode.parentNode.insertBefore(lens, liveNode.nextSibling);
|
|
1894
|
+
}
|
|
1895
|
+
// Badge: "anchored on <tag>" + an X close button.
|
|
1896
|
+
const badge = lens.querySelector('#rwa-lens-badge');
|
|
1897
|
+
if (badge) {
|
|
1898
|
+
badge.hidden = false;
|
|
1899
|
+
badge.textContent = '';
|
|
1900
|
+
const tagSpan = document.createElement('span');
|
|
1901
|
+
tagSpan.textContent = `anchored on ${entry.tag.toLowerCase()} `;
|
|
1902
|
+
badge.appendChild(tagSpan);
|
|
1903
|
+
const x = document.createElement('button');
|
|
1904
|
+
x.type = 'button';
|
|
1905
|
+
x.textContent = '✕';
|
|
1906
|
+
x.setAttribute('aria-label', 'release anchor');
|
|
1907
|
+
x.addEventListener('click', releaseAnchor);
|
|
1908
|
+
badge.appendChild(x);
|
|
1909
|
+
}
|
|
1910
|
+
// Visual highlight on the live block.
|
|
1911
|
+
if (lensState._highlighted) lensState._highlighted.removeAttribute('data-rwa-anchored');
|
|
1912
|
+
if (liveNode) {
|
|
1913
|
+
liveNode.setAttribute('data-rwa-anchored', '');
|
|
1914
|
+
lensState._highlighted = liveNode;
|
|
1915
|
+
} else {
|
|
1916
|
+
lensState._highlighted = null;
|
|
1917
|
+
}
|
|
1918
|
+
// Placeholder shift — what the user can do here.
|
|
1919
|
+
const input = lens.querySelector('#rwa-lens-input');
|
|
1920
|
+
if (input) input.placeholder = 'insert after this block, or /edit it';
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// Releases an anchored lens back to its docked default position.
|
|
1924
|
+
// Called by the badge X button and by the global Esc keydown handler.
|
|
1925
|
+
function releaseAnchor() {
|
|
1926
|
+
const lens = document.getElementById('rwa-lens');
|
|
1927
|
+
if (!lens) return;
|
|
1928
|
+
// Move lens back to its docked position. The default mount is direct child
|
|
1929
|
+
// of body (CSS uses position:fixed; bottom:0 to dock it). Re-appending to
|
|
1930
|
+
// body is sufficient for layout and matches the harness expectation.
|
|
1931
|
+
document.body.appendChild(lens);
|
|
1932
|
+
lens.dataset.state = 'default';
|
|
1933
|
+
const badge = lens.querySelector('#rwa-lens-badge');
|
|
1934
|
+
if (badge) {
|
|
1935
|
+
badge.hidden = true;
|
|
1936
|
+
badge.textContent = '';
|
|
1937
|
+
}
|
|
1938
|
+
if (lensState._highlighted) {
|
|
1939
|
+
lensState._highlighted.removeAttribute('data-rwa-anchored');
|
|
1940
|
+
lensState._highlighted = null;
|
|
1941
|
+
}
|
|
1942
|
+
lensState.anchor = null;
|
|
1943
|
+
const input = lens.querySelector('#rwa-lens-input');
|
|
1944
|
+
if (input) input.placeholder = 'Write, or describe what you want.';
|
|
1945
|
+
}
|
|
1946
|
+
window.releaseAnchor = releaseAnchor;
|
|
1947
|
+
if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
|
|
1948
|
+
window.__handleMountClick = handleMountClick;
|
|
1949
|
+
window.__liveNodeForEntry = liveNodeForEntry;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// rwa-lens/1: submit dispatcher. Routes (state × mode) → envelope/agent path.
|
|
1953
|
+
async function submitLens(text) {
|
|
1954
|
+
if (!text || !text.length) return;
|
|
1955
|
+
const isEscapedSlash = text.startsWith('\\/');
|
|
1956
|
+
const isCommand = text.startsWith('/') && !isEscapedSlash;
|
|
1957
|
+
const stripped = isEscapedSlash ? text.slice(1) : text;
|
|
1958
|
+
const anchored = lensState.anchor !== null;
|
|
1959
|
+
if (isCommand) {
|
|
1960
|
+
const instruction = stripped.slice(1); // drop the leading '/'
|
|
1961
|
+
if (anchored) {
|
|
1962
|
+
await runAnchoredCommand(lensState.anchor, instruction);
|
|
1963
|
+
} else {
|
|
1964
|
+
// Default-state slash command: thread lensMeta so the rwa_hist record
|
|
1965
|
+
// carries surface='default-command' (Invariant 6 — every lens-originated
|
|
1966
|
+
// commit names the surface that produced it).
|
|
1967
|
+
await modify(instruction, {
|
|
1968
|
+
surface: 'default-command',
|
|
1969
|
+
instruction,
|
|
1970
|
+
scope: { type: 'document' },
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
} else {
|
|
1974
|
+
const envelope = anchored
|
|
1975
|
+
? synthesizeAnchoredInsert(lensState.anchor, stripped)
|
|
1976
|
+
: synthesizeDefaultAppend(stripped);
|
|
1977
|
+
const surface = anchored ? 'anchored-text' : 'default-text';
|
|
1978
|
+
await synthesizeAndCommit(envelope, surface, stripped);
|
|
1979
|
+
}
|
|
1980
|
+
// Clear the input only on successful submit. Placed AFTER the await above
|
|
1981
|
+
// (not in a finally) so a thrown error preserves the user's typed text —
|
|
1982
|
+
// the keydown handler surfaces the failure and the user can re-submit.
|
|
1983
|
+
const input = document.getElementById('rwa-lens-input');
|
|
1984
|
+
if (input) {
|
|
1985
|
+
input.value = '';
|
|
1986
|
+
// Collapse the auto-grown textarea back to one row. Without this the
|
|
1987
|
+
// textarea would keep the height it grew to during input.
|
|
1988
|
+
if (typeof window.__lensAutosize === 'function') window.__lensAutosize();
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
window.submitLens = submitLens;
|
|
1992
|
+
|
|
1993
|
+
// Resolve the user-selected backend into a transport config.
|
|
1994
|
+
// kind:'openai_compat' covers openrouter/ollama/lmstudio — same wire protocol,
|
|
1995
|
+
// different baseUrl/auth. kind:'bridge' is a separate transport entirely (a
|
|
1996
|
+
// localhost shell shim) and is handled by callBridgeSingleShot / modifyViaBridge.
|
|
1997
|
+
function resolveBackendConfig() {
|
|
1998
|
+
const backend = sessionStorage.getItem(RWA.K_BACKEND) || 'openrouter';
|
|
1999
|
+
if (backend === 'ollama') {
|
|
2000
|
+
return {
|
|
2001
|
+
backend, kind:'openai_compat',
|
|
2002
|
+
baseUrl: (sessionStorage.getItem(RWA.K_BASE_URL_OLLAMA) || '').trim() || RWA.DEFAULT_OLLAMA_URL,
|
|
2003
|
+
apiKey: null, extraHeaders: {}, requiresKey: false,
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
if (backend === 'lmstudio') {
|
|
2007
|
+
return {
|
|
2008
|
+
backend, kind:'openai_compat',
|
|
2009
|
+
baseUrl: (sessionStorage.getItem(RWA.K_BASE_URL_LMSTUDIO) || '').trim() || RWA.DEFAULT_LMSTUDIO_URL,
|
|
2010
|
+
apiKey: null, extraHeaders: {}, requiresKey: false,
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
if (backend === 'bridge') {
|
|
2014
|
+
return { backend, kind:'bridge' };
|
|
2015
|
+
}
|
|
2016
|
+
// openrouter (and unknown values default here)
|
|
2017
|
+
return {
|
|
2018
|
+
backend:'openrouter', kind:'openai_compat',
|
|
2019
|
+
baseUrl: 'https://openrouter.ai/api/v1',
|
|
2020
|
+
apiKey: sessionStorage.getItem(RWA.K_API),
|
|
2021
|
+
extraHeaders: {
|
|
2022
|
+
'HTTP-Referer': 'https://github.com/ikangai/rewritable',
|
|
2023
|
+
'X-Title': 're-write-able',
|
|
2024
|
+
},
|
|
2025
|
+
requiresKey: true,
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
// Unified OpenAI-compatible POST /chat/completions. Caller supplies the body
|
|
2030
|
+
// (model, messages, optional tools/tool_choice). Returns the parsed JSON body.
|
|
2031
|
+
async function openAiCompatChat(cfg, body) {
|
|
2032
|
+
const headers = { 'Content-Type': 'application/json', ...(cfg.extraHeaders || {}) };
|
|
2033
|
+
if (cfg.apiKey) headers['Authorization'] = 'Bearer ' + cfg.apiKey;
|
|
2034
|
+
const url = cfg.baseUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
2035
|
+
const r = await fetch(url, { method:'POST', headers, body: JSON.stringify(body) });
|
|
2036
|
+
if (!r.ok) {
|
|
2037
|
+
const e = await r.json().catch(() => ({}));
|
|
2038
|
+
throw new Error((e.error && e.error.message) || r.statusText || ('http ' + r.status));
|
|
2039
|
+
}
|
|
2040
|
+
return r.json();
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// GET <baseUrl>/models — used by the settings panel to populate the model
|
|
2044
|
+
// suggestions for Ollama/LMStudio. Returns an array of model id strings.
|
|
2045
|
+
async function listOpenAiCompatModels(cfg) {
|
|
2046
|
+
const headers = { ...(cfg.extraHeaders || {}) };
|
|
2047
|
+
if (cfg.apiKey) headers['Authorization'] = 'Bearer ' + cfg.apiKey;
|
|
2048
|
+
const url = cfg.baseUrl.replace(/\/+$/, '') + '/models';
|
|
2049
|
+
const r = await fetch(url, { method:'GET', headers });
|
|
2050
|
+
if (!r.ok) throw new Error('http ' + r.status);
|
|
2051
|
+
const data = await r.json();
|
|
2052
|
+
const items = Array.isArray(data?.data) ? data.data : (Array.isArray(data?.models) ? data.models : []);
|
|
2053
|
+
return items.map(m => (typeof m === 'string' ? m : (m.id || m.name || ''))).filter(Boolean);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
// rwa-lens/1: single-shot completion helper for anchored slash commands.
|
|
2057
|
+
// Unlike modify(), no tool_use loop: the runtime constructs the envelope from
|
|
2058
|
+
// the model's plain-text response (one block of HTML), so we don't need tools
|
|
2059
|
+
// at all — just a plain chat completion. tool_choice: 'none' is implicit by
|
|
2060
|
+
// omitting tools.
|
|
2061
|
+
async function callAgentSingleShot(prompt) {
|
|
2062
|
+
const cfg = resolveBackendConfig();
|
|
2063
|
+
if (cfg.kind === 'bridge') return callBridgeSingleShot(prompt);
|
|
2064
|
+
if (cfg.requiresKey && !cfg.apiKey) throw new Error('no API key');
|
|
2065
|
+
const model = sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
|
|
2066
|
+
const data = await openAiCompatChat(cfg, {
|
|
2067
|
+
model,
|
|
2068
|
+
max_tokens: 32000,
|
|
2069
|
+
messages: [{ role:'user', content: prompt }],
|
|
2070
|
+
});
|
|
2071
|
+
const msg = data.choices?.[0]?.message;
|
|
2072
|
+
return (msg?.content || '').trim();
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// Bridge counterpart to callAgentSingleShot. Anchored commands ask for naked
|
|
2076
|
+
// HTML, not a JSON envelope, so we don't run parseBridgeEnvelope — but we do
|
|
2077
|
+
// strip a leading ```html / ``` fence in case claude wraps it anyway.
|
|
2078
|
+
async function callBridgeSingleShot(prompt) {
|
|
2079
|
+
const promptB64 = btoa(unescape(encodeURIComponent(prompt)));
|
|
2080
|
+
const cmd = `echo '${promptB64}' | base64 -d | claude -p --output-format text --permission-mode bypassPermissions`;
|
|
2081
|
+
let resp;
|
|
2082
|
+
try {
|
|
2083
|
+
resp = await fetch(RWA.BRIDGE_URL, {
|
|
2084
|
+
method: 'POST',
|
|
2085
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2086
|
+
body: JSON.stringify({ command: cmd }),
|
|
2087
|
+
});
|
|
2088
|
+
} catch (err) {
|
|
2089
|
+
throw new Error('bridge unreachable at ' + RWA.BRIDGE_URL + ' — is web_cli_bridge running?');
|
|
2090
|
+
}
|
|
2091
|
+
if (!resp.ok) throw new Error('bridge http ' + resp.status);
|
|
2092
|
+
const { stdout, stderr, exit_code } = await resp.json();
|
|
2093
|
+
if (exit_code !== 0) {
|
|
2094
|
+
const tail = (stderr || '').trim().split('\n').slice(-3).join('\n').slice(0, 300);
|
|
2095
|
+
throw new Error('claude -p exited ' + exit_code + (tail ? ': ' + tail : ''));
|
|
2096
|
+
}
|
|
2097
|
+
return (stdout || '').trim()
|
|
2098
|
+
.replace(/^```(?:html)?\s*\n?/i, '')
|
|
2099
|
+
.replace(/\n?\s*```\s*$/, '')
|
|
2100
|
+
.trim();
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// rwa-lens/1: post-commit anchor behavior (spec §5.4).
|
|
2104
|
+
// Three branches based on the agent's response shape:
|
|
2105
|
+
// 1. Single anchorable block → re-anchor on the new block at the same source-start.
|
|
2106
|
+
// 2. Multiple anchorable blocks → release with affordance (the lens can no longer
|
|
2107
|
+
// point at "this block" because the response replaced it with several).
|
|
2108
|
+
// 3. Empty (or no anchorable elements after parse) → release without affordance
|
|
2109
|
+
// (the user explicitly asked for deletion, no surprise to surface).
|
|
2110
|
+
function handlePostCommitAnchor(response, prevAnchor) {
|
|
2111
|
+
const parsed = new DOMParser().parseFromString(`<body>${response}</body>`, 'text/html');
|
|
2112
|
+
const tops = Array.from(parsed.body.children).filter(el => ANCHORABLE_TAGS.has(el.tagName));
|
|
2113
|
+
if (tops.length === 0) {
|
|
2114
|
+
// Empty (or no anchorable elements) → release without affordance.
|
|
2115
|
+
releaseAnchor();
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
if (tops.length === 1) {
|
|
2119
|
+
// Single anchorable block → re-anchor on the new entry at the prevAnchor.start position.
|
|
2120
|
+
const newEntry = sourceMap ? sourceMap.find(m => m.start === prevAnchor.start) : null;
|
|
2121
|
+
if (newEntry) anchorTo(newEntry);
|
|
2122
|
+
else releaseAnchor();
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
// Multi-block → release with affordance.
|
|
2126
|
+
releaseAnchor();
|
|
2127
|
+
showAffordance('anchor released — response was multi-block');
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// rwa-lens/1: ephemeral toast for surfacing post-commit affordances.
|
|
2131
|
+
// Used by handlePostCommitAnchor's multi-block branch to explain why the
|
|
2132
|
+
// lens is no longer anchored. Auto-removes after 3 seconds.
|
|
2133
|
+
function showAffordance(text) {
|
|
2134
|
+
const el = document.createElement('div');
|
|
2135
|
+
el.className = 'rwa-lens-toast';
|
|
2136
|
+
el.textContent = text;
|
|
2137
|
+
document.body.appendChild(el);
|
|
2138
|
+
setTimeout(() => el.remove(), 3000);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// rwa-lens/1: anchored slash command runner (spec §7).
|
|
2142
|
+
// Build the bounded context, prompt the agent for a replacement block, validate
|
|
2143
|
+
// the response shape, synthesize an apply_edits envelope (anchored find +
|
|
2144
|
+
// prefix/suffix from resolveAnchorFind), commit through applyEdits, and apply
|
|
2145
|
+
// the spec §5.4 post-commit anchor behavior (re-anchor / release-with-affordance /
|
|
2146
|
+
// release). Up to 3 retries with structured failure context appended to the
|
|
2147
|
+
// prompt. No silent escalation to replace_document.
|
|
2148
|
+
async function runAnchoredCommand(anchor, instruction) {
|
|
2149
|
+
if (modifyMutex) {
|
|
2150
|
+
setStatus('err', '✗ another modify in progress');
|
|
2151
|
+
throw new RwaEditError('concurrent_modify');
|
|
2152
|
+
}
|
|
2153
|
+
modifyMutex = true;
|
|
2154
|
+
// Task 11.1: visible busy indicator on the lens during in-flight commands.
|
|
2155
|
+
const lensBusy = document.getElementById('rwa-lens');
|
|
2156
|
+
if (lensBusy) lensBusy.dataset.busy = 'true';
|
|
2157
|
+
setStatus('run', '⌘K running');
|
|
2158
|
+
try {
|
|
2159
|
+
const { target, context } = buildAnchoredContextWindow(anchor);
|
|
2160
|
+
const basePrompt = buildAnchoredPrompt(target, context, instruction);
|
|
2161
|
+
let attempts = 0;
|
|
2162
|
+
let lastFailure = null;
|
|
2163
|
+
while (attempts < 3) {
|
|
2164
|
+
attempts++;
|
|
2165
|
+
const tryPrompt = basePrompt + (lastFailure ? `\n\nPrevious attempt failed: ${lastFailure}\nRetry honoring the constraint.` : '');
|
|
2166
|
+
const response = await callAgentSingleShot(tryPrompt);
|
|
2167
|
+
const validation = validateAnchoredResponse(response, anchor);
|
|
2168
|
+
if (!validation.ok) { lastFailure = validation.reason; continue; }
|
|
2169
|
+
const find = resolveAnchorFind(anchor);
|
|
2170
|
+
if (!find) { lastFailure = 'anchor could not be resolved (ambiguous source)'; continue; }
|
|
2171
|
+
const envelope = {
|
|
2172
|
+
version: 'rwa-edit/1',
|
|
2173
|
+
edits: [{
|
|
2174
|
+
find: find.find,
|
|
2175
|
+
replace: find.replacePrefix + response + find.replaceSuffix,
|
|
2176
|
+
reason: 'lens: anchored slash command',
|
|
2177
|
+
}],
|
|
2178
|
+
reason: `lens: ${instruction}`,
|
|
2179
|
+
};
|
|
2180
|
+
try {
|
|
2181
|
+
const lensMeta = {
|
|
2182
|
+
surface: 'anchored-command',
|
|
2183
|
+
instruction,
|
|
2184
|
+
scope: { type: 'block', block_id: String(anchor.start) },
|
|
2185
|
+
};
|
|
2186
|
+
const result = await applyEdits(envelope, await getDoc(), lensMeta);
|
|
2187
|
+
renderDoc(result);
|
|
2188
|
+
setDirty(true);
|
|
2189
|
+
await rwaBumpDirtyCount().catch(() => {});
|
|
2190
|
+
rwaCheckQuota();
|
|
2191
|
+
// Defer the emit so by the time listeners run, the `finally` block
|
|
2192
|
+
// below has released `modifyMutex`. Without this, a listener that
|
|
2193
|
+
// synchronously calls `runtime.modify()` from inside its callback
|
|
2194
|
+
// would hit `concurrent_modify` and have the error swallowed by
|
|
2195
|
+
// `emitRuntimeEvent`'s try/catch (Fix I2).
|
|
2196
|
+
queueMicrotask(() => {
|
|
2197
|
+
emitRuntimeEvent('modify', { instruction, lensMeta });
|
|
2198
|
+
emitRuntimeEvent('status', getStatusSnapshot());
|
|
2199
|
+
});
|
|
2200
|
+
// Spec §5.4 post-commit anchor branches: single → re-anchor on new
|
|
2201
|
+
// block; multi → release with affordance; empty → release silently.
|
|
2202
|
+
// The pre-commit anchor (with its start offset) is the stable handle
|
|
2203
|
+
// for the single-block re-anchor lookup, since find/replace splices
|
|
2204
|
+
// at the same source offset preserve the start position.
|
|
2205
|
+
handlePostCommitAnchor(response, anchor);
|
|
2206
|
+
setStatus('ok', '✓ done');
|
|
2207
|
+
return;
|
|
2208
|
+
} catch (err) {
|
|
2209
|
+
if (!(err instanceof RwaEditError)) throw err;
|
|
2210
|
+
lastFailure = err.code + (err.editIndex != null ? ' [edit ' + err.editIndex + ']' : '');
|
|
2211
|
+
continue;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
setStatus('err', `✗ ${lastFailure || 'lens command failed after retries'}`);
|
|
2215
|
+
} catch (e) {
|
|
2216
|
+
setStatus('err', '✗ ' + e.message);
|
|
2217
|
+
console.error(e);
|
|
2218
|
+
} finally {
|
|
2219
|
+
modifyMutex = false;
|
|
2220
|
+
if (lensBusy) lensBusy.dataset.busy = 'false';
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
function synthesizeAnchoredInsert(anchor, text) {
|
|
2224
|
+
const find = resolveAnchorFind(anchor);
|
|
2225
|
+
if (!find) return null;
|
|
2226
|
+
const wrapped = wrapDirectText(text, anchor.tag);
|
|
2227
|
+
return {
|
|
2228
|
+
version: 'rwa-edit/1',
|
|
2229
|
+
edits: [{
|
|
2230
|
+
find: find.find,
|
|
2231
|
+
replace: find.find + '\n' + wrapped,
|
|
2232
|
+
reason: 'lens: anchored direct-text insert',
|
|
2233
|
+
}],
|
|
2234
|
+
reason: 'lens: insert after anchor',
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
|
|
2238
|
+
window.__synthesizeAnchoredInsert = synthesizeAnchoredInsert;
|
|
2239
|
+
}
|
|
2240
|
+
function synthesizeDefaultAppend(text) {
|
|
2241
|
+
const eof = resolveEofAnchor();
|
|
2242
|
+
if (!eof) return null; // empty doc — caller routes via replace_document
|
|
2243
|
+
const wrapped = wrapDirectText(text, null);
|
|
2244
|
+
return {
|
|
2245
|
+
version: 'rwa-edit/1',
|
|
2246
|
+
edits: [{
|
|
2247
|
+
find: eof.find,
|
|
2248
|
+
replace: eof.find + '\n' + wrapped,
|
|
2249
|
+
reason: 'lens: default-state direct-text append',
|
|
2250
|
+
}],
|
|
2251
|
+
reason: 'lens: append at EOF',
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
|
|
2255
|
+
window.__synthesizeDefaultAppend = synthesizeDefaultAppend;
|
|
2256
|
+
}
|
|
2257
|
+
async function synthesizeAndCommit(envelope, surface, instruction) {
|
|
2258
|
+
// Test seam preserved.
|
|
2259
|
+
if (typeof window.__synthesizeAndCommit === 'function') {
|
|
2260
|
+
return window.__synthesizeAndCommit(envelope, surface, instruction);
|
|
2261
|
+
}
|
|
2262
|
+
// Acquire modifyMutex around the read+commit window. Without this, two
|
|
2263
|
+
// concurrent ⌘Enter submits could race against each other: both read the
|
|
2264
|
+
// same currentDoc, both push it onto rwa_undo, and the later commit
|
|
2265
|
+
// silently overwrites the earlier one with a stale-base envelope. Throw
|
|
2266
|
+
// concurrent_modify so the keydown handler can surface it (and preserve
|
|
2267
|
+
// the input — see Issue 2).
|
|
2268
|
+
if (modifyMutex) throw new RwaEditError('concurrent_modify');
|
|
2269
|
+
modifyMutex = true;
|
|
2270
|
+
try {
|
|
2271
|
+
// Capture pre-commit anchor's start offset for post-commit re-anchor lookup.
|
|
2272
|
+
// The anchor's source-start is the stable handle across a commit: an
|
|
2273
|
+
// insert-after-anchor edit (anchored direct text) leaves the anchor's start
|
|
2274
|
+
// unchanged, and a replace-this-anchor edit (Phase 7) preserves it because
|
|
2275
|
+
// find/replace splices at the same source offset.
|
|
2276
|
+
const prevAnchorStart = lensState.anchor ? lensState.anchor.start : null;
|
|
2277
|
+
// Phase 9.1: build lensMeta so the rwa_hist record carries surface,
|
|
2278
|
+
// instruction, and the scope (block-anchored or eof). Scope is captured
|
|
2279
|
+
// pre-commit because lensState.anchor may be cleared by renderDoc below.
|
|
2280
|
+
const lensMeta = {
|
|
2281
|
+
surface,
|
|
2282
|
+
instruction,
|
|
2283
|
+
scope: lensState.anchor
|
|
2284
|
+
? { type: 'block', block_id: String(lensState.anchor.start) }
|
|
2285
|
+
: { type: 'eof' },
|
|
2286
|
+
};
|
|
2287
|
+
let result;
|
|
2288
|
+
if (!envelope) {
|
|
2289
|
+
// Empty doc → first append → use replace_document.
|
|
2290
|
+
const wrapped = wrapDirectText(instruction || '', null);
|
|
2291
|
+
const repEnv = {
|
|
2292
|
+
version: 'rwa-edit/1',
|
|
2293
|
+
doc: wrapped,
|
|
2294
|
+
reason: 'initial content into an empty document',
|
|
2295
|
+
};
|
|
2296
|
+
result = await replaceDocument(repEnv, await getDoc(), lensMeta);
|
|
2297
|
+
} else {
|
|
2298
|
+
result = await applyEdits(envelope, await getDoc(), lensMeta);
|
|
2299
|
+
}
|
|
2300
|
+
// Re-render so the user sees the new content. renderDoc rebuilds sourceMap
|
|
2301
|
+
// and (because the lens may sit inside the mount) calls releaseAnchor, so we
|
|
2302
|
+
// must re-resolve the anchor afterwards using the offset captured above.
|
|
2303
|
+
renderDoc(result);
|
|
2304
|
+
setDirty(true);
|
|
2305
|
+
await rwaBumpDirtyCount().catch(() => {});
|
|
2306
|
+
rwaCheckQuota();
|
|
2307
|
+
// Defer the emit so the `finally` below releases `modifyMutex` before any
|
|
2308
|
+
// listener fires; otherwise a listener calling `runtime.modify()` would
|
|
2309
|
+
// hit `concurrent_modify` and that error would be swallowed by
|
|
2310
|
+
// `emitRuntimeEvent`'s try/catch (Fix I2).
|
|
2311
|
+
queueMicrotask(() => {
|
|
2312
|
+
emitRuntimeEvent('modify', { instruction, lensMeta });
|
|
2313
|
+
emitRuntimeEvent('status', getStatusSnapshot());
|
|
2314
|
+
});
|
|
2315
|
+
if (prevAnchorStart !== null && sourceMap) {
|
|
2316
|
+
const newEntry = sourceMap.find(m => m.start === prevAnchorStart);
|
|
2317
|
+
if (newEntry) {
|
|
2318
|
+
// Re-anchor onto the same logical block. anchorTo handles lens repos,
|
|
2319
|
+
// badge text, and the data-rwa-anchored visual highlight on the live
|
|
2320
|
+
// node (which is a fresh DOM element after the innerHTML rebuild).
|
|
2321
|
+
anchorTo(newEntry);
|
|
2322
|
+
}
|
|
2323
|
+
// If newEntry is null the anchor's source-start no longer exists; the
|
|
2324
|
+
// releaseAnchor inside renderDoc has already cleared lensState.anchor and
|
|
2325
|
+
// returned the lens to its docked default, so no extra work is needed.
|
|
2326
|
+
}
|
|
2327
|
+
return result;
|
|
2328
|
+
} finally {
|
|
2329
|
+
modifyMutex = false;
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// Test seam: directly write a doc to IDB + rebuild the map, no agent loop.
|
|
2334
|
+
async function __setDocForTest(d) {
|
|
2335
|
+
const db = await openDB();
|
|
2336
|
+
await new Promise((resolve, reject) => {
|
|
2337
|
+
const tx = db.transaction('rwa_doc', 'readwrite');
|
|
2338
|
+
tx.objectStore('rwa_doc').put(d, 'self');
|
|
2339
|
+
tx.oncomplete = resolve;
|
|
2340
|
+
tx.onerror = () => reject(tx.error);
|
|
2341
|
+
});
|
|
2342
|
+
renderDoc(d);
|
|
2343
|
+
}
|
|
2344
|
+
// Test seam: only exposed under jsdom (the tests/ harness).
|
|
2345
|
+
// In real browsers this binding stays internal — a script inside INLINE_DOC
|
|
2346
|
+
// could otherwise call __setDocForTest('') to wipe the doc with no undo.
|
|
2347
|
+
if (typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)) {
|
|
2348
|
+
window.__setDocForTest = __setDocForTest;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// Token-level balance check for <script> and <style>. The HTML parser
|
|
2352
|
+
// auto-closes these silently at EOF when the closing tag is missing, so a
|
|
2353
|
+
// post-parse shape check can pass while the doc has actually been
|
|
2354
|
+
// corrupted (a lone `<script>` swallows all following markup as text).
|
|
2355
|
+
// We catch this by counting raw tokens in the doc string.
|
|
2356
|
+
function tagBalance(doc) {
|
|
2357
|
+
const opens = {
|
|
2358
|
+
script: (doc.match(/<script\b/gi) || []).length,
|
|
2359
|
+
style: (doc.match(/<style\b/gi) || []).length,
|
|
2360
|
+
};
|
|
2361
|
+
const closes = {
|
|
2362
|
+
script: (doc.match(/<\/script\s*>/gi) || []).length,
|
|
2363
|
+
style: (doc.match(/<\/style\s*>/gi) || []).length,
|
|
2364
|
+
};
|
|
2365
|
+
for (const tag of ['script', 'style']) {
|
|
2366
|
+
if (opens[tag] !== closes[tag]) return { tag, opens: opens[tag], closes: closes[tag] };
|
|
2367
|
+
}
|
|
2368
|
+
return null;
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
function computeShape(parsedDoc) {
|
|
2372
|
+
if (!parsedDoc) return null;
|
|
2373
|
+
const body = parsedDoc.body;
|
|
2374
|
+
// Track the SET of distinct top-level tag types (not the multiset count) —
|
|
2375
|
+
// splitting one <p> into two <p>s is a legitimate edit, but introducing a
|
|
2376
|
+
// <section> alongside <div> is a structural shape change.
|
|
2377
|
+
const topLevelTypes = body
|
|
2378
|
+
? [...new Set(Array.from(body.children).map(el => el.tagName.toLowerCase()))].sort().join(',')
|
|
2379
|
+
: '';
|
|
2380
|
+
return {
|
|
2381
|
+
scripts: parsedDoc.querySelectorAll('script').length,
|
|
2382
|
+
styles: parsedDoc.querySelectorAll('style').length,
|
|
2383
|
+
topLevelTypes,
|
|
2384
|
+
};
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
const shapesEqual = (a, b) =>
|
|
2388
|
+
!!a && !!b &&
|
|
2389
|
+
a.scripts === b.scripts &&
|
|
2390
|
+
a.styles === b.styles &&
|
|
2391
|
+
a.topLevelTypes === b.topLevelTypes;
|
|
2392
|
+
|
|
2393
|
+
function dataRwaFrozenSnapshot(parsedDoc) {
|
|
2394
|
+
if (!parsedDoc) return [];
|
|
2395
|
+
return Array.from(parsedDoc.querySelectorAll('[data-rwa-frozen]'))
|
|
2396
|
+
.map(el => el.tagName.toLowerCase() + '\0' + el.outerHTML)
|
|
2397
|
+
.sort();
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
function snapshotsEqual(a, b) {
|
|
2401
|
+
if (a.length !== b.length) return false;
|
|
2402
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
2403
|
+
return true;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
// Reserved IDs/attributes the runtime owns. The render mount is #rwa-doc-mount
|
|
2407
|
+
// (CLAUDE.md §"Reserved namespaces"). A doc that uses that ID shadows the
|
|
2408
|
+
// runtime's contract.
|
|
2409
|
+
//
|
|
2410
|
+
// data-rwa-id is intentionally NOT rejected here: as of bootstrap 0.9 the
|
|
2411
|
+
// runtime auto-assigns data-rwa-id to anchorable blocks for URL-fragment
|
|
2412
|
+
// stability on the web. Stored docs will normally carry these. The agent is
|
|
2413
|
+
// instructed (SYSTEM_PROMPT) to preserve existing values verbatim and not
|
|
2414
|
+
// invent new ones; injectMissingBlockIds() re-asserts coverage on every
|
|
2415
|
+
// commit.
|
|
2416
|
+
function findReservedIdViolation(parsedDoc) {
|
|
2417
|
+
if (!parsedDoc) return null;
|
|
2418
|
+
if (parsedDoc.querySelector('#rwa-doc-mount')) return 'rwa-doc-mount';
|
|
2419
|
+
return null;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// rwa-bootstrap 0.9 — stable block identifiers.
|
|
2423
|
+
//
|
|
2424
|
+
// Every anchorable block (ANCHORABLE_TAGS — p, h1..h6, blockquote, li, figure,
|
|
2425
|
+
// pre, aside) carries a `data-rwa-id` attribute the runtime assigns. The ID is
|
|
2426
|
+
// the public name of that block; a URL like `notes.html#7k3p2m9q` resolves to
|
|
2427
|
+
// it via scrollToFragment(). IDs are part of the canonical document text so
|
|
2428
|
+
// they round-trip through commits and undo.
|
|
2429
|
+
//
|
|
2430
|
+
// Format: 8 chars, RFC 4648 base32 lowercase (a–z, 2–7) from 5 random bytes.
|
|
2431
|
+
// 40 bits ≈ 1e12 codes; collision risk in a single doc is negligible.
|
|
2432
|
+
function generateBlockId() {
|
|
2433
|
+
const ALPHA = 'abcdefghijklmnopqrstuvwxyz234567';
|
|
2434
|
+
const buf = new Uint8Array(5);
|
|
2435
|
+
crypto.getRandomValues(buf);
|
|
2436
|
+
let bits = 0, val = 0, out = '';
|
|
2437
|
+
for (const b of buf) {
|
|
2438
|
+
val = (val << 8) | b;
|
|
2439
|
+
bits += 8;
|
|
2440
|
+
while (bits >= 5) { bits -= 5; out += ALPHA[(val >>> bits) & 31]; }
|
|
2441
|
+
}
|
|
2442
|
+
return out;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
// Inject `data-rwa-id="…"` into every anchorable block that lacks one. Pure
|
|
2446
|
+
// string surgery against the source text — preserves whitespace, attribute
|
|
2447
|
+
// order, and quoting bytes exactly (the format-stability promise from
|
|
2448
|
+
// invariant 11). The scan mirrors buildSourcePositionMap: same tag set, same
|
|
2449
|
+
// masking of script/style/comments, same outer-wins skip past nested blocks.
|
|
2450
|
+
//
|
|
2451
|
+
// Frozen zones (marker form and data-rwa-frozen elements) are skipped — their
|
|
2452
|
+
// bytes are author-declared invariants; injecting an attribute inside one
|
|
2453
|
+
// would break frozenZonesIntact() / dataRwaFrozenSnapshot() on the next edit.
|
|
2454
|
+
//
|
|
2455
|
+
// Returns { text, assigned }. If assigned === 0, text === doc byte-for-byte.
|
|
2456
|
+
function injectMissingBlockIds(doc) {
|
|
2457
|
+
if (typeof doc !== 'string' || doc.length === 0) return { text: doc, assigned: 0 };
|
|
2458
|
+
const masked = maskRawTextAndComments(doc);
|
|
2459
|
+
const frozenRanges = markerZoneRangesIn(doc);
|
|
2460
|
+
const inFrozen = (pos) => {
|
|
2461
|
+
for (const [s, e] of frozenRanges) if (pos >= s && pos < e) return true;
|
|
2462
|
+
return false;
|
|
2463
|
+
};
|
|
2464
|
+
const tagOpen = /<([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g;
|
|
2465
|
+
const inserts = [];
|
|
2466
|
+
let m;
|
|
2467
|
+
while ((m = tagOpen.exec(masked)) !== null) {
|
|
2468
|
+
const tag = m[1].toUpperCase();
|
|
2469
|
+
if (!ANCHORABLE_TAGS.has(tag)) continue;
|
|
2470
|
+
const closeEnd = findCloseTagEnd(masked, m[1], m.index + m[0].length);
|
|
2471
|
+
if (closeEnd < 0) continue;
|
|
2472
|
+
tagOpen.lastIndex = closeEnd;
|
|
2473
|
+
if (inFrozen(m.index)) continue;
|
|
2474
|
+
const attrs = m[2] || '';
|
|
2475
|
+
if (/\sdata-rwa-id\s*=/.test(attrs)) continue;
|
|
2476
|
+
const pos = m.index + 1 + m[1].length;
|
|
2477
|
+
inserts.push({ pos, text: ' data-rwa-id="' + generateBlockId() + '"' });
|
|
2478
|
+
}
|
|
2479
|
+
if (inserts.length === 0) return { text: doc, assigned: 0 };
|
|
2480
|
+
let out = doc;
|
|
2481
|
+
for (let i = inserts.length - 1; i >= 0; i--) {
|
|
2482
|
+
out = out.slice(0, inserts[i].pos) + inserts[i].text + out.slice(inserts[i].pos);
|
|
2483
|
+
}
|
|
2484
|
+
return { text: out, assigned: inserts.length };
|
|
2485
|
+
}
|
|
2486
|
+
window.injectMissingBlockIds = injectMissingBlockIds; // expose for tests
|
|
2487
|
+
|
|
2488
|
+
// ─── Commit (rwa-edit/1) ────────────────────────────────────────────
|
|
2489
|
+
// Single IDB transaction across rwa_doc, rwa_undo, rwa_hist. Reads happen
|
|
2490
|
+
// before the transaction (modifyMutex prevents concurrent writes). The
|
|
2491
|
+
// transaction itself only writes — three put()s, all atomic.
|
|
2492
|
+
async function commitDoc(currentDoc, newDoc, histRecord) {
|
|
2493
|
+
// Web-citizen: backfill data-rwa-id on any anchorable block the agent
|
|
2494
|
+
// produced without one. Pure-attribute injection outside frozen zones; the
|
|
2495
|
+
// shape/balance checks the caller already ran remain valid (no tags added
|
|
2496
|
+
// or removed). Keeps URL-fragment stability invariant: every anchorable
|
|
2497
|
+
// block in the stored doc has a stable name (bootstrap 0.9). Returned so
|
|
2498
|
+
// callers can render the persisted form instead of their pre-inject text.
|
|
2499
|
+
//
|
|
2500
|
+
// Gated on whether the container has already been "id-blessed" (has any
|
|
2501
|
+
// data-rwa-id attribute in its current text). The bootstrap IIFE blesses
|
|
2502
|
+
// every freshly opened container; commits then maintain the invariant.
|
|
2503
|
+
// Test fixtures that install an id-less doc via raw IDB.put stay id-less
|
|
2504
|
+
// so byte-fidelity assertions remain valid.
|
|
2505
|
+
const wasIdBlessed = /\sdata-rwa-id\s*=/.test(currentDoc);
|
|
2506
|
+
const idRes = wasIdBlessed ? injectMissingBlockIds(newDoc) : { text: newDoc, assigned: 0 };
|
|
2507
|
+
const persistDoc = idRes.assigned > 0 ? idRes.text : newDoc;
|
|
2508
|
+
const db = await openDB();
|
|
2509
|
+
const undoArr = (await idbGet(RWA.UNDO)) || [];
|
|
2510
|
+
undoArr.push(currentDoc);
|
|
2511
|
+
while (undoArr.length > RWA.UNDO_CAP) undoArr.shift();
|
|
2512
|
+
const histArr = (await idbGet(RWA.HIST)) || [];
|
|
2513
|
+
histArr.unshift(histRecord);
|
|
2514
|
+
const histTrimmed = histArr.slice(0, RWA.HIST_CAP);
|
|
2515
|
+
await new Promise((resolve, reject) => {
|
|
2516
|
+
const tx = db.transaction([RWA.DOC, RWA.UNDO, RWA.HIST], 'readwrite');
|
|
2517
|
+
tx.oncomplete = () => resolve();
|
|
2518
|
+
tx.onerror = () => reject(tx.error || new Error('transaction error'));
|
|
2519
|
+
tx.onabort = () => reject(tx.error || new Error('transaction aborted'));
|
|
2520
|
+
tx.objectStore(RWA.DOC).put(persistDoc, RWA.KEY);
|
|
2521
|
+
tx.objectStore(RWA.UNDO).put(undoArr, RWA.KEY);
|
|
2522
|
+
tx.objectStore(RWA.HIST).put(histTrimmed, RWA.KEY);
|
|
2523
|
+
});
|
|
2524
|
+
return persistDoc;
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
// ─── apply_edits / replace_document ─────────────────────────────────
|
|
2528
|
+
// UTF-16 well-formedness — JS strings can hold lone surrogates which become
|
|
2529
|
+
// U+FFFD on UTF-8 encoding (file write, fetch body, etc.) and silently
|
|
2530
|
+
// corrupt downstream byte-equality checks. Spec §10: malformed_envelope.
|
|
2531
|
+
// String.prototype.isWellFormed is ES2024 (Node 22+, Chromium 124+); guard
|
|
2532
|
+
// for older runtimes by treating absence as "no check available."
|
|
2533
|
+
const isWellFormed = (s) => typeof s !== 'string' || typeof s.isWellFormed !== 'function' || s.isWellFormed();
|
|
2534
|
+
|
|
2535
|
+
async function applyEdits(envelope, currentDocRaw, lensMeta = null) {
|
|
2536
|
+
if (envelope?.version !== 'rwa-edit/1') throw new RwaEditError('version_unsupported');
|
|
2537
|
+
if (!Array.isArray(envelope.edits) || envelope.edits.length === 0) throw new RwaEditError('malformed_envelope');
|
|
2538
|
+
for (let i = 0; i < envelope.edits.length; i++) {
|
|
2539
|
+
const e = envelope.edits[i] || {};
|
|
2540
|
+
if (!isWellFormed(e.find) || !isWellFormed(e.replace))
|
|
2541
|
+
throw new RwaEditError('malformed_envelope', i, { reason: 'lone_surrogate' });
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
const currentDoc = canonLF(currentDocRaw);
|
|
2545
|
+
const originalFrozen = extractFrozenZones(currentDoc);
|
|
2546
|
+
const originalParsed = parseHtmlFragment(currentDoc);
|
|
2547
|
+
const originalShape = computeShape(originalParsed.doc);
|
|
2548
|
+
const originalAttrs = dataRwaFrozenSnapshot(originalParsed.doc);
|
|
2549
|
+
|
|
2550
|
+
let work = currentDoc;
|
|
2551
|
+
for (let i = 0; i < envelope.edits.length; i++) {
|
|
2552
|
+
const edit = envelope.edits[i] || {};
|
|
2553
|
+
if (!edit.find) throw new RwaEditError('empty_find', i);
|
|
2554
|
+
const replaceRaw = edit.replace || '';
|
|
2555
|
+
if (containsReservedMarker(edit.find) || containsReservedMarker(replaceRaw))
|
|
2556
|
+
throw new RwaEditError('frozen_zone_violation', i);
|
|
2557
|
+
if (replaceRaw.length > RWA_EDIT.MAX_REPLACE)
|
|
2558
|
+
throw new RwaEditError('replace_too_large', i, { length: replaceRaw.length, cap: RWA_EDIT.MAX_REPLACE });
|
|
2559
|
+
const find = canonLF(edit.find);
|
|
2560
|
+
const replace = canonLF(replaceRaw);
|
|
2561
|
+
const occ = countOccurrences(work, find);
|
|
2562
|
+
if (occ === 0) throw new RwaEditError('find_not_found', i);
|
|
2563
|
+
if (occ > 1) throw new RwaEditError('find_not_unique', i, { count: occ, hints: nearbySnippets(work, find) });
|
|
2564
|
+
const idx = work.indexOf(find);
|
|
2565
|
+
// rwa-lens/1: class-declared lock check (spec §7). Reject any find range
|
|
2566
|
+
// that overlaps a .rwa-locked source range. Adjacent insertions (find ends
|
|
2567
|
+
// exactly where a lock begins, or starts exactly where one ends) are OK.
|
|
2568
|
+
// Recomputed per iteration because `work` mutates after each splice.
|
|
2569
|
+
const editStart = idx;
|
|
2570
|
+
const editEnd = idx + find.length;
|
|
2571
|
+
const locks = lockedRangesIn(work);
|
|
2572
|
+
for (const [ls, le] of locks) {
|
|
2573
|
+
// Overlap: NOT (editEnd <= ls || editStart >= le).
|
|
2574
|
+
if (editEnd > ls && editStart < le) {
|
|
2575
|
+
throw new RwaEditError('class_lock_violation', i, { lockRange: [ls, le], editRange: [editStart, editEnd] });
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
// Slice-based splice — String.prototype.replace honors $&, $$, $`, $' patterns
|
|
2579
|
+
// even when the search arg is a literal string, mangling replacements like
|
|
2580
|
+
// "$$amount" → "$amount". Splicing preserves the model's bytes verbatim.
|
|
2581
|
+
work = work.slice(0, idx) + replace + work.slice(idx + find.length);
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
const newFrozen = extractFrozenZones(work);
|
|
2585
|
+
if (!frozenZonesIntact(originalFrozen, newFrozen))
|
|
2586
|
+
throw new RwaEditError('frozen_zone_corrupted');
|
|
2587
|
+
|
|
2588
|
+
const parseResult = parseHtmlFragment(work);
|
|
2589
|
+
if (!parseResult.ok) throw new RwaEditError('parse_error_post_apply', null, { message: parseResult.error });
|
|
2590
|
+
|
|
2591
|
+
const newShape = computeShape(parseResult.doc);
|
|
2592
|
+
if (!shapesEqual(originalShape, newShape))
|
|
2593
|
+
throw new RwaEditError('structural_shape_changed', null, { shape_before: originalShape, shape_after: newShape });
|
|
2594
|
+
|
|
2595
|
+
const imbalance = tagBalance(work);
|
|
2596
|
+
if (imbalance && !tagBalance(currentDoc))
|
|
2597
|
+
throw new RwaEditError('structural_shape_changed', null, { tag_imbalance: imbalance });
|
|
2598
|
+
|
|
2599
|
+
if (!snapshotsEqual(originalAttrs, dataRwaFrozenSnapshot(parseResult.doc)))
|
|
2600
|
+
throw new RwaEditError('frozen_zone_corrupted');
|
|
2601
|
+
|
|
2602
|
+
const reservedId = findReservedIdViolation(parseResult.doc);
|
|
2603
|
+
if (reservedId) throw new RwaEditError('reserved_id_used', null, { id: reservedId });
|
|
2604
|
+
|
|
2605
|
+
if (work.length > RWA_EDIT.MAX_DOC) throw new RwaEditError('target_size_exceeded');
|
|
2606
|
+
|
|
2607
|
+
const histRecord = { ts: Date.now(), kind: 'edit_batch', envelope };
|
|
2608
|
+
if (lensMeta) {
|
|
2609
|
+
histRecord.surface = lensMeta.surface;
|
|
2610
|
+
histRecord.instruction = lensMeta.instruction;
|
|
2611
|
+
histRecord.scope = lensMeta.scope;
|
|
2612
|
+
}
|
|
2613
|
+
return await commitDoc(currentDoc, work, histRecord);
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
async function replaceDocument(envelope, currentDocRaw, lensMeta = null) {
|
|
2617
|
+
if (envelope?.version !== 'rwa-edit/1') throw new RwaEditError('version_unsupported');
|
|
2618
|
+
if (typeof envelope.doc !== 'string' || typeof envelope.reason !== 'string' || !envelope.reason)
|
|
2619
|
+
throw new RwaEditError('malformed_envelope');
|
|
2620
|
+
if (!isWellFormed(envelope.doc) || !isWellFormed(envelope.reason))
|
|
2621
|
+
throw new RwaEditError('malformed_envelope', null, { reason: 'lone_surrogate' });
|
|
2622
|
+
|
|
2623
|
+
const currentDoc = canonLF(currentDocRaw);
|
|
2624
|
+
const newDoc = canonLF(envelope.doc);
|
|
2625
|
+
|
|
2626
|
+
// rwa-lens/1: class-declared lock coverage check (spec §7).
|
|
2627
|
+
// A bare .rwa-locked block in the current doc cannot survive a wholesale
|
|
2628
|
+
// rewrite — the wrapper can be reshaped, attribute-mutated, or just
|
|
2629
|
+
// dropped. Locks are only safe under replace_document if their source
|
|
2630
|
+
// range is entirely contained within a marker-form frozen zone (markers
|
|
2631
|
+
// wrap or equal the lock — NOT the inverse). Markers nested inside a
|
|
2632
|
+
// .rwa-locked wrapper preserve only their inner content, so the wrapper
|
|
2633
|
+
// itself remains mutable; that pattern fails coverage too.
|
|
2634
|
+
const lockRanges = lockedRangesIn(currentDoc);
|
|
2635
|
+
const markerRanges = markerZoneRangesIn(currentDoc);
|
|
2636
|
+
for (const [ls, le] of lockRanges) {
|
|
2637
|
+
const covered = markerRanges.some(([ms, me]) => ms <= ls && le <= me);
|
|
2638
|
+
if (!covered) {
|
|
2639
|
+
throw new RwaEditError('class_lock_uncovered', null, { lockRange: [ls, le] });
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
const originalFrozen = extractFrozenZones(currentDoc);
|
|
2644
|
+
const originalAttrs = dataRwaFrozenSnapshot(parseHtmlFragment(currentDoc).doc);
|
|
2645
|
+
|
|
2646
|
+
const parseResult = parseHtmlFragment(newDoc);
|
|
2647
|
+
if (!parseResult.ok) throw new RwaEditError('parse_error_post_apply', null, { message: parseResult.error });
|
|
2648
|
+
|
|
2649
|
+
if (!frozenZonesIntact(originalFrozen, extractFrozenZones(newDoc)))
|
|
2650
|
+
throw new RwaEditError('frozen_zone_corrupted');
|
|
2651
|
+
|
|
2652
|
+
if (!snapshotsEqual(originalAttrs, dataRwaFrozenSnapshot(parseResult.doc)))
|
|
2653
|
+
throw new RwaEditError('frozen_zone_corrupted');
|
|
2654
|
+
|
|
2655
|
+
const reservedId = findReservedIdViolation(parseResult.doc);
|
|
2656
|
+
if (reservedId) throw new RwaEditError('reserved_id_used', null, { id: reservedId });
|
|
2657
|
+
|
|
2658
|
+
if (newDoc.length > RWA_EDIT.MAX_DOC) throw new RwaEditError('target_size_exceeded');
|
|
2659
|
+
|
|
2660
|
+
const histRecord = { ts: Date.now(), kind: 'replace_document', reason: envelope.reason };
|
|
2661
|
+
if (lensMeta) {
|
|
2662
|
+
histRecord.surface = lensMeta.surface;
|
|
2663
|
+
histRecord.instruction = lensMeta.instruction;
|
|
2664
|
+
histRecord.scope = lensMeta.scope;
|
|
2665
|
+
}
|
|
2666
|
+
return await commitDoc(currentDoc, newDoc, histRecord);
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
// ─── apply_dsl_plan compiler (rwa-edit-dsl/1) ──────────────────────
|
|
2670
|
+
// Sugar over apply_edits: a small fixed vocabulary of structural transforms
|
|
2671
|
+
// that compile down to apply_edits / replace_document envelopes. The DSL
|
|
2672
|
+
// parser is the trust boundary; compiled output flows through applyEdits /
|
|
2673
|
+
// replaceDocument above so all rwa-edit/1 invariants (frozen zones, shape,
|
|
2674
|
+
// reserved markers) still hold. Spec: rwa-edit-dsl-spec.md.
|
|
2675
|
+
//
|
|
2676
|
+
// Empirically motivated: gemini-3.1-pro-preview meanT jumped from 0.88
|
|
2677
|
+
// (apply_edits direct) to 1.44 (apply_dsl_plan + compile) across 89
|
|
2678
|
+
// fidelity scenarios — biggest gains on paste (0.22→2.00), mixed
|
|
2679
|
+
// (0.00→0.89), and content (0.76→1.33).
|
|
2680
|
+
const DSL_VERSION = 'rwa-edit-dsl/1';
|
|
2681
|
+
|
|
2682
|
+
function compileDslPlan(plan, doc) {
|
|
2683
|
+
if (!plan || typeof plan !== 'object')
|
|
2684
|
+
throw new RwaEditError('malformed_envelope', null, { reason: 'plan must be an object' });
|
|
2685
|
+
if (plan.version !== DSL_VERSION)
|
|
2686
|
+
throw new RwaEditError('version_unsupported');
|
|
2687
|
+
if (!Array.isArray(plan.ops) || plan.ops.length === 0)
|
|
2688
|
+
throw new RwaEditError('malformed_envelope', null, { reason: 'plan.ops must be a non-empty array' });
|
|
2689
|
+
|
|
2690
|
+
// replace_document op is a sole-op escape — must be the only op in the plan.
|
|
2691
|
+
if (plan.ops.some(op => op?.op === 'replace_document')) {
|
|
2692
|
+
if (plan.ops.length !== 1)
|
|
2693
|
+
throw new RwaEditError('malformed_envelope', null, { reason: 'replace_document must be the sole op' });
|
|
2694
|
+
const op = plan.ops[0];
|
|
2695
|
+
if (typeof op.doc !== 'string' || typeof op.reason !== 'string')
|
|
2696
|
+
throw new RwaEditError('malformed_envelope', 0, { reason: 'replace_document requires doc and reason' });
|
|
2697
|
+
return { tool: 'replace_document', envelope: { version: 'rwa-edit/1', doc: op.doc, reason: op.reason } };
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
// Apply each op against an evolving in-memory shadow so subsequent ops'
|
|
2701
|
+
// anchors resolve against the post-prior-op state, mirroring the runtime's
|
|
2702
|
+
// sequential apply_edits semantics.
|
|
2703
|
+
let shadow = canonLF(doc);
|
|
2704
|
+
const edits = [];
|
|
2705
|
+
for (let i = 0; i < plan.ops.length; i++) {
|
|
2706
|
+
const op = plan.ops[i];
|
|
2707
|
+
if (!op || typeof op !== 'object' || typeof op.op !== 'string')
|
|
2708
|
+
throw new RwaEditError('malformed_envelope', i, { reason: 'op must have a string `op` field' });
|
|
2709
|
+
let opEdits;
|
|
2710
|
+
switch (op.op) {
|
|
2711
|
+
case 'replace': opEdits = compileDslReplace(op, shadow, i); break;
|
|
2712
|
+
case 'insert': opEdits = compileDslInsert(op, shadow, i); break;
|
|
2713
|
+
case 'delete': opEdits = compileDslDelete(op, shadow, i); break;
|
|
2714
|
+
case 'set_attr': opEdits = compileDslSetAttr(op, shadow, i); break;
|
|
2715
|
+
default: throw new RwaEditError('malformed_envelope', i, { reason: 'unknown op: ' + op.op });
|
|
2716
|
+
}
|
|
2717
|
+
for (const e of opEdits) {
|
|
2718
|
+
const idx = shadow.indexOf(e.find);
|
|
2719
|
+
if (idx < 0)
|
|
2720
|
+
throw new RwaEditError('malformed_envelope', i, { reason: 'compiler shadow drift: emitted edit no longer matches' });
|
|
2721
|
+
if (shadow.indexOf(e.find, idx + 1) >= 0)
|
|
2722
|
+
throw new RwaEditError('malformed_envelope', i, { reason: 'compiler shadow drift: emitted edit ambiguous' });
|
|
2723
|
+
shadow = shadow.slice(0, idx) + e.replace + shadow.slice(idx + e.find.length);
|
|
2724
|
+
edits.push(e);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
return { tool: 'apply_edits', envelope: { version: 'rwa-edit/1', edits } };
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function dslAllOccurrences(haystack, needle) {
|
|
2731
|
+
const out = [];
|
|
2732
|
+
if (!needle.length) return out;
|
|
2733
|
+
let from = 0;
|
|
2734
|
+
while (true) {
|
|
2735
|
+
const idx = haystack.indexOf(needle, from);
|
|
2736
|
+
if (idx < 0) break;
|
|
2737
|
+
out.push(idx);
|
|
2738
|
+
from = idx + 1;
|
|
2739
|
+
}
|
|
2740
|
+
return out;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
function compileDslReplace(op, doc, opIndex) {
|
|
2744
|
+
if (typeof op.find !== 'string' || typeof op.replace !== 'string')
|
|
2745
|
+
throw new RwaEditError('malformed_envelope', opIndex, { reason: 'replace requires find and replace strings' });
|
|
2746
|
+
let windowStart = 0, windowEnd = doc.length;
|
|
2747
|
+
if (typeof op.region === 'string') {
|
|
2748
|
+
const occs = dslAllOccurrences(doc, op.region);
|
|
2749
|
+
if (!occs.length) throw new RwaEditError('malformed_envelope', opIndex, { reason: 'region not found' });
|
|
2750
|
+
if (occs.length > 1) throw new RwaEditError('malformed_envelope', opIndex, { reason: 'region not unique' });
|
|
2751
|
+
windowStart = occs[0]; windowEnd = occs[0] + op.region.length;
|
|
2752
|
+
}
|
|
2753
|
+
const window = doc.slice(windowStart, windowEnd);
|
|
2754
|
+
const localOccs = dslAllOccurrences(window, op.find);
|
|
2755
|
+
if (!localOccs.length) throw new RwaEditError('malformed_envelope', opIndex, { reason: 'find has zero matches in window' });
|
|
2756
|
+
if (!op.all && localOccs.length > 1)
|
|
2757
|
+
throw new RwaEditError('malformed_envelope', opIndex, { reason: 'find non-unique in window with all=false' });
|
|
2758
|
+
|
|
2759
|
+
if (!op.all) {
|
|
2760
|
+
const globalOccs = dslAllOccurrences(doc, op.find);
|
|
2761
|
+
if (globalOccs.length === 1) return [{ find: op.find, replace: op.replace }];
|
|
2762
|
+
return [contextualizeDslEdit(doc, windowStart + localOccs[0], op.find, op.replace, opIndex)];
|
|
2763
|
+
}
|
|
2764
|
+
return localOccs.map(localStart => contextualizeDslEdit(doc, windowStart + localStart, op.find, op.replace, opIndex));
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
function contextualizeDslEdit(doc, absStart, find, replace, opIndex) {
|
|
2768
|
+
const findEnd = absStart + find.length;
|
|
2769
|
+
let pre = 0, post = 0;
|
|
2770
|
+
const MAX = 200;
|
|
2771
|
+
while (true) {
|
|
2772
|
+
const ctxFind = doc.slice(absStart - pre, findEnd + post);
|
|
2773
|
+
if (dslAllOccurrences(doc, ctxFind).length === 1) {
|
|
2774
|
+
const ctxReplace = doc.slice(absStart - pre, absStart) + replace + doc.slice(findEnd, findEnd + post);
|
|
2775
|
+
return { find: ctxFind, replace: ctxReplace };
|
|
2776
|
+
}
|
|
2777
|
+
if (pre >= MAX && post >= MAX)
|
|
2778
|
+
throw new RwaEditError('malformed_envelope', opIndex, { reason: 'unable to disambiguate within ' + MAX + ' chars' });
|
|
2779
|
+
if (post <= pre && findEnd + post < doc.length) post++;
|
|
2780
|
+
else if (absStart - pre > 0) pre++;
|
|
2781
|
+
else post++;
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
function compileDslInsert(op, doc, opIndex) {
|
|
2786
|
+
if (typeof op.content !== 'string')
|
|
2787
|
+
throw new RwaEditError('malformed_envelope', opIndex, { reason: 'insert requires content string' });
|
|
2788
|
+
const positionalCount = (typeof op.after === 'string' ? 1 : 0) + (typeof op.before === 'string' ? 1 : 0);
|
|
2789
|
+
if (positionalCount !== 1)
|
|
2790
|
+
throw new RwaEditError('malformed_envelope', opIndex, { reason: 'insert requires exactly one of after or before' });
|
|
2791
|
+
const anchor = typeof op.after === 'string' ? op.after : op.before;
|
|
2792
|
+
const occs = dslAllOccurrences(doc, anchor);
|
|
2793
|
+
if (!occs.length) throw new RwaEditError('malformed_envelope', opIndex, { reason: 'insert anchor not found' });
|
|
2794
|
+
if (occs.length > 1) throw new RwaEditError('malformed_envelope', opIndex, { reason: 'insert anchor not unique' });
|
|
2795
|
+
if (typeof op.after === 'string') return [{ find: anchor, replace: anchor + op.content }];
|
|
2796
|
+
return [{ find: anchor, replace: op.content + anchor }];
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
function compileDslDelete(op, doc, opIndex) {
|
|
2800
|
+
if (typeof op.target !== 'string')
|
|
2801
|
+
throw new RwaEditError('malformed_envelope', opIndex, { reason: 'delete requires target string' });
|
|
2802
|
+
const occs = dslAllOccurrences(doc, op.target);
|
|
2803
|
+
if (!occs.length) throw new RwaEditError('malformed_envelope', opIndex, { reason: 'delete target not found' });
|
|
2804
|
+
if (occs.length > 1) throw new RwaEditError('malformed_envelope', opIndex, { reason: 'delete target not unique' });
|
|
2805
|
+
return [{ find: op.target, replace: '' }];
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
function compileDslSetAttr(op, doc, opIndex) {
|
|
2809
|
+
if (typeof op.anchor !== 'string' || typeof op.attr !== 'string' || typeof op.value !== 'string')
|
|
2810
|
+
throw new RwaEditError('malformed_envelope', opIndex, { reason: 'set_attr requires anchor, attr, value strings' });
|
|
2811
|
+
if (!op.anchor.startsWith('<') || op.anchor.endsWith('>'))
|
|
2812
|
+
throw new RwaEditError('malformed_envelope', opIndex, { reason: 'set_attr.anchor must start with < and end before >' });
|
|
2813
|
+
const occs = dslAllOccurrences(doc, op.anchor);
|
|
2814
|
+
if (!occs.length) throw new RwaEditError('malformed_envelope', opIndex, { reason: 'set_attr.anchor not found' });
|
|
2815
|
+
if (occs.length > 1) throw new RwaEditError('malformed_envelope', opIndex, { reason: 'set_attr.anchor not unique' });
|
|
2816
|
+
const closeIdx = doc.indexOf('>', occs[0] + op.anchor.length);
|
|
2817
|
+
if (closeIdx < 0) throw new RwaEditError('malformed_envelope', opIndex, { reason: 'no `>` after set_attr.anchor' });
|
|
2818
|
+
const fullTag = doc.slice(occs[0], closeIdx + 1);
|
|
2819
|
+
const escapedValue = op.value.replace(/&/g, '&').replace(/"/g, '"');
|
|
2820
|
+
const existing = findAttrInTag(fullTag, op.attr);
|
|
2821
|
+
const newTag = existing
|
|
2822
|
+
? fullTag.slice(0, existing[0]) + op.attr + '="' + escapedValue + '"' + fullTag.slice(existing[1])
|
|
2823
|
+
: fullTag.slice(0, -1) + ' ' + op.attr + '="' + escapedValue + '">';
|
|
2824
|
+
return [{ find: fullTag, replace: newTag }];
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
// Locate `attrName` inside a parsed opening tag, returning [start, end) byte
|
|
2828
|
+
// offsets within the tag of the full `name="value"` (or `name='...'`,
|
|
2829
|
+
// `name=value`, or boolean `name`) substring. Respects quote state to avoid
|
|
2830
|
+
// matching attribute substrings inside another attribute's value.
|
|
2831
|
+
function findAttrInTag(tag, attrName) {
|
|
2832
|
+
const nameMatch = tag.match(/^<\/?([a-zA-Z][a-zA-Z0-9_-]*)/);
|
|
2833
|
+
if (!nameMatch) return null;
|
|
2834
|
+
let i = nameMatch[0].length;
|
|
2835
|
+
while (i < tag.length - 1) {
|
|
2836
|
+
while (i < tag.length && /\s/.test(tag[i])) i++;
|
|
2837
|
+
if (i >= tag.length || tag[i] === '>' || tag[i] === '/') break;
|
|
2838
|
+
const attrStart = i;
|
|
2839
|
+
let nameEnd = i;
|
|
2840
|
+
while (nameEnd < tag.length && !/[\s=>/]/.test(tag[nameEnd])) nameEnd++;
|
|
2841
|
+
const name = tag.slice(attrStart, nameEnd);
|
|
2842
|
+
i = nameEnd;
|
|
2843
|
+
let attrEnd = nameEnd;
|
|
2844
|
+
if (tag[i] === '=') {
|
|
2845
|
+
i++;
|
|
2846
|
+
if (tag[i] === '"') { const c = tag.indexOf('"', i + 1); if (c < 0) return null; attrEnd = c + 1; i = attrEnd; }
|
|
2847
|
+
else if (tag[i] === "'") { const c = tag.indexOf("'", i + 1); if (c < 0) return null; attrEnd = c + 1; i = attrEnd; }
|
|
2848
|
+
else { while (i < tag.length && !/[\s>]/.test(tag[i])) i++; attrEnd = i; }
|
|
2849
|
+
}
|
|
2850
|
+
if (name === attrName) return [attrStart, attrEnd];
|
|
2851
|
+
}
|
|
2852
|
+
return null;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
// ─── Modify lifecycle (rwa-edit/1) ──────────────────────────────────
|
|
2856
|
+
let modifyMutex = false;
|
|
2857
|
+
|
|
2858
|
+
function buildUserPrompt(instr, doc, frozenZones) {
|
|
2859
|
+
const fzText = frozenZones.length === 0
|
|
2860
|
+
? '(none)'
|
|
2861
|
+
: frozenZones.map(z => '- ' + z.name + (z.error ? ' [' + z.error + ']' : '')).join('\n');
|
|
2862
|
+
// rwa-lens/1 §7: enumerate class-declared locks alongside marker frozen zones.
|
|
2863
|
+
// Each item shows the wrapper tag plus a compact preview so the agent can
|
|
2864
|
+
// identify the region without scanning the entire doc.
|
|
2865
|
+
const lockRanges = lockedRangesIn(doc);
|
|
2866
|
+
let lockSection = '';
|
|
2867
|
+
if (lockRanges.length > 0) {
|
|
2868
|
+
const items = lockRanges.map(([s, e]) => {
|
|
2869
|
+
const slice = doc.slice(s, e);
|
|
2870
|
+
const tagMatch = slice.match(/^<([a-zA-Z][a-zA-Z0-9]*)/);
|
|
2871
|
+
const tag = tagMatch ? tagMatch[1] : '?';
|
|
2872
|
+
const preview = slice.replace(/\s+/g, ' ').slice(0, 80);
|
|
2873
|
+
return '- <' + tag + ' class="rwa-locked">: ' + preview + (slice.length > 80 ? '...' : '');
|
|
2874
|
+
}).join('\n');
|
|
2875
|
+
lockSection = '\n\nClass-declared locked regions (rwa-lens/1 §7) — also off-limits:\n' + items +
|
|
2876
|
+
'\n\nNote: replace_document is unavailable on documents containing class-declared locks not entirely covered by marker-form frozen zones. Prefer apply_edits or apply_dsl_plan.';
|
|
2877
|
+
}
|
|
2878
|
+
return 'User request:\n' + instr +
|
|
2879
|
+
'\n\nFrozen zones in the current doc (do not produce marker text in find/replace; do not modify their inner content):\n' + fzText +
|
|
2880
|
+
lockSection +
|
|
2881
|
+
'\n\nThe current document follows. Make your edit by calling apply_edits or replace_document.\n\n<DOC>\n' + doc + '\n</DOC>';
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
function failureToToolResult(err) {
|
|
2885
|
+
const payload = { ok: false, code: err.code };
|
|
2886
|
+
if (err.editIndex != null) payload.edit_index = err.editIndex;
|
|
2887
|
+
if (err.context) Object.assign(payload, err.context);
|
|
2888
|
+
return JSON.stringify(payload);
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
async function modify(instr, lensMeta = null) {
|
|
2892
|
+
const cfg = resolveBackendConfig();
|
|
2893
|
+
if (cfg.kind === 'bridge') return modifyViaBridge(instr, lensMeta);
|
|
2894
|
+
|
|
2895
|
+
const model = sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
|
|
2896
|
+
if (cfg.requiresKey && !cfg.apiKey) {
|
|
2897
|
+
setPalSt('err', 'no API key — open ⚙ settings');
|
|
2898
|
+
document.getElementById('rwa-set-panel').classList.add('open');
|
|
2899
|
+
document.getElementById('rwa-key').focus();
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
if (modifyMutex) {
|
|
2904
|
+
setPalSt('err', '✗ another modify in progress');
|
|
2905
|
+
throw new RwaEditError('concurrent_modify');
|
|
2906
|
+
}
|
|
2907
|
+
modifyMutex = true;
|
|
2908
|
+
|
|
2909
|
+
closePal();
|
|
2910
|
+
setStatus('run', '⌘K running');
|
|
2911
|
+
|
|
2912
|
+
try {
|
|
2913
|
+
const cur = canonLF(await getDoc());
|
|
2914
|
+
const frozenZones = extractFrozenZones(cur);
|
|
2915
|
+
|
|
2916
|
+
const messages = [
|
|
2917
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
2918
|
+
{ role: 'user', content: buildUserPrompt(instr, cur, frozenZones) }
|
|
2919
|
+
];
|
|
2920
|
+
|
|
2921
|
+
let attemptsLeft = RWA_EDIT.RETRIES;
|
|
2922
|
+
let lastFailure = null;
|
|
2923
|
+
let newDoc = null;
|
|
2924
|
+
|
|
2925
|
+
while (attemptsLeft > 0 && newDoc === null) {
|
|
2926
|
+
attemptsLeft--;
|
|
2927
|
+
|
|
2928
|
+
const data = await openAiCompatChat(cfg, {
|
|
2929
|
+
model,
|
|
2930
|
+
max_tokens: 32000,
|
|
2931
|
+
messages,
|
|
2932
|
+
tools: TOOL_SCHEMAS,
|
|
2933
|
+
tool_choice: 'auto',
|
|
2934
|
+
});
|
|
2935
|
+
const msg = data.choices?.[0]?.message;
|
|
2936
|
+
if (!msg) throw new Error('empty response');
|
|
2937
|
+
|
|
2938
|
+
const toolCalls = msg.tool_calls || [];
|
|
2939
|
+
if (toolCalls.length === 0) {
|
|
2940
|
+
const text = (msg.content || '(no message)').trim();
|
|
2941
|
+
setStatus('err', '✗ model declined');
|
|
2942
|
+
setPalSt('err', text.slice(0, 200));
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
const tc = toolCalls[0];
|
|
2947
|
+
const fnName = tc.function?.name;
|
|
2948
|
+
let envelope;
|
|
2949
|
+
try {
|
|
2950
|
+
envelope = JSON.parse(tc.function?.arguments ?? '{}');
|
|
2951
|
+
} catch {
|
|
2952
|
+
lastFailure = new RwaEditError('malformed_envelope', null, { message: 'invalid JSON arguments' });
|
|
2953
|
+
if (attemptsLeft > 0) {
|
|
2954
|
+
// Echo only the consumed tool_call. If the model emits parallel calls
|
|
2955
|
+
// we ignore the rest, and providers reject assistant messages whose
|
|
2956
|
+
// tool_calls don't all have a paired tool_result on the next turn.
|
|
2957
|
+
messages.push({ role: 'assistant', content: msg.content || '', tool_calls: [tc] });
|
|
2958
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: failureToToolResult(lastFailure) });
|
|
2959
|
+
}
|
|
2960
|
+
continue;
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
try {
|
|
2964
|
+
if (fnName === 'apply_dsl_plan') {
|
|
2965
|
+
const compiled = compileDslPlan(envelope, cur);
|
|
2966
|
+
if (compiled.tool === 'apply_edits') newDoc = await applyEdits(compiled.envelope, cur, lensMeta);
|
|
2967
|
+
else if (compiled.tool === 'replace_document') newDoc = await replaceDocument(compiled.envelope, cur, lensMeta);
|
|
2968
|
+
else throw new RwaEditError('unknown_tool', null, { name: compiled.tool });
|
|
2969
|
+
}
|
|
2970
|
+
else if (fnName === 'apply_edits') newDoc = await applyEdits(envelope, cur, lensMeta);
|
|
2971
|
+
else if (fnName === 'replace_document') newDoc = await replaceDocument(envelope, cur, lensMeta);
|
|
2972
|
+
else throw new RwaEditError('unknown_tool', null, { name: fnName });
|
|
2973
|
+
} catch (err) {
|
|
2974
|
+
if (!(err instanceof RwaEditError)) throw err;
|
|
2975
|
+
lastFailure = err;
|
|
2976
|
+
if (attemptsLeft > 0) {
|
|
2977
|
+
messages.push({ role: 'assistant', content: msg.content || '', tool_calls: [tc] });
|
|
2978
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: failureToToolResult(err) });
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
if (newDoc !== null) {
|
|
2984
|
+
renderDoc(newDoc);
|
|
2985
|
+
setDirty(true);
|
|
2986
|
+
await rwaBumpDirtyCount().catch(() => {});
|
|
2987
|
+
rwaCheckQuota();
|
|
2988
|
+
// Defer the emit so the `finally` below releases `modifyMutex` before
|
|
2989
|
+
// any listener fires; otherwise a listener calling `runtime.modify()`
|
|
2990
|
+
// would hit `concurrent_modify` and that error would be swallowed by
|
|
2991
|
+
// `emitRuntimeEvent`'s try/catch (Fix I2).
|
|
2992
|
+
queueMicrotask(() => {
|
|
2993
|
+
emitRuntimeEvent('modify', { instruction: instr, lensMeta });
|
|
2994
|
+
emitRuntimeEvent('status', getStatusSnapshot());
|
|
2995
|
+
});
|
|
2996
|
+
setStatus('ok', '✓ done');
|
|
2997
|
+
} else {
|
|
2998
|
+
const summary = lastFailure
|
|
2999
|
+
? '✗ ' + lastFailure.code + (lastFailure.editIndex != null ? ' [edit ' + lastFailure.editIndex + ']' : '')
|
|
3000
|
+
: '✗ retry budget exhausted';
|
|
3001
|
+
setStatus('err', summary);
|
|
3002
|
+
console.error('rwa-edit retry exhausted', lastFailure);
|
|
3003
|
+
}
|
|
3004
|
+
} catch (e) {
|
|
3005
|
+
setStatus('err', '✗ ' + e.message);
|
|
3006
|
+
console.error(e);
|
|
3007
|
+
} finally {
|
|
3008
|
+
modifyMutex = false;
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
// ─── Bridge backend (claude -p via localhost shell) ────────────────
|
|
3013
|
+
//
|
|
3014
|
+
// Single-shot agent loop. claude -p has no mid-stream tool_calls (it's a
|
|
3015
|
+
// completion-style endpoint), so we ask it to emit a JSON envelope matching
|
|
3016
|
+
// one of the rwa-edit/1 tool schemas and dispatch it through the same
|
|
3017
|
+
// applyEdits/compileDslPlan/replaceDocument machinery the OpenRouter path
|
|
3018
|
+
// uses. No multi-turn retries — claude -p starts a fresh session each call.
|
|
3019
|
+
async function modifyViaBridge(instr, lensMeta = null) {
|
|
3020
|
+
if (modifyMutex) {
|
|
3021
|
+
setPalSt('err', '✗ another modify in progress');
|
|
3022
|
+
throw new RwaEditError('concurrent_modify');
|
|
3023
|
+
}
|
|
3024
|
+
modifyMutex = true;
|
|
3025
|
+
closePal();
|
|
3026
|
+
setStatus('run', '⌘K running (bridge)');
|
|
3027
|
+
|
|
3028
|
+
try {
|
|
3029
|
+
const cur = canonLF(await getDoc());
|
|
3030
|
+
const frozenZones = extractFrozenZones(cur);
|
|
3031
|
+
|
|
3032
|
+
const prompt = SYSTEM_PROMPT + '\n\n' + buildUserPrompt(instr, cur, frozenZones) +
|
|
3033
|
+
'\n\nThis backend is single-shot (no tool-calling protocol). Output ONLY a single JSON envelope as your last response, no markdown fences, no commentary, no preamble. The envelope MUST be one of these three exact shapes:\n\n' +
|
|
3034
|
+
'{"tool":"apply_dsl_plan","envelope":{"version":"rwa-edit-dsl/1","ops":[/* per the rules above */]}}\n\n' +
|
|
3035
|
+
'{"tool":"apply_edits","envelope":{"version":"rwa-edit/1","edits":[{"find":"...","replace":"..."}]}}\n\n' +
|
|
3036
|
+
'{"tool":"replace_document","envelope":{"version":"rwa-edit/1","doc":"...","reason":"..."}}\n\n' +
|
|
3037
|
+
'Pick the tool per the same preference rules: structural → apply_dsl_plan, content → apply_edits, wholesale → replace_document.';
|
|
3038
|
+
|
|
3039
|
+
// Encode the prompt as base64 to sidestep shell quoting (the doc may
|
|
3040
|
+
// contain quotes, backticks, dollar signs, newlines — anything). The
|
|
3041
|
+
// shell command decodes and pipes to claude -p's stdin via the
|
|
3042
|
+
// text input mode.
|
|
3043
|
+
const promptB64 = btoa(unescape(encodeURIComponent(prompt)));
|
|
3044
|
+
const cmd = `echo '${promptB64}' | base64 -d | claude -p --output-format text --permission-mode bypassPermissions`;
|
|
3045
|
+
|
|
3046
|
+
let resp;
|
|
3047
|
+
try {
|
|
3048
|
+
resp = await fetch(RWA.BRIDGE_URL, {
|
|
3049
|
+
method: 'POST',
|
|
3050
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3051
|
+
body: JSON.stringify({ command: cmd }),
|
|
3052
|
+
});
|
|
3053
|
+
} catch (err) {
|
|
3054
|
+
throw new Error('bridge unreachable at ' + RWA.BRIDGE_URL + ' — is web_cli_bridge running?');
|
|
3055
|
+
}
|
|
3056
|
+
if (!resp.ok) throw new Error('bridge http ' + resp.status);
|
|
3057
|
+
const { stdout, stderr, exit_code } = await resp.json();
|
|
3058
|
+
if (exit_code !== 0) {
|
|
3059
|
+
const tail = (stderr || '').trim().split('\n').slice(-3).join('\n').slice(0, 300);
|
|
3060
|
+
throw new Error('claude -p exited ' + exit_code + (tail ? ': ' + tail : ''));
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
const parsed = parseBridgeEnvelope(stdout);
|
|
3064
|
+
if (!parsed) {
|
|
3065
|
+
const preview = (stdout || '').trim().slice(0, 300);
|
|
3066
|
+
throw new Error('bridge: model output did not contain a parseable JSON envelope. Preview: ' + preview);
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
let newDoc = null;
|
|
3070
|
+
if (parsed.tool === 'apply_dsl_plan') {
|
|
3071
|
+
const compiled = compileDslPlan(parsed.envelope, cur);
|
|
3072
|
+
if (compiled.tool === 'apply_edits') newDoc = await applyEdits(compiled.envelope, cur, lensMeta);
|
|
3073
|
+
else if (compiled.tool === 'replace_document') newDoc = await replaceDocument(compiled.envelope, cur, lensMeta);
|
|
3074
|
+
else throw new RwaEditError('unknown_tool', null, { name: compiled.tool });
|
|
3075
|
+
} else if (parsed.tool === 'apply_edits') {
|
|
3076
|
+
newDoc = await applyEdits(parsed.envelope, cur, lensMeta);
|
|
3077
|
+
} else if (parsed.tool === 'replace_document') {
|
|
3078
|
+
newDoc = await replaceDocument(parsed.envelope, cur, lensMeta);
|
|
3079
|
+
} else {
|
|
3080
|
+
throw new RwaEditError('unknown_tool', null, { name: parsed.tool });
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
renderDoc(newDoc);
|
|
3084
|
+
setDirty(true);
|
|
3085
|
+
await rwaBumpDirtyCount().catch(() => {});
|
|
3086
|
+
rwaCheckQuota();
|
|
3087
|
+
// Defer the emit so the `finally` below releases `modifyMutex` before
|
|
3088
|
+
// any listener fires; otherwise a listener calling `runtime.modify()`
|
|
3089
|
+
// would hit `concurrent_modify` and that error would be swallowed by
|
|
3090
|
+
// `emitRuntimeEvent`'s try/catch (Fix I2).
|
|
3091
|
+
queueMicrotask(() => {
|
|
3092
|
+
emitRuntimeEvent('modify', { instruction: instr, lensMeta });
|
|
3093
|
+
emitRuntimeEvent('status', getStatusSnapshot());
|
|
3094
|
+
});
|
|
3095
|
+
setStatus('ok', '✓ done');
|
|
3096
|
+
} catch (e) {
|
|
3097
|
+
if (e instanceof RwaEditError) {
|
|
3098
|
+
setStatus('err', '✗ ' + e.code + (e.editIndex != null ? ' [edit ' + e.editIndex + ']' : ''));
|
|
3099
|
+
} else {
|
|
3100
|
+
setStatus('err', '✗ ' + e.message);
|
|
3101
|
+
}
|
|
3102
|
+
console.error(e);
|
|
3103
|
+
} finally {
|
|
3104
|
+
modifyMutex = false;
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
// Pull the first balanced top-level JSON object out of stdout. claude may
|
|
3109
|
+
// wrap output in stray text or markdown fences despite the prompt; walk
|
|
3110
|
+
// braces honoring strings + escapes to find the envelope.
|
|
3111
|
+
function parseBridgeEnvelope(text) {
|
|
3112
|
+
if (typeof text !== 'string') return null;
|
|
3113
|
+
// First strip ```json or ``` fences if the model emitted them.
|
|
3114
|
+
const fenceStripped = text.replace(/```(?:json|html)?\s*/i, '').replace(/```\s*$/i, '');
|
|
3115
|
+
const start = fenceStripped.indexOf('{');
|
|
3116
|
+
if (start < 0) return null;
|
|
3117
|
+
let depth = 0, inStr = false, escape = false;
|
|
3118
|
+
for (let i = start; i < fenceStripped.length; i++) {
|
|
3119
|
+
const ch = fenceStripped[i];
|
|
3120
|
+
if (escape) { escape = false; continue; }
|
|
3121
|
+
if (inStr) {
|
|
3122
|
+
if (ch === '\\') escape = true;
|
|
3123
|
+
else if (ch === '"') inStr = false;
|
|
3124
|
+
continue;
|
|
3125
|
+
}
|
|
3126
|
+
if (ch === '"') { inStr = true; continue; }
|
|
3127
|
+
if (ch === '{') depth++;
|
|
3128
|
+
else if (ch === '}') {
|
|
3129
|
+
depth--;
|
|
3130
|
+
if (depth === 0) {
|
|
3131
|
+
try {
|
|
3132
|
+
const obj = JSON.parse(fenceStripped.slice(start, i + 1));
|
|
3133
|
+
if (obj && typeof obj === 'object' && typeof obj.tool === 'string' && obj.envelope) return obj;
|
|
3134
|
+
return null;
|
|
3135
|
+
} catch (_) { return null; }
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
return null;
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
async function undo() {
|
|
3143
|
+
// ⌘Z must not race a ⌘K commit: modify() holds `cur` from before its fetch,
|
|
3144
|
+
// and its commitDoc would clobber the doc/undo writes we make here.
|
|
3145
|
+
if (modifyMutex) { setStatus('err', '✗ modify in progress'); return; }
|
|
3146
|
+
const p = await popUndo();
|
|
3147
|
+
if (!p) { setStatus('err', '✗ nothing to undo'); return; }
|
|
3148
|
+
await idbPut(RWA.DOC, p);
|
|
3149
|
+
renderDoc(p);
|
|
3150
|
+
setDirty(true);
|
|
3151
|
+
setStatus('ok', '↩ undone');
|
|
3152
|
+
// A doc watching `'status'` for clean→dirty transitions would otherwise
|
|
3153
|
+
// miss the one caused by undo (setDirty(true) above doesn't emit). Stick to
|
|
3154
|
+
// `status` here; spec text in Task 6 will decide whether undo should also
|
|
3155
|
+
// qualify as a 'modify' event (Fix I3).
|
|
3156
|
+
emitRuntimeEvent('status', getStatusSnapshot());
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
async function commit() {
|
|
3160
|
+
setStatus('run', '⌘S writing');
|
|
3161
|
+
try {
|
|
3162
|
+
const text = buildFile(await getDoc());
|
|
3163
|
+
if ('showSaveFilePicker' in window) {
|
|
3164
|
+
let h = null; try { h = await idbGet(RWA.FSA); } catch (_) {}
|
|
3165
|
+
try {
|
|
3166
|
+
if (h) {
|
|
3167
|
+
let p = await h.queryPermission({ mode: 'readwrite' });
|
|
3168
|
+
if (p === 'prompt') p = await h.requestPermission({ mode: 'readwrite' });
|
|
3169
|
+
// Mirror the resolved FSA permission state into _fsaState so
|
|
3170
|
+
// runtime.status reflects what the user actually sees post-commit.
|
|
3171
|
+
if (p === 'granted') _fsaState = 'granted';
|
|
3172
|
+
else if (p === 'denied') _fsaState = 'denied';
|
|
3173
|
+
else if (p === 'prompt') _fsaState = 'prompt';
|
|
3174
|
+
if (p !== 'granted') {
|
|
3175
|
+
// Stale or revoked handle — drop it so the next ⌘S re-prompts.
|
|
3176
|
+
await idbDel(RWA.FSA).catch(() => {});
|
|
3177
|
+
h = null;
|
|
3178
|
+
throw new Error('permission denied — re-pick on next ⌘S');
|
|
3179
|
+
}
|
|
3180
|
+
} else {
|
|
3181
|
+
h = await window.showSaveFilePicker({ suggestedName: RWA.FILE, types: [{ description: 'HTML', accept: { 'text/html': ['.html'] } }] });
|
|
3182
|
+
await idbPut(RWA.FSA, h);
|
|
3183
|
+
_fsaState = 'granted'; // showSaveFilePicker only resolves on user pick
|
|
3184
|
+
}
|
|
3185
|
+
try {
|
|
3186
|
+
const w = await h.createWritable(); await w.write(text); await w.close();
|
|
3187
|
+
} catch (writeErr) {
|
|
3188
|
+
// InvalidStateError surfaces when the handle is no longer valid
|
|
3189
|
+
// (file moved/deleted, tab restored from bfcache, etc.). Mark the
|
|
3190
|
+
// handle as lost so runtime.status stops claiming 'granted'.
|
|
3191
|
+
if (writeErr && writeErr.name === 'InvalidStateError') _fsaState = 'lost';
|
|
3192
|
+
throw writeErr;
|
|
3193
|
+
}
|
|
3194
|
+
setDirty(false); await rwaResetOnCommit().catch(() => {}); setStatus('ok', '✓ committed');
|
|
3195
|
+
emitRuntimeEvent('commit', null);
|
|
3196
|
+
emitRuntimeEvent('status', getStatusSnapshot());
|
|
3197
|
+
return;
|
|
3198
|
+
} catch (e) {
|
|
3199
|
+
if (e.name === 'AbortError') { setStatus('', 'cancelled'); return; }
|
|
3200
|
+
console.warn('FSA failed, falling back to download:', e);
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
const a = document.createElement('a');
|
|
3204
|
+
a.href = URL.createObjectURL(new Blob([text], { type: 'text/html' }));
|
|
3205
|
+
a.download = RWA.FILE;
|
|
3206
|
+
a.click();
|
|
3207
|
+
URL.revokeObjectURL(a.href);
|
|
3208
|
+
setDirty(false); await rwaResetOnCommit().catch(() => {}); setStatus('ok', '✓ exported');
|
|
3209
|
+
emitRuntimeEvent('commit', null);
|
|
3210
|
+
emitRuntimeEvent('status', getStatusSnapshot());
|
|
3211
|
+
} catch (e) {
|
|
3212
|
+
setStatus('err', '✗ ' + e.message);
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
document.addEventListener('keydown', e => {
|
|
3217
|
+
const mod = navigator.platform.includes('Mac') ? e.metaKey : e.ctrlKey;
|
|
3218
|
+
if (!mod) return;
|
|
3219
|
+
if (e.key === 'k') { e.preventDefault(); document.getElementById('rwa-pal').classList.contains('open') ? closePal() : openPal(); }
|
|
3220
|
+
else if (e.key === 'z') { e.preventDefault(); undo(); }
|
|
3221
|
+
else if (e.key === 's') { e.preventDefault(); commit(); }
|
|
3222
|
+
});
|
|
3223
|
+
|
|
3224
|
+
(async () => {
|
|
3225
|
+
try {
|
|
3226
|
+
// Spec §9.1: private/incognito mode is unsupported. Detect before any other
|
|
3227
|
+
// init (persist, quota check, IDB open) — modify/commit are unsafe in private mode.
|
|
3228
|
+
if (await rwaDetectPrivateMode()) {
|
|
3229
|
+
rwaShowPrivateModeBanner();
|
|
3230
|
+
return; // do not initialize the rest of the runtime
|
|
3231
|
+
}
|
|
3232
|
+
FROZEN = canonLF('<!DOCTYPE html>\n' + document.documentElement.outerHTML);
|
|
3233
|
+
buildUI();
|
|
3234
|
+
if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {});
|
|
3235
|
+
rwaCheckQuota(); // fire-and-forget; non-blocking
|
|
3236
|
+
// Spec §9.1: secondary signal — IDB open throws. Surface the same banner.
|
|
3237
|
+
try {
|
|
3238
|
+
await openDB();
|
|
3239
|
+
} catch (err) {
|
|
3240
|
+
console.warn('rwa: IDB open failed', err);
|
|
3241
|
+
rwaShowPrivateModeBanner();
|
|
3242
|
+
return;
|
|
3243
|
+
}
|
|
3244
|
+
// Spec §7: rehydrate user-declared stores from rwa_state. If any are
|
|
3245
|
+
// missing from the just-opened DB (because they were declared in a prior
|
|
3246
|
+
// session), close + reopen so the upgrade handler creates them before
|
|
3247
|
+
// document code runs. This keeps the invariant "if userStoreDecls knows
|
|
3248
|
+
// about it, _db has the object store" true at every runtime.db.* call.
|
|
3249
|
+
await loadUserStoreDecls();
|
|
3250
|
+
if ([...userStoreDecls.keys()].some(s => !_db.objectStoreNames.contains(s))) {
|
|
3251
|
+
_db.close(); _db = null;
|
|
3252
|
+
await openDB(); // probe-then-upgrade picks up the missing stores
|
|
3253
|
+
}
|
|
3254
|
+
// Spec §5.6: rehydrate the dirty-state nudge if a prior session left it ≥threshold.
|
|
3255
|
+
const initialCount = await rwaGetDirtyCount();
|
|
3256
|
+
if (initialCount >= RWA.NUDGE_THRESHOLD) showCommitNudge(initialCount);
|
|
3257
|
+
let doc = await getDoc();
|
|
3258
|
+
// Web-citizen: ensure every anchorable block has a stable data-rwa-id
|
|
3259
|
+
// before first render so URL-fragment links resolve immediately, without
|
|
3260
|
+
// waiting for the first agent edit. One-shot per container — subsequent
|
|
3261
|
+
// bootstraps see IDs already present and skip the idbPut.
|
|
3262
|
+
const idRes = injectMissingBlockIds(doc);
|
|
3263
|
+
if (idRes.assigned > 0) {
|
|
3264
|
+
doc = idRes.text;
|
|
3265
|
+
await idbPut(RWA.DOC, doc);
|
|
3266
|
+
}
|
|
3267
|
+
// Spec §7: expose the public runtime API. Only constructed on the success
|
|
3268
|
+
// path (after private-mode detection + openDB() + block-id seeding) so
|
|
3269
|
+
// documents never see a half-initialized runtime. Private-mode early-return
|
|
3270
|
+
// above leaves window.runtime undefined — that's correct: no IDB, no API.
|
|
3271
|
+
window.runtime = {
|
|
3272
|
+
id: DOC_UUID,
|
|
3273
|
+
db: {
|
|
3274
|
+
get: runtimeDbGet,
|
|
3275
|
+
put: runtimeDbPut,
|
|
3276
|
+
del: runtimeDbDel,
|
|
3277
|
+
all: runtimeDbAll,
|
|
3278
|
+
open: runtimeDbOpen,
|
|
3279
|
+
subscribe: runtimeDbSubscribe,
|
|
3280
|
+
},
|
|
3281
|
+
fs: {
|
|
3282
|
+
read: runtimeFsRead,
|
|
3283
|
+
write: runtimeFsWrite,
|
|
3284
|
+
del: runtimeFsDel,
|
|
3285
|
+
list: runtimeFsList,
|
|
3286
|
+
},
|
|
3287
|
+
modify: runtimeModify,
|
|
3288
|
+
commit: runtimeCommit,
|
|
3289
|
+
undo: runtimeUndo,
|
|
3290
|
+
on: runtimeOn,
|
|
3291
|
+
};
|
|
3292
|
+
// `status` is a getter so each read returns a fresh snapshot of
|
|
3293
|
+
// dirty/fsa/storage; an enumerable data prop would let stale references
|
|
3294
|
+
// outlive the state change they were observing.
|
|
3295
|
+
Object.defineProperty(window.runtime, 'status', {
|
|
3296
|
+
get: getStatusSnapshot,
|
|
3297
|
+
enumerable: true,
|
|
3298
|
+
configurable: false,
|
|
3299
|
+
});
|
|
3300
|
+
renderDoc(doc);
|
|
3301
|
+
scrollToFragment();
|
|
328
3302
|
setStatus('ok', '● ready');
|
|
329
3303
|
} catch (e) {
|
|
330
3304
|
document.body.textContent = 'Bootstrap error: ' + e.message;
|
|
331
3305
|
console.error(e);
|
|
332
3306
|
}
|
|
333
3307
|
})();
|
|
3308
|
+
window.addEventListener('hashchange', () => scrollToFragment());
|
|
334
3309
|
</script>
|
|
335
3310
|
</body>
|
|
336
3311
|
</html>
|