@wabot-dev/create 0.0.2 → 0.0.3
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/README.md +189 -0
- package/bin/create-wabot.mjs +7 -100
- package/lib/cli.mjs +49 -0
- package/lib/create-project.mjs +299 -0
- package/lib/skills/install.mjs +179 -0
- package/lib/skills/registry.mjs +17 -0
- package/package.json +4 -5
- package/skills/wabot-framework/SKILL.md +32 -0
- package/skills/wabot-framework/agents/openai.yaml +5 -0
- package/skills/wabot-framework/references/async-and-cron.md +57 -0
- package/skills/wabot-framework/references/controllers-and-auth.md +104 -0
- package/skills/wabot-framework/references/core-patterns.md +83 -0
- package/skills/wabot-framework/references/full-example.md +278 -0
- package/skills/wabot-framework/references/mindset-and-chat.md +97 -0
- package/skills/wabot-framework/references/ops.md +58 -0
- package/skills/wabot-framework/references/persistence.md +85 -0
- package/skills/wabot-framework/references/quickstart.md +56 -0
- package/skills/wabot-framework/references/validation-and-data.md +88 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import fs from 'fs/promises'
|
|
2
|
+
import os from 'os'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
|
|
5
|
+
import { getSkill, listSkillNames } from './registry.mjs'
|
|
6
|
+
|
|
7
|
+
const supportedAgents = {
|
|
8
|
+
codex: ['.codex', 'skills'],
|
|
9
|
+
claude: ['.claude', 'skills'],
|
|
10
|
+
agents: ['.agents', 'skills'],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function installSkill(skillName, options, cwd = process.cwd()) {
|
|
14
|
+
const skill = getSkill(skillName)
|
|
15
|
+
|
|
16
|
+
if (!skill) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Unknown skill "${skillName}". Available skills: ${listSkillNames().join(', ')}`,
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const plan = await buildInstallPlan(skill, options, cwd)
|
|
23
|
+
|
|
24
|
+
for (const destination of plan.destinations) {
|
|
25
|
+
await fs.mkdir(destination.baseDir, { recursive: true })
|
|
26
|
+
await fs.cp(skill.sourceDir, destination.installDir, {
|
|
27
|
+
recursive: true,
|
|
28
|
+
force: false,
|
|
29
|
+
errorOnExist: true,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
skill,
|
|
35
|
+
mode: plan.mode,
|
|
36
|
+
homeRoot: plan.homeRoot,
|
|
37
|
+
installations: plan.destinations.map((destination) => ({
|
|
38
|
+
agent: destination.agent,
|
|
39
|
+
installDir: destination.installDir,
|
|
40
|
+
})),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function buildInstallPlan(skill, options, cwd) {
|
|
45
|
+
await assertPathExists(skill.sourceDir, `Packaged skill source not found at ${skill.sourceDir}`)
|
|
46
|
+
|
|
47
|
+
if (options.local && options.global) {
|
|
48
|
+
throw new Error('Choose only one install mode: --local or --global')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!options.global && (options.target || options.targets || options.home)) {
|
|
52
|
+
throw new Error('--home, --target, and --targets can only be used together with --global')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (options.local) {
|
|
56
|
+
const installDir = path.join(cwd, '.agents', 'skills', skill.name)
|
|
57
|
+
await assertDestinationAvailable(installDir)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
mode: 'local',
|
|
61
|
+
homeRoot: null,
|
|
62
|
+
destinations: [
|
|
63
|
+
{
|
|
64
|
+
agent: 'agents',
|
|
65
|
+
baseDir: path.join(cwd, '.agents', 'skills'),
|
|
66
|
+
installDir,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const homeRoot = resolveHomeRoot(options.home)
|
|
73
|
+
const targets = resolveTargets(options)
|
|
74
|
+
const destinations = targets.map((agent) => {
|
|
75
|
+
const baseDir = resolveAgentSkillsDir(homeRoot, agent)
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
agent,
|
|
79
|
+
baseDir,
|
|
80
|
+
installDir: path.join(baseDir, skill.name),
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
await Promise.all(destinations.map((destination) => assertDestinationAvailable(destination.installDir)))
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
mode: 'global',
|
|
88
|
+
homeRoot,
|
|
89
|
+
destinations,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveTargets(options) {
|
|
94
|
+
const parsedTargets = []
|
|
95
|
+
|
|
96
|
+
if (options.target) {
|
|
97
|
+
parsedTargets.push(options.target)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (options.targets) {
|
|
101
|
+
parsedTargets.push(
|
|
102
|
+
...String(options.targets)
|
|
103
|
+
.split(',')
|
|
104
|
+
.map((target) => target.trim())
|
|
105
|
+
.filter(Boolean),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (parsedTargets.length === 0) {
|
|
110
|
+
return ['codex']
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const uniqueTargets = [...new Set(parsedTargets)]
|
|
114
|
+
|
|
115
|
+
if (uniqueTargets.length !== parsedTargets.length) {
|
|
116
|
+
throw new Error('Duplicate targets are not allowed in --target/--targets')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const target of uniqueTargets) {
|
|
120
|
+
if (!supportedAgents[target]) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Unsupported target "${target}". Supported targets: ${Object.keys(supportedAgents).join(', ')}`,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return uniqueTargets
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveHomeRoot(explicitHome) {
|
|
131
|
+
if (explicitHome) {
|
|
132
|
+
return path.resolve(explicitHome)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const homeRoot = os.homedir()
|
|
136
|
+
|
|
137
|
+
if (!homeRoot) {
|
|
138
|
+
throw new Error('Unable to resolve a home directory. Pass --home <path> explicitly.')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return path.resolve(homeRoot)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveAgentSkillsDir(homeRoot, agent) {
|
|
145
|
+
const segments = supportedAgents[agent]
|
|
146
|
+
|
|
147
|
+
if (!segments) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Unsupported target "${agent}". Supported targets: ${Object.keys(supportedAgents).join(', ')}`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return path.join(homeRoot, ...segments)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function assertDestinationAvailable(installDir) {
|
|
157
|
+
try {
|
|
158
|
+
await fs.access(installDir)
|
|
159
|
+
throw new Error(`Skill already exists at ${installDir}`)
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error?.code === 'ENOENT') {
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
throw error
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function assertPathExists(targetPath, errorMessage) {
|
|
170
|
+
try {
|
|
171
|
+
await fs.access(targetPath)
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error?.code === 'ENOENT') {
|
|
174
|
+
throw new Error(errorMessage)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { fileURLToPath } from 'url'
|
|
2
|
+
|
|
3
|
+
const skillDefinitions = {
|
|
4
|
+
'wabot-framework': {
|
|
5
|
+
name: 'wabot-framework',
|
|
6
|
+
sourceDir: fileURLToPath(new URL('../../skills/wabot-framework', import.meta.url)),
|
|
7
|
+
description: 'Wabot Framework packaged skill',
|
|
8
|
+
},
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getSkill(skillName) {
|
|
12
|
+
return skillDefinitions[skillName] ?? null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function listSkillNames() {
|
|
16
|
+
return Object.keys(skillDefinitions).sort()
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wabot-dev/create",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Project creator for Wabot Framework",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "index.js",
|
|
9
|
-
"scripts": {
|
|
10
|
-
"test": "echo \"Error: no test specified\" && exit 1"
|
|
11
|
-
},
|
|
12
9
|
"bin": {
|
|
13
10
|
"create-wabot": "./bin/create-wabot.mjs"
|
|
14
11
|
},
|
|
15
12
|
"files": [
|
|
16
|
-
"bin"
|
|
13
|
+
"bin",
|
|
14
|
+
"lib",
|
|
15
|
+
"skills"
|
|
17
16
|
],
|
|
18
17
|
"dependencies": {
|
|
19
18
|
"chalk": "^5.4.1",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wabot-framework
|
|
3
|
+
description: Use when building, reviewing, or debugging apps with @wabot-dev/framework. This skill is self-contained and includes offline examples for project setup, dependency injection, mindsets, external function modules, validation, persistence, chat controllers, REST and socket controllers, async commands, cron, auth, logger, and locking.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Wabot Framework
|
|
7
|
+
|
|
8
|
+
Use this skill for Wabot application work. It is self-contained: read the bundled examples first and do not depend on web access for normal tasks.
|
|
9
|
+
|
|
10
|
+
## How To Use
|
|
11
|
+
|
|
12
|
+
Load the reference file that matches the request:
|
|
13
|
+
|
|
14
|
+
- `references/quickstart.md` for project bootstrap and first run
|
|
15
|
+
- `references/core-patterns.md` for dependency injection and service lifecycles
|
|
16
|
+
- `references/mindset-and-chat.md` for bot personality, tools, and chat routing
|
|
17
|
+
- `references/validation-and-data.md` for DTOs, validation, and `Mapper`
|
|
18
|
+
- `references/persistence.md` for entities, repositories, and PostgreSQL persistence
|
|
19
|
+
- `references/controllers-and-auth.md` for REST, socket, and JWT auth
|
|
20
|
+
- `references/async-and-cron.md` for commands and scheduled work
|
|
21
|
+
- `references/ops.md` for logger and locking
|
|
22
|
+
- `references/full-example.md` for an end-to-end sales bot app
|
|
23
|
+
|
|
24
|
+
## Rules
|
|
25
|
+
|
|
26
|
+
- Keep prose in Spanish when explaining to the team.
|
|
27
|
+
- Keep code identifiers in English.
|
|
28
|
+
- Use `@wabot-dev/framework` imports in examples.
|
|
29
|
+
- Do not invent APIs. If a detail is not covered by the bundled examples, say what is missing instead of guessing.
|
|
30
|
+
- Keep controllers thin and push business logic into services or modules.
|
|
31
|
+
- Use `@scoped()` only when request, chat, or socket isolation matters.
|
|
32
|
+
- Prefer the smallest example that demonstrates the pattern.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Async And Cron
|
|
2
|
+
|
|
3
|
+
Use this when scheduling work in the background or recurring jobs.
|
|
4
|
+
|
|
5
|
+
## Command and handler
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { command, commandHandler, type ICommandHandler } from '@wabot-dev/framework'
|
|
9
|
+
import { isString } from '@wabot-dev/framework'
|
|
10
|
+
|
|
11
|
+
@command('save-test-tag')
|
|
12
|
+
export class SaveTestTag {
|
|
13
|
+
@isString()
|
|
14
|
+
value!: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@commandHandler(SaveTestTag)
|
|
18
|
+
export class SaveTestTagHandler implements ICommandHandler<SaveTestTag> {
|
|
19
|
+
constructor(private tags: TagRepository) {}
|
|
20
|
+
|
|
21
|
+
async handle(input: SaveTestTag) {
|
|
22
|
+
await this.tags.save(input.value)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Run or schedule a command
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { Async } from '@wabot-dev/framework'
|
|
31
|
+
|
|
32
|
+
const job = await async.runCommand(SaveTestTag, { value: 'hello' })
|
|
33
|
+
await async.scheduleCommand(SaveTestTag, { value: 'later' }, { minutes: 10 })
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Cron handler
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import { cron } from '@wabot-dev/framework'
|
|
40
|
+
|
|
41
|
+
@cron({ name: 'sync-leads', cron: '0 * * * *' })
|
|
42
|
+
export class SyncLeadsCron {
|
|
43
|
+
constructor(private leadService: LeadService) {}
|
|
44
|
+
|
|
45
|
+
async handle() {
|
|
46
|
+
await this.leadService.sync()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Good practice
|
|
52
|
+
|
|
53
|
+
- Use commands for one-off background work.
|
|
54
|
+
- Use cron for repeated schedules.
|
|
55
|
+
- Keep command DTOs validated and small.
|
|
56
|
+
- Keep cron handlers idempotent because they can run many times.
|
|
57
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Controllers And Auth
|
|
2
|
+
|
|
3
|
+
Use this when exposing HTTP endpoints, socket events, or protected routes.
|
|
4
|
+
|
|
5
|
+
## REST controller
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { IncomingMessage } from 'http'
|
|
9
|
+
import { onPost, restController } from '@wabot-dev/framework'
|
|
10
|
+
|
|
11
|
+
class PaymentService {
|
|
12
|
+
processWebhook(rawBody: string) {
|
|
13
|
+
return { ok: true, rawBody }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@restController('/webhooks/bold')
|
|
18
|
+
export class BoldWebhookController {
|
|
19
|
+
constructor(private paymentService: PaymentService) {}
|
|
20
|
+
|
|
21
|
+
@onPost({
|
|
22
|
+
disableJsonParser: true,
|
|
23
|
+
disableUrlEncodedParser: true,
|
|
24
|
+
})
|
|
25
|
+
async receiveWebhook(request: IncomingMessage) {
|
|
26
|
+
const rawBody = await this.readRawBody(request)
|
|
27
|
+
return this.paymentService.processWebhook(rawBody)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Socket controller
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { jwtHandshakeGuard, onSocketEvent, socketController } from '@wabot-dev/framework'
|
|
36
|
+
|
|
37
|
+
@jwtHandshakeGuard()
|
|
38
|
+
@socketController('/sales')
|
|
39
|
+
export class SalesSocketController {
|
|
40
|
+
@onSocketEvent('message')
|
|
41
|
+
async onMessage(payload: { text: string }) {
|
|
42
|
+
return { ok: true, received: payload.text }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## JWT and API key auth
|
|
48
|
+
|
|
49
|
+
Use JWT when the client already has a signed token. Use API keys when the client should present a shared secret.
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import {
|
|
53
|
+
apiKeyGuard,
|
|
54
|
+
apiKeyHandshakeGuard,
|
|
55
|
+
jwtGuard,
|
|
56
|
+
jwtHandshakeGuard,
|
|
57
|
+
onGet,
|
|
58
|
+
onPost,
|
|
59
|
+
restController,
|
|
60
|
+
socketController,
|
|
61
|
+
} from '@wabot-dev/framework'
|
|
62
|
+
|
|
63
|
+
class CreateLeadDto {
|
|
64
|
+
name!: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class LeadService {
|
|
68
|
+
async create(body: CreateLeadDto) {
|
|
69
|
+
return { created: true, lead: body }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@restController('/api/leads')
|
|
74
|
+
export class LeadApiController {
|
|
75
|
+
constructor(private leadService: LeadService) {}
|
|
76
|
+
|
|
77
|
+
@apiKeyGuard()
|
|
78
|
+
@onGet()
|
|
79
|
+
async listLeads() {
|
|
80
|
+
return []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@jwtGuard()
|
|
84
|
+
@onPost()
|
|
85
|
+
async createLead(body: CreateLeadDto) {
|
|
86
|
+
return this.leadService.create(body)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@socketController('/lead-room')
|
|
91
|
+
@jwtHandshakeGuard()
|
|
92
|
+
export class LeadRoomSocketController {}
|
|
93
|
+
|
|
94
|
+
@apiKeyHandshakeGuard()
|
|
95
|
+
@socketController('/api-room')
|
|
96
|
+
export class ApiRoomSocketController {}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Rule of thumb
|
|
100
|
+
|
|
101
|
+
- REST for request/response workflows, webhooks, and admin APIs.
|
|
102
|
+
- Socket for live events and long-lived sessions.
|
|
103
|
+
- JWT when the client already has a signed token.
|
|
104
|
+
- API key when the client should present a shared secret.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Core Patterns
|
|
2
|
+
|
|
3
|
+
Use this when deciding how to register and inject services.
|
|
4
|
+
|
|
5
|
+
## Lifecycles
|
|
6
|
+
|
|
7
|
+
| Decorator | Use for | Example |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `@singleton()` | Shared clients, repositories, config, caches | one DB repository for the app |
|
|
10
|
+
| `@injectable()` | Stateless services and modules | calculators, adapters, helpers |
|
|
11
|
+
| `@scoped(Lifecycle.ContainerScoped)` | Request/chat/socket-local state | auth context, request context |
|
|
12
|
+
|
|
13
|
+
## Singleton example
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { singleton } from '@wabot-dev/framework'
|
|
17
|
+
|
|
18
|
+
@singleton()
|
|
19
|
+
export class ProductRepository {
|
|
20
|
+
private products = new Map<string, string>()
|
|
21
|
+
|
|
22
|
+
save(id: string, name: string) {
|
|
23
|
+
this.products.set(id, name)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Injectable example
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { injectable } from '@wabot-dev/framework'
|
|
32
|
+
|
|
33
|
+
@injectable()
|
|
34
|
+
export class QuoteCalculator {
|
|
35
|
+
total(base: number, discount: number) {
|
|
36
|
+
return base - base * discount
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Scoped example
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { Lifecycle, scoped } from '@wabot-dev/framework'
|
|
45
|
+
|
|
46
|
+
@scoped(Lifecycle.ContainerScoped)
|
|
47
|
+
export class RequestContext {
|
|
48
|
+
private userId?: string
|
|
49
|
+
|
|
50
|
+
setUserId(userId: string) {
|
|
51
|
+
this.userId = userId
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
requireUserId() {
|
|
55
|
+
if (!this.userId) throw new Error('Unauthorized')
|
|
56
|
+
return this.userId
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Token injection
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { container, inject, injectable } from '@wabot-dev/framework'
|
|
65
|
+
|
|
66
|
+
container.register('SMTP_HOST', { useValue: 'smtp.example.com' })
|
|
67
|
+
container.register('SMTP_PORT', { useValue: 587 })
|
|
68
|
+
|
|
69
|
+
@injectable()
|
|
70
|
+
export class Mailer {
|
|
71
|
+
constructor(
|
|
72
|
+
@inject('SMTP_HOST') private smtpHost: string,
|
|
73
|
+
@inject('SMTP_PORT') private smtpPort: number,
|
|
74
|
+
) {}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Rule of thumb
|
|
79
|
+
|
|
80
|
+
- Use singleton for shared infrastructure.
|
|
81
|
+
- Use injectable for pure business logic.
|
|
82
|
+
- Use scoped only when state must not leak across requests or chats.
|
|
83
|
+
|