create-manychat-ai-agent 0.0.0-stage → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/agent/.env.example +122 -0
- package/agent/config/catalog.json.example +59 -0
- package/agent/config/prompt.md.example +22 -0
- package/agent/config/rules.json.example +31 -0
- package/agent/package.json +107 -0
- package/demo-tenant.mjs +36 -0
- package/index.mjs +104 -0
- package/package.json +27 -4
- package/template/cases.jsonl +5 -0
- package/template/ci.yml +55 -0
- package/template/docker-compose.yml +36 -0
- package/template/gitignore +8 -0
- package/template/renovate.json +12 -0
package/README.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
-
#
|
|
1
|
+
# create-manychat-ai-agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
```sh
|
|
4
|
+
npm create manychat-ai-agent@latest my-agent
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
Generates a tenant project for
|
|
8
|
+
[manychat-ai-agent](https://github.com/pedronastasi/manychat-ai-agent): `config/`
|
|
9
|
+
from the fictional demo tenant, an offline `.env`, an eval suite, CI, Compose
|
|
10
|
+
and Renovate. It depends on the agent at the version of this scaffolder.
|
|
11
|
+
|
|
12
|
+
Replace `config/` with your own, and keep the repository private: the generated
|
|
13
|
+
CI fails when it is public. See
|
|
14
|
+
[specs/035](https://github.com/pedronastasi/manychat-ai-agent/blob/main/specs/035-create-scaffolds-a-tenant-project.md).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# ---- Model ------------------------------------------------------------------
|
|
2
|
+
# provider:model — switching provider is an edit here and a restart (ADR-0002).
|
|
3
|
+
# anthropic:claude-haiku-4-5 openai:gpt-5-mini google:gemini-2.5-flash
|
|
4
|
+
# Model IDs carry no date suffix.
|
|
5
|
+
AGENT_MODEL=anthropic:claude-haiku-4-5
|
|
6
|
+
# Reasoning models (openai:gpt-5*, openai:o*) spend this budget on reasoning
|
|
7
|
+
# BEFORE emitting any reply, and run out mid-thought at 400: the turn returns no
|
|
8
|
+
# text at all and every reply escalates. Measured 2026-09-15 against the demo
|
|
9
|
+
# catalog, gpt-5-mini used ~900 reasoning tokens before its first output token.
|
|
10
|
+
# A production catalog costs more: gpt-5-mini was still closing turns at 1455 of
|
|
11
|
+
# a 1500 ceiling and failing outright past it, so treat ~1500 as a floor rather
|
|
12
|
+
# than a fix. Raising the ceiling only buys room for deliberation you are also
|
|
13
|
+
# paying for; AGENT_REASONING_EFFORT below caps the deliberation instead.
|
|
14
|
+
AGENT_MAX_OUTPUT_TOKENS=400
|
|
15
|
+
AGENT_TEMPERATURE=0.3
|
|
16
|
+
# Reasoning models only — unset sends nothing, so non-reasoning models are
|
|
17
|
+
# unaffected. Answering from a fixed catalog rarely rewards deliberation, and
|
|
18
|
+
# 'low' buys back both the latency that loses the race and the output budget
|
|
19
|
+
# that otherwise runs out mid-thought.
|
|
20
|
+
# AGENT_REASONING_EFFORT=low
|
|
21
|
+
|
|
22
|
+
# Voice notes and video soundtracks are transcribed by a second model, in the
|
|
23
|
+
# same provider:model form (specs/020). Unset, a voice note gets the tenant's
|
|
24
|
+
# rules.messages.mediaFallback, asking the contact to type it. Anthropic has no
|
|
25
|
+
# transcription model, so this needs another provider's key as well:
|
|
26
|
+
# openai:gpt-4o-mini-transcribe openai:gpt-4o-transcribe openai:whisper-1
|
|
27
|
+
# TRANSCRIPTION_MODEL=openai:gpt-4o-mini-transcribe
|
|
28
|
+
|
|
29
|
+
# Only the ACTIVE providers' keys are required.
|
|
30
|
+
ANTHROPIC_API_KEY=
|
|
31
|
+
# OPENAI_API_KEY=
|
|
32
|
+
# GOOGLE_GENERATIVE_AI_API_KEY=
|
|
33
|
+
|
|
34
|
+
# ---- Ollama (local model) ---------------------------------------------------
|
|
35
|
+
# Start with: docker compose --profile local-model up
|
|
36
|
+
# Then: AGENT_MODEL=ollama:llama3.1:8b pnpm simulate "hola"
|
|
37
|
+
# The first run pulls ~4.7 GB of weights; they persist in a named volume.
|
|
38
|
+
# OLLAMA_BASE_URL=http://localhost:11434/v1
|
|
39
|
+
# Inside compose the agent service overrides this to http://ollama:11434/v1.
|
|
40
|
+
|
|
41
|
+
# ---- Channel ----------------------------------------------------------------
|
|
42
|
+
CHANNEL=whatsapp
|
|
43
|
+
# HTTPS base used to build external_message_callback. Must be publicly reachable.
|
|
44
|
+
PUBLIC_BASE_URL=https://agent.example.com
|
|
45
|
+
|
|
46
|
+
# Proxies whose X-Forwarded-For is believed: addresses, CIDR ranges, or the
|
|
47
|
+
# presets loopback, linklocal, uniquelocal. Empty trusts none, so every request
|
|
48
|
+
# is keyed to the socket peer. Behind a reverse proxy, list the proxy, or every
|
|
49
|
+
# caller shares one rate-limit budget (specs/017).
|
|
50
|
+
TRUST_PROXY=
|
|
51
|
+
|
|
52
|
+
# Validates inbound Dynamic Block requests. Comma-separate two values during
|
|
53
|
+
# rotation so ManyChat flows need no downtime (ADR-0012); SECURITY.md has the
|
|
54
|
+
# steps, including the 24-hour wait before removing the old one. Minimum 16 chars.
|
|
55
|
+
# openssl rand -hex 32
|
|
56
|
+
MANYCHAT_SHARED_SECRET=change-me-to-a-long-random-string
|
|
57
|
+
|
|
58
|
+
# Required for the deferred push when the model loses the race.
|
|
59
|
+
MANYCHAT_API_TOKEN=
|
|
60
|
+
MANYCHAT_API_BASE=https://api.manychat.com
|
|
61
|
+
|
|
62
|
+
# Deferred delivery is two calls: write the reply to a per-subscriber custom
|
|
63
|
+
# field, then trigger a flow that renders it (specs/002). Both name objects that
|
|
64
|
+
# live in the ManyChat account, so renaming them there breaks delivery silently.
|
|
65
|
+
MANYCHAT_REPLY_FIELD=ai_message
|
|
66
|
+
MANYCHAT_REPLY_FLOW_NS=
|
|
67
|
+
# The pause between two messages of a deferred reply, and between two replies
|
|
68
|
+
# to one contact. A flow reads the field when its message step runs, which can
|
|
69
|
+
# be after the trigger has returned, so the next message must not overwrite it
|
|
70
|
+
# sooner. Never pace with a delay before that step inside the flow: the field
|
|
71
|
+
# would be overwritten while it waits (specs/002).
|
|
72
|
+
MANYCHAT_REPLY_GAP_MS=2000
|
|
73
|
+
|
|
74
|
+
# Each contact's token lives in this custom field, and the Dynamic Block body
|
|
75
|
+
# sends it back as "ai_token": "{{ai_token}}". Only a request carrying it reads
|
|
76
|
+
# the contact's history (specs/019, ADR-0012).
|
|
77
|
+
MANYCHAT_TOKEN_FIELD=ai_token
|
|
78
|
+
# false only during the rollout in specs/019, while tokens reach existing
|
|
79
|
+
# contacts; a request without the token then reads history as it used to.
|
|
80
|
+
CONTACT_TOKENS_ENFORCED=true
|
|
81
|
+
|
|
82
|
+
# ---- Storage ----------------------------------------------------------------
|
|
83
|
+
DATABASE_URL=postgres://agent:agent@localhost:5432/agent
|
|
84
|
+
TENANT_ID=demo
|
|
85
|
+
|
|
86
|
+
# ---- Timeouts ---------------------------------------------------------------
|
|
87
|
+
# ManyChat hard-terminates at 10000ms, so the race deadline sits below it.
|
|
88
|
+
RACE_DEADLINE_MS=8000
|
|
89
|
+
# MODEL_ABORT_MS must be GREATER than RACE_DEADLINE_MS. Losing the race does not
|
|
90
|
+
# cancel the model call - it keeps running and delivers via the outbox - so this
|
|
91
|
+
# is the outer safety net that stops a runaway call, not the race timeout.
|
|
92
|
+
MODEL_ABORT_MS=30000
|
|
93
|
+
|
|
94
|
+
# ---- Runtime ----------------------------------------------------------------
|
|
95
|
+
NODE_ENV=development
|
|
96
|
+
PORT=3000
|
|
97
|
+
LOG_LEVEL=info
|
|
98
|
+
|
|
99
|
+
# ---- Evals (tooling, not runtime) -------------------------------------------
|
|
100
|
+
# Not part of EnvSchema: nothing validates these, and a typo silently falls back
|
|
101
|
+
# to the default rather than failing at boot.
|
|
102
|
+
#
|
|
103
|
+
# Left commented deliberately. `pnpm eval` loads this file via --env-file, so an
|
|
104
|
+
# uncommented value here would pin every run and defeat the per-invocation form:
|
|
105
|
+
# EVAL_DIR=evals/tenant CONFIG_DIR=config pnpm eval
|
|
106
|
+
#
|
|
107
|
+
# Which suite to replay (specs/009-tenant-eval-suites.md). A tenant deploying
|
|
108
|
+
# from a private overlay keeps its own cases out of the framework's set.
|
|
109
|
+
# EVAL_DIR=evals/golden
|
|
110
|
+
#
|
|
111
|
+
# Which tenant config the suite runs against. CI points this at the fixtures.
|
|
112
|
+
# `pnpm dev` reads it too, so a bug can be reproduced against the fixture tenant
|
|
113
|
+
# (specs/021-contributor-surface.md).
|
|
114
|
+
# CONFIG_DIR=config
|
|
115
|
+
#
|
|
116
|
+
# How slow a reply may be before the suite fails it. Defaults to
|
|
117
|
+
# RACE_DEADLINE_MS. Raise it here rather than raising RACE_DEADLINE_MS: that one
|
|
118
|
+
# is bounded by ManyChat's 10s cutoff and governs the live race, whereas a turn
|
|
119
|
+
# that merely overruns it in an eval is not wrong - it is deferred to the outbox
|
|
120
|
+
# and delivered. A tenant on a reasoning model wants a looser figure for the
|
|
121
|
+
# suite without touching the request path.
|
|
122
|
+
# EVAL_MAX_LATENCY_MS=30000
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"businessName": "Demo Academy",
|
|
3
|
+
"currency": "USD",
|
|
4
|
+
"courses": [
|
|
5
|
+
{
|
|
6
|
+
"id": "foundation",
|
|
7
|
+
"name": "Foundation Course",
|
|
8
|
+
"description": "Start from zero. No previous experience required.",
|
|
9
|
+
"price": { "amount": 45000, "currency": "USD" },
|
|
10
|
+
"durationHours": 24,
|
|
11
|
+
"schedule": "Tuesdays and Thursdays, 6-9pm, for 4 weeks",
|
|
12
|
+
"enrollmentUrl": "https://example.com/enrol/foundation"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "advanced",
|
|
16
|
+
"name": "Advanced Course",
|
|
17
|
+
"description": "For anyone who finished the foundation course or has equivalent experience.",
|
|
18
|
+
"price": { "amount": 68000, "currency": "USD" },
|
|
19
|
+
"durationHours": 32,
|
|
20
|
+
"schedule": "Saturdays, 10am-4pm, for 5 weeks",
|
|
21
|
+
"enrollmentUrl": "https://example.com/enrol/advanced"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "weekend-intensive",
|
|
25
|
+
"name": "Weekend Intensive",
|
|
26
|
+
"description": "The foundation course condensed into two full days.",
|
|
27
|
+
"price": { "amount": 32000, "currency": "USD" },
|
|
28
|
+
"durationHours": 12,
|
|
29
|
+
"schedule": "One weekend a month, Saturday and Sunday 10am-4pm",
|
|
30
|
+
"enrollmentUrl": "https://example.com/enrol/weekend-intensive"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"faq": [
|
|
34
|
+
{
|
|
35
|
+
"question": "Do you give a certificate?",
|
|
36
|
+
"answer": "Yes, you receive a certificate of attendance when you finish."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"question": "Are materials included?",
|
|
40
|
+
"answer": "The practice kit is included with every course."
|
|
41
|
+
},
|
|
42
|
+
{ "question": "Where are you located?", "answer": "Classes are in person at the main campus." },
|
|
43
|
+
{ "question": "Do you run online classes?", "answer": "All courses are in person for now." },
|
|
44
|
+
{
|
|
45
|
+
"question": "Do you guarantee I will get a job?",
|
|
46
|
+
"answer": "We don't guarantee job placement. The certificate is recognised by local salons."
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"paymentOptions": [
|
|
50
|
+
{
|
|
51
|
+
"id": "instalments",
|
|
52
|
+
"description": "Any course can be paid in three equal monthly instalments, with no surcharge."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "deposit",
|
|
56
|
+
"description": "A $120 deposit holds a place; the rest is due before the first class."
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
You are the front desk for {{businessName}}, a professional training academy.
|
|
2
|
+
You answer people who message on WhatsApp asking about the courses.
|
|
3
|
+
|
|
4
|
+
LANGUAGE
|
|
5
|
+
Reply in the same language the contact writes in. Default to English.
|
|
6
|
+
|
|
7
|
+
TONE
|
|
8
|
+
Warm and direct, like someone on the team who knows the offering well.
|
|
9
|
+
Short sentences. Don't oversell, don't sound like a brochure.
|
|
10
|
+
No emoji in every message: at most one, and only when it adds something.
|
|
11
|
+
|
|
12
|
+
WHAT YOU DO
|
|
13
|
+
Answer questions about prices, schedules, duration, format and how to enrol,
|
|
14
|
+
always taking the facts from the CATALOG. Help the contact find the course that
|
|
15
|
+
fits them and, once they have what they need, ask them to enrol.
|
|
16
|
+
|
|
17
|
+
WHAT YOU DON'T DO
|
|
18
|
+
Don't negotiate prices or offer discounts.
|
|
19
|
+
Don't invent dates, availability, deadlines or promotions.
|
|
20
|
+
Don't push: one question at a time, and a "not now" is respected.
|
|
21
|
+
Don't promise job outcomes.
|
|
22
|
+
Don't ask for sensitive details (ID numbers, card details, home address).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"messages": {
|
|
3
|
+
"acknowledgement": "Give me one second while I check that.",
|
|
4
|
+
"escalation": "Let me put you through to someone on the team who can give you the exact answer. They'll reply here shortly.",
|
|
5
|
+
"mediaFallback": "I couldn't make out that message. Could you type your question for me?",
|
|
6
|
+
"closer": "You're welcome! Write to me here whenever you need anything else."
|
|
7
|
+
},
|
|
8
|
+
"confidenceThreshold": 0.6,
|
|
9
|
+
"maxTurnsPerConversation": 25,
|
|
10
|
+
"idleResetHours": 24,
|
|
11
|
+
"historyDays": 30,
|
|
12
|
+
"escalationKeywords": [
|
|
13
|
+
"speak to a human",
|
|
14
|
+
"talk to someone",
|
|
15
|
+
"complaint",
|
|
16
|
+
"refund",
|
|
17
|
+
"money back",
|
|
18
|
+
"scam"
|
|
19
|
+
],
|
|
20
|
+
"openingTrigger": {
|
|
21
|
+
"keywords": ["start workflow"],
|
|
22
|
+
"message": "Hi! Would you like me to send you the course prices?"
|
|
23
|
+
},
|
|
24
|
+
"budget": {
|
|
25
|
+
"dailyTokenCap": 1000000,
|
|
26
|
+
"dailyCostCapUsd": 5
|
|
27
|
+
},
|
|
28
|
+
"rateLimit": {
|
|
29
|
+
"turnsPerSubscriberPerHour": 60
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "manychat-ai-agent",
|
|
3
|
+
"version": "0.18.0",
|
|
4
|
+
"description": "A provider-agnostic conversational AI agent gateway that bridges no-code chat platforms to any LLM.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/pedronastasi/manychat-ai-agent.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"agent": "dist/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./config": {
|
|
20
|
+
"types": "./dist/config/index.d.ts",
|
|
21
|
+
"default": "./dist/config/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./testing": {
|
|
24
|
+
"types": "./dist/testing/index.d.ts",
|
|
25
|
+
"default": "./dist/testing/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist/",
|
|
30
|
+
"db/migrations/",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE",
|
|
33
|
+
"CHANGELOG.md"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22"
|
|
37
|
+
},
|
|
38
|
+
"packageManager": "pnpm@12.9.1",
|
|
39
|
+
"scripts": {
|
|
40
|
+
"dev": "node --experimental-strip-types --disable-warning=ExperimentalWarning --env-file=.env --watch src/main.ts",
|
|
41
|
+
"bootstrap": "node scripts/setup.mjs",
|
|
42
|
+
"spec:index": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/spec-index.ts",
|
|
43
|
+
"docs:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/export-openapi.ts",
|
|
44
|
+
"docs:build": "pnpm docs:api && vitepress build",
|
|
45
|
+
"docs:dev": "pnpm docs:api && vitepress dev",
|
|
46
|
+
"docs:preview": "vitepress preview",
|
|
47
|
+
"demo:record": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/record-demo.ts",
|
|
48
|
+
"build": "tsc --project tsconfig.build.json",
|
|
49
|
+
"start": "node dist/main.js",
|
|
50
|
+
"typecheck": "tsc --noEmit",
|
|
51
|
+
"lint": "eslint .",
|
|
52
|
+
"format": "prettier --write .",
|
|
53
|
+
"format:check": "prettier --check .",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"test:coverage": "vitest run --coverage",
|
|
56
|
+
"test:watch": "vitest",
|
|
57
|
+
"simulate": "node --experimental-strip-types --disable-warning=ExperimentalWarning --env-file=.env src/channels/manychat/simulator.ts",
|
|
58
|
+
"eval": "node --experimental-strip-types --disable-warning=ExperimentalWarning --env-file=.env evals/run.ts",
|
|
59
|
+
"eval:mock": "node --experimental-strip-types --disable-warning=ExperimentalWarning evals/run.ts",
|
|
60
|
+
"tokens:backfill": "node --experimental-strip-types --disable-warning=ExperimentalWarning --env-file=.env src/backfill.ts",
|
|
61
|
+
"db:generate": "drizzle-kit generate",
|
|
62
|
+
"db:migrate": "drizzle-kit migrate",
|
|
63
|
+
"worker": "node --experimental-strip-types --disable-warning=ExperimentalWarning --env-file=.env src/outbox/worker.ts"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@ai-sdk/anthropic": "^4.0.53",
|
|
67
|
+
"@ai-sdk/google": "^4.0.69",
|
|
68
|
+
"@ai-sdk/openai": "^4.0.66",
|
|
69
|
+
"@ai-sdk/provider": "4.0.22",
|
|
70
|
+
"@fastify/helmet": "^13.1.1",
|
|
71
|
+
"@fastify/rate-limit": "^11.2.0",
|
|
72
|
+
"@fastify/swagger": "^9.8.1",
|
|
73
|
+
"@fastify/under-pressure": "^9.1.0",
|
|
74
|
+
"ai": "^7.0.100",
|
|
75
|
+
"drizzle-orm": "^0.45.2",
|
|
76
|
+
"fastify": "^5.12.4",
|
|
77
|
+
"fastify-type-provider-zod": "^7.0.0",
|
|
78
|
+
"manychat-sdk": "^0.1.0",
|
|
79
|
+
"pino": "^10.4.0",
|
|
80
|
+
"postgres": "^3.4.9",
|
|
81
|
+
"zod": "^4.6.5"
|
|
82
|
+
},
|
|
83
|
+
"peerDependencies": {
|
|
84
|
+
"@electric-sql/pglite": "^0.5.8"
|
|
85
|
+
},
|
|
86
|
+
"peerDependenciesMeta": {
|
|
87
|
+
"@electric-sql/pglite": {
|
|
88
|
+
"optional": true
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"devDependencies": {
|
|
92
|
+
"@electric-sql/pglite": "^0.5.8",
|
|
93
|
+
"@eslint/js": "^10.0.1",
|
|
94
|
+
"@types/node": "^24.0.0",
|
|
95
|
+
"@vitest/coverage-v8": "5.0.3",
|
|
96
|
+
"drizzle-kit": "^0.31.10",
|
|
97
|
+
"eslint": "^10.10.0",
|
|
98
|
+
"mermaid": "^12.0.0",
|
|
99
|
+
"prettier": "^3.9.6",
|
|
100
|
+
"typescript": "~6.0.0",
|
|
101
|
+
"typescript-eslint": "^8.70.0",
|
|
102
|
+
"vitepress": "^1.6.4",
|
|
103
|
+
"vitest": "^5.0.0",
|
|
104
|
+
"vue": "^3.5.43",
|
|
105
|
+
"yaml": "^2.9.1"
|
|
106
|
+
}
|
|
107
|
+
}
|
package/demo-tenant.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fictional demo tenant, as both `pnpm bootstrap` and `create` start from
|
|
3
|
+
* it (specs/035). One list and one set of edits, so the two cannot drift into a
|
|
4
|
+
* second copy of the example tenant.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The committed examples a first run copies, and the names they take. */
|
|
8
|
+
export const EXAMPLE_CONFIG = [
|
|
9
|
+
['config/prompt.md.example', 'config/prompt.md'],
|
|
10
|
+
['config/catalog.json.example', 'config/catalog.json'],
|
|
11
|
+
['config/rules.json.example', 'config/rules.json'],
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/** Each line of `.env.example` an offline first run replaces, and with what. */
|
|
15
|
+
const OFFLINE = [
|
|
16
|
+
['MANYCHAT_SHARED_SECRET=change-me-to-a-long-random-string', 'MANYCHAT_SHARED_SECRET=<secret>'],
|
|
17
|
+
// A runnable offline setup: no API key, no database to install.
|
|
18
|
+
['AGENT_MODEL=anthropic:claude-haiku-4-5', 'AGENT_MODEL=mock:demo'],
|
|
19
|
+
['DATABASE_URL=postgres://agent:agent@localhost:5432/agent', 'DATABASE_URL=pglite'],
|
|
20
|
+
// With no ManyChat account there is no field to hold a contact's token, so
|
|
21
|
+
// `simulate` could never present one and would get no history.
|
|
22
|
+
['CONTACT_TOKENS_ENFORCED=true', 'CONTACT_TOKENS_ENFORCED=false'],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `.env.example` with the offline defaults. Throws when a line it replaces is
|
|
27
|
+
* gone, rather than writing a `.env` that silently needs an API key.
|
|
28
|
+
*/
|
|
29
|
+
export function offlineEnv(example, secret) {
|
|
30
|
+
let env = example;
|
|
31
|
+
for (const [line, replacement] of OFFLINE) {
|
|
32
|
+
if (!env.includes(line)) throw new Error(`.env.example no longer contains: ${line}`);
|
|
33
|
+
env = env.replace(line, replacement.replace('<secret>', secret));
|
|
34
|
+
}
|
|
35
|
+
return env;
|
|
36
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `npm create manychat-ai-agent@latest my-agent` (specs/035).
|
|
4
|
+
*
|
|
5
|
+
* Generates a tenant project from the fictional demo tenant and nothing else.
|
|
6
|
+
* Never from a live deployment, and never over an existing directory.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { EXAMPLE_CONFIG, offlineEnv } from './demo-tenant.mjs';
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where the demo tenant is read from. Published, `prepack` has copied it into
|
|
18
|
+
* `agent/`; in this repository it is the root, so the scaffolder and
|
|
19
|
+
* `pnpm bootstrap` read the same committed files (C1, C9).
|
|
20
|
+
*/
|
|
21
|
+
const agentRoot = existsSync(join(here, 'agent')) ? join(here, 'agent') : join(here, '..', '..');
|
|
22
|
+
|
|
23
|
+
const { version } = JSON.parse(readFileSync(join(here, 'package.json'), 'utf8'));
|
|
24
|
+
const agent = JSON.parse(readFileSync(join(agentRoot, 'package.json'), 'utf8'));
|
|
25
|
+
|
|
26
|
+
const target = process.argv[2];
|
|
27
|
+
if (target === undefined || target.startsWith('-')) {
|
|
28
|
+
console.error('Usage: npm create manychat-ai-agent@latest <directory>');
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const root = resolve(target);
|
|
33
|
+
const name = basename(root);
|
|
34
|
+
// The name becomes the package name and the eval suite's directory.
|
|
35
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) {
|
|
36
|
+
console.error(`"${name}" is not a valid name: use lowercase letters, digits, ".", "_" or "-".`);
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
if (existsSync(root) && readdirSync(root).length > 0) {
|
|
40
|
+
console.error(`${root} is not empty. Nothing was written.`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const template = file => readFileSync(join(here, 'template', file), 'utf8');
|
|
45
|
+
|
|
46
|
+
function write(path, content) {
|
|
47
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
48
|
+
writeFileSync(join(root, path), content);
|
|
49
|
+
console.log(` created ${path}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const [from, to] of EXAMPLE_CONFIG) {
|
|
53
|
+
write(to, readFileSync(join(agentRoot, from)));
|
|
54
|
+
}
|
|
55
|
+
write(
|
|
56
|
+
'.env',
|
|
57
|
+
offlineEnv(
|
|
58
|
+
readFileSync(join(agentRoot, '.env.example'), 'utf8'),
|
|
59
|
+
randomBytes(32).toString('hex'),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
write(`evals/${name}/cases.jsonl`, template('cases.jsonl'));
|
|
63
|
+
write(
|
|
64
|
+
'package.json',
|
|
65
|
+
`${JSON.stringify(
|
|
66
|
+
{
|
|
67
|
+
name,
|
|
68
|
+
// config/ is real tenant data once the tenant replaces the demo, so this
|
|
69
|
+
// project is never published.
|
|
70
|
+
private: true,
|
|
71
|
+
type: 'module',
|
|
72
|
+
packageManager: agent.packageManager,
|
|
73
|
+
scripts: {
|
|
74
|
+
check: 'agent config check',
|
|
75
|
+
eval: `EVAL_DIR=evals/${name} agent eval`,
|
|
76
|
+
test: 'vitest run --passWithNoTests',
|
|
77
|
+
},
|
|
78
|
+
dependencies: { 'manychat-ai-agent': `^${version}` },
|
|
79
|
+
devDependencies: {
|
|
80
|
+
// The embedded database the offline .env selects; the agent leaves it
|
|
81
|
+
// to the tenant as an optional peer, so the image does not carry it.
|
|
82
|
+
'@electric-sql/pglite': agent.peerDependencies['@electric-sql/pglite'],
|
|
83
|
+
vitest: agent.devDependencies.vitest,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
null,
|
|
87
|
+
2,
|
|
88
|
+
)}\n`,
|
|
89
|
+
);
|
|
90
|
+
write('docker-compose.yml', template('docker-compose.yml').replaceAll('{{version}}', version));
|
|
91
|
+
write('.github/workflows/ci.yml', template('ci.yml'));
|
|
92
|
+
write('renovate.json', template('renovate.json'));
|
|
93
|
+
write('.gitignore', template('gitignore'));
|
|
94
|
+
|
|
95
|
+
console.log(`
|
|
96
|
+
Ready. Next:
|
|
97
|
+
|
|
98
|
+
cd ${target}
|
|
99
|
+
pnpm install
|
|
100
|
+
pnpm check && pnpm eval
|
|
101
|
+
|
|
102
|
+
config/ is the fictional demo tenant: replace it with your own. Keep this
|
|
103
|
+
repository private; its CI fails when it is public.
|
|
104
|
+
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-manychat-ai-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.18.0",
|
|
4
|
+
"description": "Scaffolds a manychat-ai-agent tenant project from the fictional demo tenant.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/pedronastasi/manychat-ai-agent.git",
|
|
9
|
+
"directory": "packages/create"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"create-manychat-ai-agent": "index.mjs"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.mjs",
|
|
17
|
+
"demo-tenant.mjs",
|
|
18
|
+
"template/",
|
|
19
|
+
"README.md",
|
|
20
|
+
"agent/"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"prepack": "node pack-agent.mjs",
|
|
27
|
+
"postpack": "node pack-agent.mjs --clean"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{"id": "greeting", "text": "hello!", "expect": {"escalate": false}}
|
|
2
|
+
{"id": "price", "text": "how much is the foundation course?", "expect": {"escalate": false}, "must_not_invent_prices": true}
|
|
3
|
+
{"id": "schedule", "text": "what days are the foundation classes?", "expect": {"escalate": false}}
|
|
4
|
+
{"id": "discount", "text": "can you give me a discount?", "expect": {"escalate": true, "reason": "price_negotiation"}}
|
|
5
|
+
{"id": "human", "text": "can i speak to a human?", "expect": {"escalate": true}}
|
package/template/ci.yml
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push: { branches: [main] }
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
verify:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
# config/ is this tenant's prompt, prices and schedule, tracked because
|
|
15
|
+
# the repository is private. Made public, it fails here on the next push
|
|
16
|
+
# rather than after it has been indexed (specs/035).
|
|
17
|
+
- name: Repository is private
|
|
18
|
+
env:
|
|
19
|
+
PRIVATE: ${{ github.event.repository.private }}
|
|
20
|
+
run: |
|
|
21
|
+
if [ "$PRIVATE" != "true" ]; then
|
|
22
|
+
echo "::error::This repository is public, and config/ holds tenant data. Make it private."
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
- uses: actions/checkout@v7
|
|
27
|
+
- uses: pnpm/action-setup@v6
|
|
28
|
+
- uses: actions/setup-node@v7
|
|
29
|
+
with: { node-version: 24, cache: pnpm }
|
|
30
|
+
|
|
31
|
+
- run: pnpm install --frozen-lockfile
|
|
32
|
+
|
|
33
|
+
# The validation the server runs at startup, so a typo in config/ fails
|
|
34
|
+
# this pull request rather than the container at boot. Placeholder
|
|
35
|
+
# environment: the check parses it and connects to nothing.
|
|
36
|
+
- name: Config check
|
|
37
|
+
run: pnpm check
|
|
38
|
+
env:
|
|
39
|
+
AGENT_MODEL: mock:demo
|
|
40
|
+
PUBLIC_BASE_URL: https://ci.example.com
|
|
41
|
+
MANYCHAT_SHARED_SECRET: ci-secret-ci-secret-ci-secret-xx
|
|
42
|
+
DATABASE_URL: pglite
|
|
43
|
+
|
|
44
|
+
# The eval suite against the mock model: free and deterministic. A run
|
|
45
|
+
# against a real model costs money and stays a deliberate local step.
|
|
46
|
+
- name: Eval (mock model)
|
|
47
|
+
run: pnpm eval
|
|
48
|
+
env:
|
|
49
|
+
AGENT_MODEL: mock:demo
|
|
50
|
+
PUBLIC_BASE_URL: https://ci.example.com
|
|
51
|
+
MANYCHAT_SHARED_SECRET: ci-secret-ci-secret-ci-secret-xx
|
|
52
|
+
DATABASE_URL: pglite
|
|
53
|
+
|
|
54
|
+
- name: Tenant tests
|
|
55
|
+
run: pnpm test
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:18-alpine
|
|
4
|
+
environment:
|
|
5
|
+
POSTGRES_USER: agent
|
|
6
|
+
POSTGRES_PASSWORD: agent
|
|
7
|
+
POSTGRES_DB: agent
|
|
8
|
+
volumes: ['pgdata:/var/lib/postgresql']
|
|
9
|
+
healthcheck:
|
|
10
|
+
test: ['CMD-SHELL', 'pg_isready -U agent']
|
|
11
|
+
interval: 5s
|
|
12
|
+
timeout: 3s
|
|
13
|
+
retries: 10
|
|
14
|
+
|
|
15
|
+
agent:
|
|
16
|
+
# The published agent, at the version package.json depends on. Renovate
|
|
17
|
+
# moves both in one pull request.
|
|
18
|
+
image: ghcr.io/pedronastasi/manychat-ai-agent:{{version}}
|
|
19
|
+
depends_on:
|
|
20
|
+
postgres: { condition: service_healthy }
|
|
21
|
+
environment:
|
|
22
|
+
DATABASE_URL: postgres://agent:agent@postgres:5432/agent
|
|
23
|
+
# The offline .env turns contact tokens off so `simulate` gets history.
|
|
24
|
+
# A deployment never runs that way: these override .env (specs/019).
|
|
25
|
+
CONTACT_TOKENS_ENFORCED: 'true'
|
|
26
|
+
PORT: 3000
|
|
27
|
+
# A directory, never single files: an editor that replaces a file leaves a
|
|
28
|
+
# single-file mount showing the old one. SIGHUP reloads it.
|
|
29
|
+
volumes: ['./config:/app/config:ro']
|
|
30
|
+
# Credentials come from .env, never from this file.
|
|
31
|
+
env_file: [.env]
|
|
32
|
+
ports: ['3000:3000']
|
|
33
|
+
restart: unless-stopped
|
|
34
|
+
|
|
35
|
+
volumes:
|
|
36
|
+
pgdata:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
3
|
+
"extends": ["config:recommended", ":semanticCommits"],
|
|
4
|
+
"packageRules": [
|
|
5
|
+
{
|
|
6
|
+
"description": "The agent's npm package and its image are one version: CI checks the package, docker-compose.yml runs the image. A release arrives as one pull request moving both, never automerged.",
|
|
7
|
+
"matchPackageNames": ["manychat-ai-agent", "ghcr.io/pedronastasi/manychat-ai-agent"],
|
|
8
|
+
"groupName": "manychat-ai-agent",
|
|
9
|
+
"automerge": false
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|