rewritable 0.6.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.6.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": {
@@ -380,6 +380,13 @@ const RWA = {
380
380
  // not block these requests even when the container is served over HTTPS.
381
381
  DEFAULT_OLLAMA_URL:'http://localhost:11434/v1',
382
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',
383
390
  // Optional alternative agent backend: a localhost CLI bridge
384
391
  // (https://github.com/martintreiber/web_cli_bridge style) that runs
385
392
  // arbitrary shell commands. When the user picks "bridge" in settings, ⌘K
@@ -411,7 +418,7 @@ try {
411
418
  qs.delete('key');
412
419
  }
413
420
  const b = qs.get('backend');
414
- if (b && ['openrouter','ollama','lmstudio','bridge','bridge-session'].includes(b)) {
421
+ if (b && ['openrouter','ollama','lmstudio','atomic','bridge','bridge-session'].includes(b)) {
415
422
  sessionStorage.setItem(RWA.K_BACKEND, b);
416
423
  qs.delete('backend');
417
424
  }
@@ -1117,7 +1124,7 @@ function buildUI() {
1117
1124
  <button class="rwa-st-btn pri" id="rwa-st-commit">⌘S</button>
1118
1125
  </div>
1119
1126
  <div id="rwa-set-panel">
1120
- <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>
1121
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>
1122
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>
1123
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>
@@ -1198,6 +1205,12 @@ function buildUI() {
1198
1205
  defaultUrl: RWA.DEFAULT_LMSTUDIO_URL,
1199
1206
  storageKey: RWA.K_BASE_URL_LMSTUDIO,
1200
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
+ },
1201
1214
  bridge: {
1202
1215
  showKey: false, showBaseUrl: false, showModel: false, showHint: false,
1203
1216
  hintHTML: '',
@@ -4321,6 +4334,18 @@ function resolveBackendConfig() {
4321
4334
  apiKey: null, extraHeaders: {}, requiresKey: false,
4322
4335
  };
4323
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
+ }
4324
4349
  if (backend === 'bridge') {
4325
4350
  return { backend, kind:'bridge' };
4326
4351
  }
@@ -4379,7 +4404,7 @@ async function callAgentSingleShot(prompt) {
4379
4404
  const model = sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
4380
4405
  const data = await openAiCompatChat(cfg, {
4381
4406
  model,
4382
- max_tokens: 32000,
4407
+ max_tokens: cfg.maxTokens || 32000,
4383
4408
  messages: [{ role:'user', content: prompt }],
4384
4409
  });
4385
4410
  const msg = data.choices?.[0]?.message;
@@ -6578,7 +6603,7 @@ async function modify(instr, lensMeta = null, opts = null) {
6578
6603
  try {
6579
6604
  data = await openAiCompatChat(cfg, {
6580
6605
  model,
6581
- max_tokens: 32000,
6606
+ max_tokens: cfg.maxTokens || 32000,
6582
6607
  messages,
6583
6608
  tools: TOOL_SCHEMAS,
6584
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