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.
@@ -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
- :root{--bg:#0e0e0f;--surf:#161618;--b1:#232327;--b2:#2d2d34;--text:#dddde4;--muted:#575766;--accent:#b8ff57;--blue:#57c8ff;--red:#ff5757;}
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
- body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;}
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(--surf);border:1px solid var(--b2);color:var(--muted);font-family:'DM Mono',monospace;font-size:10px;padding:6px 10px;border-radius:4px;cursor:pointer;letter-spacing:.5px;text-transform:uppercase;}
14
- .rwa-st-btn:hover{color:var(--text);border-color:var(--muted);}
15
- .rwa-st-btn.dirty{color:var(--accent);border-color:var(--accent);}
16
- .rwa-st-btn.pri{background:var(--accent);color:#000;border-color:var(--accent);}
17
- .rwa-st-btn.run{color:var(--blue);border-color:var(--blue);}
18
- .rwa-st-btn.err{color:var(--red);border-color:var(--red);}
19
- .rwa-st-btn.ok{color:var(--accent);border-color:var(--accent);}
20
- #rwa-set-panel{position:fixed;top:50px;right:12px;background:var(--surf);border:1px solid var(--b2);border-radius:6px;padding:12px;display:none;min-width:300px;z-index:999;}
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:'DM Mono',monospace;font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted);}
25
- .rwa-set-row input{background:var(--bg);border:1px solid var(--b2);color:var(--text);font-family:'DM Mono',monospace;font-size:12px;padding:6px 9px;outline:none;border-radius:3px;}
26
- #rwa-pal{position:fixed;inset:0;display:none;align-items:flex-start;justify-content:center;padding-top:13vh;background:rgba(0,0,0,.6);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:99999;}
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(--surf);border:1px solid var(--b2);border-radius:8px;overflow:hidden;}
29
- .rwa-pal-top{display:flex;align-items:center;border-bottom:1px solid var(--b1);}
30
- .rwa-pal-sig{padding:0 14px;font-size:13px;color:var(--accent);font-family:'DM Mono',monospace;letter-spacing:.5px;}
31
- #rwa-pal-inp{flex:1;background:transparent;border:none;outline:none;color:var(--text);font-family:'DM Sans',sans-serif;font-size:14px;padding:14px 0;}
32
- #rwa-pal-inp::placeholder{color:#363642;}
33
- #rwa-pal-go{background:var(--accent);color:#000;border:none;border-radius:4px;font-family:'DM Mono',monospace;font-size:10px;padding:5px 11px;margin:7px 10px;cursor:pointer;letter-spacing:.5px;}
34
- #rwa-pal-go:disabled{opacity:.3;pointer-events:none;}
35
- .rwa-pal-foot{border-top:1px solid var(--b1);padding:7px 14px;display:flex;justify-content:space-between;align-items:center;}
36
- #rwa-pal-st{font-family:'DM Mono',monospace;font-size:10px;color:var(--muted);}
37
- #rwa-pal-st.run{color:var(--blue);}#rwa-pal-st.ok{color:var(--accent);}#rwa-pal-st.err{color:var(--red);}
38
- .rwa-pal-hint{font-family:'DM Mono',monospace;font-size:10px;color:#363642;}
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:'Instrument Serif',serif;font-style:italic;font-size:clamp(56px,9vw,112px);line-height:1;letter-spacing:-.02em;background:linear-gradient(135deg,var(--text) 50%,var(--muted));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
61
- .hello p{font-family:'Instrument Serif',serif;font-size:18px;color:var(--muted);margin-top:22px;font-style:italic;max-width:42ch;line-height:1.5;}
62
- .hello kbd{font-family:'DM Mono',monospace;font-size:11px;background:var(--surf);border:1px solid var(--b2);padding:2px 7px;border-radius:3px;color:var(--accent);font-style:normal;}
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. Press <kbd>⌘K</kbd> and tell it what to become.</p>
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:15,
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
- // Open without specifying a version, so we work whatever the existing db version is.
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 (toRecreate.length === 0) { _db = probe; return _db; }
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
- const s = (await idbGet(RWA.UNDO)) || [];
137
- if (!s.length) return null;
138
- const v = s.pop(); await idbPut(RWA.UNDO, s); return v;
139
- }
140
- async function addHist(i) {
141
- const l = (await idbGet(RWA.HIST)) || [];
142
- await idbPut(RWA.HIST, [i, ...l.filter(x => x !== i)].slice(0, RWA.HIST_CAP));
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>OpenRouter Key</label><input type="password" id="rwa-key" placeholder="sk-or-..." autocomplete="off"></div>
189
- <div class="rwa-set-row"><label>Model</label><input type="text" id="rwa-model" autocomplete="off"></div>
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()) { e.preventDefault(); modify(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 = () => inp.value.trim() && modify(inp.value.trim());
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
- // ─── Agent ──────────────────────────────────────────────────────────
230
- const SYSTEM_PROMPT = `You are modifying a document. The document may be prose, it may be a tracker, it may be a spreadsheet, it may be all three. Read what is there. Apply the user's instruction to the actual content — its tone if it is prose, its structure if it is data, its behavior if it is interactive.
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
- If the user's input is itself substantial content — a long block of prose, a structured outline, a markdown document, a list of items — they want that content rendered into the document, not summarized. Preserve every paragraph, every section, every list item, every example. Do not condense, abbreviate with ellipsis, or omit anything for brevity. If the input has 100 items, the output has 100 items. If the input has 12 sections, the output has 12 sections.
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
- Return the complete modified document only — no commentary, no markdown fence. The document is an HTML fragment that lives inside a mount div. CSS may be inline in <style> tags; JS may be inline in <script> tags. Do NOT include <!DOCTYPE>, <html>, <head>, or <body> — those belong to the bootstrap. The first character of your response should begin the modified content directly.`;
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
- async function modify(instr) {
237
- const key = sessionStorage.getItem(RWA.K_API);
238
- const model = sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
239
- if (!key) {
240
- setPalSt('err', 'no API key — open ⚙ settings');
241
- document.getElementById('rwa-set-panel').classList.add('open');
242
- document.getElementById('rwa-key').focus();
243
- return;
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
- closePal();
246
- setStatus('run', '⌘K running');
247
- try {
248
- const cur = await getDoc();
249
- const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
250
- method: 'POST',
251
- headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + key, 'HTTP-Referer': 'https://github.com/ikangai/rewritable', 'X-Title': 're-write-able' },
252
- body: JSON.stringify({ model, max_tokens: 32000, messages: [{ role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: 'Document:\n' + cur + '\n\nInstruction: ' + instr }] }),
253
- });
254
- if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error((e.error && e.error.message) || r.statusText); }
255
- let text = (((await r.json()).choices || [{}])[0].message?.content || '').trim();
256
- const fence = '```';
257
- if (text.startsWith(fence)) { const nl = text.indexOf('\n'); if (nl > 0) text = text.slice(nl + 1); }
258
- if (text.endsWith(fence)) text = text.slice(0, text.lastIndexOf(fence)).trim();
259
- if (!text) throw new Error('empty response');
260
- await pushUndo(cur);
261
- await idbPut(RWA.DOC, text);
262
- await addHist(instr);
263
- renderDoc(text);
264
- setDirty(true);
265
- setStatus('ok', '✓ done');
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
- async function undo() {
273
- const p = await popUndo();
274
- if (!p) { setStatus('err', '✗ nothing to undo'); return; }
275
- await idbPut(RWA.DOC, p);
276
- renderDoc(p);
277
- setDirty(true);
278
- setStatus('ok', '↩ undone');
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
- async function commit() {
282
- setStatus('run', '⌘S writing');
283
- try {
284
- const text = buildFile(await getDoc());
285
- if ('showSaveFilePicker' in window) {
286
- let h = null; try { h = await idbGet(RWA.FSA); } catch (_) {}
287
- try {
288
- if (h) {
289
- let p = await h.queryPermission({ mode: 'readwrite' });
290
- if (p === 'prompt') p = await h.requestPermission({ mode: 'readwrite' });
291
- if (p !== 'granted') throw new Error('permission denied');
292
- } else {
293
- h = await window.showSaveFilePicker({ suggestedName: RWA.FILE, types: [{ description: 'HTML', accept: { 'text/html': ['.html'] } }] });
294
- await idbPut(RWA.FSA, h);
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1170
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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
- const a = document.createElement('a');
304
- a.href = URL.createObjectURL(new Blob([text], { type: 'text/html' }));
305
- a.download = RWA.FILE;
306
- a.click();
307
- URL.revokeObjectURL(a.href);
308
- setDirty(false); setStatus('ok', '✓ exported');
309
- } catch (e) {
310
- setStatus('err', '✗ ' + e.message);
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
- document.addEventListener('keydown', e => {
315
- const mod = navigator.platform.includes('Mac') ? e.metaKey : e.ctrlKey;
316
- if (!mod) return;
317
- if (e.key === 'k') { e.preventDefault(); document.getElementById('rwa-pal').classList.contains('open') ? closePal() : openPal(); }
318
- else if (e.key === 'z') { e.preventDefault(); undo(); }
319
- else if (e.key === 's') { e.preventDefault(); commit(); }
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
- (async () => {
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
- FROZEN = '<!DOCTYPE html>\n' + document.documentElement.outerHTML;
325
- buildUI();
326
- if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {});
327
- renderDoc(await getDoc());
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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, '&amp;').replace(/"/g, '&quot;');
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>