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.
- package/README.md +24 -4
- package/bin/rwa.mjs +56 -11
- package/package.json +1 -1
- package/seeds/rewritable.html +885 -48
- 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/src/identity.mjs +1 -0
- package/src/seed.mjs +84 -1
- package/src/workspace.mjs +262 -0
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
|
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
|
|
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, '&')
|
|
33
|
+
.replace(/</g, '<')
|
|
34
|
+
.replace(/>/g, '>')
|
|
35
|
+
.replace(/"/g, '"');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function escapeAttr(s) {
|
|
39
|
+
return escapeHtml(s).replace(/'/g, ''');
|
|
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
|
+
}
|