@jbarket/loomai 0.7.0 → 0.9.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.
@@ -0,0 +1,360 @@
1
+ /**
2
+ * The page vocabulary (docs/extensions.md, "The page").
3
+ *
4
+ * loom's page is built from these shapes, both loom's own pages and the
5
+ * installed pieces'. A piece answers `ui --json` (a manifest), `ui page --json`
6
+ * (a page) and `ui act --json` (an action's result) in this vocabulary. Pieces
7
+ * send data, never markup: loom renders every block itself and escapes every
8
+ * string, so a piece can't put HTML or script on loom's page. Anything that
9
+ * doesn't validate is dropped whole (a piece that sends garbage just isn't
10
+ * shown), never half-rendered.
11
+ */
12
+ export const LIMITS = {
13
+ sections: 10,
14
+ blocks: 20,
15
+ items: 200,
16
+ columns: 12,
17
+ string: 2000,
18
+ asks: 10,
19
+ fields: 30,
20
+ hidden: 20,
21
+ options: 50,
22
+ query: 10,
23
+ queryValue: 200,
24
+ path: 200,
25
+ };
26
+ const TONES = new Set(['ok', 'warn', 'bad', 'muted']);
27
+ export const ID = /^[a-z][a-z0-9-]{0,39}$/;
28
+ const NAME = /^[a-z][a-z0-9_-]{0,39}$/;
29
+ export const QUERY_KEY = /^[a-z][a-z0-9_-]{0,31}$/;
30
+ const PATH_CHARS = /^[a-z0-9._~/-]*$/;
31
+ class Invalid extends Error {
32
+ }
33
+ /** loom's own pages a link may name with `loom:`. */
34
+ const CORE_PAGES = /^(|mind|people|people\/[a-z0-9][a-z0-9-]*|memory\/.+)$/;
35
+ /** A page path: `""` (the main page) or lowercase segments, never `..`, never absolute. */
36
+ export function validPath(p) {
37
+ if (p.length > LIMITS.path || !PATH_CHARS.test(p))
38
+ return false;
39
+ if (p === '')
40
+ return true;
41
+ if (p.startsWith('/') || p.endsWith('/'))
42
+ return false;
43
+ return p.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
44
+ }
45
+ function str(v, what) {
46
+ if (typeof v === 'number' && Number.isFinite(v))
47
+ return String(v);
48
+ if (typeof v !== 'string')
49
+ throw new Invalid(`${what} must be a string`);
50
+ if (v.length > LIMITS.string)
51
+ throw new Invalid(`${what} is longer than ${LIMITS.string}`);
52
+ return v;
53
+ }
54
+ function optStr(v, what) {
55
+ return v === undefined || v === null ? undefined : str(v, what);
56
+ }
57
+ function bool(v) {
58
+ return v === undefined ? undefined : v === true;
59
+ }
60
+ function tone(v) {
61
+ if (v === undefined || v === null)
62
+ return undefined;
63
+ if (typeof v !== 'string' || !TONES.has(v))
64
+ throw new Invalid(`tone must be one of ${[...TONES].join(', ')}`);
65
+ return v;
66
+ }
67
+ function arr(v, what, max) {
68
+ if (!Array.isArray(v))
69
+ throw new Invalid(`${what} must be an array`);
70
+ if (v.length > max)
71
+ throw new Invalid(`${what} has more than ${max} entries`);
72
+ return v;
73
+ }
74
+ function obj(v, what) {
75
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
76
+ throw new Invalid(`${what} must be an object`);
77
+ return v;
78
+ }
79
+ function name(v, what) {
80
+ const s = str(v, what);
81
+ if (!NAME.test(s))
82
+ throw new Invalid(`${what} ${JSON.stringify(s)} must be lowercase letters, digits, _ and -`);
83
+ return s;
84
+ }
85
+ /**
86
+ * A page link: `[loom:]<path>[?<query>]`. Split into the source (loom's own or
87
+ * this piece's), the path and the query, or null when it isn't valid.
88
+ */
89
+ export function splitPage(s) {
90
+ const core = s.startsWith('loom:');
91
+ const rest = core ? s.slice(5) : s;
92
+ const q = rest.indexOf('?');
93
+ const path = q === -1 ? rest : rest.slice(0, q);
94
+ if (!validPath(path))
95
+ return null;
96
+ // Into loom, only loom's own pages: not another piece's, and not login,
97
+ // logout, actions or the JSON endpoints.
98
+ if (core && !CORE_PAGES.test(path))
99
+ return null;
100
+ const query = {};
101
+ if (q !== -1) {
102
+ const params = new URLSearchParams(rest.slice(q + 1));
103
+ let n = 0;
104
+ for (const [k, v] of params) {
105
+ if (!QUERY_KEY.test(k) || v.length > LIMITS.queryValue || ++n > LIMITS.query)
106
+ return null;
107
+ query[k] = v;
108
+ }
109
+ }
110
+ return { core, path, query };
111
+ }
112
+ /** A link to a page: this source's path, or `loom:<path>` for one of loom's own, either with an optional query. */
113
+ function page(v, what) {
114
+ if (v === undefined || v === null)
115
+ return undefined;
116
+ const s = str(v, what);
117
+ if (!splitPage(s))
118
+ throw new Invalid(`${what} ${JSON.stringify(s)} is not a valid page link`);
119
+ return s;
120
+ }
121
+ /** Only http(s): no javascript:, data:, or relative tricks. */
122
+ function href(v, what) {
123
+ if (v === undefined || v === null)
124
+ return undefined;
125
+ const s = str(v, what);
126
+ let url;
127
+ try {
128
+ url = new URL(s);
129
+ }
130
+ catch {
131
+ throw new Invalid(`${what} must be an absolute URL`);
132
+ }
133
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
134
+ throw new Invalid(`${what} must be http(s)`);
135
+ return url.href;
136
+ }
137
+ function target(o, what) {
138
+ const p = page(o.page, `${what}.page`);
139
+ const h = href(o.href, `${what}.href`);
140
+ if (p !== undefined && h !== undefined)
141
+ throw new Invalid(`${what} has both page and href`);
142
+ return { ...(p !== undefined ? { page: p } : {}), ...(h !== undefined ? { href: h } : {}) };
143
+ }
144
+ function strMap(v, what, max) {
145
+ if (v === undefined || v === null)
146
+ return undefined;
147
+ const o = obj(v, what);
148
+ const keys = Object.keys(o);
149
+ if (keys.length > max)
150
+ throw new Invalid(`${what} has more than ${max} entries`);
151
+ const out = {};
152
+ for (const k of keys)
153
+ out[name(k, `${what} key`)] = str(o[k], `${what}.${k}`);
154
+ return out;
155
+ }
156
+ function field(v) {
157
+ const o = obj(v, 'field');
158
+ const kind = o.kind;
159
+ if (kind !== 'text' && kind !== 'textarea' && kind !== 'choice' && kind !== 'secret') {
160
+ throw new Invalid('field.kind must be text, textarea, choice or secret');
161
+ }
162
+ const f = { name: name(o.name, 'field.name'), label: str(o.label, 'field.label'), kind };
163
+ const hint = optStr(o.hint, 'field.hint');
164
+ if (hint !== undefined)
165
+ f.hint = hint;
166
+ // A secret field never carries a value: the page must not be able to show one.
167
+ if (kind !== 'secret') {
168
+ const value = optStr(o.value, 'field.value');
169
+ if (value !== undefined)
170
+ f.value = value;
171
+ }
172
+ if (bool(o.required))
173
+ f.required = true;
174
+ if (kind === 'choice') {
175
+ f.options = arr(o.options, 'field.options', LIMITS.options).map((x) => {
176
+ const opt = obj(x, 'option');
177
+ return { value: str(opt.value, 'option.value'), label: str(opt.label, 'option.label'), detail: optStr(opt.detail, 'option.detail') };
178
+ });
179
+ }
180
+ return f;
181
+ }
182
+ function block(v, depth = 0) {
183
+ const b = obj(v, 'block');
184
+ switch (b.type) {
185
+ case 'stats':
186
+ return {
187
+ type: 'stats',
188
+ items: arr(b.items, 'stats.items', LIMITS.items).map((i) => {
189
+ const o = obj(i, 'stat');
190
+ return {
191
+ label: str(o.label, 'stat.label'), value: str(o.value, 'stat.value'),
192
+ detail: optStr(o.detail, 'stat.detail'), tone: tone(o.tone), page: page(o.page, 'stat.page'),
193
+ };
194
+ }),
195
+ };
196
+ case 'list':
197
+ return {
198
+ type: 'list',
199
+ items: arr(b.items, 'list.items', LIMITS.items).map((i) => {
200
+ const o = obj(i, 'list item');
201
+ return {
202
+ text: str(o.text, 'list.text'), meta: optStr(o.meta, 'list.meta'), badge: optStr(o.badge, 'list.badge'),
203
+ tone: tone(o.tone), ...target(o, 'list item'),
204
+ };
205
+ }),
206
+ };
207
+ case 'table': {
208
+ const columns = arr(b.columns, 'table.columns', LIMITS.columns).map((c) => str(c, 'table column'));
209
+ const rows = arr(b.rows, 'table.rows', LIMITS.items).map((r) => arr(r, 'table row', columns.length).map((c) => {
210
+ if (typeof c === 'string' || typeof c === 'number')
211
+ return str(c, 'table cell');
212
+ const o = obj(c, 'table cell');
213
+ const p = page(o.page, 'table cell.page');
214
+ return p !== undefined ? { text: str(o.text, 'table cell.text'), page: p } : str(o.text, 'table cell.text');
215
+ }));
216
+ return { type: 'table', columns, rows };
217
+ }
218
+ case 'kv':
219
+ return {
220
+ type: 'kv',
221
+ items: arr(b.items, 'kv.items', LIMITS.items).map((i) => {
222
+ const o = obj(i, 'kv item');
223
+ return { label: str(o.label, 'kv.label'), value: str(o.value, 'kv.value'), page: page(o.page, 'kv.page') };
224
+ }),
225
+ };
226
+ case 'text':
227
+ return { type: 'text', text: str(b.text, 'text'), tone: tone(b.tone) };
228
+ case 'callout':
229
+ return { type: 'callout', text: str(b.text, 'callout.text'), title: optStr(b.title, 'callout.title'), tone: tone(b.tone) };
230
+ case 'link': {
231
+ const t = target(b, 'link');
232
+ if (t.page === undefined && t.href === undefined)
233
+ throw new Invalid('link needs page or href');
234
+ return { type: 'link', text: str(b.text, 'link.text'), ...t };
235
+ }
236
+ case 'filters': {
237
+ const param = str(b.param, 'filters.param');
238
+ if (!QUERY_KEY.test(param))
239
+ throw new Invalid('filters.param must be a query key');
240
+ return {
241
+ type: 'filters',
242
+ param,
243
+ options: arr(b.options, 'filters.options', LIMITS.options).map((x) => {
244
+ const o = obj(x, 'filter option');
245
+ const value = str(o.value, 'filter.value');
246
+ if (value.length > LIMITS.queryValue)
247
+ throw new Invalid('filter.value is too long');
248
+ return { value, label: str(o.label, 'filter.label') };
249
+ }),
250
+ };
251
+ }
252
+ case 'details':
253
+ if (depth > 0)
254
+ throw new Invalid('details can\'t contain details');
255
+ return {
256
+ type: 'details',
257
+ summary: str(b.summary, 'details.summary'),
258
+ meta: optStr(b.meta, 'details.meta'),
259
+ blocks: arr(b.blocks, 'details.blocks', LIMITS.blocks).map((x) => block(x, depth + 1)),
260
+ };
261
+ case 'form':
262
+ return {
263
+ type: 'form',
264
+ action: name(b.action, 'form.action'),
265
+ submit: str(b.submit, 'form.submit'),
266
+ fields: arr(b.fields, 'form.fields', LIMITS.fields).map(field),
267
+ title: optStr(b.title, 'form.title'),
268
+ note: optStr(b.note, 'form.note'),
269
+ hidden: strMap(b.hidden, 'form.hidden', LIMITS.hidden),
270
+ };
271
+ case 'buttons':
272
+ return {
273
+ type: 'buttons',
274
+ items: arr(b.items, 'buttons.items', LIMITS.fields).map((x) => {
275
+ const o = obj(x, 'button');
276
+ const t = o.tone;
277
+ if (t !== undefined && t !== 'primary' && t !== 'danger')
278
+ throw new Invalid('button.tone must be primary or danger');
279
+ return {
280
+ action: name(o.action, 'button.action'),
281
+ label: str(o.label, 'button.label'),
282
+ tone: t,
283
+ fields: strMap(o.fields, 'button.fields', LIMITS.hidden),
284
+ };
285
+ }),
286
+ };
287
+ default:
288
+ throw new Invalid(`unknown block type ${JSON.stringify(b.type)}`);
289
+ }
290
+ }
291
+ function section(v) {
292
+ const o = obj(v, 'section');
293
+ const id = str(o.id, 'section.id');
294
+ if (!ID.test(id))
295
+ throw new Invalid(`section.id ${JSON.stringify(id)} must be lowercase letters, digits and dashes`);
296
+ return {
297
+ id,
298
+ title: optStr(o.title, 'section.title'),
299
+ side: bool(o.side),
300
+ tone: tone(o.tone),
301
+ blocks: arr(o.blocks, 'section.blocks', LIMITS.blocks).map((x) => block(x)),
302
+ };
303
+ }
304
+ function sections(v) {
305
+ return arr(v, 'sections', LIMITS.sections).map(section);
306
+ }
307
+ function attempt(f) {
308
+ try {
309
+ return f();
310
+ }
311
+ catch (err) {
312
+ if (err instanceof Invalid)
313
+ return err.message;
314
+ throw err;
315
+ }
316
+ }
317
+ /** Validate a piece's `ui --json` answer (v2, or a first-version `{sections}`). */
318
+ export function parseManifest(value) {
319
+ return attempt(() => {
320
+ const top = obj(value, 'answer');
321
+ if (top.v === undefined && top.sections !== undefined) {
322
+ return { asks: [], legacy: sections(top.sections) };
323
+ }
324
+ if (top.v !== 2)
325
+ throw new Invalid('v must be 2');
326
+ const m = { asks: [] };
327
+ if (top.tab !== undefined)
328
+ m.tab = { label: str(obj(top.tab, 'tab').label, 'tab.label') };
329
+ if (top.tile !== undefined) {
330
+ const t = obj(top.tile, 'tile');
331
+ m.tile = {
332
+ label: str(t.label, 'tile.label'), value: str(t.value, 'tile.value'),
333
+ detail: optStr(t.detail, 'tile.detail'), tone: tone(t.tone), page: page(t.page, 'tile.page'),
334
+ };
335
+ }
336
+ if (top.asks !== undefined) {
337
+ m.asks = arr(top.asks, 'asks', LIMITS.asks).map((x) => {
338
+ const a = obj(x, 'ask');
339
+ return { text: str(a.text, 'ask.text'), meta: optStr(a.meta, 'ask.meta'), page: page(a.page, 'ask.page'), label: optStr(a.label, 'ask.label') };
340
+ });
341
+ }
342
+ return m;
343
+ });
344
+ }
345
+ /** Validate a piece's `ui page --json` answer. */
346
+ export function parsePage(value) {
347
+ return attempt(() => {
348
+ const top = obj(value, 'page');
349
+ return { title: str(top.title, 'title'), intro: optStr(top.intro, 'intro'), sections: sections(top.sections) };
350
+ });
351
+ }
352
+ /** Validate a piece's `ui act --json` answer. */
353
+ export function parseActResult(value) {
354
+ return attempt(() => {
355
+ const top = obj(value, 'result');
356
+ if (typeof top.ok !== 'boolean')
357
+ throw new Invalid('ok must be true or false');
358
+ return { ok: top.ok, message: str(top.message, 'message'), page: page(top.page, 'page') };
359
+ });
360
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbarket/loomai",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "MCP server for persistent AI agent identity and memory. Single-file sqlite-vec + local ONNX embeddings; zero external services.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",