@astrofoundry/pi-astro 0.24.0 → 0.25.0
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/attachments.ts +65 -0
- package/extensions/astro-discord/discord.test.ts +32 -0
- package/extensions/astro-discord/index.ts +14 -3
- package/extensions/astro-discord/quote.ts +1 -1
- package/extensions/astro-subagents/child.test.ts +2 -0
- package/extensions/astro-subagents/child.ts +7 -3
- package/extensions/astro-subagents/index.test.ts +8 -1
- package/extensions/astro-subagents/index.ts +21 -0
- package/package.json +1 -1
- package/specialists/README.md +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import type { QuotedMessage } from "./quote.ts";
|
|
6
|
+
|
|
7
|
+
/** An image on a Discord message, by its CDN url. */
|
|
8
|
+
export interface ImageAttachment {
|
|
9
|
+
filename: string;
|
|
10
|
+
url: string;
|
|
11
|
+
contentType: string;
|
|
12
|
+
size: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const MAX_IMAGES = 4;
|
|
16
|
+
export const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
/** Image attachments of the message and of the messages it quotes, oldest quote first, within the count and size limits. */
|
|
19
|
+
export function imageAttachments(messages: readonly QuotedMessage[]): ImageAttachment[] {
|
|
20
|
+
const images: ImageAttachment[] = [];
|
|
21
|
+
for (const message of messages) {
|
|
22
|
+
for (const a of message.attachments ?? []) {
|
|
23
|
+
if (!a.url || !a.content_type?.startsWith("image/") || (a.size ?? 0) > MAX_IMAGE_BYTES) continue;
|
|
24
|
+
images.push({ filename: a.filename, url: a.url, contentType: a.content_type, size: a.size ?? 0 });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return images.slice(0, MAX_IMAGES);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DownloadedImages {
|
|
31
|
+
dir: string;
|
|
32
|
+
files: string[];
|
|
33
|
+
failed: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Saves the images to a private temp directory, named by an index and a hash so filenames from Discord never reach the file system. */
|
|
37
|
+
export async function downloadImages(images: readonly ImageAttachment[], fetchImpl: typeof fetch = fetch): Promise<DownloadedImages> {
|
|
38
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "astro-discord-images-"));
|
|
39
|
+
const files: string[] = [];
|
|
40
|
+
const failed: string[] = [];
|
|
41
|
+
for (const [index, image] of images.entries()) {
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetchImpl(image.url);
|
|
44
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
45
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
46
|
+
if (bytes.length > MAX_IMAGE_BYTES) throw new Error("larger than the limit");
|
|
47
|
+
const name = `${index + 1}-${createHash("sha256").update(image.url).digest("hex").slice(0, 12)}${path.extname(image.filename).toLowerCase().replace(/[^.a-z0-9]/g, "")}`;
|
|
48
|
+
const file = path.join(dir, name);
|
|
49
|
+
await fs.writeFile(file, bytes, { mode: 0o600 });
|
|
50
|
+
files.push(file);
|
|
51
|
+
} catch {
|
|
52
|
+
failed.push(image.filename);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { dir, files, failed };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Task text telling the specialist which images travel with it and which could not be fetched. */
|
|
59
|
+
export function describeImages(task: string, images: readonly ImageAttachment[], failed: readonly string[]): string {
|
|
60
|
+
const lines: string[] = [];
|
|
61
|
+
const fetched = images.filter((i) => !failed.includes(i.filename));
|
|
62
|
+
if (fetched.length > 0) lines.push(`Images attached to the message, included with this task: ${fetched.map((i) => i.filename).join(", ")}.`);
|
|
63
|
+
if (failed.length > 0) lines.push(`Images that could not be fetched: ${failed.join(", ")}.`);
|
|
64
|
+
return lines.length === 0 ? task : `${task}\n\n${lines.join("\n")}`;
|
|
65
|
+
}
|
|
@@ -13,6 +13,7 @@ import { failureReply } from "./index.ts";
|
|
|
13
13
|
import { DiscordRest } from "./rest.ts";
|
|
14
14
|
import { CONVERSATION_IDLE_MS, CONVERSATION_MAX_AGE_MS, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, startConversation } from "./conversations.ts";
|
|
15
15
|
import { quoteText, withQuotes } from "./quote.ts";
|
|
16
|
+
import { MAX_IMAGE_BYTES, describeImages, downloadImages, imageAttachments } from "./attachments.ts";
|
|
16
17
|
|
|
17
18
|
const ID = "123456789012345678";
|
|
18
19
|
const OTHER = "223456789012345678";
|
|
@@ -289,3 +290,34 @@ describe("quotes", () => {
|
|
|
289
290
|
expect(withQuotes("check this", [{ label: "Forwarded message", message: alert }])).toMatch(/^check this\n\nForwarded message:\nWazuh alert/);
|
|
290
291
|
});
|
|
291
292
|
});
|
|
293
|
+
|
|
294
|
+
describe("image attachments", () => {
|
|
295
|
+
const png = { filename: "alert.png", url: "https://cdn.example/alert.png", content_type: "image/png", size: 1000 };
|
|
296
|
+
const image = { filename: png.filename, url: png.url, contentType: png.content_type, size: png.size };
|
|
297
|
+
it("keeps images within the limits, in message order", () => {
|
|
298
|
+
const big = { ...png, filename: "big.png", size: MAX_IMAGE_BYTES + 1 };
|
|
299
|
+
const text = { filename: "log.txt", url: "https://cdn.example/log.txt", content_type: "text/plain", size: 10 };
|
|
300
|
+
const many = Array.from({ length: 6 }, (_, i) => ({ ...png, filename: `${i}.png` }));
|
|
301
|
+
expect(imageAttachments([{ attachments: [text, big, png] }, { attachments: [{ ...png, filename: "quoted.jpg" }] }]).map((i) => i.filename)).toEqual(["alert.png", "quoted.jpg"]);
|
|
302
|
+
expect(imageAttachments([{ attachments: many }])).toHaveLength(4);
|
|
303
|
+
expect(imageAttachments([{}])).toEqual([]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("downloads to a private temp dir and reports failures", async () => {
|
|
307
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
|
308
|
+
const u = String(url);
|
|
309
|
+
if (u.endsWith("missing.png")) return new Response("nope", { status: 404 });
|
|
310
|
+
return new Response(new Uint8Array([137, 80, 78, 71]), { status: 200 });
|
|
311
|
+
}) as unknown as typeof fetch;
|
|
312
|
+
const result = await downloadImages([image, { ...image, filename: "missing.png", url: "https://cdn.example/missing.png" }], fetchImpl);
|
|
313
|
+
try {
|
|
314
|
+
expect(result.files).toHaveLength(1);
|
|
315
|
+
expect(result.files[0]).toMatch(/\/1-[0-9a-f]{12}\.png$/);
|
|
316
|
+
expect(result.failed).toEqual(["missing.png"]);
|
|
317
|
+
expect(describeImages("check this", [image, { ...image, filename: "missing.png" }], result.failed)).toBe("check this\n\nImages attached to the message, included with this task: alert.png.\nImages that could not be fetched: missing.png.");
|
|
318
|
+
expect(describeImages("check this", [], [])).toBe("check this");
|
|
319
|
+
} finally {
|
|
320
|
+
rmSync(result.dir, { recursive: true, force: true });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
1
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { loadConfig as loadSpecialists } from "../specialist-gate/config.ts";
|
|
3
4
|
import { APPROVAL_TOKEN_ENV, APPROVAL_URL_ENV } from "../specialist-gate/index.ts";
|
|
@@ -10,6 +11,7 @@ import { applyAdminCommand } from "./admin.ts";
|
|
|
10
11
|
import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./commands.ts";
|
|
11
12
|
import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, saveConfig } from "./config.ts";
|
|
12
13
|
import { type ConversationState, type ResolvedConversation, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, sessionsRoot, startConversation } from "./conversations.ts";
|
|
14
|
+
import { describeImages, downloadImages, type ImageAttachment, imageAttachments } from "./attachments.ts";
|
|
13
15
|
import { GatewayClient } from "./gateway.ts";
|
|
14
16
|
import { type Quote, type QuotedMessage, withQuotes } from "./quote.ts";
|
|
15
17
|
import { DiscordRest, type MessageComponent } from "./rest.ts";
|
|
@@ -58,6 +60,7 @@ interface IncomingMessage {
|
|
|
58
60
|
guild_id?: string;
|
|
59
61
|
content?: string;
|
|
60
62
|
author?: { id: string; bot?: boolean };
|
|
63
|
+
attachments?: QuotedMessage["attachments"];
|
|
61
64
|
/** The message this one replies to; Discord resolves it on every reply event. */
|
|
62
65
|
referenced_message?: QuotedMessage | null;
|
|
63
66
|
/** Copies of forwarded messages, without their authors. */
|
|
@@ -82,6 +85,7 @@ interface ActiveTask {
|
|
|
82
85
|
userId: string;
|
|
83
86
|
startedAt: number;
|
|
84
87
|
conversation: ResolvedConversation;
|
|
88
|
+
images: ImageAttachment[];
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
function log(line: string): void {
|
|
@@ -238,8 +242,10 @@ class Bridge {
|
|
|
238
242
|
);
|
|
239
243
|
if (!conversation.resumed) this.conversations = startConversation(this.conversations, conversation.key, conversation.id, now);
|
|
240
244
|
saveState(this.conversations);
|
|
241
|
-
const
|
|
242
|
-
const
|
|
245
|
+
const quotes = this.quotesFor(message, conversation.resumed);
|
|
246
|
+
const images = imageAttachments([message, ...quotes.map((q) => q.message)]);
|
|
247
|
+
const task: ActiveTask = { specialist: command.specialist, channelId: message.channel_id, messageId: message.id, userId, startedAt: now, conversation, images };
|
|
248
|
+
const taskText = withQuotes(command.task, quotes);
|
|
243
249
|
const previous = this.queues.get(command.specialist) ?? Promise.resolve();
|
|
244
250
|
const next = previous.then(() => this.runTask(agent, taskText, task)).catch((err) => log(`run failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
245
251
|
this.queues.set(command.specialist, next);
|
|
@@ -273,6 +279,9 @@ class Bridge {
|
|
|
273
279
|
log(`run astro.${task.specialist} for ${task.userId} (${task.conversation.resumed ? "continuing" : "new conversation"} ${task.conversation.id}): ${taskText.slice(0, 200)}`);
|
|
274
280
|
const session: ChildSession = { dir: sessionDirFor(sessionsRoot(), task.specialist), id: task.conversation.id };
|
|
275
281
|
pruneSessions(session.dir);
|
|
282
|
+
const images = task.images.length > 0 ? await downloadImages(task.images) : undefined;
|
|
283
|
+
if (images && images.failed.length > 0) log(`astro.${task.specialist}: could not fetch ${images.failed.join(", ")}`);
|
|
284
|
+
const prompt = images ? describeImages(taskText, task.images, images.failed) : taskText;
|
|
276
285
|
const dirs = defaultDirs();
|
|
277
286
|
const { skills, missing } = resolveSkills(agent.skills, dirs);
|
|
278
287
|
if (missing.length > 0) log(`${agent.name}: skill(s) not found: ${missing.join(", ")}`);
|
|
@@ -289,7 +298,8 @@ class Bridge {
|
|
|
289
298
|
const result = await runAgent({
|
|
290
299
|
agent: { ...agent, timeoutMinutes: agent.timeoutMinutes ?? TASK_TIMEOUT_MINUTES },
|
|
291
300
|
skills,
|
|
292
|
-
task:
|
|
301
|
+
task: prompt,
|
|
302
|
+
files: images?.files,
|
|
293
303
|
cwd: process.cwd(),
|
|
294
304
|
depth,
|
|
295
305
|
remaining: childRemaining(remaining, agent),
|
|
@@ -318,6 +328,7 @@ class Bridge {
|
|
|
318
328
|
} finally {
|
|
319
329
|
clearInterval(typing);
|
|
320
330
|
this.active.delete(task.specialist);
|
|
331
|
+
if (images) await fs.rm(images.dir, { recursive: true, force: true }).catch(() => undefined);
|
|
321
332
|
}
|
|
322
333
|
await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
|
|
323
334
|
await this.rest.addReaction(task.channelId, task.messageId, failed ? REACTION.failed : REACTION.done).catch(() => undefined);
|
|
@@ -3,7 +3,7 @@ export interface QuotedMessage {
|
|
|
3
3
|
id?: string;
|
|
4
4
|
content?: string;
|
|
5
5
|
embeds?: { title?: string; description?: string; fields?: { name: string; value: string }[]; footer?: { text: string }; author?: { name?: string } }[];
|
|
6
|
-
attachments?: { filename: string }[];
|
|
6
|
+
attachments?: { filename: string; url?: string; content_type?: string; size?: number }[];
|
|
7
7
|
author?: { id: string; username?: string; bot?: boolean };
|
|
8
8
|
}
|
|
9
9
|
|
|
@@ -62,6 +62,8 @@ describe("child arguments", () => {
|
|
|
62
62
|
const exact = buildChildArgs({ agent: pinned, task: "t", promptFile: null, defaults: {}, session: { dir: "/s/x", id: "dns-1", continue: true } });
|
|
63
63
|
expect(exact.slice(3, 7)).toEqual(["--session-dir", "/s/x", "--session-id", "dns-1"]);
|
|
64
64
|
expect(exact).not.toContain("--continue");
|
|
65
|
+
const withFiles = buildChildArgs({ agent: pinned, task: "t", promptFile: null, defaults: {}, files: ["/tmp/a.png", "/tmp/b.png"] });
|
|
66
|
+
expect(withFiles.slice(-4)).toEqual(["--", "@/tmp/a.png", "@/tmp/b.png", "Task: t"]);
|
|
65
67
|
});
|
|
66
68
|
|
|
67
69
|
it("inlines skills after the agent prompt", () => {
|
|
@@ -88,10 +88,12 @@ export interface ChildArgsInput {
|
|
|
88
88
|
promptFile: string | null;
|
|
89
89
|
defaults: DispatchDefaults;
|
|
90
90
|
session?: ChildSession;
|
|
91
|
+
/** Local files passed as `@file` prompt inputs; Pi attaches images by content type. */
|
|
92
|
+
files?: string[];
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
/** Arguments for the child `pi` process: print mode, JSON events, a session file only when asked. */
|
|
94
|
-
export function buildChildArgs({ agent, task, promptFile, defaults, session }: ChildArgsInput): string[] {
|
|
96
|
+
export function buildChildArgs({ agent, task, promptFile, defaults, session, files }: ChildArgsInput): string[] {
|
|
95
97
|
const args = ["--mode", "json", "-p"];
|
|
96
98
|
if (session) {
|
|
97
99
|
args.push("--session-dir", session.dir);
|
|
@@ -106,7 +108,7 @@ export function buildChildArgs({ agent, task, promptFile, defaults, session }: C
|
|
|
106
108
|
if (!agent.inheritProjectContext) args.push("--no-context-files");
|
|
107
109
|
if (!agent.inheritSkills) args.push("--no-skills");
|
|
108
110
|
if (promptFile) args.push(agent.systemPromptMode === "append" ? "--append-system-prompt" : "--system-prompt", promptFile);
|
|
109
|
-
args.push("--", `Task: ${task}`);
|
|
111
|
+
args.push("--", ...(files ?? []).map((file) => `@${file}`), `Task: ${task}`);
|
|
110
112
|
return args;
|
|
111
113
|
}
|
|
112
114
|
|
|
@@ -201,6 +203,8 @@ export interface RunOptions {
|
|
|
201
203
|
extraSystemPrompt?: string;
|
|
202
204
|
/** Saved conversation to continue or start; omitted for a one-off run. */
|
|
203
205
|
session?: ChildSession;
|
|
206
|
+
/** Local files (images) attached to the task. */
|
|
207
|
+
files?: string[];
|
|
204
208
|
}
|
|
205
209
|
|
|
206
210
|
export async function runAgent(options: RunOptions): Promise<RunResult> {
|
|
@@ -226,7 +230,7 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
|
|
|
226
230
|
}
|
|
227
231
|
try {
|
|
228
232
|
if (options.session) await fs.promises.mkdir(options.session.dir, { recursive: true, mode: 0o700 });
|
|
229
|
-
const args = buildChildArgs({ agent, task, promptFile, defaults: options.defaults, session: options.session });
|
|
233
|
+
const args = buildChildArgs({ agent, task, promptFile, defaults: options.defaults, session: options.session, files: options.files });
|
|
230
234
|
const invocation = (options.invocation ?? piInvocation)(args);
|
|
231
235
|
const env = buildChildEnv(agent, options.depth, options.remaining, { ...process.env, ...options.env });
|
|
232
236
|
let aborted = false;
|
|
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { AgentDirs } from "./agents.ts";
|
|
6
6
|
import { DEPTH_ENV, REMAINING_ENV } from "./child.ts";
|
|
7
|
-
import astroSubagents, { parseRunCommand, pruneLegacyCopies } from "./index.ts";
|
|
7
|
+
import astroSubagents, { parseRunCommand, pruneLegacyCopies, runEntryText } from "./index.ts";
|
|
8
8
|
|
|
9
9
|
type Handler = (event: Record<string, unknown>, ctx: unknown) => Promise<unknown>;
|
|
10
10
|
|
|
@@ -16,6 +16,8 @@ interface CapturedPi {
|
|
|
16
16
|
registerCommand: (name: string, opts: { handler: (args: string, ctx: unknown) => Promise<void> | void }) => void;
|
|
17
17
|
on: (event: string, handler: Handler) => void;
|
|
18
18
|
sendMessage: ReturnType<typeof vi.fn>;
|
|
19
|
+
appendEntry: ReturnType<typeof vi.fn>;
|
|
20
|
+
registerEntryRenderer: ReturnType<typeof vi.fn>;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function makePi(): CapturedPi {
|
|
@@ -32,6 +34,8 @@ function makePi(): CapturedPi {
|
|
|
32
34
|
handlers[event] = handler;
|
|
33
35
|
},
|
|
34
36
|
sendMessage: vi.fn(),
|
|
37
|
+
appendEntry: vi.fn(),
|
|
38
|
+
registerEntryRenderer: vi.fn(),
|
|
35
39
|
};
|
|
36
40
|
}
|
|
37
41
|
|
|
@@ -78,6 +82,9 @@ describe("astro-subagents extension", () => {
|
|
|
78
82
|
const ctx = { ui: { notify: vi.fn(), setStatus }, hasUI: true, cwd: root, model: { provider: "openai-codex", id: "gpt-6-astra" } };
|
|
79
83
|
await pi.commands.get("run")?.handler("astro.nobody -- x", ctx);
|
|
80
84
|
expect(setStatus).toHaveBeenCalledWith("subagent", expect.stringContaining("astro.nobody on openai-codex/gpt-6-astra"));
|
|
85
|
+
expect(pi.appendEntry).toHaveBeenCalledWith("astro-subagents-run", { agent: "astro.nobody", task: "x", continue: false });
|
|
86
|
+
expect(pi.registerEntryRenderer).toHaveBeenCalledWith("astro-subagents-run", expect.any(Function));
|
|
87
|
+
expect(runEntryText({ agent: "astro.dns", task: "zones", continue: true })).toBe("/run astro.dns --continue -- zones");
|
|
81
88
|
expect(pi.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "astro-subagents", content: expect.stringContaining("Unknown agent") }), { triggerTurn: false });
|
|
82
89
|
});
|
|
83
90
|
|
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
4
4
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
5
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
6
7
|
import { type Static, Type } from "typebox";
|
|
7
8
|
import { type AgentConfig, type AgentDirs, type AgentScope, BUNDLED_NAMESPACE, defaultDirs, discoverAgents, formatAgentList, resolveSkills } from "./agents.ts";
|
|
8
9
|
import { type ChildSession, childRemaining, currentDepth, type DispatchDefaults, finalOutput, isFailed, resultOutput, type RunResult, runAgent } from "./child.ts";
|
|
@@ -88,6 +89,17 @@ export function parseRunCommand(input: string): { agent: string; task: string; c
|
|
|
88
89
|
return { agent: match[1], task, continue: match[2] !== undefined };
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/** Transcript entry for a `/run` invocation; shown to the user, never sent to the model. */
|
|
93
|
+
export interface RunEntry {
|
|
94
|
+
agent: string;
|
|
95
|
+
task: string;
|
|
96
|
+
continue: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function runEntryText(entry: RunEntry): string {
|
|
100
|
+
return `/run ${entry.agent}${entry.continue ? " --continue" : ""} -- ${entry.task}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
91
103
|
export interface GateOptions {
|
|
92
104
|
dirs?: AgentDirs;
|
|
93
105
|
env?: NodeJS.ProcessEnv;
|
|
@@ -265,6 +277,14 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
265
277
|
return { content: [{ type: "text", text: finalOutput(result.messages) || "(no output)" }], details: details("single", [result]) };
|
|
266
278
|
}
|
|
267
279
|
|
|
280
|
+
// Slash commands are not echoed in the transcript; show the invocation the way a user message looks.
|
|
281
|
+
pi.registerEntryRenderer<RunEntry>("astro-subagents-run", (entry, _options, theme) => {
|
|
282
|
+
if (!entry.data) return undefined;
|
|
283
|
+
const box = new Box(1, 0, (text) => theme.bg("userMessageBg", text));
|
|
284
|
+
box.addChild(new Text(theme.fg("userMessageText", runEntryText(entry.data)), 0, 0));
|
|
285
|
+
return box;
|
|
286
|
+
});
|
|
287
|
+
|
|
268
288
|
pi.registerCommand("run", {
|
|
269
289
|
description: "Run an agent: /run <agent> [--continue] -- <task>",
|
|
270
290
|
handler: async (args, ctx) => {
|
|
@@ -278,6 +298,7 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
278
298
|
return;
|
|
279
299
|
}
|
|
280
300
|
const agents = listAgents(ctx.cwd, "user").agents;
|
|
301
|
+
pi.appendEntry<RunEntry>("astro-subagents-run", { agent: parsed.agent, task: parsed.task, continue: parsed.continue });
|
|
281
302
|
// Every /run saves its session so a later --continue can pick the conversation up.
|
|
282
303
|
const session: ChildSession = { dir: sessionDirFor(dirs.sessions, parsed.agent), continue: parsed.continue };
|
|
283
304
|
pruneSessions(session.dir);
|
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. 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). Each channel and specialist has one conversation: a reply to the bot's answer, or another task for that specialist within 30 minutes, continues it with everything the specialist saw before; otherwise a new one starts, and a task beginning with `new` forces that. Replied-to and forwarded messages (text, embeds, attachment names) travel with the task, so "check this" on a forwarded alert works. Sessions live under `~/.config/astro-discord/sessions/<specialist>/` and are deleted after 7 days. Long answers arrive as a `.md` attachment.
|
|
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). Each channel and specialist has one conversation: a reply to the bot's answer, or another task for that specialist within 30 minutes, continues it with everything the specialist saw before; otherwise a new one starts, and a task beginning with `new` forces that. Replied-to and forwarded messages (text, embeds, attachment names) travel with the task, so "check this" on a forwarded alert works. Image attachments on the message or on what it quotes (up to 4, 8 MiB each) are downloaded and passed to the specialist, which sees them; other file types are named only. Sessions live under `~/.config/astro-discord/sessions/<specialist>/` and are deleted after 7 days. 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`.
|