rewritable 0.6.0 → 0.8.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.
@@ -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
package/src/identity.mjs CHANGED
@@ -35,6 +35,7 @@ export const KIND_PROVIDERS = {
35
35
  workflow: [],
36
36
  // skill-host: no first-party affordances; installed skills (provenance:'installed')
37
37
  // come from parseSkillZone (§8), not this table. Explicit [] mirrors the oracle.
38
+ workspace: [],
38
39
  'skill-host': [],
39
40
  };
40
41
 
package/src/seed.mjs CHANGED
@@ -1316,6 +1316,82 @@ const KIND_PRESENTATION_BODY = `<article>
1316
1316
  <p>The view is a pure re-presentation at render time; the bytes on disk never change shape.</p>
1317
1317
  </article>`;
1318
1318
 
1319
+ // ── workspace (directory index / control center) ─────────────────────────────
1320
+ const KIND_WORKSPACE_LENS = 'Describe a change to this workspace index.';
1321
+ const KIND_WORKSPACE_PAL = 'edit this workspace index...';
1322
+
1323
+ const KIND_WORKSPACE_HEADER = `// === PRODUCT HEADER ===
1324
+ // Product: workspace (directory control center).
1325
+ //
1326
+ // A workspace index is a normal re-writeable file named rwa-index.html that
1327
+ // summarizes sibling rewritables in the same directory. The editable article is
1328
+ // a dashboard; the frozen #rwa-workspace JSON manifest is regenerated by the CLI
1329
+ // (\`rwa workspace create|sync\`) from the files on disk. The index coordinates a
1330
+ // folder; it does not merge documents, expand the skill-host runtime, or persist
1331
+ // runtime chrome into any child document.
1332
+ // === END PRODUCT HEADER ===`;
1333
+
1334
+ const KIND_WORKSPACE_BODY = `<!-- rwa:frozen:begin workspace-style -->
1335
+ <style>
1336
+ .rwa-workspace{max-width:1040px;margin:0 auto;padding:32px 24px 72px;}
1337
+ .rwa-workspace header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:24px;border-bottom:1px solid var(--gray-200,#e5e7eb);padding-bottom:18px;}
1338
+ .rwa-workspace h1{margin:0;font-size:2rem;line-height:1.1;}
1339
+ .rwa-workspace .rwa-ws-meta{margin:0;color:var(--gray-500,#6b7280);font-size:13px;}
1340
+ .rwa-ws-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;}
1341
+ .rwa-ws-card{display:flex;flex-direction:column;gap:7px;padding:14px 16px;border:1px solid var(--gray-200,#e5e7eb);border-radius:8px;text-decoration:none;color:inherit;background:var(--gray-50,#f9fafb);}
1342
+ .rwa-ws-card:hover{border-color:var(--gray-400,#9ca3af);background:#fff;}
1343
+ .rwa-ws-card strong{font-size:16px;line-height:1.25;}
1344
+ .rwa-ws-card span{font-size:13px;color:var(--gray-600,#4b5563);overflow-wrap:anywhere;}
1345
+ .rwa-ws-card small{font-size:12px;color:var(--gray-500,#6b7280);}
1346
+ .rwa-ws-kind{align-self:flex-start;text-transform:uppercase;letter-spacing:.04em;font-size:10px!important;color:#fff!important;background:var(--gray-800,#1f2937);border-radius:4px;padding:2px 6px;}
1347
+ .rwa-ws-empty{color:var(--gray-500,#6b7280);line-height:1.5;}
1348
+ .rwa-ws-context{display:grid;grid-template-columns:minmax(0,1fr);gap:10px;margin:20px 0 28px;}
1349
+ .rwa-ws-context h2{margin:18px 0 0;font-size:1.1rem;}
1350
+ .rwa-ws-context h2:first-child{margin-top:0;}
1351
+ .rwa-ws-context p,.rwa-ws-context ul,.rwa-ws-context ol{margin:0;line-height:1.55;}
1352
+ .rwa-ws-live{margin-top:28px;padding-top:18px;border-top:1px solid var(--gray-200,#e5e7eb);}
1353
+ .rwa-ws-live h2{margin:0 0 12px;font-size:1rem;}
1354
+ .rwa-ws-live-card{background:#fff;border-color:var(--blue,#2563eb);}
1355
+ </style>
1356
+ <!-- rwa:frozen:end workspace-style -->
1357
+ <article class="rwa-workspace">
1358
+ <header>
1359
+ <div>
1360
+ <h1>Workspace</h1>
1361
+ <p class="rwa-ws-meta">0 documents · run <code>rwa workspace sync</code> in this folder</p>
1362
+ </div>
1363
+ </header>
1364
+ <section class="rwa-ws-context" data-rwa-workspace-context>
1365
+ <h2>Workspace memory</h2>
1366
+ <p>Use this space for durable notes that every document in this workspace should be able to rely on.</p>
1367
+
1368
+ <h2>Guidelines</h2>
1369
+ <ul>
1370
+ <li>Describe the shared tone, standards, constraints, and recurring decisions for this workspace.</li>
1371
+ <li>For a writing workspace, add voice, structure, audience, and publishing rules here.</li>
1372
+ </ul>
1373
+
1374
+ <h2>Examples</h2>
1375
+ <p>Add canonical examples that new documents can imitate, such as a representative blog post, proposal, report, or brief.</p>
1376
+
1377
+ <h2>Open questions</h2>
1378
+ <ul>
1379
+ <li>Track unresolved decisions that should shape future documents.</li>
1380
+ </ul>
1381
+ </section>
1382
+ <h2>Workspace documents</h2>
1383
+ <section class="rwa-ws-grid" aria-label="Workspace documents">
1384
+ <p class="rwa-ws-empty">No sibling rewritables yet. Add documents to this folder, then run <code>rwa workspace sync</code>.</p>
1385
+ </section>
1386
+ <section class="rwa-ws-live" data-rwa-workspace-live hidden>
1387
+ <h2>Open now</h2>
1388
+ <div class="rwa-ws-grid" data-rwa-workspace-live-grid></div>
1389
+ </section>
1390
+ </article>
1391
+ <!-- rwa:frozen:begin workspace-manifest -->
1392
+ <script type="application/rwa-workspace+json" id="rwa-workspace" data-rwa-frozen>{"version":"rwa-workspace/1","name":"Workspace","documents":[]}</script>
1393
+ <!-- rwa:frozen:end workspace-manifest -->`;
1394
+
1319
1395
  // ── skill-host (v0.8 actions spec §2) ──────────────────────────────────────
1320
1396
  const KIND_SKILLHOST_LENS = 'Describe a change to this skill host.';
1321
1397
  const KIND_SKILLHOST_PAL = 'edit this skill host...';
@@ -1368,6 +1444,13 @@ const KIND_TABLE = {
1368
1444
  // (spec §5.10); this kind only sets PRODUCT_KIND + starter/framing/lens.
1369
1445
  lensClickToAnchor: false,
1370
1446
  },
1447
+ workspace: {
1448
+ body: KIND_WORKSPACE_BODY,
1449
+ lensPlaceholder: KIND_WORKSPACE_LENS,
1450
+ palPlaceholder: KIND_WORKSPACE_PAL,
1451
+ productHeader: KIND_WORKSPACE_HEADER,
1452
+ lensClickToAnchor: false,
1453
+ },
1371
1454
  'skill-host': {
1372
1455
  body: KIND_SKILLHOST_BODY,
1373
1456
  lensPlaceholder: KIND_SKILLHOST_LENS,
@@ -1377,7 +1460,7 @@ const KIND_TABLE = {
1377
1460
  // in the runtime-owned frozen zone, not authored by clicking a paragraph.
1378
1461
  lensClickToAnchor: false,
1379
1462
  },
1380
- // app, workspace: reserved — wire when the templates land. The CLI rejects
1463
+ // app: reserved — wire when the template lands. The CLI rejects
1381
1464
  // unknown kinds explicitly rather than silently emitting a document.
1382
1465
  };
1383
1466
 
@@ -0,0 +1,262 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ import { loadSeed, applySeedSubs, replaceInlineDoc, extractInlineDoc, kindOverrides } from './seed.mjs';
6
+ import { inspectDoc } from './doc.mjs';
7
+
8
+ export const WORKSPACE_INDEX = 'rwa-index.html';
9
+ const UUID_RE = /const DOC_UUID = '([0-9a-f-]{36})';/;
10
+
11
+ function titleFromDir(dir) {
12
+ const base = path.basename(path.resolve(dir)) || 'Workspace';
13
+ return base
14
+ .replace(/[-_]+/g, ' ')
15
+ .split(' ')
16
+ .filter(Boolean)
17
+ .map(w => w[0].toUpperCase() + w.slice(1))
18
+ .join(' ') || 'Workspace';
19
+ }
20
+
21
+ function titleFromFile(file) {
22
+ return path.basename(file, path.extname(file))
23
+ .replace(/[-_]+/g, ' ')
24
+ .split(' ')
25
+ .filter(Boolean)
26
+ .map(w => w[0].toUpperCase() + w.slice(1))
27
+ .join(' ') || file;
28
+ }
29
+
30
+ function escapeHtml(s) {
31
+ return String(s == null ? '' : s)
32
+ .replace(/&/g, '&amp;')
33
+ .replace(/</g, '&lt;')
34
+ .replace(/>/g, '&gt;')
35
+ .replace(/"/g, '&quot;');
36
+ }
37
+
38
+ function escapeAttr(s) {
39
+ return escapeHtml(s).replace(/'/g, '&#39;');
40
+ }
41
+
42
+ function safeScriptJson(obj) {
43
+ return JSON.stringify(obj, null, 2).replace(/<\/script/gi, '<\\/script');
44
+ }
45
+
46
+ function defaultWorkspaceContext(name) {
47
+ return `<section class="rwa-ws-context" data-rwa-workspace-context>
48
+ <h2>Workspace memory</h2>
49
+ <p>Use this space for durable notes that every document in this workspace should be able to rely on.</p>
50
+
51
+ <h2>Guidelines</h2>
52
+ <ul>
53
+ <li>Describe the shared tone, standards, constraints, and recurring decisions for this workspace.</li>
54
+ <li>For a writing workspace, add voice, structure, audience, and publishing rules here.</li>
55
+ </ul>
56
+
57
+ <h2>Examples</h2>
58
+ <p>Add canonical examples that new documents can imitate, such as a representative blog post, proposal, report, or brief.</p>
59
+
60
+ <h2>Open questions</h2>
61
+ <ul>
62
+ <li>Track unresolved decisions that should shape future documents.</li>
63
+ </ul>
64
+ </section>`;
65
+ }
66
+
67
+ function extractWorkspaceContext(doc, name) {
68
+ const m = String(doc || '').match(/<section\b[^>]*\bdata-rwa-workspace-context\b[^>]*>[\s\S]*?<\/section>/i);
69
+ return m ? m[0] : defaultWorkspaceContext(name);
70
+ }
71
+
72
+ async function indexUuid(indexPath) {
73
+ try {
74
+ const text = await fs.readFile(indexPath, 'utf8');
75
+ return (text.match(UUID_RE) || [])[1] || crypto.randomUUID();
76
+ } catch (e) {
77
+ if (e && e.code === 'ENOENT') return crypto.randomUUID();
78
+ throw e;
79
+ }
80
+ }
81
+
82
+ async function ensureCreateTarget(dir, indexPath, force) {
83
+ try {
84
+ const st = await fs.stat(dir);
85
+ if (!st.isDirectory()) {
86
+ const e = new Error(`workspace target is not a directory: ${dir}`);
87
+ e.exitCode = 2;
88
+ throw e;
89
+ }
90
+ } catch (e) {
91
+ if (e && e.code === 'ENOENT') {
92
+ await fs.mkdir(dir, { recursive: true });
93
+ } else {
94
+ throw e;
95
+ }
96
+ }
97
+
98
+ try {
99
+ await fs.stat(indexPath);
100
+ if (!force) {
101
+ const e = new Error(`workspace index exists: ${indexPath} (use --force to overwrite)`);
102
+ e.exitCode = 2;
103
+ throw e;
104
+ }
105
+ } catch (e) {
106
+ if (e && e.code === 'ENOENT') return;
107
+ throw e;
108
+ }
109
+ }
110
+
111
+ async function ensureSyncTarget(dir) {
112
+ const st = await fs.stat(dir).catch(e => {
113
+ if (e && e.code === 'ENOENT') {
114
+ const err = new Error(`workspace directory not found: ${dir}`);
115
+ err.exitCode = 2;
116
+ throw err;
117
+ }
118
+ throw e;
119
+ });
120
+ if (!st.isDirectory()) {
121
+ const e = new Error(`workspace target is not a directory: ${dir}`);
122
+ e.exitCode = 2;
123
+ throw e;
124
+ }
125
+ }
126
+
127
+ export async function scanWorkspace(dir) {
128
+ const names = (await fs.readdir(dir))
129
+ .filter(n => /\.html?$/i.test(n))
130
+ .filter(n => n !== WORKSPACE_INDEX)
131
+ .sort((a, b) => a.localeCompare(b));
132
+ const docs = [];
133
+ for (const name of names) {
134
+ const filePath = path.join(dir, name);
135
+ try {
136
+ const info = await inspectDoc(filePath);
137
+ docs.push({
138
+ file: name,
139
+ title: info.self.title || titleFromFile(name),
140
+ kind: info.self.kind || info.kind || 'document',
141
+ uuid: info.uuid,
142
+ blocks: info.self.blocks || 0,
143
+ affordances: Array.isArray(info.self.affordances)
144
+ ? info.self.affordances.map(a => ({ kind: a.kind, name: a.name, label: a.label, provenance: a.provenance }))
145
+ : [],
146
+ });
147
+ } catch (e) {
148
+ if (e && e.subcode === 'not_a_rewritable') continue;
149
+ throw e;
150
+ }
151
+ }
152
+ return docs;
153
+ }
154
+
155
+ export function buildWorkspaceBody({ name, docs, generatedAt = new Date().toISOString(), contextHtml }) {
156
+ const manifest = {
157
+ version: 'rwa-workspace/1',
158
+ name,
159
+ generatedAt,
160
+ index: WORKSPACE_INDEX,
161
+ documents: docs,
162
+ };
163
+ const cards = docs.length
164
+ ? docs.map(doc => {
165
+ const kinds = doc.affordances && doc.affordances.length
166
+ ? doc.affordances.map(a => a.kind).join(', ')
167
+ : 'baseline';
168
+ const href = './' + encodeURI(doc.file).replace(/"/g, '%22');
169
+ return `<a class="rwa-ws-card" href="${escapeAttr(href)}">
170
+ <span class="rwa-ws-kind">${escapeHtml(doc.kind)}</span>
171
+ <strong>${escapeHtml(doc.title)}</strong>
172
+ <span>${escapeHtml(doc.file)}</span>
173
+ <small>${escapeHtml(String(doc.blocks))} blocks · ${escapeHtml(kinds)}</small>
174
+ </a>`;
175
+ }).join('\n')
176
+ : '<p class="rwa-ws-empty">No sibling rewritables yet. Add documents to this folder, then run <code>rwa workspace sync</code>.</p>';
177
+
178
+ return `<!-- rwa:frozen:begin workspace-style -->
179
+ <style>
180
+ .rwa-workspace{max-width:1040px;margin:0 auto;padding:32px 24px 72px;}
181
+ .rwa-workspace header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:24px;border-bottom:1px solid var(--gray-200,#e5e7eb);padding-bottom:18px;}
182
+ .rwa-workspace h1{margin:0;font-size:2rem;line-height:1.1;}
183
+ .rwa-workspace .rwa-ws-meta{margin:0;color:var(--gray-500,#6b7280);font-size:13px;}
184
+ .rwa-ws-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;}
185
+ .rwa-ws-card{display:flex;flex-direction:column;gap:7px;padding:14px 16px;border:1px solid var(--gray-200,#e5e7eb);border-radius:8px;text-decoration:none;color:inherit;background:var(--gray-50,#f9fafb);}
186
+ .rwa-ws-card:hover{border-color:var(--gray-400,#9ca3af);background:#fff;}
187
+ .rwa-ws-card strong{font-size:16px;line-height:1.25;}
188
+ .rwa-ws-card span{font-size:13px;color:var(--gray-600,#4b5563);overflow-wrap:anywhere;}
189
+ .rwa-ws-card small{font-size:12px;color:var(--gray-500,#6b7280);}
190
+ .rwa-ws-kind{align-self:flex-start;text-transform:uppercase;letter-spacing:.04em;font-size:10px!important;color:#fff!important;background:var(--gray-800,#1f2937);border-radius:4px;padding:2px 6px;}
191
+ .rwa-ws-empty{color:var(--gray-500,#6b7280);line-height:1.5;}
192
+ .rwa-ws-context{display:grid;grid-template-columns:minmax(0,1fr);gap:10px;margin:20px 0 28px;}
193
+ .rwa-ws-context h2{margin:18px 0 0;font-size:1.1rem;}
194
+ .rwa-ws-context h2:first-child{margin-top:0;}
195
+ .rwa-ws-context p,.rwa-ws-context ul,.rwa-ws-context ol{margin:0;line-height:1.55;}
196
+ .rwa-ws-live{margin-top:28px;padding-top:18px;border-top:1px solid var(--gray-200,#e5e7eb);}
197
+ .rwa-ws-live h2{margin:0 0 12px;font-size:1rem;}
198
+ .rwa-ws-live-card{background:#fff;border-color:var(--blue,#2563eb);}
199
+ </style>
200
+ <!-- rwa:frozen:end workspace-style -->
201
+ <article class="rwa-workspace">
202
+ <header>
203
+ <div>
204
+ <h1>${escapeHtml(name)}</h1>
205
+ <p class="rwa-ws-meta">${docs.length} document${docs.length === 1 ? '' : 's'} · synced ${escapeHtml(generatedAt)}</p>
206
+ </div>
207
+ </header>
208
+ ${contextHtml || defaultWorkspaceContext(name)}
209
+ <h2>Workspace documents</h2>
210
+ <section class="rwa-ws-grid" aria-label="Workspace documents">
211
+ ${cards}
212
+ </section>
213
+ <section class="rwa-ws-live" data-rwa-workspace-live hidden>
214
+ <h2>Open now</h2>
215
+ <div class="rwa-ws-grid" data-rwa-workspace-live-grid></div>
216
+ </section>
217
+ </article>
218
+ <!-- rwa:frozen:begin workspace-manifest -->
219
+ <script type="application/rwa-workspace+json" id="rwa-workspace" data-rwa-frozen>${safeScriptJson(manifest)}</script>
220
+ <!-- rwa:frozen:end workspace-manifest -->`;
221
+ }
222
+
223
+ async function writeWorkspaceIndex(dir, { seedCandidates, uuid }) {
224
+ const indexPath = path.join(dir, WORKSPACE_INDEX);
225
+ const docs = await scanWorkspace(dir);
226
+ const name = titleFromDir(dir);
227
+ let contextHtml = defaultWorkspaceContext(name);
228
+ try {
229
+ const existing = await fs.readFile(indexPath, 'utf8');
230
+ contextHtml = extractWorkspaceContext(extractInlineDoc(existing), name);
231
+ } catch (e) {
232
+ if (!(e && e.code === 'ENOENT')) contextHtml = defaultWorkspaceContext(name);
233
+ }
234
+ const overrides = kindOverrides('workspace');
235
+ let html = applySeedSubs(await loadSeed(seedCandidates), {
236
+ uuid,
237
+ title: name,
238
+ fileMeta: WORKSPACE_INDEX,
239
+ lensPlaceholder: overrides.lensPlaceholder,
240
+ palPlaceholder: overrides.palPlaceholder,
241
+ productHeader: overrides.productHeader,
242
+ productKind: 'workspace',
243
+ lensClickToAnchor: overrides.lensClickToAnchor,
244
+ });
245
+ html = replaceInlineDoc(html, buildWorkspaceBody({ name, docs, contextHtml }));
246
+ await fs.writeFile(indexPath, html, 'utf8');
247
+ return { indexPath, docs };
248
+ }
249
+
250
+ export async function workspaceCreateCmd({ dirPath = '.', force = false, seedCandidates }) {
251
+ const dir = path.resolve(dirPath);
252
+ const indexPath = path.join(dir, WORKSPACE_INDEX);
253
+ await ensureCreateTarget(dir, indexPath, force);
254
+ return writeWorkspaceIndex(dir, { seedCandidates, uuid: await indexUuid(indexPath) });
255
+ }
256
+
257
+ export async function workspaceSyncCmd({ dirPath = '.', seedCandidates }) {
258
+ const dir = path.resolve(dirPath);
259
+ await ensureSyncTarget(dir);
260
+ const indexPath = path.join(dir, WORKSPACE_INDEX);
261
+ return writeWorkspaceIndex(dir, { seedCandidates, uuid: await indexUuid(indexPath) });
262
+ }