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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionFactory,
|
|
5
|
+
ToolDefinition,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
/** The ids of the tool calls whose code is running, outermost first. */
|
|
9
|
+
const calls = new AsyncLocalStorage<readonly string[]>();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The ids of the tool calls the code asking runs within, outermost first: the call of a session
|
|
13
|
+
* part's tool, and the calls it runs within in turn, such as a worker's call inside the call that
|
|
14
|
+
* started the worker. Empty outside every such call.
|
|
15
|
+
*/
|
|
16
|
+
export function runningCalls(): readonly string[] {
|
|
17
|
+
return calls.getStore() ?? [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A session part's extension whose tools each run within their call's id, so what a call starts,
|
|
22
|
+
* a task's worker among them, knows which call it serves rather than every call running at once.
|
|
23
|
+
*/
|
|
24
|
+
export function scopedCalls(factory: ExtensionFactory): ExtensionFactory {
|
|
25
|
+
return (pi) =>
|
|
26
|
+
factory(
|
|
27
|
+
new Proxy(pi, {
|
|
28
|
+
get(target, key) {
|
|
29
|
+
if (key === "registerTool")
|
|
30
|
+
return (tool: ToolDefinition) =>
|
|
31
|
+
target.registerTool({
|
|
32
|
+
...tool,
|
|
33
|
+
execute: (toolCallId, ...rest) =>
|
|
34
|
+
calls.run([...runningCalls(), toolCallId], () =>
|
|
35
|
+
tool.execute(toolCallId, ...rest),
|
|
36
|
+
),
|
|
37
|
+
});
|
|
38
|
+
const value: unknown = Reflect.get(target, key, target);
|
|
39
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
40
|
+
},
|
|
41
|
+
}) satisfies ExtensionAPI,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { TurnResult } from "../domain/conversation.ts";
|
|
1
|
+
import type { TranscriptEntry, TurnResult } from "../domain/conversation.ts";
|
|
2
2
|
import { AgentRunError } from "../domain/errors.ts";
|
|
3
3
|
import { hasReplyFiles } from "../reply-files.ts";
|
|
4
4
|
import { lastAssistant, textOf } from "../shared/session-messages.ts";
|
|
5
|
+
import { TRANSCRIPT_ENTRY_CHARS } from "./runtime-types.ts";
|
|
5
6
|
|
|
6
7
|
interface MessagePart {
|
|
7
8
|
type?: unknown;
|
|
@@ -20,6 +21,34 @@ type Assistant = Message & {
|
|
|
20
21
|
const isAssistant = (message: Message): message is Assistant =>
|
|
21
22
|
message.role === "assistant";
|
|
22
23
|
|
|
24
|
+
/** The answer to a turn sent without a speaker: refused, never run as the owner's by default. */
|
|
25
|
+
export function unspokenTurn(): TurnResult {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
error: new AgentRunError(
|
|
29
|
+
"a turn needs a speaker: pass TurnRequest.speaker, the person the turn is for; for a turn started on someone's behalf, get theirs from IDENTITY.speakerFor",
|
|
30
|
+
),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The last `limit` user and assistant messages as transcript entries, each cut to its first characters. */
|
|
35
|
+
export function transcriptOf(
|
|
36
|
+
messages: readonly (Message & { content?: unknown })[],
|
|
37
|
+
limit: number,
|
|
38
|
+
): TranscriptEntry[] {
|
|
39
|
+
const entries: TranscriptEntry[] = [];
|
|
40
|
+
for (const message of messages) {
|
|
41
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
42
|
+
const text = textOf(message.content).trim();
|
|
43
|
+
if (text)
|
|
44
|
+
entries.push({
|
|
45
|
+
role: message.role,
|
|
46
|
+
text: text.slice(0, TRANSCRIPT_ENTRY_CHARS),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return entries.slice(-limit);
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
/** The session's last answer, which tells the judge what a short follow-up continues. */
|
|
24
53
|
export function lastReply(messages: readonly Message[]): string | undefined {
|
|
25
54
|
const last = messages.findLast(isAssistant);
|
package/src/core/services.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { Agent, AgentGroup } from "./agents/agent-rules.ts";
|
|
2
2
|
import type { AvatarMode } from "./agents/agent-tools.ts";
|
|
3
3
|
import type { TeamStatus } from "./agents/team-status.ts";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
BackgroundRunsAs,
|
|
6
|
+
ScheduledOutcome,
|
|
7
|
+
} from "./contract/channels.ts";
|
|
5
8
|
import type { AgentRuntime } from "./contract/runtime.ts";
|
|
6
9
|
import { type ServiceKey, serviceKey } from "./contract/services.ts";
|
|
7
10
|
import type { ConversationRegistry } from "./conversations/conversation-registry.ts";
|
|
8
11
|
import type { PendingConfirmation } from "./domain/conversation.ts";
|
|
9
12
|
import type { HttpRoute } from "./http/listeners.ts";
|
|
13
|
+
import type { IdentityService } from "./identity/identity-service.ts";
|
|
10
14
|
import type { ThinkingSetting } from "./models.ts";
|
|
11
15
|
import type {
|
|
12
16
|
DelegationJob,
|
|
@@ -36,6 +40,12 @@ import type { AgentTurnScope, ChannelKey } from "./sessions.ts";
|
|
|
36
40
|
// interface, so an object with the same methods satisfies it without being one of the built-in
|
|
37
41
|
// classes; a plugin replaces a built-in by providing its own under the same key.
|
|
38
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Who the host serves: its principals, the identities linked to each, and their lasting roles.
|
|
45
|
+
* Provided by the `identity` plugin, which every host registers first, so every plugin may read it.
|
|
46
|
+
*/
|
|
47
|
+
export const IDENTITY: ServiceKey<IdentityService> =
|
|
48
|
+
serviceKey<IdentityService>("roundtable.identity");
|
|
39
49
|
/**
|
|
40
50
|
* The runtime every conversation turn runs on: the agent server's, and each turn run through
|
|
41
51
|
* `context.turns`. The `runtime` provider slot's when a plugin fills it, Pi's otherwise. Provided
|
|
@@ -178,6 +188,16 @@ export interface AgentTeam {
|
|
|
178
188
|
/** Scheduled turns, stored. A one-time schedule is deleted once it fires. */
|
|
179
189
|
export interface ScheduleStore {
|
|
180
190
|
create(schedule: NewSchedule): Promise<Schedule>;
|
|
191
|
+
/**
|
|
192
|
+
* Creates a schedule unless its creator already has `limit.max` of its target's schedules,
|
|
193
|
+
* counting those whose creator id is one of `limit.creators`; resolves the count reached instead.
|
|
194
|
+
* Two calls for one creator never count at once, so creates at once in many conversations stay
|
|
195
|
+
* within the limit.
|
|
196
|
+
*/
|
|
197
|
+
createWithin(
|
|
198
|
+
schedule: NewSchedule,
|
|
199
|
+
limit: { creators: readonly string[]; max: number },
|
|
200
|
+
): Promise<{ created: Schedule } | { reached: number }>;
|
|
181
201
|
get(id: number): Promise<Schedule | undefined>;
|
|
182
202
|
forChannel(channel: ChannelKey): Promise<Schedule[]>;
|
|
183
203
|
all(): Promise<Schedule[]>;
|
|
@@ -208,6 +228,12 @@ export interface BackgroundTurns {
|
|
|
208
228
|
firedAt: Date,
|
|
209
229
|
finding?: PrecheckFinding,
|
|
210
230
|
): Promise<ScheduledOutcome>;
|
|
231
|
+
/**
|
|
232
|
+
* Who a due schedule's turn would run as now, checked as the turn is: asked before its
|
|
233
|
+
* precheck, which runs only for a creator who may run the turn, at the tier it would run at.
|
|
234
|
+
* Throws when the identity service cannot tell.
|
|
235
|
+
*/
|
|
236
|
+
runsAs(schedule: Schedule): Promise<BackgroundRunsAs>;
|
|
211
237
|
/** A delegated task's report, answered in its channel under the same rules as a schedule. */
|
|
212
238
|
runDelegated(job: DelegationJob, result: DelegationOutcome): Promise<void>;
|
|
213
239
|
/** The process's own logged error, reported to an agent in its channel as a report turn. */
|
package/src/core/sessions.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { PluginError } from "./errors.ts";
|
|
3
|
+
import type { Principal } from "./identity/principal-store.ts";
|
|
4
|
+
import type { OwnerIdentity } from "./identity.ts";
|
|
3
5
|
import type { Speaker } from "./speakers.ts";
|
|
4
6
|
|
|
5
7
|
/** `<surface>:<channel id>`, for example `discord:1234`. A DM is one channel. */
|
|
@@ -41,6 +43,15 @@ export interface TransientTask {
|
|
|
41
43
|
onFinished?(toolCalls: readonly string[]): void;
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Whom a session's conversation serves, fixed when the session is made: `private` to one
|
|
48
|
+
* principal, whose name and pronouns are known when the host has a record of them, or `shared`
|
|
49
|
+
* by whoever its claim admits, as an agent's session always is.
|
|
50
|
+
*/
|
|
51
|
+
export type SessionConversation =
|
|
52
|
+
| { visibility: "private"; principalId: string; principal?: Principal }
|
|
53
|
+
| { visibility: "shared" };
|
|
54
|
+
|
|
44
55
|
/** What one Pi session is for, as a session tool's factory sees it. */
|
|
45
56
|
export interface SessionContext {
|
|
46
57
|
/** "agent" for an agent's session; otherwise the kind of conversation, which the claim that owns the channel names. */
|
|
@@ -53,6 +64,25 @@ export interface SessionContext {
|
|
|
53
64
|
agent?: AgentTurnScope;
|
|
54
65
|
/** Wraps a compactor so the core's compaction tiers decide when it may answer. */
|
|
55
66
|
compaction: { wrap(compactor: ExtensionFactory): ExtensionFactory };
|
|
67
|
+
/**
|
|
68
|
+
* Whom the conversation serves, as `TurnRequest.conversation` or the host's record says when the
|
|
69
|
+
* session is made; shared when neither says. A private conversation's tools serve its person:
|
|
70
|
+
* their memory, their notices. A shared one's serve each turn's speaker.
|
|
71
|
+
*/
|
|
72
|
+
conversation: SessionConversation;
|
|
73
|
+
/**
|
|
74
|
+
* Whom the session's tool descriptions address, fixed when it is made: a private conversation's
|
|
75
|
+
* person by their name and pronouns, the primary owner exactly as 0.8 wrote them, and
|
|
76
|
+
* `THE_SPEAKER` in a shared conversation, where each turn's prompt says who speaks.
|
|
77
|
+
*/
|
|
78
|
+
addressee: OwnerIdentity;
|
|
79
|
+
/**
|
|
80
|
+
* Whose memory the session's turns may read: `"speaker"` (the default), as `conversation` and
|
|
81
|
+
* each turn's speaker decide, or `"none"`, for a persona that declares it; then no memory loads.
|
|
82
|
+
* While the running turn is one the agent server keeps out of memory (`AgentSessions.memory`),
|
|
83
|
+
* it reads `"none"` for that turn alone.
|
|
84
|
+
*/
|
|
85
|
+
memory: "speaker" | "none";
|
|
56
86
|
/** The person the session's running turn is for; undefined between turns. */
|
|
57
87
|
speaker(): Speaker | undefined;
|
|
58
88
|
/** Runs a task beside this session, under its confirmation gate. */
|
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
|
+
}
|