@cosmicdrift/kumiko-samples 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 (37) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +177 -42
  4. package/packages/bundled-features/src/agent-tools/changes.json +19 -0
  5. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +8 -15
  6. package/packages/bundled-features/src/agent-tools/types.ts +11 -10
  7. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +2 -2
  8. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
  9. package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +1 -0
  10. package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +3 -3
  11. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +5 -5
  12. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +5 -7
  13. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  14. package/packages/bundled-features/src/managed-pages/feature.ts +9 -1
  15. package/packages/bundled-features/src/managed-pages/render-page-body.ts +66 -12
  16. package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -2
  17. package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +2 -19
  18. package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +2 -2
  19. package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +0 -1
  20. package/packages/bundled-features/src/readiness/changes.json +6 -0
  21. package/packages/bundled-features/src/readiness/handlers/run-list.query.ts +2 -1
  22. package/packages/bundled-features/src/readiness/run-view.ts +4 -5
  23. package/packages/bundled-features/src/template-resolver/changes.json +12 -0
  24. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +2 -2
  25. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
  26. package/packages/bundled-features/src/template-resolver/index.ts +1 -0
  27. package/packages/bundled-features/src/template-resolver/web/document-schema.ts +18 -1
  28. package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -2
  29. package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +5 -3
  30. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +41 -33
  31. package/packages/bundled-features/src/user-data-rights/boot-rules.ts +19 -0
  32. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  33. package/packages/bundled-features/src/user-data-rights/web/request-deletion-screen.tsx +20 -18
  34. package/samples/apps/use-all-bundled/src/app/collection-labels-feature.ts +3 -1
  35. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  36. package/samples/recipes/dashboard-stats/README.md +5 -0
  37. package/samples/recipes/dashboard-stats/src/feature.ts +27 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.401.0",
3
+ "version": "0.403.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.401.0",
3
+ "version": "0.403.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -21,6 +21,7 @@ import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-type
21
21
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
22
22
  import * as z from "zod";
23
23
  import { recordManifestDenyQns } from "./manifest-deny-qns.ts";
24
+ import { isExplicitlyAgentHidden } from "./tool-catalog.ts";
24
25
  import type {
25
26
  AgentManifest,
26
27
  AgentManifestEntity,
@@ -34,6 +35,31 @@ import type {
34
35
  RegistryManifestView,
35
36
  } from "./types.ts";
36
37
 
38
+ const FALLBACK_LOCALE = "en";
39
+
40
+ /** Requested locale, then English, then the first remaining locale in code-point
41
+ * order; `undefined` when the entry has no text at all (the key is then omitted). */
42
+ function pickLabel(labels: Readonly<Record<string, string>>, locale: string): string | undefined {
43
+ const nonEmpty = (value: string | undefined): string | undefined =>
44
+ value !== undefined && value !== "" ? value : undefined;
45
+ const firstLocale = Object.keys(labels)
46
+ .sort(compareIds)
47
+ .find((key) => nonEmpty(labels[key]));
48
+ return (
49
+ nonEmpty(labels[locale]) ??
50
+ nonEmpty(labels[FALLBACK_LOCALE]) ??
51
+ (firstLocale !== undefined ? labels[firstLocale] : undefined)
52
+ );
53
+ }
54
+
55
+ function labelProp(
56
+ labels: Readonly<Record<string, string>>,
57
+ locale: string,
58
+ ): { readonly label?: string } {
59
+ const label = pickLabel(labels, locale);
60
+ return label === undefined ? {} : { label };
61
+ }
62
+
37
63
  /** The manifest surfaces one string per locale — a plural entry reads as its
38
64
  * CLDR `other` form since the manifest has no `count` to select a category. */
39
65
  function entryAsLabels(entry: TranslationEntry): Readonly<Record<string, string>> {
@@ -135,6 +161,7 @@ function buildField(
135
161
  field: FieldDefinition,
136
162
  translations: TranslationKeys,
137
163
  searchableFields: ReadonlySet<string>,
164
+ locale: string,
138
165
  ): AgentManifestField {
139
166
  const options =
140
167
  field.type === "select" || field.type === "multiSelect" ? field.options : undefined;
@@ -143,7 +170,7 @@ function buildField(
143
170
  return {
144
171
  name: fieldName,
145
172
  type: field.type,
146
- labels: labelsForSuffix(translations, `:entity:${entityName}:field:${fieldName}`),
173
+ ...labelProp(labelsForSuffix(translations, `:entity:${entityName}:field:${fieldName}`), locale),
147
174
  ...(field.description !== undefined && { description: field.description }),
148
175
  ...(options !== undefined && { options }),
149
176
  ...(references !== undefined && { references }),
@@ -153,23 +180,25 @@ function buildField(
153
180
  };
154
181
  }
155
182
 
156
- /** Entity schemas are emitted unfiltered by design: roles gate handlers, screens,
157
- * navs and workspaces, while the handler pipeline gates the values themselves.
158
- * The manifest describes shape, never data. */
183
+ /** Shape, never data. Only entities the role reaches are emitted: the manifest is
184
+ * prompt payload, so every extra schema costs tokens on each turn. */
159
185
  function buildEntities(
160
186
  entities: ReadonlyMap<string, EntityDefinition>,
161
187
  translations: TranslationKeys,
162
188
  getSearchableFields: (entityName: string) => readonly string[],
189
+ visibleEntityNames: ReadonlySet<string>,
190
+ locale: string,
163
191
  ): readonly AgentManifestEntity[] {
164
192
  const result: AgentManifestEntity[] = [];
165
193
  for (const [entityName, entity] of entities) {
194
+ if (!visibleEntityNames.has(entityName)) continue;
166
195
  const searchableFields = new Set(getSearchableFields(entityName));
167
196
  const fields = Object.entries(entity.fields).map(([fieldName, field]) =>
168
- buildField(entityName, fieldName, field, translations, searchableFields),
197
+ buildField(entityName, fieldName, field, translations, searchableFields, locale),
169
198
  );
170
199
  result.push({
171
200
  name: entityName,
172
- labels: labelsForSuffix(translations, `:entity:${entityName}`),
201
+ ...labelProp(labelsForSuffix(translations, `:entity:${entityName}`), locale),
173
202
  ...(entity.description !== undefined && { description: entity.description }),
174
203
  fields,
175
204
  });
@@ -177,14 +206,32 @@ function buildEntities(
177
206
  return result;
178
207
  }
179
208
 
209
+ const FEATURE_DESCRIPTION_MAX_CHARS = 300;
210
+
211
+ // Only ". " before a capital ends the sentence, so "e.g. the" and "Art. 15" stay whole.
212
+ const SENTENCE_END = /\.\s+(?=\p{Lu})/u;
213
+
214
+ /** The manifest is prompt payload; the full text stays in `FeatureDefinition.description`
215
+ * for the docs generators. */
216
+ function leadSentence(description: string): string {
217
+ const text = description.replace(/\s+/g, " ").trim();
218
+ const end = SENTENCE_END.exec(text);
219
+ const sentence = end === null ? text : text.slice(0, end.index + 1);
220
+ return sentence.slice(0, FEATURE_DESCRIPTION_MAX_CHARS);
221
+ }
222
+
180
223
  function buildFeatures(
181
224
  features: ReadonlyMap<string, FeatureDefinition>,
225
+ visibleFeatureNames: ReadonlySet<string>,
182
226
  ): readonly AgentManifestFeature[] {
183
227
  const result: AgentManifestFeature[] = [];
184
228
  for (const [, feature] of features) {
229
+ if (!visibleFeatureNames.has(feature.name)) continue;
185
230
  result.push({
186
231
  name: feature.name,
187
- ...(feature.description !== undefined && { description: feature.description }),
232
+ ...(feature.description !== undefined && {
233
+ description: leadSentence(feature.description),
234
+ }),
188
235
  ...(feature.uiHints?.displayLabel !== undefined && {
189
236
  displayLabel: feature.uiHints.displayLabel,
190
237
  }),
@@ -237,7 +284,8 @@ function buildHandlerEntry(
237
284
 
238
285
  // `agent.expose: true` without a `description` still opts in — the author
239
286
  // asked for exposure explicitly, there's just nothing to show as prose.
240
- const description = def.description ?? "";
287
+ const description =
288
+ def.description !== undefined && def.description !== "" ? def.description : undefined;
241
289
 
242
290
  let inputSchema: Readonly<Record<string, unknown>>;
243
291
  try {
@@ -254,7 +302,7 @@ function buildHandlerEntry(
254
302
  return {
255
303
  qn,
256
304
  kind,
257
- description,
305
+ ...(description !== undefined && { description }),
258
306
  risk: exposure.risk,
259
307
  inputSchema,
260
308
  ...(entity !== undefined && { entity }),
@@ -298,6 +346,7 @@ function buildNavs(
298
346
  translations: TranslationKeys,
299
347
  roles: readonly string[],
300
348
  screens: ReadonlyMap<string, ScreenDefinition>,
349
+ locale: string,
301
350
  ): readonly AgentManifestNav[] {
302
351
  const result: AgentManifestNav[] = [];
303
352
  for (const [, nav] of navs) {
@@ -312,10 +361,10 @@ function buildNavs(
312
361
 
313
362
  result.push({
314
363
  id: nav.id,
315
- labels: entryAsLabels(translations[nav.label] ?? {}),
364
+ ...labelProp(entryAsLabels(translations[nav.label] ?? {}), locale),
316
365
  ...(nav.screen !== undefined && { screen: nav.screen }),
317
366
  ...(nav.parent !== undefined && { parent: nav.parent }),
318
- workspaces: visibleWorkspaces,
367
+ ...(visibleWorkspaces.length > 0 && { workspaces: visibleWorkspaces }),
319
368
  });
320
369
  }
321
370
  return withoutOrphanedNavs(result);
@@ -333,6 +382,70 @@ function withoutOrphanedNavs(navs: readonly AgentManifestNav[]): readonly AgentM
333
382
  }
334
383
  }
335
384
 
385
+ // Qualified handler, screen and nav ids all start with the owning feature name.
386
+ function featureNameOf(qualifiedId: string): string {
387
+ const idx = qualifiedId.indexOf(":");
388
+ return idx === -1 ? qualifiedId : qualifiedId.slice(0, idx);
389
+ }
390
+
391
+ function visibleFeatureNamesOf(
392
+ handlers: readonly AgentManifestHandler[],
393
+ screens: readonly AgentManifestScreen[],
394
+ navs: readonly AgentManifestNav[],
395
+ ): ReadonlySet<string> {
396
+ return new Set(
397
+ [...handlers.map((h) => h.qn), ...screens.map((s) => s.id), ...navs.map((n) => n.id)].map(
398
+ featureNameOf,
399
+ ),
400
+ );
401
+ }
402
+
403
+ function entitiesOfQueryToolsFor(
404
+ registry: RegistryManifestView,
405
+ roles: readonly string[],
406
+ denyQns: ReadonlySet<string>,
407
+ ): readonly string[] {
408
+ const names: string[] = [];
409
+ for (const [qn, def] of registry.getAllQueryHandlers()) {
410
+ const entityName = registry.getHandlerEntity(qn);
411
+ if (entityName === undefined || denyQns.has(qn) || isExplicitlyAgentHidden(def)) continue;
412
+ if (hasAccess({ roles }, def.access)) names.push(entityName);
413
+ }
414
+ return names;
415
+ }
416
+
417
+ // A reference target is "entity" or "<feature>:entity"; registry entity keys are the bare name.
418
+ function entityNameOfReference(target: string): string {
419
+ return target.slice(target.lastIndexOf(":") + 1);
420
+ }
421
+
422
+ function referenceTargetsOf(entity: EntityDefinition | undefined): readonly string[] {
423
+ return Object.values(entity?.fields ?? {}).flatMap((field) =>
424
+ field.type === "reference" ? [entityNameOfReference(field.entity)] : [],
425
+ );
426
+ }
427
+
428
+ /** Reachable = tagged on a manifest handler, shown by a manifest screen, or read by a
429
+ * registry-derived `get_`/`list_` tool (which takes labels from the manifest entity). */
430
+ function visibleEntityNamesOf(
431
+ registry: RegistryManifestView,
432
+ roles: readonly string[],
433
+ denyQns: ReadonlySet<string>,
434
+ handlers: readonly AgentManifestHandler[],
435
+ screens: readonly AgentManifestScreen[],
436
+ ): ReadonlySet<string> {
437
+ const reachable = new Set<string>([
438
+ ...handlers.flatMap((handler) => (handler.entity !== undefined ? [handler.entity] : [])),
439
+ ...screens.flatMap((screen) => [screen.entity ?? [], screen.detailFor ?? []].flat()),
440
+ ...entitiesOfQueryToolsFor(registry, roles, denyQns),
441
+ ]);
442
+ const entities = registry.getAllEntities();
443
+ return new Set([
444
+ ...reachable,
445
+ ...[...reachable].flatMap((n) => referenceTargetsOf(entities.get(n))),
446
+ ]);
447
+ }
448
+
336
449
  function lastSegment(qualifiedId: string): string {
337
450
  const idx = qualifiedId.lastIndexOf(":");
338
451
  return idx === -1 ? qualifiedId : qualifiedId.slice(idx + 1);
@@ -346,10 +459,42 @@ function screenTitles(
346
459
  translations: TranslationKeys,
347
460
  screenId: string,
348
461
  shortId: string,
349
- ): AgentManifestScreen["titles"] {
350
- return entryAsLabels(
351
- translations[`${screenId}.title`] ?? translations[`screen:${shortId}.title`] ?? {},
462
+ locale: string,
463
+ ): { readonly title?: string } {
464
+ const title = pickLabel(
465
+ entryAsLabels(
466
+ translations[`${screenId}.title`] ?? translations[`screen:${shortId}.title`] ?? {},
467
+ ),
468
+ locale,
352
469
  );
470
+ return title === undefined ? {} : { title };
471
+ }
472
+
473
+ function buildScreenEntry(
474
+ screen: ScreenDefinition,
475
+ screenWorkspaces: readonly string[],
476
+ translations: TranslationKeys,
477
+ locale: string,
478
+ ): AgentManifestScreen {
479
+ return {
480
+ id: screen.id,
481
+ type: screen.type,
482
+ ...screenTitles(translations, screen.id, lastSegment(screen.id), locale),
483
+ // `screen.description` may be a raw i18n key (`registry.getAllTranslations()`
484
+ // double-prefixes it, see build-app-schema.ts:77-81) — match on suffix like
485
+ // `labelsForSuffix` does for entity/field labels, else fall back to the
486
+ // literal string as authored.
487
+ ...(screen.description !== undefined && {
488
+ description:
489
+ pickLabel(labelsForSuffix(translations, screen.description), locale) ?? screen.description,
490
+ }),
491
+ ...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
492
+ // A detail screen takes the row id under its idParam; everything else is parameterless.
493
+ ...(screen.detailFor !== undefined && { params: [detailIdParam(screen)] }),
494
+ ...(screenWorkspaces.length > 0 && { workspaces: screenWorkspaces }),
495
+ ...(screen.detailFor !== undefined && { detailFor: screen.detailFor }),
496
+ ...("handler" in screen && typeof screen.handler === "string" && { handler: screen.handler }),
497
+ };
353
498
  }
354
499
 
355
500
  function buildScreens(
@@ -359,6 +504,7 @@ function buildScreens(
359
504
  translations: TranslationKeys,
360
505
  roles: readonly string[],
361
506
  visibleNavIds: ReadonlySet<string>,
507
+ locale: string,
362
508
  ): readonly AgentManifestScreen[] {
363
509
  const allNavs = [...navs.values()];
364
510
  const result: AgentManifestScreen[] = [];
@@ -382,29 +528,11 @@ function buildScreens(
382
528
  });
383
529
  if (!uiVisible(screen.access, roles) || !reachable) continue;
384
530
 
385
- const shortId = lastSegment(screen.id);
386
531
  const screenWorkspaces = dedupeSorted(
387
532
  accessibleNavs.flatMap((nav) => visibleWorkspaceIds(nav.workspaces, workspaces, roles)),
388
533
  );
389
534
 
390
- result.push({
391
- id: screen.id,
392
- type: screen.type,
393
- titles: screenTitles(translations, screen.id, shortId),
394
- // `screen.description` may be a raw i18n key (`registry.getAllTranslations()`
395
- // double-prefixes it, see build-app-schema.ts:77-81) — match on suffix like
396
- // `labelsForSuffix` does for entity/field labels, else fall back to the
397
- // literal string as authored.
398
- ...(screen.description !== undefined && {
399
- description: labelsForSuffix(translations, screen.description)["en"] ?? screen.description,
400
- }),
401
- ...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
402
- // A detail screen takes the row id under its idParam; everything else is parameterless.
403
- params: screen.detailFor !== undefined ? [detailIdParam(screen)] : [],
404
- workspaces: screenWorkspaces,
405
- ...(screen.detailFor !== undefined && { detailFor: screen.detailFor }),
406
- ...("handler" in screen && typeof screen.handler === "string" && { handler: screen.handler }),
407
- });
535
+ result.push(buildScreenEntry(screen, screenWorkspaces, translations, locale));
408
536
  }
409
537
  return result;
410
538
  }
@@ -413,13 +541,14 @@ function buildWorkspaces(
413
541
  workspaces: ReadonlyMap<string, WorkspaceDefinition>,
414
542
  translations: TranslationKeys,
415
543
  roles: readonly string[],
544
+ locale: string,
416
545
  ): readonly AgentManifestWorkspace[] {
417
546
  const result: AgentManifestWorkspace[] = [];
418
547
  for (const [, workspace] of workspaces) {
419
548
  if (!uiVisible(workspace.access, roles)) continue;
420
549
  result.push({
421
550
  id: workspace.id,
422
- labels: entryAsLabels(translations[workspace.label] ?? {}),
551
+ ...labelProp(entryAsLabels(translations[workspace.label] ?? {}), locale),
423
552
  ...(workspace.default === true && { default: true as const }),
424
553
  });
425
554
  }
@@ -439,19 +568,16 @@ export function buildAgentManifest(
439
568
  const { roles } = options;
440
569
  assertDenyQnsExist(options.denyQns ?? [], registry);
441
570
 
442
- const features = buildFeatures(registry.features);
443
- const entities = buildEntities(registry.getAllEntities(), translations, (name) =>
444
- registry.getSearchableFields(name),
445
- );
571
+ const denyQns = new Set(options.denyQns ?? []);
446
572
  const handlers = buildHandlers(
447
573
  registry.getAllQueryHandlers(),
448
574
  registry.getAllWriteHandlers(),
449
575
  roles,
450
576
  (qn) => registry.getHandlerEntity(qn),
451
- new Set(options.denyQns ?? []),
577
+ denyQns,
452
578
  );
453
579
  const screenMap = registry.getAllScreens();
454
- const navs = buildNavs(navMap, workspaceMap, translations, roles, screenMap);
580
+ const navs = buildNavs(navMap, workspaceMap, translations, roles, screenMap, options.locale);
455
581
  const screens = buildScreens(
456
582
  screenMap,
457
583
  navMap,
@@ -459,8 +585,17 @@ export function buildAgentManifest(
459
585
  translations,
460
586
  roles,
461
587
  new Set(navs.map((nav) => nav.id)),
588
+ options.locale,
589
+ );
590
+ const workspaces = buildWorkspaces(workspaceMap, translations, roles, options.locale);
591
+ const features = buildFeatures(registry.features, visibleFeatureNamesOf(handlers, screens, navs));
592
+ const entities = buildEntities(
593
+ registry.getAllEntities(),
594
+ translations,
595
+ (name) => registry.getSearchableFields(name),
596
+ visibleEntityNamesOf(registry, roles, denyQns, handlers, screens),
597
+ options.locale,
462
598
  );
463
- const workspaces = buildWorkspaces(workspaceMap, translations, roles);
464
599
 
465
600
  const manifest: AgentManifest = {
466
601
  builtForRoles: [...roles],
@@ -475,6 +610,6 @@ export function buildAgentManifest(
475
610
  ...(options.currency && { currency: options.currency }),
476
611
  },
477
612
  };
478
- recordManifestDenyQns(manifest, new Set(options.denyQns ?? []));
613
+ recordManifestDenyQns(manifest, denyQns);
479
614
  return manifest;
480
615
  }
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "0.402.0",
4
+ "type": "breaking",
5
+ "title": "Agent manifest labels are one string in the requested locale",
6
+ "detail": "`buildAgentManifest` used to emit every label in every locale (`labels: { de, en, es }`, often `{}`), although the caller names one `locale`. Entities, fields, navs and workspaces now carry `label?: string`, screens `title?: string`, picked in the requested locale, then English, then the first other locale, and left out when untranslated. Empty `params` and `workspaces` on screens, empty `workspaces` on navs and an empty handler `description` are left out as well (`description`, `params` and `workspaces` are optional in the types). `buildToolCatalog` reads the new fields.",
7
+ "migration": "Read `entity.label`, `field.label`, `nav.label`, `workspace.label` and `screen.title` instead of the `labels`/`titles` maps. Code that only passes the manifest to `buildToolCatalog` or serialises it needs no change."
8
+ },
9
+ {
10
+ "version": "0.402.0",
11
+ "type": "improvement",
12
+ "title": "Agent manifest shows only the first sentence of a feature description",
13
+ "detail": "`buildAgentManifest` emits the first sentence of `FeatureDefinition.description` (a `. ` before a capital letter ends it, so \"e.g.\" and \"Art. 15\" stay whole), at most 300 characters. The full text stays on the definition for the docs generators. In an app with 77 features the descriptions drop from about 46.6k to 12.2k characters."
14
+ },
15
+ {
16
+ "version": "0.402.0",
17
+ "type": "improvement",
18
+ "title": "Agent manifest features and entities follow the role",
19
+ "detail": "`buildAgentManifest` now leaves out a feature when none of its handlers, screens or navs remain in the manifest for the role, and an entity when no remaining handler or screen refers to it, no `get_`/`list_` tool of the role reads it and no reachable entity points at it through a reference field (one hop). Before, `features` and `entities` were identical for every role, so a restricted role paid for schemas it could not use in every system prompt. A translation-only feature such as `locale-de` no longer appears in `features`."
20
+ },
2
21
  {
3
22
  "version": "0.389.0",
4
23
  "type": "improvement",
@@ -104,7 +104,7 @@ function isDetailHandlerQn(qn: string, entityName: string): boolean {
104
104
  * `resolveAgentExposure` never runs for them. Only an EXPLICIT opt-out may hide one here —
105
105
  * the resolver's own default is fail-closed on a missing `description`, and CRUD-generated
106
106
  * handlers never carry one, so reusing it would delete every entity tool in every app. */
107
- function isExplicitlyAgentHidden(def: QueryHandlerDef): boolean {
107
+ export function isExplicitlyAgentHidden(def: QueryHandlerDef): boolean {
108
108
  return def.agent?.expose === false;
109
109
  }
110
110
 
@@ -146,13 +146,9 @@ function addToolsForListHandler(
146
146
  }
147
147
  }
148
148
 
149
- function entityDisplayLabel(
150
- entity: AgentManifestEntity | undefined,
151
- entityName: string,
152
- locale: string,
153
- ): string {
149
+ function entityDisplayLabel(entity: AgentManifestEntity | undefined, entityName: string): string {
154
150
  if (entity?.description) return entity.description;
155
- const label = entity?.labels[locale];
151
+ const label = entity?.label;
156
152
  if (label) return label;
157
153
  return entityName;
158
154
  }
@@ -285,7 +281,7 @@ function stripFieldFromSchema(
285
281
  }
286
282
 
287
283
  function handlerDescription(handler: AgentManifestHandler): string {
288
- return handler.description.length > 0 ? handler.description : `Run ${handler.qn}.`;
284
+ return handler.description ?? `Run ${handler.qn}.`;
289
285
  }
290
286
 
291
287
  /** Drops the `query`/`write` verb segment from a handler QN, joins the rest with `_`, and
@@ -483,12 +479,11 @@ function addGetTools(
483
479
  registry: RegistrySearchView,
484
480
  detailQnByEntity: ReadonlyMap<string, string>,
485
481
  entityByName: ReadonlyMap<string, AgentManifestEntity>,
486
- locale: string,
487
482
  sink: CatalogSink,
488
483
  ): void {
489
484
  const getEntries = [...detailQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
490
485
  for (const [entityName, qn] of getEntries) {
491
- const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
486
+ const label = entityDisplayLabel(entityByName.get(entityName), entityName);
492
487
  const { tool, descriptor } = buildGetTool(
493
488
  entityName,
494
489
  qn,
@@ -504,12 +499,11 @@ function addListTools(
504
499
  listQnByEntity: ReadonlyMap<string, string>,
505
500
  entityByName: ReadonlyMap<string, AgentManifestEntity>,
506
501
  roles: readonly string[],
507
- locale: string,
508
502
  sink: CatalogSink,
509
503
  ): void {
510
504
  const listEntries = [...listQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
511
505
  for (const [entityName, qn] of listEntries) {
512
- const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
506
+ const label = entityDisplayLabel(entityByName.get(entityName), entityName);
513
507
  const searchableFields = registry.getSearchableFields(entityName);
514
508
  const filterableFields = filterableFieldsOf(
515
509
  entityByName.get(entityName),
@@ -636,7 +630,6 @@ export function buildToolCatalog(
636
630
  ): ToolCatalog {
637
631
  const sink: CatalogSink = { tools: [], dispatchTable: new Map(), usedNames: new Set() };
638
632
  const roleFilter = { roles: manifest.builtForRoles };
639
- const locale = manifest.tenantSettings.locale;
640
633
  const entityByName = new Map(manifest.entities.map((entity) => [entity.name, entity]));
641
634
  const denyQns = new Set(options.denyQns ?? []);
642
635
  assertCatalogMirrorsManifestDenyQns(manifest, denyQns);
@@ -647,8 +640,8 @@ export function buildToolCatalog(
647
640
  roleFilter,
648
641
  denyQns,
649
642
  );
650
- addGetTools(registry, detailQnByEntity, entityByName, locale, sink);
651
- addListTools(registry, listQnByEntity, entityByName, roleFilter.roles, locale, sink);
643
+ addGetTools(registry, detailQnByEntity, entityByName, sink);
644
+ addListTools(registry, listQnByEntity, entityByName, roleFilter.roles, sink);
652
645
  addQueryHandlerTools(manifest, entityListDetailQns, denyQns, sink);
653
646
  if (options.mode !== "read-only") {
654
647
  addWriteHandlerTools(manifest, detailQnByEntity, denyQns, sink);
@@ -125,8 +125,8 @@ export type AgentManifestOptions = {
125
125
  export type AgentManifestField = {
126
126
  readonly name: string;
127
127
  readonly type: string;
128
- /** Label per app locale, keyed by locale code. */
129
- readonly labels: Readonly<Record<string, string>>;
128
+ /** Label in the requested locale (English, then any other, as fallback); absent when untranslated. */
129
+ readonly label?: string;
130
130
  readonly description?: string;
131
131
  readonly options?: readonly string[];
132
132
  /** Entity a `reference` field points at. */
@@ -140,7 +140,7 @@ export type AgentManifestField = {
140
140
 
141
141
  export type AgentManifestEntity = {
142
142
  readonly name: string;
143
- readonly labels: Readonly<Record<string, string>>;
143
+ readonly label?: string;
144
144
  readonly description?: string;
145
145
  readonly fields: readonly AgentManifestField[];
146
146
  };
@@ -148,7 +148,7 @@ export type AgentManifestEntity = {
148
148
  export type AgentManifestHandler = {
149
149
  readonly qn: string;
150
150
  readonly kind: "query" | "write";
151
- readonly description: string;
151
+ readonly description?: string;
152
152
  readonly risk: AgentRisk;
153
153
  readonly inputSchema: Readonly<Record<string, unknown>>;
154
154
  readonly entity?: string;
@@ -159,11 +159,12 @@ export type AgentManifestHandler = {
159
159
  export type AgentManifestScreen = {
160
160
  readonly id: string;
161
161
  readonly type: string;
162
- readonly titles: Readonly<Record<string, string>>;
162
+ readonly title?: string;
163
163
  readonly description?: string;
164
164
  readonly entity?: string;
165
- readonly params: readonly string[];
166
- readonly workspaces: readonly string[];
165
+ /** Only present on detail screens. */
166
+ readonly params?: readonly string[];
167
+ readonly workspaces?: readonly string[];
167
168
  /** Entity this screen is the detail view for. Distinct from `entity`: a custom
168
169
  * screen can render entity A while being the detail view for B. */
169
170
  readonly detailFor?: string;
@@ -173,15 +174,15 @@ export type AgentManifestScreen = {
173
174
 
174
175
  export type AgentManifestNav = {
175
176
  readonly id: string;
176
- readonly labels: Readonly<Record<string, string>>;
177
+ readonly label?: string;
177
178
  readonly screen?: string;
178
179
  readonly parent?: string;
179
- readonly workspaces: readonly string[];
180
+ readonly workspaces?: readonly string[];
180
181
  };
181
182
 
182
183
  export type AgentManifestWorkspace = {
183
184
  readonly id: string;
184
- readonly labels: Readonly<Record<string, string>>;
185
+ readonly label?: string;
185
186
  readonly default?: true;
186
187
  };
187
188
 
@@ -2,7 +2,7 @@ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headles
2
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
3
  import { Marked } from "marked";
4
4
  import {
5
- isAllowedDocumentLinkHref,
5
+ isAllowedDocumentUrlHref,
6
6
  isDocumentPlaceholderHref,
7
7
  } from "../../template-resolver/index.ts";
8
8
  import type { CheckoutConsentRecordedPayload } from "../events.ts";
@@ -149,7 +149,7 @@ function formatDate(iso: string, locale: ConsentLocale, timeZone: string): strin
149
149
  const ROW_END = "\n\n";
150
150
 
151
151
  function isMailLinkHref(href: string): boolean {
152
- return isAllowedDocumentLinkHref(href) && !isDocumentPlaceholderHref(href);
152
+ return isAllowedDocumentUrlHref(href) && !isDocumentPlaceholderHref(href);
153
153
  }
154
154
 
155
155
  const inlineHtmlMarkdown = new Marked({ gfm: false, breaks: false });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.402.0",
4
+ "type": "breaking",
5
+ "title": "The inbound mail supervisor requires a lock and the dedup anchor is unique",
6
+ "detail": "`createInboundMailSupervisor` takes a required `lock` (a DistributedLock) instead of an optional one, so only one worker holds the IMAP watch per account; the single-process path without a lock is gone. The unmanaged table store_mail_seen_messages gets a unique index on (account_id, provider_message_id), which the ingest comment always promised; ingest-message already serialized concurrent ingests of one mail with a per-message advisory lock, so the second one reports duplicate.",
7
+ "migration": "Pass `lock: createDistributedLock(redis, \"kumiko:lock:inbound-mail:watch:\")` to createInboundMailSupervisor. Run `kumiko-schema generate <name>` in each app that mounts inbound-mail-foundation to get the migration for the unique index on store_mail_seen_messages; existing rows must hold no duplicate (account_id, provider_message_id)."
8
+ },
2
9
  {
3
10
  "version": "0.394.0",
4
11
  "type": "improvement",
@@ -231,6 +231,7 @@ export const syncCursorEntity = createEntity({
231
231
  export const seenMessageEntity = createEntity({
232
232
  table: "store_mail_seen_messages",
233
233
  softDelete: false,
234
+ indexes: [{ unique: true, columns: ["accountId", "providerMessageId"] }],
234
235
  fields: {
235
236
  accountId: createTextField({
236
237
  required: true,
@@ -10,7 +10,7 @@ export type WatchLease = {
10
10
  // The cross-worker claim on one account's watch: acquired before plugin.watch(), renewed by a
11
11
  // heartbeat for as long as the watcher state exists, and given up locally once it is lost.
12
12
  export function createWatchLease(deps: {
13
- readonly lock: DistributedLock | undefined;
13
+ readonly lock: DistributedLock;
14
14
  readonly ttlSeconds: number;
15
15
  readonly renewIntervalMs: number;
16
16
  readonly watchers: ReadonlyMap<string, WatcherState>;
@@ -53,7 +53,7 @@ export function createWatchLease(deps: {
53
53
  ): Promise<void> {
54
54
  state.renewTimer = null;
55
55
  // skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
56
- if (!isRunning() || state.generation !== generation || !state.lockToken || !lock) return;
56
+ if (!isRunning() || state.generation !== generation || !state.lockToken) return;
57
57
  let renewed: boolean;
58
58
  try {
59
59
  renewed = await lock.renew(account.id, state.lockToken, ttlSeconds);
@@ -97,7 +97,7 @@ export function createWatchLease(deps: {
97
97
  account: MailAccountRecord,
98
98
  state: WatcherState,
99
99
  ): Promise<WatchLeaseResult> {
100
- if (!lock || state.lockToken) return "acquired";
100
+ if (state.lockToken) return "acquired";
101
101
  // A throwing lock (Redis outage) reads as "not claimed": the next tick retries instead of leaving the start flag set.
102
102
  const token = await lock.acquire(account.id, { ttlSeconds: ttlSeconds }).catch((err) => {
103
103
  log(
@@ -41,15 +41,15 @@ export type InboundMailSupervisorDeps = {
41
41
  /** Coordinates the IMAP watch (not the poll) across worker processes:
42
42
  * before `plugin.watch()`, ensureWatcher claims a TTL lease for the
43
43
  * account via `lock.acquire` and renews it via `lock.renew` while the
44
- * watcher lives. Omit for single-process deployments — every active
45
- * account is watched locally, same as before. */
46
- readonly lock?: DistributedLock;
44
+ * watcher lives. Required: without it every worker would hold its own
45
+ * IDLE connection per account. */
46
+ readonly lock: DistributedLock;
47
47
  /** Access-token lifecycle for OAuth accounts. Defaults to a manager built
48
48
  * from `db` + `providerCtx.secrets`; pass the instance shared with
49
49
  * `createInboundMailConnectRoutes` so a fresh connect primes this cache. */
50
50
  readonly oauthTokens?: OAuthAccessTokenManager;
51
51
  /** TTL (seconds) for the per-account watch lease. Default 90s, renewed
52
- * every ttl/3. Only used when `lock` is set. */
52
+ * every ttl/3. */
53
53
  readonly watchLeaseTtlSeconds?: number;
54
54
  readonly log?: (line: string) => void;
55
55
  };
@@ -62,7 +62,7 @@ export type WatcherState = {
62
62
  * an already stopped watcher. */
63
63
  generation: number;
64
64
  /** Token from `deps.lock.acquire` while this worker owns the account's
65
- * watch lease; null when unclaimed (no `deps.lock`, or claim lost). */
65
+ * watch lease; null when unclaimed (claim lost). */
66
66
  lockToken: string | null;
67
67
  renewTimer: ReturnType<typeof setTimeout> | null;
68
68
  /** Consecutive renew() throws — tear down once failures span a full TTL. */