pi-roundtable 0.30.0 → 0.30.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/docs/plugins.md +2 -1
- package/package.json +1 -1
- package/src/core/host-registration.ts +1 -1
- package/src/core/plugin.ts +13 -13
- package/src/core/registry/chat-commands.ts +99 -0
- package/src/core/registry/contributions.ts +14 -2
- package/src/core/runtime/session-factory.ts +3 -0
- package/src/testing.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.30.3] - 2026-11-01
|
|
9
|
+
|
|
10
|
+
- 0.30.2 was tagged but never published: its release run stopped on lint (a hook's dependency suppression the change had dropped) before any package was published. 0.30.3 carries 0.30.2's changes. No core behavior changes.
|
|
11
|
+
|
|
12
|
+
## [0.30.2] - 2026-11-01
|
|
13
|
+
|
|
14
|
+
- Release for the web package's changes (see its changelog); no core behavior changes.
|
|
15
|
+
|
|
16
|
+
## [0.30.1] - 2026-11-01
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- A declared chat command is listed (`PluginContext.chatCommands()`, the web command menu) only after a session the host built has loaded it from an extension of the declaring plugin: a package in its own `piPackages` or one of its own session tools. A declaration nothing registers, or that another plugin's extension registers, is no longer listed, so a plugin cannot label another package's command with its own description. `LinkedSessions` has a new optional `chatCommands` part that the runtime tells of each built session's commands; a hand-built `LinkedSessions` without it still works and lists no declared command. An extension belongs to the package whose folder is nearest to it (the nearest `package.json`), so a package installed inside another's folder is not taken for the outer one.
|
|
21
|
+
|
|
8
22
|
## [0.30.0] - 2026-11-01
|
|
9
23
|
|
|
10
24
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -2351,9 +2351,10 @@ export default {
|
|
|
2351
2351
|
|
|
2352
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
2353
|
- `description` is one line for the menu.
|
|
2354
|
+
- A declaration is a label until a session proves it: a command is listed only after a session the host built has loaded it from an extension of the declaring plugin itself, that is a package in the plugin's own `piPackages` or one of its own `sessionTools` (or `tools`). A name nothing registers is never listed, and neither is a name that another plugin's extension registers: declare a command from the plugin that carries it. The host's startup check builds the first sessions, so the list is complete before any service starts; an extension that only a later session loads joins the list when that session is built. The host does not refuse such a declaration, it only leaves it out. An extension belongs to the package of the nearest folder above it with a `package.json`, so a package installed inside another package's folder counts as its own. Until the first session is built the list is empty (so is it under `testPlugin`, which builds no session), and a command stays listed for the life of the process once a session has loaded it.
|
|
2354
2355
|
- 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
2356
|
- 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
|
|
2357
|
+
- `context.chatCommands()` returns the declarations that are listed (see above) in contribution order, once linked; during `setup` it throws `NotLinkedError`. The web app reads it for `GET api/rooms/<room>/commands`.
|
|
2357
2358
|
|
|
2358
2359
|
### `sessionTools`: the raw form of `tools`
|
|
2359
2360
|
|
package/package.json
CHANGED
|
@@ -218,7 +218,7 @@ export class Linkage {
|
|
|
218
218
|
throw new NotLinkedError(
|
|
219
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
220
|
);
|
|
221
|
-
return this.registry.
|
|
221
|
+
return this.registry.chatCommandSources.listed();
|
|
222
222
|
},
|
|
223
223
|
observe: (filter, observer) => this.observers.add(filter, observer),
|
|
224
224
|
apiKey: async (provider) => this.#options.apiKey?.(provider),
|
package/src/core/plugin.ts
CHANGED
|
@@ -37,10 +37,7 @@ import type {
|
|
|
37
37
|
import type { Speaker, Tier } from "./speakers.ts";
|
|
38
38
|
import type { ToolTiers } from "./tool-tiers.ts";
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
* A long-lived part of a plugin. Services start in registration order and stop in reverse,
|
|
42
|
-
* so one registered earlier outlives those registered after it.
|
|
43
|
-
*/
|
|
40
|
+
/** A long-lived part of a plugin. Services start in registration order and stop in reverse, so one registered earlier outlives those after it. */
|
|
44
41
|
export interface Service {
|
|
45
42
|
name: string;
|
|
46
43
|
start?(): Promise<void> | void;
|
|
@@ -291,12 +288,18 @@ export interface LinkedSessions {
|
|
|
291
288
|
agentTools: readonly string[];
|
|
292
289
|
/** Every plugin's agent selection merged, read before each agent turn. */
|
|
293
290
|
agentSelection(): ToolSelection;
|
|
291
|
+
/** Told which commands each built session registers, so only declared commands that exist are listed; a host that links sessions by hand may leave it out, and then no declared command is listed. */
|
|
292
|
+
chatCommands?: {
|
|
293
|
+
loaded(
|
|
294
|
+
commands: readonly {
|
|
295
|
+
invocationName: string;
|
|
296
|
+
sourceInfo: { path: string; baseDir?: string };
|
|
297
|
+
}[],
|
|
298
|
+
): void;
|
|
299
|
+
};
|
|
294
300
|
}
|
|
295
301
|
|
|
296
|
-
/**
|
|
297
|
-
* What the agents' turns may use of the machine: "full" gives them the shell and file tools, and
|
|
298
|
-
* "chat-only" none of them, as `agentServer: { tools: "chat-only" }` configures.
|
|
299
|
-
*/
|
|
302
|
+
/** What the agents' turns may use of the machine: "full" has the shell and file tools, "chat-only" none (`agentServer: { tools: "chat-only" }`). */
|
|
300
303
|
export type AgentTools = "full" | "chat-only";
|
|
301
304
|
|
|
302
305
|
/** The host's own locale and time zone, as its plugins read them. */
|
|
@@ -378,7 +381,7 @@ export interface PluginContext {
|
|
|
378
381
|
providers: ResolvedProviders;
|
|
379
382
|
/** Every plugin's dashboard lines, in contribution order; throws NotLinkedError during setup. */
|
|
380
383
|
dashboard(): readonly string[];
|
|
381
|
-
/**
|
|
384
|
+
/** The declared chat commands a loaded extension of the declaring plugin registers, in contribution order; throws NotLinkedError during setup. */
|
|
382
385
|
chatCommands(): readonly ChatCommand[];
|
|
383
386
|
/**
|
|
384
387
|
* The credential the host's model login holds for a provider, such as `openai-codex`: the same
|
|
@@ -395,10 +398,7 @@ export interface PluginContext {
|
|
|
395
398
|
listener(id: string): Readonly<ListenerAddress> | undefined;
|
|
396
399
|
}
|
|
397
400
|
|
|
398
|
-
/**
|
|
399
|
-
* An identity a plugin's own credential stands for, such as the bearer token of an endpoint it
|
|
400
|
-
* serves, and the principal it speaks as.
|
|
401
|
-
*/
|
|
401
|
+
/** An identity a plugin's own credential stands for, such as the bearer token of an endpoint it serves, and the principal it speaks as. */
|
|
402
402
|
export interface PluginIdentity {
|
|
403
403
|
/**
|
|
404
404
|
* Written `token:<subject>`, such as `token:remote-mcp`. Only the `token` provider is accepted:
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
1
3
|
import type { ChatCommand } from "../contract/chat-command.ts";
|
|
2
4
|
import { PluginError } from "../errors.ts";
|
|
5
|
+
import { packageDir } from "../shared/package-dir.ts";
|
|
3
6
|
|
|
4
7
|
const CHAT_COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
5
8
|
/** The commands the web app answers itself, so a declared command cannot take their names. */
|
|
@@ -31,3 +34,99 @@ export function claimChatCommand(
|
|
|
31
34
|
);
|
|
32
35
|
names.set(name, plugin);
|
|
33
36
|
}
|
|
37
|
+
|
|
38
|
+
/** Where a plugin's extensions come from: its Pi packages, and the names of its session tools. */
|
|
39
|
+
export interface ChatCommandOrigin {
|
|
40
|
+
packages: readonly string[];
|
|
41
|
+
sessionTools: readonly string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A command a loaded Pi session registers, with the extension that registered it (Pi's `sourceInfo`). */
|
|
45
|
+
export interface LoadedCommand {
|
|
46
|
+
/** What a message starts with after the slash. */
|
|
47
|
+
invocationName: string;
|
|
48
|
+
sourceInfo: { path: string; baseDir?: string };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Tells which declared chat commands are real. A declaration is only a label; a command is listed
|
|
53
|
+
* once a session the host built has loaded it from an extension of the plugin that declared it: a
|
|
54
|
+
* package that plugin lists in `piPackages`, or one of its session tools. A declaration nothing
|
|
55
|
+
* registers, or one that another plugin's extension registers, is never listed.
|
|
56
|
+
*/
|
|
57
|
+
export class ChatCommandSources {
|
|
58
|
+
readonly #declared = new Map<
|
|
59
|
+
string,
|
|
60
|
+
{ command: ChatCommand; origin: ChatCommandOrigin }
|
|
61
|
+
>();
|
|
62
|
+
/** Each command name a session registered, with the extensions that registered it. */
|
|
63
|
+
readonly #loaded = new Map<string, LoadedCommand["sourceInfo"][]>();
|
|
64
|
+
readonly #dirs = new Map<string, string | undefined>();
|
|
65
|
+
readonly #roots = new Map<string, string | undefined>();
|
|
66
|
+
|
|
67
|
+
/** `resolve` finds a package's folder by name; tests give their own. */
|
|
68
|
+
readonly #resolve: (name: string) => string;
|
|
69
|
+
|
|
70
|
+
constructor(resolve: (name: string) => string = packageDir) {
|
|
71
|
+
this.#resolve = resolve;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
declare(command: ChatCommand, origin: ChatCommandOrigin): void {
|
|
75
|
+
this.#declared.set(command.name, { command, origin });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Records the commands a built session registered; called for every session, so the list grows as extensions load. */
|
|
79
|
+
loaded(commands: readonly LoadedCommand[]): void {
|
|
80
|
+
for (const { invocationName, sourceInfo } of commands) {
|
|
81
|
+
const seen = this.#loaded.get(invocationName) ?? [];
|
|
82
|
+
if (!seen.some((one) => one.path === sourceInfo.path))
|
|
83
|
+
this.#loaded.set(invocationName, [...seen, sourceInfo]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The declared commands a loaded extension of the declaring plugin registers, in declaration order. */
|
|
88
|
+
listed(): ChatCommand[] {
|
|
89
|
+
return [...this.#declared.values()].flatMap(({ command, origin }) =>
|
|
90
|
+
(this.#loaded.get(command.name) ?? []).some((source) =>
|
|
91
|
+
this.#owns(origin, source),
|
|
92
|
+
)
|
|
93
|
+
? [command]
|
|
94
|
+
: [],
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#owns(origin: ChatCommandOrigin, source: LoadedCommand["sourceInfo"]) {
|
|
99
|
+
if (origin.sessionTools.some((name) => source.path === `<inline:${name}>`))
|
|
100
|
+
return true;
|
|
101
|
+
return origin.packages.some((name) => {
|
|
102
|
+
const dir = this.#dir(name);
|
|
103
|
+
// The extension's own package is the nearest folder with a package.json, so a package
|
|
104
|
+
// installed inside another's folder (nested node_modules) is not taken for the outer one.
|
|
105
|
+
return dir !== undefined && this.#root(source.path) === dir;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#root(path: string): string | undefined {
|
|
110
|
+
if (!this.#roots.has(path)) {
|
|
111
|
+
let dir = existsSync(join(path, "package.json")) ? path : dirname(path);
|
|
112
|
+
while (!existsSync(join(dir, "package.json")) && dirname(dir) !== dir)
|
|
113
|
+
dir = dirname(dir);
|
|
114
|
+
this.#roots.set(
|
|
115
|
+
path,
|
|
116
|
+
existsSync(join(dir, "package.json")) ? dir : undefined,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return this.#roots.get(path);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#dir(name: string): string | undefined {
|
|
123
|
+
if (!this.#dirs.has(name)) {
|
|
124
|
+
try {
|
|
125
|
+
this.#dirs.set(name, this.#resolve(name));
|
|
126
|
+
} catch {
|
|
127
|
+
this.#dirs.set(name, undefined);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return this.#dirs.get(name);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
} from "../sessions.ts";
|
|
35
35
|
import { TIERS } from "../speakers.ts";
|
|
36
36
|
import type { ToolTierTable } from "../tool-tiers.ts";
|
|
37
|
-
import { claimChatCommand } from "./chat-commands.ts";
|
|
37
|
+
import { ChatCommandSources, claimChatCommand } from "./chat-commands.ts";
|
|
38
38
|
import { ServiceRegistry } from "./services.ts";
|
|
39
39
|
import { surfaceService } from "./surface-service.ts";
|
|
40
40
|
|
|
@@ -57,6 +57,8 @@ export interface Registry {
|
|
|
57
57
|
directChannels: DirectChannelProvider[];
|
|
58
58
|
dashboard: string[];
|
|
59
59
|
chatCommands: ChatCommand[];
|
|
60
|
+
/** Which of the declared chat commands a loaded extension really registers. */
|
|
61
|
+
chatCommandSources: ChatCommandSources;
|
|
60
62
|
tools: ToolContribution[];
|
|
61
63
|
seeds: AgentSeed[];
|
|
62
64
|
prompt: PromptSection[];
|
|
@@ -79,6 +81,7 @@ export const emptyRegistry = (): Registry => ({
|
|
|
79
81
|
directChannels: [],
|
|
80
82
|
dashboard: [],
|
|
81
83
|
chatCommands: [],
|
|
84
|
+
chatCommandSources: new ChatCommandSources(),
|
|
82
85
|
tools: [],
|
|
83
86
|
seeds: [],
|
|
84
87
|
prompt: [],
|
|
@@ -451,8 +454,16 @@ export async function collectContributions(
|
|
|
451
454
|
registry.backgroundTargets.push(...backgroundTargets);
|
|
452
455
|
registry.directChannels.push(...directChannels);
|
|
453
456
|
registry.dashboard.push(...dashboard);
|
|
454
|
-
for (const command of chatCommands)
|
|
457
|
+
for (const command of chatCommands) {
|
|
455
458
|
claimChatCommand(chatCommandNames, plugin.name, command);
|
|
459
|
+
registry.chatCommandSources.declare(command, {
|
|
460
|
+
packages: piPackages,
|
|
461
|
+
sessionTools: [
|
|
462
|
+
...(contribution.sessionTools ?? []),
|
|
463
|
+
...tools.map((tool) => tool.session),
|
|
464
|
+
].map((tool) => tool.name),
|
|
465
|
+
});
|
|
466
|
+
}
|
|
456
467
|
registry.chatCommands.push(...chatCommands);
|
|
457
468
|
checkSeedCharacters(plugin.name, seeds);
|
|
458
469
|
registry.seeds.push(...seeds);
|
|
@@ -481,5 +492,6 @@ export function linkSessions(registry: Registry): LinkedSessions {
|
|
|
481
492
|
requiredTools: [...new Set(registry.requiredTools)],
|
|
482
493
|
agentTools: registry.tools.flatMap((t) => (t.agent ? [t.name] : [])),
|
|
483
494
|
agentSelection: () => mergeSelections(registry.agentSelections),
|
|
495
|
+
chatCommands: registry.chatCommandSources,
|
|
484
496
|
};
|
|
485
497
|
}
|
|
@@ -485,6 +485,9 @@ export class SessionFactory {
|
|
|
485
485
|
}
|
|
486
486
|
await session.setModel(resolved);
|
|
487
487
|
await this.#awaitTools(session, awaited);
|
|
488
|
+
this.link().chatCommands?.loaded(
|
|
489
|
+
session.extensionRunner.getRegisteredCommands(),
|
|
490
|
+
);
|
|
488
491
|
session.subscribe((event) => {
|
|
489
492
|
if (event.type === "compaction_end")
|
|
490
493
|
this.#logCompaction(channel, event, session, tiers);
|
package/src/testing.ts
CHANGED
|
@@ -557,7 +557,7 @@ export async function testPlugin(
|
|
|
557
557
|
providers,
|
|
558
558
|
dashboard: () => (linked ? registry.dashboard : unlinked("dashboard")),
|
|
559
559
|
chatCommands: () =>
|
|
560
|
-
linked ? registry.
|
|
560
|
+
linked ? registry.chatCommandSources.listed() : unlinked("chatCommands"),
|
|
561
561
|
apiKey: async (provider) =>
|
|
562
562
|
Object.hasOwn(options.apiKeys ?? {}, provider)
|
|
563
563
|
? options.apiKeys?.[provider]
|