pi-roundtable 0.8.0 → 0.9.1
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 +115 -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 +618 -86
- 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 +150 -86
- 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 +87 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +82 -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 +42 -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/drain.ts +56 -5
- package/src/core/errors.ts +13 -0
- package/src/core/host-background.ts +56 -0
- package/src/core/host.ts +52 -68
- package/src/core/i18n/cards.ts +8 -4
- package/src/core/i18n/conversation.ts +2 -0
- 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 +56 -4
- 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-queue.ts +18 -1
- package/src/core/routing/channel-router.ts +274 -21
- package/src/core/routing/conversation-turns.ts +45 -10
- package/src/core/routing/surface-port.ts +32 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/abort-guard.ts +58 -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 +139 -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 +174 -190
- 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 +51 -5
- package/src/core/runtime/runtime-types.ts +48 -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 +21 -3
- package/src/core/runtime/thinking-level.ts +47 -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-wrapup.ts +63 -0
- 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 +48 -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
package/src/core/speakers.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { freeze } from "./freeze.ts";
|
|
2
|
+
import {
|
|
3
|
+
rulesOfSpeakerMap,
|
|
4
|
+
speakerPolicyOf,
|
|
5
|
+
} from "./identity/access-policy.ts";
|
|
2
6
|
import type { OwnerIdentity } from "./identity.ts";
|
|
3
7
|
|
|
4
8
|
/** How much a speaker may do: `owner` above `admin` above `member`. */
|
|
@@ -12,9 +16,16 @@ export function tierAtLeast(tier: Tier, least: Tier): boolean {
|
|
|
12
16
|
|
|
13
17
|
/** A person whose message starts a turn, and the tier the policy gave them. */
|
|
14
18
|
export interface Speaker {
|
|
19
|
+
/** The id the surface knows them by: a Discord user id, a web chat's `oidc:<issuer>:<subject>`. */
|
|
15
20
|
id: string;
|
|
16
21
|
name: string;
|
|
17
22
|
tier: Tier;
|
|
23
|
+
/**
|
|
24
|
+
* The principal they are, whose memory, schedules, and conversations are theirs on every
|
|
25
|
+
* surface. For a person carried over from 0.8 it equals `id`. The identity service sets it;
|
|
26
|
+
* whoever starts a turn for someone else takes it from `IDENTITY.speakerFor`.
|
|
27
|
+
*/
|
|
28
|
+
principalId: string;
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
/**
|
|
@@ -59,7 +70,10 @@ export function attributed(speaker: Speaker, text: string): string {
|
|
|
59
70
|
return `(Message from ${speaker.name}, at the ${speaker.tier} tier.)\n\n${text}`;
|
|
60
71
|
}
|
|
61
72
|
|
|
62
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* What a surface reports about the author of a message; the policy decides the rest. The 0.8
|
|
75
|
+
* form of `ActorFacts` from the identity service, with the surface's own ids and role ids.
|
|
76
|
+
*/
|
|
63
77
|
export interface SpeakerFacts {
|
|
64
78
|
id: string;
|
|
65
79
|
name: string;
|
|
@@ -92,23 +106,11 @@ export interface SpeakerMap {
|
|
|
92
106
|
members?: TierMembers;
|
|
93
107
|
}
|
|
94
108
|
|
|
95
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* The policy of an operator's map; the highest tier an author qualifies for wins. It evaluates
|
|
111
|
+
* the access rules the map means on one surface, so it decides as the identity service's rules
|
|
112
|
+
* do; each speaker's principal is their own id, as for a person carried over from 0.8.
|
|
113
|
+
*/
|
|
96
114
|
export function speakerPolicy(map: SpeakerMap): SpeakerPolicy {
|
|
97
|
-
|
|
98
|
-
tier !== undefined &&
|
|
99
|
-
(tier.everyone === true ||
|
|
100
|
-
tier.users?.includes(author.id) === true ||
|
|
101
|
-
(author.roleIds ?? []).some((role) => tier.roles?.includes(role)));
|
|
102
|
-
const tierOf = (author: SpeakerFacts): Tier | undefined => {
|
|
103
|
-
if (map.owners.includes(author.id)) return "owner";
|
|
104
|
-
if (holds(map.admins, author)) return "admin";
|
|
105
|
-
if (holds(map.members, author)) return "member";
|
|
106
|
-
return undefined;
|
|
107
|
-
};
|
|
108
|
-
return {
|
|
109
|
-
resolve(author) {
|
|
110
|
-
const tier = tierOf(author);
|
|
111
|
-
return tier && { id: author.id, name: author.name, tier };
|
|
112
|
-
},
|
|
113
|
-
};
|
|
115
|
+
return speakerPolicyOf(rulesOfSpeakerMap(map));
|
|
114
116
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { BackgroundRunsAs, BackgroundTurn } from "../contract/channels.ts";
|
|
2
|
+
import type { Schedule } from "../modules/schedules/schedule-store.ts";
|
|
3
|
+
|
|
4
|
+
/** A conversation port's `runsAs` for tests: every turn runs as its author, at its own tier. */
|
|
5
|
+
export async function runsAsAuthor(
|
|
6
|
+
turn: BackgroundTurn,
|
|
7
|
+
): Promise<BackgroundRunsAs> {
|
|
8
|
+
const { author, tier } = turn;
|
|
9
|
+
return {
|
|
10
|
+
speaker: {
|
|
11
|
+
id: author.id,
|
|
12
|
+
name: author.name,
|
|
13
|
+
tier,
|
|
14
|
+
principalId: author.principalId,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A scheduled runner's `runsAs` for tests: every schedule runs as its creator, at the tier it was set at. */
|
|
20
|
+
export async function runsAsCreator(
|
|
21
|
+
schedule: Schedule,
|
|
22
|
+
): Promise<BackgroundRunsAs> {
|
|
23
|
+
return {
|
|
24
|
+
speaker: {
|
|
25
|
+
id: schedule.createdById,
|
|
26
|
+
name: schedule.createdByName,
|
|
27
|
+
tier: schedule.createdTier,
|
|
28
|
+
principalId: schedule.createdById,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { SQL } from "bun";
|
|
4
|
+
import { testDatabaseUrl } from "./database.ts";
|
|
5
|
+
|
|
6
|
+
/** A scratch database on the test server; drop it when the test ends. */
|
|
7
|
+
export interface ScratchDatabase {
|
|
8
|
+
url: string;
|
|
9
|
+
sql: SQL;
|
|
10
|
+
drop(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A new, empty database on the test server, or one loaded from a recorded fixture such as
|
|
15
|
+
* `"0.8.0"` (src/core/testing/fixtures/db-<version>.sql, written by scripts/fixture-db.ts). It
|
|
16
|
+
* needs PostgreSQL: run it under `describeDb`.
|
|
17
|
+
*/
|
|
18
|
+
export async function scratchDatabase(
|
|
19
|
+
fixture?: string,
|
|
20
|
+
): Promise<ScratchDatabase> {
|
|
21
|
+
const name = `roundtable_scratch_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
22
|
+
let url: URL;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(testDatabaseUrl);
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`ROUNDTABLE_TEST_DATABASE_URL is not a postgres url: ${testDatabaseUrl}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
url.pathname = `/${name}`;
|
|
31
|
+
const server = new SQL(testDatabaseUrl, { max: 1 });
|
|
32
|
+
await server.unsafe(`CREATE DATABASE ${name}`);
|
|
33
|
+
await server.close();
|
|
34
|
+
const sql = new SQL(url.href, { max: 4 });
|
|
35
|
+
if (fixture)
|
|
36
|
+
await sql.unsafe(
|
|
37
|
+
readFileSync(
|
|
38
|
+
join(import.meta.dir, "fixtures", `db-${fixture}.sql`),
|
|
39
|
+
"utf8",
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
return {
|
|
43
|
+
url: url.href,
|
|
44
|
+
sql,
|
|
45
|
+
drop: async () => {
|
|
46
|
+
await sql.close();
|
|
47
|
+
const admin = new SQL(testDatabaseUrl, { max: 1 });
|
|
48
|
+
try {
|
|
49
|
+
await admin.unsafe(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`);
|
|
50
|
+
} finally {
|
|
51
|
+
await admin.close();
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { IdentityError } from "../domain/errors.ts";
|
|
2
|
+
import type { ActorFacts } from "../identity/actor-facts.ts";
|
|
3
|
+
import type { ContactAssessor } from "../identity/contact.ts";
|
|
4
|
+
import type { IdentityService } from "../identity/identity-service.ts";
|
|
5
|
+
import type { IdentityLink, Principal } from "../identity/principal-store.ts";
|
|
6
|
+
import { type SpeakerMap, speakerPolicy, tierAtLeast } from "../speakers.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Who a 0.8 speaker map serves, for a test without a database: each person their own principal,
|
|
10
|
+
* as one carried over from 0.8 is, and nothing written; a background turn runs as the person of
|
|
11
|
+
* its principal id at the map's tier for their id with no roles. It resolves as the router's assessor
|
|
12
|
+
* and as the `resolve` of `IDENTITY`; its owners are the map's, and a person has a Discord
|
|
13
|
+
* identity once the map names their user id or they have been resolved through it.
|
|
14
|
+
*/
|
|
15
|
+
export function mapIdentity(
|
|
16
|
+
map: SpeakerMap,
|
|
17
|
+
): ContactAssessor &
|
|
18
|
+
Pick<IdentityService, "resolve" | "owners" | "identities" | "speakerFor"> {
|
|
19
|
+
const policy = speakerPolicy(map);
|
|
20
|
+
const linked = new Set([
|
|
21
|
+
...map.owners,
|
|
22
|
+
...(map.admins?.users ?? []),
|
|
23
|
+
...(map.members?.users ?? []),
|
|
24
|
+
]);
|
|
25
|
+
const resolve = async (facts: ActorFacts) => {
|
|
26
|
+
const speaker = policy.resolve({
|
|
27
|
+
id: facts.subject,
|
|
28
|
+
name: facts.name,
|
|
29
|
+
roleIds: (facts.roles ?? []).map((role) =>
|
|
30
|
+
role.replace(/^discord:role:/, ""),
|
|
31
|
+
),
|
|
32
|
+
});
|
|
33
|
+
if (speaker) linked.add(speaker.id);
|
|
34
|
+
return speaker;
|
|
35
|
+
};
|
|
36
|
+
const principal = (id: string): Principal => ({
|
|
37
|
+
id,
|
|
38
|
+
displayName: id,
|
|
39
|
+
disabled: false,
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
resolve,
|
|
43
|
+
assess: async (facts) => {
|
|
44
|
+
const speaker = await resolve(facts);
|
|
45
|
+
return speaker && { speaker, take: async () => speaker };
|
|
46
|
+
},
|
|
47
|
+
owners: async () => map.owners.map(principal),
|
|
48
|
+
// Whom a background turn runs as: the person of the id at the map's tier for them, or lower.
|
|
49
|
+
speakerFor: async (principalId, tier) => {
|
|
50
|
+
const own = policy.resolve({
|
|
51
|
+
id: principalId,
|
|
52
|
+
name: principalId,
|
|
53
|
+
roleIds: [],
|
|
54
|
+
})?.tier;
|
|
55
|
+
if (!own) throw new IdentityError(`there is no principal ${principalId}`);
|
|
56
|
+
return {
|
|
57
|
+
id: principalId,
|
|
58
|
+
name: principalId,
|
|
59
|
+
tier: tier && tierAtLeast(own, tier) ? tier : own,
|
|
60
|
+
principalId,
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
identities: async (principalId): Promise<IdentityLink[]> =>
|
|
64
|
+
linked.has(principalId)
|
|
65
|
+
? [
|
|
66
|
+
{
|
|
67
|
+
provider: "discord",
|
|
68
|
+
subject: principalId,
|
|
69
|
+
principalId,
|
|
70
|
+
source: "legacy",
|
|
71
|
+
linkedAt: new Date(0),
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
: [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionFactory,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { OwnerIdentity } from "../identity.ts";
|
|
6
|
+
import type {
|
|
7
|
+
AgentTurnScope,
|
|
8
|
+
ChannelKey,
|
|
9
|
+
SessionContext,
|
|
10
|
+
} from "../sessions.ts";
|
|
11
|
+
import { type Speaker, THE_SPEAKER } from "../speakers.ts";
|
|
12
|
+
import { OWNER_CHANNEL, type setUpModules } from "./modules.ts";
|
|
13
|
+
import { OWNER_SPEAKER } from "./owner.ts";
|
|
14
|
+
|
|
15
|
+
/** Sessions, people, and tool calls the modules' tests share. */
|
|
16
|
+
export const HOME: ChannelKey = "discord:scout";
|
|
17
|
+
export const GROUP: ChannelKey = "discord:war-room";
|
|
18
|
+
export const OUTSIDE: ChannelKey = "other:table-1";
|
|
19
|
+
|
|
20
|
+
export const scout: AgentTurnScope = {
|
|
21
|
+
name: "scout",
|
|
22
|
+
session: HOME,
|
|
23
|
+
home: HOME,
|
|
24
|
+
};
|
|
25
|
+
export const seat: AgentTurnScope = {
|
|
26
|
+
name: "scout",
|
|
27
|
+
session: GROUP,
|
|
28
|
+
home: HOME,
|
|
29
|
+
group: "war-room",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function context(
|
|
33
|
+
agent?: AgentTurnScope,
|
|
34
|
+
home?: ChannelKey,
|
|
35
|
+
): SessionContext {
|
|
36
|
+
const session: SessionContext = {
|
|
37
|
+
kind: agent ? "agent" : "owner",
|
|
38
|
+
homeChannel: home ?? agent?.home ?? OWNER_CHANNEL,
|
|
39
|
+
turnChannel: agent?.session ?? home ?? OWNER_CHANNEL,
|
|
40
|
+
compaction: { wrap: (compactor) => compactor },
|
|
41
|
+
// Shared, as the host makes a session it has no record of; `privateTo` makes it someone's.
|
|
42
|
+
conversation: { visibility: "shared" },
|
|
43
|
+
addressee: THE_SPEAKER,
|
|
44
|
+
memory: "speaker",
|
|
45
|
+
// A turn of the owner's runs, as every tool call is part of one.
|
|
46
|
+
speaker: () => OWNER_SPEAKER,
|
|
47
|
+
runTask: async () => "report",
|
|
48
|
+
};
|
|
49
|
+
if (agent) session.agent = agent;
|
|
50
|
+
return session;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A session of a conversation of the owner's kind whose turns are someone else's. */
|
|
54
|
+
export function contextOf(speaker: Speaker, home: ChannelKey): SessionContext {
|
|
55
|
+
return { ...context(undefined, home), speaker: () => speaker };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** An admin who is not the primary owner, such as one a remote MCP token is bound to. */
|
|
59
|
+
export const ANN: Speaker = {
|
|
60
|
+
id: "p_ann",
|
|
61
|
+
name: "Ann",
|
|
62
|
+
tier: "admin",
|
|
63
|
+
principalId: "p_ann",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface Registered {
|
|
67
|
+
name: string;
|
|
68
|
+
parameters: { properties: Record<string, unknown> };
|
|
69
|
+
execute(
|
|
70
|
+
id: string,
|
|
71
|
+
params: unknown,
|
|
72
|
+
): Promise<{ content: { text: string }[]; isError?: boolean }>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The extension one of the modules' session tools gives a session; null when it gives none. */
|
|
76
|
+
export function factoryOf(
|
|
77
|
+
setup: Awaited<ReturnType<typeof setUpModules>>,
|
|
78
|
+
name: string,
|
|
79
|
+
session: SessionContext,
|
|
80
|
+
): ExtensionFactory | null | undefined {
|
|
81
|
+
return setup.contribution.sessionTools
|
|
82
|
+
?.find((tool) => tool.name === name)
|
|
83
|
+
?.snapshot()
|
|
84
|
+
.factory(session);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The tools one of the modules' extensions registers in a session. */
|
|
88
|
+
export async function registered(
|
|
89
|
+
setup: Awaited<ReturnType<typeof setUpModules>>,
|
|
90
|
+
session: SessionContext,
|
|
91
|
+
extension: string,
|
|
92
|
+
): Promise<Registered[]> {
|
|
93
|
+
const tool = setup.contribution.sessionTools?.find(
|
|
94
|
+
(candidate) => candidate.name === extension,
|
|
95
|
+
);
|
|
96
|
+
const factory: ExtensionFactory | null | undefined = tool
|
|
97
|
+
?.snapshot()
|
|
98
|
+
.factory(session);
|
|
99
|
+
if (!factory) throw new Error(`no ${extension} extension`);
|
|
100
|
+
const tools: Registered[] = [];
|
|
101
|
+
await factory({
|
|
102
|
+
registerTool: (definition: Registered) => tools.push(definition),
|
|
103
|
+
on: () => undefined,
|
|
104
|
+
} as unknown as ExtensionAPI);
|
|
105
|
+
return tools;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The session of a conversation private to `principalId`, its tools addressing them as given. */
|
|
109
|
+
export function privateTo(
|
|
110
|
+
session: SessionContext,
|
|
111
|
+
principalId: string,
|
|
112
|
+
addressee: OwnerIdentity = THE_SPEAKER,
|
|
113
|
+
): SessionContext {
|
|
114
|
+
return {
|
|
115
|
+
...session,
|
|
116
|
+
conversation: { visibility: "private", principalId },
|
|
117
|
+
addressee,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Runs notify in a session; the tool's answer and whether it was an error. */
|
|
122
|
+
export async function notifyIn(
|
|
123
|
+
setup: Awaited<ReturnType<typeof setUpModules>>,
|
|
124
|
+
session: SessionContext,
|
|
125
|
+
text = "the build is green",
|
|
126
|
+
) {
|
|
127
|
+
const [notify] = await registered(setup, session, "notify");
|
|
128
|
+
if (!notify) return undefined;
|
|
129
|
+
const answer = await notify.execute("1", { text });
|
|
130
|
+
return {
|
|
131
|
+
text: answer.content[0]?.text ?? "",
|
|
132
|
+
error: answer.isError === true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { SQL } from "bun";
|
|
3
|
-
import { OWNER_TARGET } from "../agents/agent-claim.ts";
|
|
4
3
|
import { DISCORD, type DiscordServices } from "../builtin/discord.ts";
|
|
5
4
|
import { discordAdminPlugin } from "../builtin/discord-admin.ts";
|
|
6
5
|
import { type ModulesOptions, modulesPlugin } from "../builtin/modules.ts";
|
|
7
6
|
import { skillsPlugin } from "../builtin/skills.ts";
|
|
8
7
|
import { memoryPlugin } from "../builtin/stores.ts";
|
|
8
|
+
import type { ConversationRegistry } from "../conversations/conversation-registry.ts";
|
|
9
9
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
10
|
+
import type { IdentityService } from "../identity/identity-service.ts";
|
|
11
|
+
import type { Principal } from "../identity/principal-store.ts";
|
|
10
12
|
import { silentLogger } from "../log.ts";
|
|
13
|
+
import { PERSONAL_TARGET } from "../modules/background/personal-target.ts";
|
|
11
14
|
import type { SkillStore } from "../modules/skills/skill-store.ts";
|
|
12
15
|
import {
|
|
13
16
|
type Contribution,
|
|
@@ -15,10 +18,22 @@ import {
|
|
|
15
18
|
pluginContext,
|
|
16
19
|
type RoundtablePlugin,
|
|
17
20
|
} from "../plugin.ts";
|
|
21
|
+
import {
|
|
22
|
+
type DirectChannelProvider,
|
|
23
|
+
directChannelsPort,
|
|
24
|
+
} from "../presence/direct-channels.ts";
|
|
18
25
|
import { ServiceRegistry } from "../registry/services.ts";
|
|
19
26
|
import { surfacePort } from "../routing/surface-port.ts";
|
|
20
27
|
import type { AgentServer, MemoryStore, ScheduleStore } from "../services.ts";
|
|
21
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
AGENTS,
|
|
30
|
+
CONVERSATIONS,
|
|
31
|
+
IDENTITY,
|
|
32
|
+
MEMORY,
|
|
33
|
+
SCHEDULES,
|
|
34
|
+
} from "../services.ts";
|
|
35
|
+
import type { Tier } from "../speakers.ts";
|
|
36
|
+
import { toolTiers } from "../tool-tiers.ts";
|
|
22
37
|
|
|
23
38
|
/** What the modules' tools did, for a test to read. */
|
|
24
39
|
export interface ModuleRecord {
|
|
@@ -26,14 +41,28 @@ export interface ModuleRecord {
|
|
|
26
41
|
threadOrigins: (ChannelKey | undefined)[];
|
|
27
42
|
/** The channel each delegated report came back to. */
|
|
28
43
|
reportChannels: ChannelKey[];
|
|
29
|
-
/** How often
|
|
44
|
+
/** How often anything asked the connection for the primary owner's messages, which the modules no longer read. */
|
|
30
45
|
ownerChannelAsked: number;
|
|
46
|
+
/** Whose direct channel was asked for, in order. */
|
|
47
|
+
directAsked: string[];
|
|
48
|
+
/** The notices sent through a direct channel, by principal. */
|
|
49
|
+
notified: { principalId: string; text: string }[];
|
|
31
50
|
/** What was posted on the surface, by channel. */
|
|
32
51
|
posted: { channel: ChannelKey; text: string }[];
|
|
33
52
|
}
|
|
34
53
|
|
|
35
54
|
export const OWNER_CHANNEL: ChannelKey = "discord:owner-dm";
|
|
36
55
|
|
|
56
|
+
/** The primary owner the modules serve: principal "1". */
|
|
57
|
+
export const MODULES_OWNER = {
|
|
58
|
+
id: "1",
|
|
59
|
+
name: "Owner",
|
|
60
|
+
pronouns: { subject: "they", object: "them", possessive: "their" },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The direct messages the stand-in Discord provider reaches by default: the owner's, principal "1". */
|
|
64
|
+
const DIRECT: Readonly<Record<string, ChannelKey>> = { "1": OWNER_CHANNEL };
|
|
65
|
+
|
|
37
66
|
/** The modules plugin set up over stand-in stores and surface; returns its contribution and the record. */
|
|
38
67
|
export async function setUpModules(
|
|
39
68
|
options: {
|
|
@@ -46,7 +75,29 @@ export async function setUpModules(
|
|
|
46
75
|
owns?: (channel: ChannelKey) => boolean;
|
|
47
76
|
/** Whether the claim owning a channel takes background turns; by default every owned channel's does. */
|
|
48
77
|
takesBackground?: (channel: ChannelKey) => boolean;
|
|
78
|
+
/** Whether the claim owning a channel takes the host's own turns; by default every owned channel's does. */
|
|
79
|
+
takesSystemReports?: (channel: ChannelKey) => boolean;
|
|
49
80
|
errorReporter?: ModulesOptions["errorReporter"];
|
|
81
|
+
/** The schedules the tools keep; by default a stub that answers nothing. */
|
|
82
|
+
schedules?: ScheduleStore;
|
|
83
|
+
/** The conversations the host records, which say whose a conversation is; by default none is recorded. */
|
|
84
|
+
conversations?: Pick<ConversationRegistry, "get">;
|
|
85
|
+
/**
|
|
86
|
+
* Each principal's direct messages on the stand-in Discord provider, the host's only direct
|
|
87
|
+
* channel; by default the owner's are `OWNER_CHANNEL`, and without Discord there is none.
|
|
88
|
+
*/
|
|
89
|
+
direct?: Readonly<Record<string, ChannelKey>>;
|
|
90
|
+
/**
|
|
91
|
+
* Whether the stand-in provider reaches the network, read at each `reaches`, which throws
|
|
92
|
+
* while it does not; `knows` answers from its records either way. Default always.
|
|
93
|
+
*/
|
|
94
|
+
online?: () => boolean;
|
|
95
|
+
/** The host's owners, as `IDENTITY` lists them; by default there is no identity service. */
|
|
96
|
+
owners?: readonly Principal[];
|
|
97
|
+
/** The operator's tool tiers; by default none, so notify stays the owners'. */
|
|
98
|
+
toolTiers?: Readonly<Record<string, Tier>>;
|
|
99
|
+
/** The modules' options besides the test's own. */
|
|
100
|
+
modules?: Partial<ModulesOptions>;
|
|
50
101
|
} = {},
|
|
51
102
|
): Promise<{
|
|
52
103
|
plugin: RoundtablePlugin;
|
|
@@ -58,14 +109,31 @@ export async function setUpModules(
|
|
|
58
109
|
threadOrigins: [],
|
|
59
110
|
reportChannels: [],
|
|
60
111
|
ownerChannelAsked: 0,
|
|
112
|
+
directAsked: [],
|
|
113
|
+
notified: [],
|
|
61
114
|
posted: [],
|
|
62
115
|
};
|
|
116
|
+
const direct =
|
|
117
|
+
options.direct ?? (options.discord === false ? undefined : DIRECT);
|
|
118
|
+
const providers: DirectChannelProvider[] = direct
|
|
119
|
+
? [
|
|
120
|
+
{
|
|
121
|
+
name: "discord",
|
|
122
|
+
label: "a direct message on Discord",
|
|
123
|
+
knows: async (principalId) => direct[principalId] !== undefined,
|
|
124
|
+
reaches: async (principalId) => {
|
|
125
|
+
record.directAsked.push(principalId);
|
|
126
|
+
if (options.online?.() === false)
|
|
127
|
+
throw new Error("Discord is unreachable");
|
|
128
|
+
return direct[principalId];
|
|
129
|
+
},
|
|
130
|
+
deliver: async (principalId, text) =>
|
|
131
|
+
void record.notified.push({ principalId, text }),
|
|
132
|
+
},
|
|
133
|
+
]
|
|
134
|
+
: [];
|
|
63
135
|
const plugin = modulesPlugin({
|
|
64
|
-
owner:
|
|
65
|
-
id: "1",
|
|
66
|
-
name: "Owner",
|
|
67
|
-
pronouns: { subject: "they", object: "them", possessive: "their" },
|
|
68
|
-
},
|
|
136
|
+
owner: MODULES_OWNER,
|
|
69
137
|
assistant: "Assistant",
|
|
70
138
|
// SAFETY: a worker of the test's own replaces the one that would read the runtime.
|
|
71
139
|
modelRuntime: {} as ModelRuntime,
|
|
@@ -77,11 +145,26 @@ export async function setUpModules(
|
|
|
77
145
|
worker: { run: async () => "found it" },
|
|
78
146
|
},
|
|
79
147
|
...(options.errorReporter ? { errorReporter: options.errorReporter } : {}),
|
|
148
|
+
...options.modules,
|
|
80
149
|
});
|
|
81
150
|
const services = new ServiceRegistry([plugin]);
|
|
82
151
|
// SAFETY: the tools under test read no store; each stub is asked for nothing else.
|
|
83
152
|
services.preset(MEMORY, {} as MemoryStore);
|
|
84
|
-
services.preset(SCHEDULES, {} as ScheduleStore);
|
|
153
|
+
services.preset(SCHEDULES, options.schedules ?? ({} as ScheduleStore));
|
|
154
|
+
if (options.conversations)
|
|
155
|
+
// SAFETY: the schedule tools only read a conversation's record.
|
|
156
|
+
services.preset(
|
|
157
|
+
CONVERSATIONS,
|
|
158
|
+
options.conversations as unknown as ConversationRegistry,
|
|
159
|
+
);
|
|
160
|
+
if (options.owners) {
|
|
161
|
+
const owners = options.owners;
|
|
162
|
+
// SAFETY: the modules ask the identity service for the owners and a principal by id only.
|
|
163
|
+
services.preset(IDENTITY, {
|
|
164
|
+
owners: async () => owners,
|
|
165
|
+
principal: async (id: string) => owners.find((owner) => owner.id === id),
|
|
166
|
+
} as unknown as IdentityService);
|
|
167
|
+
}
|
|
85
168
|
if (options.agentChannelOf)
|
|
86
169
|
// SAFETY: the schedule tools ask the agent team for a channel and nothing else.
|
|
87
170
|
services.preset(AGENTS, {
|
|
@@ -108,30 +191,34 @@ export async function setUpModules(
|
|
|
108
191
|
} as unknown as DiscordServices);
|
|
109
192
|
const owns =
|
|
110
193
|
options.owns ?? ((channel: ChannelKey) => channel.startsWith("discord:"));
|
|
111
|
-
//
|
|
194
|
+
// Only Discord's channels have a surface; any other conversation has none to report in.
|
|
195
|
+
const surfaces = surfacePort(() => [
|
|
196
|
+
{
|
|
197
|
+
surface: "discord",
|
|
198
|
+
start: async () => undefined,
|
|
199
|
+
sendReply: async (channel, reply) =>
|
|
200
|
+
void record.posted.push({ channel, text: reply.chunks.join("") }),
|
|
201
|
+
},
|
|
202
|
+
]);
|
|
203
|
+
// SAFETY: setup and the preflight read only the logger, the conversations' background, targets and owners, the surfaces, the direct channels, and the services.
|
|
112
204
|
const context = {
|
|
113
205
|
logger: silentLogger(),
|
|
114
206
|
conversations: {
|
|
115
207
|
target: (name: string) =>
|
|
116
|
-
options.ownerTarget !== false && name ===
|
|
117
|
-
?
|
|
208
|
+
options.ownerTarget !== false && name === PERSONAL_TARGET.name
|
|
209
|
+
? PERSONAL_TARGET
|
|
118
210
|
: undefined,
|
|
119
211
|
owns,
|
|
120
212
|
takesBackground: options.takesBackground ?? owns,
|
|
213
|
+
takesSystemReports: options.takesSystemReports ?? owns,
|
|
121
214
|
background: async (turn: { channel: ChannelKey }) => {
|
|
122
215
|
record.reportChannels.push(turn.channel);
|
|
123
216
|
return { status: "ran" };
|
|
124
217
|
},
|
|
125
218
|
},
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
surface: "discord",
|
|
130
|
-
start: async () => undefined,
|
|
131
|
-
sendReply: async (channel, reply) =>
|
|
132
|
-
void record.posted.push({ channel, text: reply.chunks.join("") }),
|
|
133
|
-
},
|
|
134
|
-
]),
|
|
219
|
+
surfaces,
|
|
220
|
+
directChannels: directChannelsPort(() => providers, surfaces),
|
|
221
|
+
toolTiers: toolTiers(options.toolTiers),
|
|
135
222
|
} as unknown as Omit<PluginContext, "services">;
|
|
136
223
|
const contribution = await services.setUp(plugin, () =>
|
|
137
224
|
plugin.setup(pluginContext(plugin, context, services.forPlugin(plugin))),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Principal } from "../identity/principal-store.ts";
|
|
1
2
|
import type { OwnerIdentity } from "../identity.ts";
|
|
2
3
|
import type { Speaker } from "../speakers.ts";
|
|
3
4
|
|
|
@@ -7,9 +8,25 @@ export const TEST_OWNER: OwnerIdentity = {
|
|
|
7
8
|
pronouns: { subject: "he", object: "him", possessive: "his" },
|
|
8
9
|
};
|
|
9
10
|
|
|
11
|
+
/** The owner as a principal of the tests, its id the owner speaker's, as for an owner carried over from 0.8. */
|
|
12
|
+
export const OWNER_PRINCIPAL: Principal = {
|
|
13
|
+
id: "1",
|
|
14
|
+
displayName: TEST_OWNER.name,
|
|
15
|
+
pronouns: "he",
|
|
16
|
+
disabled: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** A member as a principal of the tests. */
|
|
20
|
+
export const MEMBER_PRINCIPAL: Principal = {
|
|
21
|
+
id: "2",
|
|
22
|
+
displayName: "Bo",
|
|
23
|
+
disabled: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
10
26
|
/** The owner as a speaker of the tests. */
|
|
11
27
|
export const OWNER_SPEAKER: Speaker = {
|
|
12
28
|
id: "1",
|
|
13
29
|
name: TEST_OWNER.name,
|
|
14
30
|
tier: "owner",
|
|
31
|
+
principalId: OWNER_PRINCIPAL.id,
|
|
15
32
|
};
|