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 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 to
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rewritable",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "CLI for re-writeable: emit and import single-file rwa documents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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',
@@ -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: 32000 and tool_choice: 'auto'. The
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 lmstudio run locally
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 lmstudio honor RWA_*_URL overrides (remote host / non-standard port);
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