@happyvertical/smrt-chat 0.55.12 → 0.55.14

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.
@@ -3,13 +3,13 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-chat",
6
- "packageVersion": "0.55.12",
6
+ "packageVersion": "0.55.14",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "5bfe4b2c5c60101849783a3eec84f39acef280f99b4a8c6304d09bff1ec9d984",
11
- "packageJson": "29bb9fd27d604db8b31d246c5bb8174ae66e0cb6cf2c4262c17c338633561aff",
12
- "agents": "5dd4f1b94ec589ca363a11f204e50f778d92166cd7d81a62990da02e1405df30",
10
+ "manifest": "f34712ab44b39f51909d68565ca4603d1079232fff1fd333814bb07e12e521c6",
11
+ "packageJson": "d2468ff767cbd3752c35f5d23d77e2a51e4454fb7d0778209250132880dc43c3",
12
+ "agents": "aa720d6aa1fa097a61d56b1dab61b7857a8d4ee41920443aa1f085016488f90a",
13
13
  "moduleDoc:agents/dev-workbench.md": "f07a428519e8cca4a46371da0a3b33b9c8d3102f1d9056e4c8efba3c0b702048",
14
14
  "moduleDoc:agents/overview-tools.md": "752a4afb6b57a2e9224f3c65a98b0deb23cda87371f01ac0566f0887e9bb12da",
15
15
  "moduleDoc:agents/token-streaming.md": "e92a28a8702d111a3751a86b8cdff30cb5146cc115fed86c227402bef1c683fd",
@@ -1723,96 +1723,110 @@
1723
1723
  "type": "foreignKey",
1724
1724
  "required": true,
1725
1725
  "related": "ChatRoom",
1726
- "columnType": "UUID"
1726
+ "columnType": "UUID",
1727
+ "description": "The room in which this message was sent."
1727
1728
  },
1728
1729
  {
1729
1730
  "name": "threadId",
1730
1731
  "type": "foreignKey",
1731
1732
  "required": false,
1732
1733
  "related": "ChatThread",
1733
- "columnType": "UUID"
1734
+ "columnType": "UUID",
1735
+ "description": "The discussion thread containing this message, when there is one."
1734
1736
  },
1735
1737
  {
1736
1738
  "name": "senderProfileId",
1737
1739
  "type": "crossPackageRef",
1738
1740
  "required": true,
1739
1741
  "related": "@happyvertical/smrt-profiles:Profile",
1740
- "columnType": "UUID"
1742
+ "columnType": "UUID",
1743
+ "description": "The person or assistant who sent this message."
1741
1744
  },
1742
1745
  {
1743
1746
  "name": "agentSessionId",
1744
1747
  "type": "foreignKey",
1745
1748
  "required": false,
1746
1749
  "related": "AgentSession",
1747
- "columnType": "UUID"
1750
+ "columnType": "UUID",
1751
+ "description": "The assistant conversation associated with this message, when applicable."
1748
1752
  },
1749
1753
  {
1750
1754
  "name": "content",
1751
1755
  "type": "text",
1752
1756
  "required": false,
1753
1757
  "columnType": "TEXT",
1754
- "default": ""
1758
+ "default": "",
1759
+ "description": "The text of the message shared with the conversation."
1755
1760
  },
1756
1761
  {
1757
1762
  "name": "messageType",
1758
1763
  "type": "text",
1759
1764
  "required": true,
1760
1765
  "columnType": "TEXT",
1761
- "default": "text"
1766
+ "default": "text",
1767
+ "description": "The kind of message, such as text, a file, or an activity notice."
1762
1768
  },
1763
1769
  {
1764
1770
  "name": "role",
1765
1771
  "type": "text",
1766
1772
  "required": true,
1767
1773
  "columnType": "TEXT",
1768
- "default": "user"
1774
+ "default": "user",
1775
+ "description": "Whether this message comes from a person, an assistant, or an automated action."
1769
1776
  },
1770
1777
  {
1771
1778
  "name": "isEdited",
1772
1779
  "type": "boolean",
1773
1780
  "required": false,
1774
1781
  "columnType": "BOOLEAN",
1775
- "default": false
1782
+ "default": false,
1783
+ "description": "Whether the message has changed since it was sent."
1776
1784
  },
1777
1785
  {
1778
1786
  "name": "editedAt",
1779
1787
  "type": "datetime",
1780
1788
  "required": false,
1781
- "columnType": "TIMESTAMP"
1789
+ "columnType": "TIMESTAMP",
1790
+ "description": "When the message was most recently edited."
1782
1791
  },
1783
1792
  {
1784
1793
  "name": "isDeleted",
1785
1794
  "type": "boolean",
1786
1795
  "required": false,
1787
1796
  "columnType": "BOOLEAN",
1788
- "default": false
1797
+ "default": false,
1798
+ "description": "Whether this message has been removed from the conversation."
1789
1799
  },
1790
1800
  {
1791
1801
  "name": "replyToMessageId",
1792
1802
  "type": "foreignKey",
1793
1803
  "required": false,
1794
1804
  "related": "ChatMessage",
1795
- "columnType": "UUID"
1805
+ "columnType": "UUID",
1806
+ "description": "The earlier message this reply responds to."
1796
1807
  },
1797
1808
  {
1798
1809
  "name": "metadata",
1799
1810
  "type": "text",
1800
1811
  "required": false,
1801
1812
  "columnType": "TEXT",
1802
- "default": "{}"
1813
+ "default": "{}",
1814
+ "description": "Additional message details maintained by the application."
1803
1815
  },
1804
1816
  {
1805
1817
  "name": "toolCallData",
1806
1818
  "type": "text",
1807
1819
  "required": false,
1808
- "columnType": "TEXT"
1820
+ "columnType": "TEXT",
1821
+ "description": "Details of an automated action associated with this message."
1809
1822
  },
1810
1823
  {
1811
1824
  "name": "attachments",
1812
1825
  "type": "text",
1813
1826
  "required": false,
1814
1827
  "columnType": "TEXT",
1815
- "default": "[]"
1828
+ "default": "[]",
1829
+ "description": "Files shared alongside the text of this message."
1816
1830
  }
1817
1831
  ],
1818
1832
  "relationships": [
@@ -2225,75 +2239,86 @@
2225
2239
  "type": "foreignKey",
2226
2240
  "required": true,
2227
2241
  "related": "ChatRoom",
2228
- "columnType": "UUID"
2242
+ "columnType": "UUID",
2243
+ "description": "The room to which this membership belongs."
2229
2244
  },
2230
2245
  {
2231
2246
  "name": "profileId",
2232
2247
  "type": "crossPackageRef",
2233
2248
  "required": true,
2234
2249
  "related": "@happyvertical/smrt-profiles:Profile",
2235
- "columnType": "UUID"
2250
+ "columnType": "UUID",
2251
+ "description": "The person or assistant participating in the room."
2236
2252
  },
2237
2253
  {
2238
2254
  "name": "role",
2239
2255
  "type": "text",
2240
2256
  "required": true,
2241
2257
  "columnType": "TEXT",
2242
- "default": "member"
2258
+ "default": "member",
2259
+ "description": "The member role that determines what they can do in the room."
2243
2260
  },
2244
2261
  {
2245
2262
  "name": "status",
2246
2263
  "type": "text",
2247
2264
  "required": true,
2248
2265
  "columnType": "TEXT",
2249
- "default": "active"
2266
+ "default": "active",
2267
+ "description": "Whether this member is active, has left, was removed, or is banned."
2250
2268
  },
2251
2269
  {
2252
2270
  "name": "onlineStatus",
2253
2271
  "type": "text",
2254
2272
  "required": true,
2255
2273
  "columnType": "TEXT",
2256
- "default": "offline"
2274
+ "default": "offline",
2275
+ "description": "The availability shown for this member, such as online or away."
2257
2276
  },
2258
2277
  {
2259
2278
  "name": "lastReadMessageId",
2260
2279
  "type": "foreignKey",
2261
2280
  "required": false,
2262
2281
  "related": "ChatMessage",
2263
- "columnType": "UUID"
2282
+ "columnType": "UUID",
2283
+ "description": "The most recent message this member has read."
2264
2284
  },
2265
2285
  {
2266
2286
  "name": "lastSeenAt",
2267
2287
  "type": "datetime",
2268
2288
  "required": false,
2269
- "columnType": "TIMESTAMP"
2289
+ "columnType": "TIMESTAMP",
2290
+ "description": "When this member was last seen in the conversation."
2270
2291
  },
2271
2292
  {
2272
2293
  "name": "joinedAt",
2273
2294
  "type": "datetime",
2274
2295
  "required": false,
2275
- "columnType": "TIMESTAMP"
2296
+ "columnType": "TIMESTAMP",
2297
+ "description": "When this member joined the room."
2276
2298
  },
2277
2299
  {
2278
2300
  "name": "nickname",
2279
2301
  "type": "text",
2280
2302
  "required": false,
2281
2303
  "columnType": "TEXT",
2282
- "default": ""
2304
+ "default": "",
2305
+ "description": "The display name this member uses within the room."
2283
2306
  },
2284
2307
  {
2285
2308
  "name": "isMuted",
2286
2309
  "type": "boolean",
2287
2310
  "required": false,
2288
2311
  "columnType": "BOOLEAN",
2289
- "default": false
2312
+ "default": false,
2313
+ "description": "Whether notifications for this room are muted for this member."
2290
2314
  },
2291
2315
  {
2292
2316
  "name": "isPinned",
2293
2317
  "type": "boolean",
2294
2318
  "required": false,
2295
2319
  "columnType": "BOOLEAN",
2296
- "default": false
2320
+ "default": false,
2321
+ "description": "Whether this room is pinned for this member."
2297
2322
  }
2298
2323
  ],
2299
2324
  "relationships": [
@@ -2540,21 +2565,24 @@
2540
2565
  "type": "foreignKey",
2541
2566
  "required": true,
2542
2567
  "related": "ChatMessage",
2543
- "columnType": "UUID"
2568
+ "columnType": "UUID",
2569
+ "description": "The message to which this reaction was added."
2544
2570
  },
2545
2571
  {
2546
2572
  "name": "profileId",
2547
2573
  "type": "crossPackageRef",
2548
2574
  "required": true,
2549
2575
  "related": "@happyvertical/smrt-profiles:Profile",
2550
- "columnType": "UUID"
2576
+ "columnType": "UUID",
2577
+ "description": "The person who added this reaction."
2551
2578
  },
2552
2579
  {
2553
2580
  "name": "emoji",
2554
2581
  "type": "text",
2555
2582
  "required": true,
2556
2583
  "columnType": "TEXT",
2557
- "default": ""
2584
+ "default": "",
2585
+ "description": "The symbol chosen to express a response to the message."
2558
2586
  }
2559
2587
  ],
2560
2588
  "relationships": [
@@ -2629,76 +2657,87 @@
2629
2657
  "type": "text",
2630
2658
  "required": false,
2631
2659
  "columnType": "TEXT",
2632
- "default": ""
2660
+ "default": "",
2661
+ "description": "A recognizable name that helps teammates find this room."
2633
2662
  },
2634
2663
  {
2635
2664
  "name": "description",
2636
2665
  "type": "text",
2637
2666
  "required": false,
2638
2667
  "columnType": "TEXT",
2639
- "default": ""
2668
+ "default": "",
2669
+ "description": "Explain the purpose of the room and who should join."
2640
2670
  },
2641
2671
  {
2642
2672
  "name": "roomType",
2643
2673
  "type": "text",
2644
2674
  "required": true,
2645
2675
  "columnType": "TEXT",
2646
- "default": "public"
2676
+ "default": "public",
2677
+ "description": "Whether the room is public, private, a direct message, or an assistant conversation."
2647
2678
  },
2648
2679
  {
2649
2680
  "name": "status",
2650
2681
  "type": "text",
2651
2682
  "required": true,
2652
2683
  "columnType": "TEXT",
2653
- "default": "active"
2684
+ "default": "active",
2685
+ "description": "Whether this room is active, archived, or deleted."
2654
2686
  },
2655
2687
  {
2656
2688
  "name": "topic",
2657
2689
  "type": "text",
2658
2690
  "required": false,
2659
2691
  "columnType": "TEXT",
2660
- "default": ""
2692
+ "default": "",
2693
+ "description": "The current subject or focus of the room."
2661
2694
  },
2662
2695
  {
2663
2696
  "name": "avatarUrl",
2664
2697
  "type": "text",
2665
2698
  "required": false,
2666
2699
  "columnType": "TEXT",
2667
- "default": ""
2700
+ "default": "",
2701
+ "description": "The address of the image used to represent this room."
2668
2702
  },
2669
2703
  {
2670
2704
  "name": "isArchived",
2671
2705
  "type": "boolean",
2672
2706
  "required": false,
2673
2707
  "columnType": "BOOLEAN",
2674
- "default": false
2708
+ "default": false,
2709
+ "description": "Whether the room has been archived from active conversations."
2675
2710
  },
2676
2711
  {
2677
2712
  "name": "maxParticipants",
2678
2713
  "type": "integer",
2679
2714
  "required": false,
2680
2715
  "columnType": "INTEGER",
2681
- "default": 0
2716
+ "default": 0,
2717
+ "description": "The maximum number of members; zero means no configured limit."
2682
2718
  },
2683
2719
  {
2684
2720
  "name": "metadata",
2685
2721
  "type": "text",
2686
2722
  "required": false,
2687
2723
  "columnType": "TEXT",
2688
- "default": "{}"
2724
+ "default": "{}",
2725
+ "description": "Additional room details maintained by the application."
2689
2726
  },
2690
2727
  {
2691
2728
  "name": "createdByProfileId",
2692
2729
  "type": "crossPackageRef",
2693
2730
  "required": false,
2694
2731
  "related": "@happyvertical/smrt-profiles:Profile",
2695
- "columnType": "UUID"
2732
+ "columnType": "UUID",
2733
+ "description": "The person who originally created this room."
2696
2734
  },
2697
2735
  {
2698
2736
  "name": "lastMessageAt",
2699
2737
  "type": "datetime",
2700
2738
  "required": false,
2701
- "columnType": "TIMESTAMP"
2739
+ "columnType": "TIMESTAMP",
2740
+ "description": "When the most recent message was sent in this room."
2702
2741
  }
2703
2742
  ],
2704
2743
  "relationships": [
@@ -2984,48 +3023,55 @@
2984
3023
  "type": "foreignKey",
2985
3024
  "required": true,
2986
3025
  "related": "ChatRoom",
2987
- "columnType": "UUID"
3026
+ "columnType": "UUID",
3027
+ "description": "The room containing this discussion thread."
2988
3028
  },
2989
3029
  {
2990
3030
  "name": "rootMessageId",
2991
3031
  "type": "foreignKey",
2992
3032
  "required": false,
2993
3033
  "related": "ChatMessage",
2994
- "columnType": "UUID"
3034
+ "columnType": "UUID",
3035
+ "description": "The message that started this thread, when it began as a reply."
2995
3036
  },
2996
3037
  {
2997
3038
  "name": "title",
2998
3039
  "type": "text",
2999
3040
  "required": false,
3000
3041
  "columnType": "TEXT",
3001
- "default": ""
3042
+ "default": "",
3043
+ "description": "A short title describing the subject of this thread."
3002
3044
  },
3003
3045
  {
3004
3046
  "name": "isResolved",
3005
3047
  "type": "boolean",
3006
3048
  "required": false,
3007
3049
  "columnType": "BOOLEAN",
3008
- "default": false
3050
+ "default": false,
3051
+ "description": "Whether the discussion has reached a resolution."
3009
3052
  },
3010
3053
  {
3011
3054
  "name": "messageCount",
3012
3055
  "type": "integer",
3013
3056
  "required": false,
3014
3057
  "columnType": "INTEGER",
3015
- "default": 0
3058
+ "default": 0,
3059
+ "description": "The number of messages in this thread."
3016
3060
  },
3017
3061
  {
3018
3062
  "name": "lastMessageAt",
3019
3063
  "type": "datetime",
3020
3064
  "required": false,
3021
- "columnType": "TIMESTAMP"
3065
+ "columnType": "TIMESTAMP",
3066
+ "description": "When the most recent message was sent in this thread."
3022
3067
  },
3023
3068
  {
3024
3069
  "name": "participantCount",
3025
3070
  "type": "integer",
3026
3071
  "required": false,
3027
3072
  "columnType": "INTEGER",
3028
- "default": 0
3073
+ "default": 0,
3074
+ "description": "The number of people participating in this thread."
3029
3075
  }
3030
3076
  ],
3031
3077
  "relationships": [
@@ -5152,7 +5198,7 @@
5152
5198
  "polymorphicAssociations": 0,
5153
5199
  "uuidColumns": 78
5154
5200
  },
5155
- "agentDoc": "# @happyvertical/smrt-chat\n\nChat rooms, threads, and agent sessions with app-controlled tool whitelisting.\n\n## Dev Server\n\n`pnpm --dir packages/chat dev` runs a package-local SvelteKit workbench. The root\nroute separates **Character setup** and **Chat dev** into tabs; switching tabs\nretains the current character rig and chat state. The chat tab is an interactive\nsurface with a dev-only `/api/dev-chat` endpoint:\nit uses `@happyvertical/ai` when local provider credentials are present and\nfalls back to a deterministic local assistant otherwise. `/api/dev-chat-stream`\nis its SSE companion (#1936) — the same provider/local-fallback resolution wired\nthrough `createChatStreamHandler` in plain mode, so an embedded `SmrtChatBackend`\nclient can exercise token streaming locally. `/previews` hosts the shared\ncomponent playground entries from `src/svelte/playground.ts`.\n\nThe root route also drives the **browser inference path**\n(`@happyvertical/smrt-web/ai`: bitgpu, WebLLM, and route backends composed by\n`createInferencePath`) and an **agent-addressable draft form** (`useViewIntent`\nover `src/routes/chat-dev.intents.ts`, #2588). Their dev-config dependencies\n(static optional-peer imports, `workspace-aliases.js` prefix rules, the theme\nstylesheet import) and the stage→apply tool-call gotchas are in\n[agents/dev-workbench.md](agents/dev-workbench.md).\n\nDev-only voice and opt-in local character persistence are documented in\n[agents/dev-workbench.md](agents/dev-workbench.md#voice-and-local-character-persistence).\nThese endpoints are loopback-only demos; production hosts supply authenticated\nprincipals, tenancy, and authorization to the owning services.\n\n## Models\n\nAll seven models expose only generated REST/MCP `list`/`get`; structural tests\ncheck the registry for accidental mutating operations. Writes go through\nmembership/owner-checked `ChatService` or the binding-checked voice adapter.\n\n`ChatService` is a closed facade. Its collections and security-sensitive\nhelpers use ES `#private` fields/methods (TypeScript `private` is erased).\nCollection classes are not exported from the package index. The agent-reply\nbridge is a Symbol-keyed static accessible through a non-exported symbol held\nby the module-local `sendAgentReply` function.\n\n- **ChatRoom**: `roomType` (public/private/dm/agent), `status`, `topic`, `maxParticipants`, `lastMessageAt`. Tenant-scoped (required).\n- **ChatMessage**: shared by users + agents. `role` (user/assistant/system/tool), `messageType` (text/system/action/file/tool_call/tool_result), `toolCallData` JSON. Unified model — no separate agent message type. Tenant-scoped (required).\n- **ChatParticipant**: `role` (owner/admin/member/viewer), `onlineStatus`, `lastReadMessageId`, `isMuted`. Tenant-scoped (required).\n- **ChatThread**: `rootMessageId`, `isResolved`, `messageCount`. Created via `ChatService.startThread()` (member-checked). Tenant-scoped (required).\n- **ChatReaction**: `messageId`, `profileId`, `emoji`. Added/removed via `ChatService.addReaction()`/`removeReaction()` (member-checked, self-keyed). Tenant-scoped (required).\n- **AgentSession**: `agentId` (application slug, string ref, not FK), `agentProfileId` (nullable `crossPackageRef` to `Profile` — the `bot` profile the agent AUTHORS as, #2995), `allowedTools` (JSON string array), `sessionContext` (JSON, sensitive), `systemPrompt`, limits (`maxTokens`/`maxMessages`/`expiresAt`). Optional tenancy.\n- **VoiceSession**: short-lived voice-gateway binding over `(tenant, actorProfileId, personaId, room/thread/agentSession)` plus a persona snapshot, gateway `session_id`, expiry, replay tracking, and metadata. Tenant-scoped (required). Generated surface is read-only; creation and turns go through `createVoiceChatSession()` / `handleVoiceGatewayTurn()`.\n\n## ChatService\n\nPublic writes take server-supplied `actorProfileId`; never trust a request's\n`senderProfileId`, `role`, owner id, or participant id as authority.\n\n| Operation | Authorization and behavior |\n|---|---|\n| `sendMessage()` | Room member; authors as actor with `role: 'user'`; no public membership bypass |\n| `createRoom()` | Actor becomes owner |\n| `startThread()` | Member; optional root message bound to room and tenant |\n| `addParticipant()` / `removeParticipant()` | Owner/admin; self-leave allowed |\n| `updateRoom()` | Owner/admin |\n| `addReaction()` / `removeReaction()` | Member; self-keyed |\n| `getOrCreateDM()` | Actor must be a DM participant |\n| `createAgentSession()` | Actor becomes session participant; existing-session lookup tenant-bound; optional `sessionKey` isolates conversation subjects |\n| `updateAgentSessionConfig()` | Owner; mandatory tenant-bound lookup |\n| `sendAgentUserMessage()` | Actor must be session participant; authored as participant |\n\nRead facade: `getAgentSession({agentSessionId, tenantId})`,\n`findActiveAgentSessions({tenantId, agentId, participantProfileId})`, and\n`getThread({threadId, tenantId})` are tenant-bound; consumers still check\nownership/context. `listRoomThreads({roomId, actorProfileId, tenantId})`,\n`getThreadMessages({threadId, actorProfileId, tenantId, limit?})` (chronological),\n`getRoomMessages({roomId, actorProfileId, tenantId})`, and\n`getRoomForMember(roomId, actorProfileId, tenantId)` additionally require\nmembership of the server-supplied actor, not a caller-selected subject.\n\nTrusted agent runtime imports `sendAgentReply(service, params)` only from\n`@happyvertical/smrt-chat/internal/agent-runtime`. It is not a `ChatService`\nmethod or package-index export. It authors as `session.agentProfileId` — the\nagent's resolved `bot` Profile, NEVER the `agentId` slug (#2995) — accepts a\nsame-room/tenant thread, and enforces `allowedTools` fail-closed.\n\nPrivate `#writeMessage` alone can select arbitrary authors/roles or bypass\nmembership. It binds every supplied `threadId`, `agentSessionId`, and\n`replyToMessageId` to the same room **and** tenant. Internal enrollment supports\nautomatically created rooms, sessions, and participants.\n\n## Agent Tool Whitelisting\n\n`allowedTools` is a JSON array controlled by the consuming app. Fail-closed: an empty/unparseable whitelist permits NO tools. The internal `sendAgentReply(service, params)` function enforces the whitelist before emitting any `tool`/`tool_call` message; a caller cannot supply a `senderProfileId`/`role` to post as the agent, and the function is not reachable from the package index.\n\nPrincipal-bound data discovery and reads are available as `PrincipalTool[]` via\n`createDataSurfaceTools()` (re-exported by the chat package). Pass the tools as\n`extraTools` to the persona conversation/tool loop. They use the same\nfail-closed `allowedTools` offer/execution gates; RBAC, tenant, redaction,\nbounded query results, and audit authority remain in the authenticated\n`PrincipalRun` supplied by `@happyvertical/smrt-agents`.\n\n## Runtime report tools (#3711)\n\n`createRuntimeReportTools()` (`runtime-report-tools.ts`) returns four\n`PrincipalTool`s for the persona `extraTools` seam: `reports.runtime.sources`,\n`reports.runtime.define` (`preview` / `apply`), `reports.runtime.list`, and\n`reports.runtime.run`. The model supplies only a declarative\n`RuntimeReportSpec` from `@happyvertical/smrt-reports`; sources are a\nserver-owned allow-list gated by `run.assertOperation(collection, 'read')`, and\nfield policy, `readPermission`, and tenancy are enforced by the compiler against\nthe live run on every compile and every stored-report run. `apply` needs the\n`specHash` of the previewed spec plus an app-owned `confirmation` host that\nresolves only after a human approved that exact spec; with no host the tools are\npropose-only and a model-supplied `confirmed` flag is ignored. Saving also needs\n`create` on the `RuntimeReport` collection, and `list`/`run` need `read`; the\ndefault `reportsCollection` is the `RuntimeReport` class itself, which resolves\nto the catalog slug. A string `collection`/`reportsCollection` is used verbatim\nas a catalog slug (never a class name); tests must run the tools under the real\n`assertOperationPermission` guard so a wrong slug fails. A stored report is\nwritten only by this confirmed `apply`: the model layer refuses every other\ninsert/spec change, and the generic manifest tools (`buildManifestToolCatalog`,\n`invokeManifestTool`) never offer or run a `RuntimeReport` write. Stored-report\nreads/saves run under the principal's tenant (`withPrincipalTenant`), refuse a\nmismatching ambient tenant, and filter by the principal tenant explicitly. Contract and authority model:\n[`packages/reports/agents/runtime-reports.md`](../reports/agents/runtime-reports.md).\n\n## Overview tools (#3727)\n\n`createOverviewTools({ host })` (`overview-tools.ts`) returns\n`overviews.describe`, `overviews.apply` (an atomic batch of structured widget\noperations) and `overviews.undo` (single step, keyed by tenant, user and page).\nThe host opens a page per `PrincipalRun` (smrt-svelte's\n`createOverviewAssistantSurface`, typed structurally here); validation and\n`canCustomize` are the surface's. Contract:\n[agents/overview-tools.md](agents/overview-tools.md).\n\n## Conversational Harness (L3, #1891)\n\nThe `AgentSession` runtime depends on personas, agents, and users. Keep those\nedges acyclic: none of those packages may depend back on chat.\n\n- **`runToolLoop(options)`** (`tool-loop.ts`) — a bounded `tool_call → observe → respond` loop. Tools are **manifest operations** of installed packages: `buildManifestToolCatalog({ allowedTools })` reads the `PermissionCatalogService` catalog and keeps only the `(collection, action)` entries named in the persona's allow-list (the **offer gate**; absent/empty ⇒ NO tools). The loop runs inside one `executeAsPrincipal` context, and `invokeManifestTool` executes each op **in-process (\"side door\")** against `run.context.database` (the RLS tx when Postgres RLS is on), after re-asserting the fail-closed allow-list (`run.assertToolAllowed`) AND the catalog permission (`run.assertOperation`) — the **execution gate**. Bounded by a max-steps ceiling (`DEFAULT_MAX_STEPS = 8`): on the ceiling it disables tools for one final completion so the turn always terminates with text. A thrown tool error is classified (`classifyToolError`): permission denials and 401/403 are `not_permitted`; a `ValidationError` or 400/404/409/422 is `invalid_request` (observation: actionable message, `publicMessage` first, `code`, fix-and-retry hint); else (incl. 429) `execution_error`.\n- **`runPersonaConversationTurn(options)`** (`persona-conversation.ts`) — binds a conversation to an `AgentPersona`/`ResolvedPersona`: runs as its principal (`runAsUserId`), offers only its `allowedTools`, speaks its instructions (`resolvePersonaInstructions`, layering approved learned directives), and injects its **recalled learning memory** (`personaLearningMemory`, isolated per `memoryScope`) into the system prompt. `bindPersonaToSession()` mirrors the persona's `allowedTools`/instructions onto the `AgentSession` so the chat authoring gate agrees with the loop's offer gate. Authors the reply (and each executed tool) via the internal `sendAgentReply` bridge. Tools run with the run-as user's **live** permissions unless the host passes a `permissions` snapshot (#2978), which `runToolLoop` forwards to `executeAsPrincipal` so a narrowed principal (e.g. a shared station) cannot reach grants — such as `readPermission`-gated data-surface fields — beyond that snapshot, including through `agents.invoke` delegation (the snapshot becomes the worker's permission ceiling).\n- **Agent orchestration** (L3, #1892) — the loop accepts non-manifest **`extraTools`** (`PrincipalTool[]`, from `@happyvertical/smrt-agents`), gated by the *same* fail-closed allow-list. The standard **`invoke-agent`** tool (`createInvokeAgentTool`, slug `agents.invoke`) lets a conversational agent delegate to a **worker agent under its own principal** — the worker runs via `executeAsPrincipal` as the originating user (never its own authority), the principal is immutable along the chain, and its completion is surfaced back into the conversation. `runPersonaConversationTurn` filters `extraTools` by the persona's `allowedTools` (offer gate); the tool's `execute` re-asserts `assertToolAllowed` (execution gate). See `@happyvertical/smrt-agents` for the delegation envelope + transports.\n- **Chat feedback capture** (`chat-feedback.ts`) — `captureChatFeedback()` + `acceptAppliedChange`/`rejectAppliedChange`/`correctResponse`/`rateResponse`/`thumbsUp`/`thumbsDown` write a `Feedback` row (personas) carrying the conversation's **correlation-id**, and (by default) reinforce the persona's learning memory (`reinforceFromFeedback`). So an in-chat reject decays a strategy below the reuse floor and it stops being recalled; a correction supersedes its stored value.\n\n## Voice Gateway Turns (#1910)\n\nVoice is an input mode for the existing persona chat harness, not a separate chat runtime. `createVoiceChatSession()` creates a short-lived `VoiceSession` for an authenticated actor/profile, binding tenant, persona, agent session, room, and optional thread. `handleVoiceGatewayTurn()` resolves that binding from `metadata.voiceSessionId`, checks the gateway's `session_id` and any supplied tenant/profile/persona/session/thread metadata against the server-side binding, persists the transcript through `ChatService.sendAgentUserMessage()`, runs `runPersonaConversationTurn()`, stamps voice/correlation metadata onto the persisted user/assistant/tool messages, records the gateway `turn_id`, and returns the gateway response contract. The Fetch-compatible `createVoiceGatewayTurnHandler()` adds the coarse gateway bearer-token check.\n\nThe gateway bearer token proves only \"this request came from the gateway\"; it never authorizes the end user. The short-lived `VoiceSession` binding is the user/session proof, and untrusted gateway metadata must be validated against that binding before any chat write or tool loop. Tool execution remains fail-closed through the persona allow-list mirrored onto `AgentSession` by `bindPersonaToSession()`.\n\n## Token streaming\n\n`chat-stream.ts` serves browser-safe SSE through `./client`. The host's\n`authorize(request, body)` is the sole trust boundary: resolve identity, tenant,\ncapabilities, and generation limits server-side; never authorize from request\nsession metadata. Persona turns reuse the principal and fail-closed tool gates.\nThe engine and browser-safe `SmrtChatBackend` keep custom-tool resolution\nserver-side. See [agents/token-streaming.md](agents/token-streaming.md) for the\nevent contract, CORS policy, and streaming behavior.\n\n## Streamed assistant turns with browser tools (#2908)\n\n`runAssistantTurn()` (`assistant-turn.ts`) is the host-route engine for the\nAssistantDock: one turn over `runToolLoop` with server tools (offer-gated by\n`principal.allowedTools`), browser tools (validated by\n`sanitizeClientToolDeclarations` + a server allow-list), `maxSteps`, `signal`,\nand `onUsage`. A browser tool call SUSPENDS the turn (`stoppedReason:\n'client_tools'`); the transcript waits in an `AssistantContinuationStore`\n(single-use, 15-minute TTL; `createSessionContinuationStore` keeps it in the\nAgentSession's `sessionContext`) and the browser resumes it with results,\nwhich reach the model wrapped `untrusted`. `createAssistantTurnResponse`\nserves the events as SSE; `assistant-turn-events.ts` is the browser-safe wire\ncontract (`./assistant-turn` subpath, also a vite library entry because the\nSvelte dock imports it). The dock decides whether a browser call runs or waits\nfor the user from the page registry, never the server's echo; destructive\nalways waits. Details: [`docs/assistant-dock.md`](../../docs/assistant-dock.md).\n\n## AssistantDock (#2904)\n\n`AssistantDock` (`svelte/components/assistant/`, exported from `./svelte`)\nuses a host `DataSurfaceRegistry`'s mounted descriptors (none: plain chat).\nActions: `normalizeDataSurfaceActionRequest` → `AssistantActionClient`\n(preview/apply over a server `DataSurfaceActionAdapter`, not the\n`data-surface-bridge.ts` channel). Generated `ChatThread`/`ChatMessage`\n`list`/`get` are tenant- not member-scoped; the dock never calls them.\n\n**Server routes (#3368).** `./sveltekit` `mountAssistantRoutes()` serves the\ndock from one `[...path]` route; `createAssistantHttp{Transport,ActionClient}`\n(`./svelte`) are its browser half. Principal from `event.locals` or\n`resolvePrincipal`; threads only from the actor's keyed session room in that\ntenant (else 404). `allowedTools` is fail-closed; without `tools` it offers\nthose manifest ops (an unprovided name is an error, #3414). `ai` defaults to\nthe config `ai` block. Pass `runtime`: a turn never keeps the RLS request tx\n(own `runAsPrincipal` tx). `clientRequestId` makes the row id a UUIDv5\n(`clientRequestMessageId`): the PK is the retry reservation. See\n[docs](../../docs/assistant-dock.md).\n\n## Recipes (#3719)\n\n`src/recipes.ts` declares `chat.assistant` (the AssistantDock as a `header.end`\nshell widget, an `llm` provider, runtime `both`; help in\n`src/assistant.recipe.md`). Keep each recipe class self-contained. Details:\n[agents/recipes.md](agents/recipes.md).\n\n## Gotchas\n\n- **sessionContext, not context**: `context` is reserved for slug scoping. Use `getSessionContext()`/`updateSessionContext()` for agent memory.\n- **Agent rooms auto-created**: `roomType: 'agent'`, `maxParticipants` defaults to 2; the agent is enrolled as a member so its replies pass the membership check. `createAgentSession()` re-enrolls the participant AND the agent on the existing-session path, so legacy sessions created before the agent was enrolled self-heal.\n- **The agent's author is a Profile, not `agentId` (#2995)**: `senderProfileId`/`profileId` are `crossPackageRef`s to `Profile` and therefore uuid columns on PostgreSQL, so the slug fails the cast (`22P02`) and no agent reply can persist. `createAgentSession()` resolves the agent's `bot` Profile through `resolveAgentProfile()` in `@happyvertical/smrt-profiles` (created on first use, tenant-bound) and records it as `AgentSession.agentProfileId`; both enrolment and `sendAgentReply` use that uuid. A consumer whose agent already has an acting identity passes `agentProfileId` to skip resolution — the voice path does this with the persona's `actsAsProfileId`, so persona replies stay attributed to the configured acting profile. Like `actorProfileId`, `agentProfileId` is SERVER-supplied, never request input: it selects the author of every later assistant message. It is validated (`#requireAgentProfile`) to exist and be visible in the tenant and to differ from the acting participant, and it can never re-point a session that already resolved one — only a pre-#2995 session with none is backfilled. **Migration**: `agent_profile_id` is an ordinary additive manifest field, so a consumer adopts it the same way as any other — `smrt db:migrate` generates and applies the column from the manifest, and `db:status --parity` verifies it; the framework never creates application schema at runtime. It is nullable and needs no offline DATA backfill — a session created before #2995 resolves and persists its `agentProfileId`, and re-enrols the agent, on its first agent turn after the upgrade. Two pre-#2995 cohorts: a session whose `agentId` is an ordinary slug never committed an author on PostgreSQL (the cast failed), so there is nothing to repair there, while SQLite/DuckDB may hold `chat_messages`/`chat_participants` rows whose id column holds the slug — readable, but not joinable to `profiles`. A session whose `agentId` is already a Profile uuid (the voice path used to collapse a persona's `actsAsProfileId` into it) DID commit on PostgreSQL; that profile is ADOPTED as the author, tenant-checked and never the participant, so the conversation's author does not change and the two-seat room gains no third identity. Known one-time effect: because `voice.ts` no longer derives `agentId` from `actsAsProfileId`, a keyless `createVoiceChatSession()` for such a persona opens a new session and room instead of reusing the legacy one; the old room and its messages stay intact and readable, and resuming with an explicit `agentSessionId` is unaffected.\n- **Only SQLite-backed tests would miss this class of bug**: uuid columns accept any text on SQLite. `src/__tests__/agent-reply-postgres.test.ts` (`pnpm --filter @happyvertical/smrt-chat test:postgres`) pins the agent-reply path against real PostgreSQL. That suite runs in `postgres-tests.yml` on every same-repository PR and merge group (#2659); keep a cheap SQLite assertion alongside it anyway, since the default lane is SQLite-only (`chat-service.test.ts` asserts the author is the profile uuid, not the slug).\n- **Per-subject sessions need `sessionKey`**: `createAgentSession()` reuses ANY active session for the same `(agentId, participantProfileId, tenantId)`. Callers that open separate conversations per subject (e.g. one content-editor session per content id) MUST pass a stable `sessionKey` (stored in `sessionContext.__sessionKey`, read via `AgentSession.getSessionKey()`); otherwise a session opened for one subject is reused and its context overwritten for another, surfacing the wrong room/threads (S5 #1392). A keyed create never reuses a keyless/legacy session.\n- **Session expiry**: check `isActive()` before allowing messages (expiresAt or limit-based)\n- **DM identity**: derived from the deterministic per-tenant `canonicalDmRoomId()` and the authoritative `chat_participants` join, not client metadata; concurrent creates upsert onto one row.\n- **Tenant-bound lookups**: membership/session/DM lookups REQUIRE `tenantId` and always bind it into the WHERE clause (`findActiveMembership`/`isActiveMember`/`findActiveSession` take a required `tenantId`; AgentSession's `null` tenant is an explicit bound scope, not \"any tenant\") so they can never resolve a row from another tenant.\n",
5201
+ "agentDoc": "# @happyvertical/smrt-chat\n\nChat rooms, threads, and agent sessions with app-controlled tool whitelisting.\n\n## Dev Server\n\n`pnpm --dir packages/chat dev` runs a package-local SvelteKit workbench. The root\nroute separates **Character setup** and **Chat dev** into tabs; switching tabs\nretains the current character rig and chat state. The chat tab is an interactive\nsurface with a dev-only `/api/dev-chat` endpoint:\nit uses `@happyvertical/ai` when local provider credentials are present and\nfalls back to a deterministic local assistant otherwise. `/api/dev-chat-stream`\nis its SSE companion (#1936) — the same provider/local-fallback resolution wired\nthrough `createChatStreamHandler` in plain mode, so an embedded `SmrtChatBackend`\nclient can exercise token streaming locally. `/previews` hosts the shared\ncomponent playground entries from `src/svelte/playground.ts`.\n\nThe root route also drives the **browser inference path**\n(`@happyvertical/smrt-web/ai`: bitgpu, WebLLM, and route backends composed by\n`createInferencePath`) and an **agent-addressable draft form** (`useViewIntent`\nover `src/routes/chat-dev.intents.ts`, #2588). Their dev-config dependencies\n(static optional-peer imports, `workspace-aliases.js` prefix rules, the theme\nstylesheet import) and the stage→apply tool-call gotchas are in\n[agents/dev-workbench.md](agents/dev-workbench.md).\n\nDev-only voice and opt-in local character persistence are documented in\n[agents/dev-workbench.md](agents/dev-workbench.md#voice-and-local-character-persistence).\nThese endpoints are loopback-only demos; production hosts supply authenticated\nprincipals, tenancy, and authorization to the owning services.\n\n## Models\n\nAll seven models expose only generated REST/MCP `list`/`get`; structural tests\ncheck the registry for accidental mutating operations. Writes go through\nmembership/owner-checked `ChatService` or the binding-checked voice adapter.\n\n`ChatService` is a closed facade. Its collections and security-sensitive\nhelpers use ES `#private` fields/methods (TypeScript `private` is erased).\nCollection classes are not exported from the package index. The agent-reply\nbridge is a Symbol-keyed static accessible through a non-exported symbol held\nby the module-local `sendAgentReply` function.\n\n- **ChatRoom**: `roomType` (public/private/dm/agent), `status`, `topic`, `maxParticipants`, `lastMessageAt`. Tenant-scoped (required).\n- **ChatMessage**: shared by users + agents. `role` (user/assistant/system/tool), `messageType` (text/system/action/file/tool_call/tool_result), `toolCallData` JSON. Unified model — no separate agent message type. Tenant-scoped (required).\n- **ChatParticipant**: `role` (owner/admin/member/viewer), `onlineStatus`, `lastReadMessageId`, `isMuted`. Tenant-scoped (required).\n- **ChatThread**: `rootMessageId`, `isResolved`, `messageCount`. Created via `ChatService.startThread()` (member-checked). Tenant-scoped (required).\n- **ChatReaction**: `messageId`, `profileId`, `emoji`. Added/removed via `ChatService.addReaction()`/`removeReaction()` (member-checked, self-keyed). Tenant-scoped (required).\n- **AgentSession**: `agentId` (application slug, string ref, not FK), `agentProfileId` (nullable `crossPackageRef` to `Profile` — the `bot` profile the agent AUTHORS as, #2995), `allowedTools` (JSON string array), `sessionContext` (JSON, sensitive), `systemPrompt`, limits (`maxTokens`/`maxMessages`/`expiresAt`). Optional tenancy.\n- **VoiceSession**: short-lived voice-gateway binding over `(tenant, actorProfileId, personaId, room/thread/agentSession)` plus a persona snapshot, gateway `session_id`, expiry, replay tracking, and metadata. Tenant-scoped (required). Generated surface is read-only; creation and turns go through `createVoiceChatSession()` / `handleVoiceGatewayTurn()`.\n\n## ChatService\n\nPublic writes take server-supplied `actorProfileId`; never trust a request's\n`senderProfileId`, `role`, owner id, or participant id as authority.\n\n| Operation | Authorization and behavior |\n|---|---|\n| `sendMessage()` | Room member; authors as actor with `role: 'user'`; no public membership bypass |\n| `createRoom()` | Actor becomes owner |\n| `startThread()` | Member; optional root message bound to room and tenant |\n| `addParticipant()` / `removeParticipant()` | Owner/admin; self-leave allowed |\n| `updateRoom()` | Owner/admin |\n| `addReaction()` / `removeReaction()` | Member; self-keyed |\n| `getOrCreateDM()` | Actor must be a DM participant |\n| `createAgentSession()` | Actor becomes session participant; existing-session lookup tenant-bound; optional `sessionKey` isolates conversation subjects |\n| `updateAgentSessionConfig()` | Owner; mandatory tenant-bound lookup |\n| `sendAgentUserMessage()` | Actor must be session participant; authored as participant |\n\nRead facade: `getAgentSession({agentSessionId, tenantId})`,\n`findActiveAgentSessions({tenantId, agentId, participantProfileId})`, and\n`getThread({threadId, tenantId})` are tenant-bound; consumers still check\nownership/context. `listRoomThreads({roomId, actorProfileId, tenantId})`,\n`getThreadMessages({threadId, actorProfileId, tenantId, limit?})` (chronological),\n`getRoomMessages({roomId, actorProfileId, tenantId})`, and\n`getRoomForMember(roomId, actorProfileId, tenantId)` additionally require\nmembership of the server-supplied actor, not a caller-selected subject.\n\nTrusted agent runtime imports `sendAgentReply(service, params)` only from\n`@happyvertical/smrt-chat/internal/agent-runtime`. It is not a `ChatService`\nmethod or package-index export. It authors as `session.agentProfileId` — the\nagent's resolved `bot` Profile, NEVER the `agentId` slug (#2995) — accepts a\nsame-room/tenant thread, and enforces `allowedTools` fail-closed.\n\nPrivate `#writeMessage` alone can select arbitrary authors/roles or bypass\nmembership. It binds every supplied `threadId`, `agentSessionId`, and\n`replyToMessageId` to the same room **and** tenant. Internal enrollment supports\nautomatically created rooms, sessions, and participants.\n\n## Agent Tool Whitelisting\n\n`allowedTools` is a JSON array controlled by the consuming app. Fail-closed: an empty/unparseable whitelist permits NO tools. The internal `sendAgentReply(service, params)` function enforces the whitelist before emitting any `tool`/`tool_call` message; a caller cannot supply a `senderProfileId`/`role` to post as the agent, and the function is not reachable from the package index.\n\nPrincipal-bound data discovery and reads are available as `PrincipalTool[]` via\n`createDataSurfaceTools()` (re-exported by the chat package). Pass the tools as\n`extraTools` to the persona conversation/tool loop. They use the same\nfail-closed `allowedTools` offer/execution gates; RBAC, tenant, redaction,\nbounded query results, and audit authority remain in the authenticated\n`PrincipalRun` supplied by `@happyvertical/smrt-agents`.\n\n## Runtime report tools (#3711)\n\n`createRuntimeReportTools()` (`runtime-report-tools.ts`) returns four\n`PrincipalTool`s for the persona `extraTools` seam: `reports.runtime.sources`,\n`reports.runtime.define` (`preview` / `apply`), `reports.runtime.list`, and\n`reports.runtime.run`. The model supplies only a declarative\n`RuntimeReportSpec` from `@happyvertical/smrt-reports`; sources are a\nserver-owned allow-list gated by `run.assertOperation(collection, 'read')`, and\nfield policy, `readPermission`, and tenancy are enforced by the compiler against\nthe live run on every compile and every stored-report run. `apply` needs the\n`specHash` of the previewed spec plus an app-owned `confirmation` host that\nresolves only after a human approved that exact spec; with no host the tools are\npropose-only and a model-supplied `confirmed` flag is ignored. Saving also needs\n`create` on the `RuntimeReport` collection, and `list`/`run` need `read`; the\ndefault `reportsCollection` is the `RuntimeReport` class itself, which resolves\nto the catalog slug. A string `collection`/`reportsCollection` is used verbatim\nas a catalog slug (never a class name); tests must run the tools under the real\n`assertOperationPermission` guard so a wrong slug fails. A stored report is\nwritten only by this confirmed `apply`: the model layer refuses every other\ninsert/spec change, and the generic manifest tools (`buildManifestToolCatalog`,\n`invokeManifestTool`) never offer or run a `RuntimeReport` write. Stored-report\nreads/saves run under the principal's tenant (`withPrincipalTenant`), refuse a\nmismatching ambient tenant (checked before `confirmSave`), and filter by the principal tenant explicitly. Contract and authority model:\n[`packages/reports/agents/runtime-reports.md`](../reports/agents/runtime-reports.md).\n\n## Overview tools (#3727)\n\n`createOverviewTools({ host })` (`overview-tools.ts`) returns\n`overviews.describe`, `overviews.apply` (an atomic batch of structured widget\noperations) and `overviews.undo` (single step, keyed by tenant, user and page).\nThe host opens a page per `PrincipalRun` (smrt-svelte's\n`createOverviewAssistantSurface`, typed structurally here); validation and\n`canCustomize` are the surface's. Contract:\n[agents/overview-tools.md](agents/overview-tools.md).\n\n## Conversational Harness (L3, #1891)\n\nThe `AgentSession` runtime depends on personas, agents, and users. Keep those\nedges acyclic: none of those packages may depend back on chat.\n\n- **`runToolLoop(options)`** (`tool-loop.ts`) — a bounded `tool_call → observe → respond` loop. Tools are **manifest operations** of installed packages: `buildManifestToolCatalog({ allowedTools })` reads the `PermissionCatalogService` catalog and keeps only the `(collection, action)` entries named in the persona's allow-list (the **offer gate**; absent/empty ⇒ NO tools). The loop runs inside one `executeAsPrincipal` context, and `invokeManifestTool` executes each op **in-process (\"side door\")** against `run.context.database` (the RLS tx when Postgres RLS is on), after re-asserting the fail-closed allow-list (`run.assertToolAllowed`) AND the catalog permission (`run.assertOperation`) — the **execution gate**. Bounded by a max-steps ceiling (`DEFAULT_MAX_STEPS = 8`): on the ceiling it disables tools for one final completion so the turn always terminates with text. A thrown tool error is classified (`classifyToolError`): permission denials and 401/403 are `not_permitted`; a `ValidationError` or 400/404/409/422 is `invalid_request` (observation: actionable message, `publicMessage` first, `code`, fix-and-retry hint); else (incl. 429) `execution_error`.\n- **`runPersonaConversationTurn(options)`** (`persona-conversation.ts`) — binds a conversation to an `AgentPersona`/`ResolvedPersona`: runs as its principal (`runAsUserId`), offers only its `allowedTools`, speaks its instructions (`resolvePersonaInstructions`, layering approved learned directives), and injects its **recalled learning memory** (`personaLearningMemory`, isolated per `memoryScope`) into the system prompt. `bindPersonaToSession()` mirrors the persona's `allowedTools`/instructions onto the `AgentSession` so the chat authoring gate agrees with the loop's offer gate. Authors the reply (and each executed tool) via the internal `sendAgentReply` bridge. Tools run with the run-as user's **live** permissions unless the host passes a `permissions` snapshot (#2978), which `runToolLoop` forwards to `executeAsPrincipal` so a narrowed principal (e.g. a shared station) cannot reach grants — such as `readPermission`-gated data-surface fields — beyond that snapshot, including through `agents.invoke` delegation (the snapshot becomes the worker's permission ceiling).\n- **Agent orchestration** (L3, #1892) — the loop accepts non-manifest **`extraTools`** (`PrincipalTool[]`, from `@happyvertical/smrt-agents`), gated by the *same* fail-closed allow-list. The standard **`invoke-agent`** tool (`createInvokeAgentTool`, slug `agents.invoke`) lets a conversational agent delegate to a **worker agent under its own principal** — the worker runs via `executeAsPrincipal` as the originating user (never its own authority), the principal is immutable along the chain, and its completion is surfaced back into the conversation. `runPersonaConversationTurn` filters `extraTools` by the persona's `allowedTools` (offer gate); the tool's `execute` re-asserts `assertToolAllowed` (execution gate). See `@happyvertical/smrt-agents` for the delegation envelope + transports.\n- **Chat feedback capture** (`chat-feedback.ts`) — `captureChatFeedback()` + `acceptAppliedChange`/`rejectAppliedChange`/`correctResponse`/`rateResponse`/`thumbsUp`/`thumbsDown` write a `Feedback` row (personas) carrying the conversation's **correlation-id**, and (by default) reinforce the persona's learning memory (`reinforceFromFeedback`). So an in-chat reject decays a strategy below the reuse floor and it stops being recalled; a correction supersedes its stored value.\n\n## Voice Gateway Turns (#1910)\n\nVoice is an input mode for the existing persona chat harness, not a separate chat runtime. `createVoiceChatSession()` creates a short-lived `VoiceSession` for an authenticated actor/profile, binding tenant, persona, agent session, room, and optional thread. `handleVoiceGatewayTurn()` resolves that binding from `metadata.voiceSessionId`, checks the gateway's `session_id` and any supplied tenant/profile/persona/session/thread metadata against the server-side binding, persists the transcript through `ChatService.sendAgentUserMessage()`, runs `runPersonaConversationTurn()`, stamps voice/correlation metadata onto the persisted user/assistant/tool messages, records the gateway `turn_id`, and returns the gateway response contract. The Fetch-compatible `createVoiceGatewayTurnHandler()` adds the coarse gateway bearer-token check.\n\nThe gateway bearer token proves only \"this request came from the gateway\"; it never authorizes the end user. The short-lived `VoiceSession` binding is the user/session proof, and untrusted gateway metadata must be validated against that binding before any chat write or tool loop. Tool execution remains fail-closed through the persona allow-list mirrored onto `AgentSession` by `bindPersonaToSession()`.\n\n## Token streaming\n\n`chat-stream.ts` serves browser-safe SSE through `./client`. The host's\n`authorize(request, body)` is the sole trust boundary: resolve identity, tenant,\ncapabilities, and generation limits server-side; never authorize from request\nsession metadata. Persona turns reuse the principal and fail-closed tool gates.\nThe engine and browser-safe `SmrtChatBackend` keep custom-tool resolution\nserver-side. See [agents/token-streaming.md](agents/token-streaming.md) for the\nevent contract, CORS policy, and streaming behavior.\n\n## Streamed assistant turns with browser tools (#2908)\n\n`runAssistantTurn()` (`assistant-turn.ts`) is the host-route engine for the\nAssistantDock: one turn over `runToolLoop` with server tools (offer-gated by\n`principal.allowedTools`), browser tools (validated by\n`sanitizeClientToolDeclarations` + a server allow-list), `maxSteps`, `signal`,\nand `onUsage`. A browser tool call SUSPENDS the turn (`stoppedReason:\n'client_tools'`); the transcript waits in an `AssistantContinuationStore`\n(single-use, 15-minute TTL; `createSessionContinuationStore` keeps it in the\nAgentSession's `sessionContext`) and the browser resumes it with results,\nwhich reach the model wrapped `untrusted`. `createAssistantTurnResponse`\nserves the events as SSE; `assistant-turn-events.ts` is the browser-safe wire\ncontract (`./assistant-turn` subpath, also a vite library entry because the\nSvelte dock imports it). The dock decides whether a browser call runs or waits\nfor the user from the page registry, never the server's echo; destructive\nalways waits. Details: [`docs/assistant-dock.md`](../../docs/assistant-dock.md).\n\n## AssistantDock (#2904)\n\n`AssistantDock` (`svelte/components/assistant/`, exported from `./svelte`)\nuses a host `DataSurfaceRegistry`'s mounted descriptors (none: plain chat).\nActions: `normalizeDataSurfaceActionRequest` → `AssistantActionClient`\n(preview/apply over a server `DataSurfaceActionAdapter`, not the\n`data-surface-bridge.ts` channel). Generated `ChatThread`/`ChatMessage`\n`list`/`get` are tenant- not member-scoped; the dock never calls them.\n\n**Server routes (#3368).** `./sveltekit` `mountAssistantRoutes()` serves the\ndock from one `[...path]` route; `createAssistantHttp{Transport,ActionClient}`\n(`./svelte`) are its browser half. Principal from `event.locals` or\n`resolvePrincipal`; threads only from the actor's keyed session room in that\ntenant (else 404). `allowedTools` is fail-closed; without `tools` it offers\nthose manifest ops (an unprovided name is an error, #3414). `ai` defaults to\nthe config `ai` block. Pass `runtime`: a turn never keeps the RLS request tx\n(own `runAsPrincipal` tx). `clientRequestId` makes the row id a UUIDv5\n(`clientRequestMessageId`): the PK is the retry reservation. See\n[docs](../../docs/assistant-dock.md).\n\n## Recipes (#3719)\n\n`src/recipes.ts` declares `chat.assistant` (the AssistantDock as a `header.end`\nshell widget, an `llm` provider, runtime `both`; help in\n`src/assistant.recipe.md`). Keep each recipe class self-contained. Details:\n[agents/recipes.md](agents/recipes.md).\n\n## Gotchas\n\n- **sessionContext, not context**: `context` is reserved for slug scoping. Use `getSessionContext()`/`updateSessionContext()` for agent memory.\n- **Agent rooms auto-created**: `roomType: 'agent'`, `maxParticipants` defaults to 2; the agent is enrolled as a member so its replies pass the membership check. `createAgentSession()` re-enrolls the participant AND the agent on the existing-session path, so legacy sessions created before the agent was enrolled self-heal.\n- **The agent's author is a Profile, not `agentId` (#2995)**: `senderProfileId`/`profileId` are `crossPackageRef`s to `Profile` and therefore uuid columns on PostgreSQL, so the slug fails the cast (`22P02`) and no agent reply can persist. `createAgentSession()` resolves the agent's `bot` Profile through `resolveAgentProfile()` in `@happyvertical/smrt-profiles` (created on first use, tenant-bound) and records it as `AgentSession.agentProfileId`; both enrolment and `sendAgentReply` use that uuid. A consumer whose agent already has an acting identity passes `agentProfileId` to skip resolution — the voice path does this with the persona's `actsAsProfileId`, so persona replies stay attributed to the configured acting profile. Like `actorProfileId`, `agentProfileId` is SERVER-supplied, never request input: it selects the author of every later assistant message. It is validated (`#requireAgentProfile`) to exist and be visible in the tenant and to differ from the acting participant, and it can never re-point a session that already resolved one — only a pre-#2995 session with none is backfilled. **Migration**: `agent_profile_id` is an ordinary additive manifest field, so a consumer adopts it the same way as any other — `smrt db:migrate` generates and applies the column from the manifest, and `db:status --parity` verifies it; the framework never creates application schema at runtime. It is nullable and needs no offline DATA backfill — a session created before #2995 resolves and persists its `agentProfileId`, and re-enrols the agent, on its first agent turn after the upgrade. Two pre-#2995 cohorts: a session whose `agentId` is an ordinary slug never committed an author on PostgreSQL (the cast failed), so there is nothing to repair there, while SQLite/DuckDB may hold `chat_messages`/`chat_participants` rows whose id column holds the slug — readable, but not joinable to `profiles`. A session whose `agentId` is already a Profile uuid (the voice path used to collapse a persona's `actsAsProfileId` into it) DID commit on PostgreSQL; that profile is ADOPTED as the author, tenant-checked and never the participant, so the conversation's author does not change and the two-seat room gains no third identity. Known one-time effect: because `voice.ts` no longer derives `agentId` from `actsAsProfileId`, a keyless `createVoiceChatSession()` for such a persona opens a new session and room instead of reusing the legacy one; the old room and its messages stay intact and readable, and resuming with an explicit `agentSessionId` is unaffected.\n- **Only SQLite-backed tests would miss this class of bug**: uuid columns accept any text on SQLite. `src/__tests__/agent-reply-postgres.test.ts` (`pnpm --filter @happyvertical/smrt-chat test:postgres`) pins the agent-reply path against real PostgreSQL. That suite runs in `postgres-tests.yml` on every same-repository PR and merge group (#2659); keep a cheap SQLite assertion alongside it anyway, since the default lane is SQLite-only (`chat-service.test.ts` asserts the author is the profile uuid, not the slug).\n- **Per-subject sessions need `sessionKey`**: `createAgentSession()` reuses ANY active session for the same `(agentId, participantProfileId, tenantId)`. Callers that open separate conversations per subject (e.g. one content-editor session per content id) MUST pass a stable `sessionKey` (stored in `sessionContext.__sessionKey`, read via `AgentSession.getSessionKey()`); otherwise a session opened for one subject is reused and its context overwritten for another, surfacing the wrong room/threads (S5 #1392). A keyed create never reuses a keyless/legacy session.\n- **Session expiry**: check `isActive()` before allowing messages (expiresAt or limit-based)\n- **DM identity**: derived from the deterministic per-tenant `canonicalDmRoomId()` and the authoritative `chat_participants` join, not client metadata; concurrent creates upsert onto one row.\n- **Tenant-bound lookups**: membership/session/DM lookups REQUIRE `tenantId` and always bind it into the WHERE clause (`findActiveMembership`/`isActiveMember`/`findActiveSession` take a required `tenantId`; AgentSession's `null` tenant is an explicit bound scope, not \"any tenant\") so they can never resolve a row from another tenant.\n",
5156
5202
  "moduleDocs": [
5157
5203
  {
5158
5204
  "path": "agents/dev-workbench.md",
@@ -5289,7 +5335,7 @@
5289
5335
  ],
5290
5336
  "nav": [
5291
5337
  {
5292
- "label": "Chat Rooms",
5338
+ "label": "Chat rooms",
5293
5339
  "model": "@happyvertical/smrt-chat:ChatRoom",
5294
5340
  "icon": "users",
5295
5341
  "description": "Team conversations organized by topic or audience.",
@@ -5348,7 +5394,7 @@
5348
5394
  }
5349
5395
  },
5350
5396
  "help": {
5351
- "markdown": "## Overview\n\nTeam Chat keeps conversations in rooms so people can find the right discussion,\nfollow threads, and see reactions without mixing unrelated work together. The\nroom and message lists are read-only generated views. Create rooms, manage\nmembers, and send messages through the app's authorized chat workflow.\n\n## Tasks\n\n### Find the right room\n\n1. Open **Chat Rooms** from the navigation.\n2. Search for a room by its **{field:ChatRoom.name}**, or use its\n **{field:ChatRoom.topic}** to tell similar rooms apart.\n3. Open the room and check **{field:ChatRoom.lastMessageAt}** to see where the\n most recent discussion happened.\n\n### Follow a conversation\n\n1. Read the room messages and open a thread when you need the discussion in\n one place.\n2. Use the thread **{field:ChatThread.title}** to describe the subject when\n your app supplies one.\n3. Add a reaction by choosing the **{field:ChatReaction.emoji}** that best\n communicates your response.\n\n### Keep a room useful\n\n1. Give the room a clear **{field:ChatRoom.name}** and a short\n **{field:ChatRoom.description}**.\n2. Set **{field:ChatRoom.roomType}** deliberately: public for broad team\n discussion, private for restricted groups, and direct messages for one-to-one\n conversations.\n3. Use a focused **{field:ChatMessage.content}** so teammates can scan the\n conversation and reply in context.\n",
5397
+ "markdown": "## Overview\n\nTeam Chat keeps conversations in rooms so people can find the right discussion,\nfollow threads, and see reactions without mixing unrelated work together. The\nroom and message lists are read-only generated views. Create rooms, manage\nmembers, and send messages through the app's authorized chat workflow.\n\n## Tasks\n\n### Find the right room\n\n1. Open **Chat rooms** from the navigation.\n2. Search for a room by its **{field:ChatRoom.name}**, or use its\n **{field:ChatRoom.topic}** to tell similar rooms apart.\n3. Open the room and check **{field:ChatRoom.lastMessageAt}** to see where the\n most recent discussion happened.\n\n### Follow a conversation\n\n1. Read the room messages and open a thread when you need the discussion in\n one place.\n2. Use the thread **{field:ChatThread.title}** to describe the subject when\n your app supplies one.\n3. Add a reaction by choosing the **{field:ChatReaction.emoji}** that best\n communicates your response.\n\n### Keep a room useful\n\n1. Give the room a clear **{field:ChatRoom.name}** and a short\n **{field:ChatRoom.description}**.\n2. Set **{field:ChatRoom.roomType}** deliberately: public for broad team\n discussion, private for restricted groups, and direct messages for one-to-one\n conversations.\n3. Use a focused **{field:ChatMessage.content}** so teammates can scan the\n conversation and reply in context.\n",
5352
5398
  "fieldRefs": [
5353
5399
  "ChatMessage.content",
5354
5400
  "ChatReaction.emoji",
@@ -5366,11 +5412,6 @@
5366
5412
  "path": "/chat/rooms",
5367
5413
  "export": "@happyvertical/smrt-chat/svelte#ChatLayout",
5368
5414
  "label": "Team chat"
5369
- },
5370
- {
5371
- "kind": "playground",
5372
- "export": "@happyvertical/smrt-chat/playground#default",
5373
- "label": "Static chat fixtures"
5374
5415
  }
5375
5416
  ],
5376
5417
  "runtime": "both",
package/dist/sveltekit.js CHANGED
@@ -1,5 +1,5 @@
1
- import { i as clientRequestMessageId, n as ChatService, t as ChatClientRequestConflictError } from "./chunks/ChatService-CFaaDHyU.js";
2
- import { D as runAssistantTurn, E as createSessionContinuationStore, P as buildManifestToolCatalog, j as MAX_CLIENT_TOOL_RESULT_CHARS, v as ASSISTANT_TURN_GENERIC_ERROR, w as createAssistantTurnResponse, z as sanitizeClientToolDeclarations } from "./chunks/chat-stream-CE-vuz1u.js";
1
+ import { i as clientRequestMessageId, n as ChatService, t as ChatClientRequestConflictError } from "./chunks/ChatService-DjGf2AoJ.js";
2
+ import { D as runAssistantTurn, E as createSessionContinuationStore, P as buildManifestToolCatalog, j as MAX_CLIENT_TOOL_RESULT_CHARS, v as ASSISTANT_TURN_GENERIC_ERROR, w as createAssistantTurnResponse, z as sanitizeClientToolDeclarations } from "./chunks/chat-stream-BGU2DV6D.js";
3
3
  import { getCurrentSessionPermissionContext, getRequestScopedDatabase } from "@happyvertical/smrt-users";
4
4
  import { getConfig, loadConfig, resolveConfiguredAIProvider, toAIClientOptions } from "@happyvertical/smrt-config";
5
5
  //#region src/sveltekit.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-chat",
3
- "version": "0.55.12",
3
+ "version": "0.55.14",
4
4
  "smrtJsdoc": "strict",
5
5
  "description": "Chat rooms, DMs, threads, and agent conversations for the SMRT framework",
6
6
  "type": "module",
@@ -83,22 +83,22 @@
83
83
  },
84
84
  "dependencies": {
85
85
  "@happyvertical/animation": "0.5.0",
86
- "@happyvertical/ai": "^0.103.1",
87
- "@happyvertical/speech": "^0.103.1",
88
- "@happyvertical/smrt-images": "0.55.12",
89
- "@happyvertical/smrt-agents": "0.55.12",
90
- "@happyvertical/smrt-assets": "0.55.12",
91
- "@happyvertical/smrt-config": "0.55.12",
92
- "@happyvertical/smrt-core": "0.55.12",
93
- "@happyvertical/smrt-personas": "0.55.12",
94
- "@happyvertical/smrt-profiles": "0.55.12",
95
- "@happyvertical/smrt-reports": "0.55.12",
96
- "@happyvertical/smrt-tenancy": "0.55.12",
97
- "@happyvertical/smrt-types": "0.55.12",
98
- "@happyvertical/smrt-ui": "0.55.12",
99
- "@happyvertical/smrt-users": "0.55.12",
100
- "@happyvertical/sql": "^0.103.1",
101
- "@happyvertical/images": "^0.103.1",
86
+ "@happyvertical/ai": "^0.103.2",
87
+ "@happyvertical/speech": "^0.103.2",
88
+ "@happyvertical/smrt-images": "0.55.14",
89
+ "@happyvertical/smrt-agents": "0.55.14",
90
+ "@happyvertical/smrt-assets": "0.55.14",
91
+ "@happyvertical/smrt-config": "0.55.14",
92
+ "@happyvertical/smrt-core": "0.55.14",
93
+ "@happyvertical/smrt-personas": "0.55.14",
94
+ "@happyvertical/smrt-profiles": "0.55.14",
95
+ "@happyvertical/smrt-reports": "0.55.14",
96
+ "@happyvertical/smrt-tenancy": "0.55.14",
97
+ "@happyvertical/smrt-types": "0.55.14",
98
+ "@happyvertical/smrt-ui": "0.55.14",
99
+ "@happyvertical/smrt-users": "0.55.14",
100
+ "@happyvertical/sql": "^0.103.2",
101
+ "@happyvertical/images": "^0.103.2",
102
102
  "gsap": "^3.13.0"
103
103
  },
104
104
  "peerDependencies": {
@@ -110,10 +110,10 @@
110
110
  }
111
111
  },
112
112
  "devDependencies": {
113
- "@happyvertical/smrt-playground": "0.55.12",
114
- "@happyvertical/smrt-scanner": "0.55.12",
115
- "@happyvertical/smrt-vitest": "0.55.12",
116
- "@happyvertical/smrt-web": "0.55.12",
113
+ "@happyvertical/smrt-playground": "0.55.14",
114
+ "@happyvertical/smrt-scanner": "0.55.14",
115
+ "@happyvertical/smrt-vitest": "0.55.14",
116
+ "@happyvertical/smrt-web": "0.55.14",
117
117
  "@mlc-ai/web-llm": "^0.2.85",
118
118
  "bitgpu": "0.19.1",
119
119
  "@sveltejs/kit": "2.69.1",