@happyvertical/smrt-core 0.57.35 → 0.57.37
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/agents/build-knowledge.md +4 -0
- package/agents/data-query.md +23 -0
- package/agents/recipes.md +6 -0
- package/dist/cookbook/engine/schemas.d.ts.map +1 -1
- package/dist/cookbook/engine/schemas.js +1 -1
- package/dist/cookbook/engine/schemas.js.map +1 -1
- package/dist/data-query.d.ts.map +1 -1
- package/dist/data-query.js +13 -1
- package/dist/data-query.js.map +1 -1
- package/dist/knowledge.d.ts.map +1 -1
- package/dist/knowledge.js +28 -4
- package/dist/knowledge.js.map +1 -1
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/smrt-knowledge.json +9 -9
- package/package.json +11 -11
package/dist/manifest.json
CHANGED
package/dist/smrt-knowledge.json
CHANGED
|
@@ -3,30 +3,30 @@
|
|
|
3
3
|
"sensitiveFieldsExcluded": true,
|
|
4
4
|
"generatedAt": "1970-01-01T00:00:00.000Z",
|
|
5
5
|
"packageName": "@happyvertical/smrt-core",
|
|
6
|
-
"packageVersion": "0.57.
|
|
6
|
+
"packageVersion": "0.57.37",
|
|
7
7
|
"sourceManifestPath": "dist/manifest.json",
|
|
8
8
|
"agentDocPath": "AGENTS.md",
|
|
9
9
|
"sourceHashes": {
|
|
10
|
-
"manifest": "
|
|
11
|
-
"packageJson": "
|
|
10
|
+
"manifest": "3f398a906ab357bb75a63bcc4949741349df2fa8e12e10b748b546b0d9df3bff",
|
|
11
|
+
"packageJson": "53bb76009fea40ffb4c6f1aaba422e39ed41934cc68216c8722f5f05aa80ae5c",
|
|
12
12
|
"agents": "952dd7df7461bd7657b346f3ea63f1d240a625863b6ceef7b0f6d4960f978068",
|
|
13
13
|
"moduleDoc:agents/object-runtime.md": "fd2748649e820c2fe5960fa4a44c5f3e2a5afa57da89925e8b4cb3b53d67a07b",
|
|
14
14
|
"moduleDoc:agents/revision-guard.md": "f6ea32a6860964c98aba14b93587f40b33c8f7d0deccb2c54d21bff45db3da42",
|
|
15
15
|
"moduleDoc:agents/collection-reads.md": "39acea7faf3505d38bfb999e414f4c4c6f964ec7c3d19cc52993302c29223029",
|
|
16
16
|
"moduleDoc:agents/query-bounds.md": "3a0601ddaf2bea4e90e3f22e16a2eb3508a6fb8c019e3fb2c1f930c90a377cd5",
|
|
17
|
-
"moduleDoc:agents/data-query.md": "
|
|
17
|
+
"moduleDoc:agents/data-query.md": "726ef89560d4143d03bfc26943a993ecaa0b42b47e64e861d9de08ee17f1981b",
|
|
18
18
|
"moduleDoc:agents/schema-paths.md": "34a9c8cf909fc1b3a9d988d1329617fdc0dfcf985bb2194bc68441d34e9aa891",
|
|
19
19
|
"moduleDoc:agents/deployment-phases.md": "e1036073f31d54db95de95e498c74cddce5e47f367d56ccc00aad69f4e5a28e1",
|
|
20
20
|
"moduleDoc:agents/change-feed.md": "c3d6206758dd1d2e0475177e1938fb7d7b6cfe8a6c4d3c3b56ef62bc7bc7739d",
|
|
21
21
|
"moduleDoc:agents/change-signals.md": "a2dbe7077483b5a19bd5ea9589b5a08fe847377c8e05d01d57f813c6d9cbc28b",
|
|
22
22
|
"moduleDoc:agents/generators.md": "abf359fdc6fa7f0afaf4a096d52b75982084b80d539a4ed021d3d95b47714b14",
|
|
23
|
-
"moduleDoc:agents/build-knowledge.md": "
|
|
23
|
+
"moduleDoc:agents/build-knowledge.md": "5c3a24930403f870a252e9a53699191d7e38a52373ec52f9f2b862445892a3d1",
|
|
24
24
|
"moduleDoc:agents/memory.md": "a86bdf370403d08ecad3be86e341eb322dfe6e6b7a4e8806a6f8be9bf3421da5",
|
|
25
25
|
"moduleDoc:agents/system-diagnostics.md": "b7ccae18ee312bf788d5470f95fa78d3535f1789247d5372e40ca86538290772",
|
|
26
26
|
"moduleDoc:agents/registry-snapshot.md": "6234aa540427396d1dda1825a4641b1072e247187d5ddf413ae46c691344ee30",
|
|
27
27
|
"moduleDoc:agents/model-moves.md": "c64147fee1b7ec242fd1c178e7aea7cc90cafc704dee2738141c7a6d61403cd8",
|
|
28
28
|
"moduleDoc:agents/run-once.md": "43c589a3cab2ff61147128014cc0084509faf623eb76860140d589539420a1f3",
|
|
29
|
-
"moduleDoc:agents/recipes.md": "
|
|
29
|
+
"moduleDoc:agents/recipes.md": "528dfe91b1e54d210bfbfa4d7de3420e0e897d55954c678089413851357e4275",
|
|
30
30
|
"moduleDoc:agents/cookbook.md": "20c256f4f6219f9b2add6365ef930e0bb3eda37e7866ddbc4bdd466830f76e4f",
|
|
31
31
|
"moduleDoc:agents/cookbook-engine.md": "f8e6869021b6cc214fe34da20b73a83509abc10bab71299ea9668b47a1c4307d",
|
|
32
32
|
"moduleDoc:agents/browser-entry.md": "8d928babfe7308512c901a12c23fbfb7ff395f6b1cddce5cd069e4ece38743da",
|
|
@@ -1173,7 +1173,7 @@
|
|
|
1173
1173
|
{
|
|
1174
1174
|
"path": "agents/data-query.md",
|
|
1175
1175
|
"module": "data-query",
|
|
1176
|
-
"content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Bounded data queries (#2444)\n\n`normalizeDataQueryRequest()` and `normalizeDataQueryResult()` define the trust\nboundary for the transport-neutral table/report/content query envelope. A\ntrusted adapter supplies `DataQuerySchema`; callers receive only its declared\nprojectable, sortable, filterable, and facetable fields. The helpers validate\nand normalize but never execute SQL or decide tenant/principal access.\n\nUse `createDataQueryFingerprint()` for cache and result correlation. It omits\nrequest id and page position, canonicalizes equivalent filter/projection/facet\nforms, and adds the identity sort tie-break. Keep values scalar and request,\npage, and facet sizes positive and bounded. Results must stay within the schema\nbyte cap with declared field types preserved; datetimes are RFC 3339 instants,\nidentity fields are string/number/datetime-compatible, and JSON values are\nbounded by depth, container count, string size, and bytes before cloning.\n\n## Allowed field values (#4381)\n\nA `string`, `number`, or `boolean` field may declare `values`: a closed set of\n`{ value, label?, description? }` entries in declaration order, so an agent can\nmap \"open\" onto `status in [pending, in_progress]`. Values are host-declared\nschema, never inferred from rows. `normalizeDataQuerySchema()` (via the\nexported `normalizeDataQueryFieldValues()`) requires a non-empty, duplicate-free\nlist of at most `MAX_DATA_QUERY_FIELD_VALUES` (100) entries whose values match\nthe field type; value, label, and description text are bounded\n(`MAX_DATA_QUERY_FIELD_VALUE_LENGTH` 256, `..._LABEL_LENGTH` 128,\n`..._DESCRIPTION_LENGTH` 512) and reject Unicode control, format (bidi,\nzero-width), surrogate, and line/paragraph-separator characters. Declared values\nare enforced by default: non-null `eq`/`ne`/`in`/`notIn` filter values must be\nmembers, or the request fails with `DATA_QUERY_VALUE_NOT_ALLOWED` and a message\nlisting the allowed values. The message and `details.allowedValues` both stop\nat the first 20 values; `details.allowedValueCount` gives the full size.\n`ne`/`notIn` are checked too: a misspelled value there would otherwise\nsilently match every row. `valuesEnforced: false` (valid only with `values`) marks the set advisory and\nskips that check; the normalized schema keeps only the `false` form. Range and\n`like` operators, result rows, and facets are never checked against the set.\n\nOnly normalized `DataQueryResult` envelopes cross REST, MCP, WebMCP, and browser\nboundaries. Adapter-specific report/content context wraps the base envelope; it\ndoes not add unsafe fields or SQL-like controls.\n"
|
|
1176
|
+
"content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Bounded data queries (#2444)\n\n`normalizeDataQueryRequest()` and `normalizeDataQueryResult()` define the trust\nboundary for the transport-neutral table/report/content query envelope. A\ntrusted adapter supplies `DataQuerySchema`; callers receive only its declared\nprojectable, sortable, filterable, and facetable fields. The helpers validate\nand normalize but never execute SQL or decide tenant/principal access.\n\nUse `createDataQueryFingerprint()` for cache and result correlation. It omits\nrequest id and page position, canonicalizes equivalent filter/projection/facet\nforms, and adds the identity sort tie-break. Keep values scalar and request,\npage, and facet sizes positive and bounded. Results must stay within the schema\nbyte cap with declared field types preserved; datetimes are RFC 3339 instants,\nidentity fields are string/number/datetime-compatible, and JSON values are\nbounded by depth, container count, string size, and bytes before cloning.\n\n## Allowed field values (#4381)\n\nA `string`, `number`, or `boolean` field may declare `values`: a closed set of\n`{ value, label?, description? }` entries in declaration order, so an agent can\nmap \"open\" onto `status in [pending, in_progress]`. Values are host-declared\nschema, never inferred from rows. `normalizeDataQuerySchema()` (via the\nexported `normalizeDataQueryFieldValues()`) requires a non-empty, duplicate-free\nlist of at most `MAX_DATA_QUERY_FIELD_VALUES` (100) entries whose values match\nthe field type; value, label, and description text are bounded\n(`MAX_DATA_QUERY_FIELD_VALUE_LENGTH` 256, `..._LABEL_LENGTH` 128,\n`..._DESCRIPTION_LENGTH` 512) and reject Unicode control, format (bidi,\nzero-width), surrogate, and line/paragraph-separator characters. Declared values\nare enforced by default: non-null `eq`/`ne`/`in`/`notIn` filter values must be\nmembers, or the request fails with `DATA_QUERY_VALUE_NOT_ALLOWED` and a message\nlisting the allowed values. The message and `details.allowedValues` both stop\nat the first 20 values; `details.allowedValueCount` gives the full size.\n`ne`/`notIn` are checked too: a misspelled value there would otherwise\nsilently match every row. `valuesEnforced: false` (valid only with `values`) marks the set advisory and\nskips that check; the normalized schema keeps only the `false` form. Range and\n`like` operators, result rows, and facets are never checked against the set.\n\n## Null tests (#4448)\n\n`isNull` and `isNotNull` are `condition` operators with no `value`:\n`{\"kind\":\"condition\",\"field\":\"endedAt\",\"operator\":\"isNull\"}`. A field offers\nthem only when its `filterOperators` declares them, which adapters do only for\nstorage that can hold null; `data.inspect` shows them with the other\noperators. The canonical form has no `value` key; an explicit `\"value\": null`\nis accepted and dropped, so both spellings share one fingerprint, and any\nother value fails with the valueless shape in the message. They never touch\nallowed values (#4381). The types split `DataQueryCondition` into\n`DataQueryValueCondition` (`DataQueryValueOperator`) and\n`DataQueryNullCondition` (`DataQueryNullOperator`, `value?: never`).\n\nExisting requests keep their meaning and canonical form. `eq null` / `ne null`\nwere already accepted wherever `eq`/`ne` are declared and still lower to\n`IS NULL` / `IS NOT NULL`; `isNull` is a distinct canonical form with its own\nfingerprint, not a rewrite of `eq null`. Null semantics of value operators are\nthe adapter's (SQL three-valued logic): the SMRT collection and content\nadapters treat `ne x`/`notIn` as null-inclusive (`IS NULL OR <> x`), and the\nreport adapter's `ne x`/`notIn` is a plain `<>` that excludes nulls. Negation\nmaps `isNull` and `isNotNull` onto each other, an exact complement in every\nadapter.\n\nOnly normalized `DataQueryResult` envelopes cross REST, MCP, WebMCP, and browser\nboundaries. Adapter-specific report/content context wraps the base envelope; it\ndoes not add unsafe fields or SQL-like controls.\n"
|
|
1177
1177
|
},
|
|
1178
1178
|
{
|
|
1179
1179
|
"path": "agents/schema-paths.md",
|
|
@@ -1203,7 +1203,7 @@
|
|
|
1203
1203
|
{
|
|
1204
1204
|
"path": "agents/build-knowledge.md",
|
|
1205
1205
|
"module": "build-knowledge",
|
|
1206
|
-
"content": "# Decorators, build integration, and knowledge\n\nKey options: `tableName`, `tableStrategy` ('cti'|'sti'), `conflictColumns`, `indexes` (declared multi-column indexes; see [schema-paths.md](schema-paths.md)), `api`/`mcp`/`cli` (generation config), `ai` (callable methods), `hooks` (beforeSave/afterSave/beforeDelete/afterDelete), `embeddings` (auto-generate), `tenantScoped`, `agent`, `ui` (`{ icon, label, description }` — nav/help hints round-tripped through the manifest as plain data; `description` is the object-level seed for form-level help, #2046).\n\nRegistration sets `SMRT_TABLE_NAME` static property (survives minification).\n\nAn explicit `@smrt({ collection: 'domain.records' })` names the route/permission\nnamespace independently of `tableName`, including standalone non-STI models.\nScanner output preserves it in both `collection` and `decoratorConfig.collection`.\nGenerated registration reads the merged declaration, so older manifests with a\nderived top-level collection still agree with native decorators. Explicit runtime\nregistration options win; absent declarations retain manifest/STI/default naming.\n\n## @field() UI hints (#2046)\n\n`@field({ ui: { basic, group, order, locked } })` — a static, presentation-only\nseed for the field-policy rail (epic #2045). Carried in the manifest under the\nfield's `_meta.ui` (never a top-level `FieldDefinition` key), readable at\nruntime via `getAllFields()` at `field._meta.ui`, and emitted (sanitized) with\n`description` into generated web-collection definitions and browser MCP tool\nschemas. No schema/persistence/security effect — `sensitive`/`readPermission`\nstay the security rail, and `sensitive`/`transient` fields never emit to the\nclient at all.\n\n`ui.widget` (#3599) adds a presentation widget hint:\n`'textarea' | 'currency' | 'email' | 'url' | 'phone'`. It rides the same\n`_meta.ui` channel and is also emitted (sanitized) into web-collection\ndefinitions. The manifest generator validates it against the field type and\nfails the build otherwise: every widget needs a `text` field (`currency` is a\ncurrency-CODE picker for an ISO 4217 string such as `currency = 'USD'`, not a\nmoney amount; amounts are integer minor units and take no widget); an unknown\nvalue or a non-text field is an error. `ui.widget` is a hint only — it never changes\nthe column type or validation.\n\n`ui.format` (#3718) says how a value is presented: `money` (an `integer` of\nminor units — the amount hint record pages format and total), `date` (a\nbusiness date on a `datetime`, shown without a time), `datetime`, `percent`\n(a rate on a `decimal`/`integer`). Same channel, same emission, same build-time\ncheck against the field type. `@foreignKey(Target, { role: 'items' })` (or on\n`@crossPackageRef`) marks the declaring model as the target's line items; the\nscanner keeps it at `_meta.role`, the build rejects it on a non-reference or\nan unknown value, and the generated web relationship edge carries\n`role: 'items'` (edges without a role stay byte-stable). Both are hints: no\nschema, persistence or security effect.\n\n## Presentation metadata (#3599)\n\nPure helpers live in `src/ui-metadata.ts`, published browser-safe as\n`@happyvertical/smrt-core/ui-metadata` (also re-exported from the package\nroot). `ManifestGenerator.applyUiMetadata()` runs in `applyGenerationPasses`\nafter inheritance, so every producer (Vite plugin, `ManifestBuilder`,\n`generateManifest`) validates the same way.\n\n- **Display label.** `@smrt({ display: { label: '<field>' } })` names the own\n field generic pickers and assistants use to label a record. Own fields only —\n no paths into other packages; a model whose label lives elsewhere ships a\n selector. Undeclared, it defaults to the first of `name`, `title`, `label`,\n `code` that is a usable own field. Build fails when the declared field is\n missing, `sensitive`, `transient`, or a relationship/json field. The resolved\n field is emitted as `displayLabelField` on the manifest object and in the\n knowledge artifact, and generated MCP/WebMCP `list`/`get`/`update`/`delete`\n descriptions end with ``Records are identified by their `<field>` field.``\n (`describeAction` in `generators/tool-schema.ts`; nothing is added when the\n model has no label field).\n- **Selector slots.** `ModuleUISlot.selects: '@scope/pkg:Model'`\n (`@happyvertical/smrt-types`) marks a package component as THE selector for a\n model. The scanner reads such slots statically from `src/**/ui.ts`\n (`parseUiSelectorsFile`, independent of the class glob) and the manifest\n carries them as `uiSelectors` keyed by slot id. `selects` must be a string\n literal; a duplicate slot id, two selectors for one model, or an own-package\n target that does not exist fails the build. Hosts call\n `findSelectorFor(manifests, qualifiedName)`; generic forms use the selector\n for any `@foreignKey`/`@crossPackageRef` field targeting that model and fall\n back to a generic picker labelled by `displayLabelField`. `display` is read\n per class: an STI subclass that declares none uses the default lookup rather\n than its base's declaration, and a CTI child cannot name a parent's field.\n\n## Worker registration artifact (#3117)\n\nWith `svelteKit.enabled`, a production `vite build` compiles the generated\n`smrt-register.ts` into `.smrt/runtime/register.js` after the server build\n(`src/vite-plugin/worker-registration.ts`, called once from `closeBundle`).\nadapter-node re-bundles the server from its own entries, so the web bundle is\nnever importable by a separate Node process; this artifact is how a deployed\ntask/schedule worker registers the app's local objects (and, through the\n`.smrt/register.js` it imports, consumed packages' objects) with the same\nqualified identities and isolated manifests as the web server. It is a nested\nSSR build with no consumer plugins: only aliases pointing into the project\nsources (e.g. `$lib`) apply, and bare imports stay external so the worker\nshares the installed `@happyvertical/smrt-core` registry. The sole exception is\n`@oxc-project/runtime` and its helper subpaths: Vite injects them when lowering\ndecorators, but applications do not declare that compiler support dependency,\nso the artifact bundles them. SvelteKit\nvirtual modules (`$env/*`, `$app/*`) are not available to a worker; an object\nimporting one fails at worker startup. A failure fails the build.\n\n**Register imports stay Node-loadable (#4219).** `.smrt/register.js`\n(`generateRegistrationFile` in `src/consumer-plugin/index.ts`) is evaluated by\nplain Node — `smrt db:migrate`, every CLI, and the worker artifact above — as\nwell as by Vite SSR, so it may only import modules Node can load. Each object\nis imported from its manifest `importPath` (the scanner's export-subpath match,\ne.g. `@happyvertical/smrt-products/models`). Each configured provider in\n`smrtDependencies` is also imported once so it is evaluated in Node's installed\npackage context (#4187): the package root, unless the package declares that\nroot as a Svelte entry (`isComponentBearingRoot`: a `svelte` field, or a `.`\nexport with a `svelte` condition or a `.svelte`/`.css` target — e.g.\n`@happyvertical/smrt-products`, whose root re-exports its components and so\nreaches `@happyvertical/smrt-ui`'s `.svelte` files). Such a root is never\nimported and no subpath is substituted for it (`resolveProviderContextImport`\nreturns `undefined`): its already-imported object entries establish the\ncontext, a provider contributing only nested objects needs none because the\ntransitive loader uses only `import.meta.resolve(provider)`, and a substitute\n`./models` whose JS reached `.svelte` would re-create the failure. A package that publishes Svelte\nonly under subpaths keeps its root import; the broader `isComponentBearingPackage`\ntest (any Svelte export) only selects `ssr.noExternal` packages. A `svelte`\ncondition counts even with a plain `.js` target — that is what svelte-package\nand Vite library builds emit for a root that re-exports components, and every\nSvelte root in this workspace (`smrt-products`, `smrt-ui`, `smrt-svelte`,\n`smrt-planner`) has that shape and fails under plain Node; a Node-safe root\nthat declares `svelte` anyway only loses an optional context import.\nTransitive-only object modules are loaded through `importSmrtDependency()`\nfrom their provider's context, which needs no root import.\n\nSelective SSR compilation compares a JavaScript barrel's `svelte` export target\nagainst the package instance visible from its actual importer. pnpm may install\nthe same version in multiple peer contexts; the app-root package directory is\nnot the identity of a transitive import. Read that importer's installed export\nmap and root together, preserving exact target matching. Plain server entries\nremain external; do not fix peer-context resolution by deduplicating app\npackages or bundling every server export (#4318).\n\nNot covered by contract: a package whose root is a Svelte entry AND whose\n`@smrt()` objects are exported only from that root. The object loop must then\nimport the root (it is the only module exporting the constructors) — statically\nwhen the app resolves the package, or through `importSmrtDependency()` when it\nis reached only through its provider — so Vite SSR works but plain Node can\nload the register only if that root happens to be Node-safe. Generation keeps\nthe import and warns once per package (`declares a Svelte root entry`), asking\nto verify the root under plain Node or publish the objects from `./models` or\n`./objects`. The declaration is a heuristic: a Node-safe root that declares\n`svelte` only by convention gets the same warning (a false positive, harmless —\nthe import is kept) and, when its objects come from subpaths, loses only its\noptional provider-context import. Regression: the `#4219` tests in\n`index.test.ts` (unit cases for `isComponentBearingRoot` /\n`resolveProviderContextImport`, a child-Node load of the generated register,\nthe nested-only provider that gets no import, and the root-only and transitive\nwarnings).\n\n**Gate: `pnpm check:node-roots`** (root script; run after `pnpm build`). It\nruns `scripts/survey-node-root-load.mjs --strict --allow=<packages>`, which\nspawns `node --input-type=module -e \"await import('<name>')\"` from every\nbuilt workspace package whose manifest declares `@smrt()` objects and fails on\nany root plain Node cannot evaluate that is not allow-listed. The allow-list\nis explicit and lives in the root `package.json` script: currently only\n`@happyvertical/smrt-products`, whose root re-exports its Svelte components by\ndocumented contract. A new entry needs the same justification; a model root\nthat merely starts importing `@happyvertical/smrt-ui` must be fixed, not\nallowed. `--strict` also fails when nothing was surveyed (run before\n`pnpm build`) and when an allow-listed package was not surveyed or now loads,\nso the list cannot go stale. `--all` surveys every published package,\nincluding those without objects, as a report.\n\nCI runs the gate in both Knowledge Freshness jobs of\n`.github/workflows/test-suite.yml`. In full mode (the merge queue, and every PR\nwhile `vars.CI_MERGE_QUEUE_ENABLED` is not `true`) it always runs. In affected\nmode it runs only when the `knowledge` paths filter matches, which covers\n`package.json` files and docs but not `packages/*/src/**`; a same-repo PR that\nmakes a model root import smrt-ui only through source is therefore first\ncaught in the merge queue, which still blocks the merge. The filter is not\nwidened for this, because that would run the knowledge job on every PR. Run\n`pnpm check:node-roots` locally when changing a model package's root imports.\n\n## Lightweight discovery\n\nFilesystem-only discovery, module-doc parsing, and graph freshness are owned by\n`@happyvertical/smrt-scanner/knowledge`. Core's knowledge exports remain compatible\nreexports. Runtime manifest projection and custom-action semantics stay in core.\n\n`src/knowledge-discovery.ts`, exported through `smrt-core/knowledge`, enumerates\ninstalled scope directories and reads canonical AGENTS/legacy CLAUDE docs without\nloading package code, artifacts, or scanning objects. CLI snapshots and MCP share\nthese primitives. Callers retain selection policy: snapshots resolve links and\nfall back to `packages/*` only when no installed SMRT package loads; MCP preserves\nnode_modules paths, deduplicates realpaths, excludes authored workspace links,\nand then enriches the selected packages. Workspace-root/glob discovery remains\nowned by each consumer.\n\n## Domain Knowledge Artifacts\n\n`smrtPlugin()` writes runtime manifests and agent/developer knowledge artifacts:\n\n- local dev/build: `.smrt/manifest.json` and `.smrt/smrt-knowledge.json`\n- package build: `dist/manifest.json` and `dist/smrt-knowledge.json`\n\nKeep `manifest.json` runtime-focused. `smrt-knowledge.json` is the deterministic\nagent contract for downstream review and architecture tools.\n\nThe CLI's schema-command gate reads that same merged file: a pure-consumer\nproject (0 local objects) qualifies on the consumed packages its manifest\nrecords, so a clobbered `objects` map is what made `smrt db:migrate` report\n`missing_local_manifest` (#2925).\n\n`.smrt/manifest.json` has two writers and neither owns all of it: `smrtPlugin()`\ncontributes the project's scanned objects and `smrtConsumer()` the consumed\npackages' entries. Both writes are merge-preserving, keyed on each entry's\nrecorded `packageName` — a plain overwrite in either direction silently drops\nthe other's objects, and the last pass of a multi-environment build is not\nreliably the aggregating one (#1760, #2925). `dist/manifest.json` stays\nlocal-only: a published package must not carry its dependencies' objects.\nCore's canonical generator excludes temporary `__test-*` source roots as well\nas test files and `__tests__`; prepack verifies fresh and reused manifests\ncontain no test-artifact marker or fixture source provenance (#4190).\n\nThe schema-version-1 object projection is additive and high-signal: it retains\nnormalized tenant mode/field, explicit `cti`/`sti` strategy, conflict columns,\nmethod signatures, and field defaults/constraints/readonly/transient flags.\nSensitive fields are removed before both `fields` and `relationships` are\nderived, including legacy flags stored under `_meta`; matching field and\nsnake-case column names are also removed from projected conflict columns, and a\nsensitive custom tenant field is omitted while retaining scope and mode.\nGenerated artifacts assert this boundary with `sensitiveFieldsExcluded: true`;\nthe optional marker keeps schema version 1 additive while letting readers\nidentify older artifacts that require raw-manifest corroboration.\n\nConfig precedence for knowledge is defaults → top-level `knowledge` in\n`smrt.config.ts` → `packages[packageName].knowledge` → plugin option →\nobject-level `@smrt({ knowledge })`.\n\nTest generation uses that same effective configuration and publishes knowledge\nmatching the manifest it generated. Disabling knowledge removes a previous local\nknowledge artifact, so consumers cannot mistake it for current enabled output.\n`new ManifestBuilder(projectRoot)` keeps discovery, package identity, and output\nat that explicit root, including installed providers and external base classes;\nomitting the argument retains the current-working-directory\nbehavior. Both manifest writers retain declared package dependencies when merging\ntheir local and consumed-object projections.\n\nTest manifests carry optional `artifactPurpose: 'test'`; dependency discovery\nskips those artifacts, including `.smrt` fallbacks. Unmarked runtime manifests,\nempty published providers, and source-only development providers remain eligible.\n`ManifestBuilder` accepts explicit `outputMode: 'dev' | 'build'`; omitted mode\nretains legacy filename inference. Vitest selects dev mode explicitly, so tests\nnever replace a production `dist/manifest.json`. Relative output directories are\nresolved against the builder's project root.\n\nWith `svelteKit.enabled`, `smrtPlugin()`'s transform prepends\n`import './smrt-register.js';` to the configured config module\n(`<configPath>/<configFileName>`, `src/vite-plugin/sveltekit-register-injection.ts`)\nonce the register module exists, on the same first line so line numbers hold.\nApps therefore never hand-write the guarded registration import (#3416); a\nconfig module that still has it imports the same module twice, harmlessly.\n\nTracked `src/lib/server/smrt-register.ts` belongs to SvelteKit config/type\ngeneration. Generic library builds consume it but do not cache or restore it\nas an output; otherwise a concurrent config producer's bytes can be replayed\nover a newer registration after dependency eligibility changes.\n\nObject-level `knowledge: false` excludes an object from authored context only;\nit must not change runtime manifest registration. Use\n`knowledge: { tags, summary, risks }` for review-sensitive domain objects.\n\nHTTP knowledge routes are disabled by default. If `knowledge.api.enabled` is\ntrue, generated SvelteKit routes must stay GET-only and guarded by dev mode or\nadmin auth.\n\n\n## Vite Plugin\n\n`src/vite-preset/` (`@happyvertical/smrt-core/vite`, `smrt()`) composes the Oxc\ndecorator block, `smrtConsumer`, then `smrtPlugin` with the template path\nconventions. Its package list comes only from the `smrt({ packages })` option, or\n`smrt.config` `consumer.packages` (smrt-config schema); it never sniffs\n`package.json`, and a missing or malformed list throws. Keep its output\nbyte-identical to the two-plugin form (`src/vite-preset/index.test.ts`). It\nalso dedupes `@sveltejs/kit` (`smrt:shared-runtime`): SvelteKit matches\n`redirect()`/`fail()` by class, and a linked/workspace SMRT SvelteKit entry\nwould otherwise import its own copy and turn redirects into 500s. The preset\nalso keeps the thin `@happyvertical/smrt-app-runtime/sveltekit` entry inside\nthe SSR bundle: adapter-node bundles Kit itself, and an external runtime\nwould load a separate class through Node even at the same installed version.\nVite matches `ssr.noExternal` by package name, so a pre-resolver keeps the\nexact runtime root external and `ssr.external` keeps `./sveltekit-runtime`\nexternal. That implementation subpath is an internal composition boundary;\napps keep importing `./sveltekit`. This avoids bundling native SQL dependencies.\nThis split applies only to builds; development keeps normal SSR externalization\nand Kit dedupe because Vite's dev module runner ignores resolver external flags.\nOther runtime dependencies retain their normal externalization. Do not explicitly\nexternalize app-runtime in an app config. The production regression in\n`packages/app-runtime/src/sveltekit-production.test.ts` exercises the final\nadapter-node HTTP handler using an installed copy of the published dist entry.\n\n**Package models over REST (#3749).** `consumer.packages` registers a package's\nmodels and gives `@smrt/web` (the consumer's `collectionDefinitions`, one per\nlist-capable model, endpoint `/<collection>`) their definitions, but a package\nmodel gets generated REST routes only when it is on the consumer's hosting\nallowlist (`smrtConsumer({ svelteKit: { objects } })`, #2850: an HTTP exposure\nboundary, each model's own `api` config still applies, so `api: false` and\nempty-include models stay closed, tenancy comes with the generated route). The\npreset fills that allowlist: with `smrt.cookbook.json` present it is the models\nof the cookbook's recipes plus `features`, minus models the cookbook `exposure`\nturns off for `api` or a recipe's `options[model].exposure.api === false`\n(`vite-preset/cookbook-exposure.ts`; recipe `{ exclude }` narrowing is not\napplied by the build yet). `smrt({ expose })` replaces the derived list. No\ncookbook and no `expose` keeps `svelteKit: true` (nothing hosted). The producer's\n`@happyvertical/smrt-virt-web` still covers only the app's own models; read\npackage models from `@smrt/web`. `smrt({ objectsDir })` is passed to the hosting\ncontribution too: the producer and consumer share one `routesDir`, and the\ngenerator refuses it when their settings differ.\n\n**STI siblings (#3749).** Package STI subclasses share their base's table, and\nthe manifest gives them the base's `collection` unless they declare one\n(`consumer-plugin/hosted-sti-collections.ts`). Two hosted siblings would both\nclaim `/<base collection>`, so each hosted subclass that inherits its\ncollection gets its class-derived one (`Order` -> `orders`,\n`defaultCollectionName` in the scanner), in every manifest view the consumer\nderives: routes, `@smrt/web`, the generated client and registration. This is the\nconvention the framework already has for an independent route collection\n(`@smrt({ collection })`), applied by default for hosted subclasses; an explicit\ncollection wins. The route therefore reads through the subclass's own\ncollection (rows scoped to its `_meta_type`) and the definition for that model\nadvertises that route (`objectRef` = the model, `endpoint` = `/orders`). A hosted\nbase keeps the shared name and the base-collection semantics (every row of the\ntable, any subtype). A subclass left off the allowlist keeps the shared\ncollection and folds into the base's definition, as before. A collection class\nthat inherits another's item type (`MaterialCollection extends\nProductCollection`) resolves to the same item and collection, so only one of\nthem is hosted (the class named for the item, else the first). Subclasses that\ndeclare no `api` config get the omitted-config default (full CRUD), not the\nbase's `include`; narrow them on the subclass if the base is meant to restrict\nthem.\n\n```typescript\n// vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)\nexport default defineConfig({\n oxc: {\n decorator: {\n legacy: true,\n emitDecoratorMetadata: true,\n },\n },\n});\n```\n\nUnder Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`\nrecipe (or tsconfig `experimentalDecorators` reached through SvelteKit's\n`extends \"./.svelte-kit/tsconfig.json\"` chain), so that recipe throws\n`SyntaxError: Invalid or unexpected token` on the first SSR request. Configure\ndecorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need\nthe legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,\nemitDecoratorMetadata: true`.\n\nFor independent CI invocations, both `smrtPlugin()` and `smrtConsumer()` accept\nthe same `generationSnapshot: { path, sha256, provenance, sourceRoot }`. The\nschema-v1 snapshot produced by `serializeSmrtGenerationSnapshot()` contains the\nmerged project/dependency manifest, portable source paths, and source-file\ndigests; each plugin selects its own view. Reuse mode fails closed on\nbyte/provenance/path/content drift, skips scans and manifest writes, and still\ngenerates routes, types, registration, and virtual modules. Omit it for normal\nlocal development and watch mode.\n\n## Clean consumer type checks (TypeScript 6)\n\nThe consumer config hook writes `.smrt/register.js` alongside generated types\nafter route preflight succeeds. Generated SvelteKit registration imports that\nmodule, so it must exist during `svelte-kit sync`, before Vite's `buildStart`.\nBoth hosted routes and the legacy `svelteKit: true` mode follow this order.\nThe clean-consumer regression fixture ships real provider declarations and\nchecks the generated imports with `svelte-check`.\nCore's `generate:test` task depends on its own `build`: both publish the local\n`.smrt` manifest/knowledge pair, so Turbo must serialize production before test\ngeneration instead of caching an interleaved pair.\n\n## Model descriptions\n\nEach manifest object and knowledge `objects[]` entry may carry a user-facing\n`description` (for the planner Features tab and similar). The scanner\n(`extractClassJsDocDescription` in `packages/scanner/src/oxc-parser.ts`) reads it\nfrom the parsed source, in priority order: explicit `@smrt({ description })`,\nelse the first paragraph of the class JSDoc (the JSDoc may sit above `export`\nand decorators), minus a leading `ClassName - ` / `ClassName: `, tags\n(`{@link}`, backticks, HTML) and later paragraphs, capped at 200 chars on a\nsentence boundary. `@internal` classes and classes without docs emit no key. It\nis not inherited from a parent class. Generated MCP tool descriptions are\nunchanged (they stay generic).\n"
|
|
1206
|
+
"content": "# Decorators, build integration, and knowledge\n\nKey options: `tableName`, `tableStrategy` ('cti'|'sti'), `conflictColumns`, `indexes` (declared multi-column indexes; see [schema-paths.md](schema-paths.md)), `api`/`mcp`/`cli` (generation config), `ai` (callable methods), `hooks` (beforeSave/afterSave/beforeDelete/afterDelete), `embeddings` (auto-generate), `tenantScoped`, `agent`, `ui` (`{ icon, label, description }` — nav/help hints round-tripped through the manifest as plain data; `description` is the object-level seed for form-level help, #2046).\n\nRegistration sets `SMRT_TABLE_NAME` static property (survives minification).\n\nAn explicit `@smrt({ collection: 'domain.records' })` names the route/permission\nnamespace independently of `tableName`, including standalone non-STI models.\nScanner output preserves it in both `collection` and `decoratorConfig.collection`.\nGenerated registration reads the merged declaration, so older manifests with a\nderived top-level collection still agree with native decorators. Explicit runtime\nregistration options win; absent declarations retain manifest/STI/default naming.\n\n## @field() UI hints (#2046)\n\n`@field({ ui: { basic, group, order, locked } })` — a static, presentation-only\nseed for the field-policy rail (epic #2045). Carried in the manifest under the\nfield's `_meta.ui` (never a top-level `FieldDefinition` key), readable at\nruntime via `getAllFields()` at `field._meta.ui`, and emitted (sanitized) with\n`description` into generated web-collection definitions and browser MCP tool\nschemas. No schema/persistence/security effect — `sensitive`/`readPermission`\nstay the security rail, and `sensitive`/`transient` fields never emit to the\nclient at all.\n\n`ui.widget` (#3599) adds a presentation widget hint:\n`'textarea' | 'currency' | 'email' | 'url' | 'phone'`. It rides the same\n`_meta.ui` channel and is also emitted (sanitized) into web-collection\ndefinitions. The manifest generator validates it against the field type and\nfails the build otherwise: every widget needs a `text` field (`currency` is a\ncurrency-CODE picker for an ISO 4217 string such as `currency = 'USD'`, not a\nmoney amount; amounts are integer minor units and take no widget); an unknown\nvalue or a non-text field is an error. `ui.widget` is a hint only — it never changes\nthe column type or validation.\n\n`ui.format` (#3718) says how a value is presented: `money` (an `integer` of\nminor units — the amount hint record pages format and total), `date` (a\nbusiness date on a `datetime`, shown without a time), `datetime`, `percent`\n(a rate on a `decimal`/`integer`). Same channel, same emission, same build-time\ncheck against the field type. `@foreignKey(Target, { role: 'items' })` (or on\n`@crossPackageRef`) marks the declaring model as the target's line items; the\nscanner keeps it at `_meta.role`, the build rejects it on a non-reference or\nan unknown value, and the generated web relationship edge carries\n`role: 'items'` (edges without a role stay byte-stable). Both are hints: no\nschema, persistence or security effect.\n\n## Presentation metadata (#3599)\n\nPure helpers live in `src/ui-metadata.ts`, published browser-safe as\n`@happyvertical/smrt-core/ui-metadata` (also re-exported from the package\nroot). `ManifestGenerator.applyUiMetadata()` runs in `applyGenerationPasses`\nafter inheritance, so every producer (Vite plugin, `ManifestBuilder`,\n`generateManifest`) validates the same way.\n\n- **Display label.** `@smrt({ display: { label: '<field>' } })` names the own\n field generic pickers and assistants use to label a record. Own fields only —\n no paths into other packages; a model whose label lives elsewhere ships a\n selector. Undeclared, it defaults to the first of `name`, `title`, `label`,\n `code` that is a usable own field. Build fails when the declared field is\n missing, `sensitive`, `transient`, or a relationship/json field. The resolved\n field is emitted as `displayLabelField` on the manifest object and in the\n knowledge artifact, and generated MCP/WebMCP `list`/`get`/`update`/`delete`\n descriptions end with ``Records are identified by their `<field>` field.``\n (`describeAction` in `generators/tool-schema.ts`; nothing is added when the\n model has no label field).\n- **Selector slots.** `ModuleUISlot.selects: '@scope/pkg:Model'`\n (`@happyvertical/smrt-types`) marks a package component as THE selector for a\n model. The scanner reads such slots statically from `src/**/ui.ts`\n (`parseUiSelectorsFile`, independent of the class glob) and the manifest\n carries them as `uiSelectors` keyed by slot id. `selects` must be a string\n literal; a duplicate slot id, two selectors for one model, or an own-package\n target that does not exist fails the build. Hosts call\n `findSelectorFor(manifests, qualifiedName)`; generic forms use the selector\n for any `@foreignKey`/`@crossPackageRef` field targeting that model and fall\n back to a generic picker labelled by `displayLabelField`. `display` is read\n per class: an STI subclass that declares none uses the default lookup rather\n than its base's declaration, and a CTI child cannot name a parent's field.\n\n## Worker registration artifact (#3117)\n\nWith `svelteKit.enabled`, a production `vite build` compiles the generated\n`smrt-register.ts` into `.smrt/runtime/register.js` after the server build\n(`src/vite-plugin/worker-registration.ts`, called once from `closeBundle`).\nadapter-node re-bundles the server from its own entries, so the web bundle is\nnever importable by a separate Node process; this artifact is how a deployed\ntask/schedule worker registers the app's local objects (and, through the\n`.smrt/register.js` it imports, consumed packages' objects) with the same\nqualified identities and isolated manifests as the web server. It is a nested\nSSR build with no consumer plugins: only aliases pointing into the project\nsources (e.g. `$lib`) apply, and bare imports stay external so the worker\nshares the installed `@happyvertical/smrt-core` registry. The sole exception is\n`@oxc-project/runtime` and its helper subpaths: Vite injects them when lowering\ndecorators, but applications do not declare that compiler support dependency,\nso the artifact bundles them. SvelteKit\nvirtual modules (`$env/*`, `$app/*`) are not available to a worker; an object\nimporting one fails at worker startup. A failure fails the build.\n\n**Register imports stay Node-loadable (#4219).** `.smrt/register.js`\n(`generateRegistrationFile` in `src/consumer-plugin/index.ts`) is evaluated by\nplain Node — `smrt db:migrate`, every CLI, and the worker artifact above — as\nwell as by Vite SSR, so it may only import modules Node can load. Each object\nis imported from its manifest `importPath` (the scanner's export-subpath match,\ne.g. `@happyvertical/smrt-products/models`). Each configured provider in\n`smrtDependencies` is also imported once so it is evaluated in Node's installed\npackage context (#4187): the package root, unless the package declares that\nroot as a Svelte entry (`isComponentBearingRoot`: a `svelte` field, or a `.`\nexport with a `svelte` condition or a `.svelte`/`.css` target — e.g.\n`@happyvertical/smrt-products`, whose root re-exports its components and so\nreaches `@happyvertical/smrt-ui`'s `.svelte` files). Such a root is never\nimported and no subpath is substituted for it (`resolveProviderContextImport`\nreturns `undefined`): its already-imported object entries establish the\ncontext, a provider contributing only nested objects needs none because the\ntransitive loader uses only `import.meta.resolve(provider)`, and a substitute\n`./models` whose JS reached `.svelte` would re-create the failure. A package that publishes Svelte\nonly under subpaths keeps its root import; the broader `isComponentBearingPackage`\ntest (any Svelte export) only selects `ssr.noExternal` packages. A `svelte`\ncondition counts even with a plain `.js` target — that is what svelte-package\nand Vite library builds emit for a root that re-exports components, and every\nSvelte root in this workspace (`smrt-products`, `smrt-ui`, `smrt-svelte`,\n`smrt-planner`) has that shape and fails under plain Node; a Node-safe root\nthat declares `svelte` anyway only loses an optional context import.\nTransitive-only object modules are loaded through `importSmrtDependency()`\nfrom their provider's context, which needs no root import.\n\nSelective SSR compilation compares a JavaScript barrel's `svelte` export target\nagainst the package instance visible from its actual importer. pnpm may install\nthe same version in multiple peer contexts; the app-root package directory is\nnot the identity of a transitive import. Read that importer's installed export\nmap and root together, preserving exact target matching. Plain server entries\nremain external; do not fix peer-context resolution by deduplicating app\npackages or bundling every server export (#4318).\n\nNot covered by contract: a package whose root is a Svelte entry AND whose\n`@smrt()` objects are exported only from that root. The object loop must then\nimport the root (it is the only module exporting the constructors) — statically\nwhen the app resolves the package, or through `importSmrtDependency()` when it\nis reached only through its provider — so Vite SSR works but plain Node can\nload the register only if that root happens to be Node-safe. Generation keeps\nthe import and warns once per package (`declares a Svelte root entry`), asking\nto verify the root under plain Node or publish the objects from `./models` or\n`./objects`. The declaration is a heuristic: a Node-safe root that declares\n`svelte` only by convention gets the same warning (a false positive, harmless —\nthe import is kept) and, when its objects come from subpaths, loses only its\noptional provider-context import. Regression: the `#4219` tests in\n`index.test.ts` (unit cases for `isComponentBearingRoot` /\n`resolveProviderContextImport`, a child-Node load of the generated register,\nthe nested-only provider that gets no import, and the root-only and transitive\nwarnings).\n\n**Gate: `pnpm check:node-roots`** (root script; run after `pnpm build`). It\nruns `scripts/survey-node-root-load.mjs --strict --allow=<packages>`, which\nspawns `node --input-type=module -e \"await import('<name>')\"` from every\nbuilt workspace package whose manifest declares `@smrt()` objects and fails on\nany root plain Node cannot evaluate that is not allow-listed. The allow-list\nis explicit and lives in the root `package.json` script: currently only\n`@happyvertical/smrt-products`, whose root re-exports its Svelte components by\ndocumented contract. A new entry needs the same justification; a model root\nthat merely starts importing `@happyvertical/smrt-ui` must be fixed, not\nallowed. `--strict` also fails when nothing was surveyed (run before\n`pnpm build`) and when an allow-listed package was not surveyed or now loads,\nso the list cannot go stale. `--all` surveys every published package,\nincluding those without objects, as a report.\n\nCI runs the gate in both Knowledge Freshness jobs of\n`.github/workflows/test-suite.yml`. In full mode (the merge queue, and every PR\nwhile `vars.CI_MERGE_QUEUE_ENABLED` is not `true`) it always runs. In affected\nmode it runs only when the `knowledge` paths filter matches, which covers\n`package.json` files and docs but not `packages/*/src/**`; a same-repo PR that\nmakes a model root import smrt-ui only through source is therefore first\ncaught in the merge queue, which still blocks the merge. The filter is not\nwidened for this, because that would run the knowledge job on every PR. Run\n`pnpm check:node-roots` locally when changing a model package's root imports.\n\n## Lightweight discovery\n\nFilesystem-only discovery, module-doc parsing, and graph freshness are owned by\n`@happyvertical/smrt-scanner/knowledge`. Core's knowledge exports remain compatible\nreexports. Runtime manifest projection and custom-action semantics stay in core.\n\n`src/knowledge-discovery.ts`, exported through `smrt-core/knowledge`, enumerates\ninstalled scope directories and reads canonical AGENTS/legacy CLAUDE docs without\nloading package code, artifacts, or scanning objects. CLI snapshots and MCP share\nthese primitives. Callers retain selection policy: snapshots resolve links and\nfall back to `packages/*` only when no installed SMRT package loads; MCP preserves\nnode_modules paths, deduplicates realpaths, excludes authored workspace links,\nand then enriches the selected packages. Workspace-root/glob discovery remains\nowned by each consumer.\n\n## Domain Knowledge Artifacts\n\n`smrtPlugin()` writes runtime manifests and agent/developer knowledge artifacts:\n\n- local dev/build: `.smrt/manifest.json` and `.smrt/smrt-knowledge.json`\n- package build: `dist/manifest.json` and `dist/smrt-knowledge.json`\n\nKeep `manifest.json` runtime-focused. `smrt-knowledge.json` is the deterministic\nagent contract for downstream review and architecture tools.\n\nThe CLI's schema-command gate reads that same merged file: a pure-consumer\nproject (0 local objects) qualifies on the consumed packages its manifest\nrecords, so a clobbered `objects` map is what made `smrt db:migrate` report\n`missing_local_manifest` (#2925).\n\n`.smrt/manifest.json` has two writers and neither owns all of it: `smrtPlugin()`\ncontributes the project's scanned objects and `smrtConsumer()` the consumed\npackages' entries. Both writes are merge-preserving, keyed on each entry's\nrecorded `packageName` — a plain overwrite in either direction silently drops\nthe other's objects, and the last pass of a multi-environment build is not\nreliably the aggregating one (#1760, #2925). `dist/manifest.json` stays\nlocal-only: a published package must not carry its dependencies' objects.\nCore's canonical generator excludes temporary `__test-*` source roots as well\nas test files and `__tests__`; prepack verifies fresh and reused manifests\ncontain no test-artifact marker or fixture source provenance (#4190).\n\nThe schema-version-1 object projection is additive and high-signal: it retains\nnormalized tenant mode/field, explicit `cti`/`sti` strategy, conflict columns,\nmethod signatures, and field defaults/constraints/readonly/transient flags.\nSensitive fields are removed before both `fields` and `relationships` are\nderived, including legacy flags stored under `_meta`; matching field and\nsnake-case column names are also removed from projected conflict columns, and a\nsensitive custom tenant field is omitted while retaining scope and mode.\nGenerated artifacts assert this boundary with `sensitiveFieldsExcluded: true`;\nthe optional marker keeps schema version 1 additive while letting readers\nidentify older artifacts that require raw-manifest corroboration.\n\nConfig precedence for knowledge is defaults → top-level `knowledge` in\n`smrt.config.ts` → `packages[packageName].knowledge` → plugin option →\nobject-level `@smrt({ knowledge })`.\n\nTest generation uses that same effective configuration and publishes knowledge\nmatching the manifest it generated. Disabling knowledge removes a previous local\nknowledge artifact, so consumers cannot mistake it for current enabled output.\n`new ManifestBuilder(projectRoot)` keeps discovery, package identity, and output\nat that explicit root, including installed providers and external base classes;\nomitting the argument retains the current-working-directory\nbehavior. Both manifest writers retain declared package dependencies when merging\ntheir local and consumed-object projections.\n\nTest manifests carry optional `artifactPurpose: 'test'`; dependency discovery\nskips those artifacts, including `.smrt` fallbacks. Unmarked runtime manifests,\nempty published providers, and source-only development providers remain eligible.\n`ManifestBuilder` accepts explicit `outputMode: 'dev' | 'build'`; omitted mode\nretains legacy filename inference. Vitest selects dev mode explicitly, so tests\nnever replace a production `dist/manifest.json`. Relative output directories are\nresolved against the builder's project root.\n\nWith `svelteKit.enabled`, `smrtPlugin()`'s transform prepends\n`import './smrt-register.js';` to the configured config module\n(`<configPath>/<configFileName>`, `src/vite-plugin/sveltekit-register-injection.ts`)\nonce the register module exists, on the same first line so line numbers hold.\nApps therefore never hand-write the guarded registration import (#3416); a\nconfig module that still has it imports the same module twice, harmlessly.\n\nTracked `src/lib/server/smrt-register.ts` belongs to SvelteKit config/type\ngeneration. Generic library builds consume it but do not cache or restore it\nas an output; otherwise a concurrent config producer's bytes can be replayed\nover a newer registration after dependency eligibility changes.\n\nObject-level `knowledge: false` excludes an object from authored context only;\nit must not change runtime manifest registration. Use\n`knowledge: { tags, summary, risks }` for review-sensitive domain objects.\n\nHTTP knowledge routes are disabled by default. If `knowledge.api.enabled` is\ntrue, generated SvelteKit routes must stay GET-only and guarded by dev mode or\nadmin auth.\n\n\n## Vite Plugin\n\n`src/vite-preset/` (`@happyvertical/smrt-core/vite`, `smrt()`) composes the Oxc\ndecorator block, `smrtConsumer`, then `smrtPlugin` with the template path\nconventions. Its package list comes only from the `smrt({ packages })` option, or\n`smrt.config` `consumer.packages` (smrt-config schema); it never sniffs\n`package.json`, and a missing or malformed list throws. Keep its output\nbyte-identical to the two-plugin form (`src/vite-preset/index.test.ts`). It\nalso dedupes `@sveltejs/kit` (`smrt:shared-runtime`): SvelteKit matches\n`redirect()`/`fail()` by class, and a linked/workspace SMRT SvelteKit entry\nwould otherwise import its own copy and turn redirects into 500s. The preset\nalso keeps the thin `@happyvertical/smrt-app-runtime/sveltekit` entry inside\nthe SSR bundle: adapter-node bundles Kit itself, and an external runtime\nwould load a separate class through Node even at the same installed version.\nVite matches `ssr.noExternal` by package name, so a pre-resolver keeps the\nexact runtime root external and `ssr.external` keeps `./sveltekit-runtime`\nexternal. That implementation subpath is an internal composition boundary;\napps keep importing `./sveltekit`. This avoids bundling native SQL dependencies.\nThis split applies only to builds; development keeps normal SSR externalization\nand Kit dedupe because Vite's dev module runner ignores resolver external flags.\nOther runtime dependencies retain their normal externalization. Do not explicitly\nexternalize app-runtime in an app config. The production regression in\n`packages/app-runtime/src/sveltekit-production.test.ts` exercises the final\nadapter-node HTTP handler using an installed copy of the published dist entry.\n\n**Package models over REST (#3749).** `consumer.packages` registers a package's\nmodels and gives `@smrt/web` (the consumer's `collectionDefinitions`, one per\nlist-capable model, endpoint `/<collection>`) their definitions, but a package\nmodel gets generated REST routes only when it is on the consumer's hosting\nallowlist (`smrtConsumer({ svelteKit: { objects } })`, #2850: an HTTP exposure\nboundary, each model's own `api` config still applies, so `api: false` and\nempty-include models stay closed, tenancy comes with the generated route). The\npreset fills that allowlist: with `smrt.cookbook.json` present it is the models\nof the cookbook's recipes plus `features`, minus models the cookbook `exposure`\nturns off for `api` or a recipe's `options[model].exposure.api === false`\n(`vite-preset/cookbook-exposure.ts`; recipe `{ exclude }` narrowing is not\napplied by the build yet). `smrt({ expose })` replaces the derived list. No\ncookbook and no `expose` keeps `svelteKit: true` (nothing hosted). The producer's\n`@happyvertical/smrt-virt-web` still covers only the app's own models; read\npackage models from `@smrt/web`. `smrt({ objectsDir })` is passed to the hosting\ncontribution too: the producer and consumer share one `routesDir`, and the\ngenerator refuses it when their settings differ.\n\n**STI siblings (#3749).** Package STI subclasses share their base's table, and\nthe manifest gives them the base's `collection` unless they declare one\n(`consumer-plugin/hosted-sti-collections.ts`). Two hosted siblings would both\nclaim `/<base collection>`, so each hosted subclass that inherits its\ncollection gets its class-derived one (`Order` -> `orders`,\n`defaultCollectionName` in the scanner), in every manifest view the consumer\nderives: routes, `@smrt/web`, the generated client and registration. This is the\nconvention the framework already has for an independent route collection\n(`@smrt({ collection })`), applied by default for hosted subclasses; an explicit\ncollection wins. The route therefore reads through the subclass's own\ncollection (rows scoped to its `_meta_type`) and the definition for that model\nadvertises that route (`objectRef` = the model, `endpoint` = `/orders`). A hosted\nbase keeps the shared name and the base-collection semantics (every row of the\ntable, any subtype). A subclass left off the allowlist keeps the shared\ncollection and folds into the base's definition, as before. A collection class\nthat inherits another's item type (`MaterialCollection extends\nProductCollection`) resolves to the same item and collection, so only one of\nthem is hosted (the class named for the item, else the first). Subclasses that\ndeclare no `api` config get the omitted-config default (full CRUD), not the\nbase's `include`; narrow them on the subclass if the base is meant to restrict\nthem.\n\n```typescript\n// vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)\nexport default defineConfig({\n oxc: {\n decorator: {\n legacy: true,\n emitDecoratorMetadata: true,\n },\n },\n});\n```\n\nUnder Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`\nrecipe (or tsconfig `experimentalDecorators` reached through SvelteKit's\n`extends \"./.svelte-kit/tsconfig.json\"` chain), so that recipe throws\n`SyntaxError: Invalid or unexpected token` on the first SSR request. Configure\ndecorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need\nthe legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,\nemitDecoratorMetadata: true`.\n\nFor independent CI invocations, both `smrtPlugin()` and `smrtConsumer()` accept\nthe same `generationSnapshot: { path, sha256, provenance, sourceRoot }`. The\nschema-v1 snapshot produced by `serializeSmrtGenerationSnapshot()` contains the\nmerged project/dependency manifest, portable source paths, and source-file\ndigests; each plugin selects its own view. Reuse mode fails closed on\nbyte/provenance/path/content drift, skips scans and manifest writes, and still\ngenerates routes, types, registration, and virtual modules. Omit it for normal\nlocal development and watch mode.\n\n## Clean consumer type checks (TypeScript 6)\n\nThe consumer config hook writes `.smrt/register.js` alongside generated types\nafter route preflight succeeds. Generated SvelteKit registration imports that\nmodule, so it must exist during `svelte-kit sync`, before Vite's `buildStart`.\nBoth hosted routes and the legacy `svelteKit: true` mode follow this order.\nThe clean-consumer regression fixture ships real provider declarations and\nchecks the generated imports with `svelte-check`.\nCore's `generate:test` task depends on its own `build`: both publish the local\n`.smrt` manifest/knowledge pair, so Turbo must serialize production before test\ngeneration instead of caching an interleaved pair.\n\n## Model descriptions\n\nEach manifest object and knowledge `objects[]` entry may carry a user-facing\n`description` (for the planner Features tab and similar). The scanner\n(`extractClassJsDocDescription` in `packages/scanner/src/oxc-parser.ts`) reads it\nfrom the parsed source, in priority order: explicit `@smrt({ description })`,\nelse the first paragraph of the class JSDoc (the JSDoc may sit above `export`\nand decorators), minus a leading `ClassName - ` / `ClassName: `, tags\n(`{@link}`, backticks, HTML) and later paragraphs, capped at 200 chars on a\nsentence boundary. `@internal` classes and classes without docs emit no key. It\nis not inherited from a parent class. Generated MCP tool descriptions are\nunchanged (they stay generic).\n\n## AGENTS.md discovery in a monorepo (#3476)\n\n`buildDomainKnowledgeManifest()` takes the agent doc from the project root, else from the nearest ancestor `AGENTS.md` up to the workspace root (the nearest directory with `pnpm-workspace.yaml`, else the git root, a directory containing `.git`). It never walks past that boundary, and with no boundary above the project root it checks only the root. `agentDocPath` stays relative to the project root (so an inherited doc reads `../../AGENTS.md`), and `agentDoc` and `sourceHashes.agents` are populated from it. Module docs linked from the doc (`moduleDocs`) are only followed when `AGENTS.md` sits at the project root, since their links resolve against that directory.\n"
|
|
1207
1207
|
},
|
|
1208
1208
|
{
|
|
1209
1209
|
"path": "agents/memory.md",
|
|
@@ -1233,7 +1233,7 @@
|
|
|
1233
1233
|
{
|
|
1234
1234
|
"path": "agents/recipes.md",
|
|
1235
1235
|
"module": "recipes",
|
|
1236
|
-
"content": "# SmrtRecipe: declared units of app functionality (#3590)\n\nA **recipe** is a small, named, user-facing unit (\"Sales\", \"Customers\"),\nsmaller than a package. An app or agent adds a recipe and gets that section:\nnavigation, list and create/edit views, and its prerequisites, without every\nmodel in the owning package. Source: `src/recipe.ts` (authoring base class),\n`packages/scanner/src/recipes.ts` (matcher and validation), types in\n`@happyvertical/smrt-types` (`RecipeDefinition`).\n\n## Authoring\n\n`SmrtRecipe` is an abstract, **non-persisted, non-`@smrt()`** base class\nexported from both core entries (`index.ts` and `browser.ts`). A recipe is a\nsubclass with static properties, in the package that owns its models:\n\n```ts\nexport class SalesRecipe extends SmrtRecipe {\n static id = 'commerce.sales'; // dotted lowercase, unique\n static label = 'Sales';\n static summary = 'Take customer orders and track them.';\n static synonyms = ['sales orders', 'orders'];\n static models = [Order]; // class references\n static nav = [{ label: 'Sales Orders', model: Order }];\n static requires = ['commerce.customers']; // recipe ids, any package\n static options = { Order: { fields: { status: { default: 'draft' } } } };\n}\n```\n\nStatics are read **structurally** (nothing is evaluated), so each must be\nspelled literally; `models` and `nav[].model` take identifiers. Write\n`static options = { ... } as const` so `visibility` stays a literal type.\nThe emitted `options` are keyed by qualified model name.\n\n## Host metadata: group, section, nav detail, alternatives (#3604)\n\nEverything a catalog host (an app shell, an agent, a CLI) needs\nto present a recipe lives on the recipe, so it reads the manifest or knowledge\nartifact instead of importing runtime code. All are optional and additive:\n\n| Static | Shape | Meaning |\n| --- | --- | --- |\n| `group` | `{ id, label, summary? }` | Recipes with the same `group.id` share one card; the first declaration of an id supplies `label` and `summary`. Declaration order is the sub-switch order. |\n| `section` | `{ id, label, icon?, description? }` | Navigation section the recipe suggests for its `nav` entries. The host owns sections and the user may rename them, so keep `id` stable; the same `id` shares a section. |\n| `requiresAny` | `string[][]` | At least one id of each inner list must be on (`[['products.simple', 'products.clothing']]`); adding the recipe with none on adds the first. |\n| `nav[].icon` / `description` | string | Shell icon name and one friendly line for a business owner. The icon must be a key of `SHELL_ICON_PATHS` (smrt-svelte `admin-shell/shell-icons.ts`, camelCase such as `fileText` or `shoppingBag`); the same holds for `section.icon`. `packages/scanner/src/__tests__/workspace-recipes.test.ts` fails on any other name. |\n| `nav[].noun` | string | What \"New\" creates when the label is not countable (\"Stock levels\" -> `stock entry`). |\n| `nav[].key` | slug | Fixed key for the layout id `item:<pkg>:<Model>:<key>`. Required for a second entry over the same model and for a `filter`. |\n| `nav[].filter` | `{ field, value }` | Narrows the entry to rows where `field` equals `value` (Ingredients over Products: `productType` = `material`). Needs a `key`. |\n| `options.<Model>.fields.<f>.required` | boolean | The form refuses to save without a value. |\n\nAcross recipes of one package the scanner also rejects a repeated nav `key`\nover the same model (it names one layout id) and one `group`/`section` id with\ntwo different labels. `packages/scanner/src/__tests__/workspace-recipes.test.ts`\nscans every recipe in the workspace and checks ids, `requires`/`requiresAny`\ntargets (which a single package cannot see) and shared labels.\n\nChild models (line items) are simply listed in `models` without a `nav` entry.\nValidation: `group`/`section` take only their keys, slug ids, non-empty\nstrings; `requiresAny` ids follow the recipe-id format and cannot name the\nrecipe itself; each (model, key) pair appears once in `nav`; a `filter.field`\nmust be a field the model declares (checked in core on the merged manifest,\n`assertRecipeOptions`).\n\nNot part of the recipe declaration (still host-local): a host's\ncross-package `forms` and `extends` (form records spanning profiles, variants\nand SKUs).\n\nUser-facing field text for the recipes comes from `@field({ description })` on\nthe models (the glossary seed), so a package that declares a recipe carries the\ndescriptions of every field the recipe shows. A field the package does not\ndeclare itself (`parentId` from `SmrtHierarchical`) is described by the recipe's\n`options.<Model>.fields.<f>.help`, which wins over the description in the\nglossary. Write all of it in plain words: no API, JSON, REST, MCP, SQL or UUID\noutside help that is about connecting other tools.\n\n## Non-model surfaces, providers, runtime, demo seed (#3708)\n\nMany features are not a list of models: a shell widget (assistant dock,\nnotification bell), a component, a route, a settings panel. All four statics are\noptional and additive; a recipe that declares none emits none of the keys. They\nare emitted on the recipe entry in `manifest.json` and `smrt-knowledge.json`.\nComponents are named by `'<package specifier>#<ExportName>'` and never imported\nat scan time; a host resolves them. Relative or absolute specifiers are rejected.\n\n| Static | Shape | Meaning |\n| --- | --- | --- |\n| `surfaces` | `RecipeSurface[]` | `{ kind: 'shell-widget', slot, export, label, icon? }`, `{ kind: 'route', path, export, label }`, `{ kind: 'settings-panel', export, label }`, `{ kind: 'playground', export, label? }`, `{ kind: 'widget', type, export, label, ... }` (see below). `slot` is a smrt-svelte `ShellSlot`; `path` starts with `/` and has no whitespace, `?`, `#` or `..`. |\n| `providers` | `RecipeProvider[]` | `{ id, kind, options, required, secrets?, browserOptions? }`: lowercase slugs, `options` non-empty and distinct, `required` always written, `secrets` distinct `UPPER_SNAKE` names (never values), `browserOptions` a non-empty subset of `options` that run in a browser with none of the secrets (#3709). Ids are unique per recipe. |\n| `runtime` | `'browser' \\| 'server' \\| 'both'` | Where the recipe's runtime pieces can run. Omitted means `both`; the value is emitted as authored. The build refines it into `demo` (below). |\n| `exposure` | `'ui-only'` | The recipe's models are closed to the generated API, MCP and CLI on purpose (employment records). A host still offers the recipe and builds its screens, and the planner's Features card says agents cannot read it. The scanner rejects it unless every model sets `api`, `mcp` and `cli` to `false` or `{ include: [] }`, so the flag cannot drift (#4440). Omit it for every other recipe. |\n| `demoSeed` | `{ export } \\| { data }` | A fixture export reference, or inline JSON of at most 8 KB, for demo hosts. Exactly one key. |\n\nWrite `surfaces` with `as const` (like `options`) so `kind` and `slot` stay\nliteral types. The slot union is `RecipeShellSlot` in smrt-types (types only) and the\nscanner's `RECIPE_SHELL_SLOTS` list copies smrt-svelte's `SHELL_SLOTS`; a\ntype-level test in smrt-svelte and a scanner test keep them equal, so there is\nno runtime dependency on smrt-svelte. Inline `demoSeed.data` must be finite JSON. Every violation (unknown\nkind or slot, extra key, duplicate route path or provider id, malformed\nreference) is a scan error. Example: `events.calendar` in\n`packages/events/src/recipes.ts`.\n\n## Widget surfaces (#3727)\n\n`{ kind: 'widget', ... }` contributes a widget to customizable overviews\n(smrt-svelte `./overview`). The manifest carries everything a host needs to\nregister it; the helper `registerRecipeWidgets` in smrt-svelte does that with an\ninjected module resolver, so nothing is imported at scan time.\n\n```ts\nstatic surfaces = [\n {\n kind: 'widget',\n type: 'sales-total', // registry type, unique per package\n export: '@acme/shop/svelte#SalesTotal', // the component\n label: 'Sales total', // text or an i18n key\n description: 'Order totals for the period.',\n icon: 'chart',\n version: 2, // option-schema version (default 1)\n migrate: '@acme/shop/server#migrateSalesTotal', // needs version above 1\n optionsEditor: '@acme/shop/svelte#SalesOptions', // optional dependent-choice editor\n options: [\n { key: 'model', type: 'model', label: 'Model', required: true },\n { key: 'style', type: 'enum', label: 'Style', default: 'bar',\n choices: [{ value: 'bar', label: 'Bar' }, { value: 'line', label: 'Line' }] },\n ],\n data: { load: '@acme/shop/server#loadSalesTotal', models: ['@acme/shop:Order'] },\n allowedIn: ['shop.home'], // overview ids; omit for any\n sectionDefault: true, // optional: this widget IS the section page\n defaultSpan: 2, minSpan: 1, maxSpan: 3,\n },\n] as const;\n```\n\nEmitted keys, in this order: `kind`, `type`, `export`, `label`, `description`,\n`icon`, `version`, `migrate`, `optionsEditor`, `options`, `data`, `allowedIn`,\n`sectionDefault`, `defaultSpan`, `minSpan`, `maxSpan`; absent ones are omitted. Rules (each violation is a scan\nerror, same strictness as the other kinds):\n\n- `type` is a lowercase kebab id (`[a-z][a-z0-9-]{0,31}`), unique among a\n recipe's surfaces and across the package's recipes. `export`, `data.load`,\n `migrate`, and `optionsEditor` are `'<package specifier>#<ExportName>'`;\n relative specifiers are rejected. `label` is required. A UI host resolves an\n `optionsEditor` lazily for dependent or remote choices; the server registry\n never imports it, and every saved value still passes the ordinary option schema.\n- `options` is a non-empty list of plain-JSON fields limited to the overview\n option types: `text`, `markdown`, `identifier`, `model`, `integer`, `number`,\n `boolean`, closed `enum` (non-empty distinct `choices`). There is no free-form\n query, expression, URL or SQL type. Keys are camelCase (`[A-Za-z][A-Za-z0-9]{0,31}`)\n and unique; `default` must pass its field's own rule; `min`/`max` only on\n numbers, `maxLength` only on text. Only `key`, `type`, `label`, `help`,\n `required`, `default`, `min`, `max`, `maxLength`, `choices` are accepted.\n- `sectionDefault: true` makes the widget the section page of the recipe's\n section: when the recipe leads a navigation section (the first recipe\n suggesting it), a host rendering data widgets defaults that section's\n overview to this widget alone, full width, instead of the generic shortcuts,\n count and latest records (smrt-svelte `sectionDefaultWidget`). At most one per\n recipe, and every required option needs a `default`, since it is placed with\n no options. The person can still edit the page.\n- `data` takes `load` and/or `models` (qualified class names, distinct). The\n scanner cannot see other packages, so a model is checked for format only.\n- `allowedIn` is a non-empty list of distinct overview ids\n (`[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}`); spans are integers 1 to 4 with\n `minSpan <= defaultSpan <= maxSpan`.\n\nThe scanner's copies of these rules live in `packages/scanner/src/recipe-widgets.ts`\nand a scanner test compares them to the smrt-svelte source. See\n[overview-surfaces.md](../../smrt-svelte/agents/overview-surfaces.md#recipe-widgets-recipe-widgetsts)\nfor how a host registers them.\n\n## Browser-demo mode: Live, Mock, Sample, Server (#3709)\n\nA catalog host (smrt-planner) labels each feature by how far it runs in a\nbrowser-only demo. The label is **derived at build time**, never authored: the\nmanifest build combines what the bundle-gate measured for the recipe's package\nwith the recipe's own `runtime`, `providers` and `demoSeed`, and emits it as\n`demo` on the recipe entry and `browser` on the package. A recipe that declares\nnone of those keeps its shape except for `demo`, which every recipe of a\nmeasured package gets.\n\n```jsonc\n// manifest.json and smrt-knowledge.json, top level\n\"browser\": {\n \"status\": \"browser-safe\" | \"server-only\",\n \"issues\": [\"#3624\"], // tracking issues; omitted when browser-safe\n \"reason\": \"node:crypto\", // the package's own blocker; omitted when only inherited\n \"via\": [\"@happyvertical/smrt-tenancy\"] // inherited from these; omitted when none\n}\n// on each entry of \"recipes\"\n\"demo\": {\n \"mode\": \"live\" | \"mock\" | \"sample\" | \"server\",\n \"reasons\": [\"Needs a server because ...\"], // plain sentences, in rule order\n \"mocked\": [\"smtp\"] // providers a demo fakes; omitted when none\n}\n```\n\n| Mode | Meaning |\n| --- | --- |\n| `live` | Real browser data layer (PGlite); nothing faked. |\n| `mock` | Runs in the browser, but a provider (sending mail, OAuth) is faked. |\n| `sample` | Runs in the browser on `demoSeed` fixtures only. |\n| `server` | Needs a server; a browser-only host cannot offer it. |\n\n**Package capability.** The browser gate (`packages/bundle-gate`) fails any\nmodel package that is not in its expected-failures list and does not build for\na browser, and any listed package that now builds. So a package is `server-only`\nexactly when it is listed. The ratchet attributes a defect to the package that\nowns the fix, so a dependent passes while still dragging the failure into a\nbrowser bundle; the derivation therefore also marks a package `server-only`\nwhen a workspace package it depends on (transitively, through `dependencies`)\nis, and records them in `via`. `smrt-core` is the platform baseline and does not\npropagate. The table lives in `src/manifest/browser-capability.generated.ts`\n(generated, never edited) and a bundle-gate spec fails when it is stale; see\nthat package's AGENTS.md for the regenerate command. A package the gate does\nnot measure (an app's own, a UI package) emits no `browser`, and its recipes get\na `demo` only when they declare `runtime`, `providers` or `demoSeed`.\n\n**Rules** (`src/recipe-demo.ts`, pure and browser-safe; the worst mode wins,\n`live` < `mock` < `sample` < `server`):\n\n1. `runtime: 'server'` is `server`. `runtime: 'both'` or omitted makes no claim:\n the package capability refines it, so `both` on a `server-only` package is\n `server`.\n2. A `server-only` package is `server`.\n3. A **server provider** is one that lists `secrets` (credentials a browser\n cannot hold). One with a `browserOptions` entry (an in-browser model such as\n `webllm`) is satisfied in a browser and changes nothing. Otherwise it is\n **mockable** when its `options` include `mock`. A *required* server provider\n that is neither is `sample` when the recipe has a `demoSeed` (the fixtures\n stand in for what the provider would feed), else `server`. An optional one\n that is neither leaves the mode alone and adds a reason.\n4. Any mockable server provider makes the recipe `mock` and lands in `mocked`.\n5. Otherwise `live`.\n\n**Contradictions fail the build** (like `assertRecipeOptions`), naming the\nrecipe: `runtime: 'browser'` on a `server-only` package, or beside a required\nserver provider with no in-browser and no `mock` option. Fix by giving the\nprovider a `browserOptions` entry or a `mock` option, making it optional, or\ndeclaring `both`/`server`. An explicit `both` or\n`server` on a `server-only` package is not a contradiction.\n\n**Dependencies between recipes.** `demo` is the recipe's own answer. A host that\nwants the answer including what the recipe needs calls\n`effectiveRecipeDemo(id, recipes)` (exported from both core entries): the worst\nof its own mode and every `requires` (transitively), counting the best known\nalternative of each `requiresAny` list; unknown ids are ignored.\n\n**Importing it in a browser page.** Use the subpath\n`@happyvertical/smrt-core/recipe-demo` (`deriveRecipeDemo`,\n`effectiveRecipeDemo`, `RECIPE_DEMO_MODES` and the demo types). Its module graph\nis `src/recipe-demo.ts` alone, with type-only imports, so it carries no `node:`\nimport. The root entry and `/browser` both also export these names, but both\nreach `manifest/manifest-loader.ts` through the registry, and that module calls\n`createRequire` from `node:module` as it loads, which a browser page cannot\nsatisfy. `src/__tests__/recipe-demo-entry.test.ts` walks the module graph and\nfails if the subpath gains a `node:` or package import.\n\nThe classification runs in `ManifestGenerator.applyGenerationPasses` (the one\npass sequence the Vite plugin, `ManifestBuilder` and `generateManifest()` share)\nthrough `applyRecipeDemo` in `src/scanner/recipe-demo-pass.ts`, after the\nmerged-manifest recipe assertions. It needs the package name, so it cannot run\nin the scanner, which resolves recipes before it knows the package.\n\n## How the scanner collects it\n\nThe scanner class pass finds classes by decorator, which a recipe lacks, so\n`recipes.ts` is a second matcher in the style of the agent-surface one: a class\nwhose `extends` resolves to the `SmrtRecipe` import binding from\n`@happyvertical/smrt-core` (named, aliased `SmrtRecipe as R`, or namespace\n`Core.SmrtRecipe`; any core subpath such as `/browser`). A same-named local\nclass or an import from another library is not a recipe. A recipe must extend\n`SmrtRecipe` **directly**. Within one file, extending another recipe or declaring\none as a class expression or inside a block is a scan error rather than a silent\ndrop. Across files it is not detected: a module that never names `SmrtRecipe`\nis not read, so a recipe extending a recipe or intermediate base imported from\nanother module is unsupported and would be missing from the artifacts.\n\nClass references resolve to qualified names (`@scope/pkg:Class`) through the\nimport binding: a relative specifier picks the scanned class by file, then by\nclass name (barrel re-exports keep the name); an aliased import follows the\n*imported* name; `Ns.Class` namespace members and same-file classes work; a\nbare package specifier is rejected (models must belong to this package). When\nthe imported file declares no such class, the barrel fallback binds a same-named\nscanned class, so keep model class names unique within a package. This\nwas chosen over qualified-name strings because it type-checks and survives\nrenames; strings were not needed.\n\nRecipe discovery is independent of the model `include` glob (like intents; it is\nnot gated by the `agentSurface` option), and\n`ScanResults.recipes` is populated by `resolve()`. Every error is a scan error,\nso every producer that reads `scanAndResolve()` (`smrtPlugin`, `ManifestBuilder`)\nfails the build. (The legacy `ManifestGenerator.generateManifest()` path takes\npre-scanned results and never sees recipes.) The adapter qualifies names and emits a top-level `recipes` array in\n`manifest.json`; `buildDomainKnowledgeManifest` projects the same array into\n`smrt-knowledge.json`. A package with no recipe emits no key.\n\n## Build-time validation\n\nIds are dotted lowercase and unique (the first segment is usually a package short\nname and may be hyphenated: `human-resources.people`); `models` is non-empty, resolvable, and\nduplicate-free; every `nav` model is in `models`; `requires` are well-formed,\nnot self-referential or duplicated, and acyclic within the package (ids in other\npackages cannot be checked at scan time); `options` name only listed models and\nand only the keys below. Field names are checked in core\n(`ManifestGenerator.assertRecipeOptions`, after inherited fields merge) against\nthe merged manifest, so STI-merged, tenant, and framework-base fields such as\n`parentId` are accepted along with the universal `id`, `slug`, `context`,\n`created_at`, `updated_at`; anything else fails the build.\n\n## options: curation hints, never a second schema\n\nKeyed by model class name. `fields.<name>` accepts the smrt-fields policy\nvocabulary: `default`, `label`, `help`, `order`, `visibility`\n(`basic | advanced | hidden`), `locked`; applying a recipe produces\nfield-policy rows. `exposure.<api|mcp|cli>` accepts only `false` or\nnon-empty `{ exclude: [...] }` naming a CRUD verb (`list`, `get`, `create`,\n`update`, `delete`) or a public instance method the model declares or inherits: `true` and `include` are\nrejected because they can widen what the model already declares. Custom fields are out of scope.\n\n## User-facing help (#3591)\n\nHelp is documentation for the people using the app, not developers. It lives\nbeside the recipe as Markdown and is pointed to by `static help`:\n\n```ts\nexport class SalesRecipe extends SmrtRecipe {\n // ...\n static help = './sales.recipe.md'; // relative to the recipe file, .md only\n}\n```\n\nThe file holds an **Overview** and **Tasks** (short how-tos). A step names a\nfield as `{field:<fieldName>}` or `{field:<Model>.<fieldName>}`; the Markdown\nblock (heading, paragraph, or list item) containing a reference is **tied to\nthat field**. Keep prose in the separate file rather than the recipe class.\nField text itself comes from `@field({ description })`, the user-facing seed\nsmrt-fields uses for form help; every field a recipe shows should carry one.\n\n**Emission.** The scanner reads the file (a missing, empty, non-`.md`, absolute\nor `..`-climbing path is a scan error) and emits\n`help: { markdown, fieldRefs }` on the recipe entry in `manifest.json` and\n`smrt-knowledge.json`, so a static host needs no further request.\n`fieldRefs` (distinct references as written, sorted) is **derived**, never\nauthored. Knowledge fields also carry `description` (sensitive fields stay\nexcluded). `ManifestGenerator.assertRecipeHelp` fails the build on a reference\nto a field the recipe's models do not declare, and on a `fieldRefs` list that\ndisagrees with the Markdown. The scanner has its own copy of the reference\ngrammar (`deriveHelpFieldRefs`; it cannot import core), so that comparison also\nkeeps the two from drifting. A reference to a **sensitive** or universal (`id`,\n`slug`, ...) field also fails the build: neither reaches the knowledge artifact,\nso a host building its field list from it would silently drop the step naming\none; describe such a field in prose without a reference.\n\nKnown limitation: the help file is a generator input, but editing only it does\nnot trigger a dev-server rescan (the watch matches source globs) and generation\nsnapshots do not digest it, so rebuild (or touch a `.ts` source) after editing.\n\n**Rendering.** `src/recipe-help.ts` is pure and browser-safe (exported from\nboth core entries). `renderHelp(help, models, options?)` takes the recipe's\nhelp plus the app's effective field policies (`HelpModel[]`; label, help,\nvisibility, disabled in smrt-fields vocabulary, with `@field` descriptions) and\nreturns `{ blocks, glossary, connect? }`:\n\n- `{field:x}` becomes the effective label;\n- a block tied to a hidden, disabled, undeclared, or **advanced** field (unless\n `showAdvanced`) is dropped; a list item drops alone; a heading whose section\n had content that was all dropped is pruned (a heading that never had content\n stays);\n- the glossary lists each shown field with its effective help, else its\n description. A shown field with neither is **skipped**, not listed blank;\n- `connect` is the collapsed \"Connect other tools\" section: the recipe's\n generated REST routes, MCP tools and CLI commands (`options.surfaces`, from\n the knowledge artifact), minus the recipe's `exposure` narrowing\n (`options.exposure`). Hosts render it last.\n\nThe result is an AST of text nodes (`text`, `code`, `strong`, `em`), never HTML,\nso authored markup stays inert text and nothing needs sanitizing. Only\n`##`-`####` headings, paragraphs, flat lists and bold/italic/code are\nunderstood. `helpToMarkdown(rendered)` flattens it for hosts with their own\nrenderer; render that with raw HTML disabled. `validateHelp(help, models)`\nreturns the build-failure problems for hand-built entries.\n"
|
|
1236
|
+
"content": "# SmrtRecipe: declared units of app functionality (#3590)\n\nA **recipe** is a small, named, user-facing unit (\"Sales\", \"Customers\"),\nsmaller than a package. An app or agent adds a recipe and gets that section:\nnavigation, list and create/edit views, and its prerequisites, without every\nmodel in the owning package. Source: `src/recipe.ts` (authoring base class),\n`packages/scanner/src/recipes.ts` (matcher and validation), types in\n`@happyvertical/smrt-types` (`RecipeDefinition`).\n\n## Authoring\n\n`SmrtRecipe` is an abstract, **non-persisted, non-`@smrt()`** base class\nexported from both core entries (`index.ts` and `browser.ts`). A recipe is a\nsubclass with static properties, in the package that owns its models:\n\n```ts\nexport class SalesRecipe extends SmrtRecipe {\n static id = 'commerce.sales'; // dotted lowercase, unique\n static label = 'Sales';\n static summary = 'Take customer orders and track them.';\n static synonyms = ['sales orders', 'orders'];\n static models = [Order]; // class references\n static nav = [{ label: 'Sales Orders', model: Order }];\n static requires = ['commerce.customers']; // recipe ids, any package\n static options = { Order: { fields: { status: { default: 'draft' } } } };\n}\n```\n\nStatics are read **structurally** (nothing is evaluated), so each must be\nspelled literally; `models` and `nav[].model` take identifiers. Write\n`static options = { ... } as const` so `visibility` stays a literal type.\nThe emitted `options` are keyed by qualified model name.\n\n## Host metadata: group, section, nav detail, alternatives (#3604)\n\nEverything a catalog host (an app shell, an agent, a CLI) needs\nto present a recipe lives on the recipe, so it reads the manifest or knowledge\nartifact instead of importing runtime code. All are optional and additive:\n\n| Static | Shape | Meaning |\n| --- | --- | --- |\n| `group` | `{ id, label, summary? }` | Recipes with the same `group.id` share one card; the first declaration of an id supplies `label` and `summary`. Declaration order is the sub-switch order. |\n| `section` | `{ id, label, icon?, description? }` | Navigation section the recipe suggests for its `nav` entries. The host owns sections and the user may rename them, so keep `id` stable; the same `id` shares a section. |\n| `requiresAny` | `string[][]` | At least one id of each inner list must be on (`[['products.simple', 'products.clothing']]`); adding the recipe with none on adds the first. |\n| `nav[].icon` / `description` | string | Shell icon name and one friendly line for a business owner. The icon must be a key of `SHELL_ICON_PATHS` (smrt-svelte `admin-shell/shell-icons.ts`, camelCase such as `fileText` or `shoppingBag`); the same holds for `section.icon`. `packages/scanner/src/__tests__/workspace-recipes.test.ts` fails on any other name. |\n| `nav[].noun` | string | What \"New\" creates when the label is not countable (\"Stock levels\" -> `stock entry`). |\n| `nav[].key` | slug | Fixed key for the layout id `item:<pkg>:<Model>:<key>`. Required for a second entry over the same model and for a `filter`. |\n| `nav[].filter` | `{ field, value }` | Narrows the entry to rows where `field` equals `value` (Ingredients over Products: `productType` = `material`). Needs a `key`. |\n| `options.<Model>.fields.<f>.required` | boolean | The form refuses to save without a value. |\n\nAcross recipes of one package the scanner also rejects a repeated nav `key`\nover the same model (it names one layout id) and one `group`/`section` id with\ntwo different labels. `packages/scanner/src/__tests__/workspace-recipes.test.ts`\nscans every recipe in the workspace and checks ids, `requires`/`requiresAny`\ntargets (which a single package cannot see) and shared labels.\n\nChild models (line items) are simply listed in `models` without a `nav` entry.\nValidation: `group`/`section` take only their keys, slug ids, non-empty\nstrings; `requiresAny` ids follow the recipe-id format and cannot name the\nrecipe itself; each (model, key) pair appears once in `nav`; a `filter.field`\nmust be a field the model declares (checked in core on the merged manifest,\n`assertRecipeOptions`).\n\nNot part of the recipe declaration (still host-local): a host's\ncross-package `forms` and `extends` (form records spanning profiles, variants\nand SKUs).\n\nUser-facing field text for the recipes comes from `@field({ description })` on\nthe models (the glossary seed), so a package that declares a recipe carries the\ndescriptions of every field the recipe shows. A field the package does not\ndeclare itself (`parentId` from `SmrtHierarchical`) is described by the recipe's\n`options.<Model>.fields.<f>.help`, which wins over the description in the\nglossary. Write all of it in plain words: no API, JSON, REST, MCP, SQL or UUID\noutside help that is about connecting other tools.\n\n## Non-model surfaces, providers, runtime, demo seed (#3708)\n\nMany features are not a list of models: a shell widget (assistant dock,\nnotification bell), a component, a route, a settings panel. All four statics are\noptional and additive; a recipe that declares none emits none of the keys. They\nare emitted on the recipe entry in `manifest.json` and `smrt-knowledge.json`.\nComponents are named by `'<package specifier>#<ExportName>'` and never imported\nat scan time; a host resolves them. Relative or absolute specifiers are rejected.\n\n| Static | Shape | Meaning |\n| --- | --- | --- |\n| `surfaces` | `RecipeSurface[]` | `{ kind: 'shell-widget', slot, export, label, icon? }`, `{ kind: 'route', path, export, label }`, `{ kind: 'settings-panel', export, label }`, `{ kind: 'playground', export, label? }`, `{ kind: 'widget', type, export, label, ... }` (see below). `slot` is a smrt-svelte `ShellSlot`; `path` starts with `/` and has no whitespace, `?`, `#` or `..`. |\n| `providers` | `RecipeProvider[]` | `{ id, kind, options, required, secrets?, browserOptions? }`: lowercase slugs, `options` non-empty and distinct, `required` always written, `secrets` distinct `UPPER_SNAKE` names (never values), `browserOptions` a non-empty subset of `options` that run in a browser with none of the secrets (#3709). Ids are unique per recipe. |\n| `runtime` | `'browser' \\| 'server' \\| 'both'` | Where the recipe's runtime pieces can run. Omitted means `both`; the value is emitted as authored. The build refines it into `demo` (below). |\n| `exposure` | `'ui-only'` | The recipe's models are closed to the generated API, MCP and CLI on purpose (employment records). A host still offers the recipe and builds its screens, and the planner's Features card says agents cannot read it. The scanner rejects it unless every model sets `api`, `mcp` and `cli` to `false` or `{ include: [] }`, so the flag cannot drift (#4440). Omit it for every other recipe. |\n| `demoSeed` | `{ export } \\| { data }` | A fixture export reference, or inline JSON of at most 8 KB, for demo hosts. Exactly one key. |\n\nWrite `surfaces` with `as const` (like `options`) so `kind` and `slot` stay\nliteral types. The slot union is `RecipeShellSlot` in smrt-types (types only) and the\nscanner's `RECIPE_SHELL_SLOTS` list copies smrt-svelte's `SHELL_SLOTS`; a\ntype-level test in smrt-svelte and a scanner test keep them equal, so there is\nno runtime dependency on smrt-svelte. Inline `demoSeed.data` must be finite JSON. Every violation (unknown\nkind or slot, extra key, duplicate route path or provider id, malformed\nreference) is a scan error. Example: `events.calendar` in\n`packages/events/src/recipes.ts`.\n\n## Widget surfaces (#3727)\n\n`{ kind: 'widget', ... }` contributes a widget to customizable overviews\n(smrt-svelte `./overview`). The manifest carries everything a host needs to\nregister it; the helper `registerRecipeWidgets` in smrt-svelte does that with an\ninjected module resolver, so nothing is imported at scan time.\n\n```ts\nstatic surfaces = [\n {\n kind: 'widget',\n type: 'sales-total', // registry type, unique per package\n export: '@acme/shop/svelte#SalesTotal', // the component\n label: 'Sales total', // text or an i18n key\n description: 'Order totals for the period.',\n icon: 'chart',\n version: 2, // option-schema version (default 1)\n migrate: '@acme/shop/server#migrateSalesTotal', // needs version above 1\n optionsEditor: '@acme/shop/svelte#SalesOptions', // optional dependent-choice editor\n options: [\n { key: 'model', type: 'model', label: 'Model', required: true },\n { key: 'style', type: 'enum', label: 'Style', default: 'bar',\n choices: [{ value: 'bar', label: 'Bar' }, { value: 'line', label: 'Line' }] },\n ],\n data: { load: '@acme/shop/server#loadSalesTotal', models: ['@acme/shop:Order'] },\n allowedIn: ['shop.home'], // overview ids; omit for any\n sectionDefault: true, // optional: this widget IS the section page\n defaultSpan: 2, minSpan: 1, maxSpan: 3,\n },\n] as const;\n```\n\nEmitted keys, in this order: `kind`, `type`, `export`, `label`, `description`,\n`icon`, `version`, `migrate`, `optionsEditor`, `options`, `data`, `allowedIn`,\n`sectionDefault`, `defaultSpan`, `minSpan`, `maxSpan`; absent ones are omitted. Rules (each violation is a scan\nerror, same strictness as the other kinds):\n\n- `type` is a lowercase kebab id (`[a-z][a-z0-9-]{0,31}`), unique among a\n recipe's surfaces and across the package's recipes. `export`, `data.load`,\n `migrate`, and `optionsEditor` are `'<package specifier>#<ExportName>'`;\n relative specifiers are rejected. `label` is required. A UI host resolves an\n `optionsEditor` lazily for dependent or remote choices; the server registry\n never imports it, and every saved value still passes the ordinary option schema.\n- `options` is a non-empty list of plain-JSON fields limited to the overview\n option types: `text`, `markdown`, `identifier`, `model`, `integer`, `number`,\n `boolean`, closed `enum` (non-empty distinct `choices`). There is no free-form\n query, expression, URL or SQL type. Keys are camelCase (`[A-Za-z][A-Za-z0-9]{0,31}`)\n and unique; `default` must pass its field's own rule; `min`/`max` only on\n numbers, `maxLength` only on text. Only `key`, `type`, `label`, `help`,\n `required`, `default`, `min`, `max`, `maxLength`, `choices` are accepted.\n- `sectionDefault: true` makes the widget the section page of the recipe's\n section: when the recipe leads a navigation section (the first recipe\n suggesting it), a host rendering data widgets defaults that section's\n overview to this widget alone, full width, instead of the generic shortcuts,\n count and latest records (smrt-svelte `sectionDefaultWidget`). At most one per\n recipe, and every required option needs a `default`, since it is placed with\n no options. The person can still edit the page.\n- `data` takes `load` and/or `models` (qualified class names, distinct). The\n scanner cannot see other packages, so a model is checked for format only.\n- `allowedIn` is a non-empty list of distinct overview ids\n (`[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}`); spans are integers 1 to 4 with\n `minSpan <= defaultSpan <= maxSpan`.\n\nThe scanner's copies of these rules live in `packages/scanner/src/recipe-widgets.ts`\nand a scanner test compares them to the smrt-svelte source. See\n[overview-surfaces.md](../../smrt-svelte/agents/overview-surfaces.md#recipe-widgets-recipe-widgetsts)\nfor how a host registers them.\n\n## Browser-demo mode: Live, Mock, Sample, Server (#3709)\n\nA catalog host (smrt-planner) labels each feature by how far it runs in a\nbrowser-only demo. The label is **derived at build time**, never authored: the\nmanifest build combines what the bundle-gate measured for the recipe's package\nwith the recipe's own `runtime`, `providers` and `demoSeed`, and emits it as\n`demo` on the recipe entry and `browser` on the package. A recipe that declares\nnone of those keeps its shape except for `demo`, which every recipe of a\nmeasured package gets.\n\n```jsonc\n// manifest.json and smrt-knowledge.json, top level\n\"browser\": {\n \"status\": \"browser-safe\" | \"server-only\",\n \"issues\": [\"#3624\"], // tracking issues; omitted when browser-safe\n \"reason\": \"node:crypto\", // the package's own blocker; omitted when only inherited\n \"via\": [\"@happyvertical/smrt-tenancy\"] // inherited from these; omitted when none\n}\n// on each entry of \"recipes\"\n\"demo\": {\n \"mode\": \"live\" | \"mock\" | \"sample\" | \"server\",\n \"reasons\": [\"Needs a server because ...\"], // plain sentences, in rule order\n \"mocked\": [\"smtp\"] // providers a demo fakes; omitted when none\n}\n```\n\n| Mode | Meaning |\n| --- | --- |\n| `live` | Real browser data layer (PGlite); nothing faked. |\n| `mock` | Runs in the browser, but a provider (sending mail, OAuth) is faked. |\n| `sample` | Runs in the browser on `demoSeed` fixtures only. |\n| `server` | Needs a server; a browser-only host cannot offer it. |\n\n**Package capability.** The browser gate (`packages/bundle-gate`) fails any\nmodel package that is not in its expected-failures list and does not build for\na browser, and any listed package that now builds. So a package is `server-only`\nexactly when it is listed. The ratchet attributes a defect to the package that\nowns the fix, so a dependent passes while still dragging the failure into a\nbrowser bundle; the derivation therefore also marks a package `server-only`\nwhen a workspace package it depends on (transitively, through `dependencies`)\nis, and records them in `via`. `smrt-core` is the platform baseline and does not\npropagate. The table lives in `src/manifest/browser-capability.generated.ts`\n(generated, never edited) and a bundle-gate spec fails when it is stale; see\nthat package's AGENTS.md for the regenerate command. A package the gate does\nnot measure (an app's own, a UI package) emits no `browser`, and its recipes get\na `demo` only when they declare `runtime`, `providers` or `demoSeed`.\n\n**Rules** (`src/recipe-demo.ts`, pure and browser-safe; the worst mode wins,\n`live` < `mock` < `sample` < `server`):\n\n1. `runtime: 'server'` is `server`. `runtime: 'both'` or omitted makes no claim:\n the package capability refines it, so `both` on a `server-only` package is\n `server`.\n2. A `server-only` package is `server`.\n3. A **server provider** is one that lists `secrets` (credentials a browser\n cannot hold). One with a `browserOptions` entry (an in-browser model such as\n `webllm`) is satisfied in a browser and changes nothing. Otherwise it is\n **mockable** when its `options` include `mock`. A *required* server provider\n that is neither is `sample` when the recipe has a `demoSeed` (the fixtures\n stand in for what the provider would feed), else `server`. An optional one\n that is neither leaves the mode alone and adds a reason.\n4. Any mockable server provider makes the recipe `mock` and lands in `mocked`.\n5. Otherwise `live`.\n\n**Contradictions fail the build** (like `assertRecipeOptions`), naming the\nrecipe: `runtime: 'browser'` on a `server-only` package, or beside a required\nserver provider with no in-browser and no `mock` option. Fix by giving the\nprovider a `browserOptions` entry or a `mock` option, making it optional, or\ndeclaring `both`/`server`. An explicit `both` or\n`server` on a `server-only` package is not a contradiction.\n\n**Dependencies between recipes.** `demo` is the recipe's own answer. A host that\nwants the answer including what the recipe needs calls\n`effectiveRecipeDemo(id, recipes)` (exported from both core entries): the worst\nof its own mode and every `requires` (transitively), counting the best known\nalternative of each `requiresAny` list; unknown ids are ignored.\n\n**Importing it in a browser page.** Use the subpath\n`@happyvertical/smrt-core/recipe-demo` (`deriveRecipeDemo`,\n`effectiveRecipeDemo`, `RECIPE_DEMO_MODES` and the demo types). Its module graph\nis `src/recipe-demo.ts` alone, with type-only imports, so it carries no `node:`\nimport. The root entry and `/browser` both also export these names, but both\nreach `manifest/manifest-loader.ts` through the registry, and that module calls\n`createRequire` from `node:module` as it loads, which a browser page cannot\nsatisfy. `src/__tests__/recipe-demo-entry.test.ts` walks the module graph and\nfails if the subpath gains a `node:` or package import.\n\nThe classification runs in `ManifestGenerator.applyGenerationPasses` (the one\npass sequence the Vite plugin, `ManifestBuilder` and `generateManifest()` share)\nthrough `applyRecipeDemo` in `src/scanner/recipe-demo-pass.ts`, after the\nmerged-manifest recipe assertions. It needs the package name, so it cannot run\nin the scanner, which resolves recipes before it knows the package.\n\n## How the scanner collects it\n\nThe scanner class pass finds classes by decorator, which a recipe lacks, so\n`recipes.ts` is a second matcher in the style of the agent-surface one: a class\nwhose `extends` resolves to the `SmrtRecipe` import binding from\n`@happyvertical/smrt-core` (named, aliased `SmrtRecipe as R`, or namespace\n`Core.SmrtRecipe`; any core subpath such as `/browser`). A same-named local\nclass or an import from another library is not a recipe. A recipe must extend\n`SmrtRecipe` **directly**. Within one file, extending another recipe or declaring\none as a class expression or inside a block is a scan error rather than a silent\ndrop. Across files it is not detected: a module that never names `SmrtRecipe`\nis not read, so a recipe extending a recipe or intermediate base imported from\nanother module is unsupported and would be missing from the artifacts.\n\nClass references resolve to qualified names (`@scope/pkg:Class`) through the\nimport binding: a relative specifier picks the scanned class by file, then by\nclass name (barrel re-exports keep the name); an aliased import follows the\n*imported* name; `Ns.Class` namespace members and same-file classes work; a\nbare package specifier is rejected (models must belong to this package). When\nthe imported file declares no such class, the barrel fallback binds a same-named\nscanned class, so keep model class names unique within a package. This\nwas chosen over qualified-name strings because it type-checks and survives\nrenames; strings were not needed.\n\nRecipe discovery is independent of the model `include` glob (like intents; it is\nnot gated by the `agentSurface` option), and\n`ScanResults.recipes` is populated by `resolve()`. Every error is a scan error,\nso every producer that reads `scanAndResolve()` (`smrtPlugin`, `ManifestBuilder`)\nfails the build. (The legacy `ManifestGenerator.generateManifest()` path takes\npre-scanned results and never sees recipes.) The adapter qualifies names and emits a top-level `recipes` array in\n`manifest.json`; `buildDomainKnowledgeManifest` projects the same array into\n`smrt-knowledge.json`. A package with no recipe emits no key.\n\n## Build-time validation\n\nIds are dotted lowercase and unique (the first segment is usually a package short\nname and may be hyphenated: `human-resources.people`); `models` is non-empty, resolvable, and\nduplicate-free; every `nav` model is in `models`; `requires` are well-formed,\nnot self-referential or duplicated, and acyclic within the package (ids in other\npackages cannot be checked at scan time); `options` name only listed models and\nand only the keys below. Field names are checked in core\n(`ManifestGenerator.assertRecipeOptions`, after inherited fields merge) against\nthe merged manifest, so STI-merged, tenant, and framework-base fields such as\n`parentId` are accepted along with the universal `id`, `slug`, `context`,\n`created_at`, `updated_at`; anything else fails the build.\nA package-local test that runs `applyGenerationPasses` on a single package's\nmanifest must first set `manifest.smrtDependencies` to the packages whose\nmodels it inherits from (`['@happyvertical/smrt-core']` for `SmrtHierarchical`'s\n`parentId`, plus `@happyvertical/smrt-content` for a `Content` subclass), or\nevery inherited field name in `options` is rejected; the real build reads them\nfrom the dependency tree (`packages/places/src/__tests__/recipes.test.ts`).\n\n## options: curation hints, never a second schema\n\nKeyed by model class name. `fields.<name>` accepts the smrt-fields policy\nvocabulary: `default`, `label`, `help`, `order`, `visibility`\n(`basic | advanced | hidden`), `locked`; applying a recipe produces\nfield-policy rows. `exposure.<api|mcp|cli>` accepts only `false` or\nnon-empty `{ exclude: [...] }` naming a CRUD verb (`list`, `get`, `create`,\n`update`, `delete`) or a public instance method the model declares or inherits: `true` and `include` are\nrejected because they can widen what the model already declares. Custom fields are out of scope.\n\n## User-facing help (#3591)\n\nHelp is documentation for the people using the app, not developers. It lives\nbeside the recipe as Markdown and is pointed to by `static help`:\n\n```ts\nexport class SalesRecipe extends SmrtRecipe {\n // ...\n static help = './sales.recipe.md'; // relative to the recipe file, .md only\n}\n```\n\nThe file holds an **Overview** and **Tasks** (short how-tos). A step names a\nfield as `{field:<fieldName>}` or `{field:<Model>.<fieldName>}`; the Markdown\nblock (heading, paragraph, or list item) containing a reference is **tied to\nthat field**. Keep prose in the separate file rather than the recipe class.\nField text itself comes from `@field({ description })`, the user-facing seed\nsmrt-fields uses for form help; every field a recipe shows should carry one.\n\n**Emission.** The scanner reads the file (a missing, empty, non-`.md`, absolute\nor `..`-climbing path is a scan error) and emits\n`help: { markdown, fieldRefs }` on the recipe entry in `manifest.json` and\n`smrt-knowledge.json`, so a static host needs no further request.\n`fieldRefs` (distinct references as written, sorted) is **derived**, never\nauthored. Knowledge fields also carry `description` (sensitive fields stay\nexcluded). `ManifestGenerator.assertRecipeHelp` fails the build on a reference\nto a field the recipe's models do not declare, and on a `fieldRefs` list that\ndisagrees with the Markdown. The scanner has its own copy of the reference\ngrammar (`deriveHelpFieldRefs`; it cannot import core), so that comparison also\nkeeps the two from drifting. A reference to a **sensitive** or universal (`id`,\n`slug`, ...) field also fails the build: neither reaches the knowledge artifact,\nso a host building its field list from it would silently drop the step naming\none; describe such a field in prose without a reference.\n\nKnown limitation: the help file is a generator input, but editing only it does\nnot trigger a dev-server rescan (the watch matches source globs) and generation\nsnapshots do not digest it, so rebuild (or touch a `.ts` source) after editing.\n\n**Rendering.** `src/recipe-help.ts` is pure and browser-safe (exported from\nboth core entries). `renderHelp(help, models, options?)` takes the recipe's\nhelp plus the app's effective field policies (`HelpModel[]`; label, help,\nvisibility, disabled in smrt-fields vocabulary, with `@field` descriptions) and\nreturns `{ blocks, glossary, connect? }`:\n\n- `{field:x}` becomes the effective label;\n- a block tied to a hidden, disabled, undeclared, or **advanced** field (unless\n `showAdvanced`) is dropped; a list item drops alone; a heading whose section\n had content that was all dropped is pruned (a heading that never had content\n stays);\n- the glossary lists each shown field with its effective help, else its\n description. A shown field with neither is **skipped**, not listed blank;\n- `connect` is the collapsed \"Connect other tools\" section: the recipe's\n generated REST routes, MCP tools and CLI commands (`options.surfaces`, from\n the knowledge artifact), minus the recipe's `exposure` narrowing\n (`options.exposure`). Hosts render it last.\n\nThe result is an AST of text nodes (`text`, `code`, `strong`, `em`), never HTML,\nso authored markup stays inert text and nothing needs sanitizing. Only\n`##`-`####` headings, paragraphs, flat lists and bold/italic/code are\nunderstood. `helpToMarkdown(rendered)` flattens it for hosts with their own\nrenderer; render that with raw HTML disabled. `validateHelp(help, models)`\nreturns the build-failure problems for hand-built entries.\n"
|
|
1237
1237
|
},
|
|
1238
1238
|
{
|
|
1239
1239
|
"path": "agents/cookbook.md",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-core",
|
|
3
|
-
"version": "0.57.
|
|
4
|
-
"description": "Core
|
|
3
|
+
"version": "0.57.37",
|
|
4
|
+
"description": "Core SMRT framework for business objects, persistence, and generated REST, CLI, MCP, and AI interfaces",
|
|
5
5
|
"author": "HappyVertical",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
@@ -195,15 +195,15 @@
|
|
|
195
195
|
"./dist/utils/json.js": "./dist/utils/json.browser.js"
|
|
196
196
|
},
|
|
197
197
|
"dependencies": {
|
|
198
|
-
"@happyvertical/ai": "^0.
|
|
199
|
-
"@happyvertical/files": "^0.
|
|
200
|
-
"@happyvertical/json": "^0.
|
|
201
|
-
"@happyvertical/logger": "^0.
|
|
202
|
-
"@happyvertical/smrt-config": "0.57.
|
|
203
|
-
"@happyvertical/smrt-scanner": "0.57.
|
|
204
|
-
"@happyvertical/smrt-types": "0.57.
|
|
205
|
-
"@happyvertical/sql": "^0.
|
|
206
|
-
"@happyvertical/utils": "^0.
|
|
198
|
+
"@happyvertical/ai": "^0.110.0",
|
|
199
|
+
"@happyvertical/files": "^0.110.0",
|
|
200
|
+
"@happyvertical/json": "^0.110.0",
|
|
201
|
+
"@happyvertical/logger": "^0.110.0",
|
|
202
|
+
"@happyvertical/smrt-config": "0.57.37",
|
|
203
|
+
"@happyvertical/smrt-scanner": "0.57.37",
|
|
204
|
+
"@happyvertical/smrt-types": "0.57.37",
|
|
205
|
+
"@happyvertical/sql": "^0.110.0",
|
|
206
|
+
"@happyvertical/utils": "^0.110.0",
|
|
207
207
|
"@noble/hashes": "^2.4.0",
|
|
208
208
|
"@oxc-project/runtime": "^0.150.0",
|
|
209
209
|
"cheerio": "^1.2.0",
|