@crombolo/assistant 0.0.0-stage → 0.9.1

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/src/tools.ts ADDED
@@ -0,0 +1,522 @@
1
+ /**
2
+ * The tools the model is given: two read tools, and one propose tool per change
3
+ * kind. There is no mutating tool, so there is nothing for a prompt-injection
4
+ * payload in page content to reach for.
5
+ *
6
+ * A propose tool builds a complete Management API request and stores it. It never
7
+ * dispatches, so "the assistant changed something" is not a state this code can
8
+ * produce — only an approval, elsewhere, can.
9
+ */
10
+ import { type HttpMethod, listOperations, loadSpec, type OperationInfo } from '@crombolo/contracts'
11
+ import type { ChangesetStore, ProposedChange, SchemaProposalPort } from './changeset.ts'
12
+ import { scanTemplateSource } from './guardrails.ts'
13
+ import { APPLY_OPERATIONS, type ChangeKind, isReadable, readableOperations } from './operations.ts'
14
+
15
+ export interface ToolDefinition {
16
+ name: string
17
+ description: string
18
+ /** JSON Schema for the tool's input, as every provider wants it. */
19
+ inputSchema: Record<string, unknown>
20
+ }
21
+
22
+ export interface ToolResult {
23
+ ok: boolean
24
+ content: unknown
25
+ }
26
+
27
+ /** One Management API call, made as the signed-in user by the host. */
28
+ export type ManagementCall = (input: {
29
+ method: HttpMethod
30
+ path: string
31
+ query?: Readonly<Record<string, string>>
32
+ body?: unknown
33
+ }) => Promise<{ status: number; body: unknown }>
34
+
35
+ export interface ToolHostOptions {
36
+ call: ManagementCall
37
+ changesets: ChangesetStore
38
+ /** Reading and writing schema files; absent on a site with no schema directory. */
39
+ schema?: SchemaProposalPort
40
+ /**
41
+ * The changeset to append to, resolved on first use — an MCP client sends
42
+ * several requests before it proposes anything, and a changeset per handshake
43
+ * would fill the review list with empty ones.
44
+ */
45
+ changeset: () => Promise<string>
46
+ operations?: readonly OperationInfo[]
47
+ }
48
+
49
+ const VALUES = {
50
+ type: 'array',
51
+ description: 'Property values to set. Aliases not listed keep their current value.',
52
+ items: {
53
+ type: 'object',
54
+ properties: {
55
+ alias: { type: 'string' },
56
+ value: { description: 'Any JSON value the property editor accepts.' },
57
+ culture: { type: ['string', 'null'] },
58
+ segment: { type: ['string', 'null'] },
59
+ },
60
+ required: ['alias'],
61
+ },
62
+ } as const
63
+
64
+ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [
65
+ {
66
+ name: 'list_operations',
67
+ description:
68
+ 'List the read operations available, with their method and path. Call this to discover what can be queried before calling query.',
69
+ inputSchema: {
70
+ type: 'object',
71
+ properties: {
72
+ area: {
73
+ type: 'string',
74
+ description:
75
+ 'Restrict to one area, e.g. document, document-type, data-type, tree, item, template.',
76
+ },
77
+ },
78
+ },
79
+ },
80
+ {
81
+ name: 'query',
82
+ description:
83
+ 'Run a read operation from the management API and return its response. Only read operations are available; there is no way to change anything with this tool.',
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ operationId: { type: 'string', description: 'An operationId from list_operations.' },
88
+ params: {
89
+ type: 'object',
90
+ description:
91
+ 'Path parameters, keyed as the path template names them, e.g. { "id": "…" }.',
92
+ additionalProperties: { type: 'string' },
93
+ },
94
+ query: {
95
+ type: 'object',
96
+ description: 'Query-string parameters, e.g. { "skip": "0", "take": "100" }.',
97
+ additionalProperties: { type: 'string' },
98
+ },
99
+ body: { description: 'Request body, for the few read operations that take one.' },
100
+ },
101
+ required: ['operationId'],
102
+ },
103
+ },
104
+ {
105
+ name: 'propose_document_update',
106
+ description:
107
+ 'Propose changes to an existing page. Saved as a draft when the user approves it; you cannot publish.',
108
+ inputSchema: {
109
+ type: 'object',
110
+ properties: {
111
+ documentId: { type: 'string' },
112
+ summary: {
113
+ type: 'string',
114
+ description: 'One line describing the change, for the reviewer.',
115
+ },
116
+ name: { type: 'string', description: 'A new name for the page, if it should change.' },
117
+ culture: {
118
+ type: ['string', 'null'],
119
+ description: 'The culture a new name applies to, for a culture-variant page.',
120
+ },
121
+ values: VALUES,
122
+ },
123
+ required: ['documentId', 'summary'],
124
+ },
125
+ },
126
+ {
127
+ name: 'propose_document_create',
128
+ description:
129
+ 'Propose a new page. Created unpublished when the user approves it. Check the parent type allows this type as a child first.',
130
+ inputSchema: {
131
+ type: 'object',
132
+ properties: {
133
+ contentTypeId: { type: 'string' },
134
+ parentId: {
135
+ type: ['string', 'null'],
136
+ description: 'Omit or null to create at the root, which the type must allow.',
137
+ },
138
+ name: { type: 'string' },
139
+ summary: { type: 'string' },
140
+ templateId: { type: ['string', 'null'] },
141
+ values: VALUES,
142
+ },
143
+ required: ['contentTypeId', 'name', 'summary'],
144
+ },
145
+ },
146
+ {
147
+ name: 'propose_template',
148
+ description:
149
+ 'Propose template source. TSX only: import from "@crombolo/crombolo" or a file beside it, and no Bun, process, fetch, eval or node: imports. Approving this goes live at once, so say what it changes.',
150
+ inputSchema: {
151
+ type: 'object',
152
+ properties: {
153
+ templateId: {
154
+ type: ['string', 'null'],
155
+ description: 'The template to change; omit to propose a new one.',
156
+ },
157
+ name: { type: 'string' },
158
+ alias: { type: 'string' },
159
+ content: { type: 'string', description: 'The complete TSX source, not a fragment.' },
160
+ summary: { type: 'string' },
161
+ },
162
+ required: ['name', 'content', 'summary'],
163
+ },
164
+ },
165
+ {
166
+ name: 'propose_content_type',
167
+ description:
168
+ 'Propose a content type as the TOML file it is stored in \u2014 the file that gets committed. Write it as it should read: aliases rather than ids, one [[property]] table per property. Call read_schema first for a type that exists and change what it gives you.',
169
+ inputSchema: {
170
+ type: 'object',
171
+ properties: {
172
+ alias: { type: 'string', description: 'The type\u2019s alias, which names its file.' },
173
+ toml: {
174
+ type: 'string',
175
+ description:
176
+ 'The whole file, not a fragment: a [content-type] table with alias and name, then a [[property]] table per property whose `type` is a data type alias such as textstring or textarea.',
177
+ },
178
+ summary: { type: 'string' },
179
+ },
180
+ required: ['alias', 'toml', 'summary'],
181
+ },
182
+ },
183
+ {
184
+ name: 'propose_data_type',
185
+ description:
186
+ 'Propose a data type (a configured property editor) as the TOML file it is stored in.',
187
+ inputSchema: {
188
+ type: 'object',
189
+ properties: {
190
+ alias: { type: 'string' },
191
+ toml: {
192
+ type: 'string',
193
+ description: 'The whole file: a [data-type] table with alias, name and editor.',
194
+ },
195
+ summary: { type: 'string' },
196
+ },
197
+ required: ['alias', 'toml', 'summary'],
198
+ },
199
+ },
200
+ {
201
+ name: 'read_schema',
202
+ description:
203
+ 'The TOML of a content type or data type as it is on disk now, so it can be changed rather than rewritten from nothing. Empty when the type does not exist yet.',
204
+ inputSchema: {
205
+ type: 'object',
206
+ properties: {
207
+ kind: { type: 'string', enum: ['document-type', 'data-type'] },
208
+ alias: { type: 'string' },
209
+ },
210
+ required: ['kind', 'alias'],
211
+ },
212
+ },
213
+ ]
214
+
215
+ const ok = (content: unknown): ToolResult => ({ ok: true, content })
216
+ const fail = (message: string): ToolResult => ({ ok: false, content: { error: message } })
217
+
218
+ /** Fills `{id}`-style placeholders; a missing parameter is an error, not an empty segment. */
219
+ function fillPath(template: string, params: Readonly<Record<string, string>>): string | undefined {
220
+ let missing = false
221
+ const path = template.replace(/\{([^}]+)\}/g, (_, name: string) => {
222
+ const value = params[name]
223
+ if (value === undefined || value === '') {
224
+ missing = true
225
+ return ''
226
+ }
227
+ return encodeURIComponent(value)
228
+ })
229
+ return missing ? undefined : path
230
+ }
231
+
232
+ async function hash(value: string): Promise<string> {
233
+ return new Bun.CryptoHasher('sha256').update(value).digest('hex')
234
+ }
235
+
236
+ /** What the entity looked like when proposed, so apply can refuse a stale write. */
237
+ const baselineOf = (body: unknown): Promise<string> => hash(JSON.stringify(body ?? null))
238
+
239
+ const str = (input: Record<string, unknown>, name: string): string | undefined => {
240
+ const value = input[name]
241
+ return typeof value === 'string' && value !== '' ? value : undefined
242
+ }
243
+
244
+ interface ValueEntry {
245
+ alias: string
246
+ value?: unknown
247
+ culture?: string | null
248
+ segment?: string | null
249
+ }
250
+
251
+ const sameSlot = (a: ValueEntry, b: ValueEntry) =>
252
+ a.alias === b.alias &&
253
+ (a.culture ?? null) === (b.culture ?? null) &&
254
+ (a.segment ?? null) === (b.segment ?? null)
255
+
256
+ /** Proposed values over the document's current ones, so an unnamed alias is left alone. */
257
+ function mergeValues(
258
+ current: readonly ValueEntry[],
259
+ proposed: readonly ValueEntry[],
260
+ ): ValueEntry[] {
261
+ const merged = current.map((entry) => ({ ...entry }))
262
+ for (const entry of proposed) {
263
+ const at = merged.findIndex((existing) => sameSlot(existing, entry))
264
+ const next = {
265
+ alias: entry.alias,
266
+ value: entry.value,
267
+ culture: entry.culture ?? null,
268
+ segment: entry.segment ?? null,
269
+ }
270
+ if (at === -1) merged.push(next)
271
+ else merged[at] = next
272
+ }
273
+ return merged
274
+ }
275
+
276
+ export function createToolHost(options: ToolHostOptions) {
277
+ const operations = options.operations ?? listOperations(loadSpec())
278
+ const byId = new Map(operations.map((operation) => [operation.operationId, operation]))
279
+ const readable = readableOperations(operations)
280
+
281
+ /** A read, refused here rather than relied on to be refused downstream. */
282
+ const read = async (
283
+ operationId: string,
284
+ params: Readonly<Record<string, string>> = {},
285
+ query?: Readonly<Record<string, string>>,
286
+ body?: unknown,
287
+ ): Promise<{ status: number; body: unknown } | { error: string }> => {
288
+ const operation = byId.get(operationId)
289
+ if (!operation) return { error: `${operationId} is not an operation in the contract` }
290
+ if (!isReadable(operation)) return { error: `${operationId} is not available to the assistant` }
291
+ const path = fillPath(operation.path, params)
292
+ if (path === undefined)
293
+ return { error: `${operationId} needs path parameters: ${operation.path}` }
294
+ return await options.call({ method: operation.method, path, query, body })
295
+ }
296
+
297
+ const append = async (
298
+ kind: ChangeKind,
299
+ input: {
300
+ summary: string
301
+ params: Record<string, string>
302
+ body: unknown
303
+ baseline: string | undefined
304
+ problems?: ProposedChange['problems']
305
+ },
306
+ ): Promise<ToolResult> => {
307
+ const change: ProposedChange = {
308
+ key: crypto.randomUUID(),
309
+ kind,
310
+ summary: input.summary,
311
+ params: input.params,
312
+ body: input.body,
313
+ baseline: input.baseline,
314
+ problems: input.problems ?? [],
315
+ status: 'proposed',
316
+ error: undefined,
317
+ createDate: new Date().toISOString(),
318
+ }
319
+ await options.changesets.append(await options.changeset(), change)
320
+ return ok({
321
+ proposed: change.key,
322
+ kind,
323
+ operation: APPLY_OPERATIONS[kind],
324
+ problems: change.problems,
325
+ awaiting:
326
+ change.problems.length > 0
327
+ ? 'This proposal has problems and cannot be approved until they are fixed.'
328
+ : 'Waiting for the user to review and approve it. Nothing has changed yet.',
329
+ })
330
+ }
331
+
332
+ const proposeDocumentUpdate = async (input: Record<string, unknown>): Promise<ToolResult> => {
333
+ const id = str(input, 'documentId')
334
+ const summary = str(input, 'summary')
335
+ if (!id || !summary) return fail('documentId and summary are required')
336
+
337
+ const current = await read('GetDocumentById', { id })
338
+ if ('error' in current) return fail(current.error)
339
+ if (current.status !== 200) return fail(`could not read document ${id} (${current.status})`)
340
+
341
+ const document = current.body as {
342
+ values?: ValueEntry[]
343
+ variants?: { culture?: string | null; segment?: string | null; name?: string }[]
344
+ documentType?: { id?: string }
345
+ template?: { id?: string } | null
346
+ }
347
+ const name = str(input, 'name')
348
+ const culture = typeof input.culture === 'string' ? input.culture : null
349
+ const variants = (document.variants ?? []).map((variant) => ({
350
+ culture: variant.culture ?? null,
351
+ segment: variant.segment ?? null,
352
+ name:
353
+ name !== undefined && (variant.culture ?? null) === culture ? name : (variant.name ?? ''),
354
+ }))
355
+
356
+ const values = mergeValues(
357
+ document.values ?? [],
358
+ Array.isArray(input.values) ? (input.values as ValueEntry[]) : [],
359
+ )
360
+
361
+ return await append('document', {
362
+ summary,
363
+ params: { id },
364
+ // The template is carried through deliberately: the update handler reads it
365
+ // from the body, so leaving it out would clear the page's template and stop
366
+ // it rendering. A partial proposal still has to be a complete request.
367
+ body: { values, variants, template: document.template ?? null },
368
+ baseline: await baselineOf(current.body),
369
+ })
370
+ }
371
+
372
+ const proposeDocumentCreate = async (input: Record<string, unknown>): Promise<ToolResult> => {
373
+ const contentTypeId = str(input, 'contentTypeId')
374
+ const name = str(input, 'name')
375
+ const summary = str(input, 'summary')
376
+ if (!contentTypeId || !name || !summary) {
377
+ return fail('contentTypeId, name and summary are required')
378
+ }
379
+ const parentId = str(input, 'parentId')
380
+ const values = Array.isArray(input.values) ? (input.values as ValueEntry[]) : []
381
+
382
+ // Without a template a page has nothing to render with, and the model has no
383
+ // reason to know which one. The backoffice's create dialog preselects the
384
+ // type's default; an omitted template does the same rather than sending null.
385
+ let templateId = str(input, 'templateId')
386
+ if (!templateId) {
387
+ const type = await read('GetDocumentTypeById', { id: contentTypeId })
388
+ if (!('error' in type) && type.status === 200) {
389
+ // The management API's own field name, which is Umbraco's contract.
390
+ templateId = (type.body as { defaultTemplate?: { id?: string } | null }).defaultTemplate?.id
391
+ }
392
+ }
393
+
394
+ return await append('document-create', {
395
+ summary,
396
+ params: {},
397
+ body: {
398
+ documentType: { id: contentTypeId },
399
+ parent: parentId ? { id: parentId } : null,
400
+ template: templateId ? { id: templateId } : null,
401
+ values: values.map((entry) => ({
402
+ alias: entry.alias,
403
+ value: entry.value,
404
+ culture: entry.culture ?? null,
405
+ segment: entry.segment ?? null,
406
+ })),
407
+ variants: [{ culture: null, segment: null, name }],
408
+ },
409
+ baseline: undefined,
410
+ })
411
+ }
412
+
413
+ const proposeTemplate = async (input: Record<string, unknown>): Promise<ToolResult> => {
414
+ const name = str(input, 'name')
415
+ const summary = str(input, 'summary')
416
+ const content = typeof input.content === 'string' ? input.content : undefined
417
+ if (!name || !summary || content === undefined) {
418
+ return fail('name, content and summary are required')
419
+ }
420
+ const templateId = str(input, 'templateId')
421
+ const alias = str(input, 'alias') ?? name.replace(/[^A-Za-z0-9]+/g, '')
422
+
423
+ let baseline: string | undefined
424
+ if (templateId) {
425
+ const current = await read('GetTemplateById', { id: templateId })
426
+ if ('error' in current) return fail(current.error)
427
+ if (current.status !== 200) return fail(`could not read template ${templateId}`)
428
+ baseline = await baselineOf((current.body as { content?: string | null }).content ?? null)
429
+ }
430
+
431
+ const problems = scanTemplateSource(`${alias}.tsx`, content)
432
+ return await append(templateId ? 'template' : 'template-create', {
433
+ summary,
434
+ params: templateId ? { id: templateId } : {},
435
+ body: { name, alias, content },
436
+ baseline,
437
+ problems,
438
+ })
439
+ }
440
+
441
+ /**
442
+ * A schema proposal is the TOML file, validated against the rest of the schema
443
+ * before anyone is asked to approve it, so a reviewer is never shown something
444
+ * that cannot be applied.
445
+ */
446
+ const proposeSchema = async (
447
+ input: Record<string, unknown>,
448
+ base: 'document-type' | 'data-type',
449
+ ): Promise<ToolResult> => {
450
+ const alias = str(input, 'alias')
451
+ const toml = typeof input.toml === 'string' ? input.toml : undefined
452
+ const summary = str(input, 'summary')
453
+ if (!alias || !toml || !summary) return fail('alias, toml and summary are required')
454
+ if (!options.schema) return fail('this site does not manage schema as files')
455
+
456
+ const current = await options.schema.read(base, alias)
457
+ const kind = (current === undefined ? `${base}-create` : base) as ChangeKind
458
+ const problems = await options.schema.validate(kind, alias, toml)
459
+
460
+ return await append(kind, {
461
+ summary,
462
+ params: {},
463
+ body: { alias, toml, file: options.schema.fileFor(base, alias) },
464
+ // The file as it stands, so an approval refuses if somebody else changed it.
465
+ baseline: current === undefined ? undefined : await baselineOf(current),
466
+ problems,
467
+ })
468
+ }
469
+
470
+ const execute = async (name: string, input: Record<string, unknown>): Promise<ToolResult> => {
471
+ switch (name) {
472
+ case 'list_operations': {
473
+ const area = str(input, 'area')
474
+ const list = readable.filter((operation) => !area || operation.area === area)
475
+ return ok(
476
+ list.map((operation) => ({
477
+ operationId: operation.operationId,
478
+ method: operation.method,
479
+ path: operation.path,
480
+ })),
481
+ )
482
+ }
483
+ case 'query': {
484
+ const operationId = str(input, 'operationId')
485
+ if (!operationId) return fail('operationId is required')
486
+ const result = await read(
487
+ operationId,
488
+ (input.params as Record<string, string>) ?? {},
489
+ (input.query as Record<string, string>) ?? undefined,
490
+ input.body,
491
+ )
492
+ return 'error' in result ? fail(result.error) : ok(result)
493
+ }
494
+ case 'propose_document_update':
495
+ return await proposeDocumentUpdate(input)
496
+ case 'propose_document_create':
497
+ return await proposeDocumentCreate(input)
498
+ case 'propose_template':
499
+ return await proposeTemplate(input)
500
+ case 'read_schema': {
501
+ const kind = str(input, 'kind')
502
+ const alias = str(input, 'alias')
503
+ if (!kind || !alias) return fail('kind and alias are required')
504
+ if (!options.schema) return fail('this site does not manage schema as files')
505
+ const toml = await options.schema.read(kind, alias)
506
+ return ok({
507
+ file: options.schema.fileFor(kind, alias),
508
+ exists: toml !== undefined,
509
+ toml: toml ?? '',
510
+ })
511
+ }
512
+ case 'propose_content_type':
513
+ return await proposeSchema(input, 'document-type')
514
+ case 'propose_data_type':
515
+ return await proposeSchema(input, 'data-type')
516
+ default:
517
+ return fail(`${name} is not a tool the assistant has`)
518
+ }
519
+ }
520
+
521
+ return { definitions: TOOL_DEFINITIONS, execute }
522
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.