pi-roundtable 0.24.2 → 0.26.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/docs/plugins.md +174 -7
  3. package/package.json +1 -1
  4. package/src/cli/checks/agent-tools.ts +23 -0
  5. package/src/cli/doctor.ts +6 -0
  6. package/src/cli/principal.ts +9 -2
  7. package/src/core/agents/agent-book.ts +155 -0
  8. package/src/core/agents/agent-ports.ts +5 -1
  9. package/src/core/agents/agent-prompt.ts +6 -1
  10. package/src/core/agents/agent-schema.ts +3 -0
  11. package/src/core/agents/agent-statements.ts +179 -0
  12. package/src/core/agents/agent-store.ts +184 -191
  13. package/src/core/agents/agent-team-fixture.ts +21 -2
  14. package/src/core/agents/agent-team.ts +44 -36
  15. package/src/core/agents/agent-tools.ts +12 -6
  16. package/src/core/agents/team-editing.ts +154 -32
  17. package/src/core/agents/team-keys.ts +17 -0
  18. package/src/core/agents/team-lifecycle.ts +96 -23
  19. package/src/core/agents/team-options.ts +9 -4
  20. package/src/core/agents/team-selection.ts +32 -0
  21. package/src/core/builtin/agent-server.ts +30 -5
  22. package/src/core/builtin/channel-context.ts +21 -0
  23. package/src/core/builtin/discord.ts +4 -1
  24. package/src/core/builtin/prompts.ts +40 -0
  25. package/src/core/builtin/skills.ts +9 -5
  26. package/src/core/config/config-schema.ts +22 -6
  27. package/src/core/config/config.ts +30 -2
  28. package/src/core/config/conversations.ts +21 -0
  29. package/src/core/config/profiles.ts +53 -0
  30. package/src/core/contract/channel-context.ts +106 -0
  31. package/src/core/contract/surface.ts +16 -0
  32. package/src/core/conversations/conversation-registry.ts +6 -0
  33. package/src/core/conversations/conversation-retention.ts +107 -0
  34. package/src/core/conversations/conversation-store.ts +6 -0
  35. package/src/core/conversations/conversations-plugin.ts +19 -3
  36. package/src/core/define-roundtable.ts +12 -2
  37. package/src/core/discord/channel-context.ts +47 -78
  38. package/src/core/discord/discord-surface.ts +16 -0
  39. package/src/core/events.ts +1 -0
  40. package/src/core/host-registration.ts +4 -0
  41. package/src/core/host.ts +3 -1
  42. package/src/core/i18n/agent-panel.ts +4 -0
  43. package/src/core/i18n/cards.ts +1 -1
  44. package/src/core/identity/actor-facts.ts +7 -0
  45. package/src/core/identity/identity-plugin.ts +27 -5
  46. package/src/core/identity/identity-port.ts +62 -0
  47. package/src/core/identity/identity-schema.ts +2 -0
  48. package/src/core/identity/identity-service.ts +45 -52
  49. package/src/core/identity/identity-view.ts +1 -0
  50. package/src/core/identity/person-profiles.ts +275 -0
  51. package/src/core/identity/principal-tables.ts +10 -0
  52. package/src/core/identity/profile-choices.ts +45 -0
  53. package/src/core/identity/profile-memory.ts +65 -0
  54. package/src/core/identity/profile-picture.ts +117 -0
  55. package/src/core/identity/profiles-view.ts +20 -0
  56. package/src/core/identity/profiles.ts +82 -0
  57. package/src/core/modules/host-shell/shell-policy.ts +18 -3
  58. package/src/core/modules/prompts/prompt-documents.ts +425 -0
  59. package/src/core/modules/prompts/prompt-history.ts +103 -0
  60. package/src/core/modules/skills/skill-contract.ts +52 -0
  61. package/src/core/modules/skills/skill-document-files.ts +66 -0
  62. package/src/core/modules/skills/skill-registry.ts +136 -8
  63. package/src/core/modules/skills/skill-rules.ts +94 -0
  64. package/src/core/modules/skills/skill-store.ts +88 -17
  65. package/src/core/modules/skills/skill-tools.ts +18 -10
  66. package/src/core/plugin.ts +40 -0
  67. package/src/core/registry/contributions.ts +8 -1
  68. package/src/core/runtime/chat-only.ts +32 -0
  69. package/src/core/runtime/conversation-sessions.ts +2 -1
  70. package/src/core/runtime/extensions/agent-prompt.ts +20 -5
  71. package/src/core/runtime/extensions/persona-prompt.ts +39 -0
  72. package/src/core/runtime/extensions/prompt-read.ts +22 -0
  73. package/src/core/runtime/runtime-plugin.ts +1 -0
  74. package/src/core/runtime/runtime-types.ts +3 -1
  75. package/src/core/runtime/session-factory.ts +70 -65
  76. package/src/core/runtime/session-helpers.ts +27 -0
  77. package/src/core/services.ts +97 -33
  78. package/src/index.ts +31 -0
  79. package/src/kit/index.ts +1 -0
  80. package/src/kit/oidc.ts +16 -0
  81. package/src/testing.ts +1 -0
@@ -0,0 +1,155 @@
1
+ import { AgentError } from "../domain/errors.ts";
2
+ import type { Agent, AgentGroup } from "./agent-rules.ts";
3
+ import {
4
+ checkAgentName,
5
+ checkDisplayName,
6
+ MAX_GROUP_MEMBERS,
7
+ MIN_GROUP_MEMBERS,
8
+ } from "./agent-rules.ts";
9
+ import {
10
+ type AgentRow,
11
+ type GroupRow,
12
+ toAgent,
13
+ toGroup,
14
+ } from "./agent-schema.ts";
15
+
16
+ /**
17
+ * The agents and groups a guild holds in memory, and the checks that read them: every inbound
18
+ * message asks, and only this process changes them. The store writes the rows and keeps these
19
+ * maps in step.
20
+ */
21
+ export abstract class AgentBook {
22
+ protected readonly agentMap = new Map<string, Agent>();
23
+ /** Agents whose character column holds a value, readable or not. */
24
+ protected readonly dressed = new Set<string>();
25
+ protected readonly groupMap = new Map<string, AgentGroup>();
26
+
27
+ agents(): Agent[] {
28
+ return [...this.agentMap.values()];
29
+ }
30
+
31
+ agent(name: string): Agent | undefined {
32
+ return this.agentMap.get(name);
33
+ }
34
+
35
+ /** The active agent with that name, or an error the caller can show. */
36
+ activeAgent(name: string): Agent {
37
+ const agent = this.agentMap.get(name);
38
+ if (!agent)
39
+ throw new AgentError(
40
+ `There is no agent "${name}". Active agents: ${this.activeNames()}.`,
41
+ );
42
+ if (agent.status !== "active")
43
+ throw new AgentError(
44
+ `Agent "${name}" is archived. Active agents: ${this.activeNames()}.`,
45
+ );
46
+ return agent;
47
+ }
48
+
49
+ agentByChannel(channelId: string): Agent | undefined {
50
+ for (const agent of this.agentMap.values())
51
+ if (agent.channelId === channelId && agent.status === "active")
52
+ return agent;
53
+ return undefined;
54
+ }
55
+
56
+ groups(): AgentGroup[] {
57
+ return [...this.groupMap.values()];
58
+ }
59
+
60
+ group(name: string): AgentGroup | undefined {
61
+ return this.groupMap.get(name);
62
+ }
63
+
64
+ groupByChannel(channelId: string): AgentGroup | undefined {
65
+ for (const group of this.groupMap.values())
66
+ if (group.channelId === channelId && group.status === "active")
67
+ return group;
68
+ return undefined;
69
+ }
70
+
71
+ protected activeNames(): string {
72
+ return (
73
+ this.agents()
74
+ .flatMap((a) => (a.status === "active" ? [a.name] : []))
75
+ .join(", ") || "none"
76
+ );
77
+ }
78
+
79
+ /** Names are shared between agents and groups, archived ones included. */
80
+ protected checkFree(name: string): void {
81
+ if (this.agentMap.has(name) || this.groupMap.has(name))
82
+ throw new AgentError(
83
+ `The name "${name}" is taken, archived agents and groups included.`,
84
+ );
85
+ }
86
+
87
+ /** A name that createAgent would accept, checked before its channel is made. */
88
+ checkNewName(name: string): void {
89
+ checkAgentName(name);
90
+ this.checkFree(name);
91
+ }
92
+
93
+ protected keepAgent(row: AgentRow | undefined): Agent {
94
+ if (!row) throw new AgentError("the agent was not found");
95
+ return this.keepAgentRow(row);
96
+ }
97
+
98
+ protected keepAgentRow(row: AgentRow): Agent {
99
+ const agent = toAgent(row);
100
+ this.agentMap.set(agent.name, agent);
101
+ if (row.character !== null && row.character !== undefined)
102
+ this.dressed.add(agent.name);
103
+ else this.dressed.delete(agent.name);
104
+ return agent;
105
+ }
106
+
107
+ /** Whether the stored column holds a character, even one this version cannot read. */
108
+ hasStoredCharacter(name: string): boolean {
109
+ return this.dressed.has(name);
110
+ }
111
+
112
+ protected checkMembers(members: readonly string[], host: string): void {
113
+ if (
114
+ new Set(members).size !== members.length ||
115
+ members.length < MIN_GROUP_MEMBERS ||
116
+ members.length > MAX_GROUP_MEMBERS
117
+ )
118
+ throw new AgentError(
119
+ `A group has ${MIN_GROUP_MEMBERS} to ${MAX_GROUP_MEMBERS} different members.`,
120
+ );
121
+ for (const member of members) this.activeAgent(member);
122
+ if (!members.includes(host))
123
+ throw new AgentError(`The host "${host}" must be one of the members.`);
124
+ }
125
+
126
+ /** Checks a new group before its channel is made; a `displayName` is checked too. */
127
+ checkNewGroup(
128
+ name: string,
129
+ members: readonly string[],
130
+ host: string,
131
+ displayName?: string,
132
+ ): void {
133
+ checkAgentName(name);
134
+ if (displayName !== undefined) checkDisplayName(displayName);
135
+ this.checkFree(name);
136
+ this.checkMembers(members, host);
137
+ }
138
+
139
+ /** The archived group, when it could be restored now; otherwise the error that says why not. */
140
+ checkRestorableGroup(name: string): AgentGroup {
141
+ const current = this.groupMap.get(name);
142
+ if (!current) throw new AgentError(`There is no group "${name}".`);
143
+ if (current.status === "active")
144
+ throw new AgentError(`"${name}" is not archived.`);
145
+ this.checkMembers(current.members, current.host);
146
+ return current;
147
+ }
148
+
149
+ protected keepGroup(row: GroupRow | undefined): AgentGroup {
150
+ if (!row) throw new AgentError("the group was not found");
151
+ const group = toGroup(row);
152
+ this.groupMap.set(group.name, group);
153
+ return group;
154
+ }
155
+ }
@@ -7,7 +7,11 @@ export type { ContextUse };
7
7
  /** The part of the runtime the agent team drives. */
8
8
  export type AgentTurnRunner = Pick<
9
9
  AgentRuntime,
10
- "runTurn" | "heldActions" | "startFresh" | "contextUsage"
10
+ | "runTurn"
11
+ | "heldActions"
12
+ | "startFresh"
13
+ | "deleteConversation"
14
+ | "contextUsage"
11
15
  >;
12
16
 
13
17
  /** One agent's message in a channel, posted under its name and avatar. */
@@ -1,5 +1,6 @@
1
1
  import { messages } from "../i18n/index.ts";
2
2
  import { type OwnerIdentity, ownerWords } from "../identity.ts";
3
+ import type { AgentTools } from "../plugin.ts";
3
4
  import { addressee, type Speaker } from "../speakers.ts";
4
5
  import type { Agent, AgentGroup, Backlog } from "./agent-store.ts";
5
6
 
@@ -25,6 +26,8 @@ export function agentSystemPrompt(input: {
25
26
  speaker?: Speaker;
26
27
  /** Whether the session offers generate_image; true only with an image provider. Default false. */
27
28
  images?: boolean;
29
+ /** "chat-only": the agent has no shell, file tools or workspace, and the prompt says so. Default "full". */
30
+ agentTools?: AgentTools;
28
31
  }): string {
29
32
  const { agent, coordinator, shared, workDir, owner, shellUser, group } =
30
33
  input;
@@ -37,7 +40,9 @@ export function agentSystemPrompt(input: {
37
40
  `You are "${agent.displayName}" (agent name \`${agent.name}\`), one of ${o.name}'s agents in ${o.his} Discord agent server. Each agent owns one channel and one conversation; you all share ${o.his} tools and ${o.his} memory.`,
38
41
  roleText(agent, coordinator, who),
39
42
  `The team: agent_list shows every agent and group; agent_get and agent_update read and improve any agent's prompt, display name, model, and thinking level, yours included; agent_create adds an agent with its own channel; agent_avatar changes an agent's look (class, colors, parts); schedule_list with agent reads another agent's schedules. A message marked as coming from another agent is that agent speaking, not ${w.name}; only ${w.name} approves held actions.`,
40
- `Your shell and file tools run on ${o.his} VPS as the user ${shellUser}, in the shared workspace ${workDir}.${input.scratchDir ? ` Write temporary files under the scratch dir ${input.scratchDir} ($TMPDIR), not elsewhere in /tmp; make them with \`mktemp -p "$TMPDIR"\`, since a bare \`mktemp\` ignores $TMPDIR on some systems.` : ""} Commands that are destructive or change the system, and writes outside the workspace${input.scratchDir ? " and the scratch dir" : ""}, are held for ${w.his} confirmation: tell ${w.him} exactly what will run and ask ${w.him} to confirm. To show a file you have, attach it to your reply with attach_file or give its path to discord_send_message; never read an image or base64 a file to send it.${input.images ? " To make a new picture, describe it to generate_image, which saves it and attaches it to your reply." : ""}`,
43
+ input.agentTools === "chat-only"
44
+ ? `You have no shell or file tools and no workspace on ${o.his} machine: you work through the conversation and the tools listed to you.${input.images ? " To make a new picture, describe it to generate_image, which saves it and attaches it to your reply." : ""}`
45
+ : `Your shell and file tools run on ${o.his} VPS as the user ${shellUser}, in the shared workspace ${workDir}.${input.scratchDir ? ` Write temporary files under the scratch dir ${input.scratchDir} ($TMPDIR), not elsewhere in /tmp; make them with \`mktemp -p "$TMPDIR"\`, since a bare \`mktemp\` ignores $TMPDIR on some systems.` : ""} Commands that are destructive or change the system, and writes outside the workspace${input.scratchDir ? " and the scratch dir" : ""}, are held for ${w.his} confirmation: tell ${w.him} exactly what will run and ask ${w.him} to confirm. To show a file you have, attach it to your reply with attach_file or give its path to discord_send_message; never read an image or base64 a file to send it.${input.images ? " To make a new picture, describe it to generate_image, which saves it and attaches it to your reply." : ""}`,
41
46
  ];
42
47
  if (group) {
43
48
  const others = group.members
@@ -2,6 +2,7 @@ import { readCharacter } from "../../characters/index.ts";
2
2
  import { scopeToGuild } from "../db/guild-scope.ts";
3
3
  import type { Migration } from "../db/migrations.ts";
4
4
  import type { ThinkingLevel } from "../models.ts";
5
+ import { promptHistoryMigration } from "../modules/prompts/prompt-history.ts";
5
6
  import type { Agent, AgentGroup, AgentStatus } from "./agent-rules.ts";
6
7
 
7
8
  export interface AgentRow {
@@ -133,5 +134,7 @@ export function agentMigrations(guildId: string): Migration[] {
133
134
  await sql`ALTER TABLE agents ADD COLUMN IF NOT EXISTS character jsonb`;
134
135
  },
135
136
  },
137
+ // The change history of the agents' prompts; the prompts plugin creates the same table for its documents.
138
+ promptHistoryMigration("agent-prompt-history"),
136
139
  ];
137
140
  }
@@ -0,0 +1,179 @@
1
+ import type { SQL } from "bun";
2
+ import type { CharacterSpec } from "../../characters/index.ts";
3
+ import type { AgentStatus } from "./agent-rules.ts";
4
+ import type { AgentRow, GroupRow } from "./agent-schema.ts";
5
+
6
+ /**
7
+ * The SQL the agent store runs for one guild. The store holds the rows in memory and decides
8
+ * what may change; each method here only writes the statement and returns the rows it touched.
9
+ */
10
+ export class AgentStatements {
11
+ readonly #sql: SQL;
12
+ readonly #guild: string;
13
+
14
+ constructor(sql: SQL, guildId: string) {
15
+ this.#sql = sql;
16
+ this.#guild = guildId;
17
+ }
18
+
19
+ /** Inserts a seed agent unless one of that name exists; no row comes back when one does. */
20
+ insertSeed(seed: {
21
+ name: string;
22
+ displayName: string;
23
+ prompt: string;
24
+ avatarPrompt: string;
25
+ channelId: string | null;
26
+ }): Promise<AgentRow[]> {
27
+ return this.#sql`
28
+ INSERT INTO agents (guild_id, name, display_name, prompt, avatar_prompt, channel_id)
29
+ VALUES (${this.#guild}, ${seed.name}, ${seed.displayName}, ${seed.prompt}, ${seed.avatarPrompt},
30
+ ${seed.channelId})
31
+ ON CONFLICT DO NOTHING
32
+ RETURNING *`;
33
+ }
34
+
35
+ insertAgent(agent: {
36
+ name: string;
37
+ displayName: string;
38
+ prompt: string;
39
+ avatarPrompt: string;
40
+ channelId: string | null;
41
+ avatarHash: string | null;
42
+ character: CharacterSpec | null;
43
+ }): Promise<AgentRow[]> {
44
+ return this.#sql`
45
+ INSERT INTO agents (guild_id, name, display_name, prompt, avatar_prompt, channel_id, avatar_hash, character)
46
+ VALUES (${this.#guild}, ${agent.name}, ${agent.displayName}, ${agent.prompt}, ${agent.avatarPrompt},
47
+ ${agent.channelId}, ${agent.avatarHash},
48
+ ${agent.character}::jsonb)
49
+ RETURNING *`;
50
+ }
51
+
52
+ /** Writes every column of an agent; `characterSet` false leaves the stored character alone. */
53
+ updateAgent(
54
+ name: string,
55
+ row: {
56
+ displayName: string;
57
+ prompt: string;
58
+ avatarPrompt: string;
59
+ avatarHash: string | null;
60
+ characterSet: boolean;
61
+ character: CharacterSpec | null;
62
+ channelId: string | null;
63
+ model: string | null;
64
+ thinking: string | null;
65
+ },
66
+ ): Promise<AgentRow[]> {
67
+ return this.#sql`
68
+ UPDATE agents SET
69
+ display_name = ${row.displayName},
70
+ prompt = ${row.prompt},
71
+ avatar_prompt = ${row.avatarPrompt},
72
+ avatar_hash = ${row.avatarHash},
73
+ character = CASE WHEN ${row.characterSet} THEN ${row.character}::jsonb ELSE character END,
74
+ channel_id = ${row.channelId},
75
+ model = ${row.model},
76
+ thinking = ${row.thinking},
77
+ updated_at = now()
78
+ WHERE guild_id = ${this.#guild} AND name = ${name}
79
+ RETURNING *`;
80
+ }
81
+
82
+ archiveAgent(name: string): Promise<AgentRow[]> {
83
+ return this.#sql`
84
+ UPDATE agents SET status = 'archived', updated_at = now()
85
+ WHERE guild_id = ${this.#guild} AND name = ${name} RETURNING *`;
86
+ }
87
+
88
+ restoreAgent(name: string, channelId: string | null): Promise<AgentRow[]> {
89
+ return this.#sql`
90
+ UPDATE agents SET status = 'active', channel_id = ${channelId},
91
+ updated_at = now()
92
+ WHERE guild_id = ${this.#guild} AND name = ${name}
93
+ RETURNING *`;
94
+ }
95
+
96
+ /** Deletes the agent's row, its group cursors and its prompt history together. */
97
+ async deleteAgent(name: string): Promise<void> {
98
+ await this.#sql.begin(async (tx) => {
99
+ await tx`DELETE FROM agent_group_cursors WHERE guild_id = ${this.#guild} AND agent_name = ${name}`;
100
+ await tx`DELETE FROM prompt_history WHERE guild_id = ${this.#guild} AND kind = 'agent' AND name = ${name}`;
101
+ await tx`DELETE FROM agents WHERE guild_id = ${this.#guild} AND name = ${name}`;
102
+ });
103
+ }
104
+
105
+ /** Gives an agent a character and picture only while it has no character. */
106
+ dressIfUndressed(
107
+ name: string,
108
+ character: CharacterSpec,
109
+ avatarHash: string,
110
+ ): Promise<AgentRow[]> {
111
+ return this.#sql`
112
+ UPDATE agents SET character = ${character}::jsonb, avatar_hash = ${avatarHash}, updated_at = now()
113
+ WHERE guild_id = ${this.#guild} AND name = ${name} AND character IS NULL
114
+ RETURNING *`;
115
+ }
116
+
117
+ /** Sets the picture only while the agent is active and still wears `character`. */
118
+ setAvatarIfLook(
119
+ name: string,
120
+ character: CharacterSpec,
121
+ avatarHash: string,
122
+ ): Promise<AgentRow[]> {
123
+ return this.#sql`
124
+ UPDATE agents SET avatar_hash = ${avatarHash}, updated_at = now()
125
+ WHERE guild_id = ${this.#guild} AND name = ${name} AND status = 'active'
126
+ AND character = ${character}::jsonb
127
+ RETURNING *`;
128
+ }
129
+
130
+ insertGroup(group: {
131
+ name: string;
132
+ displayName: string;
133
+ channelId: string | null;
134
+ members: readonly string[];
135
+ host: string;
136
+ }): Promise<GroupRow[]> {
137
+ return this.#sql`
138
+ INSERT INTO agent_groups (guild_id, name, display_name, channel_id, members, host)
139
+ VALUES (${this.#guild}, ${group.name}, ${group.displayName}, ${group.channelId},
140
+ ${group.members.join(",")}, ${group.host})
141
+ RETURNING *`;
142
+ }
143
+
144
+ updateGroup(
145
+ name: string,
146
+ row: {
147
+ displayName: string;
148
+ members: readonly string[];
149
+ host: string;
150
+ status: AgentStatus;
151
+ },
152
+ ): Promise<GroupRow[]> {
153
+ return this.#sql`
154
+ UPDATE agent_groups SET
155
+ display_name = ${row.displayName},
156
+ members = ${row.members.join(",")},
157
+ host = ${row.host},
158
+ status = ${row.status}
159
+ WHERE guild_id = ${this.#guild} AND name = ${name}
160
+ RETURNING *`;
161
+ }
162
+
163
+ restoreGroup(name: string, channelId: string | null): Promise<GroupRow[]> {
164
+ return this.#sql`
165
+ UPDATE agent_groups SET status = 'active', channel_id = ${channelId}
166
+ WHERE guild_id = ${this.#guild} AND name = ${name}
167
+ RETURNING *`;
168
+ }
169
+
170
+ /** Deletes the group's row, messages and cursors, in the Discord channel's log and each room's. */
171
+ async deleteGroup(name: string): Promise<void> {
172
+ const rooms = `${name}@%`;
173
+ await this.#sql.begin(async (tx) => {
174
+ await tx`DELETE FROM agent_group_cursors WHERE guild_id = ${this.#guild} AND (group_name = ${name} OR group_name LIKE ${rooms})`;
175
+ await tx`DELETE FROM agent_group_messages WHERE guild_id = ${this.#guild} AND (group_name = ${name} OR group_name LIKE ${rooms})`;
176
+ await tx`DELETE FROM agent_groups WHERE guild_id = ${this.#guild} AND name = ${name}`;
177
+ });
178
+ }
179
+ }