rewritable 0.14.0 → 0.16.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
@@ -79,6 +79,8 @@ Embeds the input file's content as the document's initial state. Supported forma
79
79
 
80
80
  Output defaults to `<input-basename>.html` in the input's directory. Conversion is deterministic and offline — no API key, no network.
81
81
 
82
+ **Import fidelity loop (PDF).** After a PDF import, `rwa` runs an offline **structural fidelity check** (text-coverage + extraction-quality). If it's low *and* a model is reachable (`RWA_OPENROUTER_KEY` set), it **auto-escalates** to `--vision` and keeps the higher-fidelity result — announced on stderr. **Offline-first is preserved:** with no key, a low-fidelity import stays offline, succeeds, and just warns (suggesting `--vision`/`--claude`). Controls: `--no-escalate` (disable the loop), `--target-fidelity <0..1>` (set the bar, default 0.85). *(The deterministic geometry import is unchanged; the loop only measures and, when authorized, escalates. The full picture-level visual judge is a browser-side follow-up.)*
83
+
82
84
  ### `rwa clone <url> [path]`
83
85
 
84
86
  Clone a public webpage into a self-contained rewritable: fetch the page, extract its main article and title, and bake the content into a fresh container. First-class for **WordPress / ikangai posts** — a blog post becomes an editable, shareable single-file `.html` you can rewrite with `⌘K`.
@@ -261,6 +263,19 @@ rwa host notes.html --url https://host.example --json # {"id":"…","token":"
261
263
 
262
264
  This command is **network-bearing** (like `rwa clone` / `rwa publish-site`), so the offline-first rule does not apply to it.
263
265
 
266
+ ### `rwa intelligence new <role>`
267
+
268
+ Mint a **droppable intelligence** — a signed `rwa-agent/1` role packaged as a *carrier* rewritable you can drop onto another rewritable to retune its ⌘K editor (intelligence/0.2). It generates a fresh Ed25519 keypair, signs the role, and scaffolds a self-describing `skill-host` carrier. The **private key** is written to a sibling `<name>.key.json`, kept out of the carrier — keep it to publish updates under the same author identity.
269
+
270
+ ```
271
+ rwa intelligence new concise \
272
+ --prompt "Tighten prose: shorter sentences, fewer hedges, meaning preserved." \
273
+ --model anthropic/claude-sonnet-4-6 --backend openrouter \
274
+ --affinity document,presentation
275
+ ```
276
+
277
+ Flags: `--prompt` (required — the role's system prompt), `--description`, `--model` / `--backend` (a *recommended* model offered on activation behind consent — never auto-applied, never carries your key), `--affinity` (comma-separated document kinds; advisory — a mismatch only warns), `--vault` (comma-separated namespaces the role may reach), `--out <path>`, `--force`. Offline; the carrier holds only the public key + signature.
278
+
264
279
  ### `rwa skin <path> <name>`
265
280
 
266
281
  Pick a **named look** for a rewritable instead of hand-styling it from the blank lens. A skin is one self-contained `<style data-rwa-skin="NAME">` block — system fonts only, no web fonts or remote assets — that the command splices into the **document body**. So it commits with the document, ships inside the exported `.html`, survives sharing, and one in-browser undo (`⌘Z`) reverts it. Five presets ship today: `notion-clean`, `linear-dark`, `editorial-serif`, `stripe-docs`, `terminal-mono` (clean · dark · editorial · docs · terminal).
package/bin/rwa.mjs CHANGED
@@ -728,6 +728,36 @@ function detectProductKind(fileText) {
728
728
  // `rwa skill publish <file.rwa-skill.json> [--url base] [--json]` — publish a SIGNED skill
729
729
  // envelope to the marketplace index (POST /skills/publish, I6 §11). The envelope is already
730
730
  // signed (no key needed). Online by design; exit 4 labeled `publish_error` (like `publish`).
731
+ // `rwa intelligence new <role> --prompt "..." [--description ..] [--model id] [--backend name]
732
+ // [--affinity kind,kind] [--vault ns,ns] [--out file] [--force]` — I-C (intelligence/0.2 §6):
733
+ // mint a signed rwa-agent/1 role and scaffold a carrier rewritable (private key → sibling file).
734
+ if (verb === 'intelligence') {
735
+ const sub = rest[0];
736
+ if (sub !== 'new') {
737
+ process.stderr.write("rwa intelligence: unknown subcommand '" + (sub || '') + "' (try: rwa intelligence new <role> --prompt \"...\")\n");
738
+ process.exitCode = 1;
739
+ return;
740
+ }
741
+ const subRest = rest.slice(1);
742
+ const valFlags = ['--prompt', '--description', '--model', '--backend', '--affinity', '--vault', '--out'];
743
+ const role = subRest.find((a, i) => !a.startsWith('-') && !valFlags.includes(subRest[i - 1]));
744
+ const g = (n) => getFlag(n, subRest).value;
745
+ const list = (n) => { const v = g(n); return v ? v.split(',').map(s => s.trim()).filter(Boolean) : []; };
746
+ try {
747
+ const { intelligenceNewCmd } = await import('../src/intelligence.mjs');
748
+ await intelligenceNewCmd({
749
+ role, prompt: g('--prompt'), description: g('--description'),
750
+ model: g('--model'), backend: g('--backend'),
751
+ affinity: list('--affinity'), vault: list('--vault'),
752
+ outPath: g('--out'), force: subRest.includes('--force') || subRest.includes('-f'),
753
+ });
754
+ } catch (e) {
755
+ process.stderr.write('rwa intelligence: ' + ((e && e.message) || e) + '\n');
756
+ process.exitCode = (e && e.exitCode) || 1;
757
+ }
758
+ return;
759
+ }
760
+
731
761
  if (verb === 'skill') {
732
762
  const sub = rest[0];
733
763
  const subRest = rest.slice(1);
@@ -1187,6 +1217,16 @@ function detectProductKind(fileText) {
1187
1217
  const vision = rest.includes('--vision');
1188
1218
  const claude = rest.includes('--claude');
1189
1219
  const trustInput = rest.includes('--trust-input');
1220
+ // Import fidelity loop (PDF): measure the deterministic import + auto-escalate to --vision when
1221
+ // low AND a model is reachable. --no-escalate opts out; --target-fidelity <0..1> sets the bar.
1222
+ const escalate = !rest.includes('--no-escalate');
1223
+ const tfIdx = rest.indexOf('--target-fidelity');
1224
+ const targetFidelity = tfIdx >= 0 ? Number(rest[tfIdx + 1]) : undefined;
1225
+ if (tfIdx >= 0 && (!Number.isFinite(targetFidelity) || targetFidelity < 0 || targetFidelity > 1)) {
1226
+ console.error('rwa import: --target-fidelity must be a number between 0 and 1');
1227
+ process.exitCode = 2;
1228
+ return;
1229
+ }
1190
1230
  // --model and --timeout take a value: find the index, then take the next arg.
1191
1231
  const modelIdx = rest.indexOf('--model');
1192
1232
  const model = modelIdx >= 0 ? rest[modelIdx + 1] : undefined;
@@ -1219,7 +1259,7 @@ function detectProductKind(fileText) {
1219
1259
  process.exitCode = 2;
1220
1260
  return;
1221
1261
  }
1222
- const positional = rest.filter((a, i) => !a.startsWith('-') && rest[i - 1] !== '--model' && rest[i - 1] !== '--timeout' && rest[i - 1] !== '--kind' && rest[i - 1] !== '--skin');
1262
+ const positional = rest.filter((a, i) => !a.startsWith('-') && rest[i - 1] !== '--model' && rest[i - 1] !== '--timeout' && rest[i - 1] !== '--kind' && rest[i - 1] !== '--skin' && rest[i - 1] !== '--target-fidelity');
1223
1263
  if (verb === 'new') {
1224
1264
  // `rwa new --kind <starter>` selects a built-in starter. Otherwise a bare-word
1225
1265
  // first positional is a TEMPLATE name (clone a data-rwa-template-labeled file
@@ -1238,7 +1278,7 @@ function detectProductKind(fileText) {
1238
1278
  process.exitCode = 2;
1239
1279
  return;
1240
1280
  }
1241
- await importCmd({ inputPath: positional[0], outPath: positional[1], force, open, vision, claude, trustInput, model, timeoutSec });
1281
+ await importCmd({ inputPath: positional[0], outPath: positional[1], force, open, vision, claude, trustInput, model, timeoutSec, escalate, targetFidelity });
1242
1282
  } else {
1243
1283
  console.error(`rwa: unknown verb "${verb}". Try --help.`);
1244
1284
  process.exitCode = 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rewritable",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "CLI for re-writeable: emit and import single-file rwa documents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2332,7 +2332,8 @@ async function renderActionsModePanel(panel) {
2332
2332
  const isActive = a.role === activeRole;
2333
2333
  const isAdvisor = advisorSet.indexOf(a.role) >= 0;
2334
2334
  const state = isActive ? 'primary' : (isAdvisor ? 'advisor' : '');
2335
- const meta = (a.verified ? 'verified' : 'unverified') + (state ? ' · ' + state : '');
2335
+ const aff = (a.affinity && a.affinity.length) ? ' · for ' + a.affinity.join('/') : '';
2336
+ const meta = (a.verified ? 'verified' : 'unverified') + (state ? ' · ' + state : '') + aff;
2336
2337
  let btns = '';
2337
2338
  if (a.verified) {
2338
2339
  const r = escRuntimeHtml(a.role);
@@ -2375,14 +2376,16 @@ async function renderActionsModePanel(panel) {
2375
2376
  btn.addEventListener('click', () => runtimeSetMode('skills'));
2376
2377
  });
2377
2378
  panel.querySelectorAll('[data-agent-on]').forEach(btn => btn.addEventListener('click', () => {
2378
- try { runtimeActivateAgent(btn.getAttribute('data-agent-on')); } // setActive + offer recommended model
2379
+ const role = btn.getAttribute('data-agent-on');
2380
+ try { runtimeActivateAgent(role); const aw = affinityWarning(role); if (aw && typeof setStatus === 'function') setStatus('', aw); } // setActive + offer model; advisory affinity warn (I-D)
2379
2381
  catch (e) { if (typeof setStatus === 'function') setStatus('err', '✗ ' + (e && (e.code || e.message))); }
2380
2382
  renderActionsModePanel(panel);
2381
2383
  }));
2382
2384
  const agentOff = panel.querySelector('[data-agent-off]');
2383
2385
  if (agentOff) agentOff.addEventListener('click', () => { runtimeSetActiveAgent(null); renderActionsModePanel(panel); });
2384
2386
  panel.querySelectorAll('[data-agent-advon]').forEach(btn => btn.addEventListener('click', () => {
2385
- try { runtimeAddAdvisor(btn.getAttribute('data-agent-advon')); } // I-E: layer an advisory lens
2387
+ const role = btn.getAttribute('data-agent-advon');
2388
+ try { runtimeAddAdvisor(role); const aw = affinityWarning(role); if (aw && typeof setStatus === 'function') setStatus('', aw); } // I-E: layer an advisory lens; I-D affinity warn
2386
2389
  catch (e) { if (typeof setStatus === 'function') setStatus('err', '✗ ' + (e && (e.code || e.message))); }
2387
2390
  renderActionsModePanel(panel);
2388
2391
  }));
@@ -7844,7 +7847,7 @@ function _agAgentsRegion() {
7844
7847
  };
7845
7848
  }
7846
7849
  function runtimeListAgents() {
7847
- return Array.from(installedAgents.values()).map(a => ({ role: a.role, author_pubkey: a.manifest && a.manifest.author_pubkey, verified: a.verified }));
7850
+ return Array.from(installedAgents.values()).map(a => ({ role: a.role, author_pubkey: a.manifest && a.manifest.author_pubkey, verified: a.verified, affinity: getAffinity(a.envelope) }));
7848
7851
  }
7849
7852
  function runtimeAgentActive() {
7850
7853
  if (!activeAgentRole) return null;
@@ -8166,6 +8169,24 @@ function getRecommendation(envelope) {
8166
8169
  if (typeof e.recommended_backend === 'string' && REC_BACKENDS.includes(e.recommended_backend.trim())) out.backend = e.recommended_backend.trim();
8167
8170
  return (out.model || out.backend) ? out : null;
8168
8171
  }
8172
+ // I-D (intelligence/0.2 §6) — advisory kind-affinity. A role may declare an UNSIGNED `affinity`
8173
+ // envelope field (the document kinds it is tuned for). Activating/advising it on a mismatched
8174
+ // PRODUCT_KIND only WARNS — never blocks (spec §4: affinity is a soft note). Advisory by design.
8175
+ function getAffinity(envelope) {
8176
+ const a = envelope && envelope.affinity;
8177
+ if (Array.isArray(a)) return a.filter(x => typeof x === 'string' && x);
8178
+ if (typeof a === 'string' && a) return [a];
8179
+ return [];
8180
+ }
8181
+ function affinityWarning(role) {
8182
+ const rec = Array.from(installedAgents.values()).find(x => x.role === role);
8183
+ if (!rec) return null;
8184
+ const aff = getAffinity(rec.envelope);
8185
+ if (!aff.length || aff.indexOf(PRODUCT_KIND) >= 0) return null;
8186
+ return '⚠ “' + role + '” is tuned for ' + aff.join('/') + ' documents, but this is a ' + PRODUCT_KIND + ' — applied anyway.';
8187
+ }
8188
+ window.__rwaGetAffinity = getAffinity;
8189
+ window.__rwaAffinityWarning = affinityWarning;
8169
8190
  function applyRecommendation(rec) {
8170
8191
  const r = rec || {};
8171
8192
  const applied = {};
package/src/commands.mjs CHANGED
@@ -228,7 +228,7 @@ export async function newCmd({ outPath, force, open, kind, templateName, skin })
228
228
 
229
229
  export { KNOWN_KINDS };
230
230
 
231
- export async function importCmd({ inputPath, outPath, force, open, vision, claude, trustInput, model, timeoutSec }) {
231
+ export async function importCmd({ inputPath, outPath, force, open, vision, claude, trustInput, model, timeoutSec, escalate, targetFidelity }) {
232
232
  if (vision && claude) {
233
233
  const e = new Error('--vision and --claude are mutually exclusive');
234
234
  e.exitCode = 2;
@@ -265,7 +265,31 @@ export async function importCmd({ inputPath, outPath, force, open, vision, claud
265
265
  // Buffer (not utf8 string) — docx and pdf are binary, and text formats
266
266
  // decode internally inside convert().
267
267
  const contents = await fs.readFile(input);
268
- ({ html, warnings } = await convert(ext, contents));
268
+ const conv = await convert(ext, contents);
269
+ ({ html, warnings } = conv);
270
+ // Import fidelity loop (PDF) — measure the deterministic import; on a low structural score,
271
+ // auto-escalate to --vision, but ONLY when a model is reachable (offline-first: a keyless
272
+ // import stays offline and warns). `--no-escalate` opts out. Design: docs/plans/2026-06-30-…
273
+ if (ext === 'pdf' && conv.fidelityInput && escalate !== false) {
274
+ const { measureAndEscalate } = await import('./import-fidelity.mjs');
275
+ // Resolve the OpenRouter key ONCE so the reachability probe and the actual escalation target
276
+ // agree (both honor RWA_OPENROUTER_KEY, the project-preferred var, AND OPENROUTER_API_KEY).
277
+ // convertPdfViaVision otherwise only reads OPENROUTER_API_KEY, so a probe that said "reachable"
278
+ // on RWA_OPENROUTER_KEY alone would escalate into a guaranteed "key required" throw.
279
+ const orKey = process.env.RWA_OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
280
+ const r = await measureAndEscalate(
281
+ { structuralInput: conv.fidelityInput, importResult: conv },
282
+ {
283
+ threshold: targetFidelity,
284
+ escalate: escalate !== false,
285
+ modelReachable: () => !!orKey,
286
+ visionImport: async () => { console.error('note: import fidelity low — escalating to --vision (openrouter)…'); return convertPdfViaVision(contents, { model, apiKey: orKey }); },
287
+ },
288
+ );
289
+ if (r.note) console.error('note: ' + r.note);
290
+ html = r.result.html;
291
+ warnings = r.result.warnings || warnings;
292
+ }
269
293
  }
270
294
  for (const w of warnings) console.error(`note: ${w}`);
271
295
 
@@ -0,0 +1,131 @@
1
+ // Import fidelity loop — increment 1 (docs/plans/2026-06-30-import-fidelity-loop-design.md).
2
+ // An OFFLINE structural check on a PDF import + auto-escalate UP the ladder (default → --vision)
3
+ // when fidelity is low — gated on offline-first: escalation fires only when a model is reachable;
4
+ // a keyless low-fidelity import stays offline and warns, never touching the network.
5
+ //
6
+ // Increment 1 measures two false-positive-free signals:
7
+ // - coverage: fraction of source word-tokens present in the imported text (transform fidelity —
8
+ // catches dropped/mangled content; ~1 for a faithful geometry import).
9
+ // - garble: 1 − share of replacement (U+FFFD) / control chars in the source (extraction
10
+ // quality — a PDF with broken font encoding extracts to garbage; the geometry import
11
+ // is then unfaithful and should escalate to --vision, which reads the rendered glyphs).
12
+ // The graphics/visual signal ("this page is a chart/scan the text import can't reproduce") needs a
13
+ // renderer; it is the browser-side VISUAL JUDGE, a later increment. `density` is returned for
14
+ // callers/future use but deliberately NOT scored here (char-count alone false-positives on short
15
+ // docs) and is not yet surfaced in CLI output.
16
+ //
17
+ // Sensitivity, stated honestly: this is a FLOOR, biased to never over-escalate a good import.
18
+ // coverage uses substring `includes`, so reordered/duplicated text scores ~1.0; garble only counts
19
+ // U+FFFD + control chars, so wrong-encoding glyphs that aren't U+FFFD score ~1.0. The offline
20
+ // trigger therefore reliably fires only on DROPPED text or replacement-char garble — visual/glyph
21
+ // faithfulness is the VLM judge's job, not this metric's.
22
+
23
+ const DEFAULT_THRESHOLD = 0.85;
24
+ const MIN_CHARS_PER_PAGE = 200; // informational density floor only (not part of the score in inc. 1)
25
+
26
+ const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().toLowerCase();
27
+ const stripTags = (h) => String(h == null ? '' : h).replace(/<[^>]*>/g, ' ');
28
+
29
+ // Count replacement (U+FFFD) + control chars (excluding tab/LF/CR) without embedding literal
30
+ // control bytes in source — a char-code scan, so the file stays clean ASCII.
31
+ function badChars(src) {
32
+ let bad = 0;
33
+ for (let i = 0; i < src.length; i++) {
34
+ const c = src.charCodeAt(i);
35
+ if (c === 0xFFFD || (c < 0x20 && c !== 9 && c !== 10 && c !== 13)) bad++;
36
+ }
37
+ return bad;
38
+ }
39
+
40
+ /** Pure offline structural score in [0,1] = min(coverage, garble). Returns the components + reasons. */
41
+ export function structuralScore({ sourceText, pages } = {}, importedHtml) {
42
+ const src = String(sourceText == null ? '' : sourceText);
43
+ const importText = norm(stripTags(importedHtml));
44
+
45
+ // coverage — unique source word-tokens (len>1) present in the imported text
46
+ const tokens = [...new Set(norm(src).split(' ').filter(t => t.length > 1))];
47
+ let coverage = 1;
48
+ if (tokens.length) {
49
+ let hit = 0;
50
+ for (const t of tokens) if (importText.includes(t)) hit++;
51
+ coverage = hit / tokens.length;
52
+ }
53
+
54
+ // garble — replacement + control char share of the source
55
+ const garble = src.length ? Math.max(0, 1 - badChars(src) / src.length) : 1;
56
+
57
+ // density — reported only (graphics-heaviness detection is the deferred visual judge)
58
+ const pageN = Math.max(1, pages | 0);
59
+ const density = Math.min(1, src.replace(/\s+/g, '').length / (pageN * MIN_CHARS_PER_PAGE));
60
+
61
+ const score = Math.min(coverage, garble);
62
+ const reasons = [];
63
+ if (coverage < 0.9) reasons.push('low-coverage');
64
+ if (garble < 0.9) reasons.push('garbled-text');
65
+ return { score, coverage, garble, density, reasons };
66
+ }
67
+
68
+ /**
69
+ * Per-page structural fidelity for a multipage import. `perPage`: [{ sourceText, html }], where
70
+ * imported page i aligns 1:1 with source page i (the geometry import preserves page order). Returns
71
+ * { pages:[{page, score, coverage, garble, reasons}], overall (mean), worst (the lowest page) }.
72
+ * The per-page strip is what the browser visual judge surfaces so you jump to the bad pages.
73
+ */
74
+ export function structuralScoreByPage(perPage = []) {
75
+ const pages = perPage.map((p, i) => ({ page: i + 1, ...structuralScore({ sourceText: p.sourceText, pages: 1 }, p.html) }));
76
+ const overall = pages.length ? pages.reduce((a, p) => a + p.score, 0) / pages.length : 1;
77
+ const worst = pages.length ? pages.reduce((w, p) => (p.score < w.score ? p : w)) : null;
78
+ return { pages, overall, worst };
79
+ }
80
+
81
+ // The escalate-trigger fidelity: the WORST page when per-page data is present (so one bad page in an
82
+ // otherwise-good doc still escalates — averaging would hide it), else the whole-doc score.
83
+ function measureStructural(structuralInput, importHtml) {
84
+ if (structuralInput && Array.isArray(structuralInput.perPage) && structuralInput.perPage.length) {
85
+ const bp = structuralScoreByPage(structuralInput.perPage);
86
+ return { score: bp.worst.score, coverage: bp.worst.coverage, garble: bp.worst.garble, reasons: bp.worst.reasons, overall: bp.overall, worst: bp.worst, pages: bp.pages };
87
+ }
88
+ return structuralScore(structuralInput, importHtml);
89
+ }
90
+
91
+ /**
92
+ * Measure the geometry import; if its score is below `threshold` AND escalation is enabled AND a
93
+ * model is reachable, re-import via the injected `visionImport` and keep the higher-rung result.
94
+ * Offline-first: with no reachable model, never call the network — keep the deterministic import and
95
+ * surface a warning `note`. A failed escalation falls back to the deterministic import (loud).
96
+ *
97
+ * deps: { threshold=0.85, escalate=true, modelReachable():boolean, visionImport():Promise<importResult> }
98
+ */
99
+ export async function measureAndEscalate({ structuralInput, importResult }, deps = {}) {
100
+ const threshold = deps.threshold == null ? DEFAULT_THRESHOLD : deps.threshold;
101
+ const escalate = deps.escalate !== false; // default on
102
+ const fidelity = measureStructural(structuralInput, importResult.html);
103
+
104
+ if (fidelity.score >= threshold || !escalate) {
105
+ return { result: importResult, fidelity, escalated: false };
106
+ }
107
+
108
+ const reachable = typeof deps.modelReachable === 'function' ? deps.modelReachable() : false;
109
+ if (!reachable) {
110
+ return {
111
+ result: importResult, fidelity, escalated: false,
112
+ note: 'low import fidelity (' + fidelity.score.toFixed(2) + (fidelity.reasons.length ? ': ' + fidelity.reasons.join(', ') : '') +
113
+ ') — set RWA_OPENROUTER_KEY or use --vision/--claude for a higher-fidelity import',
114
+ };
115
+ }
116
+
117
+ try {
118
+ const vResult = await deps.visionImport();
119
+ // Escalation succeeded: keep the higher rung. We do NOT re-score with the same text-only metric
120
+ // — its blind spot (graphics/garbled glyphs) is exactly why we escalated; the model addresses it.
121
+ return {
122
+ result: vResult, escalated: true, baselineFidelity: fidelity,
123
+ note: 'import fidelity ' + fidelity.score.toFixed(2) + ' — escalated to --vision',
124
+ };
125
+ } catch (e) {
126
+ return {
127
+ result: importResult, fidelity, escalated: false,
128
+ note: 'low import fidelity (' + fidelity.score.toFixed(2) + ') — escalation to --vision failed: ' + ((e && e.message) || e),
129
+ };
130
+ }
131
+ }
package/src/import.mjs CHANGED
@@ -264,13 +264,18 @@ async function convertPdf(bytes) {
264
264
  throw e;
265
265
  }
266
266
  const pages = [];
267
+ const perPage = [];
267
268
  let totalText = 0;
269
+ let sourceText = '';
268
270
  for (let p = 1; p <= doc.numPages; p++) {
269
271
  const page = await doc.getPage(p);
270
272
  const rendered = await renderPdfPage(page, pdfjs.Util, pdfjs.OPS);
271
273
  pages.push(rendered.html);
272
274
  totalText += rendered.textCount;
275
+ sourceText += (rendered.text || '') + '\n';
276
+ perPage.push({ sourceText: rendered.text || '', html: rendered.html }); // for per-page fidelity
273
277
  }
278
+ const pageCount = doc.numPages;
274
279
  await doc.destroy().catch(() => {});
275
280
 
276
281
  if (totalText === 0) {
@@ -281,6 +286,7 @@ async function convertPdf(bytes) {
281
286
  return {
282
287
  html: `<article class="rwa-pdf">\n${PDF_PAGE_STYLE}\n<div class="rwa-pdf-doc">\n${pages.join('\n')}\n</div>\n</article>`,
283
288
  warnings: ['pdf: imported as a geometry-faithful reconstruction (positioned text + rules) — text stays editable but is absolutely positioned'],
289
+ fidelityInput: { sourceText, pages: pageCount, perPage }, // for the import fidelity loop (import-fidelity.mjs); perPage drives per-page/worst-page scoring
284
290
  };
285
291
  }
286
292
 
@@ -489,5 +495,7 @@ async function renderPdfPage(page, Util, OPS) {
489
495
  }
490
496
 
491
497
  const html = `<div class="rwa-pdf-page" style="width:${pdfNum(vp.width)}px;height:${pdfNum(vp.height)}px">\n${parts.join('\n')}\n</div>`;
492
- return { html, textCount };
498
+ // text: the raw pdf.js-extracted source text (for the import fidelity check — coverage + garble).
499
+ const text = tc.items.map(i => i.str || '').join(' ');
500
+ return { html, textCount, text };
493
501
  }
@@ -0,0 +1,79 @@
1
+ // I-C (intelligence/0.2 §6) — `rwa intelligence new <role>`: mint a signed rwa-agent/1 role and
2
+ // scaffold a CARRIER rewritable (a skill-host holding the record + a self-describing card). The
3
+ // carrier ships only the PUBLIC key + signature; the PRIVATE key is written to a sibling .key.json
4
+ // (keep it to publish updates under the same author identity). Offline; reuses the agent canon
5
+ // (skill-manifest) and the seed bootstrap (seed.mjs) — no new wire-type, no canon fork.
6
+ import fs from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { webcrypto, randomUUID } from 'node:crypto';
9
+ import { SEED_CANDIDATES } from './commands.mjs';
10
+ import { loadSeed, applySeedSubs, kindOverrides, replaceInlineDoc } from './seed.mjs';
11
+ import { agentSigningMessage } from './skill-manifest.mjs';
12
+
13
+ const ROLE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
14
+ const REC_MODEL_RE = /^[A-Za-z0-9._:\/-]{1,200}$/;
15
+ const REC_BACKENDS = ['openrouter', 'ollama', 'lmstudio', 'atomic', 'bridge', 'bridge-session'];
16
+ const b64 = (u8) => Buffer.from(u8).toString('base64');
17
+ const rel = (p) => path.relative(process.cwd(), p) || p;
18
+ const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
19
+ const fail = (msg, code = 2) => { const e = new Error(msg); e.exitCode = code; throw e; };
20
+
21
+ export async function intelligenceNewCmd(opts = {}) {
22
+ const role = opts.role, prompt = opts.prompt;
23
+ if (!role || !ROLE_RE.test(role)) fail('intelligence: <role> must be lowercase a-z0-9_- (≤64, leading alphanumeric)');
24
+ if (!prompt || typeof prompt !== 'string') fail('intelligence: --prompt "<system prompt>" is required');
25
+ if (prompt.includes('`') || prompt.includes('${') || /<\/?DOC>/i.test(prompt)) fail('intelligence: --prompt must not contain ` ${ or <DOC>');
26
+ if (opts.model != null && !REC_MODEL_RE.test(String(opts.model))) fail('intelligence: --model is not a valid model id');
27
+ if (opts.backend != null && !REC_BACKENDS.includes(String(opts.backend))) fail('intelligence: --backend must be one of ' + REC_BACKENDS.join('/'));
28
+ const vault = (opts.vault || []).map(v => /^vault:/.test(v) ? v : 'vault:' + v);
29
+ const affinity = (opts.affinity || []).filter(Boolean);
30
+
31
+ // Mint + sign the rwa-agent/1 record. The signature is over `agent` (the canon); the
32
+ // recommendation/affinity ride OUTSIDE it (unsigned envelope fields, per I-A/I-D).
33
+ const kp = await webcrypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
34
+ const author_pubkey = b64(new Uint8Array(await webcrypto.subtle.exportKey('raw', kp.publicKey)));
35
+ const agent = { author_pubkey, description: opts.description || ('The ' + role + ' role.'), role, system_prompt: prompt, vault_namespace_set: vault, version: 'rwa-agent/1' };
36
+ const signature = b64(new Uint8Array(await webcrypto.subtle.sign({ name: 'Ed25519' }, kp.privateKey, agentSigningMessage(agent))));
37
+ const envelope = { agent, signature };
38
+ if (opts.model) envelope.recommended_model = String(opts.model);
39
+ if (opts.backend) envelope.recommended_backend = String(opts.backend);
40
+ if (affinity.length) envelope.affinity = affinity;
41
+
42
+ // Scaffold the carrier — a skill-host bootstrap + card + frozen #rwa-agents zone.
43
+ const out = path.resolve(opts.outPath || ('./' + role + '.intelligence.html'));
44
+ if (!opts.force) { let exists = false; try { await fs.access(out); exists = true; } catch (_) {} if (exists) fail('intelligence: ' + rel(out) + ' exists (use --force)'); }
45
+ const seed = await loadSeed(SEED_CANDIDATES);
46
+ const ov = kindOverrides('skill-host');
47
+ let result = applySeedSubs(seed, { uuid: randomUUID(), title: 'Intelligence — ' + role, fileMeta: path.basename(out), productKind: 'skill-host', lensPlaceholder: ov.lensPlaceholder, palPlaceholder: ov.palPlaceholder, productHeader: ov.productHeader, lensClickToAnchor: ov.lensClickToAnchor });
48
+ const zone = '<div data-rwa-frozen id="rwa-agents"><script type="application/rwa-agent+json">' + b64(Buffer.from(JSON.stringify(envelope))) + '</script></div>';
49
+ result = replaceInlineDoc(result, buildCard({ role, prompt, model: opts.model, backend: opts.backend, affinity, vault }) + '\n' + zone);
50
+ await fs.writeFile(out, result, 'utf8');
51
+
52
+ // The private key — needed to re-sign updates under the same author identity. Sibling file, loud.
53
+ const fingerprint = Buffer.from(await webcrypto.subtle.digest('SHA-256', Buffer.from(author_pubkey, 'base64'))).toString('hex').slice(0, 16);
54
+ const keyOut = out.replace(/\.html?$/i, '') + '.key.json';
55
+ // 0600: the file holds the PRIVATE key — owner read/write only, never world-readable.
56
+ await fs.writeFile(keyOut, JSON.stringify({
57
+ role, author_pubkey, fingerprint,
58
+ private_key_pkcs8_b64: b64(new Uint8Array(await webcrypto.subtle.exportKey('pkcs8', kp.privateKey))),
59
+ warning: 'SECRET. Keep this to publish updates to this intelligence under the same author identity. Never commit or share it. The carrier .html holds only the public key.',
60
+ }, null, 2) + '\n', { mode: 0o600 });
61
+ try { await fs.chmod(keyOut, 0o600); } catch (_) {} // guarantee owner-only even if the file pre-existed (writeFile mode applies only on create; best-effort on non-POSIX)
62
+
63
+ console.log('wrote ' + rel(out) + ' (intelligence "' + role + '")');
64
+ console.log('author ' + fingerprint + ' — private key saved to ' + rel(keyOut) + ' (keep secret; needed to update this intelligence)');
65
+ return { out, keyOut, fingerprint, envelope };
66
+ }
67
+
68
+ function buildCard({ role, prompt, model, backend, affinity, vault }) {
69
+ const recLine = model ? '\n<li><strong>Recommended model:</strong> <code>' + esc(model) + '</code>' + (backend ? ' on <code>' + esc(backend) + '</code>' : '') + ' — offered on activation, behind consent (your session only; key untouched).</li>' : '';
70
+ const affLine = affinity.length ? '\n<li><strong>Affinity:</strong> ' + esc(affinity.join(', ')) + ' (advisory — a mismatch only warns).</li>' : '';
71
+ const vaultLine = '\n<li><strong>Vault namespaces:</strong> ' + (vault.length ? esc(vault.join(', ')) : 'none') + '.</li>';
72
+ return '<article>\n' +
73
+ '<h1>Intelligence — &ldquo;' + esc(role) + '&rdquo;</h1>\n' +
74
+ '<p class="lede">A droppable <strong>intelligence</strong> (intelligence/0.2): a signed <code>rwa-agent/1</code> role you can drop onto another rewritable to retune its &#8984;K editor. This file is the carrier — open it, read it, drop it.</p>\n' +
75
+ '<h2>What it does</h2>\n<p>' + esc(prompt) + '</p>\n' +
76
+ '<h2>What it carries</h2>\n<ul>\n<li><strong>Role:</strong> <code>' + esc(role) + '</code></li>' + recLine + affLine + vaultLine + '\n</ul>\n' +
77
+ '<h2>How to use it</h2>\n<p>Drop this file onto another rewritable to install the role (behind the consent dialog), then activate it from the Activity panel&rsquo;s <em>Intelligences</em> section. This carrier is itself a skill-host, so the role is already installed here — try it directly.</p>\n' +
78
+ '</article>';
79
+ }