pi-roundtable 0.8.0 → 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 +102 -0
- package/README.md +35 -11
- package/README.zh-TW.md +27 -10
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +606 -78
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.ts +5 -1
- 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 +5 -2
- 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 +115 -1
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +7 -0
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +5 -0
- package/src/cli/report.ts +12 -3
- 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/team-turns.ts +8 -4
- package/src/core/builtin/agent-server.ts +9 -7
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +140 -85
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +64 -1
- package/src/core/config/config.ts +78 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +77 -9
- package/src/core/contract/runtime.ts +23 -7
- package/src/core/contract/surface.ts +22 -6
- package/src/core/conversations/conversation-registry.ts +14 -0
- package/src/core/conversations/conversation-store.ts +13 -0
- package/src/core/define-roundtable.ts +41 -11
- 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-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 -97
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +7 -2
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +27 -2
- package/src/core/host.ts +27 -8
- package/src/core/i18n/cards.ts +8 -4
- 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/plugin.ts +48 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/routing/channel-router.ts +218 -17
- package/src/core/routing/conversation-turns.ts +38 -8
- package/src/core/routing/surface-port.ts +32 -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 +28 -14
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/pending-confirmation-store.ts +30 -5
- package/src/core/runtime/pi-agent-runtime.ts +135 -134
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +4 -4
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +50 -5
- 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/services.ts +27 -1
- package/src/core/sessions.ts +30 -0
- 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 +108 -21
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +118 -41
- package/src/core/testing/test-host.ts +79 -16
- 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 +8 -0
- package/src/index.ts +47 -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 +55 -16
- package/templates/.env.example +1 -1
- package/templates/adapters/web/README.md +7 -0
- package/templates/adapters/web/roundtable.config.ts +14 -10
- package/templates/roundtable.config.ts +12 -1
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
1
2
|
import { nested, openPool, runMigrations } from "../../core/db/migrations.ts";
|
|
2
3
|
import { MigrationError } from "../../core/errors.ts";
|
|
3
4
|
import type { RoundtablePlugin } from "../../core/plugin.ts";
|
|
@@ -12,6 +13,8 @@ export interface DatabasePort {
|
|
|
12
13
|
| readonly Pick<RoundtablePlugin, "name" | "migrations">[]
|
|
13
14
|
| undefined,
|
|
14
15
|
): Promise<void>;
|
|
16
|
+
/** Runs `use` on a pool of the database at `url`, closed after; `use` only reads. */
|
|
17
|
+
read<T>(url: string, use: (sql: SQL) => Promise<T>): Promise<T>;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
class Rollback extends Error {}
|
|
@@ -39,6 +42,14 @@ export const postgres: DatabasePort = {
|
|
|
39
42
|
await pool.close();
|
|
40
43
|
}
|
|
41
44
|
},
|
|
45
|
+
async read(url, use) {
|
|
46
|
+
const pool = openPool(url);
|
|
47
|
+
try {
|
|
48
|
+
return await use(pool);
|
|
49
|
+
} finally {
|
|
50
|
+
await pool.close();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
42
53
|
};
|
|
43
54
|
|
|
44
55
|
/** The address without its password, safe to print. */
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { LEGACY_ACCESS } from "../../core/config/access.ts";
|
|
3
|
+
import type { ResolvedConfig } from "../../core/config/config.ts";
|
|
4
|
+
import type { AccessRules } from "../../core/identity/access-policy.ts";
|
|
5
|
+
import { identityOf, parseIdentity } from "../../core/identity/actor-facts.ts";
|
|
6
|
+
import { declaredConflict } from "../../core/identity/config-owner.ts";
|
|
7
|
+
import { backfillLine } from "../../core/identity/identity-plugin.ts";
|
|
8
|
+
import {
|
|
9
|
+
type BackfillSummary,
|
|
10
|
+
backfillPrincipals,
|
|
11
|
+
} from "../../core/identity/identity-schema.ts";
|
|
12
|
+
import {
|
|
13
|
+
type DeclaredIdentity,
|
|
14
|
+
declaredIdentities,
|
|
15
|
+
} from "../../core/identity/plugin-identities.ts";
|
|
16
|
+
import { PgPrincipalStore } from "../../core/identity/principal-store.ts";
|
|
17
|
+
import type { Project } from "../project.ts";
|
|
18
|
+
import { fail, ok, type Result, skipped, warn } from "../report.ts";
|
|
19
|
+
import type { DatabasePort } from "./database.ts";
|
|
20
|
+
|
|
21
|
+
const list = (items: readonly string[]): string =>
|
|
22
|
+
new Intl.ListFormat("en").format(items);
|
|
23
|
+
|
|
24
|
+
const message = (error: unknown): string =>
|
|
25
|
+
error instanceof Error ? error.message : String(error);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The chat surfaces the host talks through, as `everyone` names them: Discord when it is
|
|
29
|
+
* configured, and each web chat plugin's surface, read from its name (`webchat` is `web`,
|
|
30
|
+
* `webchat-<surface>` is `<surface>`). A surface another plugin adds at setup is not seen here.
|
|
31
|
+
*/
|
|
32
|
+
export function surfacesOf(
|
|
33
|
+
config: Pick<ResolvedConfig, "discord">,
|
|
34
|
+
plugins: readonly { name: string }[],
|
|
35
|
+
): string[] {
|
|
36
|
+
const surfaces = config.discord ? ["discord"] : [];
|
|
37
|
+
for (const { name } of plugins) {
|
|
38
|
+
if (name === "webchat") surfaces.push("web");
|
|
39
|
+
else if (name.startsWith("webchat-"))
|
|
40
|
+
surfaces.push(name.slice("webchat-".length));
|
|
41
|
+
}
|
|
42
|
+
return surfaces;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whom the configuration serves: the owners by name, a warning when `everyone: true` admits
|
|
47
|
+
* everyone on every surface of a host with more than one, and a note when it is written in the
|
|
48
|
+
* deprecated 0.8 form. It reads no database, so `start` runs it too.
|
|
49
|
+
*/
|
|
50
|
+
export async function checkAccess(project: Project): Promise<Result> {
|
|
51
|
+
const assembled = await project.assembled();
|
|
52
|
+
if (!assembled.ok) return skipped("the configuration is not valid yet");
|
|
53
|
+
const { config, defined } = assembled.value;
|
|
54
|
+
const surfaces = surfacesOf(config, defined.plugins);
|
|
55
|
+
const broad = (["admins", "members"] as const).filter(
|
|
56
|
+
(tier) => config.access[tier]?.everyone === true,
|
|
57
|
+
);
|
|
58
|
+
if (broad.length > 0 && surfaces.length > 1)
|
|
59
|
+
return warn(
|
|
60
|
+
`${list(broad.map((tier) => `access.${tier}.everyone`))} ${broad.length === 1 ? "is" : "are"} true, which admits everyone on every surface: ${list(surfaces)}.`,
|
|
61
|
+
`Name the surfaces where everyone may talk, such as everyone: ["discord"], and admit the others by identity or by role, such as roles: ["web:role:<name>"].`,
|
|
62
|
+
);
|
|
63
|
+
const owners = config.access.owners;
|
|
64
|
+
const legacy = config.deprecations.includes(LEGACY_ACCESS)
|
|
65
|
+
? "; written as the 0.8 owner and speakers, which roundtable upgrade rewrites as access"
|
|
66
|
+
: "";
|
|
67
|
+
return ok(
|
|
68
|
+
`${owners.length} ${owners.length === 1 ? "owner" : "owners"}, ${list(owners.map((owner) => owner.name))}${legacy}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the database says about the configured owners, and what the next start's backfill would make. */
|
|
73
|
+
export interface PrincipalFindings {
|
|
74
|
+
summary: BackfillSummary;
|
|
75
|
+
/** A configured owner's identity linked to another principal, which stops the start, and how to fix it. */
|
|
76
|
+
conflicts: { problem: string; identity: string; fix: string }[];
|
|
77
|
+
/** Configured owners whose principal is disabled. */
|
|
78
|
+
disabled: { index: number; id: string; name: string }[];
|
|
79
|
+
/** Whether any owner, configured or granted by the CLI, can reach the host. */
|
|
80
|
+
reachable: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reads, without writing, what the identity plugin would do at the next start: the backfill as a
|
|
85
|
+
* dry run, and the configured owners' identity links as its sync checks them. `declaredBy` holds
|
|
86
|
+
* each identity the plugins declare, with the plugin and the principal it names.
|
|
87
|
+
*/
|
|
88
|
+
export async function principalFindings(
|
|
89
|
+
sql: SQL,
|
|
90
|
+
rules: AccessRules,
|
|
91
|
+
declaredBy: ReadonlyMap<string, DeclaredIdentity> = new Map(),
|
|
92
|
+
): Promise<PrincipalFindings> {
|
|
93
|
+
const summary = await backfillPrincipals(sql, rules, { dryRun: true });
|
|
94
|
+
const [tables] = await sql`
|
|
95
|
+
SELECT to_regclass('principal_identities') IS NOT NULL
|
|
96
|
+
AND to_regclass('principal_roles') IS NOT NULL AS made`;
|
|
97
|
+
// Before the first start on 0.9, the start makes every configured owner, enabled.
|
|
98
|
+
if (tables?.made !== true)
|
|
99
|
+
return { summary, conflicts: [], disabled: [], reachable: true };
|
|
100
|
+
const store = await PgPrincipalStore.attach(sql);
|
|
101
|
+
const conflicts: PrincipalFindings["conflicts"] = [];
|
|
102
|
+
// The primary owner's principal, undefined when the start makes it.
|
|
103
|
+
let primary: string | undefined;
|
|
104
|
+
const disabled: PrincipalFindings["disabled"] = [];
|
|
105
|
+
let reachable = false;
|
|
106
|
+
for (const [index, owner] of rules.owners.entries()) {
|
|
107
|
+
let id = owner.principal;
|
|
108
|
+
const links = await Promise.all(
|
|
109
|
+
owner.identities.map(async (identity) => {
|
|
110
|
+
const ref = parseIdentity(identity);
|
|
111
|
+
const link = ref && (await store.identity(ref.provider, ref.subject));
|
|
112
|
+
// A plugin's link no plugin declares any more is unlinked by the start before the owners are synced.
|
|
113
|
+
return link?.source === "plugin" && !declaredBy.has(identityOf(link))
|
|
114
|
+
? undefined
|
|
115
|
+
: link;
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
const unlink = (identity: string) =>
|
|
119
|
+
`Unlink it with roundtable principal unlink ${identity}, or fix access.owners in roundtable.config.ts`;
|
|
120
|
+
// As at the start, a plugin's link never tells who the owner is.
|
|
121
|
+
for (const [i, link] of links.entries()) {
|
|
122
|
+
if (!link || link.source === "plugin") continue;
|
|
123
|
+
id ??= link.principalId;
|
|
124
|
+
const identity = owner.identities[i] ?? "";
|
|
125
|
+
if (link.principalId !== id)
|
|
126
|
+
conflicts.push({
|
|
127
|
+
problem: `access.owners[${index}].identities[${i}]: ${identity} is linked to principal ${link.principalId}, not to this owner's ${id}`,
|
|
128
|
+
identity,
|
|
129
|
+
fix: unlink(identity),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// As at the start, an identity a plugin declares is bound to this owner on this start, or it stops it.
|
|
133
|
+
for (const [i, identity] of owner.identities.entries()) {
|
|
134
|
+
const ref = parseIdentity(identity);
|
|
135
|
+
const declared = ref && declaredBy.get(identityOf(ref));
|
|
136
|
+
if (!declared) continue;
|
|
137
|
+
const conflict = declaredConflict({
|
|
138
|
+
index,
|
|
139
|
+
i,
|
|
140
|
+
identity,
|
|
141
|
+
id,
|
|
142
|
+
link: links[i],
|
|
143
|
+
declared,
|
|
144
|
+
primary,
|
|
145
|
+
first: index === 0,
|
|
146
|
+
});
|
|
147
|
+
if (conflict) conflicts.push({ ...conflict, identity });
|
|
148
|
+
}
|
|
149
|
+
if (index === 0) primary = id;
|
|
150
|
+
const principal = id === undefined ? undefined : await store.get(id);
|
|
151
|
+
if (principal?.disabled)
|
|
152
|
+
disabled.push({ index, id: principal.id, name: owner.name });
|
|
153
|
+
else reachable = true;
|
|
154
|
+
}
|
|
155
|
+
for (const holder of await store.holders("owner"))
|
|
156
|
+
if (
|
|
157
|
+
holder.source === "cli" &&
|
|
158
|
+
(await store.get(holder.principalId))?.disabled === false
|
|
159
|
+
)
|
|
160
|
+
reachable = true;
|
|
161
|
+
return { summary, conflicts, disabled, reachable };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The principals the next start would make from the people 0.8 stored, as a dry run printed as the
|
|
166
|
+
* start logs it; a configured owner's identity linked to another principal, which stops the
|
|
167
|
+
* start; and a disabled configured owner, or no owner left who can reach the host.
|
|
168
|
+
*/
|
|
169
|
+
export async function checkPrincipals(
|
|
170
|
+
project: Project,
|
|
171
|
+
database: DatabasePort,
|
|
172
|
+
): Promise<Result> {
|
|
173
|
+
const url = await project.text("database", "url");
|
|
174
|
+
if (!url) return skipped("database.url has no value");
|
|
175
|
+
const assembled = await project.assembled();
|
|
176
|
+
if (!assembled.ok) return skipped("the configuration is not valid yet");
|
|
177
|
+
const rules = assembled.value.config.access;
|
|
178
|
+
let declaredBy: Map<string, DeclaredIdentity>;
|
|
179
|
+
try {
|
|
180
|
+
declaredBy = new Map(
|
|
181
|
+
declaredIdentities(assembled.value.defined.plugins).map((declared) => [
|
|
182
|
+
declared.identity,
|
|
183
|
+
declared,
|
|
184
|
+
]),
|
|
185
|
+
);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return skipped(
|
|
188
|
+
`the plugins' identities are not valid yet (${message(error)})`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
let findings: PrincipalFindings;
|
|
192
|
+
try {
|
|
193
|
+
findings = await database.read(url, (sql) =>
|
|
194
|
+
principalFindings(sql, rules, declaredBy),
|
|
195
|
+
);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return skipped(`PostgreSQL did not answer (${message(error)})`);
|
|
198
|
+
}
|
|
199
|
+
const dryRun = `dry run, nothing written: ${backfillLine(findings.summary)}`;
|
|
200
|
+
if (findings.conflicts.length > 0)
|
|
201
|
+
return fail(
|
|
202
|
+
`${findings.conflicts.map((conflict) => conflict.problem).join("; ")}; the host does not start until this is fixed.`,
|
|
203
|
+
findings.conflicts.map((conflict) => conflict.fix).join("\n"),
|
|
204
|
+
);
|
|
205
|
+
if (findings.disabled.length > 0) {
|
|
206
|
+
const who = list(
|
|
207
|
+
findings.disabled.map(
|
|
208
|
+
(owner) =>
|
|
209
|
+
`access.owners[${owner.index}] ${owner.name} (principal ${owner.id})`,
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
return warn(
|
|
213
|
+
findings.reachable
|
|
214
|
+
? `${who} ${findings.disabled.length === 1 ? "is" : "are"} disabled, so the host does not serve them.`
|
|
215
|
+
: `no owner can reach the host: ${who} ${findings.disabled.length === 1 ? "is" : "are"} disabled.`,
|
|
216
|
+
[
|
|
217
|
+
...findings.disabled.map(
|
|
218
|
+
(owner) => `Enable with roundtable principal enable ${owner.id}`,
|
|
219
|
+
),
|
|
220
|
+
dryRun,
|
|
221
|
+
].join("\n"),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return ok(dryRun);
|
|
225
|
+
}
|
package/src/cli/checks/model.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { parseModelRef } from "../../core/models.ts";
|
|
2
|
+
import {
|
|
3
|
+
BRIDGE_MEMORY_FIX,
|
|
4
|
+
bridgeMemoryProblem,
|
|
5
|
+
configuredCrowd,
|
|
6
|
+
onClaudeBridge,
|
|
7
|
+
} from "../../core/runtime/bridge-guard.ts";
|
|
2
8
|
import type { Ports, Project } from "../project.ts";
|
|
3
|
-
import { fail, ok, type Result, skipped } from "../report.ts";
|
|
9
|
+
import { fail, ok, type Result, skipped, warn } from "../report.ts";
|
|
4
10
|
|
|
5
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* A login exists for the provider of the configured model: an API key variable or a stored login;
|
|
13
|
+
* with a non-failing warning for bridge's shared private-memory replay risk.
|
|
14
|
+
*/
|
|
6
15
|
export async function checkModelLogin(
|
|
7
16
|
project: Project,
|
|
8
17
|
ports: Pick<Ports, "login">,
|
|
@@ -15,12 +24,21 @@ export async function checkModelLogin(
|
|
|
15
24
|
`the model ${JSON.stringify(model)} is not written <provider>/<id>.`,
|
|
16
25
|
"Write it like anthropic/claude-sonnet-5-5 in MODEL in .env.",
|
|
17
26
|
);
|
|
27
|
+
// Admission warns of risk; each shared turn checks actual private history.
|
|
28
|
+
let risk: Result | undefined;
|
|
29
|
+
const assembled = await project.assembled();
|
|
30
|
+
if (assembled.ok) {
|
|
31
|
+
const { config } = assembled.value;
|
|
32
|
+
const crowd = configuredCrowd(config.access, config.plugins);
|
|
33
|
+
if (config.memory && crowd && onClaudeBridge(ref))
|
|
34
|
+
risk = warn(bridgeMemoryProblem(model, crowd), BRIDGE_MEMORY_FIX);
|
|
35
|
+
}
|
|
18
36
|
const dir = await project.text("agentDir");
|
|
19
37
|
const dataDir = await project.text("dataDir");
|
|
20
38
|
const agentDir = dir ?? (dataDir ? `${dataDir}/pi` : undefined);
|
|
21
39
|
if (!agentDir) return skipped("dataDir has no value");
|
|
22
40
|
const source = await ports.login(agentDir, ref.provider);
|
|
23
|
-
if (source) return ok(`${ref.provider} login from ${source}`);
|
|
41
|
+
if (source) return risk ?? ok(`${ref.provider} login from ${source}`);
|
|
24
42
|
return fail(
|
|
25
43
|
`no login for ${ref.provider}, the provider of ${model}.`,
|
|
26
44
|
`Set the provider's API key in .env (ANTHROPIC_API_KEY for anthropic, OPENAI_API_KEY for openai), or sign in with Pi so ${agentDir}/auth.json holds it.`,
|
package/src/cli/cli.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
+
import { resolveConfig } from "../core/config/config.ts";
|
|
3
4
|
import type { DefinedRoundtable } from "../core/define-roundtable.ts";
|
|
4
5
|
import { Roundtable } from "../core/host.ts";
|
|
6
|
+
import { declaredIdentities } from "../core/identity/plugin-identities.ts";
|
|
5
7
|
import { addPackage } from "./add-package.ts";
|
|
6
8
|
import { addPlugin } from "./add-plugin.ts";
|
|
7
9
|
import type { BunFacts } from "./checks/bun.ts";
|
|
@@ -10,11 +12,24 @@ import { doctor } from "./doctor.ts";
|
|
|
10
12
|
import { fetchHttp } from "./http.ts";
|
|
11
13
|
import { init } from "./init.ts";
|
|
12
14
|
import { type PackagePorts, piPackagePorts } from "./pi-packages.ts";
|
|
13
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
type Configured,
|
|
17
|
+
type ConfiguredIdentities,
|
|
18
|
+
PRINCIPAL_USAGE,
|
|
19
|
+
principalCommand,
|
|
20
|
+
} from "./principal.ts";
|
|
21
|
+
import {
|
|
22
|
+
CONFIG_FILE,
|
|
23
|
+
loadConfigFile,
|
|
24
|
+
loadDefault,
|
|
25
|
+
type Ports,
|
|
26
|
+
Project,
|
|
27
|
+
} from "./project.ts";
|
|
14
28
|
import { formatOutcomes } from "./report.ts";
|
|
15
29
|
import { assemble, providerLogin } from "./runtime.ts";
|
|
16
30
|
import { start } from "./start.ts";
|
|
17
31
|
import { ADAPTERS, isAdapter, OFFICIAL_PLUGINS } from "./templates.ts";
|
|
32
|
+
import { upgrade } from "./upgrade.ts";
|
|
18
33
|
|
|
19
34
|
/** What the command line reads from its surroundings; tests replace every part. */
|
|
20
35
|
export interface CliEnvironment {
|
|
@@ -42,6 +57,12 @@ const USAGE = `roundtable: a Discord agent server on Pi
|
|
|
42
57
|
which are copied in ready to run instead of the template
|
|
43
58
|
roundtable add package <spec> install a Pi package with bun add, and add plugins/<name>.ts that
|
|
44
59
|
loads it and gives its tools to every agent turn
|
|
60
|
+
roundtable upgrade [--write [--unchecked]]
|
|
61
|
+
show roundtable.config.ts rewritten in the 0.9 form (owner and
|
|
62
|
+
speakers as access, discord as an adapter); --write writes it once
|
|
63
|
+
it is checked to serve the same people, which needs the
|
|
64
|
+
environment the host runs with; --unchecked writes it unchecked
|
|
65
|
+
${PRINCIPAL_USAGE}
|
|
45
66
|
`;
|
|
46
67
|
|
|
47
68
|
/** The package's own version and the Bun range it needs, from the package.json beside the source. */
|
|
@@ -201,9 +222,102 @@ export async function runCli(
|
|
|
201
222
|
}
|
|
202
223
|
return 0;
|
|
203
224
|
}
|
|
225
|
+
if (command === "upgrade") {
|
|
226
|
+
const write = rest.includes("--write");
|
|
227
|
+
const unchecked = rest.includes("--unchecked");
|
|
228
|
+
if (rest.some((arg) => arg !== "--write" && arg !== "--unchecked"))
|
|
229
|
+
return usage(io, "upgrade takes only --write and --unchecked");
|
|
230
|
+
if (unchecked && !write) return usage(io, "--unchecked goes with --write");
|
|
231
|
+
return runUpgrade(io, write, unchecked);
|
|
232
|
+
}
|
|
233
|
+
if (command === "principal") {
|
|
234
|
+
const project = new Project(resolve(io.cwd), io.ports);
|
|
235
|
+
const raw = await project.raw();
|
|
236
|
+
if (!raw.ok) {
|
|
237
|
+
for (const line of formatOutcomes([
|
|
238
|
+
{ name: "configuration", result: raw.failure },
|
|
239
|
+
]))
|
|
240
|
+
io.err(line);
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
const url = await project.text("database", "url");
|
|
244
|
+
if (!url) {
|
|
245
|
+
io.err(
|
|
246
|
+
`database.url has no value in ${CONFIG_FILE}; the principals live in the host's database. Set DATABASE_URL in .env.`,
|
|
247
|
+
);
|
|
248
|
+
return 1;
|
|
249
|
+
}
|
|
250
|
+
return principalCommand(url, rest, io, configured(raw.value));
|
|
251
|
+
}
|
|
204
252
|
return usage(io, `unknown command ${JSON.stringify(command)}`);
|
|
205
253
|
}
|
|
206
254
|
|
|
255
|
+
/**
|
|
256
|
+
* The owners the configuration lists and the identities its plugins declare, read by its schema
|
|
257
|
+
* alone: its plugins and model runtime are not assembled, so their failures do not pass for a
|
|
258
|
+
* configuration that does not load. Identities the start would refuse are unknown.
|
|
259
|
+
*/
|
|
260
|
+
function configured(value: unknown): Configured {
|
|
261
|
+
let config: ReturnType<typeof resolveConfig>;
|
|
262
|
+
try {
|
|
263
|
+
config = resolveConfig(value);
|
|
264
|
+
} catch {
|
|
265
|
+
return { owners: undefined, identities: undefined };
|
|
266
|
+
}
|
|
267
|
+
let identities: ConfiguredIdentities;
|
|
268
|
+
try {
|
|
269
|
+
identities = declaredIdentities(config.plugins);
|
|
270
|
+
} catch {
|
|
271
|
+
identities = undefined;
|
|
272
|
+
}
|
|
273
|
+
return { owners: config.access.owners, identities };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function runUpgrade(
|
|
277
|
+
io: CliEnvironment,
|
|
278
|
+
write: boolean,
|
|
279
|
+
unchecked: boolean,
|
|
280
|
+
): Promise<number> {
|
|
281
|
+
const report = await upgrade({
|
|
282
|
+
cwd: resolve(io.cwd),
|
|
283
|
+
write,
|
|
284
|
+
unchecked,
|
|
285
|
+
load: loadDefault,
|
|
286
|
+
});
|
|
287
|
+
if (!report.ok) {
|
|
288
|
+
for (const line of report.problems) io.err(line);
|
|
289
|
+
io.err(`${CONFIG_FILE} was not changed.`);
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
if (report.changes.length === 0) {
|
|
293
|
+
io.out(
|
|
294
|
+
`${CONFIG_FILE} is already in the 0.9 form; there is nothing to change.`,
|
|
295
|
+
);
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
io.out(`${CONFIG_FILE} in the 0.9 form:`);
|
|
299
|
+
for (const change of report.changes) io.out(` - ${change}`);
|
|
300
|
+
io.out("");
|
|
301
|
+
for (const line of report.diff) io.out(line);
|
|
302
|
+
io.out("");
|
|
303
|
+
for (const note of report.notes) io.out(`Note: ${note}.`);
|
|
304
|
+
io.out(
|
|
305
|
+
"same" in report.verified
|
|
306
|
+
? "Checked: the rewrite serves the same owners, admins, and members, with the same Discord settings."
|
|
307
|
+
: `Not checked: ${report.verified.skipped}.`,
|
|
308
|
+
);
|
|
309
|
+
if (report.written && "skipped" in report.verified)
|
|
310
|
+
io.err(
|
|
311
|
+
`Warning: written without the check, as --unchecked asked. Nothing confirmed that the host serves the same owners, admins, and members as before; read the diff above, and run roundtable doctor with the environment the host runs with.`,
|
|
312
|
+
);
|
|
313
|
+
io.out(
|
|
314
|
+
report.written
|
|
315
|
+
? `Wrote ${CONFIG_FILE}; no other file was changed.`
|
|
316
|
+
: `Nothing was written. Run roundtable upgrade --write to write it.`,
|
|
317
|
+
);
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
207
321
|
function usage(io: CliEnvironment, message: string): number {
|
|
208
322
|
io.err(message);
|
|
209
323
|
io.err(USAGE);
|
package/src/cli/config-edit.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parse } from "@babel/parser";
|
|
2
2
|
|
|
3
3
|
/** The parts of a syntax node this edit reads; the parser's own types are not needed beyond these. */
|
|
4
|
-
interface Node {
|
|
4
|
+
export interface Node {
|
|
5
5
|
type: string;
|
|
6
6
|
start: number;
|
|
7
7
|
end: number;
|
|
@@ -20,7 +20,7 @@ const WRAPPERS = new Set([
|
|
|
20
20
|
"ParenthesizedExpression",
|
|
21
21
|
]);
|
|
22
22
|
|
|
23
|
-
const child = (node: Node, key: string): Node | undefined => {
|
|
23
|
+
export const child = (node: Node, key: string): Node | undefined => {
|
|
24
24
|
const value = node[key];
|
|
25
25
|
return typeof value === "object" && value !== null
|
|
26
26
|
? (value as Node)
|
|
@@ -28,35 +28,53 @@ const child = (node: Node, key: string): Node | undefined => {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
/** The expression under `satisfies`, `as`, `!`, and parentheses. */
|
|
31
|
-
function unwrap(node: Node | undefined): Node | undefined {
|
|
31
|
+
export function unwrap(node: Node | undefined): Node | undefined {
|
|
32
32
|
let current = node;
|
|
33
33
|
while (current && WRAPPERS.has(current.type))
|
|
34
34
|
current = child(current, "expression");
|
|
35
35
|
return current;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/** A parsed module: its top-level statements, and every comment in it. */
|
|
39
|
+
export interface ParsedModule {
|
|
40
|
+
body: Node[];
|
|
41
|
+
comments: Node[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The module parsed, or a ConfigEditError saying it does not parse and what to do by hand, `instead`. */
|
|
45
|
+
export function parseConfig(
|
|
46
|
+
source: string,
|
|
47
|
+
file: string,
|
|
48
|
+
instead: string,
|
|
49
|
+
): ParsedModule {
|
|
39
50
|
try {
|
|
40
|
-
|
|
41
|
-
return parse(source, {
|
|
51
|
+
const parsed = parse(source, {
|
|
42
52
|
sourceType: "module",
|
|
43
53
|
plugins: ["typescript"],
|
|
44
|
-
})
|
|
54
|
+
});
|
|
55
|
+
// SAFETY: every node the parser builds has a type, start, and end, which is all Node states.
|
|
56
|
+
return {
|
|
57
|
+
body: parsed.program.body as unknown as Node[],
|
|
58
|
+
comments: (parsed.comments ?? []) as unknown as Node[],
|
|
59
|
+
};
|
|
45
60
|
} catch (error) {
|
|
46
61
|
throw new ConfigEditError(
|
|
47
|
-
`${file} does not parse (${error instanceof Error ? error.message : String(error)}). Fix it, or
|
|
62
|
+
`${file} does not parse (${error instanceof Error ? error.message : String(error)}). Fix it, or ${instead}.`,
|
|
48
63
|
);
|
|
49
64
|
}
|
|
50
65
|
}
|
|
51
66
|
|
|
52
|
-
const
|
|
67
|
+
const parseModule = (source: string, file: string): Node[] =>
|
|
68
|
+
parseConfig(source, file, "add the plugin to its list by hand").body;
|
|
69
|
+
|
|
70
|
+
export const nameOf = (node: Node | undefined): string | undefined => {
|
|
53
71
|
if (node?.type === "Identifier") return node.name as string;
|
|
54
72
|
if (node?.type === "StringLiteral") return node.value as string;
|
|
55
73
|
return undefined;
|
|
56
74
|
};
|
|
57
75
|
|
|
58
76
|
/** Every name the module already binds at its top level, so an import cannot shadow one. */
|
|
59
|
-
function boundNames(body: readonly Node[]): Set<string> {
|
|
77
|
+
export function boundNames(body: readonly Node[]): Set<string> {
|
|
60
78
|
const names = new Set<string>();
|
|
61
79
|
for (const statement of body) {
|
|
62
80
|
const declaration =
|
|
@@ -80,7 +98,7 @@ function boundNames(body: readonly Node[]): Set<string> {
|
|
|
80
98
|
}
|
|
81
99
|
|
|
82
100
|
/** The object the config exports: the default export itself, or the top-level constant it names. */
|
|
83
|
-
function exportedObject(body: readonly Node[]): Node | undefined {
|
|
101
|
+
export function exportedObject(body: readonly Node[]): Node | undefined {
|
|
84
102
|
const exported = body.find(
|
|
85
103
|
(node) => node.type === "ExportDefaultDeclaration",
|
|
86
104
|
);
|
|
@@ -112,7 +130,7 @@ function pluginList(object: Node): Node | undefined {
|
|
|
112
130
|
return undefined;
|
|
113
131
|
}
|
|
114
132
|
|
|
115
|
-
interface Insertion {
|
|
133
|
+
export interface Insertion {
|
|
116
134
|
at: number;
|
|
117
135
|
text: string;
|
|
118
136
|
/** How many characters from `at` the text replaces; none when it only inserts. */
|
|
@@ -162,7 +180,11 @@ function expandedList(
|
|
|
162
180
|
}
|
|
163
181
|
|
|
164
182
|
/** The text to add so `ident` joins the list, in the list's own layout. */
|
|
165
|
-
function listInsertion(
|
|
183
|
+
export function listInsertion(
|
|
184
|
+
source: string,
|
|
185
|
+
list: Node,
|
|
186
|
+
ident: string,
|
|
187
|
+
): Insertion {
|
|
166
188
|
const elements = (list.elements as (Node | null)[]).filter(
|
|
167
189
|
(element): element is Node => element !== null,
|
|
168
190
|
);
|
|
@@ -205,6 +227,54 @@ function importInsertion(
|
|
|
205
227
|
: { at: 0, text: `${line}\n\n` };
|
|
206
228
|
}
|
|
207
229
|
|
|
230
|
+
/** Where an import of a package goes: after the package imports that sort before it, else first. */
|
|
231
|
+
export function packageImport(
|
|
232
|
+
body: readonly Node[],
|
|
233
|
+
specifier: string,
|
|
234
|
+
line: string,
|
|
235
|
+
): Insertion {
|
|
236
|
+
const imports = body.filter((node) => node.type === "ImportDeclaration");
|
|
237
|
+
const from = (node: Node) => String(child(node, "source")?.value ?? "");
|
|
238
|
+
const before = imports
|
|
239
|
+
.filter((node) => !from(node).startsWith(".") && from(node) < specifier)
|
|
240
|
+
.at(-1);
|
|
241
|
+
if (before) return { at: before.end, text: `\n${line}` };
|
|
242
|
+
const first = imports[0];
|
|
243
|
+
return first
|
|
244
|
+
? { at: first.start, text: `${line}\n` }
|
|
245
|
+
: { at: 0, text: `${line}\n\n` };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Whether the module imports `name` from `specifier` under that same name. */
|
|
249
|
+
export function importsName(
|
|
250
|
+
body: readonly Node[],
|
|
251
|
+
specifier: string,
|
|
252
|
+
name: string,
|
|
253
|
+
): boolean {
|
|
254
|
+
return body.some(
|
|
255
|
+
(node) =>
|
|
256
|
+
node.type === "ImportDeclaration" &&
|
|
257
|
+
child(node, "source")?.value === specifier &&
|
|
258
|
+
(node.specifiers as Node[]).some(
|
|
259
|
+
(imported) =>
|
|
260
|
+
imported.type === "ImportSpecifier" &&
|
|
261
|
+
nameOf(child(imported, "imported")) === name &&
|
|
262
|
+
nameOf(child(imported, "local")) === name,
|
|
263
|
+
),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Whether the file imports anything from `specifier`. */
|
|
268
|
+
export const importsFrom = (
|
|
269
|
+
body: readonly Node[],
|
|
270
|
+
specifier: string,
|
|
271
|
+
): boolean =>
|
|
272
|
+
body.some(
|
|
273
|
+
(node) =>
|
|
274
|
+
node.type === "ImportDeclaration" &&
|
|
275
|
+
child(node, "source")?.value === specifier,
|
|
276
|
+
);
|
|
277
|
+
|
|
208
278
|
/**
|
|
209
279
|
* Adds the plugin's import line and lists it in the `plugins` array of the default export, in the
|
|
210
280
|
* file's own layout; a one-line list that would pass 80 columns is put one element a line. It
|
package/src/cli/doctor.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
Discord,
|
|
10
10
|
} from "./checks/discord.ts";
|
|
11
11
|
import { checkEnvironment } from "./checks/environment.ts";
|
|
12
|
+
import { checkAccess, checkPrincipals } from "./checks/identity.ts";
|
|
12
13
|
import { checkImageProvider } from "./checks/images.ts";
|
|
13
14
|
import { checkModelLogin } from "./checks/model.ts";
|
|
14
15
|
import { checkPublicUrl } from "./checks/public-url.ts";
|
|
@@ -64,6 +65,7 @@ export function buildChecks(
|
|
|
64
65
|
run: () => checkConfiguration(project),
|
|
65
66
|
},
|
|
66
67
|
{ name: "plugins", offline: true, run: () => checkPlugins(project) },
|
|
68
|
+
{ name: "access", offline: true, run: () => checkAccess(project) },
|
|
67
69
|
{
|
|
68
70
|
name: "image provider",
|
|
69
71
|
offline: true,
|
|
@@ -74,6 +76,11 @@ export function buildChecks(
|
|
|
74
76
|
offline: false,
|
|
75
77
|
run: () => checkDatabase(project, database),
|
|
76
78
|
},
|
|
79
|
+
{
|
|
80
|
+
name: "principals",
|
|
81
|
+
offline: false,
|
|
82
|
+
run: () => checkPrincipals(project, database),
|
|
83
|
+
},
|
|
77
84
|
{
|
|
78
85
|
name: "Discord token",
|
|
79
86
|
offline: false,
|