@cosmicdrift/kumiko-bundled-features 0.401.0 → 0.403.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.
Files changed (39) hide show
  1. package/dist/agent-tools/agent-manifest.js +123 -46
  2. package/dist/agent-tools/tool-catalog.d.ts +6 -0
  3. package/dist/agent-tools/tool-catalog.js +10 -11
  4. package/dist/agent-tools/types.d.ts +11 -10
  5. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +2 -2
  6. package/dist/inbound-mail-foundation/entities.js +1 -0
  7. package/dist/inbound-mail-foundation/watch-lease.d.ts +1 -1
  8. package/dist/inbound-mail-foundation/watch-lease.js +2 -2
  9. package/dist/inbound-mail-foundation/watch-supervisor-types.d.ts +5 -5
  10. package/dist/inbound-mail-foundation/watch-supervisor.js +5 -7
  11. package/dist/managed-pages/feature.js +9 -1
  12. package/dist/managed-pages/render-page-body.d.ts +12 -1
  13. package/dist/managed-pages/render-page-body.js +51 -14
  14. package/dist/managed-pages/web/i18n.js +1 -2
  15. package/dist/managed-pages/web/lossy-markdown.d.ts +1 -1
  16. package/dist/managed-pages/web/lossy-markdown.js +2 -16
  17. package/dist/managed-pages/web/markdown-to-document.js +2 -2
  18. package/dist/managed-pages/web/page-body-section.js +0 -1
  19. package/dist/readiness/handlers/run-list.query.js +2 -1
  20. package/dist/readiness/run-view.d.ts +4 -7
  21. package/dist/template-resolver/compile-document-body.js +2 -2
  22. package/dist/template-resolver/document-sanitizer.js +3 -3
  23. package/dist/template-resolver/index.d.ts +1 -1
  24. package/dist/template-resolver/index.js +1 -1
  25. package/dist/template-resolver/web/document-schema.d.ts +1 -0
  26. package/dist/template-resolver/web/document-schema.js +16 -1
  27. package/dist/template-resolver/web/i18n.js +2 -2
  28. package/dist/template-resolver/web/use-collection-entry-create.js +4 -3
  29. package/dist/user-data-rights/boot-checks.js +38 -31
  30. package/dist/user-data-rights/boot-rules.d.ts +17 -0
  31. package/dist/user-data-rights/boot-rules.js +17 -0
  32. package/dist/user-data-rights/web/request-deletion-screen.js +3 -3
  33. package/package.json +11 -11
  34. package/src/agent-tools/changes.json +19 -0
  35. package/src/inbound-mail-foundation/changes.json +7 -0
  36. package/src/managed-pages/changes.json +6 -0
  37. package/src/readiness/changes.json +6 -0
  38. package/src/template-resolver/changes.json +12 -0
  39. package/src/user-data-rights/changes.json +12 -0
@@ -3,6 +3,23 @@ import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-type
3
3
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
4
4
  import * as z from "zod";
5
5
  import { recordManifestDenyQns } from "./manifest-deny-qns.js";
6
+ import { isExplicitlyAgentHidden } from "./tool-catalog.js";
7
+ const FALLBACK_LOCALE = "en";
8
+ /** Requested locale, then English, then the first remaining locale in code-point
9
+ * order; `undefined` when the entry has no text at all (the key is then omitted). */
10
+ function pickLabel(labels, locale) {
11
+ const nonEmpty = (value) => value !== undefined && value !== "" ? value : undefined;
12
+ const firstLocale = Object.keys(labels)
13
+ .sort(compareIds)
14
+ .find((key) => nonEmpty(labels[key]));
15
+ return (nonEmpty(labels[locale]) ??
16
+ nonEmpty(labels[FALLBACK_LOCALE]) ??
17
+ (firstLocale !== undefined ? labels[firstLocale] : undefined));
18
+ }
19
+ function labelProp(labels, locale) {
20
+ const label = pickLabel(labels, locale);
21
+ return label === undefined ? {} : { label };
22
+ }
6
23
  /** The manifest surfaces one string per locale — a plural entry reads as its
7
24
  * CLDR `other` form since the manifest has no `count` to select a category. */
8
25
  function entryAsLabels(entry) {
@@ -73,13 +90,13 @@ function isFilterableField(field) {
73
90
  function isPiiField(field) {
74
91
  return "pii" in field && field.pii === true;
75
92
  }
76
- function buildField(entityName, fieldName, field, translations, searchableFields) {
93
+ function buildField(entityName, fieldName, field, translations, searchableFields, locale) {
77
94
  const options = field.type === "select" || field.type === "multiSelect" ? field.options : undefined;
78
95
  const references = field.type === "reference" ? field.entity : undefined;
79
96
  return {
80
97
  name: fieldName,
81
98
  type: field.type,
82
- labels: labelsForSuffix(translations, `:entity:${entityName}:field:${fieldName}`),
99
+ ...labelProp(labelsForSuffix(translations, `:entity:${entityName}:field:${fieldName}`), locale),
83
100
  ...(field.description !== undefined && { description: field.description }),
84
101
  ...(options !== undefined && { options }),
85
102
  ...(references !== undefined && { references }),
@@ -88,29 +105,45 @@ function buildField(entityName, fieldName, field, translations, searchableFields
88
105
  ...(isPiiField(field) && { pii: true }),
89
106
  };
90
107
  }
91
- /** Entity schemas are emitted unfiltered by design: roles gate handlers, screens,
92
- * navs and workspaces, while the handler pipeline gates the values themselves.
93
- * The manifest describes shape, never data. */
94
- function buildEntities(entities, translations, getSearchableFields) {
108
+ /** Shape, never data. Only entities the role reaches are emitted: the manifest is
109
+ * prompt payload, so every extra schema costs tokens on each turn. */
110
+ function buildEntities(entities, translations, getSearchableFields, visibleEntityNames, locale) {
95
111
  const result = [];
96
112
  for (const [entityName, entity] of entities) {
113
+ if (!visibleEntityNames.has(entityName))
114
+ continue;
97
115
  const searchableFields = new Set(getSearchableFields(entityName));
98
- const fields = Object.entries(entity.fields).map(([fieldName, field]) => buildField(entityName, fieldName, field, translations, searchableFields));
116
+ const fields = Object.entries(entity.fields).map(([fieldName, field]) => buildField(entityName, fieldName, field, translations, searchableFields, locale));
99
117
  result.push({
100
118
  name: entityName,
101
- labels: labelsForSuffix(translations, `:entity:${entityName}`),
119
+ ...labelProp(labelsForSuffix(translations, `:entity:${entityName}`), locale),
102
120
  ...(entity.description !== undefined && { description: entity.description }),
103
121
  fields,
104
122
  });
105
123
  }
106
124
  return result;
107
125
  }
108
- function buildFeatures(features) {
126
+ const FEATURE_DESCRIPTION_MAX_CHARS = 300;
127
+ // Only ". " before a capital ends the sentence, so "e.g. the" and "Art. 15" stay whole.
128
+ const SENTENCE_END = /\.\s+(?=\p{Lu})/u;
129
+ /** The manifest is prompt payload; the full text stays in `FeatureDefinition.description`
130
+ * for the docs generators. */
131
+ function leadSentence(description) {
132
+ const text = description.replace(/\s+/g, " ").trim();
133
+ const end = SENTENCE_END.exec(text);
134
+ const sentence = end === null ? text : text.slice(0, end.index + 1);
135
+ return sentence.slice(0, FEATURE_DESCRIPTION_MAX_CHARS);
136
+ }
137
+ function buildFeatures(features, visibleFeatureNames) {
109
138
  const result = [];
110
139
  for (const [, feature] of features) {
140
+ if (!visibleFeatureNames.has(feature.name))
141
+ continue;
111
142
  result.push({
112
143
  name: feature.name,
113
- ...(feature.description !== undefined && { description: feature.description }),
144
+ ...(feature.description !== undefined && {
145
+ description: leadSentence(feature.description),
146
+ }),
114
147
  ...(feature.uiHints?.displayLabel !== undefined && {
115
148
  displayLabel: feature.uiHints.displayLabel,
116
149
  }),
@@ -152,7 +185,7 @@ function buildHandlerEntry(qn, kind, def, roles, getHandlerEntity, denyQns) {
152
185
  return undefined;
153
186
  // `agent.expose: true` without a `description` still opts in — the author
154
187
  // asked for exposure explicitly, there's just nothing to show as prose.
155
- const description = def.description ?? "";
188
+ const description = def.description !== undefined && def.description !== "" ? def.description : undefined;
156
189
  let inputSchema;
157
190
  try {
158
191
  // A handler whose schema can't be expressed as JSON Schema (transforms,
@@ -168,7 +201,7 @@ function buildHandlerEntry(qn, kind, def, roles, getHandlerEntity, denyQns) {
168
201
  return {
169
202
  qn,
170
203
  kind,
171
- description,
204
+ ...(description !== undefined && { description }),
172
205
  risk: exposure.risk,
173
206
  inputSchema,
174
207
  ...(entity !== undefined && { entity }),
@@ -199,7 +232,7 @@ function assertDenyQnsExist(denyQns, registry) {
199
232
  throw new Error(`agent denyQns names handlers that are not registered: ${unknown.join(", ")}`);
200
233
  }
201
234
  }
202
- function buildNavs(navs, workspaces, translations, roles, screens) {
235
+ function buildNavs(navs, workspaces, translations, roles, screens, locale) {
203
236
  const result = [];
204
237
  for (const [, nav] of navs) {
205
238
  if (!uiVisible(nav.access, roles))
@@ -215,10 +248,10 @@ function buildNavs(navs, workspaces, translations, roles, screens) {
215
248
  continue;
216
249
  result.push({
217
250
  id: nav.id,
218
- labels: entryAsLabels(translations[nav.label] ?? {}),
251
+ ...labelProp(entryAsLabels(translations[nav.label] ?? {}), locale),
219
252
  ...(nav.screen !== undefined && { screen: nav.screen }),
220
253
  ...(nav.parent !== undefined && { parent: nav.parent }),
221
- workspaces: visibleWorkspaces,
254
+ ...(visibleWorkspaces.length > 0 && { workspaces: visibleWorkspaces }),
222
255
  });
223
256
  }
224
257
  return withoutOrphanedNavs(result);
@@ -235,6 +268,46 @@ function withoutOrphanedNavs(navs) {
235
268
  remaining = reachable;
236
269
  }
237
270
  }
271
+ // Qualified handler, screen and nav ids all start with the owning feature name.
272
+ function featureNameOf(qualifiedId) {
273
+ const idx = qualifiedId.indexOf(":");
274
+ return idx === -1 ? qualifiedId : qualifiedId.slice(0, idx);
275
+ }
276
+ function visibleFeatureNamesOf(handlers, screens, navs) {
277
+ return new Set([...handlers.map((h) => h.qn), ...screens.map((s) => s.id), ...navs.map((n) => n.id)].map(featureNameOf));
278
+ }
279
+ function entitiesOfQueryToolsFor(registry, roles, denyQns) {
280
+ const names = [];
281
+ for (const [qn, def] of registry.getAllQueryHandlers()) {
282
+ const entityName = registry.getHandlerEntity(qn);
283
+ if (entityName === undefined || denyQns.has(qn) || isExplicitlyAgentHidden(def))
284
+ continue;
285
+ if (hasAccess({ roles }, def.access))
286
+ names.push(entityName);
287
+ }
288
+ return names;
289
+ }
290
+ // A reference target is "entity" or "<feature>:entity"; registry entity keys are the bare name.
291
+ function entityNameOfReference(target) {
292
+ return target.slice(target.lastIndexOf(":") + 1);
293
+ }
294
+ function referenceTargetsOf(entity) {
295
+ return Object.values(entity?.fields ?? {}).flatMap((field) => field.type === "reference" ? [entityNameOfReference(field.entity)] : []);
296
+ }
297
+ /** Reachable = tagged on a manifest handler, shown by a manifest screen, or read by a
298
+ * registry-derived `get_`/`list_` tool (which takes labels from the manifest entity). */
299
+ function visibleEntityNamesOf(registry, roles, denyQns, handlers, screens) {
300
+ const reachable = new Set([
301
+ ...handlers.flatMap((handler) => (handler.entity !== undefined ? [handler.entity] : [])),
302
+ ...screens.flatMap((screen) => [screen.entity ?? [], screen.detailFor ?? []].flat()),
303
+ ...entitiesOfQueryToolsFor(registry, roles, denyQns),
304
+ ]);
305
+ const entities = registry.getAllEntities();
306
+ return new Set([
307
+ ...reachable,
308
+ ...[...reachable].flatMap((n) => referenceTargetsOf(entities.get(n))),
309
+ ]);
310
+ }
238
311
  function lastSegment(qualifiedId) {
239
312
  const idx = qualifiedId.lastIndexOf(":");
240
313
  return idx === -1 ? qualifiedId : qualifiedId.slice(idx + 1);
@@ -242,10 +315,31 @@ function lastSegment(qualifiedId) {
242
315
  function detailIdParam(screen) {
243
316
  return screen.type === "projectionDetail" ? (screen.idParam ?? "id") : "id";
244
317
  }
245
- function screenTitles(translations, screenId, shortId) {
246
- return entryAsLabels(translations[`${screenId}.title`] ?? translations[`screen:${shortId}.title`] ?? {});
318
+ function screenTitles(translations, screenId, shortId, locale) {
319
+ const title = pickLabel(entryAsLabels(translations[`${screenId}.title`] ?? translations[`screen:${shortId}.title`] ?? {}), locale);
320
+ return title === undefined ? {} : { title };
321
+ }
322
+ function buildScreenEntry(screen, screenWorkspaces, translations, locale) {
323
+ return {
324
+ id: screen.id,
325
+ type: screen.type,
326
+ ...screenTitles(translations, screen.id, lastSegment(screen.id), locale),
327
+ // `screen.description` may be a raw i18n key (`registry.getAllTranslations()`
328
+ // double-prefixes it, see build-app-schema.ts:77-81) — match on suffix like
329
+ // `labelsForSuffix` does for entity/field labels, else fall back to the
330
+ // literal string as authored.
331
+ ...(screen.description !== undefined && {
332
+ description: pickLabel(labelsForSuffix(translations, screen.description), locale) ?? screen.description,
333
+ }),
334
+ ...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
335
+ // A detail screen takes the row id under its idParam; everything else is parameterless.
336
+ ...(screen.detailFor !== undefined && { params: [detailIdParam(screen)] }),
337
+ ...(screenWorkspaces.length > 0 && { workspaces: screenWorkspaces }),
338
+ ...(screen.detailFor !== undefined && { detailFor: screen.detailFor }),
339
+ ...("handler" in screen && typeof screen.handler === "string" && { handler: screen.handler }),
340
+ };
247
341
  }
248
- function buildScreens(screens, navs, workspaces, translations, roles, visibleNavIds) {
342
+ function buildScreens(screens, navs, workspaces, translations, roles, visibleNavIds, locale) {
249
343
  const allNavs = [...navs.values()];
250
344
  const result = [];
251
345
  for (const [, screen] of screens) {
@@ -265,37 +359,19 @@ function buildScreens(screens, navs, workspaces, translations, roles, visibleNav
265
359
  });
266
360
  if (!uiVisible(screen.access, roles) || !reachable)
267
361
  continue;
268
- const shortId = lastSegment(screen.id);
269
362
  const screenWorkspaces = dedupeSorted(accessibleNavs.flatMap((nav) => visibleWorkspaceIds(nav.workspaces, workspaces, roles)));
270
- result.push({
271
- id: screen.id,
272
- type: screen.type,
273
- titles: screenTitles(translations, screen.id, shortId),
274
- // `screen.description` may be a raw i18n key (`registry.getAllTranslations()`
275
- // double-prefixes it, see build-app-schema.ts:77-81) — match on suffix like
276
- // `labelsForSuffix` does for entity/field labels, else fall back to the
277
- // literal string as authored.
278
- ...(screen.description !== undefined && {
279
- description: labelsForSuffix(translations, screen.description)["en"] ?? screen.description,
280
- }),
281
- ...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
282
- // A detail screen takes the row id under its idParam; everything else is parameterless.
283
- params: screen.detailFor !== undefined ? [detailIdParam(screen)] : [],
284
- workspaces: screenWorkspaces,
285
- ...(screen.detailFor !== undefined && { detailFor: screen.detailFor }),
286
- ...("handler" in screen && typeof screen.handler === "string" && { handler: screen.handler }),
287
- });
363
+ result.push(buildScreenEntry(screen, screenWorkspaces, translations, locale));
288
364
  }
289
365
  return result;
290
366
  }
291
- function buildWorkspaces(workspaces, translations, roles) {
367
+ function buildWorkspaces(workspaces, translations, roles, locale) {
292
368
  const result = [];
293
369
  for (const [, workspace] of workspaces) {
294
370
  if (!uiVisible(workspace.access, roles))
295
371
  continue;
296
372
  result.push({
297
373
  id: workspace.id,
298
- labels: entryAsLabels(translations[workspace.label] ?? {}),
374
+ ...labelProp(entryAsLabels(translations[workspace.label] ?? {}), locale),
299
375
  ...(workspace.default === true && { default: true }),
300
376
  });
301
377
  }
@@ -310,13 +386,14 @@ export function buildAgentManifest(registry, options) {
310
386
  const navMap = registry.getAllNavs();
311
387
  const { roles } = options;
312
388
  assertDenyQnsExist(options.denyQns ?? [], registry);
313
- const features = buildFeatures(registry.features);
314
- const entities = buildEntities(registry.getAllEntities(), translations, (name) => registry.getSearchableFields(name));
315
- const handlers = buildHandlers(registry.getAllQueryHandlers(), registry.getAllWriteHandlers(), roles, (qn) => registry.getHandlerEntity(qn), new Set(options.denyQns ?? []));
389
+ const denyQns = new Set(options.denyQns ?? []);
390
+ const handlers = buildHandlers(registry.getAllQueryHandlers(), registry.getAllWriteHandlers(), roles, (qn) => registry.getHandlerEntity(qn), denyQns);
316
391
  const screenMap = registry.getAllScreens();
317
- const navs = buildNavs(navMap, workspaceMap, translations, roles, screenMap);
318
- const screens = buildScreens(screenMap, navMap, workspaceMap, translations, roles, new Set(navs.map((nav) => nav.id)));
319
- const workspaces = buildWorkspaces(workspaceMap, translations, roles);
392
+ const navs = buildNavs(navMap, workspaceMap, translations, roles, screenMap, options.locale);
393
+ const screens = buildScreens(screenMap, navMap, workspaceMap, translations, roles, new Set(navs.map((nav) => nav.id)), options.locale);
394
+ const workspaces = buildWorkspaces(workspaceMap, translations, roles, options.locale);
395
+ const features = buildFeatures(registry.features, visibleFeatureNamesOf(handlers, screens, navs));
396
+ const entities = buildEntities(registry.getAllEntities(), translations, (name) => registry.getSearchableFields(name), visibleEntityNamesOf(registry, roles, denyQns, handlers, screens), options.locale);
320
397
  const manifest = {
321
398
  builtForRoles: [...roles],
322
399
  features: sortedByKey(features, (f) => f.name),
@@ -330,6 +407,6 @@ export function buildAgentManifest(registry, options) {
330
407
  ...(options.currency && { currency: options.currency }),
331
408
  },
332
409
  };
333
- recordManifestDenyQns(manifest, new Set(options.denyQns ?? []));
410
+ recordManifestDenyQns(manifest, denyQns);
334
411
  return manifest;
335
412
  }
@@ -1,4 +1,10 @@
1
+ import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
1
2
  import type { AgentManifest, RegistrySearchView, ToolCatalog, ToolCatalogOptions } from "./types.ts";
3
+ /** The entity CRUD tools are enumerated off the registry, not off the manifest, so
4
+ * `resolveAgentExposure` never runs for them. Only an EXPLICIT opt-out may hide one here —
5
+ * the resolver's own default is fail-closed on a missing `description`, and CRUD-generated
6
+ * handlers never carry one, so reusing it would delete every entity tool in every app. */
7
+ export declare function isExplicitlyAgentHidden(def: QueryHandlerDef): boolean;
2
8
  /** Drops the `query`/`write` verb segment from a handler QN, joins the rest with `_`, and
3
9
  * sanitizes to a valid tool-call identifier. Only the FIRST matching segment is dropped —
4
10
  * QNs are `<feature>:query|write:<entity>:<verb>`, so the verb only ever appears once at that
@@ -72,7 +72,7 @@ function isDetailHandlerQn(qn, entityName) {
72
72
  * `resolveAgentExposure` never runs for them. Only an EXPLICIT opt-out may hide one here —
73
73
  * the resolver's own default is fail-closed on a missing `description`, and CRUD-generated
74
74
  * handlers never carry one, so reusing it would delete every entity tool in every app. */
75
- function isExplicitlyAgentHidden(def) {
75
+ export function isExplicitlyAgentHidden(def) {
76
76
  return def.agent?.expose === false;
77
77
  }
78
78
  /** The model's filters feed the WHERE clause, and only the output is field-filtered — a field
@@ -105,10 +105,10 @@ function addToolsForListHandler(registry, qn, entityName, entity, roles, sink) {
105
105
  addTool(sink, tool, { kind: "findBy", entityName, fieldName, qn });
106
106
  }
107
107
  }
108
- function entityDisplayLabel(entity, entityName, locale) {
108
+ function entityDisplayLabel(entity, entityName) {
109
109
  if (entity?.description)
110
110
  return entity.description;
111
- const label = entity?.labels[locale];
111
+ const label = entity?.label;
112
112
  if (label)
113
113
  return label;
114
114
  return entityName;
@@ -212,7 +212,7 @@ function stripFieldFromSchema(schema, field) {
212
212
  return { ...rest, properties: strippedProperties, required: strippedRequired };
213
213
  }
214
214
  function handlerDescription(handler) {
215
- return handler.description.length > 0 ? handler.description : `Run ${handler.qn}.`;
215
+ return handler.description ?? `Run ${handler.qn}.`;
216
216
  }
217
217
  /** Drops the `query`/`write` verb segment from a handler QN, joins the rest with `_`, and
218
218
  * sanitizes to a valid tool-call identifier. Only the FIRST matching segment is dropped —
@@ -373,18 +373,18 @@ function collectEntityHandlerQns(registry, roleFilter, denyQns) {
373
373
  }
374
374
  return { detailQnByEntity, listQnByEntity, entityListDetailQns };
375
375
  }
376
- function addGetTools(registry, detailQnByEntity, entityByName, locale, sink) {
376
+ function addGetTools(registry, detailQnByEntity, entityByName, sink) {
377
377
  const getEntries = [...detailQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
378
378
  for (const [entityName, qn] of getEntries) {
379
- const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
379
+ const label = entityDisplayLabel(entityByName.get(entityName), entityName);
380
380
  const { tool, descriptor } = buildGetTool(entityName, qn, label, entityHandlerRisk(registry, qn));
381
381
  addTool(sink, tool, descriptor);
382
382
  }
383
383
  }
384
- function addListTools(registry, listQnByEntity, entityByName, roles, locale, sink) {
384
+ function addListTools(registry, listQnByEntity, entityByName, roles, sink) {
385
385
  const listEntries = [...listQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
386
386
  for (const [entityName, qn] of listEntries) {
387
- const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
387
+ const label = entityDisplayLabel(entityByName.get(entityName), entityName);
388
388
  const searchableFields = registry.getSearchableFields(entityName);
389
389
  const filterableFields = filterableFieldsOf(entityByName.get(entityName), registry.getEntity(entityName), roles);
390
390
  const { tool, descriptor } = buildListTool(entityName, qn, label, searchableFields, filterableFields, entityHandlerRisk(registry, qn));
@@ -472,14 +472,13 @@ function addClientTools(manifest, mode, sink) {
472
472
  export function buildToolCatalog(registry, manifest, options) {
473
473
  const sink = { tools: [], dispatchTable: new Map(), usedNames: new Set() };
474
474
  const roleFilter = { roles: manifest.builtForRoles };
475
- const locale = manifest.tenantSettings.locale;
476
475
  const entityByName = new Map(manifest.entities.map((entity) => [entity.name, entity]));
477
476
  const denyQns = new Set(options.denyQns ?? []);
478
477
  assertCatalogMirrorsManifestDenyQns(manifest, denyQns);
479
478
  addRegistrySearchTools(registry, roleFilter, denyQns, sink);
480
479
  const { detailQnByEntity, listQnByEntity, entityListDetailQns } = collectEntityHandlerQns(registry, roleFilter, denyQns);
481
- addGetTools(registry, detailQnByEntity, entityByName, locale, sink);
482
- addListTools(registry, listQnByEntity, entityByName, roleFilter.roles, locale, sink);
480
+ addGetTools(registry, detailQnByEntity, entityByName, sink);
481
+ addListTools(registry, listQnByEntity, entityByName, roleFilter.roles, sink);
483
482
  addQueryHandlerTools(manifest, entityListDetailQns, denyQns, sink);
484
483
  if (options.mode !== "read-only") {
485
484
  addWriteHandlerTools(manifest, detailQnByEntity, denyQns, sink);
@@ -105,8 +105,8 @@ export type AgentManifestOptions = {
105
105
  export type AgentManifestField = {
106
106
  readonly name: string;
107
107
  readonly type: string;
108
- /** Label per app locale, keyed by locale code. */
109
- readonly labels: Readonly<Record<string, string>>;
108
+ /** Label in the requested locale (English, then any other, as fallback); absent when untranslated. */
109
+ readonly label?: string;
110
110
  readonly description?: string;
111
111
  readonly options?: readonly string[];
112
112
  /** Entity a `reference` field points at. */
@@ -119,14 +119,14 @@ export type AgentManifestField = {
119
119
  };
120
120
  export type AgentManifestEntity = {
121
121
  readonly name: string;
122
- readonly labels: Readonly<Record<string, string>>;
122
+ readonly label?: string;
123
123
  readonly description?: string;
124
124
  readonly fields: readonly AgentManifestField[];
125
125
  };
126
126
  export type AgentManifestHandler = {
127
127
  readonly qn: string;
128
128
  readonly kind: "query" | "write";
129
- readonly description: string;
129
+ readonly description?: string;
130
130
  readonly risk: AgentRisk;
131
131
  readonly inputSchema: Readonly<Record<string, unknown>>;
132
132
  readonly entity?: string;
@@ -136,11 +136,12 @@ export type AgentManifestHandler = {
136
136
  export type AgentManifestScreen = {
137
137
  readonly id: string;
138
138
  readonly type: string;
139
- readonly titles: Readonly<Record<string, string>>;
139
+ readonly title?: string;
140
140
  readonly description?: string;
141
141
  readonly entity?: string;
142
- readonly params: readonly string[];
143
- readonly workspaces: readonly string[];
142
+ /** Only present on detail screens. */
143
+ readonly params?: readonly string[];
144
+ readonly workspaces?: readonly string[];
144
145
  /** Entity this screen is the detail view for. Distinct from `entity`: a custom
145
146
  * screen can render entity A while being the detail view for B. */
146
147
  readonly detailFor?: string;
@@ -149,14 +150,14 @@ export type AgentManifestScreen = {
149
150
  };
150
151
  export type AgentManifestNav = {
151
152
  readonly id: string;
152
- readonly labels: Readonly<Record<string, string>>;
153
+ readonly label?: string;
153
154
  readonly screen?: string;
154
155
  readonly parent?: string;
155
- readonly workspaces: readonly string[];
156
+ readonly workspaces?: readonly string[];
156
157
  };
157
158
  export type AgentManifestWorkspace = {
158
159
  readonly id: string;
159
- readonly labels: Readonly<Record<string, string>>;
160
+ readonly label?: string;
160
161
  readonly default?: true;
161
162
  };
162
163
  export type AgentManifestFeature = {
@@ -1,7 +1,7 @@
1
1
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
2
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
3
  import { Marked } from "marked";
4
- import { isAllowedDocumentLinkHref, isDocumentPlaceholderHref, } from "../../template-resolver/index.js";
4
+ import { isAllowedDocumentUrlHref, isDocumentPlaceholderHref, } from "../../template-resolver/index.js";
5
5
  import { resolveConsentLocale } from "./consent-text.js";
6
6
  export const CONFIRMATION_LABELS = {
7
7
  de: {
@@ -96,7 +96,7 @@ function formatDate(iso, locale, timeZone) {
96
96
  // row as escaped text, so author HTML, images and refused links stay plain text.
97
97
  const ROW_END = "\n\n";
98
98
  function isMailLinkHref(href) {
99
- return isAllowedDocumentLinkHref(href) && !isDocumentPlaceholderHref(href);
99
+ return isAllowedDocumentUrlHref(href) && !isDocumentPlaceholderHref(href);
100
100
  }
101
101
  const inlineHtmlMarkdown = new Marked({ gfm: false, breaks: false });
102
102
  inlineHtmlMarkdown.use({
@@ -214,6 +214,7 @@ export const syncCursorEntity = createEntity({
214
214
  export const seenMessageEntity = createEntity({
215
215
  table: "store_mail_seen_messages",
216
216
  softDelete: false,
217
+ indexes: [{ unique: true, columns: ["accountId", "providerMessageId"] }],
217
218
  fields: {
218
219
  accountId: createTextField({
219
220
  required: true,
@@ -5,7 +5,7 @@ export type WatchLease = {
5
5
  readonly acquire: (account: MailAccountRecord, state: WatcherState) => Promise<WatchLeaseResult>;
6
6
  };
7
7
  export declare function createWatchLease(deps: {
8
- readonly lock: DistributedLock | undefined;
8
+ readonly lock: DistributedLock;
9
9
  readonly ttlSeconds: number;
10
10
  readonly renewIntervalMs: number;
11
11
  readonly watchers: ReadonlyMap<string, WatcherState>;
@@ -20,7 +20,7 @@ export function createWatchLease(deps) {
20
20
  async function renewLeaseOnce(account, state, generation) {
21
21
  state.renewTimer = null;
22
22
  // skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
23
- if (!isRunning() || state.generation !== generation || !state.lockToken || !lock)
23
+ if (!isRunning() || state.generation !== generation || !state.lockToken)
24
24
  return;
25
25
  let renewed;
26
26
  try {
@@ -55,7 +55,7 @@ export function createWatchLease(deps) {
55
55
  }
56
56
  // kumiko-lint-ignore complexity-budget yield-point stale-map check after acquire is intentional
57
57
  async function acquire(account, state) {
58
- if (!lock || state.lockToken)
58
+ if (state.lockToken)
59
59
  return "acquired";
60
60
  // A throwing lock (Redis outage) reads as "not claimed": the next tick retries instead of leaving the start flag set.
61
61
  const token = await lock.acquire(account.id, { ttlSeconds: ttlSeconds }).catch((err) => {
@@ -40,15 +40,15 @@ export type InboundMailSupervisorDeps = {
40
40
  /** Coordinates the IMAP watch (not the poll) across worker processes:
41
41
  * before `plugin.watch()`, ensureWatcher claims a TTL lease for the
42
42
  * account via `lock.acquire` and renews it via `lock.renew` while the
43
- * watcher lives. Omit for single-process deployments — every active
44
- * account is watched locally, same as before. */
45
- readonly lock?: DistributedLock;
43
+ * watcher lives. Required: without it every worker would hold its own
44
+ * IDLE connection per account. */
45
+ readonly lock: DistributedLock;
46
46
  /** Access-token lifecycle for OAuth accounts. Defaults to a manager built
47
47
  * from `db` + `providerCtx.secrets`; pass the instance shared with
48
48
  * `createInboundMailConnectRoutes` so a fresh connect primes this cache. */
49
49
  readonly oauthTokens?: OAuthAccessTokenManager;
50
50
  /** TTL (seconds) for the per-account watch lease. Default 90s, renewed
51
- * every ttl/3. Only used when `lock` is set. */
51
+ * every ttl/3. */
52
52
  readonly watchLeaseTtlSeconds?: number;
53
53
  readonly log?: (line: string) => void;
54
54
  };
@@ -60,7 +60,7 @@ export type WatcherState = {
60
60
  * an already stopped watcher. */
61
61
  generation: number;
62
62
  /** Token from `deps.lock.acquire` while this worker owns the account's
63
- * watch lease; null when unclaimed (no `deps.lock`, or claim lost). */
63
+ * watch lease; null when unclaimed (claim lost). */
64
64
  lockToken: string | null;
65
65
  renewTimer: ReturnType<typeof setTimeout> | null;
66
66
  /** Consecutive renew() throws — tear down once failures span a full TTL. */
@@ -17,11 +17,9 @@
17
17
  // db,
18
18
  // dispatchWrite: ({ handlerQn, payload, tenantId }) =>
19
19
  // deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
20
- // // Multi-worker deployments: share one DistributedLock (same Redis,
21
- // // same key prefix) across every worker process so only one of them
22
- // // holds the IMAP IDLE connection per account. Omit `lock`
23
- // // for a single-process deployment — every active account gets
24
- // // watched locally, as before.
20
+ // // One DistributedLock (same Redis, same key prefix) shared across
21
+ // // every worker process, so only one of them holds the IMAP IDLE
22
+ // // connection per account.
25
23
  // lock: createDistributedLock(redis, `${RedisKeys.lock}inbound-mail:watch:`),
26
24
  // });
27
25
  // await supervisor.start();
@@ -34,7 +32,7 @@
34
32
  // **Multi-worker coordination:** the reconciliation poll remains deliberately
35
33
  // N-fold: every worker polls every active account, which is idempotent and
36
34
  // cheap. Only `plugin.watch()` holds a long-lived connection, and only one
37
- // worker may hold it per account. With `deps.lock`, `ensureWatcher` claims a
35
+ // worker may hold it per account. `ensureWatcher` claims a
38
36
  // TTL lease (`lock.acquire`) before connecting; a worker that fails to claim
39
37
  // it leaves the account to its current holder, while the poll still covers
40
38
  // it. The holder renews the lease via heartbeat (`lock.renew`, every ttl/3)
@@ -375,7 +373,7 @@ export function createInboundMailSupervisor(deps) {
375
373
  log(`inbound-mail: stop watcher ${accountId} failed: ${describeError(err)}`);
376
374
  }
377
375
  }
378
- if (deps.lock && lockToken) {
376
+ if (lockToken) {
379
377
  try {
380
378
  await deps.lock.release(accountId, lockToken);
381
379
  }
@@ -184,7 +184,15 @@ export function createManagedPagesFeature(opts) {
184
184
  }
185
185
  const html = wrapLayout({
186
186
  title: data.title,
187
- bodyHtml: renderPageBody({ slug, body: data.body, bodyFormat: data.bodyFormat }),
187
+ bodyHtml: renderPageBody({
188
+ tenantId,
189
+ slug,
190
+ lang,
191
+ version: data.version,
192
+ updatedAt: data.updatedAt,
193
+ body: data.body,
194
+ bodyFormat: data.bodyFormat,
195
+ }),
188
196
  lang: data.lang,
189
197
  slug,
190
198
  description: data.description,
@@ -1,10 +1,21 @@
1
1
  import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
2
+ import { sanitizeDocumentHtml } from "../template-resolver/index.ts";
2
3
  import type { BodyFormat } from "./constants.ts";
3
4
  type WarnLogger = Pick<Logger, "warn">;
4
5
  export type RenderablePageBody = {
6
+ readonly tenantId: string;
5
7
  readonly slug: string;
8
+ readonly lang: string;
9
+ readonly version: number;
10
+ readonly updatedAt: string;
6
11
  readonly body: string;
7
12
  readonly bodyFormat: BodyFormat;
8
13
  };
9
- export declare function renderPageBody(page: RenderablePageBody, log?: WarnLogger): string;
14
+ export type PageBodyRendererDeps = {
15
+ readonly sanitizeDocument?: typeof sanitizeDocumentHtml;
16
+ readonly renderMarkdown?: (markdown: string) => string;
17
+ readonly maxEntries?: number;
18
+ };
19
+ export declare function createPageBodyRenderer(deps?: PageBodyRendererDeps): (page: RenderablePageBody, log?: WarnLogger) => string;
20
+ export declare const renderPageBody: (page: RenderablePageBody, log?: WarnLogger) => string;
10
21
  export {};