pi-discord-bot 0.1.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.
@@ -0,0 +1,157 @@
1
+ import { flattenTreeNodes, summarizeTreeNode } from "./agent-tree.js";
2
+ import { findModelByReference, formatModel, modelSortKey, resolveScopedModels } from "./agent-models.js";
3
+ export function createSessionOps(params) {
4
+ const { session, sessionManager, modelRegistry, settingsManager, runState } = params;
5
+ return {
6
+ async newSession() {
7
+ await session.newSession();
8
+ runState.pendingTools.clear();
9
+ },
10
+ renameSession(name) {
11
+ session.setSessionName(name);
12
+ },
13
+ getSessionInfo() {
14
+ const card = this.getSessionCardData();
15
+ return [card.title, ...card.fields.map((field) => `${field.name}: ${field.value}`)].join("\n");
16
+ },
17
+ getTreeSummary() {
18
+ const roots = sessionManager.getTree();
19
+ if (roots.length === 0)
20
+ return "Session tree is empty.";
21
+ return summarizeTreeNode({ entry: { id: "root", type: "root" }, children: roots }, 0, []).slice(1).join("\n");
22
+ },
23
+ getTreeBrowserData() {
24
+ const roots = sessionManager.getTree();
25
+ const currentId = sessionManager.getLeafId() ?? undefined;
26
+ const entries = flattenTreeNodes(roots, currentId ?? null);
27
+ return {
28
+ title: session.sessionName ? `Session tree · ${session.sessionName}` : "Session tree",
29
+ description: entries.length === 0
30
+ ? "This session tree is empty."
31
+ : [currentId ? `**Current leaf**: \`${currentId}\`` : "**Current leaf**: none", `**Entries**: ${entries.length}`, "Use the dropdown to navigate to a different entry."].join("\n"),
32
+ entries,
33
+ currentId,
34
+ };
35
+ },
36
+ async navigateTree(targetId) {
37
+ const result = await session.navigateTree(targetId, { summarize: false });
38
+ if (result.cancelled)
39
+ return `Navigation to ${targetId} was cancelled.`;
40
+ return `Moved session tree leaf to ${targetId}.`;
41
+ },
42
+ getSessionCardData() {
43
+ const stats = session.getSessionStats();
44
+ return {
45
+ title: session.sessionName ? `Session · ${session.sessionName}` : "Session",
46
+ fields: [
47
+ { name: "Model", value: formatModel(session.model), inline: false },
48
+ { name: "File", value: stats.sessionFile ?? "In-memory", inline: false },
49
+ { name: "ID", value: stats.sessionId, inline: false },
50
+ { name: "Messages", value: `user ${stats.userMessages} · assistant ${stats.assistantMessages} · tools ${stats.toolCalls}`, inline: false },
51
+ { name: "Tokens", value: `in ${stats.tokens.input} · out ${stats.tokens.output} · total ${stats.tokens.total}`, inline: false },
52
+ { name: "Cost", value: `$${stats.cost.toFixed(4)}`, inline: true },
53
+ { name: "Thinking", value: settingsManager.getDefaultThinkingLevel() ?? "off", inline: true },
54
+ { name: "Transport", value: settingsManager.getTransport(), inline: true },
55
+ { name: "Scoped models", value: session.scopedModels.length > 0 ? session.scopedModels.map((item) => `${item.model.provider}/${item.model.id}`).join("\n") : "All available models", inline: false },
56
+ ],
57
+ };
58
+ },
59
+ listModels(search) {
60
+ const query = search?.trim().toLowerCase();
61
+ const models = modelRegistry.getAvailable()
62
+ .filter((model) => !query || `${model.provider}/${model.id}`.toLowerCase().includes(query) || model.id.toLowerCase().includes(query))
63
+ .sort((a, b) => {
64
+ const [ap, ak] = modelSortKey(a);
65
+ const [bp, bk] = modelSortKey(b);
66
+ return ap - bp || ak.localeCompare(bk);
67
+ });
68
+ if (models.length === 0)
69
+ return query ? `No available models match \`${search}\`.` : "No available models. Authenticate with Pi first.";
70
+ return models.map((model) => `${session.model && session.model.provider === model.provider && session.model.id === model.id ? "*" : "-"} ${model.provider}/${model.id}`).join("\n");
71
+ },
72
+ currentModel() {
73
+ return formatModel(session.model);
74
+ },
75
+ async setModel(reference) {
76
+ modelRegistry.refresh();
77
+ const resolved = findModelByReference(modelRegistry, reference);
78
+ if (!resolved.model)
79
+ throw new Error(resolved.error ?? "Model not found.");
80
+ await session.setModel(resolved.model);
81
+ return formatModel(session.model);
82
+ },
83
+ getScopedModels() {
84
+ if (session.scopedModels.length === 0)
85
+ return "Scoped models: all available models";
86
+ return `Scoped models:\n${session.scopedModels.map((item) => `- ${item.model.provider}/${item.model.id}`).join("\n")}`;
87
+ },
88
+ setScopedModels(patterns) {
89
+ const list = patterns.split(",").map((item) => item.trim()).filter(Boolean);
90
+ if (list.length === 0)
91
+ return "Usage: /scoped-models <pattern[,pattern...]>";
92
+ modelRegistry.refresh();
93
+ const resolved = resolveScopedModels(modelRegistry, list);
94
+ if (resolved.length === 0)
95
+ return `No available models matched: ${list.join(", ")}`;
96
+ settingsManager.setEnabledModels(list);
97
+ session.setScopedModels(resolved);
98
+ return `Scoped models set:\n${resolved.map((item) => `- ${item.model.provider}/${item.model.id}`).join("\n")}`;
99
+ },
100
+ clearScopedModels() {
101
+ settingsManager.setEnabledModels(undefined);
102
+ session.setScopedModels([]);
103
+ return "Cleared scoped models. Pi will use all available models.";
104
+ },
105
+ async compact(customInstructions) {
106
+ await session.compact(customInstructions || undefined);
107
+ return "Compaction complete.";
108
+ },
109
+ async reload() {
110
+ modelRegistry.refresh();
111
+ await session.reload();
112
+ return "Reloaded settings, skills, prompts, extensions, and model registry.";
113
+ },
114
+ getSettingsSummary() {
115
+ return [
116
+ `Transport: ${settingsManager.getTransport()}`,
117
+ `Thinking: ${settingsManager.getDefaultThinkingLevel() ?? "off"}`,
118
+ `Steering mode: ${settingsManager.getSteeringMode()}`,
119
+ `Follow-up mode: ${settingsManager.getFollowUpMode()}`,
120
+ `Auto compact: ${settingsManager.getCompactionEnabled() ? "on" : "off"}`,
121
+ ].join("\n");
122
+ },
123
+ cycleThinkingSetting() {
124
+ const order = ["off", "minimal", "low", "medium", "high", "xhigh"];
125
+ const current = settingsManager.getDefaultThinkingLevel() ?? "off";
126
+ const next = order[(order.indexOf(current) + 1) % order.length];
127
+ settingsManager.setDefaultThinkingLevel(next);
128
+ session.setThinkingLevel(next);
129
+ return this.getSettingsSummary();
130
+ },
131
+ cycleTransportSetting() {
132
+ const order = ["auto", "sse", "websocket"];
133
+ const current = settingsManager.getTransport();
134
+ const next = order[(order.indexOf(current) + 1 + order.length) % order.length];
135
+ settingsManager.setTransport(next);
136
+ return this.getSettingsSummary();
137
+ },
138
+ toggleSteeringModeSetting() {
139
+ const next = settingsManager.getSteeringMode() === "all" ? "one-at-a-time" : "all";
140
+ settingsManager.setSteeringMode(next);
141
+ session.setSteeringMode(next);
142
+ return this.getSettingsSummary();
143
+ },
144
+ toggleFollowUpModeSetting() {
145
+ const next = settingsManager.getFollowUpMode() === "all" ? "one-at-a-time" : "all";
146
+ settingsManager.setFollowUpMode(next);
147
+ session.setFollowUpMode(next);
148
+ return this.getSettingsSummary();
149
+ },
150
+ toggleAutoCompactSetting() {
151
+ const next = !settingsManager.getCompactionEnabled();
152
+ settingsManager.setCompactionEnabled(next);
153
+ session.setAutoCompactionEnabled(next);
154
+ return this.getSettingsSummary();
155
+ },
156
+ };
157
+ }
@@ -0,0 +1,224 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export function createDiscordCustomTools(params) {
5
+ const { scratchDir, runState } = params;
6
+ const noContextError = () => ({ content: [{ type: "text", text: "No active Discord context." }], details: {}, isError: true });
7
+ return [{
8
+ name: "attach",
9
+ label: "Attach file",
10
+ description: "Upload a local file from the workspace back to Discord.",
11
+ parameters: Type.Object({
12
+ filePath: Type.String({ description: "Absolute or scratch-relative path to the file to upload" }),
13
+ title: Type.Optional(Type.String({ description: "Optional display name for the uploaded file" })),
14
+ label: Type.Optional(Type.String({ description: "Short user-visible label for the action" })),
15
+ }),
16
+ execute: async (_toolCallId, rawParams) => {
17
+ const params = rawParams;
18
+ const ctx = runState.ctx;
19
+ if (!ctx)
20
+ return { content: [{ type: "text", text: "No active Discord context; cannot upload file." }], details: {} };
21
+ const filePath = params.filePath.startsWith("/") ? params.filePath : join(scratchDir, params.filePath);
22
+ if (!existsSync(filePath)) {
23
+ return { content: [{ type: "text", text: `File not found: ${filePath}` }], details: {}, isError: true };
24
+ }
25
+ await ctx.uploadFile(filePath, params.title);
26
+ return { content: [{ type: "text", text: `Uploaded ${params.title ?? filePath} to Discord.` }], details: { filePath, title: params.title ?? null } };
27
+ },
28
+ }, {
29
+ name: "discord_list_channels",
30
+ label: "List Discord guild channels",
31
+ description: "List guild channels visible to the bot in the current server.",
32
+ parameters: Type.Object({ label: Type.Optional(Type.String()) }),
33
+ execute: async () => {
34
+ const ctx = runState.ctx;
35
+ if (!ctx)
36
+ return noContextError();
37
+ const channels = await ctx.listGuildChannels();
38
+ return { content: [{ type: "text", text: `Found ${channels.length} channels.` }], details: { channels } };
39
+ },
40
+ }, {
41
+ name: "discord_resolve_channel",
42
+ label: "Resolve Discord channel",
43
+ description: "Resolve a guild channel or category by name or ID.",
44
+ parameters: Type.Object({ query: Type.String({ description: "Channel or category name or ID" }), label: Type.Optional(Type.String()) }),
45
+ execute: async (_id, rawParams) => {
46
+ const params = rawParams;
47
+ const ctx = runState.ctx;
48
+ if (!ctx)
49
+ return noContextError();
50
+ const resolved = await ctx.resolveGuildChannel(params.query);
51
+ return { content: [{ type: "text", text: `Resolved ${params.query} to ${resolved.name}.` }], details: resolved };
52
+ },
53
+ }, {
54
+ name: "discord_resolve_member",
55
+ label: "Resolve Discord member",
56
+ description: "Resolve a guild member by username, display name, or ID.",
57
+ parameters: Type.Object({ query: Type.String({ description: "Member username, display name, or ID" }), label: Type.Optional(Type.String()) }),
58
+ execute: async (_id, rawParams) => {
59
+ const params = rawParams;
60
+ const ctx = runState.ctx;
61
+ if (!ctx)
62
+ return noContextError();
63
+ const resolved = await ctx.resolveGuildMember(params.query);
64
+ return { content: [{ type: "text", text: `Resolved ${params.query} to ${resolved.displayName}.` }], details: resolved };
65
+ },
66
+ }, {
67
+ name: "discord_resolve_role",
68
+ label: "Resolve Discord role",
69
+ description: "Resolve a guild role by name or ID.",
70
+ parameters: Type.Object({ query: Type.String({ description: "Role name or ID" }), label: Type.Optional(Type.String()) }),
71
+ execute: async (_id, rawParams) => {
72
+ const params = rawParams;
73
+ const ctx = runState.ctx;
74
+ if (!ctx)
75
+ return noContextError();
76
+ const resolved = await ctx.resolveGuildRole(params.query);
77
+ return { content: [{ type: "text", text: `Resolved ${params.query} to role ${resolved.name}.` }], details: resolved };
78
+ },
79
+ }, {
80
+ name: "discord_create_channel",
81
+ label: "Create Discord text channel",
82
+ description: "Create a guild text channel after approval.",
83
+ parameters: Type.Object({ name: Type.String(), parentId: Type.Optional(Type.String()), topic: Type.Optional(Type.String()), label: Type.Optional(Type.String()) }),
84
+ execute: async (_id, rawParams) => {
85
+ const params = rawParams;
86
+ const ctx = runState.ctx;
87
+ if (!ctx)
88
+ return noContextError();
89
+ const approved = await ctx.confirmAction({ title: "Approve channel creation", description: `Create a new text channel named \`${params.name}\`?`, bullets: [params.parentId ? `Parent category: ${params.parentId}` : "No parent category", params.topic ? `Topic: ${params.topic}` : "No topic"], caution: "This changes the Discord server structure.", approveLabel: "Create channel" });
90
+ if (!approved)
91
+ return { content: [{ type: "text", text: "Channel creation cancelled." }], details: { approved: false } };
92
+ const created = await ctx.createGuildTextChannel(params);
93
+ return { content: [{ type: "text", text: `Created channel ${created.name}.` }], details: created };
94
+ },
95
+ }, {
96
+ name: "discord_create_private_channel",
97
+ label: "Create private Discord text channel",
98
+ description: "Create a private guild text channel after approval.",
99
+ parameters: Type.Object({ name: Type.String(), parentId: Type.Optional(Type.String()), topic: Type.Optional(Type.String()), memberIds: Type.Optional(Type.Array(Type.String())), roleIds: Type.Optional(Type.Array(Type.String())), label: Type.Optional(Type.String()) }),
100
+ execute: async (_id, rawParams) => {
101
+ const params = rawParams;
102
+ const ctx = runState.ctx;
103
+ if (!ctx)
104
+ return noContextError();
105
+ const approved = await ctx.confirmAction({ title: "Approve private channel creation", description: `Create a new private text channel named \`${params.name}\`?`, bullets: [params.parentId ? `Parent category: ${params.parentId}` : "No parent category", params.topic ? `Topic: ${params.topic}` : "No topic", params.memberIds?.length ? `Extra allowed members: ${params.memberIds.join(", ")}` : "No extra allowed members", params.roleIds?.length ? `Extra allowed roles: ${params.roleIds.join(", ")}` : "No extra allowed roles", "Visible only to allowed users/roles and the bot unless you change permissions later"], caution: "This changes the Discord server structure and permissions.", approveLabel: "Create private channel" });
106
+ if (!approved)
107
+ return { content: [{ type: "text", text: "Private channel creation cancelled." }], details: { approved: false } };
108
+ const created = await ctx.createGuildTextChannel({ ...params, private: true });
109
+ return { content: [{ type: "text", text: `Created private channel ${created.name}.` }], details: created };
110
+ },
111
+ }, {
112
+ name: "discord_create_category",
113
+ label: "Create Discord category",
114
+ description: "Create a guild category after approval.",
115
+ parameters: Type.Object({ name: Type.String(), label: Type.Optional(Type.String()) }),
116
+ execute: async (_id, rawParams) => {
117
+ const params = rawParams;
118
+ const ctx = runState.ctx;
119
+ if (!ctx)
120
+ return noContextError();
121
+ const approved = await ctx.confirmAction({ title: "Approve category creation", description: `Create a category named \`${params.name}\`?`, caution: "This changes the Discord server structure.", approveLabel: "Create category" });
122
+ if (!approved)
123
+ return { content: [{ type: "text", text: "Category creation cancelled." }], details: { approved: false } };
124
+ const created = await ctx.createGuildCategory(params);
125
+ return { content: [{ type: "text", text: `Created category ${created.name}.` }], details: created };
126
+ },
127
+ }, {
128
+ name: "discord_rename_channel",
129
+ label: "Rename Discord channel",
130
+ description: "Rename a guild channel after approval.",
131
+ parameters: Type.Object({ channelId: Type.String(), name: Type.String(), label: Type.Optional(Type.String()) }),
132
+ execute: async (_id, rawParams) => {
133
+ const params = rawParams;
134
+ const ctx = runState.ctx;
135
+ if (!ctx)
136
+ return noContextError();
137
+ const approved = await ctx.confirmAction({ title: "Approve channel rename", description: `Rename channel \`${params.channelId}\` to \`${params.name}\`?`, caution: "This changes an existing Discord channel.", approveLabel: "Rename" });
138
+ if (!approved)
139
+ return { content: [{ type: "text", text: "Channel rename cancelled." }], details: { approved: false } };
140
+ const updated = await ctx.renameGuildChannel(params);
141
+ return { content: [{ type: "text", text: `Renamed channel to ${updated.name}.` }], details: updated };
142
+ },
143
+ }, {
144
+ name: "discord_move_channel",
145
+ label: "Move Discord channel",
146
+ description: "Move a guild channel into or out of a category after approval.",
147
+ parameters: Type.Object({ channelId: Type.String(), parentId: Type.Optional(Type.String()), label: Type.Optional(Type.String()) }),
148
+ execute: async (_id, rawParams) => {
149
+ const params = rawParams;
150
+ const ctx = runState.ctx;
151
+ if (!ctx)
152
+ return noContextError();
153
+ const approved = await ctx.confirmAction({ title: "Approve channel move", description: params.parentId ? `Move channel \`${params.channelId}\` into category \`${params.parentId}\`?` : `Remove channel \`${params.channelId}\` from its category?`, caution: "This changes the Discord server layout.", approveLabel: "Move channel" });
154
+ if (!approved)
155
+ return { content: [{ type: "text", text: "Channel move cancelled." }], details: { approved: false } };
156
+ const updated = await ctx.moveGuildChannel({ channelId: params.channelId, parentId: params.parentId ?? null });
157
+ return { content: [{ type: "text", text: `Moved channel ${updated.name}.` }], details: updated };
158
+ },
159
+ }, {
160
+ name: "discord_delete_channel",
161
+ label: "Delete Discord channel",
162
+ description: "Delete a guild channel after approval.",
163
+ parameters: Type.Object({ channelId: Type.String(), label: Type.Optional(Type.String()) }),
164
+ execute: async (_id, rawParams) => {
165
+ const params = rawParams;
166
+ const ctx = runState.ctx;
167
+ if (!ctx)
168
+ return noContextError();
169
+ const approved = await ctx.confirmAction({ title: "Approve channel deletion", description: `Delete channel \`${params.channelId}\`?`, bullets: ["This cannot be undone through the bot."], caution: "This permanently deletes a Discord channel.", approveLabel: "Delete channel" });
170
+ if (!approved)
171
+ return { content: [{ type: "text", text: "Channel deletion cancelled." }], details: { approved: false } };
172
+ const deleted = await ctx.deleteGuildChannel(params);
173
+ return { content: [{ type: "text", text: `Deleted channel ${deleted.id}.` }], details: deleted };
174
+ },
175
+ }, {
176
+ name: "discord_create_thread",
177
+ label: "Create Discord thread",
178
+ description: "Create a thread in the current guild channel after approval.",
179
+ parameters: Type.Object({ name: Type.String(), autoArchiveDuration: Type.Optional(Type.Union([Type.Literal(60), Type.Literal(1440), Type.Literal(4320), Type.Literal(10080)])), label: Type.Optional(Type.String()) }),
180
+ execute: async (_id, rawParams) => {
181
+ const params = rawParams;
182
+ const ctx = runState.ctx;
183
+ if (!ctx)
184
+ return noContextError();
185
+ const approved = await ctx.confirmAction({ title: "Approve thread creation", description: `Create a thread named \`${params.name}\` in the current channel?`, bullets: [params.autoArchiveDuration ? `Auto archive: ${params.autoArchiveDuration} minutes` : "Auto archive: 1440 minutes"], caution: "This creates a new thread in the current guild channel.", approveLabel: "Create thread" });
186
+ if (!approved)
187
+ return { content: [{ type: "text", text: "Thread creation cancelled." }], details: { approved: false } };
188
+ const created = await ctx.createThreadFromCurrentChannel(params);
189
+ return { content: [{ type: "text", text: `Created thread ${created.name}.` }], details: created };
190
+ },
191
+ }, {
192
+ name: "discord_rename_thread",
193
+ label: "Rename Discord thread",
194
+ description: "Rename a thread after approval.",
195
+ parameters: Type.Object({ threadId: Type.String(), name: Type.String(), label: Type.Optional(Type.String()) }),
196
+ execute: async (_id, rawParams) => {
197
+ const params = rawParams;
198
+ const ctx = runState.ctx;
199
+ if (!ctx)
200
+ return noContextError();
201
+ const approved = await ctx.confirmAction({ title: "Approve thread rename", description: `Rename thread \`${params.threadId}\` to \`${params.name}\`?`, caution: "This changes an existing Discord thread.", approveLabel: "Rename thread" });
202
+ if (!approved)
203
+ return { content: [{ type: "text", text: "Thread rename cancelled." }], details: { approved: false } };
204
+ const updated = await ctx.renameThread(params);
205
+ return { content: [{ type: "text", text: `Renamed thread to ${updated.name}.` }], details: updated };
206
+ },
207
+ }, {
208
+ name: "discord_archive_thread",
209
+ label: "Archive Discord thread",
210
+ description: "Archive or unarchive a thread after approval.",
211
+ parameters: Type.Object({ threadId: Type.String(), archived: Type.Optional(Type.Boolean()), locked: Type.Optional(Type.Boolean()), label: Type.Optional(Type.String()) }),
212
+ execute: async (_id, rawParams) => {
213
+ const params = rawParams;
214
+ const ctx = runState.ctx;
215
+ if (!ctx)
216
+ return noContextError();
217
+ const approved = await ctx.confirmAction({ title: params.archived === false ? "Approve thread unarchive" : "Approve thread archive", description: `${params.archived === false ? "Unarchive" : "Archive"} thread \`${params.threadId}\`?`, bullets: [typeof params.locked === "boolean" ? `Set locked=${params.locked}` : "Leave lock state unchanged"], caution: "This changes an existing Discord thread state.", approveLabel: params.archived === false ? "Unarchive" : "Archive" });
218
+ if (!approved)
219
+ return { content: [{ type: "text", text: "Thread archive action cancelled." }], details: { approved: false } };
220
+ const updated = await ctx.archiveThread(params);
221
+ return { content: [{ type: "text", text: `${updated.archived ? "Archived" : "Unarchived"} thread ${updated.id}.` }], details: updated };
222
+ },
223
+ }];
224
+ }
@@ -0,0 +1,52 @@
1
+ function truncate(text, max = 1800) {
2
+ return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
3
+ }
4
+ export function getTreeEntryPreview(entry) {
5
+ if (entry.type === "message") {
6
+ const role = entry.message?.role ?? "unknown";
7
+ const content = typeof entry.message?.content === "string"
8
+ ? entry.message.content
9
+ : Array.isArray(entry.message?.content)
10
+ ? entry.message.content.filter((part) => part?.type === "text").map((part) => part.text).join("\n")
11
+ : "";
12
+ return `${role} — ${truncate(String(content).replace(/\s+/g, " "), 80)}`;
13
+ }
14
+ if (entry.type === "branch_summary")
15
+ return `branch summary — ${truncate(entry.summary ?? "", 80)}`;
16
+ if (entry.type === "compaction")
17
+ return `compaction — ${truncate(entry.summary ?? "", 80)}`;
18
+ if (entry.type === "model_change")
19
+ return `model change — ${entry.provider}/${entry.modelId}`;
20
+ if (entry.type === "thinking_level_change")
21
+ return `thinking — ${entry.thinkingLevel}`;
22
+ if (entry.type === "label")
23
+ return `label — ${entry.label ?? "(cleared)"}`;
24
+ if (entry.type === "session_info")
25
+ return `session info — ${entry.name ?? "(unnamed)"}`;
26
+ return `${entry.type} ${entry.id}`;
27
+ }
28
+ export function summarizeTreeNode(node, depth = 0, lines = []) {
29
+ const indent = " ".repeat(depth);
30
+ const entry = node.entry;
31
+ let title = `${getTreeEntryPreview(entry)} (${entry.id})`;
32
+ if (node.label)
33
+ title += ` [${node.label}]`;
34
+ lines.push(`${indent}- ${title}`);
35
+ for (const child of node.children ?? [])
36
+ summarizeTreeNode(child, depth + 1, lines);
37
+ return lines;
38
+ }
39
+ export function flattenTreeNodes(nodes, currentId, depth = 0, out = []) {
40
+ for (const node of nodes) {
41
+ out.push({
42
+ id: node.entry.id,
43
+ depth,
44
+ type: node.entry.type,
45
+ preview: getTreeEntryPreview(node.entry),
46
+ label: node.label,
47
+ isCurrent: node.entry.id === currentId,
48
+ });
49
+ flattenTreeNodes(node.children ?? [], currentId, depth + 1, out);
50
+ }
51
+ return out;
52
+ }
@@ -0,0 +1,31 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { flattenTreeNodes, getTreeEntryPreview, summarizeTreeNode } from "./agent-tree.js";
4
+ test("getTreeEntryPreview formats message entries", () => {
5
+ const preview = getTreeEntryPreview({
6
+ type: "message",
7
+ id: "m1",
8
+ message: { role: "user", content: [{ type: "text", text: "hello\nworld" }] },
9
+ });
10
+ assert.match(preview, /^user — hello world/);
11
+ });
12
+ test("flattenTreeNodes preserves depth and current marker", () => {
13
+ const nodes = [{
14
+ entry: { id: "root", type: "message", message: { role: "user", content: "root" } },
15
+ children: [{ entry: { id: "child", type: "model_change", provider: "openai", modelId: "gpt-4.1" }, children: [] }],
16
+ }];
17
+ const flat = flattenTreeNodes(nodes, "child");
18
+ assert.equal(flat.length, 2);
19
+ assert.equal(flat[0].depth, 0);
20
+ assert.equal(flat[1].depth, 1);
21
+ assert.equal(flat[1].isCurrent, true);
22
+ });
23
+ test("summarizeTreeNode renders nested bullet tree", () => {
24
+ const lines = summarizeTreeNode({
25
+ entry: { id: "r", type: "message", message: { role: "user", content: "root" } },
26
+ children: [{ entry: { id: "c", type: "session_info", name: "test" }, children: [] }],
27
+ });
28
+ assert.equal(lines.length, 2);
29
+ assert.match(lines[0], /^- user — root \(r\)$/);
30
+ assert.match(lines[1], /^ - session info — test \(c\)$/);
31
+ });
@@ -0,0 +1 @@
1
+ export {};
package/dist/agent.js ADDED
@@ -0,0 +1,93 @@
1
+ import { Agent } from "@mariozechner/pi-agent-core";
2
+ import { AgentSession, AuthStorage, convertToLlm, createCodingTools, createExtensionRuntime, ModelRegistry, SessionManager, } from "@mariozechner/pi-coding-agent";
3
+ import { join } from "node:path";
4
+ import { createDiscordSettingsManager } from "./context.js";
5
+ import { resolveInitialModel, formatModel } from "./agent-models.js";
6
+ import { buildAppendSystemPrompt, getMemory, loadDiscordSkills } from "./agent-prompt.js";
7
+ import { createDiscordCustomTools } from "./agent-tools.js";
8
+ import { createSessionOps } from "./agent-session-ops.js";
9
+ import { runAgentTurn, wireSessionUpdates } from "./agent-runner.js";
10
+ const runners = new Map();
11
+ export function getOrCreateRunner(workspaceDir, conversationKey) {
12
+ const existing = runners.get(conversationKey);
13
+ if (existing)
14
+ return existing;
15
+ const created = createRunner(workspaceDir, conversationKey);
16
+ runners.set(conversationKey, created);
17
+ return created;
18
+ }
19
+ function createRunner(workspaceDir, conversationKey) {
20
+ const conversationDir = join(workspaceDir, conversationKey);
21
+ const scratchDir = join(conversationDir, "scratch");
22
+ const contextFile = join(conversationDir, "context.jsonl");
23
+ const tools = createCodingTools(scratchDir);
24
+ const baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
25
+ const authStorage = AuthStorage.create();
26
+ const modelRegistry = ModelRegistry.create(authStorage);
27
+ const sessionManager = SessionManager.open(contextFile, conversationDir);
28
+ const settingsManager = createDiscordSettingsManager(workspaceDir);
29
+ const skills = loadDiscordSkills(conversationDir);
30
+ let appendSystemPrompt = buildAppendSystemPrompt(workspaceDir, conversationKey, getMemory(conversationDir));
31
+ const resourceLoader = {
32
+ getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
33
+ getSkills: () => ({ skills, diagnostics: [] }),
34
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
35
+ getThemes: () => ({ themes: [], diagnostics: [] }),
36
+ getAgentsFiles: () => ({ agentsFiles: [] }),
37
+ getSystemPrompt: () => undefined,
38
+ getAppendSystemPrompt: () => [appendSystemPrompt],
39
+ extendResources: () => { },
40
+ reload: async () => { },
41
+ };
42
+ const runState = {
43
+ ctx: null,
44
+ queue: Promise.resolve(),
45
+ stopReason: "stop",
46
+ errorMessage: undefined,
47
+ pendingTools: new Map(),
48
+ };
49
+ const customTools = createDiscordCustomTools({ scratchDir, runState });
50
+ const initialModel = resolveInitialModel(modelRegistry, settingsManager);
51
+ let agent;
52
+ agent = new Agent({
53
+ initialState: {
54
+ systemPrompt: "",
55
+ model: initialModel,
56
+ thinkingLevel: settingsManager.getDefaultThinkingLevel() ?? "off",
57
+ tools,
58
+ },
59
+ convertToLlm,
60
+ getApiKey: async () => {
61
+ const currentModel = agent.state.model;
62
+ const key = currentModel ? await authStorage.getApiKey(currentModel.provider) : undefined;
63
+ if (!key)
64
+ throw new Error(`No auth configured for ${formatModel(currentModel)}. Use Pi auth/login first.`);
65
+ return key;
66
+ },
67
+ });
68
+ const loaded = sessionManager.buildSessionContext();
69
+ if (loaded.messages.length > 0)
70
+ agent.state.messages = loaded.messages;
71
+ const session = new AgentSession({
72
+ agent,
73
+ sessionManager,
74
+ settingsManager,
75
+ cwd: scratchDir,
76
+ modelRegistry,
77
+ resourceLoader,
78
+ baseToolsOverride,
79
+ customTools,
80
+ });
81
+ wireSessionUpdates(session, runState);
82
+ const sessionOps = createSessionOps({ session, sessionManager, modelRegistry, settingsManager, runState });
83
+ return {
84
+ async run(ctx) {
85
+ appendSystemPrompt = buildAppendSystemPrompt(workspaceDir, conversationKey, getMemory(conversationDir));
86
+ return runAgentTurn({ ctx, conversationDir, scratchDir, sessionManager, agent, session, runState });
87
+ },
88
+ abort() {
89
+ void session.abort();
90
+ },
91
+ ...sessionOps,
92
+ };
93
+ }
@@ -0,0 +1,58 @@
1
+ import { SettingsManager } from "@mariozechner/pi-coding-agent";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export function syncLogToSessionManager(sessionManager, channelDir, excludeMessageId) {
5
+ const logPath = join(channelDir, "log.jsonl");
6
+ if (!existsSync(logPath))
7
+ return 0;
8
+ const existing = new Set();
9
+ for (const entry of sessionManager.getEntries()) {
10
+ if (entry.type !== "message")
11
+ continue;
12
+ const message = entry.message;
13
+ if (message.role !== "user")
14
+ continue;
15
+ const content = message.content;
16
+ if (typeof content === "string")
17
+ existing.add(content);
18
+ if (Array.isArray(content)) {
19
+ for (const part of content) {
20
+ if (typeof part === "object" && part && "type" in part && part.type === "text" && "text" in part) {
21
+ existing.add(String(part.text));
22
+ }
23
+ }
24
+ }
25
+ }
26
+ const lines = readFileSync(logPath, "utf-8").split("\n").filter(Boolean);
27
+ const toAppend = [];
28
+ for (const line of lines) {
29
+ try {
30
+ const entry = JSON.parse(line);
31
+ if (entry.isBot)
32
+ continue;
33
+ if (excludeMessageId && entry.messageId === excludeMessageId)
34
+ continue;
35
+ const text = `[${entry.authorName ?? entry.authorId ?? "unknown"}]: ${entry.text ?? ""}`;
36
+ if (existing.has(text))
37
+ continue;
38
+ existing.add(text);
39
+ toAppend.push({
40
+ timestamp: entry.date ? new Date(entry.date).getTime() : Date.now(),
41
+ message: {
42
+ role: "user",
43
+ content: [{ type: "text", text }],
44
+ timestamp: entry.date ? new Date(entry.date).getTime() : Date.now(),
45
+ },
46
+ });
47
+ }
48
+ catch { }
49
+ }
50
+ toAppend.sort((a, b) => a.timestamp - b.timestamp);
51
+ for (const item of toAppend) {
52
+ sessionManager.appendMessage(item.message);
53
+ }
54
+ return toAppend.length;
55
+ }
56
+ export function createDiscordSettingsManager(_workspaceDir) {
57
+ return SettingsManager.create();
58
+ }
@@ -0,0 +1,35 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { syncLogToSessionManager } from "./context.js";
7
+ test("syncLogToSessionManager appends only non-bot, non-duplicate messages", () => {
8
+ const dir = mkdtempSync(join(tmpdir(), "pi-discord-bot-test-"));
9
+ writeFileSync(join(dir, "log.jsonl"), [
10
+ JSON.stringify({ messageId: "1", authorName: "alice", text: "hello", date: "2026-01-01T00:00:00.000Z", isBot: false }),
11
+ JSON.stringify({ messageId: "2", authorName: "bot", text: "reply", date: "2026-01-01T00:00:01.000Z", isBot: true }),
12
+ JSON.stringify({ messageId: "3", authorName: "alice", text: "hello", date: "2026-01-01T00:00:02.000Z", isBot: false }),
13
+ ].join("\n"));
14
+ const appended = [];
15
+ const sessionManager = {
16
+ getEntries: () => [],
17
+ appendMessage: (message) => appended.push(message),
18
+ };
19
+ const count = syncLogToSessionManager(sessionManager, dir);
20
+ assert.equal(count, 1);
21
+ assert.equal(appended.length, 1);
22
+ assert.equal(appended[0].content[0].text, "[alice]: hello");
23
+ });
24
+ test("syncLogToSessionManager respects excludeMessageId", () => {
25
+ const dir = mkdtempSync(join(tmpdir(), "pi-discord-bot-test-"));
26
+ writeFileSync(join(dir, "log.jsonl"), JSON.stringify({ messageId: "1", authorName: "alice", text: "hello", date: "2026-01-01T00:00:00.000Z", isBot: false }));
27
+ const appended = [];
28
+ const sessionManager = {
29
+ getEntries: () => [],
30
+ appendMessage: (message) => appended.push(message),
31
+ };
32
+ const count = syncLogToSessionManager(sessionManager, dir, "1");
33
+ assert.equal(count, 0);
34
+ assert.equal(appended.length, 0);
35
+ });