@astrofoundry/pi-astro 0.23.2 → 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/README.md +1 -1
- package/extensions/astro-discord/attachments.ts +65 -0
- package/extensions/astro-discord/commands.ts +11 -6
- package/extensions/astro-discord/conversations.ts +115 -0
- package/extensions/astro-discord/discord.test.ts +93 -8
- package/extensions/astro-discord/index.ts +66 -14
- package/extensions/astro-discord/quote.ts +39 -0
- package/extensions/astro-subagents/agents.test.ts +1 -0
- package/extensions/astro-subagents/agents.ts +3 -0
- package/extensions/astro-subagents/child.test.ts +8 -0
- package/extensions/astro-subagents/child.ts +29 -5
- package/extensions/astro-subagents/index.test.ts +14 -3
- package/extensions/astro-subagents/index.ts +38 -8
- package/extensions/astro-subagents/sessions.test.ts +32 -0
- package/extensions/astro-subagents/sessions.ts +34 -0
- package/package.json +1 -1
- package/specialists/README.md +2 -2
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ pi # launch; confirm [Extensions] lists astro-subagents, grimoire
|
|
|
57
57
|
## What's inside
|
|
58
58
|
|
|
59
59
|
**Tools** (LLM-callable):
|
|
60
|
-
- `subagent` - delegate a task to any agent, run several in parallel, or chain them (`{previous}` carries the prior output). Each agent runs in its own `pi` process with its own tool allowlist and skills; `/run <agent> -- <task>` does the same from the prompt, `/agents` lists what is available
|
|
60
|
+
- `subagent` - delegate a task to any agent, run several in parallel, or chain them (`{previous}` carries the prior output). Each agent runs in its own `pi` process with its own tool allowlist and skills; `/run <agent> -- <task>` does the same from the prompt and saves the agent's session, `/run <agent> --continue -- <task>` resumes it, `/agents` lists what is available
|
|
61
61
|
- `grimoire` - search indexed technical documentation via the grimoire CLI
|
|
62
62
|
- `edit` - replaces pi's built-in with batch multi-file edits and Codex-style patch mode, preflight validation, atomic rollback
|
|
63
63
|
- `gemini_image` - generate or edit images via Google Gemini native models and Imagen 4; cost-estimated confirmation before every call
|
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Trigger } from "./config.ts";
|
|
2
2
|
|
|
3
3
|
export type Command =
|
|
4
|
-
| { kind: "run"; specialist: string; task: string }
|
|
4
|
+
| { kind: "run"; specialist: string; task: string; fresh: boolean }
|
|
5
5
|
| { kind: "help" }
|
|
6
6
|
| { kind: "status" }
|
|
7
7
|
| { kind: "config"; args: string[] }
|
|
@@ -34,15 +34,19 @@ export function parseCommand(content: string, context: CommandContext): Command
|
|
|
34
34
|
if (word === "status") return { kind: "status" };
|
|
35
35
|
if (word === "config") return { kind: "config", args: rest };
|
|
36
36
|
const named = word.replace(/^astro\./, "");
|
|
37
|
-
if (context.specialists.includes(named))
|
|
38
|
-
|
|
39
|
-
return task.length > 0 ? { kind: "run", specialist: named, task } : { kind: "help" };
|
|
40
|
-
}
|
|
41
|
-
if (context.specialists.length === 1) return { kind: "run", specialist: context.specialists[0], task: text };
|
|
37
|
+
if (context.specialists.includes(named)) return runCommand(named, text.slice(first.length).trim());
|
|
38
|
+
if (context.specialists.length === 1) return runCommand(context.specialists[0], text);
|
|
42
39
|
if (addressed) return { kind: "needs-prefix", specialists: [...context.specialists] };
|
|
43
40
|
return { kind: "none" };
|
|
44
41
|
}
|
|
45
42
|
|
|
43
|
+
/** A task; a leading `new` asks for a fresh conversation instead of continuing the recent one. */
|
|
44
|
+
function runCommand(specialist: string, text: string): Command {
|
|
45
|
+
const fresh = /^new(\s|$)/i.test(text);
|
|
46
|
+
const task = fresh ? text.replace(/^new\s*/i, "").trim() : text;
|
|
47
|
+
return task.length > 0 ? { kind: "run", specialist, task, fresh } : { kind: "help" };
|
|
48
|
+
}
|
|
49
|
+
|
|
46
50
|
/** Prompt paragraph appended for tasks that arrive from a chat channel. */
|
|
47
51
|
export const CHANNEL_RULES = `## Channel rules
|
|
48
52
|
|
|
@@ -54,6 +58,7 @@ export function helpText(allowed: readonly string[], present: readonly string[],
|
|
|
54
58
|
const lines = [how];
|
|
55
59
|
if (present.length > 1) lines.push(...allowed.map((name) => `• \`${name}\``));
|
|
56
60
|
lines.push("Reactions: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed.");
|
|
61
|
+
lines.push("A reply to my answer, or another task for the same specialist within 30 minutes, continues that conversation; start the task with `new` for a fresh one.");
|
|
57
62
|
const commands = ["`help` this message", "`status` running tasks"];
|
|
58
63
|
if (canConfig) commands.push("`config show`", "`config channel <#channel> <always|mention> <*|name,name>`", "`config access <specialist> add|remove <@user>`");
|
|
59
64
|
lines.push(`Commands: ${commands.join(", ")}.`);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { configDir } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
/** A later task for the same specialist in the same channel continues the conversation within this window. */
|
|
7
|
+
export const CONVERSATION_IDLE_MS = 30 * 60 * 1000;
|
|
8
|
+
/** Conversations quiet for longer are forgotten; their session files are pruned on the same schedule. */
|
|
9
|
+
export const CONVERSATION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
10
|
+
const REMEMBERED_ANSWERS = 50;
|
|
11
|
+
|
|
12
|
+
export interface Conversation {
|
|
13
|
+
/** Pi session id of the child that holds this conversation. */
|
|
14
|
+
id: string;
|
|
15
|
+
/** When the conversation started or the specialist last answered. */
|
|
16
|
+
lastAt: number;
|
|
17
|
+
/** The bot's answer messages; a reply to any of them continues here. */
|
|
18
|
+
messageIds: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ConversationState {
|
|
22
|
+
/** One live conversation per `<channelId>:<specialist>`. */
|
|
23
|
+
conversations: Record<string, Conversation>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function conversationKey(channelId: string, specialist: string): string {
|
|
27
|
+
return `${channelId}:${specialist}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function statePath(): string {
|
|
31
|
+
return join(configDir(), "conversations.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sessionsRoot(): string {
|
|
35
|
+
return join(configDir(), "sessions");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isConversation(value: unknown): value is Conversation {
|
|
39
|
+
if (typeof value !== "object" || value === null) return false;
|
|
40
|
+
const c = value as Record<string, unknown>;
|
|
41
|
+
return typeof c.id === "string" && typeof c.lastAt === "number" && Array.isArray(c.messageIds) && c.messageIds.every((m) => typeof m === "string");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reads the saved state; an unreadable or malformed file starts empty. */
|
|
45
|
+
export function loadState(): ConversationState {
|
|
46
|
+
let raw: unknown;
|
|
47
|
+
try {
|
|
48
|
+
raw = JSON.parse(readFileSync(statePath(), "utf-8"));
|
|
49
|
+
} catch {
|
|
50
|
+
return { conversations: {} };
|
|
51
|
+
}
|
|
52
|
+
const conversations: Record<string, Conversation> = {};
|
|
53
|
+
const source = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>).conversations : undefined;
|
|
54
|
+
if (typeof source === "object" && source !== null) {
|
|
55
|
+
for (const [key, value] of Object.entries(source as Record<string, unknown>)) if (isConversation(value)) conversations[key] = value;
|
|
56
|
+
}
|
|
57
|
+
return { conversations };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function saveState(state: ConversationState): void {
|
|
61
|
+
const path = statePath();
|
|
62
|
+
const tmp = `${path}.tmp`;
|
|
63
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
64
|
+
renameSync(tmp, path);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A session id Pi accepts: specialist, UTC timestamp, random suffix. */
|
|
68
|
+
export function newConversationId(specialist: string, now: number, random: () => string = () => randomBytes(3).toString("hex")): string {
|
|
69
|
+
const stamp = new Date(now).toISOString().replace(/\D/g, "").slice(0, 14);
|
|
70
|
+
return `${specialist}-${stamp}-${random()}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ResolveInput {
|
|
74
|
+
channelId: string;
|
|
75
|
+
specialist: string;
|
|
76
|
+
/** Id of the message this one replies to, if any. */
|
|
77
|
+
repliedTo?: string;
|
|
78
|
+
/** The user asked for a fresh conversation. */
|
|
79
|
+
fresh: boolean;
|
|
80
|
+
now: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ResolvedConversation {
|
|
84
|
+
key: string;
|
|
85
|
+
id: string;
|
|
86
|
+
resumed: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The conversation a task belongs to: the one it replies to, else the recent one, else a new one. */
|
|
90
|
+
export function resolveConversation(state: ConversationState, input: ResolveInput, newId: () => string): ResolvedConversation {
|
|
91
|
+
const key = conversationKey(input.channelId, input.specialist);
|
|
92
|
+
const current = state.conversations[key];
|
|
93
|
+
if (!input.fresh && current) {
|
|
94
|
+
const repliesHere = input.repliedTo !== undefined && current.messageIds.includes(input.repliedTo);
|
|
95
|
+
if (repliesHere || input.now - current.lastAt < CONVERSATION_IDLE_MS) return { key, id: current.id, resumed: true };
|
|
96
|
+
}
|
|
97
|
+
return { key, id: newId(), resumed: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function startConversation(state: ConversationState, key: string, id: string, now: number): ConversationState {
|
|
101
|
+
return { conversations: { ...state.conversations, [key]: { id, lastAt: now, messageIds: [] } } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Remembers the bot's answer messages and refreshes the idle window. */
|
|
105
|
+
export function recordAnswer(state: ConversationState, key: string, messageIds: string[], now: number): ConversationState {
|
|
106
|
+
const current = state.conversations[key];
|
|
107
|
+
if (!current) return state;
|
|
108
|
+
const merged = [...current.messageIds, ...messageIds].slice(-REMEMBERED_ANSWERS);
|
|
109
|
+
return { conversations: { ...state.conversations, [key]: { ...current, lastAt: now, messageIds: merged } } };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function forgetStale(state: ConversationState, now: number): ConversationState {
|
|
113
|
+
const conversations = Object.fromEntries(Object.entries(state.conversations).filter(([, c]) => now - c.lastAt < CONVERSATION_MAX_AGE_MS));
|
|
114
|
+
return { conversations };
|
|
115
|
+
}
|
|
@@ -11,6 +11,9 @@ import { INTENTS, initialState, reduce } from "./gateway.ts";
|
|
|
11
11
|
import type { RunResult } from "../astro-subagents/child.ts";
|
|
12
12
|
import { failureReply } from "./index.ts";
|
|
13
13
|
import { DiscordRest } from "./rest.ts";
|
|
14
|
+
import { CONVERSATION_IDLE_MS, CONVERSATION_MAX_AGE_MS, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, startConversation } from "./conversations.ts";
|
|
15
|
+
import { quoteText, withQuotes } from "./quote.ts";
|
|
16
|
+
import { MAX_IMAGE_BYTES, describeImages, downloadImages, imageAttachments } from "./attachments.ts";
|
|
14
17
|
|
|
15
18
|
const ID = "123456789012345678";
|
|
16
19
|
const OTHER = "223456789012345678";
|
|
@@ -89,20 +92,24 @@ describe("commands", () => {
|
|
|
89
92
|
const many = { botUserId: ID, specialists: ["dns", "edge"], trigger: "always" as const, repliedToBot: false };
|
|
90
93
|
const one = { ...many, specialists: ["arcane"] };
|
|
91
94
|
it("parses prefixed tasks, mentions, help, status, and config", () => {
|
|
92
|
-
expect(parseCommand("dns list the zones", many)).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
|
|
93
|
-
expect(parseCommand(`<@${ID}> edge probe the front door`, many)).toEqual({ kind: "run", specialist: "edge", task: "probe the front door" });
|
|
94
|
-
expect(parseCommand("!astro.dns zones", many)).toEqual({ kind: "run", specialist: "dns", task: "zones" });
|
|
95
|
+
expect(parseCommand("dns list the zones", many)).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: false });
|
|
96
|
+
expect(parseCommand(`<@${ID}> edge probe the front door`, many)).toEqual({ kind: "run", specialist: "edge", task: "probe the front door", fresh: false });
|
|
97
|
+
expect(parseCommand("!astro.dns zones", many)).toEqual({ kind: "run", specialist: "dns", task: "zones", fresh: false });
|
|
95
98
|
expect(parseCommand("help", many)).toEqual({ kind: "help" });
|
|
96
99
|
expect(parseCommand("Status", many)).toEqual({ kind: "status" });
|
|
97
100
|
expect(parseCommand("config channel <#1> mention dns", many)).toEqual({ kind: "config", args: ["channel", "<#1>", "mention", "dns"] });
|
|
98
101
|
expect(parseCommand("dns", many)).toEqual({ kind: "help" });
|
|
102
|
+
expect(parseCommand("dns new list the zones", many)).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: true });
|
|
103
|
+
expect(parseCommand("New what changed?", one)).toEqual({ kind: "run", specialist: "arcane", task: "what changed?", fresh: true });
|
|
104
|
+
expect(parseCommand("newer images?", one)).toEqual({ kind: "run", specialist: "arcane", task: "newer images?", fresh: false });
|
|
105
|
+
expect(parseCommand("dns new", many)).toEqual({ kind: "help" });
|
|
99
106
|
expect(parseCommand("hello everyone", many)).toEqual({ kind: "none" });
|
|
100
107
|
expect(parseCommand("", many)).toEqual({ kind: "none" });
|
|
101
108
|
});
|
|
102
109
|
|
|
103
110
|
it("needs no prefix with one specialist and asks for one when addressed with several", () => {
|
|
104
|
-
expect(parseCommand("list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects" });
|
|
105
|
-
expect(parseCommand("arcane list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects" });
|
|
111
|
+
expect(parseCommand("list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects", fresh: false });
|
|
112
|
+
expect(parseCommand("arcane list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects", fresh: false });
|
|
106
113
|
expect(parseCommand(`<@${ID}> list the zones`, many)).toEqual({ kind: "needs-prefix", specialists: ["dns", "edge"] });
|
|
107
114
|
expect(parseCommand("list the zones", { ...many, repliedToBot: true })).toEqual({ kind: "needs-prefix", specialists: ["dns", "edge"] });
|
|
108
115
|
expect(parseCommand(`<@${ID}>`, many)).toEqual({ kind: "help" });
|
|
@@ -111,10 +118,10 @@ describe("commands", () => {
|
|
|
111
118
|
it("stays silent in mention channels unless addressed", () => {
|
|
112
119
|
const mention = { ...many, trigger: "mention" as const };
|
|
113
120
|
expect(parseCommand("dns list the zones", mention)).toEqual({ kind: "none" });
|
|
114
|
-
expect(parseCommand(`<@${ID}> dns list the zones`, mention)).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
|
|
115
|
-
expect(parseCommand("dns list the zones", { ...mention, repliedToBot: true })).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
|
|
121
|
+
expect(parseCommand(`<@${ID}> dns list the zones`, mention)).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: false });
|
|
122
|
+
expect(parseCommand("dns list the zones", { ...mention, repliedToBot: true })).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: false });
|
|
116
123
|
expect(parseCommand("anything", { ...one, trigger: "mention" })).toEqual({ kind: "none" });
|
|
117
|
-
expect(parseCommand(`<@${ID}> anything`, { ...one, trigger: "mention" })).toEqual({ kind: "run", specialist: "arcane", task: "anything" });
|
|
124
|
+
expect(parseCommand(`<@${ID}> anything`, { ...one, trigger: "mention" })).toEqual({ kind: "run", specialist: "arcane", task: "anything", fresh: false });
|
|
118
125
|
});
|
|
119
126
|
|
|
120
127
|
it("writes help and prefix texts", () => {
|
|
@@ -236,3 +243,81 @@ describe("approval server", () => {
|
|
|
236
243
|
server.close();
|
|
237
244
|
});
|
|
238
245
|
});
|
|
246
|
+
|
|
247
|
+
describe("conversations", () => {
|
|
248
|
+
const now = 1_800_000_000_000;
|
|
249
|
+
it("continues on a reply or within the idle window, otherwise starts fresh", () => {
|
|
250
|
+
let state = startConversation({ conversations: {} }, "1:dns", "dns-a", now);
|
|
251
|
+
state = recordAnswer(state, "1:dns", ["m1"], now);
|
|
252
|
+
const input = { channelId: "1", specialist: "dns", fresh: false, now: now + 60_000 };
|
|
253
|
+
expect(resolveConversation(state, input, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-a", resumed: true });
|
|
254
|
+
expect(resolveConversation(state, { ...input, now: now + CONVERSATION_IDLE_MS + 1 }, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-b", resumed: false });
|
|
255
|
+
expect(resolveConversation(state, { ...input, now: now + CONVERSATION_IDLE_MS + 1, repliedTo: "m1" }, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-a", resumed: true });
|
|
256
|
+
expect(resolveConversation(state, { ...input, fresh: true }, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-b", resumed: false });
|
|
257
|
+
expect(resolveConversation(state, { ...input, channelId: "2" }, () => "dns-b").resumed).toBe(false);
|
|
258
|
+
expect(recordAnswer(state, "missing", ["x"], now)).toBe(state);
|
|
259
|
+
expect(forgetStale(state, now + CONVERSATION_MAX_AGE_MS + 1).conversations).toEqual({});
|
|
260
|
+
expect(forgetStale(state, now + 1).conversations["1:dns"]).toBeDefined();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("builds Pi-compatible ids and round-trips the state file", () => {
|
|
264
|
+
const id = newConversationId("security", Date.UTC(2026, 8, 16, 20, 55, 0), () => "abc123");
|
|
265
|
+
expect(id).toBe("security-20260916205500-abc123");
|
|
266
|
+
expect(id).toMatch(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/);
|
|
267
|
+
const dir = mkdtempSync(join(tmpdir(), "astro-conv-"));
|
|
268
|
+
process.env.ASTRO_DISCORD_DIR = dir;
|
|
269
|
+
try {
|
|
270
|
+
expect(loadState()).toEqual({ conversations: {} });
|
|
271
|
+
const state = recordAnswer(startConversation({ conversations: {} }, "1:dns", id, now), "1:dns", ["m1"], now);
|
|
272
|
+
saveState(state);
|
|
273
|
+
expect(loadState()).toEqual(state);
|
|
274
|
+
writeFileSync(join(dir, "conversations.json"), JSON.stringify({ conversations: { bad: { id: 1 }, ok: { id: "x", lastAt: 1, messageIds: [] } } }));
|
|
275
|
+
expect(Object.keys(loadState().conversations)).toEqual(["ok"]);
|
|
276
|
+
} finally {
|
|
277
|
+
delete process.env.ASTRO_DISCORD_DIR;
|
|
278
|
+
rmSync(dir, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("quotes", () => {
|
|
284
|
+
it("flattens content, embeds, and attachments and appends them to the task", () => {
|
|
285
|
+
const alert = { content: "", embeds: [{ title: "Wazuh alert, level 12", description: "System running out of memory.", fields: [{ name: "agent", value: "arcane" }], footer: { text: "rule 5108" } }], attachments: [{ filename: "log.txt" }] };
|
|
286
|
+
expect(quoteText(alert)).toBe("Wazuh alert, level 12\nSystem running out of memory.\nagent: arcane\nrule 5108\nAttachments: log.txt");
|
|
287
|
+
expect(quoteText({ content: "x".repeat(5000) })).toHaveLength(4001);
|
|
288
|
+
expect(withQuotes("check this", [])).toBe("check this");
|
|
289
|
+
expect(withQuotes("check this", [{ label: "Forwarded message", message: { content: "" } }])).toBe("check this");
|
|
290
|
+
expect(withQuotes("check this", [{ label: "Forwarded message", message: alert }])).toMatch(/^check this\n\nForwarded message:\nWazuh alert/);
|
|
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,14 +1,19 @@
|
|
|
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";
|
|
4
5
|
import { type AgentConfig, defaultDirs, discoverAgents, resolveSkills } from "../astro-subagents/agents.ts";
|
|
5
|
-
import { AGENT_ENV, type DispatchDefaults, type RunResult, childRemaining, currentDepth, finalOutput, isFailed, runAgent } from "../astro-subagents/child.ts";
|
|
6
|
+
import { AGENT_ENV, type ChildSession, type DispatchDefaults, type RunResult, childRemaining, currentDepth, finalOutput, isFailed, runAgent } from "../astro-subagents/child.ts";
|
|
7
|
+
import { pruneSessions, sessionDirFor } from "../astro-subagents/sessions.ts";
|
|
6
8
|
import { ApprovalServer, type Ticket } from "./approvals.ts";
|
|
7
9
|
import { chunkMessage } from "./chunk.ts";
|
|
8
10
|
import { applyAdminCommand } from "./admin.ts";
|
|
9
11
|
import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./commands.ts";
|
|
10
12
|
import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, saveConfig } from "./config.ts";
|
|
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";
|
|
11
15
|
import { GatewayClient } from "./gateway.ts";
|
|
16
|
+
import { type Quote, type QuotedMessage, withQuotes } from "./quote.ts";
|
|
12
17
|
import { DiscordRest, type MessageComponent } from "./rest.ts";
|
|
13
18
|
|
|
14
19
|
const REACTION = { running: "⏳", waiting: "🔒", done: "✅", failed: "❌" } as const;
|
|
@@ -55,7 +60,11 @@ interface IncomingMessage {
|
|
|
55
60
|
guild_id?: string;
|
|
56
61
|
content?: string;
|
|
57
62
|
author?: { id: string; bot?: boolean };
|
|
58
|
-
|
|
63
|
+
attachments?: QuotedMessage["attachments"];
|
|
64
|
+
/** The message this one replies to; Discord resolves it on every reply event. */
|
|
65
|
+
referenced_message?: QuotedMessage | null;
|
|
66
|
+
/** Copies of forwarded messages, without their authors. */
|
|
67
|
+
message_snapshots?: { message: QuotedMessage }[];
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
interface ComponentInteraction {
|
|
@@ -75,6 +84,8 @@ interface ActiveTask {
|
|
|
75
84
|
messageId: string;
|
|
76
85
|
userId: string;
|
|
77
86
|
startedAt: number;
|
|
87
|
+
conversation: ResolvedConversation;
|
|
88
|
+
images: ImageAttachment[];
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
function log(line: string): void {
|
|
@@ -105,6 +116,7 @@ class Bridge {
|
|
|
105
116
|
private readonly active = new Map<string, ActiveTask>();
|
|
106
117
|
private readonly approvalMessages = new Map<string, { channelId: string; messageId: string; userMessage: ActiveTask }>();
|
|
107
118
|
private config: DiscordConfig;
|
|
119
|
+
private conversations: ConversationState;
|
|
108
120
|
private botUserId = "";
|
|
109
121
|
/** Thread id to parent channel id; null marks a plain channel. */
|
|
110
122
|
private readonly parents = new Map<string, string | null>();
|
|
@@ -115,6 +127,7 @@ class Bridge {
|
|
|
115
127
|
this.ctx = ctx;
|
|
116
128
|
this.token = token;
|
|
117
129
|
this.config = config;
|
|
130
|
+
this.conversations = loadState();
|
|
118
131
|
this.rest = new DiscordRest(token);
|
|
119
132
|
this.approvals = new ApprovalServer({ onTicket: (t) => void this.askApproval(t), onExpired: (t) => void this.expireApproval(t) }, config.approvalTimeoutMinutes * 60_000);
|
|
120
133
|
}
|
|
@@ -220,12 +233,37 @@ class Bridge {
|
|
|
220
233
|
if (!canUse(this.config, userId, command.specialist)) return;
|
|
221
234
|
const agent = agents.find((a) => a.name === `astro.${command.specialist}`);
|
|
222
235
|
if (!agent) return;
|
|
223
|
-
const
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
this.conversations = forgetStale(this.conversations, now);
|
|
238
|
+
const conversation = resolveConversation(
|
|
239
|
+
this.conversations,
|
|
240
|
+
{ channelId: message.channel_id, specialist: command.specialist, repliedTo: message.referenced_message?.id, fresh: command.fresh, now },
|
|
241
|
+
() => newConversationId(command.specialist, now),
|
|
242
|
+
);
|
|
243
|
+
if (!conversation.resumed) this.conversations = startConversation(this.conversations, conversation.key, conversation.id, now);
|
|
244
|
+
saveState(this.conversations);
|
|
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);
|
|
224
249
|
const previous = this.queues.get(command.specialist) ?? Promise.resolve();
|
|
225
|
-
const next = previous.then(() => this.runTask(agent,
|
|
250
|
+
const next = previous.then(() => this.runTask(agent, taskText, task)).catch((err) => log(`run failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
226
251
|
this.queues.set(command.specialist, next);
|
|
227
252
|
}
|
|
228
253
|
|
|
254
|
+
/** Replied-to and forwarded messages to show the specialist; the bot's own answer only when the conversation holding it is not resumed. */
|
|
255
|
+
private quotesFor(message: IncomingMessage, resumed: boolean): Quote[] {
|
|
256
|
+
const quotes: Quote[] = [];
|
|
257
|
+
const replied = message.referenced_message;
|
|
258
|
+
if (replied) {
|
|
259
|
+
const mine = replied.author?.id === this.botUserId;
|
|
260
|
+
if (!mine) quotes.push({ label: `Replied-to message from ${replied.author?.username ?? replied.author?.id ?? "an unknown author"}`, message: replied });
|
|
261
|
+
else if (!resumed) quotes.push({ label: "My earlier answer, which this message replies to", message: replied });
|
|
262
|
+
}
|
|
263
|
+
for (const snapshot of message.message_snapshots ?? []) quotes.push({ label: "Forwarded message", message: snapshot.message });
|
|
264
|
+
return quotes;
|
|
265
|
+
}
|
|
266
|
+
|
|
229
267
|
private async runTask(agent: AgentConfig, taskText: string, task: ActiveTask): Promise<void> {
|
|
230
268
|
task.startedAt = Date.now();
|
|
231
269
|
this.active.set(task.specialist, task);
|
|
@@ -238,7 +276,12 @@ class Bridge {
|
|
|
238
276
|
await this.rest.triggerTyping(task.channelId).catch(() => undefined);
|
|
239
277
|
const typing = setInterval(() => void this.rest.triggerTyping(task.channelId).catch(() => undefined), 8000);
|
|
240
278
|
typing.unref();
|
|
241
|
-
log(`run astro.${task.specialist} for ${task.userId}: ${taskText.slice(0, 200)}`);
|
|
279
|
+
log(`run astro.${task.specialist} for ${task.userId} (${task.conversation.resumed ? "continuing" : "new conversation"} ${task.conversation.id}): ${taskText.slice(0, 200)}`);
|
|
280
|
+
const session: ChildSession = { dir: sessionDirFor(sessionsRoot(), task.specialist), id: task.conversation.id };
|
|
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;
|
|
242
285
|
const dirs = defaultDirs();
|
|
243
286
|
const { skills, missing } = resolveSkills(agent.skills, dirs);
|
|
244
287
|
if (missing.length > 0) log(`${agent.name}: skill(s) not found: ${missing.join(", ")}`);
|
|
@@ -255,7 +298,8 @@ class Bridge {
|
|
|
255
298
|
const result = await runAgent({
|
|
256
299
|
agent: { ...agent, timeoutMinutes: agent.timeoutMinutes ?? TASK_TIMEOUT_MINUTES },
|
|
257
300
|
skills,
|
|
258
|
-
task:
|
|
301
|
+
task: prompt,
|
|
302
|
+
files: images?.files,
|
|
259
303
|
cwd: process.cwd(),
|
|
260
304
|
depth,
|
|
261
305
|
remaining: childRemaining(remaining, agent),
|
|
@@ -263,6 +307,7 @@ class Bridge {
|
|
|
263
307
|
// The child must not become a second Discord bridge.
|
|
264
308
|
env: { [ACTIVATION_ENV]: "0", [APPROVAL_URL_ENV]: this.approvals.url, [APPROVAL_TOKEN_ENV]: this.approvals.token, ASTRO_CHANNEL: "discord" },
|
|
265
309
|
extraSystemPrompt: CHANNEL_RULES,
|
|
310
|
+
session,
|
|
266
311
|
onUpdate: (partial) => {
|
|
267
312
|
const progress = describeProgress(partial, seen);
|
|
268
313
|
seen = progress.seen;
|
|
@@ -283,29 +328,36 @@ class Bridge {
|
|
|
283
328
|
} finally {
|
|
284
329
|
clearInterval(typing);
|
|
285
330
|
this.active.delete(task.specialist);
|
|
331
|
+
if (images) await fs.rm(images.dir, { recursive: true, force: true }).catch(() => undefined);
|
|
286
332
|
}
|
|
287
333
|
await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
|
|
288
334
|
await this.rest.addReaction(task.channelId, task.messageId, failed ? REACTION.failed : REACTION.done).catch(() => undefined);
|
|
289
|
-
await this.deliver(task, placeholder, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`, attachment);
|
|
335
|
+
const answers = await this.deliver(task, placeholder, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`, attachment);
|
|
336
|
+
this.conversations = recordAnswer(this.conversations, task.conversation.key, answers, Date.now());
|
|
337
|
+
saveState(this.conversations);
|
|
290
338
|
}
|
|
291
339
|
|
|
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<
|
|
340
|
+
/** Turns the "working" placeholder into the answer: edit it in place when it fits, otherwise replace it with chunks or an attachment. Returns the answer message ids. */
|
|
341
|
+
private async deliver(task: ActiveTask, placeholder: string | undefined, text: string, attachment?: string): Promise<string[]> {
|
|
294
342
|
const oversized = attachment !== undefined || text.length > ATTACHMENT_THRESHOLD;
|
|
295
343
|
if (oversized) {
|
|
296
344
|
if (placeholder) await this.rest.deleteMessage(task.channelId, placeholder).catch(() => undefined);
|
|
297
345
|
const file = attachment !== undefined ? { name: `astro-${task.specialist}-stderr-${Date.now()}.txt`, content: attachment } : { name: `astro-${task.specialist}-${Date.now()}.md`, content: text };
|
|
298
346
|
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 });
|
|
300
|
-
return;
|
|
347
|
+
const posted = await this.rest.createMessage(task.channelId, { content, replyTo: task.messageId, file });
|
|
348
|
+
return [posted.id];
|
|
301
349
|
}
|
|
302
350
|
const chunks = chunkMessage(text);
|
|
303
351
|
if (chunks.length === 0) chunks.push(text);
|
|
304
|
-
|
|
305
|
-
|
|
352
|
+
const ids: string[] = [];
|
|
353
|
+
if (placeholder) {
|
|
354
|
+
await this.rest.editMessage(task.channelId, placeholder, chunks[0]).catch(() => undefined);
|
|
355
|
+
ids.push(placeholder);
|
|
356
|
+
} else ids.push((await this.rest.createMessage(task.channelId, { content: chunks[0], replyTo: task.messageId })).id);
|
|
306
357
|
for (let i = 1; i < chunks.length; i++) {
|
|
307
|
-
await this.rest.createMessage(task.channelId, { content: chunks[i] });
|
|
358
|
+
ids.push((await this.rest.createMessage(task.channelId, { content: chunks[i] })).id);
|
|
308
359
|
}
|
|
360
|
+
return ids;
|
|
309
361
|
}
|
|
310
362
|
|
|
311
363
|
private async askApproval(ticket: Ticket): Promise<void> {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The parts of a Discord message worth quoting to a specialist. */
|
|
2
|
+
export interface QuotedMessage {
|
|
3
|
+
id?: string;
|
|
4
|
+
content?: string;
|
|
5
|
+
embeds?: { title?: string; description?: string; fields?: { name: string; value: string }[]; footer?: { text: string }; author?: { name?: string } }[];
|
|
6
|
+
attachments?: { filename: string; url?: string; content_type?: string; size?: number }[];
|
|
7
|
+
author?: { id: string; username?: string; bot?: boolean };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const QUOTE_LIMIT = 4000;
|
|
11
|
+
|
|
12
|
+
/** Plain text of a message: content, then each embed's texts, then attachment names. */
|
|
13
|
+
export function quoteText(message: QuotedMessage): string {
|
|
14
|
+
const parts: string[] = [];
|
|
15
|
+
const content = message.content?.trim();
|
|
16
|
+
if (content) parts.push(content);
|
|
17
|
+
for (const embed of message.embeds ?? []) {
|
|
18
|
+
const lines = [embed.author?.name, embed.title, embed.description, ...(embed.fields ?? []).map((f) => `${f.name}: ${f.value}`), embed.footer?.text];
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
const text = line?.trim();
|
|
21
|
+
if (text) parts.push(text);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (message.attachments && message.attachments.length > 0) parts.push(`Attachments: ${message.attachments.map((a) => a.filename).join(", ")}`);
|
|
25
|
+
const text = parts.join("\n");
|
|
26
|
+
return text.length > QUOTE_LIMIT ? `${text.slice(0, QUOTE_LIMIT)}…` : text;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Quote {
|
|
30
|
+
label: string;
|
|
31
|
+
message: QuotedMessage;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The task with the replied-to and forwarded messages appended, so the specialist sees what "this" refers to. */
|
|
35
|
+
export function withQuotes(task: string, quotes: readonly Quote[]): string {
|
|
36
|
+
const blocks = quotes.map((q) => ({ label: q.label, text: quoteText(q.message) })).filter((q) => q.text.length > 0);
|
|
37
|
+
if (blocks.length === 0) return task;
|
|
38
|
+
return `${task}\n\n${blocks.map((q) => `${q.label}:\n${q.text}`).join("\n\n")}`;
|
|
39
|
+
}
|
|
@@ -67,6 +67,7 @@ describe("discovery", () => {
|
|
|
67
67
|
bundledSkills: join(root, "pkg", "skills"),
|
|
68
68
|
userAgents: join(root, "user", "agents"),
|
|
69
69
|
userSkills: join(root, "user", "skills"),
|
|
70
|
+
sessions: join(root, "sessions"),
|
|
70
71
|
};
|
|
71
72
|
for (const d of Object.values(dirs)) mkdirSync(d, { recursive: true });
|
|
72
73
|
writeFileSync(join(dirs.bundledAgents, "arcane.md"), "---\nname: arcane\ndescription: bundled\nskills: arcane\n---\nB");
|
|
@@ -39,6 +39,8 @@ export interface AgentDirs {
|
|
|
39
39
|
bundledSkills: string;
|
|
40
40
|
userAgents: string;
|
|
41
41
|
userSkills: string;
|
|
42
|
+
/** Root for saved `/run` sessions, one folder per agent. */
|
|
43
|
+
sessions: string;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -50,6 +52,7 @@ export function defaultDirs(): AgentDirs {
|
|
|
50
52
|
bundledSkills: path.join(packageRoot, "skills"),
|
|
51
53
|
userAgents: path.join(getAgentDir(), "agents"),
|
|
52
54
|
userSkills: path.join(getAgentDir(), "skills"),
|
|
55
|
+
sessions: path.join(getAgentDir(), "subagent-sessions"),
|
|
53
56
|
};
|
|
54
57
|
}
|
|
55
58
|
|
|
@@ -56,6 +56,14 @@ describe("child arguments", () => {
|
|
|
56
56
|
it("does not pass the parent's thinking level to an agent that pins its own model", () => {
|
|
57
57
|
const pinned: AgentConfig = { ...agent, model: "m" };
|
|
58
58
|
expect(buildChildArgs({ agent: pinned, task: "t", promptFile: null, defaults: { thinkingLevel: "high" } })).not.toContain("--thinking");
|
|
59
|
+
const saved = buildChildArgs({ agent: pinned, task: "t", promptFile: null, defaults: {}, session: { dir: "/s/x", continue: true } });
|
|
60
|
+
expect(saved.slice(0, 6)).toEqual(["--mode", "json", "-p", "--session-dir", "/s/x", "--continue"]);
|
|
61
|
+
expect(saved).not.toContain("--no-session");
|
|
62
|
+
const exact = buildChildArgs({ agent: pinned, task: "t", promptFile: null, defaults: {}, session: { dir: "/s/x", id: "dns-1", continue: true } });
|
|
63
|
+
expect(exact.slice(3, 7)).toEqual(["--session-dir", "/s/x", "--session-id", "dns-1"]);
|
|
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"]);
|
|
59
67
|
});
|
|
60
68
|
|
|
61
69
|
it("inlines skills after the agent prompt", () => {
|
|
@@ -71,16 +71,35 @@ export function composeSystemPrompt(agent: AgentConfig, skills: ResolvedSkill[],
|
|
|
71
71
|
return parts.filter((p) => p.length > 0).join("\n\n");
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Where the child keeps its conversation. Without one the child is ephemeral.
|
|
76
|
+
* `id` opens that exact session (created when missing); otherwise `continue`
|
|
77
|
+
* resumes the most recent session in `dir` started from the same working directory.
|
|
78
|
+
*/
|
|
79
|
+
export interface ChildSession {
|
|
80
|
+
dir: string;
|
|
81
|
+
continue?: boolean;
|
|
82
|
+
id?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
export interface ChildArgsInput {
|
|
75
86
|
agent: AgentConfig;
|
|
76
87
|
task: string;
|
|
77
88
|
promptFile: string | null;
|
|
78
89
|
defaults: DispatchDefaults;
|
|
90
|
+
session?: ChildSession;
|
|
91
|
+
/** Local files passed as `@file` prompt inputs; Pi attaches images by content type. */
|
|
92
|
+
files?: string[];
|
|
79
93
|
}
|
|
80
94
|
|
|
81
|
-
/** Arguments for the child `pi` process: print mode, JSON events,
|
|
82
|
-
export function buildChildArgs({ agent, task, promptFile, defaults }: ChildArgsInput): string[] {
|
|
83
|
-
const args = ["--mode", "json", "-p"
|
|
95
|
+
/** Arguments for the child `pi` process: print mode, JSON events, a session file only when asked. */
|
|
96
|
+
export function buildChildArgs({ agent, task, promptFile, defaults, session, files }: ChildArgsInput): string[] {
|
|
97
|
+
const args = ["--mode", "json", "-p"];
|
|
98
|
+
if (session) {
|
|
99
|
+
args.push("--session-dir", session.dir);
|
|
100
|
+
if (session.id) args.push("--session-id", session.id);
|
|
101
|
+
else if (session.continue) args.push("--continue");
|
|
102
|
+
} else args.push("--no-session");
|
|
84
103
|
const model = agent.model ?? defaults.model;
|
|
85
104
|
if (model) args.push("--model", model);
|
|
86
105
|
const thinking = agent.thinking ?? (agent.model ? undefined : defaults.thinkingLevel);
|
|
@@ -89,7 +108,7 @@ export function buildChildArgs({ agent, task, promptFile, defaults }: ChildArgsI
|
|
|
89
108
|
if (!agent.inheritProjectContext) args.push("--no-context-files");
|
|
90
109
|
if (!agent.inheritSkills) args.push("--no-skills");
|
|
91
110
|
if (promptFile) args.push(agent.systemPromptMode === "append" ? "--append-system-prompt" : "--system-prompt", promptFile);
|
|
92
|
-
args.push("--", `Task: ${task}`);
|
|
111
|
+
args.push("--", ...(files ?? []).map((file) => `@${file}`), `Task: ${task}`);
|
|
93
112
|
return args;
|
|
94
113
|
}
|
|
95
114
|
|
|
@@ -182,6 +201,10 @@ export interface RunOptions {
|
|
|
182
201
|
env?: Record<string, string>;
|
|
183
202
|
/** Text appended to the system prompt, for example channel rules. */
|
|
184
203
|
extraSystemPrompt?: string;
|
|
204
|
+
/** Saved conversation to continue or start; omitted for a one-off run. */
|
|
205
|
+
session?: ChildSession;
|
|
206
|
+
/** Local files (images) attached to the task. */
|
|
207
|
+
files?: string[];
|
|
185
208
|
}
|
|
186
209
|
|
|
187
210
|
export async function runAgent(options: RunOptions): Promise<RunResult> {
|
|
@@ -206,7 +229,8 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
|
|
|
206
229
|
await fs.promises.writeFile(promptFile, prompt, { encoding: "utf-8", mode: 0o600 });
|
|
207
230
|
}
|
|
208
231
|
try {
|
|
209
|
-
|
|
232
|
+
if (options.session) await fs.promises.mkdir(options.session.dir, { recursive: true, mode: 0o700 });
|
|
233
|
+
const args = buildChildArgs({ agent, task, promptFile, defaults: options.defaults, session: options.session, files: options.files });
|
|
210
234
|
const invocation = (options.invocation ?? piInvocation)(args);
|
|
211
235
|
const env = buildChildEnv(agent, options.depth, options.remaining, { ...process.env, ...options.env });
|
|
212
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
|
|
|
@@ -46,6 +50,7 @@ describe("astro-subagents extension", () => {
|
|
|
46
50
|
bundledSkills: join(root, "pkg", "skills"),
|
|
47
51
|
userAgents: join(root, "user", "agents"),
|
|
48
52
|
userSkills: join(root, "user", "skills"),
|
|
53
|
+
sessions: join(root, "sessions"),
|
|
49
54
|
};
|
|
50
55
|
for (const d of Object.values(dirs)) mkdirSync(d, { recursive: true });
|
|
51
56
|
writeFileSync(join(dirs.bundledAgents, "arcane.md"), "---\nname: arcane\ndescription: Operates Arcane\n---\nB");
|
|
@@ -77,6 +82,9 @@ describe("astro-subagents extension", () => {
|
|
|
77
82
|
const ctx = { ui: { notify: vi.fn(), setStatus }, hasUI: true, cwd: root, model: { provider: "openai-codex", id: "gpt-6-astra" } };
|
|
78
83
|
await pi.commands.get("run")?.handler("astro.nobody -- x", ctx);
|
|
79
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");
|
|
80
88
|
expect(pi.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "astro-subagents", content: expect.stringContaining("Unknown agent") }), { triggerTurn: false });
|
|
81
89
|
});
|
|
82
90
|
|
|
@@ -92,8 +100,11 @@ describe("astro-subagents extension", () => {
|
|
|
92
100
|
});
|
|
93
101
|
|
|
94
102
|
it("parses /run arguments", () => {
|
|
95
|
-
expect(parseRunCommand("astro.arcane -- list projects")).toEqual({ agent: "astro.arcane", task: "list projects" });
|
|
96
|
-
expect(parseRunCommand("scout find auth code")).toEqual({ agent: "scout", task: "find auth code" });
|
|
103
|
+
expect(parseRunCommand("astro.arcane -- list projects")).toEqual({ agent: "astro.arcane", task: "list projects", continue: false });
|
|
104
|
+
expect(parseRunCommand("scout find auth code")).toEqual({ agent: "scout", task: "find auth code", continue: false });
|
|
105
|
+
expect(parseRunCommand("astro.security --continue -- do the next step")).toEqual({ agent: "astro.security", task: "do the next step", continue: true });
|
|
106
|
+
expect(parseRunCommand("astro.security --continue next")).toEqual({ agent: "astro.security", task: "next", continue: true });
|
|
107
|
+
expect(parseRunCommand("scout --continue")).toBeNull();
|
|
97
108
|
expect(parseRunCommand("scout")).toBeNull();
|
|
98
109
|
expect(pruneLegacyCopies(join(root, "missing"))).toEqual([]);
|
|
99
110
|
});
|
|
@@ -3,9 +3,11 @@ 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
|
-
import { childRemaining, currentDepth, type DispatchDefaults, finalOutput, isFailed, resultOutput, type RunResult, runAgent } from "./child.ts";
|
|
9
|
+
import { type ChildSession, childRemaining, currentDepth, type DispatchDefaults, finalOutput, isFailed, resultOutput, type RunResult, runAgent } from "./child.ts";
|
|
10
|
+
import { pruneSessions, sessionDirFor } from "./sessions.ts";
|
|
9
11
|
import { renderCall, renderResult, type SubagentDetails } from "./render.ts";
|
|
10
12
|
import { startTicker } from "./ticker.ts";
|
|
11
13
|
|
|
@@ -77,11 +79,25 @@ export function pruneLegacyCopies(userAgentsDir: string): string[] {
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
/** Parses `/run <agent> -- <task>` (the `--` is optional when the task has no leading dash). */
|
|
80
|
-
|
|
82
|
+
/** `/run <agent> [--continue] [--] <task>`; `--continue` resumes the agent's last saved session from this directory. */
|
|
83
|
+
export function parseRunCommand(input: string): { agent: string; task: string; continue: boolean } | null {
|
|
81
84
|
const trimmed = input.trim();
|
|
82
|
-
const match = /^(\S+)\s+(?:--\s+)?([\s\S]+)$/.exec(trimmed);
|
|
85
|
+
const match = /^(\S+)(\s+--continue)?\s+(?:--\s+)?([\s\S]+)$/.exec(trimmed);
|
|
83
86
|
if (!match) return null;
|
|
84
|
-
|
|
87
|
+
const task = match[3].trim();
|
|
88
|
+
if (task === "--continue") return null;
|
|
89
|
+
return { agent: match[1], task, continue: match[2] !== undefined };
|
|
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}`;
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
export interface GateOptions {
|
|
@@ -105,6 +121,7 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
105
121
|
step: number | undefined,
|
|
106
122
|
signal: AbortSignal | undefined,
|
|
107
123
|
onUpdate: ((r: RunResult) => void) | undefined,
|
|
124
|
+
session?: ChildSession,
|
|
108
125
|
): Promise<RunResult> {
|
|
109
126
|
const agent = agents.find((a) => a.name === agentName);
|
|
110
127
|
if (!agent) {
|
|
@@ -136,6 +153,7 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
136
153
|
step,
|
|
137
154
|
signal,
|
|
138
155
|
onUpdate,
|
|
156
|
+
session,
|
|
139
157
|
});
|
|
140
158
|
}
|
|
141
159
|
|
|
@@ -259,8 +277,16 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
259
277
|
return { content: [{ type: "text", text: finalOutput(result.messages) || "(no output)" }], details: details("single", [result]) };
|
|
260
278
|
}
|
|
261
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
|
+
|
|
262
288
|
pi.registerCommand("run", {
|
|
263
|
-
description: "Run an agent
|
|
289
|
+
description: "Run an agent: /run <agent> [--continue] -- <task>",
|
|
264
290
|
handler: async (args, ctx) => {
|
|
265
291
|
if (!canDelegate) {
|
|
266
292
|
ctx.ui.notify("subagents: this session may not delegate further (depth limit)", "warning");
|
|
@@ -268,18 +294,22 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
268
294
|
}
|
|
269
295
|
const parsed = parseRunCommand(args);
|
|
270
296
|
if (!parsed) {
|
|
271
|
-
ctx.ui.notify("usage: /run <agent> -- <task>", "warning");
|
|
297
|
+
ctx.ui.notify("usage: /run <agent> [--continue] -- <task>", "warning");
|
|
272
298
|
return;
|
|
273
299
|
}
|
|
274
300
|
const agents = listAgents(ctx.cwd, "user").agents;
|
|
301
|
+
pi.appendEntry<RunEntry>("astro-subagents-run", { agent: parsed.agent, task: parsed.task, continue: parsed.continue });
|
|
302
|
+
// Every /run saves its session so a later --continue can pick the conversation up.
|
|
303
|
+
const session: ChildSession = { dir: sessionDirFor(dirs.sessions, parsed.agent), continue: parsed.continue };
|
|
304
|
+
pruneSessions(session.dir);
|
|
275
305
|
const stopTicker = startTicker(ctx, runLabel(ctx, agents, parsed.agent));
|
|
276
306
|
let result: RunResult;
|
|
277
307
|
try {
|
|
278
|
-
result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined);
|
|
308
|
+
result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined, session);
|
|
279
309
|
} finally {
|
|
280
310
|
stopTicker();
|
|
281
311
|
}
|
|
282
|
-
const status = isFailed(result) ? "failed" : "done"
|
|
312
|
+
const status = `${isFailed(result) ? "failed" : "done"}${parsed.continue ? ", continued" : ""}`;
|
|
283
313
|
if (isFailed(result)) ctx.ui.notify(`${parsed.agent} failed`, "warning");
|
|
284
314
|
// No turn: the result joins the context for the next prompt without the parent model restating it.
|
|
285
315
|
pi.sendMessage({ customType: "astro-subagents", content: `Result from /run ${parsed.agent} (${status}). Task: ${parsed.task}\n\n${resultOutput(result)}`, display: true }, { triggerTurn: false });
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { pruneSessions, sessionDirFor } from "./sessions.ts";
|
|
6
|
+
|
|
7
|
+
describe("child sessions", () => {
|
|
8
|
+
let root: string;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
root = mkdtempSync(join(tmpdir(), "astro-sessions-"));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
13
|
+
|
|
14
|
+
it("names one directory per agent", () => {
|
|
15
|
+
expect(sessionDirFor("/s", "astro.security")).toBe("/s/astro.security");
|
|
16
|
+
expect(sessionDirFor("/s", "a b/c")).toBe("/s/a_b_c");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("prunes old session files and leaves recent ones and other files", () => {
|
|
20
|
+
const now = 1_800_000_000_000;
|
|
21
|
+
const old = join(root, "old.jsonl");
|
|
22
|
+
const fresh = join(root, "fresh.jsonl");
|
|
23
|
+
const other = join(root, "notes.txt");
|
|
24
|
+
for (const f of [old, fresh, other]) writeFileSync(f, "x");
|
|
25
|
+
utimesSync(old, new Date(now - 8 * 86_400_000), new Date(now - 8 * 86_400_000));
|
|
26
|
+
utimesSync(other, new Date(now - 8 * 86_400_000), new Date(now - 8 * 86_400_000));
|
|
27
|
+
utimesSync(fresh, new Date(now - 60_000), new Date(now - 60_000));
|
|
28
|
+
expect(pruneSessions(root, 7 * 86_400_000, now)).toBe(1);
|
|
29
|
+
expect(readdirSync(root).sort()).toEqual(["fresh.jsonl", "notes.txt"]);
|
|
30
|
+
expect(pruneSessions(join(root, "missing"), 1, now)).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Saved child sessions older than this are deleted before a run. */
|
|
5
|
+
export const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
/** Directory holding one agent's saved child sessions. */
|
|
8
|
+
export function sessionDirFor(root: string, agentName: string): string {
|
|
9
|
+
return path.join(root, agentName.replace(/[^\w.-]+/g, "_"));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Deletes session files not modified within `maxAgeMs`; a missing directory holds nothing to prune. */
|
|
13
|
+
export function pruneSessions(dir: string, maxAgeMs = SESSION_MAX_AGE_MS, now = Date.now()): number {
|
|
14
|
+
let names: string[];
|
|
15
|
+
try {
|
|
16
|
+
names = fs.readdirSync(dir);
|
|
17
|
+
} catch {
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
let removed = 0;
|
|
21
|
+
for (const name of names) {
|
|
22
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
23
|
+
const file = path.join(dir, name);
|
|
24
|
+
try {
|
|
25
|
+
if (now - fs.statSync(file).mtimeMs > maxAgeMs) {
|
|
26
|
+
fs.unlinkSync(file);
|
|
27
|
+
removed++;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// The file vanished between readdir and stat; nothing left to prune.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return removed;
|
|
34
|
+
}
|
package/package.json
CHANGED
package/specialists/README.md
CHANGED
|
@@ -14,7 +14,7 @@ A specialist is a Pi subagent that is the only way to operate one area of the ho
|
|
|
14
14
|
| `astro.proxmox` | Proxmox VE on Pulsar | API token: `guests`, `guest`, `get <path>`, `tasks`; `start`, `shutdown|reboot|stop --confirm`; snapshots create, delete, rollback; `set` of CPU, memory, options, network. Pulsar key: `host-status`, `host-journal`, `updates`, `guest-exec <vmid> <status|journal|df|updates>` |
|
|
15
15
|
| `astro.inference` | llama.cpp on Nexus, Hermes on LXC 101, Europa health | `nexus-status|start|stop|restart|log|disk`; `hermes-status|journal|errors|restart|api-health|version|guest-status|snapshots`, `hermes-releases`, `hermes-upgrade --confirm`; `europa-health` |
|
|
16
16
|
|
|
17
|
-
Call one with `/run astro.<name> -- <task>` or through the `subagent` tool (both from the bundled `astro-subagents` extension). Specialists may call each other once (`maxSubagentDepth: 1`). They run on `openai-codex/gpt-6-astra` with `xhigh` thinking, set by `model` and `thinking` in each `agents/<name>.md`; an agent without these fields inherits the caller's model and thinking. Each tool takes `args`, an array of strings; `["--help"]` lists the subcommands.
|
|
17
|
+
Call one with `/run astro.<name> -- <task>` or through the `subagent` tool (both from the bundled `astro-subagents` extension). `/run` saves the specialist's session under `~/.pi/agent/subagent-sessions/<agent>/`; `/run astro.<name> --continue -- <task>` resumes the last one started from the same directory, so the specialist keeps what it saw. Sessions older than 7 days are deleted. Specialists may call each other once (`maxSubagentDepth: 1`). They run on `openai-codex/gpt-6-astra` with `xhigh` thinking, set by `model` and `thinking` in each `agents/<name>.md`; an agent without these fields inherits the caller's model and thinking. Each tool takes `args`, an array of strings; `["--help"]` lists the subcommands.
|
|
18
18
|
|
|
19
19
|
## How it is secured
|
|
20
20
|
|
|
@@ -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). 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`.
|