create-curb.js 0.0.0 → 1.15.0-preview.1
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/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +14 -0
- package/RELEASE_NOTES.md +17 -0
- package/bin/create-curb.js +544 -0
- package/bin/install-scripts.js +39 -0
- package/bin/node-version.js +48 -0
- package/bin/npm-command.js +10 -0
- package/bin/scaffold-args.js +5 -0
- package/package.json +26 -3
- package/templates/default/.mcp.json +6 -0
- package/templates/default/AGENTS.md +160 -0
- package/templates/default/CLAUDE.md +1 -0
- package/templates/default/README.md +60 -0
- package/templates/default/context/about-you.md +13 -0
- package/templates/default/env.example +16 -0
- package/templates/default/gitignore +15 -0
- package/templates/default/package.json +33 -0
- package/templates/default/src/agent/about-you/skill.tsx +52 -0
- package/templates/default/src/agent/agent.tsx +223 -0
- package/templates/default/src/agent/how-this-works/skill.tsx +36 -0
- package/templates/default/tsconfig.json +13 -0
package/package.json
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-curb.js",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.15.0-preview.1",
|
|
4
|
+
"description": "Create a Curb voice agent: npm create curb.js my-agent",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"
|
|
6
|
+
"homepage": "https://curbjs.com",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Kugelaudio/curb.js.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.12.0"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"create-curb.js": "./bin/create-curb.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"templates",
|
|
21
|
+
"NOTICE",
|
|
22
|
+
"RELEASE_NOTES.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test \"test/**/*.test.mjs\""
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"picocolors": "^1.1.1"
|
|
29
|
+
}
|
|
7
30
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
This is a **curb.js** voice agent. React renders the agent's context (its prompt, knowledge
|
|
4
|
+
and tools) instead of HTML, and the framework runs the call: speech, turn-taking,
|
|
5
|
+
interruptions. You edit `src/agent/` and `context/`; components come from `"curb"`, the
|
|
6
|
+
providers from `"curb/providers"`. The project starts as Juno, a demo assistant: replace
|
|
7
|
+
her prompt, skills and tools with the user's agent and delete what it does not need.
|
|
8
|
+
|
|
9
|
+
The agent runs on KugelAudio: its EU-hosted LLM (`kugelaudio.llm()`), Kugel Ear and Kugel TTS.
|
|
10
|
+
Sign in with `npx curb login` in this folder: it opens the browser once to sign in, and
|
|
11
|
+
every curb command here then runs on that login (`--no-open` only prints the link, for a
|
|
12
|
+
machine without a browser). There is no key to copy; never ask for a key in the chat. A `KUGELAUDIO_API_KEY` the user sets in `.env` or the environment (an
|
|
13
|
+
API key from the dashboard, for CI or a server) also works and wins over the login. Another
|
|
14
|
+
LLM (`anthropic()`, `openai()`, `openaiCompatible()`) is one `llm` prop away and brings its
|
|
15
|
+
own key. A turn answered 403 `LLM_NOT_ENABLED` means the user's organization does not have
|
|
16
|
+
KugelAudio's LLM yet (it comes with hosted agents, in closed alpha): they ask
|
|
17
|
+
hello@kugelaudio.com, or the agent uses another LLM meanwhile.
|
|
18
|
+
|
|
19
|
+
**Without a shell** (claude.ai, the Claude desktop chat), or when the person asks for it, build
|
|
20
|
+
in the agent's **browser project**: the platform keeps the project, the person's own browser
|
|
21
|
+
tab runs it, and you work through the `kugelaudio` MCP server's tools. Run no command and ask
|
|
22
|
+
the person to run none.
|
|
23
|
+
|
|
24
|
+
1. `agent_list` finds the agent, or `agent_create` makes one named for what it does. `agent_open`
|
|
25
|
+
answers its session link: the person opens it beside the chat and keeps that tab open.
|
|
26
|
+
Every tool below but `agent_publish` runs in the tab, and a closed tab is refused with the
|
|
27
|
+
link to send.
|
|
28
|
+
2. `agent_overview` shows the project. Read with `agent_read_file`, `agent_list_files` and
|
|
29
|
+
`agent_search`; change it with `agent_edit_file`, `agent_write_file` and `agent_delete_file`.
|
|
30
|
+
Read `reload` in each change's answer and fix `failed` diagnostics first.
|
|
31
|
+
3. `agent_add_dependency` and `agent_remove_dependency` change the packages. One that needs
|
|
32
|
+
native addons, a shell or child processes does not run in the browser.
|
|
33
|
+
4. `agent_request_secret` makes the tab ask the person for a key. It stays in that tab, and
|
|
34
|
+
`agent_publish` does not deploy it; say so when the live agent needs the key too.
|
|
35
|
+
5. After every change run `agent_check`, then `agent_say` with what a caller says
|
|
36
|
+
(`new_conversation` after a code change). `agent_get_preview_log` shows what went wrong,
|
|
37
|
+
`agent_list_preview_conversations` and `agent_get_preview_conversation` what the person
|
|
38
|
+
said in the tab, and `agent_restart_preview` unsticks a preview.
|
|
39
|
+
6. `agent_publish` puts it live. Run it only when the person asks.
|
|
40
|
+
|
|
41
|
+
With a shell, the rest of this file applies as it stands.
|
|
42
|
+
|
|
43
|
+
**Say what you do.** Before each step (creating the project, installing, checking, signing
|
|
44
|
+
in, deploying), tell the user in one plain sentence what it does and what, if anything, they
|
|
45
|
+
have to do, such as open a sign-in link. They may never have used a terminal.
|
|
46
|
+
|
|
47
|
+
A new project is created with `npm create curb.js my-agent`, and you then work in
|
|
48
|
+
`my-agent` (`npm create curb.js .` fills the current empty folder instead).
|
|
49
|
+
|
|
50
|
+
The full guide ships in `node_modules/curb/docs/agent-guide/`: read `README.md` first, then
|
|
51
|
+
`what-the-model-receives.md`, `agents-and-skills.md`, `tools.md`, `state.md` and `testing.md`.
|
|
52
|
+
There is one page per task, such as `human-handoff.md` and `confirmations.md`;
|
|
53
|
+
`api-reference.md` leads to every export, and `improve-from-calls.md` is the way to fix an
|
|
54
|
+
agent from a call that went wrong.
|
|
55
|
+
A fault in Curb itself is reported as `reporting-a-bug.md` says, with the person's yes first.
|
|
56
|
+
|
|
57
|
+
## Verify every change
|
|
58
|
+
|
|
59
|
+
1. `npx curb check`: type-checks `src/`, renders every skill and prints warnings. Fix every
|
|
60
|
+
error and every warning; each one names a file, a line and the fix.
|
|
61
|
+
2. `npx curb say "<what the caller says>"`: one spoken turn, headless. It prints the reply,
|
|
62
|
+
each tool call and the context it changed. The next `curb say` continues the
|
|
63
|
+
conversation; `npx curb say --new "<first line>"` starts over (do that after a code change).
|
|
64
|
+
3. `npx curb test`: runs every `src/**/*.scenario.ts` conversation headlessly, with scripted
|
|
65
|
+
caller lines or a simulated caller pursuing a goal, and fails on turn errors, repeated
|
|
66
|
+
sentences or a goal not reached. Keep one scenario per flow that matters (`testing.md`).
|
|
67
|
+
|
|
68
|
+
4. `npm run dev`: the browser preview with chat and voice on http://127.0.0.1:4000. Open it in the person's own browser yourself (`open http://127.0.0.1:4000` on macOS, `xdg-open` on Linux, `start` on Windows): a browser built into an app, like the Claude app's Browser pane, blocks the microphone, so a call there fails.
|
|
69
|
+
|
|
70
|
+
**To improve the agent, read its recent conversations first.** `npx curb conversations`
|
|
71
|
+
prints the last ones the preview and `curb say` ran, with every tool call, its
|
|
72
|
+
input and its result (`--last <n>`, `--id <id>`, `--json`). Calls to the hosted agent are
|
|
73
|
+
read with the `kugelaudio` MCP server's `agent_list_calls`, `agent_get_call` and
|
|
74
|
+
`agent_get_call_timeline` where it is signed in. Find where the agent went wrong, fix that,
|
|
75
|
+
and turn the conversation into a scenario so it stays fixed.
|
|
76
|
+
|
|
77
|
+
## The preview
|
|
78
|
+
|
|
79
|
+
It shows what the agent sees: its prompt, the skills it has loaded and the tools it can
|
|
80
|
+
call. You change the agent by editing code, never in the preview; a tool's form there only
|
|
81
|
+
tries the tool, and `<Tool tryWith={{ ... }}>` fills it with values your demo data knows.
|
|
82
|
+
**Copy for coding agent** copies a conversation with the skill map, so you can work from
|
|
83
|
+
what really happened.
|
|
84
|
+
|
|
85
|
+
## Map
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
src/agent/agent.tsx the agent, where every conversation starts: <Agent llm stt tts>,
|
|
89
|
+
system prompt, skills index, tools every skill has, store
|
|
90
|
+
src/agent/<name>/skill.tsx one folder per skill, opened when needed; nested folders are sub-skills
|
|
91
|
+
context/*.md knowledge a skill reads with <Text src="file.md" />
|
|
92
|
+
.env other providers' keys, an optional KUGELAUDIO_API_KEY; never commit it
|
|
93
|
+
.data/conversations.jsonl every local conversation: tool calls, inputs, results
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Rules
|
|
97
|
+
|
|
98
|
+
- **Prose is one template literal**: ``<Instruction>{`...`}</Instruction>``, the same in
|
|
99
|
+
`<SystemPrompt>` and `<Text>`. Paragraphs and `- ` lists reach the model; multi-line JSX
|
|
100
|
+
text is joined into one line.
|
|
101
|
+
- **A skill's description is written once**, as `meta.description` in its `skill.tsx`,
|
|
102
|
+
phrased as a routing rule ("Use for ...", "NOT for ..."). Its `<Skill>` link repeats none
|
|
103
|
+
(a `description` on the link works too, but keep one).
|
|
104
|
+
- **No per-call state at module scope**: one runner serves several calls. Keep a call's
|
|
105
|
+
state in a `createAgentStore` store, never in a module-level variable.
|
|
106
|
+
- **Secrets** come from `.env` locally and `npx curb secrets push` when hosted. Read them
|
|
107
|
+
in tool handlers (`process.env.NAME`); never render them into a prompt.
|
|
108
|
+
- **A key never goes into a tool call.** Never paste one into a command line, a chat message
|
|
109
|
+
or an MCP tool's arguments (`agent_set_secrets` included). The user puts it in `.env`
|
|
110
|
+
themselves, and `npx curb secrets push` sends it to the hosted agent. A hosted agent gets
|
|
111
|
+
`KUGELAUDIO_API_KEY` from KugelAudio, so the default agent has nothing to push.
|
|
112
|
+
- **No hard-coded names or dates.** The framework tells the model today's date on every
|
|
113
|
+
turn; set `defineAgent({ timeZone })` to the callers' zone (default UTC). Examples use
|
|
114
|
+
placeholders such as "Max Mustermann" or "the caller's name".
|
|
115
|
+
- **The opening says it is an AI**: the agent's first line tells the caller they are talking
|
|
116
|
+
to an AI assistant. For exact words, use `defineAgent({ firstTurn: { say } })`.
|
|
117
|
+
- **A tool's `schema` is what the model sees**: lists (`z.array`), nested objects,
|
|
118
|
+
`.nullable()`, enums, ranges and `.regex()` all reach it, so pass a list as a list, never
|
|
119
|
+
as one string to split. Give every field a `.describe()`.
|
|
120
|
+
- **No prompt asks for "one moment" lines around tools**: Curb holds back the newest sentence
|
|
121
|
+
before a tool call, so a lone "One moment." is never spoken, but of two sentences the first is.
|
|
122
|
+
- **Only an action with consequences** (it books, sends, pays or cancels) uses
|
|
123
|
+
`<Tool confirm={(input) => "<read-back question>"}>`: the framework runs it only after the
|
|
124
|
+
caller's next turn says yes. To repeat a detail back (a name, a number), say it in the
|
|
125
|
+
reply; a confirm tool turns the caller's next words into the answer to its question.
|
|
126
|
+
- **Gate with state, not prompt text**: a tool that is not rendered does not exist for the
|
|
127
|
+
model. Render it from store state, and give a locked branch text that says how to unlock it.
|
|
128
|
+
- **What counts as a successful call is code too**: `defineAgent({ goals, experiments })`
|
|
129
|
+
names each goal (a tool that must succeed, or a sentence a judge checks) and each A/B test
|
|
130
|
+
with its weighted variants, and `useVariant("<test>")` reads the call's variant. The
|
|
131
|
+
dashboard only shows the results; `authoring.md` ("Goals and A/B experiments") has the rules.
|
|
132
|
+
- Tool names are unique snake_case verbs. Zod `.describe()` texts and error messages are
|
|
133
|
+
read by the model, so phrase them as the next step.
|
|
134
|
+
|
|
135
|
+
## Deploy to KugelAudio
|
|
136
|
+
|
|
137
|
+
`.mcp.json` registers two MCP servers for Claude Code started in this folder (it asks once
|
|
138
|
+
to use them): `kugelaudio`, your KugelAudio account (sign in from `/mcp`), and
|
|
139
|
+
`kugelaudio-docs`, the docs (no sign-in). Another client adds
|
|
140
|
+
`https://www.kugelaudio.com/mcp` as a remote MCP server. Look KugelAudio facts up with
|
|
141
|
+
`search_docs` and `read_docs_page`; for hosted agents use the `kugelaudio` server's,
|
|
142
|
+
since `kugelaudio-docs` serves public pages only. Deploy and read build and call logs
|
|
143
|
+
with the `kugelaudio` tools: `agent_create` (once), `agent_create_upload`, `agent_deploy`,
|
|
144
|
+
`agent_get_logs`; keys reach it only through `npx curb secrets push`. Without them:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npx curb login --no-open # once; prints a sign-in link and waits up to 5 minutes
|
|
148
|
+
npx curb secrets push # the .env keys onto the hosted agent; prints names, never values
|
|
149
|
+
npx curb deploy # build and go live; prints the playground URL
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Not signed in? Do not stop there: run `npx curb login --no-open` in the background, give
|
|
153
|
+
the user the link it prints to open in their browser on this machine, and go on once it
|
|
154
|
+
prints `Signed in as`. Hosted agents are in a
|
|
155
|
+
closed alpha, switched on per organization: on `CURB_HOSTED_AGENTS_NOT_ENABLED` the user
|
|
156
|
+
asks hello@kugelaudio.com, and `curb dev` and `curb say` keep working meanwhile. A dry run
|
|
157
|
+
is `npx curb deploy --no-activate` (or `npx curb build`, which uploads nothing).
|
|
158
|
+
|
|
159
|
+
Run `npx curb check` before deploying. `.env*`, `node_modules/`, `.git/`, `.curb/` and
|
|
160
|
+
`.data/` are never uploaded. Details: `node_modules/curb/docs/agent-guide/deploying.md`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# {{APP_NAME}}
|
|
2
|
+
|
|
3
|
+
A voice agent built with **curb.js**: React renders the agent's prompt, knowledge and
|
|
4
|
+
tools instead of HTML. It starts as **Juno**, a demo personal assistant, so you can try
|
|
5
|
+
the framework before you change anything.
|
|
6
|
+
|
|
7
|
+
## Run it
|
|
8
|
+
|
|
9
|
+
Sign in with `npx curb login`; there is no key to copy. Every curb command in this folder
|
|
10
|
+
then runs on your login: KugelAudio's EU-hosted LLM thinks, Kugel Ear listens and Kugel TTS
|
|
11
|
+
speaks. A deployed agent gets its access from KugelAudio. Prefer an API key (CI, a server)?
|
|
12
|
+
Set `KUGELAUDIO_API_KEY` in `.env`; it wins over the login. Another provider's key goes in
|
|
13
|
+
`.env` too, which `npm create curb.js` already wrote.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx curb login # sign in in the browser; Curb uses your login
|
|
17
|
+
npm run dev # preview, chat and voice on http://127.0.0.1:4000
|
|
18
|
+
npx curb check # types, render and warnings
|
|
19
|
+
npx curb say "Hallo" # one caller turn in the terminal, no browser
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Meet Juno
|
|
23
|
+
|
|
24
|
+
Type or talk in the preview:
|
|
25
|
+
|
|
26
|
+
- **"Put buy milk on my list."** The `add_task` tool updates shared state, and the task
|
|
27
|
+
list in Juno's context changes with it.
|
|
28
|
+
- **"What do you know about me?"** Juno reads the about-you skill, whose profile is
|
|
29
|
+
`context/about-you.md`. Edit the file and ask again.
|
|
30
|
+
- **"Wrap up my day."** The `wrap_up_day` tool exists only once a task is done, so with
|
|
31
|
+
nothing done she cannot.
|
|
32
|
+
- **"How do you work?"** Juno explains how she is built.
|
|
33
|
+
|
|
34
|
+
## Where things live
|
|
35
|
+
|
|
36
|
+
- `src/agent/agent.tsx`: the agent, where every conversation starts: its providers
|
|
37
|
+
(`<Agent llm stt tts>`), system prompt, skills index, and the task list with its tools.
|
|
38
|
+
- `src/agent/<name>/skill.tsx`: one folder per skill, opened only when the conversation
|
|
39
|
+
needs it.
|
|
40
|
+
- `context/`: markdown the skills read.
|
|
41
|
+
|
|
42
|
+
## Deploy to KugelAudio
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx curb login
|
|
46
|
+
npx curb secrets push
|
|
47
|
+
npx curb deploy
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`deploy` prints the playground URL where you can call the live agent. Your `.env` is
|
|
51
|
+
never uploaded; `secrets push` sends its values and prints only their names. Details:
|
|
52
|
+
`node_modules/curb/docs/agent-guide/deploying.md`. To run the voice worker on your own LiveKit instead,
|
|
53
|
+
see `node_modules/curb/docs/agent-guide/hosting.md`.
|
|
54
|
+
|
|
55
|
+
## Learn more
|
|
56
|
+
|
|
57
|
+
`AGENTS.md` briefs coding agents (and you) on the rules; `CLAUDE.md` imports it for
|
|
58
|
+
Claude Code. `.mcp.json` connects Claude Code to your KugelAudio account (sign in from
|
|
59
|
+
`/mcp`) and to the KugelAudio docs. The full guide is in
|
|
60
|
+
`node_modules/curb/docs/agent-guide/`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# About you
|
|
2
|
+
|
|
3
|
+
Edit this file with your real details: the agent's knowledge hot-reloads, no restart needed.
|
|
4
|
+
|
|
5
|
+
- **Name:** Your name here
|
|
6
|
+
- **City:** Where you live
|
|
7
|
+
- **What I do:** Your job, studies, or main project
|
|
8
|
+
|
|
9
|
+
## Preferences
|
|
10
|
+
|
|
11
|
+
- I prefer short answers
|
|
12
|
+
- Tea, not coffee
|
|
13
|
+
- Replace these bullets with your own
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Keys only. Which providers run is chosen in src/agent/agent.tsx (<Agent llm stt tts>).
|
|
2
|
+
# A chosen provider whose key is missing fails with an error naming its variable; the
|
|
3
|
+
# table in node_modules/curb/docs/agent-guide/configuration.md lists every key. A new
|
|
4
|
+
# provider's key also goes into package.json curb.requiredRuntimeSecrets.
|
|
5
|
+
# Every curb command loads .env, and `npx curb secrets push` copies it to the hosted agent.
|
|
6
|
+
|
|
7
|
+
# KugelAudio runs the whole agent: its EU-hosted LLM thinks (kugelaudio.llm()), Kugel Ear
|
|
8
|
+
# listens (kugelaudio.stt()) and Kugel TTS speaks (kugelaudio.tts()). A hosted agent gets
|
|
9
|
+
# access from the platform.
|
|
10
|
+
# Locally, KugelAudio access comes from `npx curb login`: there is no key to copy.
|
|
11
|
+
# KUGELAUDIO_API_KEY= # optional: an API key instead of npx curb login (CI, servers)
|
|
12
|
+
|
|
13
|
+
# Your own LiveKit, only for `npm start` (the self-hosted voice worker):
|
|
14
|
+
# LIVEKIT_URL=wss://your-project.livekit.cloud
|
|
15
|
+
# LIVEKIT_API_KEY=
|
|
16
|
+
# LIVEKIT_API_SECRET=
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{APP_NAME}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"curb": {
|
|
6
|
+
"requiredRuntimeSecrets": [
|
|
7
|
+
"KUGELAUDIO_API_KEY"
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "curb dev",
|
|
12
|
+
"check": "curb check",
|
|
13
|
+
"start": "curb start",
|
|
14
|
+
"server": "curb server",
|
|
15
|
+
"upgrade": "curb upgrade"
|
|
16
|
+
},
|
|
17
|
+
"allowScripts": {
|
|
18
|
+
"@livekit/blingfire": false,
|
|
19
|
+
"@livekit/local-inference": false,
|
|
20
|
+
"esbuild": false,
|
|
21
|
+
"fsevents": false,
|
|
22
|
+
"onnxruntime-node": false
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"curb": "{{FRAMEWORK_SPEC}}",
|
|
26
|
+
"react": "^19.2.7",
|
|
27
|
+
"react-dom": "^19.2.7"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/react": "^19.2.17",
|
|
31
|
+
"typescript": "^6.0.3"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Instruction, Text, Tool, z, type SkillMeta } from "curb";
|
|
2
|
+
import { useAssistant } from "../agent.js";
|
|
3
|
+
|
|
4
|
+
export const meta: SkillMeta = {
|
|
5
|
+
title: "About you",
|
|
6
|
+
description: "Use for anything about the user themselves: their name, preferences, facts to remember.",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A file-backed skill: the profile lives in context/about-you.md and reaches the model's
|
|
11
|
+
* context through <Text src>. Edit the markdown and Juno's knowledge changes live. The
|
|
12
|
+
* remember tool writes to the same shared store her day planner in agent.tsx reads.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const rememberSchema = z.object({
|
|
16
|
+
note: z
|
|
17
|
+
.string()
|
|
18
|
+
.min(1)
|
|
19
|
+
.describe("One short fact or preference in third person, e.g. 'prefers tea over coffee'."),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export default function AboutYou() {
|
|
23
|
+
const notes = useAssistant((s) => s.notes);
|
|
24
|
+
const remember = useAssistant((s) => s.remember);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<Instruction>{`
|
|
29
|
+
Answer questions about the user from the profile below and the remembered notes.
|
|
30
|
+
While the profile still holds placeholder text, invite the user once to put their own
|
|
31
|
+
details into context/about-you.md: your knowledge updates live, no restart needed.
|
|
32
|
+
`}</Instruction>
|
|
33
|
+
|
|
34
|
+
{notes.length === 0
|
|
35
|
+
? "No notes remembered yet this session."
|
|
36
|
+
: `Remembered this session: ${notes.join("; ")}.`}
|
|
37
|
+
|
|
38
|
+
<Tool
|
|
39
|
+
schema={rememberSchema}
|
|
40
|
+
name="remember"
|
|
41
|
+
description="Save a lasting fact or preference the user just told you about themselves."
|
|
42
|
+
success="Noted. It is now part of the context in every skill that shows it."
|
|
43
|
+
onSubmit={(input) => {
|
|
44
|
+
remember(input.note);
|
|
45
|
+
return { ok: true };
|
|
46
|
+
}}
|
|
47
|
+
/>
|
|
48
|
+
|
|
49
|
+
<Text src="about-you.md" />
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Agent,
|
|
3
|
+
GlobalTools,
|
|
4
|
+
Skill,
|
|
5
|
+
Skills,
|
|
6
|
+
SystemPrompt,
|
|
7
|
+
Text,
|
|
8
|
+
Tool,
|
|
9
|
+
createAgentStore,
|
|
10
|
+
defineAgent,
|
|
11
|
+
z,
|
|
12
|
+
} from "curb";
|
|
13
|
+
import { kugelaudio } from "curb/providers";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Juno, the demo assistant of a new project. This file is the agent level, where every
|
|
17
|
+
* conversation starts: her prompt, her day planner (the task list and its tools) and the
|
|
18
|
+
* skills she opens only when needed. Her profile of the user is a markdown file, and wrapping
|
|
19
|
+
* up the day exists as a tool only once a task is done. Replace her with your own agent; the
|
|
20
|
+
* shape (system prompt, skills index, tools, store) stays.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type Task = { title: string; done: boolean };
|
|
24
|
+
|
|
25
|
+
export const useAssistant = createAgentStore<{
|
|
26
|
+
tasks: Task[];
|
|
27
|
+
notes: string[];
|
|
28
|
+
addTask: (title: string) => void;
|
|
29
|
+
completeTask: (title: string) => void;
|
|
30
|
+
removeTask: (title: string) => void;
|
|
31
|
+
clearCompleted: () => void;
|
|
32
|
+
remember: (note: string) => void;
|
|
33
|
+
}>("juno", (set) => ({
|
|
34
|
+
tasks: [],
|
|
35
|
+
notes: [],
|
|
36
|
+
addTask: (title) =>
|
|
37
|
+
set((state) => {
|
|
38
|
+
const exists = state.tasks.some(
|
|
39
|
+
(task) => task.title.toLowerCase() === title.toLowerCase(),
|
|
40
|
+
);
|
|
41
|
+
if (exists) return state;
|
|
42
|
+
return { tasks: [...state.tasks, { title, done: false }] };
|
|
43
|
+
}),
|
|
44
|
+
completeTask: (title) =>
|
|
45
|
+
set((state) => ({
|
|
46
|
+
tasks: state.tasks.map((task) =>
|
|
47
|
+
task.title.toLowerCase() === title.toLowerCase()
|
|
48
|
+
? { ...task, done: true }
|
|
49
|
+
: task,
|
|
50
|
+
),
|
|
51
|
+
})),
|
|
52
|
+
removeTask: (title) =>
|
|
53
|
+
set((state) => ({
|
|
54
|
+
tasks: state.tasks.filter(
|
|
55
|
+
(task) => task.title.toLowerCase() !== title.toLowerCase(),
|
|
56
|
+
),
|
|
57
|
+
})),
|
|
58
|
+
clearCompleted: () =>
|
|
59
|
+
set((state) => ({ tasks: state.tasks.filter((task) => !task.done) })),
|
|
60
|
+
remember: (note) => set((state) => ({ notes: [...state.notes, note] })),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
const addSchema = z.object({
|
|
64
|
+
title: z
|
|
65
|
+
.string()
|
|
66
|
+
.min(1)
|
|
67
|
+
.describe("Short task title, e.g. 'buy milk'. Keep it to a few words."),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const completeSchema = z.object({
|
|
71
|
+
title: z
|
|
72
|
+
.string()
|
|
73
|
+
.min(1)
|
|
74
|
+
.describe("Title of the task to mark done. Match a task from the open list."),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const removeSchema = z.object({
|
|
78
|
+
title: z.string().min(1).describe("Title of the task to remove from the list."),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Juno's day planner, in her context from the first turn. The task list is live state: a tool
|
|
83
|
+
* call updates the store, the component re-renders, and the model's context changes. Watch it
|
|
84
|
+
* in the preview's context inspector while you talk.
|
|
85
|
+
*/
|
|
86
|
+
function DayPlanner() {
|
|
87
|
+
const tasks = useAssistant((s) => s.tasks);
|
|
88
|
+
const notes = useAssistant((s) => s.notes);
|
|
89
|
+
const addTask = useAssistant((s) => s.addTask);
|
|
90
|
+
const completeTask = useAssistant((s) => s.completeTask);
|
|
91
|
+
const removeTask = useAssistant((s) => s.removeTask);
|
|
92
|
+
const clearCompleted = useAssistant((s) => s.clearCompleted);
|
|
93
|
+
|
|
94
|
+
const open = tasks.filter((task) => !task.done);
|
|
95
|
+
const done = tasks.filter((task) => task.done);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<Text>
|
|
100
|
+
{tasks.length === 0
|
|
101
|
+
? "No tasks yet."
|
|
102
|
+
: [
|
|
103
|
+
open.length > 0 ? `Open tasks: ${open.map((t) => t.title).join(", ")}.` : null,
|
|
104
|
+
done.length > 0 ? `Done: ${done.map((t) => t.title).join(", ")}.` : null,
|
|
105
|
+
]
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.join(" ")}
|
|
108
|
+
</Text>
|
|
109
|
+
|
|
110
|
+
{/* Shared state across skills: the remember tool lives in /about-you,
|
|
111
|
+
but its effect is visible right here. */}
|
|
112
|
+
{notes.length > 0 ? (
|
|
113
|
+
<Text>{`Things I know about the user (remembered this session): ${notes.join("; ")}.`}</Text>
|
|
114
|
+
) : null}
|
|
115
|
+
|
|
116
|
+
<GlobalTools>
|
|
117
|
+
<Tool
|
|
118
|
+
schema={addSchema}
|
|
119
|
+
name="add_task"
|
|
120
|
+
description="Add a task to the user's list for today."
|
|
121
|
+
success="Added to the list."
|
|
122
|
+
onSubmit={(input) => {
|
|
123
|
+
addTask(input.title.toLowerCase());
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
<Tool
|
|
128
|
+
schema={completeSchema}
|
|
129
|
+
name="complete_task"
|
|
130
|
+
description="Mark a task on the list as done."
|
|
131
|
+
onSubmit={(input) => {
|
|
132
|
+
const match = open.find(
|
|
133
|
+
(task) => task.title.toLowerCase() === input.title.toLowerCase(),
|
|
134
|
+
);
|
|
135
|
+
if (!match) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
message: `No open task titled "${input.title}". Retry with a title from the open task list.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
completeTask(input.title);
|
|
142
|
+
return { ok: true, message: `Marked "${match.title}" done.` };
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
<Tool
|
|
146
|
+
schema={removeSchema}
|
|
147
|
+
name="remove_task"
|
|
148
|
+
description="Remove a task from the list entirely, done or not."
|
|
149
|
+
onSubmit={(input) => {
|
|
150
|
+
removeTask(input.title);
|
|
151
|
+
return { ok: true, message: "Removed from the list." };
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
|
|
155
|
+
{/* Structural gating: wrapping up the day does not exist for the model
|
|
156
|
+
until at least one task is done. No prompt text can route around it. */}
|
|
157
|
+
{done.length > 0 ? (
|
|
158
|
+
<Tool
|
|
159
|
+
name="wrap_up_day"
|
|
160
|
+
description="Wrap up the day: clear the completed tasks and recap what got done."
|
|
161
|
+
onSubmit={() => {
|
|
162
|
+
const count = done.length;
|
|
163
|
+
clearCompleted();
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
message: `Day wrapped up: ${count} task${count === 1 ? "" : "s"} done and cleared, ${open.length} still open.`,
|
|
167
|
+
};
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
) : (
|
|
171
|
+
<Text>Wrap-up is locked: the wrap_up_day tool appears once at least one task is marked done.</Text>
|
|
172
|
+
)}
|
|
173
|
+
</GlobalTools>
|
|
174
|
+
</>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function App() {
|
|
179
|
+
// The providers Juno talks through; their keys live in .env. She listens and speaks in
|
|
180
|
+
// English: another language sets `language` on both stt and tts, with a voice that speaks it.
|
|
181
|
+
return (
|
|
182
|
+
<Agent llm={kugelaudio.llm()} stt={kugelaudio.stt({ language: "en" })} tts={kugelaudio.tts()}>
|
|
183
|
+
<SystemPrompt>{`
|
|
184
|
+
You are Juno, a personal assistant for daily planning and the demo agent of this project.
|
|
185
|
+
|
|
186
|
+
You can:
|
|
187
|
+
- keep the user's task list,
|
|
188
|
+
- remember what they tell you about themselves,
|
|
189
|
+
- explain how you are built.
|
|
190
|
+
|
|
191
|
+
The task list changes only through its tools: call add_task, complete_task or remove_task first, and say a task is added or done only after the tool answered.
|
|
192
|
+
|
|
193
|
+
Greet the user as Juno and tell them that you are an AI assistant.
|
|
194
|
+
Then help them plan their day: offer to keep their task list.
|
|
195
|
+
Mention once that they can ask "how does this work?" to hear how this demo is built.
|
|
196
|
+
Be warm and brief: one or two sentences per reply.
|
|
197
|
+
`}</SystemPrompt>
|
|
198
|
+
{/* Each link's routing description is the target skill's meta.description. */}
|
|
199
|
+
<Skills>
|
|
200
|
+
<Skill path="/about-you">About you</Skill>
|
|
201
|
+
<Skill path="/how-this-works">How this works</Skill>
|
|
202
|
+
</Skills>
|
|
203
|
+
<DayPlanner />
|
|
204
|
+
</Agent>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const agent = defineAgent({
|
|
209
|
+
app: App,
|
|
210
|
+
// Voice calls open with Juno's greeting; "user" waits for the caller instead.
|
|
211
|
+
firstTurn: "agent",
|
|
212
|
+
// Every turn tells the model today's date and time in this zone (default "UTC").
|
|
213
|
+
// timeZone: "Europe/Berlin",
|
|
214
|
+
preview: {
|
|
215
|
+
suggestedPrompts: [
|
|
216
|
+
"Put buy milk on my list",
|
|
217
|
+
"What do you know about me?",
|
|
218
|
+
"Remember that I prefer tea",
|
|
219
|
+
"How does this demo work?",
|
|
220
|
+
],
|
|
221
|
+
chatPlaceholder: "What's on for today?",
|
|
222
|
+
},
|
|
223
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Instruction, Text, type SkillMeta } from "curb";
|
|
2
|
+
import { useAssistant } from "../agent.js";
|
|
3
|
+
|
|
4
|
+
export const meta: SkillMeta = {
|
|
5
|
+
title: "How this works",
|
|
6
|
+
description: "Use FIRST when the user asks how this demo, this agent, or the framework works.",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The demo explains itself. Loading this skill IS the demo of skill routing: the model
|
|
11
|
+
* read these instructions on demand because the user asked how things work.
|
|
12
|
+
*/
|
|
13
|
+
export default function HowThisWorks() {
|
|
14
|
+
const tasks = useAssistant((s) => s.tasks);
|
|
15
|
+
const notes = useAssistant((s) => s.notes);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<Instruction>{`
|
|
20
|
+
Explain how this agent is built, in plain spoken language, a couple of sentences at a time.
|
|
21
|
+
Invite the user to watch the preview's context inspector change while they talk.
|
|
22
|
+
`}</Instruction>
|
|
23
|
+
|
|
24
|
+
<Text>{`
|
|
25
|
+
How this project works:
|
|
26
|
+
- The agent is a React tree that renders context and tools for a language model instead of HTML.
|
|
27
|
+
- src/agent/agent.tsx is where every conversation starts: Juno's prompt, her task list and its tools are in her context from the first turn.
|
|
28
|
+
- Each folder under src/agent/ is a skill: instructions and tools the model reads on demand. Reading this skill is how this answer came about.
|
|
29
|
+
- The user's profile is the markdown file context/about-you.md, read by a Text component.
|
|
30
|
+
- Tasks and remembered notes are shared React state: a tool call updates the store, the tree re-renders, and the model's context changes. Tasks on the list right now: ${tasks.length}. Remembered notes: ${notes.length}.
|
|
31
|
+
- Wrapping up the day is gated structurally: the wrap_up_day tool does not exist in the model's context until a task is done.
|
|
32
|
+
- Everything editable lives in src/agent/ and context/, and changes hot-reload into the running conversation.
|
|
33
|
+
`}</Text>
|
|
34
|
+
</>
|
|
35
|
+
);
|
|
36
|
+
}
|