rewritable 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/rwa.mjs +9 -8
- package/package.json +1 -1
- package/seeds/rewritable.html +212 -4
- package/src/agent-loop.mjs +5 -3
- package/src/backend.mjs +24 -6
- package/src/commands.mjs +1 -1
- package/src/create.mjs +2 -1
package/README.md
CHANGED
|
@@ -125,10 +125,10 @@ The agent loop retries up to 3 times when the model emits plain text instead of
|
|
|
125
125
|
|
|
126
126
|
| Flag | Effect |
|
|
127
127
|
|---|---|
|
|
128
|
-
| `--backend <name>` | `openrouter` (default), `ollama`, `lmstudio`. Falls back to `$RWA_BACKEND`. `bridge` is browser-only by design. |
|
|
128
|
+
| `--backend <name>` | `openrouter` (default), `ollama`, `lmstudio`, `atomic`. Falls back to `$RWA_BACKEND`. `bridge` is browser-only by design. |
|
|
129
129
|
| `--model <id>` | model id passed to the backend. Falls back to `$RWA_MODEL`, then `google/gemini-3.5-flash`. |
|
|
130
|
-
| `--base-url <url>` | OpenAI-compatible base URL override. Defaults: `https://openrouter.ai/api/v1`, `http://localhost:11434/v1` (or `$RWA_OLLAMA_URL`), `http://localhost:1234/v1` (or `$RWA_LMSTUDIO_URL`). |
|
|
131
|
-
| `--api-key <key>` | openrouter only; falls back to `$RWA_OPENROUTER_KEY`. ollama / lmstudio run locally without auth. |
|
|
130
|
+
| `--base-url <url>` | OpenAI-compatible base URL override. Defaults: `https://openrouter.ai/api/v1`, `http://localhost:11434/v1` (or `$RWA_OLLAMA_URL`), `http://localhost:1234/v1` (or `$RWA_LMSTUDIO_URL`), `http://127.0.0.1:1337/v1` (or `$RWA_ATOMIC_URL`). |
|
|
131
|
+
| `--api-key <key>` | openrouter only; falls back to `$RWA_OPENROUTER_KEY`. ollama / lmstudio / atomic run locally without auth. |
|
|
132
132
|
|
|
133
133
|
#### Other edit flags
|
|
134
134
|
|
package/bin/rwa.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { newCmd, importCmd, version, KNOWN_KINDS, openWithPrefill, SEED_CANDIDATES } from '../src/commands.mjs';
|
|
3
|
-
import { resolveApiKey } from '../src/backend.mjs';
|
|
3
|
+
import { resolveApiKey, backendMaxTokens } from '../src/backend.mjs';
|
|
4
4
|
import { parseCreateArgs, createCmd } from '../src/create.mjs';
|
|
5
5
|
import { relative } from 'node:path';
|
|
6
6
|
|
|
@@ -101,7 +101,7 @@ Flags:
|
|
|
101
101
|
--open, -o open the resulting file in the default app. First-paint
|
|
102
102
|
sessionStorage is pre-populated from env / ./.env:
|
|
103
103
|
OPENROUTER_API_KEY → ?key=… (lifted into rwa_apikey)
|
|
104
|
-
RWA_BACKEND → ?backend= (openrouter|ollama|lmstudio|bridge)
|
|
104
|
+
RWA_BACKEND → ?backend= (openrouter|ollama|lmstudio|atomic|bridge)
|
|
105
105
|
RWA_MODEL → ?model=… (model name string)
|
|
106
106
|
The bootstrap lifts each into sessionStorage and scrubs the
|
|
107
107
|
URL bar on first paint, so the values don't sit in history.
|
|
@@ -137,8 +137,8 @@ Flags:
|
|
|
137
137
|
the raw body; on failure, the \`{code, subcode, details}\`
|
|
138
138
|
object goes to stderr.
|
|
139
139
|
--backend <n> (edit instruction path / skin --l1) backend name. One of:
|
|
140
|
-
openrouter (default), ollama, lmstudio. Falls back
|
|
141
|
-
\$RWA_BACKEND if unset.
|
|
140
|
+
openrouter (default), ollama, lmstudio, atomic. Falls back
|
|
141
|
+
to \$RWA_BACKEND if unset.
|
|
142
142
|
--model <id> (edit instruction path / skin --l1) model id passed to the
|
|
143
143
|
backend. Falls back to \$RWA_MODEL, then a
|
|
144
144
|
sensible default for the backend.
|
|
@@ -248,6 +248,7 @@ function envBaseUrl(name) {
|
|
|
248
248
|
case 'openrouter': return 'https://openrouter.ai/api/v1';
|
|
249
249
|
case 'ollama': return process.env.RWA_OLLAMA_URL || 'http://localhost:11434/v1';
|
|
250
250
|
case 'lmstudio': return process.env.RWA_LMSTUDIO_URL || 'http://localhost:1234/v1';
|
|
251
|
+
case 'atomic': return process.env.RWA_ATOMIC_URL || 'http://127.0.0.1:1337/v1';
|
|
251
252
|
default: return undefined;
|
|
252
253
|
}
|
|
253
254
|
}
|
|
@@ -377,7 +378,7 @@ function detectProductKind(fileText) {
|
|
|
377
378
|
|
|
378
379
|
// Reject unknown backends fast. `bridge` is browser-only by design
|
|
379
380
|
// (single-shot via web_cli_bridge); the CLI has no equivalent.
|
|
380
|
-
if (!['openrouter', 'ollama', 'lmstudio'].includes(backendName)) {
|
|
381
|
+
if (!['openrouter', 'ollama', 'lmstudio', 'atomic'].includes(backendName)) {
|
|
381
382
|
emitEdit({ code: 'usage_error', subcode: 'unknown_backend', details: { backend: backendName } }, jsonMode);
|
|
382
383
|
process.exitCode = 1;
|
|
383
384
|
return;
|
|
@@ -457,7 +458,7 @@ function detectProductKind(fileText) {
|
|
|
457
458
|
currentDoc: promptDoc,
|
|
458
459
|
instruction,
|
|
459
460
|
frozenZoneNames,
|
|
460
|
-
backend: { baseUrl, model: modelId, apiKey },
|
|
461
|
+
backend: { baseUrl, model: modelId, apiKey, maxTokens: backendMaxTokens(backendName) },
|
|
461
462
|
onRetry: r => {
|
|
462
463
|
if (jsonMode) {
|
|
463
464
|
process.stderr.write(JSON.stringify({ phase: 'retry', attempt: r.attempt, reason: r.reason }) + '\n');
|
|
@@ -845,7 +846,7 @@ function detectProductKind(fileText) {
|
|
|
845
846
|
const baseUrl = baseUrlFlag.value || envBaseUrl(backendName);
|
|
846
847
|
const apiKey = resolveApiKey(backendName, apiKeyFlag.value);
|
|
847
848
|
|
|
848
|
-
if (!['openrouter', 'ollama', 'lmstudio'].includes(backendName)) {
|
|
849
|
+
if (!['openrouter', 'ollama', 'lmstudio', 'atomic'].includes(backendName)) {
|
|
849
850
|
emitSkin({ code: 'usage_error', subcode: 'unknown_backend', details: { backend: backendName } });
|
|
850
851
|
process.exitCode = 1; return;
|
|
851
852
|
}
|
|
@@ -882,7 +883,7 @@ function detectProductKind(fileText) {
|
|
|
882
883
|
result = await skinCmdL1(filePath, action, {
|
|
883
884
|
systemPrompt,
|
|
884
885
|
toolSchemas: TOOL_SCHEMAS,
|
|
885
|
-
backend: { baseUrl, model: modelId, apiKey },
|
|
886
|
+
backend: { baseUrl, model: modelId, apiKey, maxTokens: backendMaxTokens(backendName) },
|
|
886
887
|
onRetry: r => {
|
|
887
888
|
if (jsonMode) process.stderr.write(JSON.stringify({ phase: 'retry', attempt: r.attempt, reason: r.reason }) + '\n');
|
|
888
889
|
else process.stderr.write(`rwa skin: attempt ${r.attempt}/3 retrying — ${r.reason}\n`);
|
package/package.json
CHANGED
package/seeds/rewritable.html
CHANGED
|
@@ -88,6 +88,17 @@ body{font-family:var(--font-ui);background:var(--bg);color:var(--text);min-heigh
|
|
|
88
88
|
#rwa-info-panel .rwa-info-foot{margin-top:12px;padding-top:10px;border-top:1px solid var(--gray-100);font-size:11px;color:var(--gray-500);line-height:1.5;}
|
|
89
89
|
#rwa-info-panel strong{color:var(--gray-900);font-weight:600;}
|
|
90
90
|
#rwa-info-panel code{font-family:var(--font-mono);font-size:11px;background:var(--gray-50);padding:1px 5px;border-radius:4px;color:var(--gray-700);}
|
|
91
|
+
#rwa-share-panel{position:fixed;top:50px;right:12px;background:var(--white);border:1px solid var(--gray-200);border-radius:var(--radius-sm);padding:18px 20px;display:none;max-width:340px;z-index:999;box-shadow:0 4px 16px rgba(0,0,0,0.08);font-family:var(--font-ui);font-size:13px;line-height:1.55;color:var(--gray-700);}
|
|
92
|
+
#rwa-share-panel.open{display:block;}
|
|
93
|
+
#rwa-share-panel h4{margin:0 0 6px;font-size:15px;color:var(--gray-900);font-weight:600;}
|
|
94
|
+
#rwa-share-panel p{margin:0 0 10px;}
|
|
95
|
+
.rwa-share-url{font-family:var(--font-mono);font-size:11px;background:var(--gray-50);border:1px solid var(--gray-100);border-radius:6px;padding:6px 8px;margin:0 0 8px;cursor:pointer;word-break:break-all;color:var(--gray-700);}
|
|
96
|
+
.rwa-share-fresh{font-family:var(--font-mono);font-size:10px;color:var(--gray-500);margin-bottom:10px;}
|
|
97
|
+
.rwa-share-msg{font-size:12px;color:var(--gray-600);background:var(--gray-50);border-radius:6px;padding:6px 8px;margin-bottom:8px;}
|
|
98
|
+
.rwa-share-actions{display:flex;gap:6px;flex-wrap:wrap;}
|
|
99
|
+
.rwa-share-actions button{font:inherit;font-size:12px;padding:5px 10px;border:1px solid var(--gray-300);border-radius:8px;background:var(--white);color:var(--gray-900);cursor:pointer;}
|
|
100
|
+
.rwa-share-actions button:disabled{opacity:.5;cursor:default;}
|
|
101
|
+
#rwa-share-create,#rwa-share-update{background:var(--gray-900);color:var(--white);border-color:var(--gray-900);}
|
|
91
102
|
.rwa-set-hint code{background:var(--gray-100);padding:1px 4px;border-radius:3px;font-size:10px;}
|
|
92
103
|
.rwa-set-hint.ok{color:#15803d;}
|
|
93
104
|
.rwa-set-hint.err{color:#b91c1c;}
|
|
@@ -369,6 +380,13 @@ const RWA = {
|
|
|
369
380
|
// not block these requests even when the container is served over HTTPS.
|
|
370
381
|
DEFAULT_OLLAMA_URL:'http://localhost:11434/v1',
|
|
371
382
|
DEFAULT_LMSTUDIO_URL:'http://localhost:1234/v1',
|
|
383
|
+
// atomic.chat: a local OpenAI-compatible inference server (MLX-backed on
|
|
384
|
+
// Apple Silicon) with the same /v1/chat/completions + /v1/models shape and
|
|
385
|
+
// real multi-turn tool_calls. CORS note: it allows http(s) page origins out
|
|
386
|
+
// of the box but NOT the file:// null origin — serve the container from a
|
|
387
|
+
// local origin (or a hosted projection) to use it.
|
|
388
|
+
DEFAULT_ATOMIC_URL:'http://127.0.0.1:1337/v1',
|
|
389
|
+
K_BASE_URL_ATOMIC:'rwa_base_url_atomic',
|
|
372
390
|
// Optional alternative agent backend: a localhost CLI bridge
|
|
373
391
|
// (https://github.com/martintreiber/web_cli_bridge style) that runs
|
|
374
392
|
// arbitrary shell commands. When the user picks "bridge" in settings, ⌘K
|
|
@@ -376,6 +394,13 @@ const RWA = {
|
|
|
376
394
|
// for users with a Claude subscription; the price is a per-call subprocess
|
|
377
395
|
// (~5-10s startup) and a single-shot agent loop (no mid-stream tool_calls).
|
|
378
396
|
BRIDGE_URL:'http://127.0.0.1:8765/run',
|
|
397
|
+
// Connected-share service (the ↗ panel): a stable share URL the user
|
|
398
|
+
// explicitly publishes versions to. Network I/O happens ONLY on share
|
|
399
|
+
// gestures — never at boot, never on ⌘S — so offline-first holds. Dev /
|
|
400
|
+
// self-hosted services override via sessionStorage rwa_share_base.
|
|
401
|
+
// Design: docs/plans/2026-06-11-save-affordance-framings.md §7c.
|
|
402
|
+
SHARE_BASE:'https://rewritable.ikangai.com',
|
|
403
|
+
K_SHARE_BASE:'rwa_share_base',
|
|
379
404
|
};
|
|
380
405
|
|
|
381
406
|
// `rwa new -o` / `rwa import -o` can pass first-paint configuration via URL
|
|
@@ -393,7 +418,7 @@ try {
|
|
|
393
418
|
qs.delete('key');
|
|
394
419
|
}
|
|
395
420
|
const b = qs.get('backend');
|
|
396
|
-
if (b && ['openrouter','ollama','lmstudio','bridge','bridge-session'].includes(b)) {
|
|
421
|
+
if (b && ['openrouter','ollama','lmstudio','atomic','bridge','bridge-session'].includes(b)) {
|
|
397
422
|
sessionStorage.setItem(RWA.K_BACKEND, b);
|
|
398
423
|
qs.delete('backend');
|
|
399
424
|
}
|
|
@@ -1095,10 +1120,11 @@ function buildUI() {
|
|
|
1095
1120
|
<button class="rwa-st-btn" id="rwa-st-info" title="what is this?" aria-label="what is this?">ⓘ</button>
|
|
1096
1121
|
<button class="rwa-st-btn" id="rwa-st-cog">⚙</button>
|
|
1097
1122
|
<button class="rwa-st-btn" id="rwa-st-skin" title="skins — pick a look" aria-label="skins">✦</button>
|
|
1123
|
+
<button class="rwa-st-btn" id="rwa-st-share" title="share at a link" aria-label="share at a link">↗</button>
|
|
1098
1124
|
<button class="rwa-st-btn pri" id="rwa-st-commit">⌘S</button>
|
|
1099
1125
|
</div>
|
|
1100
1126
|
<div id="rwa-set-panel">
|
|
1101
|
-
<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><option value="bridge-session">Bridge session (claude, persistent)</option></select></div>
|
|
1127
|
+
<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="atomic">atomic.chat (localhost)</option><option value="bridge">Bridge (claude -p, localhost)</option><option value="bridge-session">Bridge session (claude, persistent)</option></select></div>
|
|
1102
1128
|
<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>
|
|
1103
1129
|
<div class="rwa-set-row" id="rwa-set-row-bridge-token" style="display:none;"><label>Bridge Token</label><input type="password" id="rwa-bridge-token" placeholder="WEB_CLI_BRIDGE_TOKEN" autocomplete="off"></div>
|
|
1104
1130
|
<div class="rwa-set-row" id="rwa-set-row-bridge-cwd" style="display:none;"><label>Session Dir</label><input type="text" id="rwa-bridge-cwd" placeholder="/path/on/bridge/host" autocomplete="off" spellcheck="false"></div>
|
|
@@ -1108,6 +1134,7 @@ function buildUI() {
|
|
|
1108
1134
|
</div>
|
|
1109
1135
|
<div id="rwa-info-panel"></div>
|
|
1110
1136
|
<div id="rwa-skin-panel"></div>
|
|
1137
|
+
<div id="rwa-share-panel"></div>
|
|
1111
1138
|
<div id="rwa-pal">
|
|
1112
1139
|
<div id="rwa-pal-box">
|
|
1113
1140
|
<div class="rwa-pal-top">
|
|
@@ -1178,6 +1205,12 @@ function buildUI() {
|
|
|
1178
1205
|
defaultUrl: RWA.DEFAULT_LMSTUDIO_URL,
|
|
1179
1206
|
storageKey: RWA.K_BASE_URL_LMSTUDIO,
|
|
1180
1207
|
},
|
|
1208
|
+
atomic: {
|
|
1209
|
+
showKey: false, showBaseUrl: true, showModel: true, showHint: true,
|
|
1210
|
+
hintHTML: 'atomic.chat serves an OpenAI-compatible API on <code>127.0.0.1:1337</code>, no key needed — use <code>Test</code> to list its models. CORS allows http(s) page origins out of the box but <strong>not <code>file://</code> pages</strong> (the null origin): open this container from a local web server or a hosted projection to use it.',
|
|
1211
|
+
defaultUrl: RWA.DEFAULT_ATOMIC_URL,
|
|
1212
|
+
storageKey: RWA.K_BASE_URL_ATOMIC,
|
|
1213
|
+
},
|
|
1181
1214
|
bridge: {
|
|
1182
1215
|
showKey: false, showBaseUrl: false, showModel: false, showHint: false,
|
|
1183
1216
|
hintHTML: '',
|
|
@@ -1289,6 +1322,7 @@ function buildUI() {
|
|
|
1289
1322
|
document.getElementById('rwa-st-cog').onclick = () => {
|
|
1290
1323
|
document.getElementById('rwa-info-panel').classList.remove('open'); // panels are mutually exclusive
|
|
1291
1324
|
document.getElementById('rwa-skin-panel').classList.remove('open');
|
|
1325
|
+
document.getElementById('rwa-share-panel').classList.remove('open');
|
|
1292
1326
|
document.getElementById('rwa-set-panel').classList.toggle('open');
|
|
1293
1327
|
};
|
|
1294
1328
|
// ⓘ "what is this?" — render the rwa-identity/1 bundle as prose on open so it
|
|
@@ -1297,6 +1331,7 @@ function buildUI() {
|
|
|
1297
1331
|
document.getElementById('rwa-st-info').onclick = () => {
|
|
1298
1332
|
document.getElementById('rwa-set-panel').classList.remove('open');
|
|
1299
1333
|
document.getElementById('rwa-skin-panel').classList.remove('open');
|
|
1334
|
+
document.getElementById('rwa-share-panel').classList.remove('open');
|
|
1300
1335
|
const panel = document.getElementById('rwa-info-panel');
|
|
1301
1336
|
if (!panel.classList.contains('open')) panel.innerHTML = renderInfoPanel();
|
|
1302
1337
|
panel.classList.toggle('open');
|
|
@@ -1307,6 +1342,17 @@ function buildUI() {
|
|
|
1307
1342
|
if (panel && panel.classList.contains('open')) { panel.classList.remove('open'); return; }
|
|
1308
1343
|
openSkinPanel();
|
|
1309
1344
|
};
|
|
1345
|
+
// ↗ connected share — open re-renders from the live record so the panel
|
|
1346
|
+
// always reflects the current connection + freshness. Mutually exclusive
|
|
1347
|
+
// with the other three panels.
|
|
1348
|
+
document.getElementById('rwa-st-share').onclick = () => {
|
|
1349
|
+
const panel = document.getElementById('rwa-share-panel');
|
|
1350
|
+
if (panel.classList.contains('open')) { panel.classList.remove('open'); return; }
|
|
1351
|
+
document.getElementById('rwa-set-panel').classList.remove('open');
|
|
1352
|
+
document.getElementById('rwa-info-panel').classList.remove('open');
|
|
1353
|
+
document.getElementById('rwa-skin-panel').classList.remove('open');
|
|
1354
|
+
renderSharePanel().then(() => panel.classList.add('open'));
|
|
1355
|
+
};
|
|
1310
1356
|
document.getElementById('rwa-st-commit').onclick = commit;
|
|
1311
1357
|
|
|
1312
1358
|
const pal = document.getElementById('rwa-pal'), inp = document.getElementById('rwa-pal-inp'), go = document.getElementById('rwa-pal-go');
|
|
@@ -4159,6 +4205,7 @@ HARD RULES: colors are hex strings only (e.g. "#c0392b"); fonts are ONE of the f
|
|
|
4159
4205
|
if (!panel) return;
|
|
4160
4206
|
const setP = document.getElementById('rwa-set-panel'); if (setP) setP.classList.remove('open');
|
|
4161
4207
|
const infoP = document.getElementById('rwa-info-panel'); if (infoP) infoP.classList.remove('open');
|
|
4208
|
+
const shareP = document.getElementById('rwa-share-panel'); if (shareP) shareP.classList.remove('open');
|
|
4162
4209
|
const active = currentSkinName();
|
|
4163
4210
|
panel.innerHTML =
|
|
4164
4211
|
'<div class="rwa-skin-hd"><span>Skins</span><span>' + (active || '—') + '</span></div>'
|
|
@@ -4287,6 +4334,18 @@ function resolveBackendConfig() {
|
|
|
4287
4334
|
apiKey: null, extraHeaders: {}, requiresKey: false,
|
|
4288
4335
|
};
|
|
4289
4336
|
}
|
|
4337
|
+
if (backend === 'atomic') {
|
|
4338
|
+
return {
|
|
4339
|
+
backend, kind:'openai_compat',
|
|
4340
|
+
baseUrl: (sessionStorage.getItem(RWA.K_BASE_URL_ATOMIC) || '').trim() || RWA.DEFAULT_ATOMIC_URL,
|
|
4341
|
+
apiKey: null, extraHeaders: {}, requiresKey: false,
|
|
4342
|
+
// atomic.chat REJECTS (400) requests whose prompt + max generation
|
|
4343
|
+
// exceed its MAX_KV_SIZE (16384 default) rather than clamping like
|
|
4344
|
+
// ollama/lmstudio — half the window for generation, half for the
|
|
4345
|
+
// prompt + document. Callers read cfg.maxTokens || 32000.
|
|
4346
|
+
maxTokens: 8192,
|
|
4347
|
+
};
|
|
4348
|
+
}
|
|
4290
4349
|
if (backend === 'bridge') {
|
|
4291
4350
|
return { backend, kind:'bridge' };
|
|
4292
4351
|
}
|
|
@@ -4345,7 +4404,7 @@ async function callAgentSingleShot(prompt) {
|
|
|
4345
4404
|
const model = sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
|
|
4346
4405
|
const data = await openAiCompatChat(cfg, {
|
|
4347
4406
|
model,
|
|
4348
|
-
max_tokens: 32000,
|
|
4407
|
+
max_tokens: cfg.maxTokens || 32000,
|
|
4349
4408
|
messages: [{ role:'user', content: prompt }],
|
|
4350
4409
|
});
|
|
4351
4410
|
const msg = data.choices?.[0]?.message;
|
|
@@ -6154,6 +6213,155 @@ function renderInfoPanel() {
|
|
|
6154
6213
|
].join('');
|
|
6155
6214
|
}
|
|
6156
6215
|
|
|
6216
|
+
// ─── Connected share (the ↗ panel) ──────────────────────────────────
|
|
6217
|
+
// A rewritable can be CONNECTED to a stable URL: "Create share link" POSTs the
|
|
6218
|
+
// full current file bytes (buildFile output — exactly the ⌘S artifact) to the
|
|
6219
|
+
// share service; "Publish this version" re-POSTs to the same short under the
|
|
6220
|
+
// returned update token. The record {short,url,token,publishedHash,publishedAt}
|
|
6221
|
+
// lives in rwa_state — machine-local on purpose: the token is a capability,
|
|
6222
|
+
// and buildFile only serializes INLINE_DOC, so neither can ever travel in the
|
|
6223
|
+
// file. The copy says VERSION on purpose: the link shows the last published
|
|
6224
|
+
// version, not live edits (docs/plans/2026-06-11-save-affordance-framings.md
|
|
6225
|
+
// §7c). These are the ONLY fetches the runtime makes outside the agent
|
|
6226
|
+
// backends, each behind an explicit user gesture — offline-first holds.
|
|
6227
|
+
const shareBaseUrl = () =>
|
|
6228
|
+
(sessionStorage.getItem(RWA.K_SHARE_BASE) || '').trim() || RWA.SHARE_BASE;
|
|
6229
|
+
|
|
6230
|
+
// Hash + file bytes from ONE doc read, so publishedHash always describes the
|
|
6231
|
+
// exact bytes that went out (no mid-flight-edit skew).
|
|
6232
|
+
async function shareSnapshot() {
|
|
6233
|
+
const d = await getDoc();
|
|
6234
|
+
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(d));
|
|
6235
|
+
const hash = [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
|
|
6236
|
+
return { text: buildFile(d), hash };
|
|
6237
|
+
}
|
|
6238
|
+
async function shareDocHashHex() { return (await shareSnapshot()).hash; }
|
|
6239
|
+
|
|
6240
|
+
const getShareConn = () => idbGet(RWA.STATE, 'share_conn');
|
|
6241
|
+
const setShareConn = v => idbPut(RWA.STATE, v, 'share_conn');
|
|
6242
|
+
const clearShareConn = () => idbDel(RWA.STATE, 'share_conn');
|
|
6243
|
+
|
|
6244
|
+
class ShareError extends Error { constructor(code, msg) { super(msg || code); this.code = code; } }
|
|
6245
|
+
|
|
6246
|
+
async function shareFetch(path, { method = 'POST', token, body } = {}) {
|
|
6247
|
+
const headers = {};
|
|
6248
|
+
if (body != null) headers['Content-Type'] = 'text/html';
|
|
6249
|
+
if (token) headers.Authorization = 'Bearer ' + token;
|
|
6250
|
+
try {
|
|
6251
|
+
return await fetch(shareBaseUrl() + path, { method, headers, body });
|
|
6252
|
+
} catch (_) {
|
|
6253
|
+
throw new ShareError('share_unreachable', 'Sharing service unreachable — check your connection and try again.');
|
|
6254
|
+
}
|
|
6255
|
+
}
|
|
6256
|
+
|
|
6257
|
+
async function shareErrDetail(res) {
|
|
6258
|
+
try { return (await res.json()).error || ('http ' + res.status); } catch (_) { return 'http ' + res.status; }
|
|
6259
|
+
}
|
|
6260
|
+
|
|
6261
|
+
async function shareCreate() {
|
|
6262
|
+
const snap = await shareSnapshot();
|
|
6263
|
+
const res = await shareFetch('/share', { body: snap.text });
|
|
6264
|
+
if (res.status !== 201) throw new ShareError('share_failed', 'Could not create the share link (' + await shareErrDetail(res) + ').');
|
|
6265
|
+
const out = await res.json();
|
|
6266
|
+
const conn = { short: out.short, url: out.url, token: out.token, publishedHash: snap.hash, publishedAt: Date.now() };
|
|
6267
|
+
await setShareConn(conn);
|
|
6268
|
+
return conn;
|
|
6269
|
+
}
|
|
6270
|
+
|
|
6271
|
+
async function shareUpdate() {
|
|
6272
|
+
const conn = await getShareConn();
|
|
6273
|
+
if (!conn) throw new ShareError('share_not_connected', 'Not connected to a link yet.');
|
|
6274
|
+
const snap = await shareSnapshot();
|
|
6275
|
+
const res = await shareFetch('/share/' + conn.short, { token: conn.token, body: snap.text });
|
|
6276
|
+
if (res.status === 401 || res.status === 404 || res.status === 410) {
|
|
6277
|
+
// The capability is dead (unshared elsewhere, expired, or revoked). Keep
|
|
6278
|
+
// nothing stale around — the honest state is "not connected".
|
|
6279
|
+
await clearShareConn();
|
|
6280
|
+
throw new ShareError('share_connection_lost', 'This link can no longer be updated — create a new one.');
|
|
6281
|
+
}
|
|
6282
|
+
if (res.status !== 200) throw new ShareError('share_failed', 'Could not publish this version (' + await shareErrDetail(res) + ').');
|
|
6283
|
+
const next = { ...conn, publishedHash: snap.hash, publishedAt: Date.now() };
|
|
6284
|
+
await setShareConn(next);
|
|
6285
|
+
return next;
|
|
6286
|
+
}
|
|
6287
|
+
|
|
6288
|
+
async function shareUnshare() {
|
|
6289
|
+
const conn = await getShareConn();
|
|
6290
|
+
if (!conn) return;
|
|
6291
|
+
// A network failure throws BEFORE the clear — an unreachable service must
|
|
6292
|
+
// not silently orphan a live public link. 401/404 mean the share is already
|
|
6293
|
+
// gone server-side; clearing is the honest state either way.
|
|
6294
|
+
await shareFetch('/share/' + conn.short, { method: 'DELETE', token: conn.token });
|
|
6295
|
+
await clearShareConn();
|
|
6296
|
+
}
|
|
6297
|
+
|
|
6298
|
+
function shareRelTime(ts) {
|
|
6299
|
+
const m = Math.round((Date.now() - ts) / 60000);
|
|
6300
|
+
if (m < 1) return 'just now';
|
|
6301
|
+
if (m < 60) return m + ' min ago';
|
|
6302
|
+
const h = Math.round(m / 60);
|
|
6303
|
+
if (h < 24) return h + ' h ago';
|
|
6304
|
+
return Math.round(h / 24) + ' d ago';
|
|
6305
|
+
}
|
|
6306
|
+
|
|
6307
|
+
// One in-flight share action at a time; the panel re-renders disabled while
|
|
6308
|
+
// busy and re-enabled (with an error message, if any) after.
|
|
6309
|
+
let shareBusy = false;
|
|
6310
|
+
async function shareAction(fn) {
|
|
6311
|
+
if (shareBusy) return;
|
|
6312
|
+
shareBusy = true;
|
|
6313
|
+
try { await renderSharePanel(); } catch (_) {}
|
|
6314
|
+
let msg = null;
|
|
6315
|
+
try { await fn(); }
|
|
6316
|
+
catch (e) { msg = (e && e.message) || String(e); }
|
|
6317
|
+
shareBusy = false;
|
|
6318
|
+
await renderSharePanel(msg);
|
|
6319
|
+
}
|
|
6320
|
+
|
|
6321
|
+
function copyShareUrl(url) {
|
|
6322
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
6323
|
+
navigator.clipboard.writeText(url)
|
|
6324
|
+
.then(() => renderSharePanel('Link copied.'))
|
|
6325
|
+
.catch(() => {});
|
|
6326
|
+
}
|
|
6327
|
+
}
|
|
6328
|
+
|
|
6329
|
+
async function renderSharePanel(msg) {
|
|
6330
|
+
const panel = document.getElementById('rwa-share-panel');
|
|
6331
|
+
if (!panel) return;
|
|
6332
|
+
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
6333
|
+
const note = msg ? '<div class="rwa-share-msg">' + esc(msg) + '</div>' : '';
|
|
6334
|
+
const dis = shareBusy ? ' disabled' : '';
|
|
6335
|
+
const conn = await getShareConn();
|
|
6336
|
+
if (!conn) {
|
|
6337
|
+
panel.innerHTML = [
|
|
6338
|
+
'<h4>Share at a link</h4>',
|
|
6339
|
+
'<p>Anyone with the link sees the version you publish — not your live edits.</p>',
|
|
6340
|
+
note,
|
|
6341
|
+
'<div class="rwa-share-actions"><button type="button" id="rwa-share-create"' + dis + '>Create share link</button></div>',
|
|
6342
|
+
].join('');
|
|
6343
|
+
document.getElementById('rwa-share-create').onclick = () => shareAction(shareCreate);
|
|
6344
|
+
return;
|
|
6345
|
+
}
|
|
6346
|
+
const current = (await shareDocHashHex()) === conn.publishedHash;
|
|
6347
|
+
panel.innerHTML = [
|
|
6348
|
+
'<h4>Shared at a link</h4>',
|
|
6349
|
+
'<div class="rwa-share-url" id="rwa-share-url" title="click to copy">' + esc(conn.url) + '</div>',
|
|
6350
|
+
'<div class="rwa-share-fresh" id="rwa-share-fresh">Published ' + esc(shareRelTime(conn.publishedAt)) + ' · ' +
|
|
6351
|
+
(current ? 'the link shows this version' : 'behind your latest edits') + '</div>',
|
|
6352
|
+
note,
|
|
6353
|
+
'<div class="rwa-share-actions">',
|
|
6354
|
+
'<button type="button" id="rwa-share-update"' + dis + '>Publish this version</button>',
|
|
6355
|
+
'<button type="button" id="rwa-share-copy">Copy link</button>',
|
|
6356
|
+
'<button type="button" id="rwa-share-stop"' + dis + '>Stop sharing</button>',
|
|
6357
|
+
'</div>',
|
|
6358
|
+
].join('');
|
|
6359
|
+
document.getElementById('rwa-share-update').onclick = () => shareAction(shareUpdate);
|
|
6360
|
+
document.getElementById('rwa-share-stop').onclick = () => shareAction(shareUnshare);
|
|
6361
|
+
document.getElementById('rwa-share-copy').onclick = () => copyShareUrl(conn.url);
|
|
6362
|
+
document.getElementById('rwa-share-url').onclick = () => copyShareUrl(conn.url);
|
|
6363
|
+
}
|
|
6364
|
+
|
|
6157
6365
|
// ─── First-party 'presentation' view provider (spec §5.10) ──────────
|
|
6158
6366
|
// WRAP-IN-PLACE: split the doc at <h1>/<h2> boundaries into
|
|
6159
6367
|
// <section class="rwa-slide"> wrappers WITHOUT reordering. SECTION ∉
|
|
@@ -6395,7 +6603,7 @@ async function modify(instr, lensMeta = null, opts = null) {
|
|
|
6395
6603
|
try {
|
|
6396
6604
|
data = await openAiCompatChat(cfg, {
|
|
6397
6605
|
model,
|
|
6398
|
-
max_tokens: 32000,
|
|
6606
|
+
max_tokens: cfg.maxTokens || 32000,
|
|
6399
6607
|
messages,
|
|
6400
6608
|
tools: TOOL_SCHEMAS,
|
|
6401
6609
|
tool_choice: 'auto',
|
package/src/agent-loop.mjs
CHANGED
|
@@ -128,12 +128,14 @@ export async function runAgentLoop({
|
|
|
128
128
|
throw new AgentError('no_envelope_after_retries', { retries: RETRY_BUDGET });
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
async function callBackend({ baseUrl, model, apiKey }, body) {
|
|
131
|
+
async function callBackend({ baseUrl, model, apiKey, maxTokens }, body) {
|
|
132
132
|
const headers = { 'Content-Type': 'application/json' };
|
|
133
133
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
|
134
134
|
const url = baseUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
135
135
|
// Seed parity (seeds/rewritable.html openAiCompatChat caller in modify()):
|
|
136
|
-
// every request carries max_tokens
|
|
136
|
+
// every request carries the backend's max_tokens (32000 historically; 8192
|
|
137
|
+
// for atomic, whose server REJECTS prompt+generation past MAX_KV_SIZE rather
|
|
138
|
+
// than clamping — see backendMaxTokens) and tool_choice: 'auto'. The
|
|
137
139
|
// tool_choice default forces the model to call one of the provided tools
|
|
138
140
|
// rather than emitting plain text (which would trip our no_tool_call retry).
|
|
139
141
|
const res = await fetch(url, {
|
|
@@ -141,7 +143,7 @@ async function callBackend({ baseUrl, model, apiKey }, body) {
|
|
|
141
143
|
headers,
|
|
142
144
|
body: JSON.stringify({
|
|
143
145
|
model,
|
|
144
|
-
max_tokens: 32000,
|
|
146
|
+
max_tokens: maxTokens || 32000,
|
|
145
147
|
tool_choice: 'auto',
|
|
146
148
|
...body,
|
|
147
149
|
}),
|
package/src/backend.mjs
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
// precedence is unit-testable (the bin entrypoint runs on import and can't be
|
|
3
3
|
// imported cleanly).
|
|
4
4
|
//
|
|
5
|
-
// Only the openrouter backend needs a key — ollama and
|
|
6
|
-
// without auth. The key resolves in order: an explicit --api-key flag, then the
|
|
5
|
+
// Only the openrouter backend needs a key — ollama, lmstudio, and atomic run
|
|
6
|
+
// locally without auth. The key resolves in order: an explicit --api-key flag, then the
|
|
7
7
|
// project-specific RWA_OPENROUTER_KEY (env conventions match the docker-compose
|
|
8
8
|
// deploy in service/), then the CONVENTIONAL OPENROUTER_API_KEY that agents and
|
|
9
9
|
// users normally have exported. Empty strings count as absent.
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Resolve the API key for a backend.
|
|
13
|
-
* @param {string} backendName — 'openrouter' | 'ollama' | 'lmstudio'
|
|
13
|
+
* @param {string} backendName — 'openrouter' | 'ollama' | 'lmstudio' | 'atomic'
|
|
14
14
|
* @param {string|undefined} flagValue — the --api-key flag value, if any
|
|
15
15
|
* @param {Record<string,string|undefined>} [env] — environment (injectable for tests)
|
|
16
16
|
* @returns {string|undefined} the key, or undefined when none applies
|
|
@@ -26,10 +26,10 @@ export function resolveApiKey(backendName, flagValue, env = process.env) {
|
|
|
26
26
|
/**
|
|
27
27
|
* Default OpenAI-compatible base URL for a backend — mirrors the inline
|
|
28
28
|
* `envBaseUrl` in bin/rwa.mjs (and seeds/rewritable.html resolveBackendConfig).
|
|
29
|
-
* ollama and
|
|
30
|
-
* openrouter is fixed (the URL has never drifted in the seed). Shared by `rwa edit`
|
|
29
|
+
* ollama, lmstudio, and atomic honor RWA_*_URL overrides (remote host / non-standard
|
|
30
|
+
* port); openrouter is fixed (the URL has never drifted in the seed). Shared by `rwa edit`
|
|
31
31
|
* and `rwa create` so the default never diverges between the two.
|
|
32
|
-
* @param {string} name — 'openrouter' | 'ollama' | 'lmstudio'
|
|
32
|
+
* @param {string} name — 'openrouter' | 'ollama' | 'lmstudio' | 'atomic'
|
|
33
33
|
* @param {Record<string,string|undefined>} [env] — environment (injectable for tests)
|
|
34
34
|
* @returns {string|undefined}
|
|
35
35
|
*/
|
|
@@ -38,6 +38,24 @@ export function envBaseUrl(name, env = process.env) {
|
|
|
38
38
|
case 'openrouter': return 'https://openrouter.ai/api/v1';
|
|
39
39
|
case 'ollama': return env.RWA_OLLAMA_URL || 'http://localhost:11434/v1';
|
|
40
40
|
case 'lmstudio': return env.RWA_LMSTUDIO_URL || 'http://localhost:1234/v1';
|
|
41
|
+
case 'atomic': return env.RWA_ATOMIC_URL || 'http://127.0.0.1:1337/v1';
|
|
41
42
|
default: return undefined;
|
|
42
43
|
}
|
|
43
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Per-backend max_tokens for the agent loop. The historical 32000 stands for
|
|
48
|
+
* hosted/clamping backends, but atomic.chat REJECTS (400) any request whose
|
|
49
|
+
* prompt + max generation exceeds its MAX_KV_SIZE (16384 by default) rather
|
|
50
|
+
* than clamping — so it gets 8192, leaving the other half of the window for
|
|
51
|
+
* the system prompt + document. RWA_MAX_TOKENS overrides for unusual servers.
|
|
52
|
+
* Mirrors the seed's resolveBackendConfig() maxTokens.
|
|
53
|
+
* @param {string} name — backend name
|
|
54
|
+
* @param {Record<string,string|undefined>} [env]
|
|
55
|
+
* @returns {number}
|
|
56
|
+
*/
|
|
57
|
+
export function backendMaxTokens(name, env = process.env) {
|
|
58
|
+
const override = Number(env.RWA_MAX_TOKENS);
|
|
59
|
+
if (Number.isFinite(override) && override > 0) return override;
|
|
60
|
+
return name === 'atomic' ? 8192 : 32000;
|
|
61
|
+
}
|
package/src/commands.mjs
CHANGED
|
@@ -85,7 +85,7 @@ async function readEnvKey(name) {
|
|
|
85
85
|
// than throwing — pre-fill is best-effort; an unknown value just means the
|
|
86
86
|
// user sees the default backend (openrouter) on first paint.
|
|
87
87
|
function validBackend(v) {
|
|
88
|
-
return ['openrouter', 'ollama', 'lmstudio', 'bridge'].includes(v) ? v : null;
|
|
88
|
+
return ['openrouter', 'ollama', 'lmstudio', 'atomic', 'bridge'].includes(v) ? v : null;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
// Collect URL-param pre-fills from env / ./.env. Returns an object whose keys
|
package/src/create.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { runAgentLoop } from './agent-loop.mjs';
|
|
|
20
20
|
import { applyPlan, CliError } from './edit.mjs';
|
|
21
21
|
import { assertSelfContained } from './self-contained.mjs';
|
|
22
22
|
import { findFrozenZones } from './apply-edits.mjs';
|
|
23
|
-
import { resolveApiKey, envBaseUrl } from './backend.mjs';
|
|
23
|
+
import { resolveApiKey, envBaseUrl, backendMaxTokens } from './backend.mjs';
|
|
24
24
|
import { atomicWrite } from './atomic-write.mjs';
|
|
25
25
|
|
|
26
26
|
// Hard cap on --data baked into the snapshot. The dataset lands inside INLINE_DOC
|
|
@@ -219,6 +219,7 @@ export async function createCmd(parsed, { seedCandidates, cwd = process.cwd(), s
|
|
|
219
219
|
baseUrl: parsed.backend.baseUrl || envBaseUrl(backendName),
|
|
220
220
|
model: parsed.backend.model || process.env.RWA_MODEL || 'google/gemini-3.5-flash',
|
|
221
221
|
apiKey: resolveApiKey(backendName, parsed.backend.apiKey),
|
|
222
|
+
maxTokens: backendMaxTokens(backendName),
|
|
222
223
|
};
|
|
223
224
|
|
|
224
225
|
// Per-kind system prompt + the create-only self-containment directive; the brief
|