@aeratic/pagetailor 0.3.0 → 0.4.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
@@ -127,10 +127,21 @@ A user asks in natural language ("put the KPI at the top"); the agent turns it i
127
127
 
128
128
  `complete` is any `async ({ system, user }) => text`, so any model or SDK works; pagetailor depends on none. `anthropicComplete({ apiKey, model })` is a built-in fetch call to the Anthropic Messages API (`claude-sonnet-5-5` by default).
129
129
 
130
- In `pagetailor dev`, set `ANTHROPIC_API_KEY` (and optionally `PAGETAILOR_MODEL`) and use the "Ask the agent…" box in the header: `POST /api/agent` with `{ page, user, role, request }` (503 without the key). `GET /api/agent/prompt?page=&user=&role=&request=` returns the built prompt for debugging.
130
+ In `pagetailor dev`, set `ANTHROPIC_API_KEY` (and optionally `PAGETAILOR_MODEL`) and use the "Change this page…" box in the header: `POST /api/agent` with `{ page, user, role, request }` (503 without the key). `GET /api/agent/prompt?page=&user=&role=&request=` returns the built prompt for debugging.
131
+
132
+ ## Pages as answers
133
+
134
+ A user asks a question ("how did sales go this week?") and the agent composes a brand-new page from the catalog instead of changing an existing one.
135
+
136
+ 1. `runAsk(root, { user, role, request, complete })` starts from an empty `layout/stack`, asks the model for `{ "title", "why", "patches" }` using only `insert` ops (cards, grids, widgets), applies them in memory and resolves the tree with `resolve(root, "answer", { user, role, tree })`. Gate or resolve errors are sent back once, like `runAgent`.
137
+ 2. The result (`{ title, why, tree, resolved }`) is rendered as is. Nothing is written to disk until the user decides.
138
+ 3. Keep as page: `keepAnswer(root, { user, role, title, tree, why })` writes `pages/<slug>.yaml` with `owner: <user>` and adds the slug to `manifest.pages` (slugs are made unique with `-2`, `-3`).
139
+ 4. Pin to home: `pinAnswer(root, { user, role, title, tree, why })` proposes, as `user-<user>`, an `insert` of a `layout/card` with the answer's widgets into the home's main container; on a grid it gets its own full-width row at the bottom. Same gate and proposal rules as any override.
140
+
141
+ Both go through the gate with the user's role first. In `pagetailor dev` use the "Ask…" box: `POST /api/ask` `{ user, role, request }` (503 without `ANTHROPIC_API_KEY`), `POST /api/ask/keep` and `POST /api/ask/pin` with `{ user, role, title, tree, why }`.
131
142
 
132
143
  ## Status
133
144
 
134
- 0.3: engine, CLI (`init`, `validate`, `registry`, `dev`), history with rollback, proposals with owner approval, reorder editor, agent with a pluggable model adapter.
145
+ 0.4: engine, CLI (`init`, `validate`, `registry`, `dev`), history with rollback, proposals with owner approval, reorder editor, agent with a pluggable model adapter, pages as answers.
135
146
 
136
147
  License: MIT.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aeratic/pagetailor",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pages as data: declarative YAML pages, typed data catalog, per-user overrides, validated for AI agents. Renders with json-render.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/agent.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { join } from "node:path";
2
- import { loadProject, loadPage, loadOverrides, loadYaml, exists } from "./loader.js";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { stringify } from "yaml";
4
+ import { loadProject, loadPage, loadOverrides, loadYaml, exists, listPages } from "./loader.js";
3
5
  import { applyOverride, mergePatch } from "./overrides.js";
4
- import { splitRef } from "./validator.js";
6
+ import { splitRef, validateTree } from "./validator.js";
5
7
  import { propose } from "./proposals.js";
8
+ import { resolve } from "./resolver.js";
6
9
 
7
10
  // The agent never writes files: it turns a request into override patches and hands them to propose().
8
11
  const OPS = [
@@ -56,22 +59,27 @@ export async function describePage(root, page, { user, role } = {}) {
56
59
  return `PAGE ${page} (user ${user ?? "none"}, role ${role ?? "none"})\n${lines.join("\n")}`;
57
60
  }
58
61
 
62
+ const RULES = `- Use only the components, data sources and ops listed below; refs include the version (name@version).
63
+ - Respect types: a component bound to data must accept the type the source returns. Use only params listed for a source.
64
+ - Respect the role: never use a data source that is not listed (the role cannot read it).`;
65
+ const GRID_RULE = "- A grid with areas must place every child exactly once";
66
+ const reply = shape => `Answer ONLY with a JSON object, no prose:
67
+ ${shape}
68
+ or, when the request cannot be satisfied with this catalog or this user's role:
69
+ { "refuse": string }`;
70
+ const LANG = (...f) => `- Write ${f.map(x => `"${x}"`).join(", ")} (and "refuse") in the user's language.`;
71
+
59
72
  export async function buildAgentPrompt(root, { page, user, role, request }) {
60
73
  const project = await loadProject(root);
61
74
  const system = `You modify a declarative UI page for one user by writing override patches.
62
- Answer ONLY with a JSON object, no prose:
63
- { "why": string, "patches": [ ...override ops... ] }
64
- or, when the request cannot be satisfied with this catalog or this user's role:
65
- { "refuse": string }
75
+ ${reply('{ "why": string, "patches": [ ...override ops... ] }')}
66
76
 
67
77
  RULES
68
- - Use only the components, data sources and ops listed below; refs include the version (name@version).
69
- - Respect types: a component bound to data must accept the type the source returns. Use only params listed for a source.
70
- - Respect the role: never use a data source that is not listed (the role cannot read it).
78
+ ${RULES}
71
79
  - Targets are existing child names or component names from the current page.
72
- - A grid with areas must place every child exactly once: when you insert, remove or replace grid children, update areas too.
80
+ ${GRID_RULE}: when you insert, remove or replace grid children, update areas too.
73
81
  - Keep patches minimal: change only what the request asks for. Patches are appended to the user's existing ones.
74
- - Write "why" (and "refuse") in the user's language.
82
+ ${LANG("why")}
75
83
 
76
84
  ${describeCatalog(project, { role })}
77
85
 
@@ -79,6 +87,28 @@ CURRENT ${await describePage(root, page, { user, role })}`;
79
87
  return { system, user: request };
80
88
  }
81
89
 
90
+ export const ANSWER_SKELETON = { ref: "layout/stack@1.0.0", children: [] };
91
+
92
+ export async function buildAskPrompt(root, { user, role, request }) {
93
+ const project = await loadProject(root);
94
+ const system = `You answer a user's question by composing a brand-new UI page from a catalog of components and data sources.
95
+ ${reply('{ "title": string, "why": string, "patches": [ ...insert ops only... ] }')}
96
+
97
+ RULES
98
+ ${RULES}
99
+ - Use ONLY "insert" ops, onto the page's root layout/stack (target "layout/stack") or onto a layout/grid or layout/card you inserted.
100
+ - Add a layout/card (with a title) or a layout/grid to group widgets as needed. To fill a grid, insert it with named children and areas in one node.
101
+ ${GRID_RULE}.
102
+ - "title" names the page in a few words; "why" says briefly how the page answers the question.
103
+ ${LANG("title", "why")}
104
+
105
+ ${describeCatalog(project, { role })}
106
+
107
+ CURRENT PAGE answer (empty, user ${user ?? "none"}, role ${role ?? "none"})
108
+ layout/stack: layout/stack@1.0.0`;
109
+ return { system, user: request };
110
+ }
111
+
82
112
  export function parseAgentReply(text) {
83
113
  const s = String(text ?? "");
84
114
  const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(s);
@@ -121,3 +151,70 @@ export async function runAgent(root, { page, user, role, request, complete }) {
121
151
  reply = undefined; errors = [];
122
152
  }
123
153
  }
154
+
155
+ // Pages as answers: compose a page in memory; nothing is written until keepAnswer or pinAnswer.
156
+ export async function runAsk(root, { user, role, request, complete }) {
157
+ const prompt = await buildAskPrompt(root, { user, role, request });
158
+ let message = prompt.user, reply, errors = [];
159
+ for (let attempts = 1; attempts <= 2; attempts++) {
160
+ const text = await complete({ system: prompt.system, user: message });
161
+ try { reply = parseAgentReply(text); } catch (e) { errors = [e.message]; }
162
+ if (reply?.refuse) return { ok: false, refuse: reply.refuse, reply, attempts };
163
+ if (reply && !errors.length) {
164
+ if (!Array.isArray(reply.patches)) errors = ["reply has no patches array"];
165
+ else if (reply.patches.some(p => p?.op !== "insert")) errors = ["only insert ops are allowed"];
166
+ else {
167
+ const r = applyOverride(ANSWER_SKELETON, { name: "answer", patches: reply.patches });
168
+ const resolved = await resolve(root, "answer", { user, role, tree: r.tree });
169
+ errors = [...r.errors, ...resolved.errors].map(e => `${e.path}: ${e.message}`);
170
+ if (!errors.length) return { ok: true, title: reply.title ?? "Answer", why: reply.why, tree: r.tree, resolved: { spec: resolved.spec, errors: resolved.errors }, attempts };
171
+ }
172
+ }
173
+ if (attempts === 2) return { ok: false, errors, reply, attempts };
174
+ message = `${prompt.user}\n\nThe previous page was rejected: ${errors.join("; ")}. Fix it.`;
175
+ reply = undefined; errors = [];
176
+ }
177
+ }
178
+
179
+ const slugify = s => String(s ?? "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "answer";
180
+
181
+ export async function keepAnswer(root, { user, role, title, tree, why }) {
182
+ const project = await loadProject(root);
183
+ const errors = validateTree(tree, project, { role: role ?? null });
184
+ if (errors.length) return { ok: false, errors: errors.map(e => `${e.path}: ${e.message}`) };
185
+ const taken = new Set([...(await listPages(root)), ...(project.manifest.pages ?? [])]);
186
+ const base = slugify(title); let slug = base;
187
+ for (let n = 2; taken.has(slug); n++) slug = `${base}-${n}`;
188
+ await writeFile(join(root, "pages", `${slug}.yaml`), stringify({ page: slug, title, owner: user, ...(why ? { why } : {}), layout: tree }));
189
+ const mfile = join(root, "manifest.yaml");
190
+ const manifest = (await loadYaml(mfile)) ?? {};
191
+ await writeFile(mfile, stringify({ ...manifest, pages: [...(manifest.pages ?? []), slug] }));
192
+ return { ok: true, page: slug };
193
+ }
194
+
195
+ // The home's main container: the root, or the single grid it wraps.
196
+ function mainContainer(tree) {
197
+ let node = tree;
198
+ while (Array.isArray(node.children) && node.children.length === 1 && node.children[0]?.children && !Array.isArray(node.children[0].children)) node = node.children[0];
199
+ return node;
200
+ }
201
+
202
+ export async function pinAnswer(root, { user, role, page, title, tree, why }) {
203
+ if (!user) return { ok: false, errors: ["user is required"] };
204
+ page ??= (await loadProject(root)).manifest.home;
205
+ const name = `user-${user}`;
206
+ const file = join(root, "overrides", `${name}.yaml`);
207
+ const existing = await exists(file) ? (await loadYaml(file)) ?? {} : {};
208
+ const base = existing.extends === page ? existing.patches ?? [] : [];
209
+ const target = mainContainer(await currentTree(root, page, { user, role }));
210
+ const card = { ref: "layout/card@1.0.0", props: { title: title ?? "Answer" }, children: structuredClone(tree.children ?? []) };
211
+ const insert = { op: "insert", target: splitRef(target.ref)[0], node: card };
212
+ if (target.children && !Array.isArray(target.children)) {
213
+ let n = 1; while (`answer-${n}` in target.children) n++;
214
+ insert.name = `answer-${n}`;
215
+ const areas = String(target.props?.areas ?? "").trimEnd();
216
+ const cols = areas ? areas.split("\n")[0].trim().split(/\s+/).length : 1;
217
+ insert.area = `${areas ? areas + "\n" : ""}${Array(cols).fill(insert.name).join(" ")}\n`;
218
+ }
219
+ return propose(root, { name, author: user, role, override: { extends: page, why: why ?? `pinned: ${title ?? "answer"}`, patches: mergePatch(base, insert) } });
220
+ }
package/src/dev.js CHANGED
@@ -9,7 +9,7 @@ import { loadProject, loadYaml, listPages } from "./loader.js";
9
9
  import { commitOverride, listHistory, rollback } from "./history.js";
10
10
  import { propose, listPending, approve, reject } from "./proposals.js";
11
11
  import { tokensToCss } from "./tokens.js";
12
- import { buildAgentPrompt, runAgent } from "./agent.js";
12
+ import { buildAgentPrompt, runAgent, runAsk, keepAnswer, pinAnswer } from "./agent.js";
13
13
  import { anthropicComplete } from "./llm.js";
14
14
 
15
15
  const here = dirname(fileURLToPath(import.meta.url));
@@ -62,6 +62,26 @@ function App() {
62
62
  else setAgent({ error: true, text: r.refuse ?? (r.errors ?? [r.error ?? "agent failed"]).join("; ") });
63
63
  } catch (err) { setAgent({ error: true, text: err.message }); }
64
64
  };
65
+ const [question, setQuestion] = useState(""), [answer, setAnswer] = useState(null), [answerStatus, setAnswerStatus] = useState(null);
66
+ const post = (path, b) => fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }).then(r => r.json());
67
+ const sendQuestion = async e => {
68
+ e.preventDefault(); if (!question.trim()) return;
69
+ setAnswer(null); setAnswerStatus({ text: "composing…" });
70
+ try {
71
+ const r = await post("/api/ask", { user, role: role || undefined, request: question });
72
+ if (r.ok) { setAnswer(r); setAnswerStatus(null); }
73
+ else setAnswerStatus({ error: true, text: r.refuse ?? (r.errors ?? [r.error ?? "ask failed"]).join("; ") });
74
+ } catch (err) { setAnswerStatus({ error: true, text: err.message }); }
75
+ };
76
+ const answerBody = () => ({ user, role: role || undefined, title: answer.title, tree: answer.tree, why: answer.why });
77
+ const keep = async () => {
78
+ const r = await post("/api/ask/keep", answerBody());
79
+ if (r.ok) { setAnswer(null); setAnswerStatus(null); setPage(r.page); } else setAnswerStatus({ error: true, text: (r.errors ?? [r.error]).join("; ") });
80
+ };
81
+ const pin = async () => {
82
+ const r = await post("/api/ask/pin", answerBody());
83
+ setAnswerStatus(r.ok ? { text: r.pending ? "sent for approval" : "applied to home" } : { error: true, text: (r.errors ?? [r.error]).join("; ") });
84
+ };
65
85
  useEffect(() => { const es = new EventSource("/events"); es.onmessage = () => location.reload(); return () => es.close(); }, []);
66
86
  if (!state) return null;
67
87
  const pages = state.manifest.pages, roles = state.manifest.roles ?? [];
@@ -75,11 +95,18 @@ function App() {
75
95
  {editor.status === "saving" && <span>saving…</span>}
76
96
  {editor.status === "pending" && <span>sent for approval</span>}
77
97
  {editor.status === "error" && <span style={{ color: "var(--pt-color-danger)" }}>{editor.errors.join("; ")}</span>}
78
- <form onSubmit={sendAsk} style={{ display: "flex", gap: 6 }}><input value={ask} placeholder="Ask the agent…" onChange={e => setAsk(e.target.value)} style={{ width: 220 }} /><button type="submit">Ask</button></form>
98
+ <form onSubmit={sendAsk} style={{ display: "flex", gap: 6 }}><input value={ask} placeholder="Change this page…" onChange={e => setAsk(e.target.value)} style={{ width: 220 }} /><button type="submit">Ask</button></form>
99
+ <form onSubmit={sendQuestion} style={{ display: "flex", gap: 6 }}><input value={question} placeholder="Ask…" onChange={e => setQuestion(e.target.value)} style={{ width: 220 }} /><button type="submit">Ask</button></form>
79
100
  {agent && <span style={{ color: agent.error ? "var(--pt-color-danger)" : undefined }}>{agent.text}</span>}
80
101
  {state.errors.length > 0 && <span style={{ color: "var(--pt-color-danger)" }}>{state.errors.length} problem(s), see console</span>}
81
102
  </header>
82
- <main style={{ padding: 16, maxWidth: 1100, margin: "0 auto" }}><EditorContext.Provider value={{ editing: editor.editing, propose: editor.propose }}><Page resolved={state} registry={registry} /></EditorContext.Provider></main>
103
+ {(answer || answerStatus) && <div style={{ display: "flex", gap: 12, alignItems: "center", padding: "8px 16px", borderBottom: "1px solid var(--pt-color-border)", fontSize: 13 }}>
104
+ {answer && <><strong>{answer.title}</strong><span>{answer.why}</span><button onClick={keep}>Keep as page</button><button onClick={pin}>Pin to home</button><button onClick={() => { setAnswer(null); setAnswerStatus(null); }}>Back</button></>}
105
+ {answerStatus && <span style={{ color: answerStatus.error ? "var(--pt-color-danger)" : undefined }}>{answerStatus.text}</span>}
106
+ </div>}
107
+ <main style={{ padding: 16, maxWidth: 1100, margin: "0 auto" }}>{answer
108
+ ? <Page resolved={{ ...state, spec: answer.resolved.spec, errors: answer.resolved.errors }} registry={registry} />
109
+ : <EditorContext.Provider value={{ editing: editor.editing, propose: editor.propose }}><Page resolved={state} registry={registry} /></EditorContext.Provider>}</main>
83
110
  </>;
84
111
  }
85
112
  createRoot(document.getElementById("root")).render(<App />);`;
@@ -144,6 +171,15 @@ createRoot(document.getElementById("root")).render(<App />);`;
144
171
  const r = await runAgent(root, { page: b.page, user: b.user, role: b.role || undefined, request: b.request, complete });
145
172
  return json(res, r.ok ? 200 : 400, r);
146
173
  }
174
+ if (url.pathname === "/api/ask" && req.method === "POST") {
175
+ if (!process.env.ANTHROPIC_API_KEY) return json(res, 503, { error: "set ANTHROPIC_API_KEY to use the agent" });
176
+ const b = await body(req);
177
+ const complete = anthropicComplete({ apiKey: process.env.ANTHROPIC_API_KEY, ...(process.env.PAGETAILOR_MODEL ? { model: process.env.PAGETAILOR_MODEL } : {}) });
178
+ const r = await runAsk(root, { user: b.user || undefined, role: b.role || undefined, request: b.request, complete });
179
+ return json(res, r.ok ? 200 : 400, r);
180
+ }
181
+ const answer = { "/api/ask/keep": keepAnswer, "/api/ask/pin": pinAnswer }[url.pathname];
182
+ if (answer && req.method === "POST") { const b = await body(req); const r = await answer(root, { ...b, user: b.user || undefined, role: b.role || undefined }); return json(res, r.ok ? 200 : 400, r); }
147
183
  if (url.pathname === "/api/pending") return json(res, 200, await listPending(root));
148
184
  const flow = { "/api/propose": propose, "/api/approve": approve, "/api/reject": reject }[url.pathname];
149
185
  if (flow && req.method === "POST") { const r = await flow(root, await body(req)); return json(res, r.ok ? 200 : 400, r); }
package/src/index.js CHANGED
@@ -14,5 +14,5 @@ export async function dev(...args) {
14
14
  }
15
15
  export { writeRegistryFile } from "./registry-file.js";
16
16
  export { tokensToCss } from "./tokens.js";
17
- export { describeCatalog, describePage, buildAgentPrompt, parseAgentReply, runAgent } from "./agent.js";
17
+ export { describeCatalog, describePage, buildAgentPrompt, parseAgentReply, runAgent, buildAskPrompt, runAsk, keepAnswer, pinAnswer } from "./agent.js";
18
18
  export { anthropicComplete } from "./llm.js";
package/src/resolver.js CHANGED
@@ -14,7 +14,9 @@ const fingerprint = (root, page, user, role) => Promise.all([
14
14
  ].map(f => mtime(join(root, f)))).then(a => Math.max(...a));
15
15
 
16
16
  // YAML page + overrides + data -> json-render flat spec { root, elements, state }
17
- export async function resolve(root, page, { user, role, sources, fresh = false, maxAge = 0 } = {}) {
17
+ // opts.tree: an in-memory layout (e.g. an agent answer), no page file, no overrides, no cache
18
+ export async function resolve(root, page, { user, role, sources, tree, fresh = false, maxAge = 0 } = {}) {
19
+ if (tree) return build(root, page, { user, role, sources, tree });
18
20
  const key = `${root}|${page}|${user}|${role}`, fp = await fingerprint(root, page, user, role);
19
21
  const hit = cache.get(key);
20
22
  if (!fresh && hit && hit.fp === fp && (!maxAge || Date.now() - hit.at < maxAge)) return hit.result;
@@ -23,12 +25,12 @@ export async function resolve(root, page, { user, role, sources, fresh = false,
23
25
  return result;
24
26
  }
25
27
 
26
- async function build(root, page, { user, role, sources }) {
28
+ async function build(root, page, { user, role, sources, tree: given }) {
27
29
  const project = await loadProject(root);
28
30
  const fetchers = sources ?? await loadSources(root);
29
- let tree = (await loadPage(root, page)).layout;
31
+ let tree = given ?? (await loadPage(root, page)).layout;
30
32
  const errors = [];
31
- for (const ov of await loadOverrides(root, page, user, role)) {
33
+ if (!given) for (const ov of await loadOverrides(root, page, user, role)) {
32
34
  const r = applyOverride(tree, ov); tree = r.tree; errors.push(...r.errors);
33
35
  }
34
36
  errors.push(...validateTree(tree, project, { role: role ?? null }));