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.
package/dist/main.js ADDED
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env node
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { getOrCreateRunner } from "./agent.js";
5
+ import { DiscordBot } from "./discord.js";
6
+ import * as log from "./log.js";
7
+ import { ChannelStore } from "./store.js";
8
+ const token = process.env.DISCORD_TOKEN;
9
+ const configuredWorkingDir = process.argv[2] ?? process.env.PI_DISCORD_BOT_WORKDIR;
10
+ const defaultWorkingDir = join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "pi-discord-bot", "agent");
11
+ const workingDir = resolve(configuredWorkingDir ?? defaultWorkingDir);
12
+ if (!token) {
13
+ console.error("Missing DISCORD_TOKEN");
14
+ process.exit(1);
15
+ }
16
+ const rootDir = workingDir;
17
+ const states = new Map();
18
+ function getConversationKey(event) {
19
+ if (event.threadId && event.guildId)
20
+ return `guild:${event.guildId}:thread:${event.threadId}`;
21
+ if (event.guildId)
22
+ return `guild:${event.guildId}:channel:${event.channelId}`;
23
+ return `dm:${event.userId}`;
24
+ }
25
+ function getState(event) {
26
+ const key = getConversationKey(event);
27
+ let state = states.get(key);
28
+ if (!state) {
29
+ state = {
30
+ running: false,
31
+ runner: getOrCreateRunner(rootDir, key),
32
+ stopRequested: false,
33
+ };
34
+ states.set(key, state);
35
+ }
36
+ return state;
37
+ }
38
+ let bot;
39
+ function helpText() {
40
+ return [
41
+ "Commands:",
42
+ "- /new",
43
+ "- /name <name>",
44
+ "- /session",
45
+ "- /tree",
46
+ "- /tree <entryId>",
47
+ "- /model",
48
+ "- /model <provider/model-or-search>",
49
+ "- /scoped-models",
50
+ "- /scoped-models <pattern[,pattern...]>",
51
+ "- /settings",
52
+ "- /compact [instructions]",
53
+ "- /reload",
54
+ "- /login [provider]",
55
+ "- /logout [provider]",
56
+ "- /stop",
57
+ "Unsupported in Discord: /resume, /fork, /copy, /export, /share, /hotkeys, /changelog, /quit, /exit",
58
+ ].join("\n");
59
+ }
60
+ function isUiCommand(command) {
61
+ return new Set([
62
+ "/help",
63
+ "/new",
64
+ "/name",
65
+ "/session",
66
+ "/model",
67
+ "/scoped-models",
68
+ "/settings",
69
+ "/compact",
70
+ "/reload",
71
+ "/login",
72
+ "/logout",
73
+ "/resume",
74
+ "/tree",
75
+ "/fork",
76
+ "/copy",
77
+ "/export",
78
+ "/share",
79
+ "/hotkeys",
80
+ "/changelog",
81
+ "/quit",
82
+ "/exit",
83
+ ]).has(command);
84
+ }
85
+ async function handleCommand(event, transport) {
86
+ const text = event.text.trim();
87
+ if (!text.startsWith("/"))
88
+ return false;
89
+ const ctx = await transport.createContext(event);
90
+ const state = getState(event);
91
+ const [command, ...rest] = text.split(/\s+/);
92
+ const args = rest.join(" ").trim();
93
+ if (event.source === "slash" && isUiCommand(command)) {
94
+ await ctx.setWorking(false);
95
+ }
96
+ if (state.running && command !== "/stop") {
97
+ await ctx.replaceMessage("Already working. Use /stop first, then retry your command.");
98
+ return true;
99
+ }
100
+ switch (command) {
101
+ case "/help":
102
+ await ctx.replaceMessage(helpText());
103
+ return true;
104
+ case "/new":
105
+ await state.runner.newSession();
106
+ await ctx.replaceMessage(`Started a new session. Current model: ${state.runner.currentModel()}`);
107
+ return true;
108
+ case "/name":
109
+ if (!args) {
110
+ await ctx.replaceMessage("Usage: /name <name>");
111
+ return true;
112
+ }
113
+ state.runner.renameSession(args);
114
+ await ctx.replaceMessage(`Session name set to: ${args}`);
115
+ return true;
116
+ case "/session":
117
+ await transport.showSessionCard(event, state.runner.getSessionCardData());
118
+ return true;
119
+ case "/tree":
120
+ if (!args) {
121
+ const browser = state.runner.getTreeBrowserData();
122
+ if (browser.entries.length === 0) {
123
+ await ctx.replaceMessage("Session tree is empty.");
124
+ return true;
125
+ }
126
+ let page = 0;
127
+ while (true) {
128
+ const selected = await transport.promptTreeSelection(event, { ...browser, page });
129
+ if (!selected || selected === "close") {
130
+ await ctx.replaceMessage(state.runner.getTreeSummary());
131
+ return true;
132
+ }
133
+ if (selected === "prev") {
134
+ page = Math.max(0, page - 1);
135
+ continue;
136
+ }
137
+ if (selected === "next") {
138
+ page += 1;
139
+ continue;
140
+ }
141
+ await ctx.replaceMessage(await state.runner.navigateTree(selected));
142
+ return true;
143
+ }
144
+ }
145
+ await ctx.replaceMessage(await state.runner.navigateTree(args));
146
+ return true;
147
+ case "/model":
148
+ if (!args) {
149
+ let current = state.runner.currentModel();
150
+ const models = state.runner.listModels().split("\n").map((line) => line.replace(/^[*-]\s+/, "")).filter(Boolean);
151
+ let page = 0;
152
+ while (true) {
153
+ const selected = await transport.promptModelSelection(event, {
154
+ currentModel: current,
155
+ models,
156
+ title: "Select model",
157
+ page,
158
+ });
159
+ if (!selected) {
160
+ await ctx.replaceMessage(`Current model: ${current}`);
161
+ return true;
162
+ }
163
+ if (selected === "prev") {
164
+ page = Math.max(0, page - 1);
165
+ continue;
166
+ }
167
+ if (selected === "next") {
168
+ page += 1;
169
+ continue;
170
+ }
171
+ if (selected === "close") {
172
+ await ctx.replaceMessage(`Current model: ${current}`);
173
+ return true;
174
+ }
175
+ try {
176
+ current = await state.runner.setModel(selected);
177
+ await ctx.replaceMessage(`Model set to ${current}`);
178
+ return true;
179
+ }
180
+ catch (err) {
181
+ await ctx.replaceMessage(err instanceof Error ? err.message : String(err));
182
+ return true;
183
+ }
184
+ }
185
+ }
186
+ try {
187
+ const selected = await state.runner.setModel(args);
188
+ await ctx.replaceMessage(`Model set to ${selected}`);
189
+ }
190
+ catch (err) {
191
+ await ctx.replaceMessage(err instanceof Error ? err.message : String(err));
192
+ }
193
+ return true;
194
+ case "/scoped-models":
195
+ if (!args) {
196
+ const available = state.runner.listModels().split("\n").map((line) => line.replace(/^[*-]\s+/, "")).filter(Boolean);
197
+ const current = state.runner.getScopedModels().split("\n").filter((line) => line.startsWith("- ")).map((line) => line.slice(2));
198
+ let page = 0;
199
+ while (true) {
200
+ const selected = await transport.promptScopedModelSelection(event, { currentModels: current, models: available, page });
201
+ if (selected === null || selected === "close") {
202
+ await ctx.replaceMessage(state.runner.getScopedModels());
203
+ return true;
204
+ }
205
+ if (selected === "prev") {
206
+ page = Math.max(0, page - 1);
207
+ continue;
208
+ }
209
+ if (selected === "next") {
210
+ page += 1;
211
+ continue;
212
+ }
213
+ await ctx.replaceMessage(selected.length > 0 ? state.runner.setScopedModels(selected.join(",")) : state.runner.clearScopedModels());
214
+ return true;
215
+ }
216
+ }
217
+ if (args === "clear") {
218
+ await ctx.replaceMessage(state.runner.clearScopedModels());
219
+ return true;
220
+ }
221
+ await ctx.replaceMessage(state.runner.setScopedModels(args));
222
+ return true;
223
+ case "/settings": {
224
+ let summary = state.runner.getSettingsSummary();
225
+ while (true) {
226
+ const action = await transport.promptSettingsCard(event, summary);
227
+ if (!action || action === "done") {
228
+ await ctx.replaceMessage(summary);
229
+ return true;
230
+ }
231
+ summary = action === "thinking"
232
+ ? state.runner.cycleThinkingSetting()
233
+ : action === "transport"
234
+ ? state.runner.cycleTransportSetting()
235
+ : action === "steering"
236
+ ? state.runner.toggleSteeringModeSetting()
237
+ : action === "followup"
238
+ ? state.runner.toggleFollowUpModeSetting()
239
+ : state.runner.toggleAutoCompactSetting();
240
+ }
241
+ }
242
+ case "/compact": {
243
+ const approved = await transport.requestApproval(event, {
244
+ title: "Approve compaction",
245
+ description: args ? `Compact this session with custom instructions:\n\n${args}` : "Compact this session now?",
246
+ approveLabel: "Compact",
247
+ bullets: [
248
+ "Summarizes older conversation history",
249
+ "Keeps recent context and session continuity",
250
+ "May lose some fine-grained older details",
251
+ ],
252
+ caution: "Compaction is lossy. Full history remains in the session file, but active context becomes summarized.",
253
+ });
254
+ if (!approved) {
255
+ await ctx.replaceMessage("Compaction cancelled.");
256
+ return true;
257
+ }
258
+ await ctx.replaceMessage(await state.runner.compact(args || undefined));
259
+ return true;
260
+ }
261
+ case "/reload": {
262
+ const approved = await transport.requestApproval(event, {
263
+ title: "Approve reload",
264
+ description: "Reload settings, skills, prompts, extensions, and model registry?",
265
+ approveLabel: "Reload",
266
+ bullets: [
267
+ "Refreshes model registry and auth-backed availability",
268
+ "Reloads skills, prompts, and extensions",
269
+ "Applies updated configuration for future turns",
270
+ ],
271
+ caution: "Reload affects future turns. It does not rewrite past messages.",
272
+ });
273
+ if (!approved) {
274
+ await ctx.replaceMessage("Reload cancelled.");
275
+ return true;
276
+ }
277
+ await ctx.replaceMessage(await state.runner.reload());
278
+ return true;
279
+ }
280
+ case "/login":
281
+ await ctx.replaceMessage(args
282
+ ? `This Discord harness uses Pi's shared auth. Complete login locally with:\npi\n/login ${args}`
283
+ : "This Discord harness uses Pi's shared auth. Complete login locally with:\npi\n/login");
284
+ return true;
285
+ case "/logout":
286
+ await ctx.replaceMessage(args
287
+ ? `Log out locally with:\npi\n/logout ${args}`
288
+ : "Log out locally with:\npi\n/logout");
289
+ return true;
290
+ case "/stop":
291
+ await state.runner.abort();
292
+ await ctx.replaceMessage("Stop requested.");
293
+ return true;
294
+ case "/resume":
295
+ case "/fork":
296
+ case "/copy":
297
+ case "/export":
298
+ case "/share":
299
+ case "/hotkeys":
300
+ case "/changelog":
301
+ case "/quit":
302
+ case "/exit":
303
+ await ctx.replaceMessage(`${command} is a Pi TUI command that is not supported in this Discord harness.`);
304
+ return true;
305
+ default:
306
+ await ctx.replaceMessage(`Unknown command.\n\n${helpText()}`);
307
+ return true;
308
+ }
309
+ }
310
+ const handler = {
311
+ isRunning(conversationKey) {
312
+ return states.get(conversationKey)?.running ?? false;
313
+ },
314
+ async handleStop(conversationKey) {
315
+ const state = states.get(conversationKey);
316
+ if (!state?.running)
317
+ return;
318
+ state.stopRequested = true;
319
+ state.runner.abort();
320
+ },
321
+ async handleEvent(event, transport) {
322
+ if (await handleCommand(event, transport))
323
+ return;
324
+ const key = getConversationKey(event);
325
+ const state = getState(event);
326
+ if (state.running) {
327
+ const channel = await transport.createContext(event);
328
+ await channel.respond("Already working. Say `stop` to cancel.");
329
+ return;
330
+ }
331
+ state.running = true;
332
+ state.stopRequested = false;
333
+ log.info(`[${key}] starting run: ${event.text.slice(0, 80)}`);
334
+ try {
335
+ const ctx = await transport.createContext(event);
336
+ const result = await state.runner.run(ctx);
337
+ if (result.stopReason === "aborted" && state.stopRequested) {
338
+ await ctx.respondInThread("Stopped.");
339
+ }
340
+ else if (result.stopReason === "error" && result.errorMessage) {
341
+ await ctx.respondInThread(`Error: ${result.errorMessage}`);
342
+ }
343
+ }
344
+ catch (err) {
345
+ log.error(`[${key}] run failed`, err);
346
+ }
347
+ finally {
348
+ state.running = false;
349
+ }
350
+ },
351
+ };
352
+ const store = new ChannelStore(rootDir);
353
+ bot = new DiscordBot(token, handler, store, rootDir);
354
+ process.on("SIGINT", () => {
355
+ log.info("Shutting down...");
356
+ process.exit(0);
357
+ });
358
+ process.on("SIGTERM", () => {
359
+ log.info("Shutting down...");
360
+ process.exit(0);
361
+ });
362
+ await bot.start();
package/dist/store.js ADDED
@@ -0,0 +1,60 @@
1
+ import { appendFileSync, createWriteStream, mkdirSync } from "node:fs";
2
+ import { promises as fs } from "node:fs";
3
+ import { dirname, extname, join } from "node:path";
4
+ export class ChannelStore {
5
+ workingDir;
6
+ constructor(workingDir) {
7
+ this.workingDir = workingDir;
8
+ }
9
+ channelDir(channelId) {
10
+ return join(this.workingDir, channelId);
11
+ }
12
+ logPath(channelId) {
13
+ return join(this.channelDir(channelId), "log.jsonl");
14
+ }
15
+ contextPath(channelId) {
16
+ return join(this.channelDir(channelId), "context.jsonl");
17
+ }
18
+ attachmentsDir(channelId) {
19
+ return join(this.channelDir(channelId), "attachments");
20
+ }
21
+ ensureChannelDir(channelId) {
22
+ mkdirSync(this.channelDir(channelId), { recursive: true });
23
+ }
24
+ appendLog(channelId, entry) {
25
+ this.ensureChannelDir(channelId);
26
+ const path = this.logPath(channelId);
27
+ const line = `${JSON.stringify(entry)}\n`;
28
+ appendFileSync(path, line);
29
+ }
30
+ async storeAttachments(channelId, attachments) {
31
+ const dir = this.attachmentsDir(channelId);
32
+ mkdirSync(dir, { recursive: true });
33
+ const stored = [];
34
+ for (const attachment of attachments) {
35
+ const name = attachment.name ?? `${attachment.id}${extname(attachment.url)}`;
36
+ const localName = `${Date.now()}-${attachment.id}-${name}`;
37
+ const filePath = join(dir, localName);
38
+ const response = await fetch(attachment.url);
39
+ if (!response.ok)
40
+ continue;
41
+ await fs.mkdir(dirname(filePath), { recursive: true });
42
+ const fileStream = createWriteStream(filePath);
43
+ const buffer = Buffer.from(await response.arrayBuffer());
44
+ await new Promise((resolve, reject) => {
45
+ fileStream.on("finish", () => resolve());
46
+ fileStream.on("error", reject);
47
+ fileStream.end(buffer);
48
+ });
49
+ stored.push({
50
+ id: attachment.id,
51
+ url: attachment.url,
52
+ local: filePath,
53
+ name,
54
+ contentType: attachment.contentType,
55
+ size: attachment.size,
56
+ });
57
+ }
58
+ return stored;
59
+ }
60
+ }
@@ -0,0 +1,237 @@
1
+ # GitHub publish checklist and safe first release flow
2
+
3
+ This guide is the exact recommended flow for publishing `pi-discord-bot` safely as a GitHub repository.
4
+
5
+ ---
6
+
7
+ ## Part 1: Pre-publish checklist
8
+
9
+ Run these from the repo root:
10
+
11
+ ```bash
12
+ cd ~/pi-discord-bot
13
+ ```
14
+
15
+ ### 1. Confirm runtime/private data is not being published
16
+ Check that these are **not** intended for publication:
17
+ - external workspace contents
18
+ - local logs
19
+ - local env files
20
+ - auth files
21
+ - downloaded attachments
22
+
23
+ Quick check:
24
+
25
+ ```bash
26
+ git status --ignored
27
+ ```
28
+
29
+ You should see local env/runtime artifacts ignored, and the workspace should not live inside the repo.
30
+
31
+ ### 2. Confirm code is healthy
32
+
33
+ ```bash
34
+ npm test
35
+ npx tsc --noEmit
36
+ ```
37
+
38
+ ### 3. Review package metadata
39
+ Open `package.json` and confirm:
40
+ - `repository.url`
41
+ - `homepage`
42
+ - `bugs.url`
43
+ - `bin`
44
+ - `pi.skills`
45
+
46
+ If you intend to publish to npm, also confirm the package name and version are correct.
47
+
48
+ ### 4. Confirm docs are publish-ready
49
+ Check these files:
50
+ - `README.md`
51
+ - `docs/operator-env-config.md`
52
+ - `docs/publishing-checklist.md`
53
+ - `pi-discord-bot.env.example`
54
+ - `discord-policy.example.json`
55
+ - `pi-discord-bot.service`
56
+
57
+ ### 5. Confirm license choice
58
+ This repo currently includes:
59
+ - `LICENSE` = MIT
60
+
61
+ If that is what you want, keep it.
62
+
63
+ ---
64
+
65
+ ## Part 2: Safe GitHub publish flow
66
+
67
+ ### Step 1: initialize git if needed
68
+
69
+ ```bash
70
+ git init
71
+ ```
72
+
73
+ ### Step 2: inspect what will be committed
74
+
75
+ ```bash
76
+ git status
77
+ ```
78
+
79
+ Carefully verify that these are **not** staged or included:
80
+ - workspace contents
81
+ - `.env`
82
+ - any private logs
83
+ - any auth files
84
+
85
+ ### Step 3: stage files
86
+
87
+ ```bash
88
+ git add .
89
+ ```
90
+
91
+ ### Step 4: inspect staged files before commit
92
+
93
+ ```bash
94
+ git diff --cached --stat
95
+ git diff --cached
96
+ ```
97
+
98
+ This is the most important safety step.
99
+ Do not skip it.
100
+
101
+ ### Step 5: first commit
102
+
103
+ ```bash
104
+ git commit -m "Initial release"
105
+ ```
106
+
107
+ ### Step 6: create GitHub repo
108
+ Create a new empty GitHub repository, for example:
109
+ - `pi-discord-bot`
110
+
111
+ Do **not** initialize it with:
112
+ - README
113
+ - license
114
+ - gitignore
115
+
116
+ because those already exist locally.
117
+
118
+ ### Step 7: connect remote
119
+
120
+ ```bash
121
+ git branch -M main
122
+ git remote add origin git@github.com:<YOUR_USER>/pi-discord-bot.git
123
+ ```
124
+
125
+ Or use HTTPS:
126
+
127
+ ```bash
128
+ git remote add origin https://github.com/<YOUR_USER>/pi-discord-bot.git
129
+ ```
130
+
131
+ ### Step 8: push
132
+
133
+ ```bash
134
+ git push -u origin main
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Part 3: Post-publish checks
140
+
141
+ After pushing:
142
+
143
+ ### 1. Inspect the GitHub file list
144
+ Make sure the repo does **not** contain:
145
+ - `agent/`
146
+ - local env files
147
+ - logs
148
+ - private artifacts
149
+
150
+ ### 2. Check rendered docs
151
+ Verify on GitHub that these render well:
152
+ - `README.md`
153
+ - `docs/operator-env-config.md`
154
+ - `docs/publishing-checklist.md`
155
+ - `docs/github-release-flow.md`
156
+
157
+ ### 3. Check example files
158
+ Make sure users can easily find:
159
+ - `pi-discord-bot.env.example`
160
+ - `discord-policy.example.json`
161
+ - `pi-discord-bot.service`
162
+
163
+ ---
164
+
165
+ ## Part 4: Recommended first public release posture
166
+
167
+ For the public release, I recommend:
168
+ - publish the GitHub repo
169
+ - publish the npm package
170
+ - support both Pi package install and source checkout workflows
171
+
172
+ That gives users a simple low-friction install path while still keeping the source repo available.
173
+
174
+ ---
175
+
176
+ ## Part 5: Optional GitHub release tag flow
177
+
178
+ After the initial repo push, if you want a tagged release:
179
+
180
+ ```bash
181
+ git tag v0.1.0
182
+ git push origin v0.1.0
183
+ ```
184
+
185
+ Then create a GitHub Release in the UI.
186
+
187
+ Suggested first release title:
188
+ - `v0.1.0 — Initial public release`
189
+
190
+ Suggested notes:
191
+ - Discord harness around Pi primitives
192
+ - Discord-native model/settings/tree/approval cards
193
+ - systemd-friendly local operation
194
+ - Pi shared auth/settings flow
195
+ - runtime workspace stored outside the repo by default
196
+
197
+ ---
198
+
199
+ ## Part 6: Safe npm publish flow
200
+
201
+ When ready:
202
+ 1. confirm package metadata and version
203
+ 2. run:
204
+
205
+ ```bash
206
+ npm pack --dry-run
207
+ ```
208
+
209
+ 3. inspect included files carefully
210
+ 4. publish:
211
+
212
+ ```bash
213
+ npm publish --access public
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Short version
219
+
220
+ If you want the shortest safe release path:
221
+
222
+ ```bash
223
+ cd ~/pi-discord-bot
224
+ npm test
225
+ npx tsc --noEmit
226
+ git init
227
+ git add .
228
+ git diff --cached --stat
229
+ git commit -m "Initial release"
230
+ git branch -M main
231
+ git remote add origin git@github.com:<YOUR_USER>/pi-discord-bot.git
232
+ git push -u origin main
233
+ npm pack --dry-run
234
+ npm publish --access public
235
+ ```
236
+
237
+ Before `git commit`, verify again that workspace data is **not** included.