@astrofoundry/pi-astro 0.23.0 → 0.23.2
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/extensions/astro-discord/commands.ts +5 -2
- package/extensions/astro-discord/discord.test.ts +6 -4
- package/extensions/astro-discord/index.ts +24 -12
- package/extensions/astro-discord/rest.ts +9 -0
- package/extensions/astro-subagents/index.test.ts +10 -0
- package/extensions/astro-subagents/index.ts +16 -6
- package/package.json +1 -1
- package/specialists/README.md +1 -1
- package/specialists/lib/pinned.ts +2 -1
|
@@ -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.
|
|
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.
|
|
289
|
+
await this.deliver(task, placeholder, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`, attachment);
|
|
281
290
|
}
|
|
282
291
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
await this.rest.createMessage(task.channelId, { content
|
|
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
|
-
|
|
295
|
-
|
|
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
|
}
|
|
@@ -70,6 +70,16 @@ describe("astro-subagents extension", () => {
|
|
|
70
70
|
expect(notify).toHaveBeenCalledWith(expect.stringContaining("depth limit"), "warning");
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
it("posts the /run result without a model turn and names the model in the footer", async () => {
|
|
74
|
+
const pi = makePi();
|
|
75
|
+
astroSubagents(pi as unknown as Parameters<typeof astroSubagents>[0], { dirs, env: {} });
|
|
76
|
+
const setStatus = vi.fn();
|
|
77
|
+
const ctx = { ui: { notify: vi.fn(), setStatus }, hasUI: true, cwd: root, model: { provider: "openai-codex", id: "gpt-6-astra" } };
|
|
78
|
+
await pi.commands.get("run")?.handler("astro.nobody -- x", ctx);
|
|
79
|
+
expect(setStatus).toHaveBeenCalledWith("subagent", expect.stringContaining("astro.nobody on openai-codex/gpt-6-astra"));
|
|
80
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "astro-subagents", content: expect.stringContaining("Unknown agent") }), { triggerTurn: false });
|
|
81
|
+
});
|
|
82
|
+
|
|
73
83
|
it("removes legacy loader copies on session start", async () => {
|
|
74
84
|
writeFileSync(join(dirs.userAgents, "astro.arcane.md"), "old copy");
|
|
75
85
|
writeFileSync(join(dirs.userAgents, "mine.md"), "---\nname: mine\ndescription: keep\n---\n");
|
|
@@ -139,6 +139,12 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
139
139
|
});
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** Footer label: the agent and the model it will run on. */
|
|
143
|
+
function runLabel(ctx: ExtensionContext, agents: AgentConfig[], agentName: string): string {
|
|
144
|
+
const model = agents.find((a) => a.name === agentName)?.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
|
|
145
|
+
return model ? `${agentName} on ${model}` : agentName;
|
|
146
|
+
}
|
|
147
|
+
|
|
142
148
|
if (canDelegate) {
|
|
143
149
|
const initial = listAgents(process.cwd(), "user");
|
|
144
150
|
pi.registerTool({
|
|
@@ -152,7 +158,13 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
152
158
|
],
|
|
153
159
|
parameters: Params,
|
|
154
160
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
155
|
-
const label = params.chain?.length
|
|
161
|
+
const label = params.chain?.length
|
|
162
|
+
? `chain of ${params.chain.length}`
|
|
163
|
+
: params.tasks?.length
|
|
164
|
+
? `${params.tasks.length} agents`
|
|
165
|
+
: params.agent
|
|
166
|
+
? runLabel(ctx, listAgents(ctx.cwd, params.agentScope ?? "user").agents, params.agent)
|
|
167
|
+
: "subagent";
|
|
156
168
|
const stopTicker = startTicker(ctx, label);
|
|
157
169
|
try {
|
|
158
170
|
return await runTool(params, signal, onUpdate, ctx);
|
|
@@ -260,7 +272,7 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
260
272
|
return;
|
|
261
273
|
}
|
|
262
274
|
const agents = listAgents(ctx.cwd, "user").agents;
|
|
263
|
-
const stopTicker = startTicker(ctx, parsed.agent);
|
|
275
|
+
const stopTicker = startTicker(ctx, runLabel(ctx, agents, parsed.agent));
|
|
264
276
|
let result: RunResult;
|
|
265
277
|
try {
|
|
266
278
|
result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined);
|
|
@@ -269,10 +281,8 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
269
281
|
}
|
|
270
282
|
const status = isFailed(result) ? "failed" : "done";
|
|
271
283
|
if (isFailed(result)) ctx.ui.notify(`${parsed.agent} failed`, "warning");
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
{ deliverAs: "followUp", triggerTurn: true },
|
|
275
|
-
);
|
|
284
|
+
// No turn: the result joins the context for the next prompt without the parent model restating it.
|
|
285
|
+
pi.sendMessage({ customType: "astro-subagents", content: `Result from /run ${parsed.agent} (${status}). Task: ${parsed.task}\n\n${resultOutput(result)}`, display: true }, { triggerTurn: false });
|
|
276
286
|
},
|
|
277
287
|
});
|
|
278
288
|
|
package/package.json
CHANGED
package/specialists/README.md
CHANGED
|
@@ -105,7 +105,7 @@ Check: `sudo -n -u specialist -H /usr/local/bin/specialist-cli arcane --caller t
|
|
|
105
105
|
|
|
106
106
|
The `astro-discord` extension gives Discord users access to the specialists from a channel. It runs in a headless Pi host on Cortex (LaunchAgent `com.astrofoundry.astro-discord`, user `cortex`), never in interactive sessions.
|
|
107
107
|
|
|
108
|
-
- Channels are configured one by one (`channels.<id>`): `trigger` is `always` (every message that names a specialist counts) or `mention` (only messages that start with `@Cortex` or reply to the bot); `specialists` is `*` or a list. A channel with one specialist needs no prefix: every message there is a task for it. In a channel with several, `dns list the zones` names the specialist; an addressed message without a name gets a reply asking for one. Threads inherit their parent channel's entry; unlisted channels are ignored. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows
|
|
108
|
+
- Channels are configured one by one (`channels.<id>`): `trigger` is `always` (every message that names a specialist counts) or `mention` (only messages that start with `@Cortex` or reply to the bot); `specialists` is `*` or a list. A channel with one specialist needs no prefix: every message there is a task for it. In a channel with several, `dns list the zones` names the specialist; an addressed message without a name gets a reply asking for one. Threads inherit their parent channel's entry; unlisted channels are ignored. The bot replies at once with "<specialist> is working on your task" and shows the typing indicator until the answer replaces that reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows the specialists you may use in that channel and the commands (`status` lists running tasks; owners in the admin channel also see the `config` commands). Long answers arrive as a `.md` attachment.
|
|
109
109
|
- Access: `owners` may use every specialist and decide approvals; `access.<specialist>` lists extra users for that specialist; everyone else gets no reply. Owners change the configuration from the admin channel (`adminChannelId`) without a shell: `config show`, `config channel <#channel> <always|mention> <*|dns,edge>`, `config channel <#channel> remove`, `config access <specialist> add|remove <@user>`; the bot writes `~/.config/astro-discord/config.json` on Cortex, which is also editable by hand and re-read on every message. Deny the bot's role View Channel on channels it must never read; that is the boundary the map cannot provide.
|
|
110
110
|
- Approvals: a risky call (anything with `--confirm`, plus writes such as record or zone deletes, Zitadel changes, Pomerium deploys, Arcane redeploys, service restarts) pauses the specialist and posts Approve/Deny buttons; only owners' clicks count; no decision within `approvalTimeoutMinutes` denies it. The specialist then reports the denial. Ambiguous tasks get a question back instead of an action.
|
|
111
111
|
- Setup, as `cortex`: `d=$(mktemp -d) && cp ~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro/extensions/astro-discord/*.ts "$d" && node --disable-warning=ExperimentalWarning "$d/setup.ts"` (Node does not strip types inside `node_modules`, so the files are copied first) prompts for the bot token and the ids, checks each against Discord, and writes `~/.config/astro-discord/{token,config.json,run.sh}` and the LaunchAgent; it prints the `launchctl bootstrap` line. After a pi-astro update restart the bridge with `launchctl kickstart -k gui/$(id -u)/com.astrofoundry.astro-discord`. Log: `~/Library/Logs/astro-discord.log`.
|
|
@@ -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
|
-
|
|
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 ?? "";
|