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/LICENSE +21 -0
- package/README.md +352 -0
- package/discord-policy.example.json +10 -0
- package/dist/agent-models.js +81 -0
- package/dist/agent-models.test.js +40 -0
- package/dist/agent-prompt.js +68 -0
- package/dist/agent-runner.js +101 -0
- package/dist/agent-session-ops.js +157 -0
- package/dist/agent-tools.js +224 -0
- package/dist/agent-tree.js +52 -0
- package/dist/agent-tree.test.js +31 -0
- package/dist/agent-types.js +1 -0
- package/dist/agent.js +93 -0
- package/dist/context.js +58 -0
- package/dist/context.test.js +35 -0
- package/dist/discord-context.js +190 -0
- package/dist/discord-guild-tools.js +142 -0
- package/dist/discord-interactions.js +142 -0
- package/dist/discord-policy.js +90 -0
- package/dist/discord-registry.js +22 -0
- package/dist/discord-registry.test.js +17 -0
- package/dist/discord-types.js +1 -0
- package/dist/discord-ui.js +172 -0
- package/dist/discord-ui.test.js +37 -0
- package/dist/discord.js +389 -0
- package/dist/log.js +9 -0
- package/dist/main.js +362 -0
- package/dist/store.js +60 -0
- package/docs/github-release-flow.md +237 -0
- package/docs/operator-env-config.md +278 -0
- package/docs/publishing-checklist.md +95 -0
- package/docs/using-skill-in-pi.md +128 -0
- package/package.json +66 -0
- package/pi-discord-bot.env.example +12 -0
- package/pi-discord-bot.service +16 -0
- package/skills/pi-discord-bot/SKILL.md +237 -0
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.
|