pi-roundtable-mcp 0.1.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 +18 -0
- package/LICENSE +21 -0
- package/README.md +273 -0
- package/examples/connector-profiles.ts +36 -0
- package/examples/host-conversation.ts +22 -0
- package/examples/roundtable.config.ts +33 -0
- package/package.json +50 -0
- package/src/connectors/connector-commands.ts +325 -0
- package/src/connectors/connector-registry.ts +340 -0
- package/src/connectors/connectors-plugin.ts +114 -0
- package/src/connectors/contextforge.ts +371 -0
- package/src/connectors/messages.ts +119 -0
- package/src/index.ts +23 -0
- package/src/remote-mcp/channel-grants.ts +308 -0
- package/src/remote-mcp/channel-tools.ts +53 -0
- package/src/remote-mcp/default-conversation.ts +75 -0
- package/src/remote-mcp/mcp-gateway.ts +345 -0
- package/src/remote-mcp/mcp-grant-commands.ts +100 -0
- package/src/remote-mcp/mcp-grant-flow.ts +348 -0
- package/src/remote-mcp/mcp-grant-panels.ts +174 -0
- package/src/remote-mcp/messages.ts +195 -0
- package/src/remote-mcp/remote-agent.ts +127 -0
- package/src/remote-mcp/remote-claim.ts +51 -0
- package/src/remote-mcp/remote-mcp-plugin.ts +148 -0
- package/src/remote-mcp/remote-session-store.ts +55 -0
- package/src/remote-mcp/session-sweeper.ts +78 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import type { SQL } from "bun";
|
|
3
|
+
import type { Migration } from "pi-roundtable";
|
|
4
|
+
import type { ChannelOperation } from "pi-roundtable/discord";
|
|
5
|
+
|
|
6
|
+
/** A bundle is one MCP URL; its token is shown once and only its hash is kept. */
|
|
7
|
+
export interface ChannelBundle {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
tokenHash: string;
|
|
11
|
+
defaultOperations: ChannelOperation[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ChannelGrant {
|
|
15
|
+
bundleId: string;
|
|
16
|
+
channelId: string;
|
|
17
|
+
guildId: string;
|
|
18
|
+
operations: ChannelOperation[];
|
|
19
|
+
/** The name and purpose an agent sees; Discord's own name is untouched. */
|
|
20
|
+
displayName: string;
|
|
21
|
+
description: string;
|
|
22
|
+
/** Last known names, shown when the channel can no longer be fetched. */
|
|
23
|
+
guildName: string;
|
|
24
|
+
channelName: string;
|
|
25
|
+
authorizedBy: string;
|
|
26
|
+
authorizedAt: Date;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ChannelAudit {
|
|
30
|
+
tool: string;
|
|
31
|
+
status: string;
|
|
32
|
+
createdAt: Date;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const hashChannelToken = (token: string): string =>
|
|
36
|
+
createHash("sha256").update(token).digest("hex");
|
|
37
|
+
|
|
38
|
+
/** A fresh bundle URL under the public base; the token appears only in the returned URL. */
|
|
39
|
+
export function newChannelEndpoint(publicBaseUrl: string): {
|
|
40
|
+
url: string;
|
|
41
|
+
tokenHash: string;
|
|
42
|
+
} {
|
|
43
|
+
const base = URL.parse(publicBaseUrl);
|
|
44
|
+
if (!base) throw new Error("MCP public URL is not a URL");
|
|
45
|
+
if (base.protocol !== "https:")
|
|
46
|
+
throw new Error("MCP public URL must use HTTPS");
|
|
47
|
+
const token = randomBytes(32).toString("base64url");
|
|
48
|
+
return {
|
|
49
|
+
url: new URL(`/mcp/discord/${token}`, base.origin).href,
|
|
50
|
+
tokenHash: hashChannelToken(token),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A bundle token as it appears in a URL: 32 random bytes in base64url. */
|
|
55
|
+
export const CHANNEL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
56
|
+
|
|
57
|
+
interface BundleRow {
|
|
58
|
+
id: string;
|
|
59
|
+
name: string;
|
|
60
|
+
token_hash: string;
|
|
61
|
+
default_operations: ChannelOperation[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface GrantRow {
|
|
65
|
+
bundle_id: string;
|
|
66
|
+
channel_id: string;
|
|
67
|
+
guild_id: string;
|
|
68
|
+
operations: ChannelOperation[];
|
|
69
|
+
display_name: string;
|
|
70
|
+
description: string;
|
|
71
|
+
guild_name: string;
|
|
72
|
+
channel_name: string;
|
|
73
|
+
authorized_by: string;
|
|
74
|
+
authorized_at: Date;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const toBundle = (row: BundleRow): ChannelBundle => ({
|
|
78
|
+
id: row.id,
|
|
79
|
+
name: row.name,
|
|
80
|
+
tokenHash: row.token_hash,
|
|
81
|
+
defaultOperations: row.default_operations,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const toGrant = (row: GrantRow): ChannelGrant => ({
|
|
85
|
+
bundleId: row.bundle_id,
|
|
86
|
+
channelId: row.channel_id,
|
|
87
|
+
guildId: row.guild_id,
|
|
88
|
+
operations: row.operations,
|
|
89
|
+
displayName: row.display_name,
|
|
90
|
+
description: row.description,
|
|
91
|
+
guildName: row.guild_name,
|
|
92
|
+
channelName: row.channel_name,
|
|
93
|
+
authorizedBy: row.authorized_by,
|
|
94
|
+
authorizedAt: row.authorized_at,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** The owner's channel grants for outside agents, in the host's database. */
|
|
98
|
+
export class ChannelGrantStore {
|
|
99
|
+
readonly #sql: SQL;
|
|
100
|
+
|
|
101
|
+
private constructor(sql: SQL) {
|
|
102
|
+
this.#sql = sql;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The store's tables; the host runs this before any store attaches. */
|
|
106
|
+
static readonly migration: Migration = {
|
|
107
|
+
name: "channel-grants",
|
|
108
|
+
up: async (sql) => {
|
|
109
|
+
await sql`
|
|
110
|
+
CREATE TABLE IF NOT EXISTS discord_mcp_bundles (
|
|
111
|
+
id uuid PRIMARY KEY,
|
|
112
|
+
name text NOT NULL UNIQUE,
|
|
113
|
+
token_hash text NOT NULL UNIQUE,
|
|
114
|
+
default_operations jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
115
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
116
|
+
)`;
|
|
117
|
+
await sql`
|
|
118
|
+
CREATE TABLE IF NOT EXISTS discord_channel_grants (
|
|
119
|
+
bundle_id uuid NOT NULL REFERENCES discord_mcp_bundles (id) ON DELETE CASCADE,
|
|
120
|
+
channel_id text NOT NULL,
|
|
121
|
+
guild_id text NOT NULL,
|
|
122
|
+
operations jsonb NOT NULL,
|
|
123
|
+
display_name text NOT NULL,
|
|
124
|
+
description text NOT NULL DEFAULT '',
|
|
125
|
+
guild_name text NOT NULL,
|
|
126
|
+
channel_name text NOT NULL,
|
|
127
|
+
authorized_by text NOT NULL,
|
|
128
|
+
authorized_at timestamptz NOT NULL DEFAULT now(),
|
|
129
|
+
PRIMARY KEY (bundle_id, channel_id)
|
|
130
|
+
)`;
|
|
131
|
+
await sql`
|
|
132
|
+
CREATE TABLE IF NOT EXISTS discord_channel_audit (
|
|
133
|
+
id uuid PRIMARY KEY,
|
|
134
|
+
bundle_id uuid,
|
|
135
|
+
channel_id text NOT NULL,
|
|
136
|
+
guild_id text,
|
|
137
|
+
tool text NOT NULL,
|
|
138
|
+
arguments_digest text,
|
|
139
|
+
status text NOT NULL,
|
|
140
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
141
|
+
)`;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** The store over the host's migrated pool. */
|
|
146
|
+
static async attach(sql: SQL): Promise<ChannelGrantStore> {
|
|
147
|
+
return new ChannelGrantStore(sql);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async bundles(): Promise<ChannelBundle[]> {
|
|
151
|
+
const rows: BundleRow[] = await this.#sql`
|
|
152
|
+
SELECT id, name, token_hash, default_operations FROM discord_mcp_bundles ORDER BY name`;
|
|
153
|
+
return rows.map(toBundle);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async bundleByName(name: string): Promise<ChannelBundle | undefined> {
|
|
157
|
+
const rows: BundleRow[] = await this.#sql`
|
|
158
|
+
SELECT id, name, token_hash, default_operations FROM discord_mcp_bundles WHERE name = ${name}`;
|
|
159
|
+
return rows[0] && toBundle(rows[0]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async bundleByTokenHash(
|
|
163
|
+
tokenHash: string,
|
|
164
|
+
): Promise<ChannelBundle | undefined> {
|
|
165
|
+
const rows: BundleRow[] = await this.#sql`
|
|
166
|
+
SELECT id, name, token_hash, default_operations FROM discord_mcp_bundles
|
|
167
|
+
WHERE token_hash = ${tokenHash}`;
|
|
168
|
+
return rows[0] && toBundle(rows[0]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The named bundle, created with this token hash when it does not exist yet. `created`
|
|
173
|
+
* tells the caller whether the new URL is the one in effect.
|
|
174
|
+
*/
|
|
175
|
+
async ensureBundle(
|
|
176
|
+
name: string,
|
|
177
|
+
tokenHash: string,
|
|
178
|
+
defaultOperations: ChannelOperation[],
|
|
179
|
+
): Promise<{ bundle: ChannelBundle; created: boolean }> {
|
|
180
|
+
const inserted: BundleRow[] = await this.#sql`
|
|
181
|
+
INSERT INTO discord_mcp_bundles (id, name, token_hash, default_operations)
|
|
182
|
+
VALUES (${randomUUID()}, ${name}, ${tokenHash}, ${defaultOperations}::jsonb)
|
|
183
|
+
ON CONFLICT (name) DO NOTHING
|
|
184
|
+
RETURNING id, name, token_hash, default_operations`;
|
|
185
|
+
if (inserted[0]) return { bundle: toBundle(inserted[0]), created: true };
|
|
186
|
+
const existing = await this.bundleByName(name);
|
|
187
|
+
if (!existing) throw new Error(`bundle ${name} vanished`);
|
|
188
|
+
return { bundle: existing, created: false };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async rotateToken(bundleId: string, tokenHash: string): Promise<void> {
|
|
192
|
+
await this.#sql.begin(async (tx) => {
|
|
193
|
+
const rows = await tx`
|
|
194
|
+
UPDATE discord_mcp_bundles SET token_hash = ${tokenHash} WHERE id = ${bundleId}
|
|
195
|
+
RETURNING id`;
|
|
196
|
+
if (rows.length === 0) throw new Error(`bundle ${bundleId} not found`);
|
|
197
|
+
await tx`
|
|
198
|
+
INSERT INTO discord_channel_audit (id, bundle_id, channel_id, tool, status)
|
|
199
|
+
VALUES (${randomUUID()}, ${bundleId}, '-', 'rotate_token', 'succeeded')`;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Every grant, or one bundle's. */
|
|
204
|
+
async grants(bundleId?: string): Promise<ChannelGrant[]> {
|
|
205
|
+
const rows: GrantRow[] = await this.#sql`
|
|
206
|
+
SELECT * FROM discord_channel_grants
|
|
207
|
+
WHERE ${bundleId ?? null}::uuid IS NULL OR bundle_id = ${bundleId ?? null}::uuid
|
|
208
|
+
ORDER BY bundle_id, channel_id`;
|
|
209
|
+
return rows.map(toGrant);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async grant(
|
|
213
|
+
bundleId: string,
|
|
214
|
+
channelId: string,
|
|
215
|
+
): Promise<ChannelGrant | undefined> {
|
|
216
|
+
const rows: GrantRow[] = await this.#sql`
|
|
217
|
+
SELECT * FROM discord_channel_grants
|
|
218
|
+
WHERE bundle_id = ${bundleId} AND channel_id = ${channelId}`;
|
|
219
|
+
return rows[0] && toGrant(rows[0]);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Adds the channel to the bundle, or replaces its grant there. */
|
|
223
|
+
async save(grant: ChannelGrant): Promise<void> {
|
|
224
|
+
await this.#sql.begin(async (tx) => {
|
|
225
|
+
await tx`
|
|
226
|
+
INSERT INTO discord_channel_grants (bundle_id, channel_id, guild_id, operations,
|
|
227
|
+
display_name, description, guild_name, channel_name, authorized_by, authorized_at)
|
|
228
|
+
VALUES (${grant.bundleId}, ${grant.channelId}, ${grant.guildId}, ${grant.operations}::jsonb,
|
|
229
|
+
${grant.displayName}, ${grant.description}, ${grant.guildName}, ${grant.channelName},
|
|
230
|
+
${grant.authorizedBy}, ${grant.authorizedAt})
|
|
231
|
+
ON CONFLICT (bundle_id, channel_id) DO UPDATE SET
|
|
232
|
+
guild_id = EXCLUDED.guild_id, operations = EXCLUDED.operations,
|
|
233
|
+
display_name = EXCLUDED.display_name, description = EXCLUDED.description,
|
|
234
|
+
guild_name = EXCLUDED.guild_name, channel_name = EXCLUDED.channel_name,
|
|
235
|
+
authorized_by = EXCLUDED.authorized_by, authorized_at = EXCLUDED.authorized_at`;
|
|
236
|
+
await tx`
|
|
237
|
+
INSERT INTO discord_channel_audit (id, bundle_id, channel_id, guild_id, tool, status)
|
|
238
|
+
VALUES (${randomUUID()}, ${grant.bundleId}, ${grant.channelId}, ${grant.guildId},
|
|
239
|
+
'authorize', 'succeeded')`;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Resolves false when the channel was not in the bundle. */
|
|
244
|
+
async revoke(bundleId: string, channelId: string): Promise<boolean> {
|
|
245
|
+
return this.#sql.begin(async (tx) => {
|
|
246
|
+
const rows = await tx`
|
|
247
|
+
DELETE FROM discord_channel_grants WHERE bundle_id = ${bundleId} AND channel_id = ${channelId}
|
|
248
|
+
RETURNING channel_id`;
|
|
249
|
+
if (rows.length === 0) return false;
|
|
250
|
+
await tx`
|
|
251
|
+
INSERT INTO discord_channel_audit (id, bundle_id, channel_id, tool, status)
|
|
252
|
+
VALUES (${randomUUID()}, ${bundleId}, ${channelId}, 'revoke', 'succeeded')`;
|
|
253
|
+
return true;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Changes what an agent is told about the channel; resolves false when it is not granted. */
|
|
258
|
+
async describe(
|
|
259
|
+
bundleId: string,
|
|
260
|
+
channelId: string,
|
|
261
|
+
change: { displayName?: string; description?: string },
|
|
262
|
+
): Promise<boolean> {
|
|
263
|
+
const rows = await this.#sql`
|
|
264
|
+
UPDATE discord_channel_grants SET
|
|
265
|
+
display_name = COALESCE(${change.displayName ?? null}, display_name),
|
|
266
|
+
description = COALESCE(${change.description ?? null}, description)
|
|
267
|
+
WHERE bundle_id = ${bundleId} AND channel_id = ${channelId}
|
|
268
|
+
RETURNING channel_id`;
|
|
269
|
+
return rows.length > 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async recentAudit(channelId: string, limit = 10): Promise<ChannelAudit[]> {
|
|
273
|
+
const rows: { tool: string; status: string; created_at: Date }[] =
|
|
274
|
+
await this.#sql`
|
|
275
|
+
SELECT tool, status, created_at FROM discord_channel_audit
|
|
276
|
+
WHERE channel_id = ${channelId} ORDER BY created_at DESC LIMIT ${limit}`;
|
|
277
|
+
return rows.map((row) => ({
|
|
278
|
+
tool: row.tool,
|
|
279
|
+
status: row.status,
|
|
280
|
+
createdAt: row.created_at,
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Records a tool call before it runs; returns the receipt to finish. */
|
|
285
|
+
async startCall(
|
|
286
|
+
bundleId: string,
|
|
287
|
+
guildId: string,
|
|
288
|
+
tool: string,
|
|
289
|
+
args: Record<string, unknown>,
|
|
290
|
+
): Promise<string> {
|
|
291
|
+
const id = randomUUID();
|
|
292
|
+
const digest = createHash("sha256")
|
|
293
|
+
.update(JSON.stringify(args))
|
|
294
|
+
.digest("hex");
|
|
295
|
+
await this.#sql`
|
|
296
|
+
INSERT INTO discord_channel_audit (id, bundle_id, channel_id, guild_id, tool, arguments_digest, status)
|
|
297
|
+
VALUES (${id}, ${bundleId}, ${String(args.channelId)}, ${guildId}, ${tool}, ${digest}, 'started')`;
|
|
298
|
+
return id;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async finishCall(
|
|
302
|
+
receipt: string,
|
|
303
|
+
status: "succeeded" | "failed",
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
await this
|
|
306
|
+
.#sql`UPDATE discord_channel_audit SET status = ${status} WHERE id = ${receipt}`;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHANNEL_TOOLS,
|
|
3
|
+
type ChannelExecutor,
|
|
4
|
+
ChannelToolError,
|
|
5
|
+
} from "pi-roundtable/discord";
|
|
6
|
+
import type { ChannelBundle, ChannelGrantStore } from "./channel-grants.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Runs one tool call for a bundle. The grant and the bundle's token are read after Discord
|
|
10
|
+
* is inspected, so a revoke or token rotation during that network call still stops it.
|
|
11
|
+
*/
|
|
12
|
+
export async function runGrantedTool(
|
|
13
|
+
bundle: ChannelBundle,
|
|
14
|
+
tool: string,
|
|
15
|
+
args: Record<string, unknown>,
|
|
16
|
+
store: ChannelGrantStore,
|
|
17
|
+
executor: ChannelExecutor,
|
|
18
|
+
): Promise<unknown> {
|
|
19
|
+
const spec = CHANNEL_TOOLS[tool];
|
|
20
|
+
const channelId = args.channelId;
|
|
21
|
+
if (!spec || typeof channelId !== "string")
|
|
22
|
+
throw new ChannelToolError("INVALID_CHANNEL_OPERATION");
|
|
23
|
+
const { guildId } = await executor.inspect(channelId, spec.operation);
|
|
24
|
+
const [grant, current] = await Promise.all([
|
|
25
|
+
store.grant(bundle.id, channelId),
|
|
26
|
+
store.bundleByTokenHash(bundle.tokenHash),
|
|
27
|
+
]);
|
|
28
|
+
if (current?.id !== bundle.id) throw new ChannelToolError("ENDPOINT_REVOKED");
|
|
29
|
+
if (
|
|
30
|
+
!grant ||
|
|
31
|
+
grant.guildId !== guildId ||
|
|
32
|
+
!grant.operations.includes(spec.operation)
|
|
33
|
+
)
|
|
34
|
+
throw new ChannelToolError("CHANNEL_NOT_AUTHORIZED");
|
|
35
|
+
const receipt = await store.startCall(bundle.id, guildId, tool, args);
|
|
36
|
+
let result: unknown;
|
|
37
|
+
try {
|
|
38
|
+
result = await executor.execute(tool, args);
|
|
39
|
+
} catch {
|
|
40
|
+
await store.finishCall(receipt, "failed");
|
|
41
|
+
throw new ChannelToolError(
|
|
42
|
+
`DISCORD_OPERATION_FAILED: audit entry ${receipt}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
await store.finishCall(receipt, "succeeded");
|
|
47
|
+
} catch {
|
|
48
|
+
throw new ChannelToolError(
|
|
49
|
+
`DISCORD_OUTCOME_UNRECORDED: the operation may have completed, so do not retry automatically. Audit entry ${receipt}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentServer,
|
|
3
|
+
ChannelKey,
|
|
4
|
+
PluginContext,
|
|
5
|
+
Speaker,
|
|
6
|
+
TurnResult,
|
|
7
|
+
} from "pi-roundtable";
|
|
8
|
+
import { settleTurn } from "pi-roundtable/kit";
|
|
9
|
+
import type { RemoteClaimHooks } from "./remote-claim.ts";
|
|
10
|
+
|
|
11
|
+
/** The conversation kind of the default remote conversations, and of their persona. */
|
|
12
|
+
export const REMOTE_KIND = "remote";
|
|
13
|
+
|
|
14
|
+
/** The system prompt of the default remote conversations, unless the host gives its own. */
|
|
15
|
+
export const DEFAULT_PERSONA =
|
|
16
|
+
"You are the owner's personal assistant. The owner is writing to you through an outside agent over MCP, not on Discord, and each message begins with a note saying so. Answer the owner directly.";
|
|
17
|
+
|
|
18
|
+
/** Who the default turns are for: the owner, reached through an outside agent that holds the dispatch token. */
|
|
19
|
+
export const REMOTE_SPEAKER: Speaker = {
|
|
20
|
+
id: "remote-mcp",
|
|
21
|
+
name: "Remote agent",
|
|
22
|
+
tier: "owner",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** How the plugin runs a remote turn and a remote conversation's claim operations. */
|
|
26
|
+
export interface RemoteConversation {
|
|
27
|
+
answer(channel: ChannelKey, text: string): Promise<TurnResult>;
|
|
28
|
+
claim: RemoteClaimHooks;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Parts = Pick<PluginContext, "queue" | "turns"> & {
|
|
32
|
+
/** The agent server, read once every plugin is set up (`services.lazy(AGENTS)`). */
|
|
33
|
+
server: () => AgentServer;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Runs remote turns on the core: `context.turns.run` of kind `remote` for an owner-tier speaker,
|
|
38
|
+
* in the channel's queue, with nothing posted since no surface serves `mcp:` channels. A relayed
|
|
39
|
+
* message that approves held actions confirms them, as the owner's reply would on Discord.
|
|
40
|
+
*/
|
|
41
|
+
export function defaultConversation({
|
|
42
|
+
queue,
|
|
43
|
+
turns,
|
|
44
|
+
server,
|
|
45
|
+
}: Parts): RemoteConversation {
|
|
46
|
+
return {
|
|
47
|
+
answer: (channel, text) =>
|
|
48
|
+
queue.run(channel, () =>
|
|
49
|
+
settleTurn(async () => {
|
|
50
|
+
const { runtime, approvals } = server();
|
|
51
|
+
const pending = runtime.pendingConfirmation(channel);
|
|
52
|
+
const confirmed =
|
|
53
|
+
pending !== undefined && (await approvals.approves(pending, text));
|
|
54
|
+
return turns.run({
|
|
55
|
+
channel,
|
|
56
|
+
kind: REMOTE_KIND,
|
|
57
|
+
text,
|
|
58
|
+
speaker: REMOTE_SPEAKER,
|
|
59
|
+
confirmed,
|
|
60
|
+
// The outside agent polls for the answer; no surface posts it.
|
|
61
|
+
reply: async () => undefined,
|
|
62
|
+
});
|
|
63
|
+
}, "remote turn"),
|
|
64
|
+
),
|
|
65
|
+
claim: {
|
|
66
|
+
stop: (channel) => server().runtime.stop(channel),
|
|
67
|
+
startFresh: async (channel) => {
|
|
68
|
+
await server().runtime.startFresh(channel);
|
|
69
|
+
return REMOTE_KIND;
|
|
70
|
+
},
|
|
71
|
+
deleteConversation: (channel) =>
|
|
72
|
+
server().runtime.deleteConversation(channel),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|