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,60 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { IDENTITY, type Speaker } from "pi-roundtable";
|
|
3
|
+
import { servicePair, testPlugin } from "pi-roundtable/testing";
|
|
4
|
+
import { access, principals } from "./principals.ts";
|
|
5
|
+
|
|
6
|
+
test("two actor identities of one principal ask about the same person", async () => {
|
|
7
|
+
const lookedUp: string[] = [];
|
|
8
|
+
const harness = await testPlugin(principals, {
|
|
9
|
+
services: [
|
|
10
|
+
servicePair(IDENTITY, {
|
|
11
|
+
principal: async (id) => {
|
|
12
|
+
lookedUp.push(id);
|
|
13
|
+
return { id, displayName: "Mo", disabled: false };
|
|
14
|
+
},
|
|
15
|
+
}),
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
for (const id of ["discord-actor", "web-actor"]) {
|
|
20
|
+
const speaker: Speaker = {
|
|
21
|
+
id,
|
|
22
|
+
name: "Mo",
|
|
23
|
+
principalId: "p_mo",
|
|
24
|
+
tier: "member",
|
|
25
|
+
};
|
|
26
|
+
expect(await harness.runTool("principal_who", {}, { speaker })).toBe(
|
|
27
|
+
"Mo (p_mo)",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
expect(lookedUp).toEqual(["p_mo", "p_mo"]);
|
|
31
|
+
} finally {
|
|
32
|
+
await harness.stop();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("an unavailable principal is refused even at owner tier", async () => {
|
|
37
|
+
const harness = await testPlugin(principals, {
|
|
38
|
+
services: [servicePair(IDENTITY, { principal: async () => undefined })],
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
expect(
|
|
42
|
+
await harness.runTool(
|
|
43
|
+
"principal_who",
|
|
44
|
+
{},
|
|
45
|
+
{
|
|
46
|
+
speaker: {
|
|
47
|
+
id: "actor",
|
|
48
|
+
name: "Ada",
|
|
49
|
+
principalId: "operator",
|
|
50
|
+
tier: "owner",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
).toContain("This principal is not available.");
|
|
55
|
+
expect(access.owners[0]?.principal).toBe("operator");
|
|
56
|
+
expect(access.members.roles).toEqual(["web:role:App.User"]);
|
|
57
|
+
} finally {
|
|
58
|
+
await harness.stop();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AccessConfig,
|
|
3
|
+
definePlugin,
|
|
4
|
+
defineTool,
|
|
5
|
+
IDENTITY,
|
|
6
|
+
ToolRefusal,
|
|
7
|
+
} from "pi-roundtable";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
|
|
10
|
+
/** One person may have several identities; surface roles never make an owner. */
|
|
11
|
+
export const access = {
|
|
12
|
+
owners: [
|
|
13
|
+
{
|
|
14
|
+
principal: "operator",
|
|
15
|
+
name: "Ada",
|
|
16
|
+
identities: ["discord:966666600000000001", "token:remote-mcp"],
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
members: { roles: ["web:role:App.User"] },
|
|
20
|
+
provisioning: "admitted",
|
|
21
|
+
} satisfies AccessConfig;
|
|
22
|
+
|
|
23
|
+
/** Ask about the current principal, not the external actor or the primary owner. */
|
|
24
|
+
export const principals = definePlugin({
|
|
25
|
+
name: "principals",
|
|
26
|
+
requires: [IDENTITY],
|
|
27
|
+
setup: ({ services }) => {
|
|
28
|
+
const identity = services.get(IDENTITY);
|
|
29
|
+
return {
|
|
30
|
+
tools: [
|
|
31
|
+
defineTool({
|
|
32
|
+
name: "principal_who",
|
|
33
|
+
description: "Show who this turn is for.",
|
|
34
|
+
parameters: Type.Object({}),
|
|
35
|
+
minTier: "member",
|
|
36
|
+
run: async (_args, turn) => {
|
|
37
|
+
if (!turn.speaker)
|
|
38
|
+
throw new ToolRefusal("No speaker for this turn.");
|
|
39
|
+
const person = await identity.principal(turn.speaker.principalId);
|
|
40
|
+
if (!person || person.disabled)
|
|
41
|
+
throw new ToolRefusal("This principal is not available.");
|
|
42
|
+
return `${person.displayName} (${person.id})`;
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
});
|
package/examples/prompt.test.ts
CHANGED
|
@@ -17,7 +17,7 @@ test("the section names the agent, and the speaker when there is one", async ()
|
|
|
17
17
|
expect(
|
|
18
18
|
section?.build({
|
|
19
19
|
agent,
|
|
20
|
-
speaker: { id: "1", name: "Ada", tier: "member" },
|
|
20
|
+
speaker: { id: "1", name: "Ada", tier: "member", principalId: "1" },
|
|
21
21
|
scope,
|
|
22
22
|
}),
|
|
23
23
|
).toContain("You are talking with Ada.");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { IDENTITY } from "pi-roundtable";
|
|
3
|
+
import { servicePair, testPlugin } from "pi-roundtable/testing";
|
|
3
4
|
import { createEchoRuntime } from "./echo-runtime.ts";
|
|
4
5
|
import { FakeSurface } from "./fake-surface.ts";
|
|
5
6
|
import { studyRoom } from "./study-room.ts";
|
|
@@ -10,6 +11,16 @@ async function studying() {
|
|
|
10
11
|
const harness = await testPlugin(studyRoom, {
|
|
11
12
|
surfaces: [surface],
|
|
12
13
|
providers: { runtime: createEchoRuntime },
|
|
14
|
+
services: [
|
|
15
|
+
servicePair(IDENTITY, {
|
|
16
|
+
resolve: async (facts) => ({
|
|
17
|
+
id: facts.subject,
|
|
18
|
+
name: facts.name,
|
|
19
|
+
principalId: "student",
|
|
20
|
+
tier: "member",
|
|
21
|
+
}),
|
|
22
|
+
}),
|
|
23
|
+
],
|
|
13
24
|
});
|
|
14
25
|
return { surface, harness };
|
|
15
26
|
}
|
|
@@ -39,6 +50,7 @@ test("a message in a study room runs as a turn of the study kind, with the tutor
|
|
|
39
50
|
expect(started?.turn).toMatchObject({
|
|
40
51
|
kind: "study",
|
|
41
52
|
channel: "fake:study-algebra",
|
|
53
|
+
speaker: { principalId: "student", tier: "member" },
|
|
42
54
|
});
|
|
43
55
|
expect(started?.turn?.agent).toBeUndefined();
|
|
44
56
|
await harness.stop();
|
|
@@ -60,6 +72,22 @@ test("starting a room over says it was a study conversation", async () => {
|
|
|
60
72
|
await harness.stop();
|
|
61
73
|
});
|
|
62
74
|
|
|
75
|
+
test("an author with no resolved tier never starts a study turn", async () => {
|
|
76
|
+
const surface = new FakeSurface();
|
|
77
|
+
const harness = await testPlugin(studyRoom, {
|
|
78
|
+
surfaces: [surface],
|
|
79
|
+
providers: { runtime: createEchoRuntime },
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
surface.say("fake:study-algebra", "hello");
|
|
83
|
+
await Bun.sleep(30);
|
|
84
|
+
expect(surface.replies).toEqual([]);
|
|
85
|
+
expect(harness.events).toEqual([]);
|
|
86
|
+
} finally {
|
|
87
|
+
await harness.stop();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
63
91
|
test("the persona is the plugin's and belongs to the study kind only", async () => {
|
|
64
92
|
const { harness } = await studying();
|
|
65
93
|
const [persona] = harness.contribution.personas ?? [];
|
package/examples/study-room.ts
CHANGED
|
@@ -25,25 +25,22 @@ export const studyRoom = definePlugin({
|
|
|
25
25
|
priority: 10,
|
|
26
26
|
// The id of a room starts with `study-`, on whichever surface carries it.
|
|
27
27
|
owns: (channel) => parseChannelKey(channel).id.startsWith("study-"),
|
|
28
|
-
admit: (message) =>
|
|
29
|
-
message.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
},
|
|
45
|
-
failure: "a study turn failed",
|
|
46
|
-
},
|
|
28
|
+
admit: (message) => {
|
|
29
|
+
const speaker = message.speaker;
|
|
30
|
+
if (message.authorIsBot || !speaker) return undefined;
|
|
31
|
+
return {
|
|
32
|
+
kind: "turn",
|
|
33
|
+
run: async () => {
|
|
34
|
+
await turns.run({
|
|
35
|
+
channel: message.channel,
|
|
36
|
+
kind: STUDY,
|
|
37
|
+
text: message.text,
|
|
38
|
+
speaker,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
failure: "a study turn failed",
|
|
42
|
+
};
|
|
43
|
+
},
|
|
47
44
|
// What the conversation was, so a host picks the right persona when it starts over.
|
|
48
45
|
startFresh: async () => STUDY,
|
|
49
46
|
},
|
|
@@ -2,10 +2,12 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { testPlugin } from "pi-roundtable/testing";
|
|
3
3
|
import { supportDesk } from "./support-desk.ts";
|
|
4
4
|
|
|
5
|
+
// A turn runs as its author's principal: here the harness's owner, the one person it knows.
|
|
5
6
|
const turn = (target: string) => ({
|
|
6
7
|
channel: "support:1" as const,
|
|
7
8
|
target,
|
|
8
|
-
author: { id: "
|
|
9
|
+
author: { principalId: "owner", id: "owner", name: "Owner" },
|
|
10
|
+
tier: "member" as const,
|
|
9
11
|
turnId: "t1",
|
|
10
12
|
text: "check the queue",
|
|
11
13
|
});
|
package/examples/tools.test.ts
CHANGED
|
@@ -5,7 +5,12 @@ import { notes } from "./tools.ts";
|
|
|
5
5
|
|
|
6
6
|
test("note_add saves a note for the speaker and refuses an empty one", async () => {
|
|
7
7
|
const harness = await testPlugin(notes);
|
|
8
|
-
const ada: Speaker = {
|
|
8
|
+
const ada: Speaker = {
|
|
9
|
+
id: "1",
|
|
10
|
+
name: "Ada",
|
|
11
|
+
tier: "member",
|
|
12
|
+
principalId: "1",
|
|
13
|
+
};
|
|
9
14
|
expect(harness.tools).toEqual(["note_add"]);
|
|
10
15
|
expect(harness.tiers.minTier("note_add")).toBe("member");
|
|
11
16
|
expect(
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A plugin-driven Pi agent server for Discord",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"workspaces": [
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"src",
|
|
27
27
|
"!src/**/*.test.ts",
|
|
28
28
|
"!src/core/testing/file-size.ts",
|
|
29
|
+
"!src/core/testing/prompt-capture*.ts",
|
|
30
|
+
"!src/core/testing/fixtures",
|
|
31
|
+
"!src/prompt-text.snapshot.json",
|
|
29
32
|
"!src/cli/testing",
|
|
30
33
|
"templates",
|
|
31
34
|
"docs",
|
|
@@ -52,7 +55,7 @@
|
|
|
52
55
|
"typecheck": "tsc --noEmit",
|
|
53
56
|
"lint": "biome check .",
|
|
54
57
|
"test": "bun test ./src ./scripts ./examples",
|
|
55
|
-
"check:packages": "bun run --workspaces typecheck && bun run --workspaces lint && bun run --workspaces test"
|
|
58
|
+
"check:packages": "bun run --workspaces typecheck && bun run --workspaces lint && bun run --workspaces --sequential test"
|
|
56
59
|
},
|
|
57
60
|
"dependencies": {
|
|
58
61
|
"@babel/parser": "7.29.9",
|
|
@@ -74,6 +77,7 @@
|
|
|
74
77
|
"typescript": "7.0.2",
|
|
75
78
|
"@biomejs/biome": "2.5.15",
|
|
76
79
|
"@types/bun": "1.4.2",
|
|
80
|
+
"pi-self-compact": "0.2.0",
|
|
77
81
|
"pi-web-access": "0.35.0"
|
|
78
82
|
}
|
|
79
83
|
}
|
|
@@ -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.`,
|