pi-roundtable 0.7.19 → 0.9.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 +140 -0
- package/README.md +72 -15
- package/README.zh-TW.md +65 -13
- package/docs/migrating-0.8.md +132 -0
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +853 -81
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/discord-adapter.test.ts +42 -0
- package/examples/discord-adapter.ts +26 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/echo-runtime.ts +1 -1
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.test.ts +72 -0
- package/examples/headless.ts +35 -0
- package/examples/http.test.ts +31 -1
- package/examples/http.ts +39 -0
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +7 -3
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +130 -5
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +28 -5
- package/src/cli/init.ts +9 -1
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +33 -2
- package/src/cli/report.ts +12 -3
- package/src/cli/templates.ts +46 -14
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/agent-team-fixture.ts +6 -0
- package/src/core/agents/team-turns.ts +10 -1
- package/src/core/builtin/agent-server.ts +38 -101
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +197 -43
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +120 -0
- package/src/core/config/config.ts +228 -94
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +81 -9
- package/src/core/contract/providers.ts +3 -2
- package/src/core/contract/runtime.ts +31 -11
- package/src/core/contract/surface.ts +35 -6
- package/src/core/conversations/conversation-registry.ts +56 -0
- package/src/core/conversations/conversation-store.ts +126 -0
- package/src/core/conversations/conversations-plugin.ts +29 -0
- package/src/core/define-roundtable.ts +157 -57
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-adapter.ts +9 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -86
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +10 -0
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +33 -2
- package/src/core/domain/progress.ts +10 -0
- package/src/core/events.ts +1 -0
- package/src/core/host.ts +32 -10
- package/src/core/http/listeners.ts +174 -21
- package/src/core/http/websocket.ts +316 -0
- package/src/core/i18n/cards.ts +11 -9
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +43 -2
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/ops/error-reporter.ts +24 -12
- package/src/core/plugin.ts +62 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/channel-router.ts +226 -17
- package/src/core/routing/conversation-turns.ts +85 -1
- package/src/core/routing/surface-port.ts +35 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +124 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
- package/src/core/runtime/pending-confirmation-store.ts +67 -4
- package/src/core/runtime/pi-agent-runtime.ts +143 -135
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +6 -6
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +205 -0
- package/src/core/runtime/runtime-types.ts +40 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +9 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/runtime/turn-progress.ts +116 -0
- package/src/core/services.ts +42 -2
- package/src/core/sessions.ts +30 -0
- package/src/core/shared/unix-server.ts +35 -5
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +149 -38
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +460 -0
- package/src/core/testing/test-host.ts +100 -19
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +10 -0
- package/src/index.ts +65 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +96 -21
- package/templates/.env.example +1 -1
- package/templates/adapters/web/.env.example +24 -0
- package/templates/adapters/web/README.md +22 -0
- package/templates/adapters/web/package.json.tmpl +26 -0
- package/templates/adapters/web/persona/assistant.md +2 -0
- package/templates/adapters/web/roundtable.config.ts +59 -0
- package/templates/package.json.tmpl +1 -0
- package/templates/plugins/self-compact.ts +11 -0
- package/templates/roundtable.config.ts +14 -2
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { openPool } from "../core/db/migrations.ts";
|
|
3
|
+
import { IdentityError } from "../core/domain/errors.ts";
|
|
4
|
+
import type { AccessOwner } from "../core/identity/access-policy.ts";
|
|
5
|
+
import { identityOf, parseIdentity } from "../core/identity/actor-facts.ts";
|
|
6
|
+
import type { DeclaredIdentity } from "../core/identity/plugin-identities.ts";
|
|
7
|
+
import {
|
|
8
|
+
type IdentityLink,
|
|
9
|
+
PgPrincipalStore,
|
|
10
|
+
type PrincipalRecord,
|
|
11
|
+
type Pronouns,
|
|
12
|
+
type RoleGrant,
|
|
13
|
+
} from "../core/identity/principal-store.ts";
|
|
14
|
+
import type { Tier } from "../core/speakers.ts";
|
|
15
|
+
import { CONFIG_FILE } from "./project.ts";
|
|
16
|
+
|
|
17
|
+
export const PRINCIPAL_USAGE = ` roundtable principal list every principal, with its roles and its identities written as
|
|
18
|
+
access in roundtable.config.ts takes them
|
|
19
|
+
roundtable principal show <principal>
|
|
20
|
+
roundtable principal create --name <name> [--pronouns he|she|they]
|
|
21
|
+
roundtable principal link <principal> <provider>:<subject>
|
|
22
|
+
roundtable principal unlink <provider>:<subject>
|
|
23
|
+
roundtable principal grant <principal> member|admin|owner
|
|
24
|
+
roundtable principal revoke <principal> member|admin|owner
|
|
25
|
+
roundtable principal disable <principal>
|
|
26
|
+
roundtable principal enable <principal>
|
|
27
|
+
<principal> is a principal id, or an identity linked to it`;
|
|
28
|
+
|
|
29
|
+
/** What every change prints: a running host reads its own copy for up to 30 seconds. */
|
|
30
|
+
export const HOST_DELAY =
|
|
31
|
+
"A running host sees this within 30 seconds; a host that starts sees it at once.";
|
|
32
|
+
|
|
33
|
+
const TIERS: readonly Tier[] = ["member", "admin", "owner"];
|
|
34
|
+
const PRONOUNS: readonly Pronouns[] = ["he", "she", "they"];
|
|
35
|
+
|
|
36
|
+
export interface PrincipalIo {
|
|
37
|
+
out(line: string): void;
|
|
38
|
+
err(line: string): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The owners the project's configuration names, or undefined when it does not load. */
|
|
42
|
+
type ConfiguredOwners = readonly AccessOwner[] | undefined;
|
|
43
|
+
|
|
44
|
+
/** The identities the project's plugins declare, or undefined when the configuration does not load. */
|
|
45
|
+
export type ConfiguredIdentities = readonly DeclaredIdentity[] | undefined;
|
|
46
|
+
|
|
47
|
+
/** What the project's configuration says, read by its schema alone. */
|
|
48
|
+
export interface Configured {
|
|
49
|
+
owners: ConfiguredOwners;
|
|
50
|
+
identities: ConfiguredIdentities;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A refusal: the message is the whole answer, printed as it is. */
|
|
54
|
+
class Refusal extends Error {}
|
|
55
|
+
|
|
56
|
+
const describe = (principal: PrincipalRecord): string =>
|
|
57
|
+
`${principal.displayName}${principal.pronouns ? ` (${principal.pronouns})` : ""}`;
|
|
58
|
+
|
|
59
|
+
const named = (principal: PrincipalRecord): string =>
|
|
60
|
+
`principal ${principal.id} (${principal.displayName})`;
|
|
61
|
+
|
|
62
|
+
const stamp = (date: Date): string => date.toISOString();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The principal a reference names: its id, or the principal an identity is linked to. A 0.8 id
|
|
66
|
+
* can be both, such as a web user's `oidc:…` id once that identity is linked to someone else;
|
|
67
|
+
* then it is refused rather than guessed.
|
|
68
|
+
*/
|
|
69
|
+
async function find(
|
|
70
|
+
store: PgPrincipalStore,
|
|
71
|
+
reference: string | undefined,
|
|
72
|
+
): Promise<PrincipalRecord> {
|
|
73
|
+
if (reference === undefined) throw new Refusal("name a principal");
|
|
74
|
+
const byId = await store.get(reference);
|
|
75
|
+
const identity = parseIdentity(reference);
|
|
76
|
+
const link =
|
|
77
|
+
identity && (await store.identity(identity.provider, identity.subject));
|
|
78
|
+
const linked = link ? await store.get(link.principalId) : undefined;
|
|
79
|
+
if (byId && linked && byId.id !== linked.id)
|
|
80
|
+
throw new Refusal(
|
|
81
|
+
`${reference} names two principals: ${named(byId)} by its id, and ${named(linked)} by the identity linked to it. Name ${linked.id} by its id; to reach ${byId.id}, unlink ${reference} first, then link it to ${linked.id} again`,
|
|
82
|
+
);
|
|
83
|
+
const found = byId ?? linked;
|
|
84
|
+
if (found) return found;
|
|
85
|
+
throw new Refusal(
|
|
86
|
+
`there is no principal ${reference}, and no identity ${reference} is linked; roundtable principal list shows them`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function identityArgument(text: string | undefined) {
|
|
91
|
+
const identity = text === undefined ? undefined : parseIdentity(text);
|
|
92
|
+
if (!identity)
|
|
93
|
+
throw new Refusal(
|
|
94
|
+
`expected an identity written <provider>:<subject>, such as discord:<user id>, got ${JSON.stringify(text ?? "")}`,
|
|
95
|
+
);
|
|
96
|
+
return identity;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function tierArgument(text: string | undefined): Tier {
|
|
100
|
+
if (!TIERS.includes(text as Tier))
|
|
101
|
+
throw new Refusal(
|
|
102
|
+
`expected a role, one of member, admin, or owner, got ${JSON.stringify(text ?? "")}`,
|
|
103
|
+
);
|
|
104
|
+
return text as Tier;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The plugin of the configuration that declares the identity; undefined when none does, unknown when the configuration does not load. */
|
|
108
|
+
function declarer(
|
|
109
|
+
identities: ConfiguredIdentities,
|
|
110
|
+
link: IdentityLink,
|
|
111
|
+
): string | undefined | "unknown" {
|
|
112
|
+
if (identities === undefined) return "unknown";
|
|
113
|
+
const text = identityOf(link);
|
|
114
|
+
return identities.find((declared) => declared.identity === text)?.plugin;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Where a link came from, and for a plugin's, which plugin declares it. */
|
|
118
|
+
function sourceText(link: IdentityLink, configured: Configured): string {
|
|
119
|
+
if (link.source !== "plugin") return link.source;
|
|
120
|
+
const plugin = declarer(configured.identities, link);
|
|
121
|
+
if (plugin === "unknown") return "plugin";
|
|
122
|
+
return plugin === undefined
|
|
123
|
+
? "plugin, which no plugin declares any more: the next start unlinks it"
|
|
124
|
+
: `plugin ${plugin}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const linkLine = (link: IdentityLink, configured: Configured): string =>
|
|
128
|
+
` ${identityOf(link)} (${sourceText(link, configured)})`;
|
|
129
|
+
|
|
130
|
+
const rolesText = (roles: readonly RoleGrant[]): string =>
|
|
131
|
+
roles.length === 0
|
|
132
|
+
? "none"
|
|
133
|
+
: roles.map((grant) => `${grant.role} (${grant.source})`).join(", ");
|
|
134
|
+
|
|
135
|
+
/** Whether the person of its 0.8 id still links their own identity at first contact, as the identity service claims it. */
|
|
136
|
+
const claimable = (
|
|
137
|
+
principal: PrincipalRecord,
|
|
138
|
+
roles: readonly RoleGrant[],
|
|
139
|
+
): boolean =>
|
|
140
|
+
principal.claimable && !roles.some((grant) => grant.role === "owner");
|
|
141
|
+
|
|
142
|
+
async function list(
|
|
143
|
+
store: PgPrincipalStore,
|
|
144
|
+
io: PrincipalIo,
|
|
145
|
+
configured: Configured,
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
const principals = await store.list();
|
|
148
|
+
for (const principal of principals) {
|
|
149
|
+
const roles = await store.rolesOf(principal.id);
|
|
150
|
+
io.out(
|
|
151
|
+
`${principal.id} ${describe(principal)}${principal.disabled ? " disabled" : ""} roles: ${rolesText(roles)}`,
|
|
152
|
+
);
|
|
153
|
+
const links = await store.identitiesOf(principal.id);
|
|
154
|
+
for (const link of links) io.out(linkLine(link, configured));
|
|
155
|
+
if (links.length === 0)
|
|
156
|
+
io.out(
|
|
157
|
+
claimable(principal, roles)
|
|
158
|
+
? ` no identity linked yet; carried over from 0.8, so ${principal.id} links its own at its first contact`
|
|
159
|
+
: ` no identity linked yet; link one with roundtable principal link ${principal.id} <provider>:<subject>`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
io.out(
|
|
163
|
+
`${principals.length} ${principals.length === 1 ? "principal" : "principals"}. Each identity is written <provider>:<subject>, as access in roundtable.config.ts and roundtable principal link take it.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function show(
|
|
168
|
+
store: PgPrincipalStore,
|
|
169
|
+
principal: PrincipalRecord,
|
|
170
|
+
io: PrincipalIo,
|
|
171
|
+
configured: Configured,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
io.out(`principal ${principal.id}`);
|
|
174
|
+
io.out(` name: ${describe(principal)}`);
|
|
175
|
+
io.out(` status: ${principal.disabled ? "disabled" : "enabled"}`);
|
|
176
|
+
io.out(` created: ${stamp(principal.createdAt)}`);
|
|
177
|
+
io.out(
|
|
178
|
+
` last seen: ${principal.lastSeenAt ? `${stamp(principal.lastSeenAt)} as ${principal.lastTier ?? "unknown"}` : "never"}`,
|
|
179
|
+
);
|
|
180
|
+
io.out(` roles: ${rolesText(await store.rolesOf(principal.id))}`);
|
|
181
|
+
const links = await store.identitiesOf(principal.id);
|
|
182
|
+
io.out(` identities: ${links.length === 0 ? "none" : ""}`.trimEnd());
|
|
183
|
+
for (const link of links)
|
|
184
|
+
io.out(
|
|
185
|
+
` ${identityOf(link)} (${sourceText(link, configured)}, linked ${stamp(link.linkedAt)})`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The value after `flag`, and the arguments without both. */
|
|
190
|
+
function option(
|
|
191
|
+
args: readonly string[],
|
|
192
|
+
flag: string,
|
|
193
|
+
): { value: string | undefined; rest: string[] } {
|
|
194
|
+
const at = args.indexOf(flag);
|
|
195
|
+
if (at === -1) return { value: undefined, rest: [...args] };
|
|
196
|
+
return { value: args[at + 1], rest: args.toSpliced(at, 2) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Each subcommand and how many arguments it takes. */
|
|
200
|
+
const ARITY: Record<string, number> = {
|
|
201
|
+
list: 0,
|
|
202
|
+
show: 1,
|
|
203
|
+
link: 2,
|
|
204
|
+
unlink: 1,
|
|
205
|
+
grant: 2,
|
|
206
|
+
revoke: 2,
|
|
207
|
+
disable: 1,
|
|
208
|
+
enable: 1,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Where the configuration lists an identity it linked: under the owner of the principal it is
|
|
213
|
+
* linked to (or an owner found by their identities), under another owner, nowhere any more, or
|
|
214
|
+
* unknown when the configuration does not load.
|
|
215
|
+
*/
|
|
216
|
+
function listedUnder(
|
|
217
|
+
owners: ConfiguredOwners,
|
|
218
|
+
link: IdentityLink,
|
|
219
|
+
): "this principal" | "another owner" | "nowhere" | "unknown" {
|
|
220
|
+
if (owners === undefined) return "unknown";
|
|
221
|
+
const text = identityOf(link);
|
|
222
|
+
const holders = owners.filter((owner) =>
|
|
223
|
+
owner.identities.some((written) => {
|
|
224
|
+
const ref = parseIdentity(written);
|
|
225
|
+
return ref !== undefined && identityOf(ref) === text;
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
if (holders.length === 0) return "nowhere";
|
|
229
|
+
return holders.some(
|
|
230
|
+
(owner) =>
|
|
231
|
+
owner.principal === undefined || owner.principal === link.principalId,
|
|
232
|
+
)
|
|
233
|
+
? "this principal"
|
|
234
|
+
: "another owner";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function run(
|
|
238
|
+
store: PgPrincipalStore,
|
|
239
|
+
args: readonly string[],
|
|
240
|
+
io: PrincipalIo,
|
|
241
|
+
configured: Configured,
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
const { owners } = configured;
|
|
244
|
+
const [command, ...rest] = args;
|
|
245
|
+
if (command === "create") {
|
|
246
|
+
const name = option(rest, "--name");
|
|
247
|
+
const pronouns = option(name.rest, "--pronouns");
|
|
248
|
+
if (!name.value?.trim() || name.value.startsWith("--"))
|
|
249
|
+
throw new Refusal(
|
|
250
|
+
"create takes --name <name>, the name to address them by",
|
|
251
|
+
);
|
|
252
|
+
if (pronouns.rest.length > 0)
|
|
253
|
+
throw new Refusal(
|
|
254
|
+
`create takes --name and --pronouns, not ${pronouns.rest.join(" ")}`,
|
|
255
|
+
);
|
|
256
|
+
if (
|
|
257
|
+
pronouns.value !== undefined &&
|
|
258
|
+
!PRONOUNS.includes(pronouns.value as Pronouns)
|
|
259
|
+
)
|
|
260
|
+
throw new Refusal(
|
|
261
|
+
`--pronouns takes he, she, or they, not ${JSON.stringify(pronouns.value)}`,
|
|
262
|
+
);
|
|
263
|
+
const made = await store.create({
|
|
264
|
+
displayName: name.value.trim(),
|
|
265
|
+
...(pronouns.value ? { pronouns: pronouns.value as Pronouns } : {}),
|
|
266
|
+
});
|
|
267
|
+
io.out(
|
|
268
|
+
`Created ${named(made)}. Link an identity to it with roundtable principal link ${made.id} <provider>:<subject>.`,
|
|
269
|
+
);
|
|
270
|
+
io.out(HOST_DELAY);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const arity = command === undefined ? undefined : ARITY[command];
|
|
274
|
+
if (arity === undefined)
|
|
275
|
+
throw new Refusal(`unknown principal command. Usage:\n${PRINCIPAL_USAGE}`);
|
|
276
|
+
if (arity !== rest.length)
|
|
277
|
+
throw new Refusal(
|
|
278
|
+
`${command} takes ${arity} ${arity === 1 ? "argument" : "arguments"}. Usage:\n${PRINCIPAL_USAGE}`,
|
|
279
|
+
);
|
|
280
|
+
if (command === "list") return list(store, io, configured);
|
|
281
|
+
if (command === "unlink") {
|
|
282
|
+
const identity = identityArgument(rest[0]);
|
|
283
|
+
const link = await store.identity(identity.provider, identity.subject);
|
|
284
|
+
// A plugin's identity is linked again by the next start while the plugin declares it.
|
|
285
|
+
const plugin =
|
|
286
|
+
link?.source === "plugin"
|
|
287
|
+
? declarer(configured.identities, link)
|
|
288
|
+
: undefined;
|
|
289
|
+
if (link && plugin === "unknown")
|
|
290
|
+
throw new Refusal(
|
|
291
|
+
`${identityOf(identity)} is linked to principal ${link.principalId} as an identity a plugin declares, and ${CONFIG_FILE} does not load here to say whether a plugin still declares it; fix it, then try again`,
|
|
292
|
+
);
|
|
293
|
+
if (link && plugin !== undefined)
|
|
294
|
+
throw new Refusal(
|
|
295
|
+
`${identityOf(identity)} is linked to principal ${link.principalId} as an identity plugin ${plugin} declares; change that plugin's options in ${CONFIG_FILE} instead, and the next start moves or unlinks it`,
|
|
296
|
+
);
|
|
297
|
+
// Whoever linked it (the configuration, a first contact, the CLI, or a 0.8 claim), an identity
|
|
298
|
+
// an owner is listed by would be admitted by a running host as someone new once unlinked here,
|
|
299
|
+
// and the next start would fail on it.
|
|
300
|
+
const listed = link ? listedUnder(owners, link) : "nowhere";
|
|
301
|
+
if (link && (listed === "this principal" || listed === "unknown"))
|
|
302
|
+
throw new Refusal(
|
|
303
|
+
`${identityOf(identity)} is linked to principal ${link.principalId}, ${listed === "unknown" ? `and ${CONFIG_FILE} does not load here to say whether it lists it under an owner; fix it, or` : "which the configuration lists under an owner;"} remove it from access.owners[*].identities in ${CONFIG_FILE} instead, ${link.source === "config" ? "and the next start unlinks it" : "then unlink it here once the host has started with that configuration"}`,
|
|
304
|
+
);
|
|
305
|
+
if (!link || !(await store.unlink(identity.provider, identity.subject)))
|
|
306
|
+
throw new Refusal(
|
|
307
|
+
`${identityOf(identity)} is not linked to any principal`,
|
|
308
|
+
);
|
|
309
|
+
io.out(
|
|
310
|
+
`Unlinked ${identityOf(identity)} from principal ${link.principalId}.`,
|
|
311
|
+
);
|
|
312
|
+
if (listed === "another owner")
|
|
313
|
+
io.out(
|
|
314
|
+
"The configuration lists it under another owner, which a running host does not read: stop the host, then start it with this configuration. If a new p_… principal appears with this identity before then, unlink it again.",
|
|
315
|
+
);
|
|
316
|
+
io.out(HOST_DELAY);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const principal = await find(store, rest[0]);
|
|
320
|
+
if (command === "show") return show(store, principal, io, configured);
|
|
321
|
+
if (command === "link") {
|
|
322
|
+
const identity = identityArgument(rest[1]);
|
|
323
|
+
const spends = claimable(principal, await store.rolesOf(principal.id));
|
|
324
|
+
await store.link(principal.id, identity, "cli");
|
|
325
|
+
io.out(`Linked ${identityOf(identity)} to ${named(principal)}.`);
|
|
326
|
+
if (spends)
|
|
327
|
+
io.out(
|
|
328
|
+
`This uses up the 0.8 claim of principal ${principal.id}: its 0.8 id no longer links its own identity at first contact, even once this one is unlinked. Link each of their other identities here.`,
|
|
329
|
+
);
|
|
330
|
+
} else if (command === "grant") {
|
|
331
|
+
const role = tierArgument(rest[1]);
|
|
332
|
+
// Granting it again would make it the CLI's, outliving its removal from the configuration.
|
|
333
|
+
const held = (await store.rolesOf(principal.id)).find(
|
|
334
|
+
(grant) => grant.role === role,
|
|
335
|
+
);
|
|
336
|
+
if (held?.source === "config") {
|
|
337
|
+
io.out(
|
|
338
|
+
`${named(principal)} already holds ${role}, granted by the configuration; nothing changed.`,
|
|
339
|
+
);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
await store.grant(principal.id, role, "cli");
|
|
343
|
+
io.out(`Granted ${role} to ${named(principal)}.`);
|
|
344
|
+
if (role === "owner")
|
|
345
|
+
io.out(
|
|
346
|
+
"The owner role is granted only here and in the configuration; no role a surface reports makes an owner.",
|
|
347
|
+
);
|
|
348
|
+
} else if (command === "revoke") {
|
|
349
|
+
const role = tierArgument(rest[1]);
|
|
350
|
+
const grant = (await store.rolesOf(principal.id)).find(
|
|
351
|
+
(held) => held.role === role,
|
|
352
|
+
);
|
|
353
|
+
if (!grant)
|
|
354
|
+
throw new Refusal(`principal ${principal.id} does not hold ${role}`);
|
|
355
|
+
if (grant.source === "config")
|
|
356
|
+
throw new Refusal(
|
|
357
|
+
`principal ${principal.id} holds ${role} from the configuration, which grants it again at every start; remove them from access.owners in roundtable.config.ts instead`,
|
|
358
|
+
);
|
|
359
|
+
await store.revoke(principal.id, role, "cli");
|
|
360
|
+
io.out(`Revoked ${role} from ${named(principal)}.`);
|
|
361
|
+
} else if (command === "disable") {
|
|
362
|
+
await store.disable(principal.id);
|
|
363
|
+
io.out(
|
|
364
|
+
`Disabled ${named(principal)}: no surface serves them, and their background turns are skipped.`,
|
|
365
|
+
);
|
|
366
|
+
} else {
|
|
367
|
+
await store.enable(principal.id);
|
|
368
|
+
io.out(`Enabled ${named(principal)}.`);
|
|
369
|
+
}
|
|
370
|
+
io.out(HOST_DELAY);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Whether the host has made the principal tables, which its first start on 0.9 does. */
|
|
374
|
+
async function hasTables(sql: SQL): Promise<boolean> {
|
|
375
|
+
const [row] = await sql`
|
|
376
|
+
SELECT to_regclass('principals') IS NOT NULL
|
|
377
|
+
AND to_regclass('principal_identities') IS NOT NULL
|
|
378
|
+
AND to_regclass('principal_roles') IS NOT NULL AS made`;
|
|
379
|
+
return row?.made === true;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Runs one `roundtable principal` command on the database at `url`, beside a host that may be
|
|
384
|
+
* running, and returns the exit code. It changes the database directly; a running host's cache
|
|
385
|
+
* catches up within 30 seconds, which every change says.
|
|
386
|
+
*/
|
|
387
|
+
export async function principalCommand(
|
|
388
|
+
url: string,
|
|
389
|
+
args: readonly string[],
|
|
390
|
+
io: PrincipalIo,
|
|
391
|
+
configured: Configured,
|
|
392
|
+
): Promise<number> {
|
|
393
|
+
const sql = openPool(url);
|
|
394
|
+
try {
|
|
395
|
+
if (!(await hasTables(sql))) {
|
|
396
|
+
io.err(
|
|
397
|
+
"This database has no principals yet: start the host on pi-roundtable 0.9 once, and its migrations make them from the people it already knows.",
|
|
398
|
+
);
|
|
399
|
+
return 1;
|
|
400
|
+
}
|
|
401
|
+
await run(await PgPrincipalStore.attach(sql), args, io, configured);
|
|
402
|
+
return 0;
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if (error instanceof Refusal || error instanceof IdentityError) {
|
|
405
|
+
io.err(error.message);
|
|
406
|
+
return 1;
|
|
407
|
+
}
|
|
408
|
+
throw error;
|
|
409
|
+
} finally {
|
|
410
|
+
await sql.close();
|
|
411
|
+
}
|
|
412
|
+
}
|
package/src/cli/project.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { liftAdapters } from "../core/config/adapters.ts";
|
|
4
5
|
import {
|
|
5
6
|
type ResolvedConfig,
|
|
6
7
|
type RoundtableConfig,
|
|
@@ -31,6 +32,11 @@ export async function loadConfigFile(cwd: string): Promise<unknown> {
|
|
|
31
32
|
throw new Error(
|
|
32
33
|
`${CONFIG_FILE} is not in ${cwd}. Run this in the project directory, or create a project with \`roundtable init\`.`,
|
|
33
34
|
);
|
|
35
|
+
return loadDefault(path);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The value a configuration file exports by default, imported with Bun. */
|
|
39
|
+
export async function loadDefault(path: string): Promise<unknown> {
|
|
34
40
|
const module = (await import(pathToFileURL(path).href)) as {
|
|
35
41
|
default?: unknown;
|
|
36
42
|
};
|
|
@@ -51,6 +57,16 @@ export interface Assembled {
|
|
|
51
57
|
const message = (error: unknown): string =>
|
|
52
58
|
error instanceof Error ? error.message : String(error);
|
|
53
59
|
|
|
60
|
+
/** The configuration with its adapters lifted, or as written when they cannot be. */
|
|
61
|
+
// pi-lens-ignore: no-unknown-returns — the configuration as written is untyped until the schema checks it
|
|
62
|
+
function lifted(value: unknown): unknown {
|
|
63
|
+
try {
|
|
64
|
+
return liftAdapters(value);
|
|
65
|
+
} catch {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
/**
|
|
55
71
|
* The project's configuration, loaded and resolved at most once and shared by every check, so a
|
|
56
72
|
* mistake in it is reported by the configuration check and the checks that need it say so
|
|
@@ -67,10 +83,14 @@ export class Project {
|
|
|
67
83
|
this.#ports = ports;
|
|
68
84
|
}
|
|
69
85
|
|
|
70
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* The exported value as written, before the schema looks at it, with its adapters' settings in
|
|
88
|
+
* the places the host reads them, such as a Discord adapter's as `discord`. Adapters it cannot
|
|
89
|
+
* read stay as written; the configuration check reports them.
|
|
90
|
+
*/
|
|
71
91
|
raw(): Promise<Loaded<unknown>> {
|
|
72
92
|
this.#raw ??= this.#ports.loadConfig(this.#cwd).then(
|
|
73
|
-
(value): Loaded<unknown> => ({ ok: true, value }),
|
|
93
|
+
(value): Loaded<unknown> => ({ ok: true, value: lifted(value) }),
|
|
74
94
|
(error: unknown): Loaded<unknown> => ({
|
|
75
95
|
ok: false,
|
|
76
96
|
failure: fail(
|
|
@@ -96,6 +116,17 @@ export class Project {
|
|
|
96
116
|
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
97
117
|
}
|
|
98
118
|
|
|
119
|
+
/** Whether the configuration, loaded, leaves the top-level key out; false when it could not be loaded. */
|
|
120
|
+
async leavesOut(key: string): Promise<boolean> {
|
|
121
|
+
const raw = await this.raw();
|
|
122
|
+
return (
|
|
123
|
+
raw.ok &&
|
|
124
|
+
typeof raw.value === "object" &&
|
|
125
|
+
raw.value !== null &&
|
|
126
|
+
(raw.value as Record<string, unknown>)[key] === undefined
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
99
130
|
/** The configuration checked against its schema and assembled, or the failure that says why not. */
|
|
100
131
|
assembled(): Promise<Loaded<Assembled>> {
|
|
101
132
|
this.#assembled ??= this.#assemble();
|
package/src/cli/report.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* How one check ended: passed, passed with a warning and what to do about it, failed with what to
|
|
3
|
+
* do, or not run because it needs something an earlier check reports. A warning fails nothing.
|
|
4
|
+
*/
|
|
2
5
|
export type Result =
|
|
3
6
|
| { status: "ok"; detail?: string }
|
|
7
|
+
| { status: "warn"; problem: string; fix: string }
|
|
4
8
|
| { status: "fail"; problem: string; fix: string }
|
|
5
9
|
| { status: "skipped"; reason: string };
|
|
6
10
|
|
|
@@ -23,6 +27,11 @@ export const fail = (problem: string, fix: string): Result => ({
|
|
|
23
27
|
problem,
|
|
24
28
|
fix,
|
|
25
29
|
});
|
|
30
|
+
export const warn = (problem: string, fix: string): Result => ({
|
|
31
|
+
status: "warn",
|
|
32
|
+
problem,
|
|
33
|
+
fix,
|
|
34
|
+
});
|
|
26
35
|
export const skipped = (reason: string): Result => ({
|
|
27
36
|
status: "skipped",
|
|
28
37
|
reason,
|
|
@@ -53,7 +62,7 @@ export async function runChecks(checks: readonly Check[]): Promise<Outcome[]> {
|
|
|
53
62
|
export const failed = (outcomes: readonly Outcome[]): Outcome[] =>
|
|
54
63
|
outcomes.filter(({ result }) => result.status === "fail");
|
|
55
64
|
|
|
56
|
-
/** One line per check, and for a failure its problem and fix underneath. */
|
|
65
|
+
/** One line per check, and for a warning or a failure its problem and fix underneath. */
|
|
57
66
|
export function formatOutcomes(outcomes: readonly Outcome[]): string[] {
|
|
58
67
|
return outcomes.flatMap(({ name, result }) => {
|
|
59
68
|
if (result.status === "ok")
|
|
@@ -61,7 +70,7 @@ export function formatOutcomes(outcomes: readonly Outcome[]): string[] {
|
|
|
61
70
|
if (result.status === "skipped")
|
|
62
71
|
return [`- ${name}: skipped, ${result.reason}`];
|
|
63
72
|
return [
|
|
64
|
-
|
|
73
|
+
`${result.status === "warn" ? "!" : "✗"} ${name}: ${result.problem}`,
|
|
65
74
|
...result.fix.split("\n").map((line) => ` ${line}`),
|
|
66
75
|
];
|
|
67
76
|
});
|
package/src/cli/templates.ts
CHANGED
|
@@ -19,6 +19,23 @@ const OFFICIAL_DIR = "official";
|
|
|
19
19
|
/** The directory of the plugin `add package` writes around a Pi package. */
|
|
20
20
|
const PACKAGE_DIR = "package";
|
|
21
21
|
|
|
22
|
+
/** The directory of each chat network's files that replace or add to the skeleton's, one directory per adapter. */
|
|
23
|
+
const ADAPTERS_DIR = "adapters";
|
|
24
|
+
|
|
25
|
+
/** The chat networks `init` can set a project up for; Discord's files are the skeleton itself. */
|
|
26
|
+
export const ADAPTERS = ["discord", "web"] as const;
|
|
27
|
+
export type Adapter = (typeof ADAPTERS)[number];
|
|
28
|
+
|
|
29
|
+
/** Whether `name` is an adapter `init` knows. */
|
|
30
|
+
export const isAdapter = (name: string): name is Adapter =>
|
|
31
|
+
(ADAPTERS as readonly string[]).includes(name);
|
|
32
|
+
|
|
33
|
+
/** Skeleton files a project for the adapter leaves out: a web project has no agents and no shared persona. */
|
|
34
|
+
const LEFT_OUT: Readonly<Record<Adapter, readonly string[]>> = {
|
|
35
|
+
discord: [],
|
|
36
|
+
web: ["agents.ts", "persona/shared.md"],
|
|
37
|
+
};
|
|
38
|
+
|
|
22
39
|
/** The plugins the package ships ready-made: `add plugin <name>` copies these instead of the `hello` template, so the names are reserved. */
|
|
23
40
|
export const OFFICIAL_PLUGINS = [
|
|
24
41
|
"codex-images",
|
|
@@ -143,31 +160,46 @@ export function renderPackagePlugin(
|
|
|
143
160
|
}));
|
|
144
161
|
}
|
|
145
162
|
|
|
146
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Every file of a new project for `adapter` (default Discord): the skeleton, without the files the
|
|
165
|
+
* adapter leaves out and with its own files in place of the skeleton's, and the `hello` plugin;
|
|
166
|
+
* paths relative to the project.
|
|
167
|
+
*/
|
|
147
168
|
export function renderProject(
|
|
148
169
|
substitutions: Substitutions,
|
|
149
170
|
dir = TEMPLATES_DIR,
|
|
171
|
+
adapter: Adapter = "discord",
|
|
150
172
|
): Rendered[] {
|
|
151
173
|
if (!existsSync(dir)) throw new Error(`templates are missing: ${dir}`);
|
|
152
174
|
const values = {
|
|
153
175
|
PROJECT: substitutions.project,
|
|
154
176
|
VERSION: substitutions.version,
|
|
155
177
|
};
|
|
178
|
+
const render = (base: string, path: string): Rendered => ({
|
|
179
|
+
path: targetOf(path),
|
|
180
|
+
content: fill(readFileSync(join(base, path), "utf8"), values),
|
|
181
|
+
});
|
|
182
|
+
const overlayDir = join(dir, ADAPTERS_DIR, adapter);
|
|
183
|
+
const overlay = existsSync(overlayDir)
|
|
184
|
+
? filesUnder(overlayDir).map((file) =>
|
|
185
|
+
render(overlayDir, relative(overlayDir, file)),
|
|
186
|
+
)
|
|
187
|
+
: [];
|
|
188
|
+
const replaced = new Set([
|
|
189
|
+
...LEFT_OUT[adapter],
|
|
190
|
+
...overlay.map((file) => file.path),
|
|
191
|
+
]);
|
|
156
192
|
const skeleton = filesUnder(dir)
|
|
157
193
|
.map((file) => relative(dir, file))
|
|
158
|
-
.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
},
|
|
168
|
-
],
|
|
169
|
-
);
|
|
170
|
-
return [...skeleton, ...renderPlugin(pluginNames("hello"), dir)];
|
|
194
|
+
.filter(
|
|
195
|
+
(path) =>
|
|
196
|
+
![PLUGIN_DIR, OFFICIAL_DIR, PACKAGE_DIR, ADAPTERS_DIR].some((sub) =>
|
|
197
|
+
path.startsWith(`${sub}/`),
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
.map((path) => render(dir, path))
|
|
201
|
+
.filter((file) => !replaced.has(file.path));
|
|
202
|
+
return [...skeleton, ...overlay, ...renderPlugin(pluginNames("hello"), dir)];
|
|
171
203
|
}
|
|
172
204
|
|
|
173
205
|
/** Writes rendered files under `root`, creating directories; the caller has checked that none exists. */
|