@astrofoundry/pi-astro 0.23.0 → 0.23.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.
@@ -48,12 +48,15 @@ export const CHANNEL_RULES = `## Channel rules
48
48
 
49
49
  This task arrived from a Discord channel and your answer is posted there. Answer in plain text under 1500 characters when possible, facts first, no headings. When the task is ambiguous (which guest, which record, which host), do not act: ask one precise question and stop. Risky calls may wait for an operator's approval; if the tool reports a denial or a missing answer, report it and stop.`;
50
50
 
51
- export function helpText(allowed: readonly string[], present: readonly string[], isOwner: boolean, trigger: Trigger): string {
51
+ export function helpText(allowed: readonly string[], present: readonly string[], isOwner: boolean, trigger: Trigger, canConfig: boolean): string {
52
52
  if (allowed.length === 0) return present.length === 0 ? "No specialist is present in this channel." : "You may not use the specialists present in this channel.";
53
53
  const how = present.length === 1 ? `Every message${trigger === "mention" ? " that addresses me" : ""} here goes to \`${present[0]}\`.` : `Send \`<specialist> <task>\`${trigger === "mention" ? " after mentioning me" : ""}. Specialists you may use here:`;
54
54
  const lines = [how];
55
55
  if (present.length > 1) lines.push(...allowed.map((name) => `• \`${name}\``));
56
- lines.push("Reactions: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `status` lists running tasks.");
56
+ lines.push("Reactions: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed.");
57
+ const commands = ["`help` this message", "`status` running tasks"];
58
+ if (canConfig) commands.push("`config show`", "`config channel <#channel> <always|mention> <*|name,name>`", "`config access <specialist> add|remove <@user>`");
59
+ lines.push(`Commands: ${commands.join(", ")}.`);
57
60
  if (isOwner) lines.push("Owners approve risky calls with the buttons the bot posts and may use every specialist.");
58
61
  return lines.join("\n");
59
62
  }
@@ -118,10 +118,12 @@ describe("commands", () => {
118
118
  });
119
119
 
120
120
  it("writes help and prefix texts", () => {
121
- expect(helpText([], [], false, "always")).toMatch(/No specialist/);
122
- expect(helpText([], ["dns"], false, "always")).toMatch(/may not/);
123
- expect(helpText(["arcane"], ["arcane"], false, "mention")).toMatch(/addresses me here goes to `arcane`/);
124
- expect(helpText(["dns"], ["dns", "edge"], true, "always")).toMatch(/`dns`/);
121
+ expect(helpText([], [], false, "always", false)).toMatch(/No specialist/);
122
+ expect(helpText([], ["dns"], false, "always", false)).toMatch(/may not/);
123
+ expect(helpText(["arcane"], ["arcane"], false, "mention", false)).toMatch(/addresses me here goes to `arcane`/);
124
+ expect(helpText(["dns"], ["dns", "edge"], true, "always", false)).toMatch(/`dns`/);
125
+ expect(helpText(["dns"], ["dns"], true, "always", false)).not.toMatch(/config show/);
126
+ expect(helpText(["dns"], ["dns"], true, "always", true)).toMatch(/`config show`/);
125
127
  expect(needsPrefixText(["dns", "edge"])).toMatch(/`dns`, `edge`/);
126
128
  });
127
129
  });
@@ -205,7 +205,7 @@ class Bridge {
205
205
  }
206
206
  if (allowed.length === 0) return;
207
207
  if (command.kind === "help") {
208
- await this.rest.createMessage(message.channel_id, { content: helpText(allowed, present, owner, channel.settings.trigger), replyTo: message.id });
208
+ await this.rest.createMessage(message.channel_id, { content: helpText(allowed, present, owner, channel.settings.trigger, owner && channel.id === this.config.adminChannelId), replyTo: message.id });
209
209
  return;
210
210
  }
211
211
  if (command.kind === "status") {
@@ -230,6 +230,14 @@ class Bridge {
230
230
  task.startedAt = Date.now();
231
231
  this.active.set(task.specialist, task);
232
232
  await this.rest.addReaction(task.channelId, task.messageId, REACTION.running);
233
+ const placeholder = await this.rest
234
+ .createMessage(task.channelId, { content: `${REACTION.running} **${task.specialist}** is working on your task, please wait…`, replyTo: task.messageId })
235
+ .then((m) => m.id)
236
+ .catch(() => undefined);
237
+ // Discord clears the typing indicator after about ten seconds; refresh it while the task runs.
238
+ await this.rest.triggerTyping(task.channelId).catch(() => undefined);
239
+ const typing = setInterval(() => void this.rest.triggerTyping(task.channelId).catch(() => undefined), 8000);
240
+ typing.unref();
233
241
  log(`run astro.${task.specialist} for ${task.userId}: ${taskText.slice(0, 200)}`);
234
242
  const dirs = defaultDirs();
235
243
  const { skills, missing } = resolveSkills(agent.skills, dirs);
@@ -273,26 +281,30 @@ class Bridge {
273
281
  } catch (err) {
274
282
  output = err instanceof Error ? err.message : String(err);
275
283
  } finally {
284
+ clearInterval(typing);
276
285
  this.active.delete(task.specialist);
277
286
  }
278
287
  await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
279
288
  await this.rest.addReaction(task.channelId, task.messageId, failed ? REACTION.failed : REACTION.done).catch(() => undefined);
280
- await this.post(task, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`, attachment);
289
+ await this.deliver(task, placeholder, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`, attachment);
281
290
  }
282
291
 
283
- private async post(task: ActiveTask, text: string, attachment?: string): Promise<void> {
284
- if (attachment !== undefined) {
285
- await this.rest.createMessage(task.channelId, { content: text.slice(0, 1900), replyTo: task.messageId, file: { name: `astro-${task.specialist}-stderr-${Date.now()}.txt`, content: attachment } });
286
- return;
287
- }
288
- if (text.length > ATTACHMENT_THRESHOLD) {
289
- const summary = `${text.slice(0, 1200).trim()}\n… full answer attached (${text.length} characters).`;
290
- await this.rest.createMessage(task.channelId, { content: summary, replyTo: task.messageId, file: { name: `astro-${task.specialist}-${Date.now()}.md`, content: text } });
292
+ /** Turns the "working" placeholder into the answer: edit it in place when it fits, otherwise replace it with chunks or an attachment. */
293
+ private async deliver(task: ActiveTask, placeholder: string | undefined, text: string, attachment?: string): Promise<void> {
294
+ const oversized = attachment !== undefined || text.length > ATTACHMENT_THRESHOLD;
295
+ if (oversized) {
296
+ if (placeholder) await this.rest.deleteMessage(task.channelId, placeholder).catch(() => undefined);
297
+ const file = attachment !== undefined ? { name: `astro-${task.specialist}-stderr-${Date.now()}.txt`, content: attachment } : { name: `astro-${task.specialist}-${Date.now()}.md`, content: text };
298
+ const content = attachment !== undefined ? text.slice(0, 1900) : `${text.slice(0, 1200).trim()}\n… full answer attached (${text.length} characters).`;
299
+ await this.rest.createMessage(task.channelId, { content, replyTo: task.messageId, file });
291
300
  return;
292
301
  }
293
302
  const chunks = chunkMessage(text);
294
- for (let i = 0; i < chunks.length; i++) {
295
- await this.rest.createMessage(task.channelId, { content: chunks[i], replyTo: i === 0 ? task.messageId : undefined });
303
+ if (chunks.length === 0) chunks.push(text);
304
+ if (placeholder) await this.rest.editMessage(task.channelId, placeholder, chunks[0]).catch(() => undefined);
305
+ else await this.rest.createMessage(task.channelId, { content: chunks[0], replyTo: task.messageId });
306
+ for (let i = 1; i < chunks.length; i++) {
307
+ await this.rest.createMessage(task.channelId, { content: chunks[i] });
296
308
  }
297
309
  }
298
310
 
@@ -72,6 +72,15 @@ export class DiscordRest {
72
72
  await this.call("PATCH", `/channels/${channelId}/messages/${messageId}`, JSON.stringify({ content, components, allowed_mentions: { parse: [] } }), "application/json");
73
73
  }
74
74
 
75
+ /** Shows the "Cortex is typing…" indicator for about ten seconds. */
76
+ async triggerTyping(channelId: string): Promise<void> {
77
+ await this.call("POST", `/channels/${channelId}/typing`);
78
+ }
79
+
80
+ async deleteMessage(channelId: string, messageId: string): Promise<void> {
81
+ await this.call("DELETE", `/channels/${channelId}/messages/${messageId}`);
82
+ }
83
+
75
84
  async addReaction(channelId: string, messageId: string, emoji: string): Promise<void> {
76
85
  await this.call("PUT", `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`);
77
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -25,7 +25,8 @@ export function pinnedRequest(url: URL, options: PinnedOptions): Promise<PinnedR
25
25
  return new Promise((resolve, reject) => {
26
26
  const req = httpsRequest(
27
27
  url,
28
- { method: options.method, rejectUnauthorized: false, headers: options.headers, timeout: options.timeoutMs },
28
+ // agent:false forces a new connection per call: a pooled TLS socket returns an empty peer certificate on reuse, which would fail the pin.
29
+ { method: options.method, rejectUnauthorized: false, headers: options.headers, timeout: options.timeoutMs, agent: false },
29
30
  (res) => {
30
31
  const socket = res.socket as { getPeerCertificate?: () => { fingerprint256?: string } };
31
32
  const fingerprint = socket.getPeerCertificate?.().fingerprint256 ?? "";