pi-roundtable 0.29.2 → 0.30.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/CHANGELOG.md +16 -0
- package/docs/plugins.md +29 -6
- package/package.json +1 -1
- package/src/core/agents/group-round.ts +1 -1
- package/src/core/agents/group-turns.ts +8 -4
- package/src/core/contract/chat-command.ts +13 -0
- package/src/core/host-registration.ts +7 -0
- package/src/core/modules/skills/missing-warning.ts +34 -0
- package/src/core/modules/skills/skill-registry.ts +4 -5
- package/src/core/plugin.ts +6 -0
- package/src/core/registry/chat-commands.ts +33 -0
- package/src/core/registry/contributions.ts +10 -0
- package/src/core/runtime/pi-agent-runtime.ts +2 -2
- package/src/core/runtime/steerable-run.ts +25 -3
- package/src/core/runtime/worker-task.ts +9 -1
- package/src/index.ts +1 -0
- package/src/testing.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.30.0] - 2026-11-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `ChatCommand`, `Contribution.chatCommands` and `PluginContext.chatCommands()`: a plugin declares the Pi extension commands (`pi.registerCommand`) of its `piPackages` or `sessionTools` that are safe to run from an owner's chat message, as `{ name, description }`. None is listed until a plugin declares it. `clear`, `new` and `skill` are the web app's own and refused, as is a repeated name; `context.chatCommands()` returns every declaration once linked (`NotLinkedError` during setup, also in `testing`). The web app lists them in a room's command menu. See "chatCommands" in docs/plugins.md.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- A delegated task's text is sent to its worker as plain text (`promptTask`, with `expandPromptTemplates: false`; `runWorkerTask` uses it too), so a task text the model wrote that begins with `/name` or `/skill:name` can never run an extension command or expand a skill.
|
|
17
|
+
- `SkillRegistry.carried` warns of a carried skill that is missing once, not on every call: opening an agent's room or its command menu no longer repeats the line. It warns again if the skill came back and went.
|
|
18
|
+
- A message that names an extension command or a skill (`/name args`, `/skill:name args`) sent while a turn runs is no longer steered into the turn: Pi refuses to queue an extension command, and the queued expansion of a skill is not the text the run looks for among its leftovers. It waits for its own turn like a message from another speaker, and Pi runs or expands it then.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- A group round answers only the members a person's message names. When the text (or the quoted text of a reply) mentions one or more members with `@display name` or `@name`, those members answer and nobody is scored, so a high-scoring member that was not named no longer joins. A reply target and a seat holding an action the speaker may approve still answer as before, handoffs by `@` inside a member's reply keep working, and a message that names no one is scored and planned as it was. This applies wherever group rounds run: Discord channels and web rooms.
|
|
23
|
+
|
|
8
24
|
## [0.29.2] - 2026-10-31
|
|
9
25
|
|
|
10
26
|
- Release for the web package's changes (see its changelog); no core behavior changes.
|
package/docs/plugins.md
CHANGED
|
@@ -183,9 +183,9 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
|
|
|
183
183
|
| `turns` | Runs one turn of a conversation your claim owns, over the runtime and the surfaces: [`turns.run`](#personas-and-contextturns-conversations-of-a-kind-of-your-own) |
|
|
184
184
|
| `observe(filter, observer)` | Hears inbound messages without owning or admitting them: [`observe`](#observe-hearing-messages-without-owning-them). Returns a function that stops it; may be called during `setup` |
|
|
185
185
|
| `directChannels` | Every plugin's [direct channels](#directchannels-reaching-a-person-on-their-own): `providers()`, `reach(origin, principalId)`, `known(origin, principalId)`, and `notify(origin, principalId, text)`, each through the [delivery router](#where-things-are-delivered) |
|
|
186
|
-
| `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, `dashboard()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
|
|
186
|
+
| `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, `dashboard()`, `chatCommands()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
|
|
187
187
|
|
|
188
|
-
`sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, and `
|
|
188
|
+
`sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, `dashboard()`, and `chatCommands()` are available from a service's `start`, an event handler, a session tool, or a claim's turn.
|
|
189
189
|
|
|
190
190
|
Use `QueuePort` from the main entry for `context.queue`; the kit's `ChannelQueue` is also type-only.
|
|
191
191
|
|
|
@@ -2333,6 +2333,28 @@ The operator's `toolTiers` setting wins, and tools with no tier assigned require
|
|
|
2333
2333
|
If two plugins name the same tool, the host throws a `PluginError` naming both.
|
|
2334
2334
|
The built-in addons use it: each declares the tiers of its own tools.
|
|
2335
2335
|
|
|
2336
|
+
### `chatCommands`: Pi extension commands the owner may run from chat
|
|
2337
|
+
|
|
2338
|
+
A Pi extension that a package in [`piPackages`](#pipackages-pi-extensions-every-session-loads) or a [`sessionTools`](#sessiontools-the-raw-form-of-tools) extension carries can register a command with `pi.registerCommand(name, { description, handler })`. Pi runs a message that starts with `/` and names a registered command instead of sending it to the model, so an owner's message `/name args` already reaches the handler. Most commands are written for Pi's terminal screen: they open a dialog or replace the session, and do nothing or fail in a chat room.
|
|
2339
|
+
|
|
2340
|
+
The web app's command menu (typing `/` in a room's message box) therefore lists none of them. A plugin opts a command in by declaring it, once it has checked that the handler needs no screen and does not control the session:
|
|
2341
|
+
|
|
2342
|
+
```ts
|
|
2343
|
+
export default {
|
|
2344
|
+
name: "stats",
|
|
2345
|
+
setup: () => ({
|
|
2346
|
+
piPackages: ["pi-stats"],
|
|
2347
|
+
chatCommands: [{ name: "stats", description: "Show this week's usage" }],
|
|
2348
|
+
}),
|
|
2349
|
+
} satisfies RoundtablePlugin;
|
|
2350
|
+
```
|
|
2351
|
+
|
|
2352
|
+
- `name` is lowercase letters, digits, dashes and underscores, starting with a letter, written without the slash. `clear`, `new` and `skill` are the app's own (`/clear` and `/new` start a room over, `/skill:<name>` runs a skill the agent carries) and a plugin cannot take them; a name declared twice, by one plugin or two, is a `PluginError`.
|
|
2353
|
+
- `description` is one line for the menu.
|
|
2354
|
+
- Only an owner sees the entry, and only an owner's message can run the command: the text of any other speaker is prefixed with who wrote it (`(Message from …, at the member tier.)`), so Pi never reads it as a command. A schedule's text follows its own header and a delegated task's text is sent to its worker as plain text (templates, skills and commands are not expanded), so neither can reach a command. The handler runs in the host process with the owner's authority, so declare only what you would let any owner trigger from a phone.
|
|
2355
|
+
- A command is sent as a normal message. While a turn runs it is not steered into the turn (Pi refuses to queue a command): it waits for its own turn, as a message from another speaker does, and Pi runs the handler then.
|
|
2356
|
+
- `context.chatCommands()` returns every plugin's declarations in contribution order, once linked; during `setup` it throws `NotLinkedError`. The web app reads it for `GET api/rooms/<room>/commands`.
|
|
2357
|
+
|
|
2336
2358
|
### `sessionTools`: the raw form of `tools`
|
|
2337
2359
|
|
|
2338
2360
|
A session tool is a Pi extension placed in every conversation session by its phase: `tools`, `compaction`, or `mcp`.
|
|
@@ -3945,7 +3967,7 @@ It returns:
|
|
|
3945
3967
|
The harness applies the same checks as the host (a plugin that adds nothing, an unknown part, a clash of names, a tool with no tier), so a mistake fails your test with the message the start would print.
|
|
3946
3968
|
Run migrations yourself (see the [`migrations`](#migrations-and-contextdatabase-tables-of-your-own) test), call event handlers with their payloads (see [`events`](#events-hear-what-the-core-does)), and call `contribution.prompt`'s `build` to test prompt text (see [`prompt`](#prompt-text-added-to-every-agent-turn)).
|
|
3947
3969
|
The harness leaves these calls to the test.
|
|
3948
|
-
`sessions()`, `conversations`, `surfaces`, `turns`, and `
|
|
3970
|
+
`sessions()`, `conversations`, `surfaces`, `turns`, `dashboard()`, and `chatCommands()` throw `NotLinkedError` during `setup`, as they do in the host.
|
|
3949
3971
|
After setup, `conversations` routes to the plugin's claims, `surfaces` contains its surfaces, which start with its services, and `turns` runs over the runtime and surfaces.
|
|
3950
3972
|
|
|
3951
3973
|
#### Options
|
|
@@ -4203,7 +4225,7 @@ These are the messages as the code writes them, with `<...>` where your names go
|
|
|
4203
4225
|
| `plugin <name>: setup is missing. Give the function that returns what the plugin adds.` | Add `setup` |
|
|
4204
4226
|
| `plugin <name> adds nothing. Give it a part (tools, services, channels, and so on), a migration, or a provider, or remove it.` | Return a part from `setup`, or remove the plugin from `roundtable.config.ts` |
|
|
4205
4227
|
| `plugin <name>: setup must return an object of the parts it adds; return {} to add none.` | Return an object, not `undefined` |
|
|
4206
|
-
| `plugin <name>: setup returned an unknown part "<key>". Did you mean "<closest>"? The parts are services, events, http, holdRules, piPackages, sessionTools, channels, surfaces, personas, backgroundTargets, directChannels, dashboard, tools, seeds, prompt, agentSelection, requiredTools.` | Fix the key; `migrations`, `providers`, and `preflight` belong on the plugin, not in what `setup` returns |
|
|
4228
|
+
| `plugin <name>: setup returned an unknown part "<key>". Did you mean "<closest>"? The parts are services, events, http, holdRules, piPackages, chatCommands, sessionTools, channels, surfaces, personas, backgroundTargets, directChannels, dashboard, tools, seeds, prompt, agentSelection, requiredTools.` | Fix the key; `migrations`, `providers`, and `preflight` belong on the plugin, not in what `setup` returns |
|
|
4207
4229
|
|
|
4208
4230
|
### Tools
|
|
4209
4231
|
|
|
@@ -4246,14 +4268,14 @@ These are the messages as the code writes them, with `<...>` where your names go
|
|
|
4246
4268
|
|
|
4247
4269
|
### Things used before they are ready
|
|
4248
4270
|
|
|
4249
|
-
`sessions()`, `conversations`, `surfaces`, `turns`, and `
|
|
4271
|
+
`sessions()`, `conversations`, `surfaces`, `turns`, `dashboard()`, and `chatCommands()` are linked after every plugin is set up.
|
|
4250
4272
|
Calling one from `setup` fails, and the message says when it becomes ready:
|
|
4251
4273
|
|
|
4252
4274
|
```text
|
|
4253
4275
|
plugin <name>: setup failed: session parts are linked once every plugin is set up. Call sessions() from a service's start or from a handler, not during setup. Fix the error, or remove the plugin.
|
|
4254
4276
|
```
|
|
4255
4277
|
|
|
4256
|
-
The same message exists for `conversations` (`Use them from a service's start or from a handler, not during setup.`), for `surfaces` (`chat surfaces are linked once every plugin is set up.`), for `turns` (`conversation turns are linked once every plugin is set up.`), and for `
|
|
4278
|
+
The same message exists for `conversations` (`Use them from a service's start or from a handler, not during setup.`), for `surfaces` (`chat surfaces are linked once every plugin is set up.`), for `turns` (`conversation turns are linked once every plugin is set up.`), for `dashboard()`, and for `chatCommands()` (`chat commands are linked once every plugin is set up.`).
|
|
4257
4279
|
Move the call into a service's `start` or an event handler.
|
|
4258
4280
|
|
|
4259
4281
|
`context.services.get(KEY)` before the plugin that provides it has run throws `service <id> is not provided yet; plugin <name> provides it. Register plugin <name> before plugin <yours>.`
|
|
@@ -4430,6 +4452,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
4430
4452
|
| `BackgroundTurns` | `pi-roundtable` | type |
|
|
4431
4453
|
| `ChannelClaim` | `pi-roundtable` | type |
|
|
4432
4454
|
| `ChannelKey` | `pi-roundtable` | type |
|
|
4455
|
+
| `ChatCommand` | `pi-roundtable` | type |
|
|
4433
4456
|
| `ChatSurface` | `pi-roundtable` | type |
|
|
4434
4457
|
| `ClassedHold` | `pi-roundtable` | type |
|
|
4435
4458
|
| `ClientCallError` | `pi-roundtable` | type |
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import { type OwnerIdentity, ownerWords } from "../identity.ts";
|
|
|
3
3
|
import type { Logger } from "../log.ts";
|
|
4
4
|
import type { Agent, GroupMessage } from "./agent-store.ts";
|
|
5
5
|
|
|
6
|
-
/** Members at or above this relevance answer an owner message nobody is named in. */
|
|
6
|
+
/** Members at or above this relevance answer an owner message nobody is named in; a message naming members is answered by them alone. */
|
|
7
7
|
export const RELEVANCE_THRESHOLD = 0.5;
|
|
8
8
|
/** Replies one round may hold, handoffs included (spec behavior 29). */
|
|
9
9
|
export const MAX_ROUND_REPLIES = 8;
|
|
@@ -74,8 +74,9 @@ export class GroupTurns {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* One owner message in a group channel:
|
|
78
|
-
* a time, each seeing every group message since its last
|
|
77
|
+
* One owner message in a group channel: answered by the members it names, or else scored and
|
|
78
|
+
* answered by the chosen members, one at a time, each seeing every group message since its last
|
|
79
|
+
* turn. Call inside the queue.
|
|
79
80
|
*/
|
|
80
81
|
async answerGroup(
|
|
81
82
|
channel: ChannelKey,
|
|
@@ -102,7 +103,8 @@ export class GroupTurns {
|
|
|
102
103
|
text,
|
|
103
104
|
});
|
|
104
105
|
|
|
105
|
-
const
|
|
106
|
+
const named = mentionedMembers(replyText, members);
|
|
107
|
+
const forced = new Set(named);
|
|
106
108
|
const repliedTo = members.find((m) => m.displayName === repliedToName);
|
|
107
109
|
if (repliedTo) forced.add(repliedTo.name);
|
|
108
110
|
const approvals = new Map<string, boolean>();
|
|
@@ -118,8 +120,10 @@ export class GroupTurns {
|
|
|
118
120
|
await confirmations.approves(pending, replyText),
|
|
119
121
|
);
|
|
120
122
|
}
|
|
123
|
+
// A message that names members is answered by them alone (and by a seat forced by a held
|
|
124
|
+
// approval or a reply target), so nobody is scored.
|
|
121
125
|
const scores =
|
|
122
|
-
forced.size === members.length
|
|
126
|
+
named.length > 0 || forced.size === members.length
|
|
123
127
|
? undefined
|
|
124
128
|
: await scorer.score(members, recent, text);
|
|
125
129
|
const order = planRound({
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Pi extension command (one a package or session tool registers with `pi.registerCommand`) that a
|
|
3
|
+
* plugin vouches for as safe to run from a chat message: it needs no screen and does not control
|
|
4
|
+
* the session. Pi runs a message that starts with `/` and names a registered command instead of
|
|
5
|
+
* sending it to the model, so only the owner's message can start one. The room's command menu
|
|
6
|
+
* lists the declared commands to the owner.
|
|
7
|
+
*/
|
|
8
|
+
export interface ChatCommand {
|
|
9
|
+
/** Without the slash: lowercase letters, digits, dashes and underscores, starting with a letter. `clear`, `new` and `skill` are the app's own. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** One line for the menu. */
|
|
12
|
+
description: string;
|
|
13
|
+
}
|
|
@@ -213,6 +213,13 @@ export class Linkage {
|
|
|
213
213
|
);
|
|
214
214
|
return this.registry.dashboard;
|
|
215
215
|
},
|
|
216
|
+
chatCommands: () => {
|
|
217
|
+
if (!this.sessions)
|
|
218
|
+
throw new NotLinkedError(
|
|
219
|
+
"chat commands are linked once every plugin is set up. Call chatCommands() from a service's start or from a handler, not during setup.",
|
|
220
|
+
);
|
|
221
|
+
return this.registry.chatCommands;
|
|
222
|
+
},
|
|
216
223
|
observe: (filter, observer) => this.observers.add(filter, observer),
|
|
217
224
|
apiKey: async (provider) => this.#options.apiKey?.(provider),
|
|
218
225
|
listener: (id) => listenerAddress(listeners, id),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Logger } from "../../log.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Warns of a carried skill that is missing once per agent and name. The registry is asked what an
|
|
5
|
+
* agent carries on every room open and menu fetch, so a warning each time would only repeat. A
|
|
6
|
+
* skill that came back is forgotten here, and warned of again if it goes.
|
|
7
|
+
*/
|
|
8
|
+
export class WarnedMissing {
|
|
9
|
+
readonly #logger: Logger;
|
|
10
|
+
readonly #warned = new Set<string>();
|
|
11
|
+
|
|
12
|
+
constructor(logger: Logger) {
|
|
13
|
+
this.#logger = logger;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
missing(
|
|
17
|
+
agent: string,
|
|
18
|
+
skills: readonly { name: string; reason: string }[],
|
|
19
|
+
): void {
|
|
20
|
+
const key = (name: string) => `${agent}\0${name}`;
|
|
21
|
+
const now = new Set(skills.map((skill) => key(skill.name)));
|
|
22
|
+
for (const warned of this.#warned)
|
|
23
|
+
if (warned.startsWith(`${agent}\0`) && !now.has(warned))
|
|
24
|
+
this.#warned.delete(warned);
|
|
25
|
+
for (const { name, reason } of skills) {
|
|
26
|
+
if (this.#warned.has(key(name))) continue;
|
|
27
|
+
this.#warned.add(key(name));
|
|
28
|
+
this.#logger.warn(
|
|
29
|
+
{ agent, skill: name, reason },
|
|
30
|
+
"carried skill missing",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { AgentError } from "../../domain/errors.ts";
|
|
4
4
|
import { PluginError } from "../../errors.ts";
|
|
5
5
|
import type { SkillRegistry } from "../../services.ts";
|
|
6
|
+
import { WarnedMissing } from "./missing-warning.ts";
|
|
6
7
|
import {
|
|
7
8
|
readSkillDocument,
|
|
8
9
|
skillVersions,
|
|
@@ -51,10 +52,12 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
51
52
|
readonly #packageSkills = new Map<string, PackageSkill>();
|
|
52
53
|
/** The tail of the document writes, so a hash is checked and the skill written without another save between. */
|
|
53
54
|
#documents: Promise<unknown> = Promise.resolve();
|
|
55
|
+
readonly #warned: WarnedMissing;
|
|
54
56
|
|
|
55
57
|
/** Throws a PluginError when a package skill has the name of a linked or written one. */
|
|
56
58
|
constructor(options: SkillRegistryOptions) {
|
|
57
59
|
this.#options = options;
|
|
60
|
+
this.#warned = new WarnedMissing(options.logger);
|
|
58
61
|
for (const skill of options.packageSkills ?? []) {
|
|
59
62
|
const stored = options.store.skill(skill.name);
|
|
60
63
|
if (stored)
|
|
@@ -96,11 +99,7 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
96
99
|
/** What an agent carries, built-in skills included. */
|
|
97
100
|
carried(agent: string): SkillSet {
|
|
98
101
|
const set = this.resolve(this.#options.store.carriedBy(agent));
|
|
99
|
-
|
|
100
|
-
this.#options.logger.warn(
|
|
101
|
-
{ agent, skill: name, reason },
|
|
102
|
-
"carried skill missing",
|
|
103
|
-
);
|
|
102
|
+
this.#warned.missing(agent, set.missing);
|
|
104
103
|
return set;
|
|
105
104
|
}
|
|
106
105
|
|
package/src/core/plugin.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
ObserveFilter,
|
|
11
11
|
QueuePort,
|
|
12
12
|
} from "./contract/channels.ts";
|
|
13
|
+
import type { ChatCommand } from "./contract/chat-command.ts";
|
|
13
14
|
import type { Providers, ResolvedProviders } from "./contract/providers.ts";
|
|
14
15
|
import type { ServiceKey, Services } from "./contract/services.ts";
|
|
15
16
|
import type { ChatSurface, SurfacePort } from "./contract/surface.ts";
|
|
@@ -189,6 +190,8 @@ export interface Contribution {
|
|
|
189
190
|
holdRules?: readonly HoldRule[];
|
|
190
191
|
/** Pi packages every conversation session loads, in contribution order. */
|
|
191
192
|
piPackages?: readonly string[];
|
|
193
|
+
/** The Pi extension commands of `piPackages` or `sessionTools` that are safe to run from an owner's chat message; none is listed until declared. */
|
|
194
|
+
chatCommands?: readonly ChatCommand[];
|
|
192
195
|
/** Extensions of every conversation session, placed by phase around the core's own. */
|
|
193
196
|
sessionTools?: readonly SessionTool[];
|
|
194
197
|
/** The channels whose conversations the plugin owns. */
|
|
@@ -251,6 +254,7 @@ export const CONTRIBUTION_KEYS = [
|
|
|
251
254
|
"http",
|
|
252
255
|
"holdRules",
|
|
253
256
|
"piPackages",
|
|
257
|
+
"chatCommands",
|
|
254
258
|
"sessionTools",
|
|
255
259
|
"channels",
|
|
256
260
|
"surfaces",
|
|
@@ -374,6 +378,8 @@ export interface PluginContext {
|
|
|
374
378
|
providers: ResolvedProviders;
|
|
375
379
|
/** Every plugin's dashboard lines, in contribution order; throws NotLinkedError during setup. */
|
|
376
380
|
dashboard(): readonly string[];
|
|
381
|
+
/** Every plugin's declared chat commands, in contribution order; throws NotLinkedError during setup. */
|
|
382
|
+
chatCommands(): readonly ChatCommand[];
|
|
377
383
|
/**
|
|
378
384
|
* The credential the host's model login holds for a provider, such as `openai-codex`: the same
|
|
379
385
|
* login the agents use. Resolves to `undefined` when the host has none for that provider, and
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ChatCommand } from "../contract/chat-command.ts";
|
|
2
|
+
import { PluginError } from "../errors.ts";
|
|
3
|
+
|
|
4
|
+
const CHAT_COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
5
|
+
/** The commands the web app answers itself, so a declared command cannot take their names. */
|
|
6
|
+
const APP_COMMANDS = ["clear", "new", "skill"];
|
|
7
|
+
|
|
8
|
+
/** Records a plugin's chat command under its name, or throws PluginError for a name that is not allowed or taken. */
|
|
9
|
+
export function claimChatCommand(
|
|
10
|
+
names: Map<string, string>,
|
|
11
|
+
plugin: string,
|
|
12
|
+
command: ChatCommand,
|
|
13
|
+
): void {
|
|
14
|
+
const { name, description } = command;
|
|
15
|
+
if (typeof name !== "string" || !CHAT_COMMAND_NAME.test(name))
|
|
16
|
+
throw new PluginError(
|
|
17
|
+
`plugin ${plugin}: a chat command needs a name of lowercase letters, digits, dashes and underscores that starts with a letter, such as "stats", and got ${JSON.stringify(name)}.`,
|
|
18
|
+
);
|
|
19
|
+
if (APP_COMMANDS.includes(name))
|
|
20
|
+
throw new PluginError(
|
|
21
|
+
`plugin ${plugin}: the chat command "${name}" is one the app has itself (${APP_COMMANDS.join(", ")}). Pick another name.`,
|
|
22
|
+
);
|
|
23
|
+
if (typeof description !== "string" || description.trim() === "")
|
|
24
|
+
throw new PluginError(
|
|
25
|
+
`plugin ${plugin}: the chat command "${name}" needs a description, one line for the menu.`,
|
|
26
|
+
);
|
|
27
|
+
const other = names.get(name);
|
|
28
|
+
if (other !== undefined)
|
|
29
|
+
throw new PluginError(
|
|
30
|
+
`plugin ${plugin}: chat command "${name}" is already declared by plugin ${other}. Keep one declaration per name.`,
|
|
31
|
+
);
|
|
32
|
+
names.set(name, plugin);
|
|
33
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CharacterError, resolveCharacter } from "../../characters/index.ts";
|
|
2
2
|
import type { AgentSeed } from "../agents/agent-store.ts";
|
|
3
3
|
import type { BackgroundTarget, ChannelClaim } from "../contract/channels.ts";
|
|
4
|
+
import type { ChatCommand } from "../contract/chat-command.ts";
|
|
4
5
|
import type { ChatSurface } from "../contract/surface.ts";
|
|
5
6
|
import type { ToolContribution } from "../define.ts";
|
|
6
7
|
import { NotLinkedError, PluginError } from "../errors.ts";
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
} from "../sessions.ts";
|
|
34
35
|
import { TIERS } from "../speakers.ts";
|
|
35
36
|
import type { ToolTierTable } from "../tool-tiers.ts";
|
|
37
|
+
import { claimChatCommand } from "./chat-commands.ts";
|
|
36
38
|
import { ServiceRegistry } from "./services.ts";
|
|
37
39
|
import { surfaceService } from "./surface-service.ts";
|
|
38
40
|
|
|
@@ -54,6 +56,7 @@ export interface Registry {
|
|
|
54
56
|
/** The contributed direct channels, in contribution order. */
|
|
55
57
|
directChannels: DirectChannelProvider[];
|
|
56
58
|
dashboard: string[];
|
|
59
|
+
chatCommands: ChatCommand[];
|
|
57
60
|
tools: ToolContribution[];
|
|
58
61
|
seeds: AgentSeed[];
|
|
59
62
|
prompt: PromptSection[];
|
|
@@ -75,6 +78,7 @@ export const emptyRegistry = (): Registry => ({
|
|
|
75
78
|
backgroundTargets: [],
|
|
76
79
|
directChannels: [],
|
|
77
80
|
dashboard: [],
|
|
81
|
+
chatCommands: [],
|
|
78
82
|
tools: [],
|
|
79
83
|
seeds: [],
|
|
80
84
|
prompt: [],
|
|
@@ -309,6 +313,7 @@ export async function collectContributions(
|
|
|
309
313
|
const directChannelNames = new Names("direct channel");
|
|
310
314
|
const personaKinds = new Map<string, string>();
|
|
311
315
|
const targetNames = new Map<string, string>();
|
|
316
|
+
const chatCommandNames = new Map<string, string>();
|
|
312
317
|
for (const plugin of plugins) {
|
|
313
318
|
let contribution: Contribution;
|
|
314
319
|
try {
|
|
@@ -347,6 +352,7 @@ export async function collectContributions(
|
|
|
347
352
|
backgroundTargets = [],
|
|
348
353
|
directChannels = [],
|
|
349
354
|
dashboard = [],
|
|
355
|
+
chatCommands = [],
|
|
350
356
|
tools = [],
|
|
351
357
|
seeds = [],
|
|
352
358
|
prompt = [],
|
|
@@ -364,6 +370,7 @@ export async function collectContributions(
|
|
|
364
370
|
backgroundTargets,
|
|
365
371
|
directChannels,
|
|
366
372
|
dashboard,
|
|
373
|
+
chatCommands,
|
|
367
374
|
tools,
|
|
368
375
|
seeds,
|
|
369
376
|
prompt,
|
|
@@ -444,6 +451,9 @@ export async function collectContributions(
|
|
|
444
451
|
registry.backgroundTargets.push(...backgroundTargets);
|
|
445
452
|
registry.directChannels.push(...directChannels);
|
|
446
453
|
registry.dashboard.push(...dashboard);
|
|
454
|
+
for (const command of chatCommands)
|
|
455
|
+
claimChatCommand(chatCommandNames, plugin.name, command);
|
|
456
|
+
registry.chatCommands.push(...chatCommands);
|
|
447
457
|
checkSeedCharacters(plugin.name, seeds);
|
|
448
458
|
registry.seeds.push(...seeds);
|
|
449
459
|
registry.prompt.push(...prompt);
|
|
@@ -53,7 +53,7 @@ import { promptFailed, turnLimits } from "./turn-limits.ts";
|
|
|
53
53
|
import { progressReporter } from "./turn-progress.ts";
|
|
54
54
|
import { turnTools } from "./turn-tools.ts";
|
|
55
55
|
import { wrapUpTurn } from "./turn-wrapup.ts";
|
|
56
|
-
import { workerReport } from "./worker-task.ts";
|
|
56
|
+
import { promptTask, workerReport } from "./worker-task.ts";
|
|
57
57
|
|
|
58
58
|
export {
|
|
59
59
|
type CoreExtensions,
|
|
@@ -434,7 +434,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
434
434
|
(name) => registered.has(name) && this.#tiers.allows(tier, name),
|
|
435
435
|
);
|
|
436
436
|
session.setActiveToolsByName([...worker.tools]);
|
|
437
|
-
const prompted = withoutReplyFiles(() => session
|
|
437
|
+
const prompted = withoutReplyFiles(() => promptTask(session, task.text));
|
|
438
438
|
prompted.catch(() => undefined);
|
|
439
439
|
await Promise.race([prompted, guard.gaveUp]);
|
|
440
440
|
return workerReport(
|
|
@@ -21,7 +21,10 @@ export type SteeringSession = Pick<
|
|
|
21
21
|
| "abort"
|
|
22
22
|
| "prompt"
|
|
23
23
|
| "waitForIdle"
|
|
24
|
-
|
|
24
|
+
> & {
|
|
25
|
+
/** Pi's extension commands: a message that names one runs it instead of reaching the model. */
|
|
26
|
+
extensionRunner?: { getCommand(name: string): unknown };
|
|
27
|
+
};
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* One turn of a Pi session that the owner may steer or stop. Pi checks its queue once more
|
|
@@ -83,13 +86,32 @@ export class SteerableRun {
|
|
|
83
86
|
}
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Whether the text asks Pi for something it expands or runs rather than sends: an extension
|
|
91
|
+
* command (Pi refuses to queue it) or a skill (Pi queues its expansion, which no longer matches
|
|
92
|
+
* the text the leftover check looks for).
|
|
93
|
+
*/
|
|
94
|
+
#isCommand(text: string): boolean {
|
|
95
|
+
if (!text.startsWith("/")) return false;
|
|
96
|
+
const name = text.slice(1).split(/\s/, 1)[0] ?? "";
|
|
97
|
+
return (
|
|
98
|
+
name.startsWith("skill:") ||
|
|
99
|
+
this.#session.extensionRunner?.getCommand(name) !== undefined
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Adds the text to the running turn; false when it must wait for a turn of its own. A message
|
|
105
|
+
* that names an extension command or a skill waits too, as a message like any other: Pi runs
|
|
106
|
+
* or expands it when its turn comes.
|
|
107
|
+
*/
|
|
87
108
|
async steer(text: string, images: PromptImages): Promise<boolean> {
|
|
88
109
|
if (
|
|
89
110
|
this.#stopped ||
|
|
90
111
|
this.#done ||
|
|
91
112
|
!this.#session.isStreaming ||
|
|
92
|
-
!this.#canSteer()
|
|
113
|
+
!this.#canSteer() ||
|
|
114
|
+
this.#isCommand(text)
|
|
93
115
|
)
|
|
94
116
|
return false;
|
|
95
117
|
const steer = { text, images };
|
|
@@ -25,6 +25,14 @@ export function workerReport(
|
|
|
25
25
|
: report;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Sends a worker its task as plain text. A task is written by a model or a plugin, so it never
|
|
30
|
+
* runs an extension command or expands a skill, whatever it starts with.
|
|
31
|
+
*/
|
|
32
|
+
export function promptTask(session: AgentSession, text: string): Promise<void> {
|
|
33
|
+
return session.prompt(text, { expandPromptTemplates: false });
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
/** The host's model for `<provider>/<id>`; throws ConfigError when it has none. */
|
|
29
37
|
export function hostModel(runtime: ModelRuntime, model: string) {
|
|
30
38
|
const ref = parseModelRef(model);
|
|
@@ -56,7 +64,7 @@ export async function runWorkerTask(
|
|
|
56
64
|
try {
|
|
57
65
|
// Extension providers such as claude-bridge exist only once the session loaded them.
|
|
58
66
|
await session.setModel(hostModel(modelRuntime, model));
|
|
59
|
-
await session
|
|
67
|
+
await promptTask(session, task);
|
|
60
68
|
return workerReport(session.messages, aborted);
|
|
61
69
|
} finally {
|
|
62
70
|
signal.removeEventListener("abort", onAbort);
|
package/src/index.ts
CHANGED
|
@@ -100,6 +100,7 @@ export type {
|
|
|
100
100
|
ScheduledOutcome,
|
|
101
101
|
} from "./core/contract/channels.ts";
|
|
102
102
|
export { QUEUED_MARK, STEERED_MARK } from "./core/contract/channels.ts";
|
|
103
|
+
export type { ChatCommand } from "./core/contract/chat-command.ts";
|
|
103
104
|
export type {
|
|
104
105
|
ImageDrawer,
|
|
105
106
|
Judge,
|
package/src/testing.ts
CHANGED
|
@@ -319,6 +319,8 @@ const NOT_LINKED = {
|
|
|
319
319
|
"conversation turns are linked once every plugin is set up. Use turns from a service's start or from a handler, not during setup.",
|
|
320
320
|
dashboard:
|
|
321
321
|
"dashboard lines are linked once every plugin is set up. Call dashboard() from a service's start or from a handler, not during setup.",
|
|
322
|
+
chatCommands:
|
|
323
|
+
"chat commands are linked once every plugin is set up. Call chatCommands() from a service's start or from a handler, not during setup.",
|
|
322
324
|
};
|
|
323
325
|
|
|
324
326
|
function unlinked(part: keyof typeof NOT_LINKED): never {
|
|
@@ -554,6 +556,8 @@ export async function testPlugin(
|
|
|
554
556
|
},
|
|
555
557
|
providers,
|
|
556
558
|
dashboard: () => (linked ? registry.dashboard : unlinked("dashboard")),
|
|
559
|
+
chatCommands: () =>
|
|
560
|
+
linked ? registry.chatCommands : unlinked("chatCommands"),
|
|
557
561
|
apiKey: async (provider) =>
|
|
558
562
|
Object.hasOwn(options.apiKeys ?? {}, provider)
|
|
559
563
|
? options.apiKeys?.[provider]
|
|
@@ -630,6 +634,7 @@ export async function testPlugin(
|
|
|
630
634
|
personas: registry.personas,
|
|
631
635
|
backgroundTargets: registry.backgroundTargets,
|
|
632
636
|
dashboard: registry.dashboard,
|
|
637
|
+
chatCommands: registry.chatCommands,
|
|
633
638
|
tools: registry.tools,
|
|
634
639
|
seeds: registry.seeds,
|
|
635
640
|
prompt: registry.prompt,
|