@cfbender/cesium 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/prompt/system-fragment.md +73 -8
- package/src/render/annotate-frozen.ts +90 -0
- package/src/render/blocks/render.ts +20 -0
- package/src/render/blocks/renderers/callout.ts +3 -2
- package/src/render/blocks/renderers/code.ts +17 -2
- package/src/render/blocks/renderers/compare-table.ts +3 -2
- package/src/render/blocks/renderers/diagram.ts +3 -2
- package/src/render/blocks/renderers/diff.ts +23 -9
- package/src/render/blocks/renderers/hero.ts +3 -2
- package/src/render/blocks/renderers/kv.ts +3 -2
- package/src/render/blocks/renderers/list.ts +5 -4
- package/src/render/blocks/renderers/pill-row.ts +3 -2
- package/src/render/blocks/renderers/prose.ts +8 -2
- package/src/render/blocks/renderers/raw-html.ts +8 -2
- package/src/render/blocks/renderers/risk-table.ts +3 -2
- package/src/render/blocks/renderers/section.ts +4 -2
- package/src/render/blocks/renderers/timeline.ts +3 -2
- package/src/render/blocks/renderers/tldr.ts +3 -2
- package/src/render/client-js.ts +804 -6
- package/src/render/critique.ts +5 -335
- package/src/render/theme.ts +431 -6
- package/src/render/validate.ts +353 -97
- package/src/render/wrap.ts +67 -9
- package/src/server/api.ts +162 -3
- package/src/storage/index-gen.ts +4 -2
- package/src/storage/mutate.ts +433 -27
- package/src/tools/annotate.ts +336 -0
- package/src/tools/ask.ts +2 -6
- package/src/tools/critique.ts +15 -45
- package/src/tools/publish.ts +16 -56
- package/src/tools/styleguide.ts +7 -1
- package/src/tools/wait.ts +77 -24
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// Tool handler for cesium_annotate — publishes an interactive review artifact.
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tool } from "@opencode-ai/plugin";
|
|
6
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
7
|
+
import { loadConfig, type CesiumConfig } from "../config.ts";
|
|
8
|
+
import { extractTextContent } from "../render/extract.ts";
|
|
9
|
+
import { themeFromPreset, mergeTheme } from "../render/theme.ts";
|
|
10
|
+
import { validateAnnotateInput, validateBlocksArray } from "../render/validate.ts";
|
|
11
|
+
import { renderBlocks } from "../render/blocks/render.ts";
|
|
12
|
+
import { resolveHighlightTheme } from "../render/blocks/highlight.ts";
|
|
13
|
+
import { wrapDocument, type ArtifactMeta } from "../render/wrap.ts";
|
|
14
|
+
import type { InteractiveData } from "../render/validate.ts";
|
|
15
|
+
import { deriveProjectIdentity, artifactFilename, pathsFor } from "../storage/paths.ts";
|
|
16
|
+
import { atomicWrite } from "../storage/write.ts";
|
|
17
|
+
import { ensureThemeCss } from "../storage/assets.ts";
|
|
18
|
+
import { writeFaviconSvg } from "../storage/favicon-write.ts";
|
|
19
|
+
import { loadIndex, writeIndex, appendEntry, type IndexEntry } from "../storage/index-cache.ts";
|
|
20
|
+
import { withLock } from "../storage/lock.ts";
|
|
21
|
+
import { renderProjectIndex, renderGlobalIndex } from "../storage/index-gen.ts";
|
|
22
|
+
import { buildProjectSummaries } from "../storage/project-summaries.ts";
|
|
23
|
+
import {
|
|
24
|
+
ensureServerRunning as defaultEnsureServerRunning,
|
|
25
|
+
type RunningInfo,
|
|
26
|
+
type LifecycleConfig,
|
|
27
|
+
} from "../server/lifecycle.ts";
|
|
28
|
+
import { buildTerminalSummary, resolveDisplayHost } from "./publish.ts";
|
|
29
|
+
|
|
30
|
+
export interface AnnotateToolOverrides {
|
|
31
|
+
loadConfig?: () => CesiumConfig;
|
|
32
|
+
now?: () => Date;
|
|
33
|
+
nanoid?: () => string;
|
|
34
|
+
ensureRunning?: (cfg: LifecycleConfig) => Promise<RunningInfo | null>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TOOL_DESCRIPTION = `cesium_annotate — Publish an interactive review artifact where the user can leave
|
|
38
|
+
per-line and per-block comments, plus a final verdict (Approve / Request changes / Comment).
|
|
39
|
+
|
|
40
|
+
Use this when reviewing diffs, plans, PRDs, code proposals, RFCs, audits, or design docs —
|
|
41
|
+
any content where chat-based feedback would be lossy. The artifact is a self-contained .html
|
|
42
|
+
file with a comment rail and a sticky verdict footer.
|
|
43
|
+
|
|
44
|
+
When NOT to use:
|
|
45
|
+
- Short yes/no approvals → use cesium_ask with a react question instead.
|
|
46
|
+
- One-way broadcasts with no feedback needed → use cesium_publish.
|
|
47
|
+
|
|
48
|
+
Workflow:
|
|
49
|
+
1. Call cesium_annotate → get back { id, filePath, fileUrl, httpUrl, terminalSummary }.
|
|
50
|
+
2. Call cesium_wait with the returned id to block until the user finishes their review.
|
|
51
|
+
3. If the user requests changes, revise and publish a new cesium_annotate (or cesium_publish)
|
|
52
|
+
with supersedes pointing at the prior id.
|
|
53
|
+
|
|
54
|
+
Arguments:
|
|
55
|
+
- title: descriptive title (3–8 words).
|
|
56
|
+
- blocks: array of structured content blocks — all reviewable content lives here.
|
|
57
|
+
Call cesium_styleguide for the full block catalog.
|
|
58
|
+
- verdictMode: "approve" | "approve-or-reject" | "full" (default: "full").
|
|
59
|
+
"full" exposes Approve / Request changes / Comment buttons.
|
|
60
|
+
- perLineFor: block types that get per-line comment anchors (default: ["diff", "code"]).
|
|
61
|
+
- requireVerdict: whether the user must submit a verdict before completing (default: true).
|
|
62
|
+
- summary, tags, expiresAt: same semantics as cesium_publish.`;
|
|
63
|
+
|
|
64
|
+
export function createAnnotateTool(
|
|
65
|
+
ctx: PluginInput,
|
|
66
|
+
overrides?: AnnotateToolOverrides,
|
|
67
|
+
): ReturnType<typeof tool> {
|
|
68
|
+
const resolveConfig = overrides?.loadConfig ?? loadConfig;
|
|
69
|
+
const now = overrides?.now ?? (() => new Date());
|
|
70
|
+
const genId = overrides?.nanoid ?? defaultNanoid;
|
|
71
|
+
const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureServerRunning;
|
|
72
|
+
|
|
73
|
+
return tool({
|
|
74
|
+
description: TOOL_DESCRIPTION,
|
|
75
|
+
args: {
|
|
76
|
+
title: tool.schema.string(),
|
|
77
|
+
blocks: tool.schema.array(tool.schema.any()),
|
|
78
|
+
verdictMode: tool.schema
|
|
79
|
+
.enum(["approve", "approve-or-reject", "full"] as [string, ...string[]])
|
|
80
|
+
.optional(),
|
|
81
|
+
perLineFor: tool.schema.array(tool.schema.string()).optional(),
|
|
82
|
+
requireVerdict: tool.schema.boolean().optional(),
|
|
83
|
+
summary: tool.schema.string().optional(),
|
|
84
|
+
tags: tool.schema.array(tool.schema.string()).optional(),
|
|
85
|
+
expiresAt: tool.schema.string().optional(),
|
|
86
|
+
},
|
|
87
|
+
async execute(args, _context) {
|
|
88
|
+
// 1. Validate top-level input shape
|
|
89
|
+
const validation = validateAnnotateInput(args);
|
|
90
|
+
if (!validation.ok) {
|
|
91
|
+
return `Error: ${validation.error}`;
|
|
92
|
+
}
|
|
93
|
+
const input = validation.value;
|
|
94
|
+
|
|
95
|
+
// 2. Deep-validate block contents
|
|
96
|
+
const blocksValidation = validateBlocksArray(input.blocks);
|
|
97
|
+
if (!blocksValidation.ok) {
|
|
98
|
+
const errorMessages = blocksValidation.errors
|
|
99
|
+
.map((e) => `${e.path}: ${e.message}`)
|
|
100
|
+
.join("; ");
|
|
101
|
+
return `Error: blocks validation failed — ${errorMessages}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. Load config
|
|
105
|
+
const config = resolveConfig();
|
|
106
|
+
|
|
107
|
+
// 4. Probe git state
|
|
108
|
+
const shell = ctx.$.cwd(ctx.directory).nothrow();
|
|
109
|
+
let gitRemote: string | null = null;
|
|
110
|
+
let gitBranch: string | null = null;
|
|
111
|
+
let gitCommit: string | null = null;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const remoteResult = await shell`git config --get remote.origin.url`.quiet();
|
|
115
|
+
if (remoteResult.exitCode === 0) {
|
|
116
|
+
gitRemote = remoteResult.text().trim() || null;
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// not a git repo or no remote
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const branchResult = await shell`git rev-parse --abbrev-ref HEAD`.quiet();
|
|
124
|
+
if (branchResult.exitCode === 0) {
|
|
125
|
+
gitBranch = branchResult.text().trim() || null;
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// not a git repo
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const commitResult = await shell`git rev-parse HEAD`.quiet();
|
|
133
|
+
if (commitResult.exitCode === 0) {
|
|
134
|
+
gitCommit = commitResult.text().trim() || null;
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// not a git repo
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 5. Derive project identity
|
|
141
|
+
const identity = deriveProjectIdentity({
|
|
142
|
+
cwd: ctx.directory,
|
|
143
|
+
gitRemote,
|
|
144
|
+
worktree: ctx.worktree ?? null,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// 6. Generate id + timestamps
|
|
148
|
+
const id = genId();
|
|
149
|
+
const createdAt = now();
|
|
150
|
+
|
|
151
|
+
// 7. Defaults
|
|
152
|
+
const expiresAt =
|
|
153
|
+
input.expiresAt ?? new Date(createdAt.getTime() + 24 * 60 * 60 * 1000).toISOString();
|
|
154
|
+
const requireVerdict = input.requireVerdict ?? true;
|
|
155
|
+
const verdictMode = input.verdictMode ?? "full";
|
|
156
|
+
const perLineFor = input.perLineFor ?? ["diff", "code"];
|
|
157
|
+
|
|
158
|
+
// 8. Render blocks → trusted HTML (do NOT scrub — templated output is trusted)
|
|
159
|
+
const highlightTheme = resolveHighlightTheme(config.themePreset);
|
|
160
|
+
const bodyHtml = await renderBlocks(input.blocks, { highlightTheme });
|
|
161
|
+
|
|
162
|
+
// 8a. Extract body text for full-text search
|
|
163
|
+
const bodyText = extractTextContent(bodyHtml);
|
|
164
|
+
|
|
165
|
+
// 9. Content SHA-256 over the rendered HTML
|
|
166
|
+
const contentSha256 = createHash("sha256").update(bodyHtml).digest("hex");
|
|
167
|
+
|
|
168
|
+
// 10. Compute filename + paths
|
|
169
|
+
const filename = artifactFilename({ title: input.title, id, createdAt });
|
|
170
|
+
const paths = pathsFor({
|
|
171
|
+
stateDir: config.stateDir,
|
|
172
|
+
projectSlug: identity.slug,
|
|
173
|
+
filename,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// 11. Build ArtifactMeta
|
|
177
|
+
const meta: ArtifactMeta = {
|
|
178
|
+
id,
|
|
179
|
+
title: input.title,
|
|
180
|
+
kind: "annotate",
|
|
181
|
+
summary: input.summary ?? null,
|
|
182
|
+
tags: input.tags ?? [],
|
|
183
|
+
createdAt: createdAt.toISOString(),
|
|
184
|
+
model: null,
|
|
185
|
+
sessionId: null,
|
|
186
|
+
projectSlug: identity.slug,
|
|
187
|
+
projectName: identity.name,
|
|
188
|
+
cwd: ctx.directory,
|
|
189
|
+
worktree: identity.worktree,
|
|
190
|
+
gitBranch,
|
|
191
|
+
gitCommit,
|
|
192
|
+
supersedes: null,
|
|
193
|
+
supersededBy: null,
|
|
194
|
+
contentSha256,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// 12. Build interactive data
|
|
198
|
+
const interactive: InteractiveData = {
|
|
199
|
+
kind: "annotate",
|
|
200
|
+
status: "open",
|
|
201
|
+
expiresAt,
|
|
202
|
+
verdictMode,
|
|
203
|
+
requireVerdict,
|
|
204
|
+
perLineFor,
|
|
205
|
+
comments: [],
|
|
206
|
+
verdict: null,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// 13. Build theme + ensure theme.css and favicon
|
|
210
|
+
const theme = mergeTheme(themeFromPreset(config.themePreset), config.theme);
|
|
211
|
+
await ensureThemeCss(config.stateDir, theme);
|
|
212
|
+
await writeFaviconSvg(config.stateDir);
|
|
213
|
+
|
|
214
|
+
// 14. Wrap document
|
|
215
|
+
const fullHtml = wrapDocument({
|
|
216
|
+
body: bodyHtml,
|
|
217
|
+
meta,
|
|
218
|
+
theme,
|
|
219
|
+
warnings: [],
|
|
220
|
+
themeCssHref: "../../../theme.css",
|
|
221
|
+
interactive,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// 15. Atomic write
|
|
225
|
+
await atomicWrite(paths.artifactPath, fullHtml);
|
|
226
|
+
|
|
227
|
+
// 16. Build IndexEntry
|
|
228
|
+
const entry: IndexEntry = {
|
|
229
|
+
id: meta.id,
|
|
230
|
+
title: meta.title,
|
|
231
|
+
kind: meta.kind,
|
|
232
|
+
summary: meta.summary,
|
|
233
|
+
tags: meta.tags,
|
|
234
|
+
createdAt: meta.createdAt,
|
|
235
|
+
filename,
|
|
236
|
+
supersedes: null,
|
|
237
|
+
supersededBy: null,
|
|
238
|
+
gitBranch: meta.gitBranch,
|
|
239
|
+
gitCommit: meta.gitCommit,
|
|
240
|
+
contentSha256: meta.contentSha256,
|
|
241
|
+
projectSlug: identity.slug,
|
|
242
|
+
projectName: identity.name,
|
|
243
|
+
bodyText,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const lockPath = join(config.stateDir, ".index.lock");
|
|
247
|
+
|
|
248
|
+
await withLock({ lockPath }, async () => {
|
|
249
|
+
// 17. Update per-project index
|
|
250
|
+
const projectEntries = await loadIndex(paths.projectIndexJsonPath);
|
|
251
|
+
const updatedProjectEntries = appendEntry(projectEntries, entry);
|
|
252
|
+
await writeIndex(paths.projectIndexJsonPath, updatedProjectEntries);
|
|
253
|
+
|
|
254
|
+
// 18. Update global index
|
|
255
|
+
const globalEntries = await loadIndex(paths.globalIndexJsonPath);
|
|
256
|
+
const updatedGlobalEntries = appendEntry(globalEntries, entry);
|
|
257
|
+
await writeIndex(paths.globalIndexJsonPath, updatedGlobalEntries);
|
|
258
|
+
|
|
259
|
+
// 19. Render and write project index.html
|
|
260
|
+
const latestProjectEntries = await loadIndex(paths.projectIndexJsonPath);
|
|
261
|
+
const projectIndexHtml = renderProjectIndex({
|
|
262
|
+
projectSlug: identity.slug,
|
|
263
|
+
projectName: identity.name,
|
|
264
|
+
entries: latestProjectEntries,
|
|
265
|
+
theme,
|
|
266
|
+
themeCssHref: "../../theme.css",
|
|
267
|
+
});
|
|
268
|
+
await atomicWrite(paths.projectIndexPath, projectIndexHtml);
|
|
269
|
+
|
|
270
|
+
// 20. Render and write global index.html
|
|
271
|
+
const latestGlobalEntries = await loadIndex(paths.globalIndexJsonPath);
|
|
272
|
+
const projectSummaries = buildProjectSummaries(latestGlobalEntries);
|
|
273
|
+
const globalIndexHtml = renderGlobalIndex({
|
|
274
|
+
projects: projectSummaries,
|
|
275
|
+
theme,
|
|
276
|
+
themeCssHref: "theme.css",
|
|
277
|
+
});
|
|
278
|
+
await atomicWrite(paths.globalIndexPath, globalIndexHtml);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// 21. Start server (best-effort) and build URLs
|
|
282
|
+
let httpUrl: string | null = null;
|
|
283
|
+
let serverInfo: RunningInfo | null = null;
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const maybeInfo = await runEnsureRunning({
|
|
287
|
+
stateDir: config.stateDir,
|
|
288
|
+
port: config.port,
|
|
289
|
+
portMax: config.portMax,
|
|
290
|
+
idleTimeoutMs: config.idleTimeoutMs,
|
|
291
|
+
hostname: config.hostname,
|
|
292
|
+
theme,
|
|
293
|
+
});
|
|
294
|
+
if (maybeInfo !== null) {
|
|
295
|
+
serverInfo = maybeInfo;
|
|
296
|
+
const liveDisplay = resolveDisplayHost(config.hostname);
|
|
297
|
+
httpUrl = `http://${liveDisplay}:${serverInfo.port}${paths.serverPath}`;
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
httpUrl = null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const terminalSummary = buildTerminalSummary({
|
|
304
|
+
title: input.title,
|
|
305
|
+
kind: "annotate",
|
|
306
|
+
httpUrl: httpUrl ?? paths.fileUrl,
|
|
307
|
+
fileUrl: paths.fileUrl,
|
|
308
|
+
isSsh: Boolean(process.env["SSH_CONNECTION"]),
|
|
309
|
+
port: serverInfo?.port ?? config.port,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// 22. Return result
|
|
313
|
+
const result = {
|
|
314
|
+
id,
|
|
315
|
+
filePath: paths.artifactPath,
|
|
316
|
+
fileUrl: paths.fileUrl,
|
|
317
|
+
httpUrl,
|
|
318
|
+
terminalSummary,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
return JSON.stringify(result, null, 2);
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Default nanoid implementation using built-in crypto for alphanumeric IDs
|
|
327
|
+
function defaultNanoid(): string {
|
|
328
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
329
|
+
const bytes = new Uint8Array(6);
|
|
330
|
+
crypto.getRandomValues(bytes);
|
|
331
|
+
let result = "";
|
|
332
|
+
for (const byte of bytes) {
|
|
333
|
+
result += alphabet[byte % alphabet.length];
|
|
334
|
+
}
|
|
335
|
+
return result;
|
|
336
|
+
}
|
package/src/tools/ask.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { loadConfig, type CesiumConfig } from "../config.ts";
|
|
|
8
8
|
import { scrub } from "../render/scrub.ts";
|
|
9
9
|
import { extractTextContent } from "../render/extract.ts";
|
|
10
10
|
import { themeFromPreset, mergeTheme } from "../render/theme.ts";
|
|
11
|
-
import { validateAskInput
|
|
11
|
+
import { validateAskInput } from "../render/validate.ts";
|
|
12
12
|
import { wrapDocument, type ArtifactMeta } from "../render/wrap.ts";
|
|
13
13
|
import type { InteractiveData } from "../render/validate.ts";
|
|
14
14
|
import { deriveProjectIdentity, artifactFilename, pathsFor } from "../storage/paths.ts";
|
|
@@ -204,11 +204,11 @@ export function createAskTool(
|
|
|
204
204
|
supersedes: null,
|
|
205
205
|
supersededBy: null,
|
|
206
206
|
contentSha256,
|
|
207
|
-
inputMode: "html",
|
|
208
207
|
};
|
|
209
208
|
|
|
210
209
|
// 12. Build interactive data
|
|
211
210
|
const interactive: InteractiveData = {
|
|
211
|
+
kind: "ask",
|
|
212
212
|
status: "open",
|
|
213
213
|
requireAll,
|
|
214
214
|
expiresAt,
|
|
@@ -221,10 +221,6 @@ export function createAskTool(
|
|
|
221
221
|
if (scrubbed.removed.length > 0) {
|
|
222
222
|
warnings.push(`Removed ${scrubbed.removed.length} external resource(s) during scrub.`);
|
|
223
223
|
}
|
|
224
|
-
const bodyWarnings = htmlBodyWarnings(scrubbed.html);
|
|
225
|
-
for (const w of bodyWarnings) {
|
|
226
|
-
warnings.push(w);
|
|
227
|
-
}
|
|
228
224
|
|
|
229
225
|
// 14. Build theme + wrap document
|
|
230
226
|
const theme = mergeTheme(themeFromPreset(config.themePreset), config.theme);
|
package/src/tools/critique.ts
CHANGED
|
@@ -1,43 +1,34 @@
|
|
|
1
|
-
// Tool handler for cesium_critique —
|
|
2
|
-
// Accepts either { html: string } (html mode) or { blocks: Block[] } (blocks mode). Exactly one required.
|
|
1
|
+
// Tool handler for cesium_critique — analyzes a draft blocks array.
|
|
3
2
|
|
|
4
3
|
import { tool } from "@opencode-ai/plugin";
|
|
5
4
|
import type { PluginInput } from "@opencode-ai/plugin";
|
|
6
|
-
import {
|
|
7
|
-
critiqueHtml,
|
|
8
|
-
critiqueBlocks,
|
|
9
|
-
type CritiqueResult,
|
|
10
|
-
type CritiqueSeverity,
|
|
11
|
-
} from "../render/critique.ts";
|
|
5
|
+
import { critique, type CritiqueResult, type CritiqueSeverity } from "../render/critique.ts";
|
|
12
6
|
import type { Block } from "../render/blocks/types.ts";
|
|
13
7
|
|
|
14
|
-
const TOOL_DESCRIPTION = `Analyze a draft
|
|
15
|
-
system before publishing. Returns a 0-100 score and findings (warn/suggest/info).
|
|
8
|
+
const TOOL_DESCRIPTION = `Analyze a draft cesium artifact (blocks array) for adherence to the
|
|
9
|
+
cesium design system before publishing. Returns a 0-100 score and findings (warn/suggest/info).
|
|
16
10
|
|
|
17
|
-
Call this on
|
|
18
|
-
|
|
19
|
-
optional but usually worth applying. info-level findings are FYI.
|
|
11
|
+
Call this on substantive artifacts before \`cesium_publish\`. Address warn-level findings;
|
|
12
|
+
suggest-level findings are optional but usually worth applying. info-level findings are FYI.
|
|
20
13
|
|
|
21
|
-
The '
|
|
22
|
-
HTML only, no <!doctype>/<html>/<head>/<body> wrappers.`;
|
|
14
|
+
The 'blocks' argument is the same array you'd pass to cesium_publish.`;
|
|
23
15
|
|
|
24
16
|
/**
|
|
25
17
|
* Format a CritiqueResult into a concise human-readable string the agent can parse.
|
|
26
18
|
* Format:
|
|
27
19
|
* score: 87/100
|
|
28
|
-
* mode: html
|
|
29
20
|
*
|
|
30
21
|
* warn:
|
|
31
|
-
* - [
|
|
22
|
+
* - [raw-html-overuse] raw_html overuse: 3 raw_html blocks...
|
|
32
23
|
*
|
|
33
24
|
* suggest:
|
|
34
|
-
* - [
|
|
25
|
+
* - [missing-tldr] Document has 6 sections but no tldr block...
|
|
35
26
|
*
|
|
36
27
|
* info:
|
|
37
|
-
* - [code-without-
|
|
28
|
+
* - [code-without-meaningful-lang] Code block at ... uses lang "text"...
|
|
38
29
|
*/
|
|
39
30
|
export function formatCritiqueForAgent(result: CritiqueResult): string {
|
|
40
|
-
const lines: string[] = [`score: ${result.score}/100
|
|
31
|
+
const lines: string[] = [`score: ${result.score}/100`];
|
|
41
32
|
|
|
42
33
|
const bySeverity: Record<CritiqueSeverity, typeof result.findings> = {
|
|
43
34
|
warn: [],
|
|
@@ -67,34 +58,13 @@ export function createCritiqueTool(_ctx: PluginInput): ReturnType<typeof tool> {
|
|
|
67
58
|
return tool({
|
|
68
59
|
description: TOOL_DESCRIPTION,
|
|
69
60
|
args: {
|
|
70
|
-
|
|
71
|
-
blocks: tool.schema.any().optional(),
|
|
61
|
+
blocks: tool.schema.any(),
|
|
72
62
|
},
|
|
73
63
|
async execute(args) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (hasHtml && hasBlocks) {
|
|
78
|
-
return "error: provide exactly one of html or blocks, not both";
|
|
79
|
-
}
|
|
80
|
-
if (!hasHtml && !hasBlocks) {
|
|
81
|
-
return "error: provide exactly one of html or blocks";
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
let result: CritiqueResult;
|
|
85
|
-
|
|
86
|
-
if (hasHtml) {
|
|
87
|
-
if (typeof args.html !== "string") {
|
|
88
|
-
return "error: html must be a string";
|
|
89
|
-
}
|
|
90
|
-
result = critiqueHtml(args.html);
|
|
91
|
-
} else {
|
|
92
|
-
if (!Array.isArray(args.blocks)) {
|
|
93
|
-
return "error: blocks must be an array";
|
|
94
|
-
}
|
|
95
|
-
result = critiqueBlocks(args.blocks as Block[]);
|
|
64
|
+
if (!Array.isArray(args.blocks)) {
|
|
65
|
+
return "error: blocks must be an array";
|
|
96
66
|
}
|
|
97
|
-
|
|
67
|
+
const result = critique(args.blocks as Block[]);
|
|
98
68
|
return formatCritiqueForAgent(result);
|
|
99
69
|
},
|
|
100
70
|
});
|
package/src/tools/publish.ts
CHANGED
|
@@ -6,10 +6,9 @@ import { join } from "node:path";
|
|
|
6
6
|
import { tool } from "@opencode-ai/plugin";
|
|
7
7
|
import type { PluginInput } from "@opencode-ai/plugin";
|
|
8
8
|
import { loadConfig, type CesiumConfig } from "../config.ts";
|
|
9
|
-
import { scrub } from "../render/scrub.ts";
|
|
10
9
|
import { extractTextContent } from "../render/extract.ts";
|
|
11
10
|
import { themeFromPreset, mergeTheme } from "../render/theme.ts";
|
|
12
|
-
import { validatePublishInput,
|
|
11
|
+
import { validatePublishInput, PUBLISH_KINDS } from "../render/validate.ts";
|
|
13
12
|
import { renderBlocks } from "../render/blocks/render.ts";
|
|
14
13
|
import { resolveHighlightTheme } from "../render/blocks/highlight.ts";
|
|
15
14
|
import { wrapDocument, type ArtifactMeta } from "../render/wrap.ts";
|
|
@@ -74,29 +73,14 @@ When to stay in the terminal: short factual answers, status updates ("done", "fi
|
|
|
74
73
|
|
|
75
74
|
User overrides win: "/cesium" or "publish this" → publish; "in terminal" → don't.
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
- .card bordered surface block (1.5px border, 12px radius)
|
|
86
|
-
- .tldr clay-bordered summary box (use ONE per doc, near top)
|
|
87
|
-
- .callout info box; modifiers: .callout.note .callout.warn .callout.risk
|
|
88
|
-
- .code block-level code panel; inline highlights via .kw .str .cm .fn
|
|
89
|
-
- .timeline milestone list with dots and connectors
|
|
90
|
-
- .diagram wraps inline SVG with a caption below
|
|
91
|
-
- .compare-table bordered comparison grid
|
|
92
|
-
- .risk-table bordered risk-grid (likelihood/impact/mitigation columns)
|
|
93
|
-
- .kbd .pill .tag inline chips
|
|
94
|
-
- .byline rendered automatically as the footer
|
|
95
|
-
|
|
96
|
-
Inline \`style="..."\` and inline \`<svg>\` are encouraged for bespoke diagrams. NEVER reference
|
|
97
|
-
external resources: no <script src=>, no <link rel=stylesheet href=http>, no remote fonts,
|
|
98
|
-
no remote images. The plugin will silently strip external resources but the artifact will
|
|
99
|
-
look broken in the resulting render.
|
|
76
|
+
Content is described as a \`blocks\` array — a closed set of typed building blocks
|
|
77
|
+
(hero, tldr, section, prose, list, callout, code, diff, timeline, compare_table,
|
|
78
|
+
risk_table, kv, pill_row, divider, diagram, raw_html). Call \`cesium_styleguide\` for
|
|
79
|
+
the full block catalog with schemas and rendered examples.
|
|
80
|
+
|
|
81
|
+
The \`raw_html\` block is an escape hatch for content that doesn't fit the available
|
|
82
|
+
blocks; \`diagram\` is for inline SVG/HTML visualizations. Both have their payloads
|
|
83
|
+
scrubbed of external resources at publish time.
|
|
100
84
|
|
|
101
85
|
Title aim: 3-8 words, descriptive. Kind: pick the closest match.`;
|
|
102
86
|
|
|
@@ -114,15 +98,10 @@ export function createPublishTool(
|
|
|
114
98
|
args: {
|
|
115
99
|
title: tool.schema.string(),
|
|
116
100
|
kind: tool.schema.enum([...PUBLISH_KINDS] as [string, ...string[]]),
|
|
117
|
-
html: tool.schema
|
|
118
|
-
.string()
|
|
119
|
-
.optional()
|
|
120
|
-
.describe("Body HTML — escape valve / legacy mode. Provide exactly one of html or blocks."),
|
|
121
101
|
blocks: tool.schema
|
|
122
102
|
.array(tool.schema.any())
|
|
123
|
-
.optional()
|
|
124
103
|
.describe(
|
|
125
|
-
"Structured block array
|
|
104
|
+
"Structured block array describing the artifact's content. Call cesium_styleguide for the block catalog.",
|
|
126
105
|
),
|
|
127
106
|
summary: tool.schema.string().optional(),
|
|
128
107
|
tags: tool.schema.array(tool.schema.string()).optional(),
|
|
@@ -185,21 +164,9 @@ export function createPublishTool(
|
|
|
185
164
|
// 6. Timestamps
|
|
186
165
|
const createdAt = now();
|
|
187
166
|
|
|
188
|
-
// 7. Render body
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const inputMode: "html" | "blocks" = input.blocks !== undefined ? "blocks" : "html";
|
|
192
|
-
|
|
193
|
-
if (input.blocks !== undefined) {
|
|
194
|
-
// Blocks path: render structured blocks → trusted HTML
|
|
195
|
-
const highlightTheme = resolveHighlightTheme(config.themePreset);
|
|
196
|
-
bodyHtml = await renderBlocks(input.blocks, { highlightTheme });
|
|
197
|
-
} else {
|
|
198
|
-
// HTML path: scrub agent-supplied HTML
|
|
199
|
-
const scrubbed = scrub(input.html);
|
|
200
|
-
bodyHtml = scrubbed.html;
|
|
201
|
-
scrubRemovedCount = scrubbed.removed.length;
|
|
202
|
-
}
|
|
167
|
+
// 7. Render body from structured blocks
|
|
168
|
+
const highlightTheme = resolveHighlightTheme(config.themePreset);
|
|
169
|
+
const bodyHtml = await renderBlocks(input.blocks, { highlightTheme });
|
|
203
170
|
|
|
204
171
|
// 7a. Extract body text for full-text search
|
|
205
172
|
const bodyText = extractTextContent(bodyHtml);
|
|
@@ -234,18 +201,11 @@ export function createPublishTool(
|
|
|
234
201
|
supersedes: input.supersedes ?? null,
|
|
235
202
|
supersededBy: null,
|
|
236
203
|
contentSha256,
|
|
237
|
-
inputMode,
|
|
238
204
|
};
|
|
239
205
|
|
|
240
|
-
// 11. Build warnings
|
|
206
|
+
// 11. Build warnings (reserved for future use — block-mode publishes
|
|
207
|
+
// produce trusted templated output so there is nothing to warn about today).
|
|
241
208
|
const warnings: string[] = [];
|
|
242
|
-
if (scrubRemovedCount > 0) {
|
|
243
|
-
warnings.push(`Removed ${scrubRemovedCount} external resource(s) during scrub.`);
|
|
244
|
-
}
|
|
245
|
-
const bodyWarnings = input.html !== undefined ? htmlBodyWarnings(bodyHtml) : [];
|
|
246
|
-
for (const w of bodyWarnings) {
|
|
247
|
-
warnings.push(w);
|
|
248
|
-
}
|
|
249
209
|
|
|
250
210
|
// 12. Build theme + wrap document
|
|
251
211
|
const theme = mergeTheme(themeFromPreset(config.themePreset), config.theme);
|
|
@@ -282,7 +242,7 @@ export function createPublishTool(
|
|
|
282
242
|
projectSlug: identity.slug,
|
|
283
243
|
projectName: identity.name,
|
|
284
244
|
bodyText,
|
|
285
|
-
inputMode,
|
|
245
|
+
inputMode: "blocks",
|
|
286
246
|
};
|
|
287
247
|
|
|
288
248
|
const lockPath = join(config.stateDir, ".index.lock");
|
package/src/tools/styleguide.ts
CHANGED
|
@@ -8,7 +8,13 @@ import type { RenderCtx, SectionCounter } from "../render/blocks/render.ts";
|
|
|
8
8
|
|
|
9
9
|
function makeCtx(): RenderCtx {
|
|
10
10
|
const counter: SectionCounter = { value: 1 };
|
|
11
|
-
return {
|
|
11
|
+
return {
|
|
12
|
+
sectionCounter: counter,
|
|
13
|
+
depth: 0,
|
|
14
|
+
path: "blocks[0]",
|
|
15
|
+
highlightTheme: "claret-dark",
|
|
16
|
+
anchor: null,
|
|
17
|
+
};
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
/** Escape a string for safe insertion inside a markdown fenced code block. */
|