@astrofoundry/pi-astro 0.23.1 → 0.24.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/commands.ts +11 -6
- package/extensions/astro-discord/conversations.ts +115 -0
- package/extensions/astro-discord/discord.test.ts +61 -8
- package/extensions/astro-discord/index.ts +54 -13
- 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 +6 -0
- package/extensions/astro-subagents/child.ts +24 -4
- package/extensions/astro-subagents/index.test.ts +16 -2
- package/extensions/astro-subagents/index.ts +33 -14
- 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
|
|
@@ -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,8 @@ 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";
|
|
14
16
|
|
|
15
17
|
const ID = "123456789012345678";
|
|
16
18
|
const OTHER = "223456789012345678";
|
|
@@ -89,20 +91,24 @@ describe("commands", () => {
|
|
|
89
91
|
const many = { botUserId: ID, specialists: ["dns", "edge"], trigger: "always" as const, repliedToBot: false };
|
|
90
92
|
const one = { ...many, specialists: ["arcane"] };
|
|
91
93
|
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" });
|
|
94
|
+
expect(parseCommand("dns list the zones", many)).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: false });
|
|
95
|
+
expect(parseCommand(`<@${ID}> edge probe the front door`, many)).toEqual({ kind: "run", specialist: "edge", task: "probe the front door", fresh: false });
|
|
96
|
+
expect(parseCommand("!astro.dns zones", many)).toEqual({ kind: "run", specialist: "dns", task: "zones", fresh: false });
|
|
95
97
|
expect(parseCommand("help", many)).toEqual({ kind: "help" });
|
|
96
98
|
expect(parseCommand("Status", many)).toEqual({ kind: "status" });
|
|
97
99
|
expect(parseCommand("config channel <#1> mention dns", many)).toEqual({ kind: "config", args: ["channel", "<#1>", "mention", "dns"] });
|
|
98
100
|
expect(parseCommand("dns", many)).toEqual({ kind: "help" });
|
|
101
|
+
expect(parseCommand("dns new list the zones", many)).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: true });
|
|
102
|
+
expect(parseCommand("New what changed?", one)).toEqual({ kind: "run", specialist: "arcane", task: "what changed?", fresh: true });
|
|
103
|
+
expect(parseCommand("newer images?", one)).toEqual({ kind: "run", specialist: "arcane", task: "newer images?", fresh: false });
|
|
104
|
+
expect(parseCommand("dns new", many)).toEqual({ kind: "help" });
|
|
99
105
|
expect(parseCommand("hello everyone", many)).toEqual({ kind: "none" });
|
|
100
106
|
expect(parseCommand("", many)).toEqual({ kind: "none" });
|
|
101
107
|
});
|
|
102
108
|
|
|
103
109
|
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" });
|
|
110
|
+
expect(parseCommand("list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects", fresh: false });
|
|
111
|
+
expect(parseCommand("arcane list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects", fresh: false });
|
|
106
112
|
expect(parseCommand(`<@${ID}> list the zones`, many)).toEqual({ kind: "needs-prefix", specialists: ["dns", "edge"] });
|
|
107
113
|
expect(parseCommand("list the zones", { ...many, repliedToBot: true })).toEqual({ kind: "needs-prefix", specialists: ["dns", "edge"] });
|
|
108
114
|
expect(parseCommand(`<@${ID}>`, many)).toEqual({ kind: "help" });
|
|
@@ -111,10 +117,10 @@ describe("commands", () => {
|
|
|
111
117
|
it("stays silent in mention channels unless addressed", () => {
|
|
112
118
|
const mention = { ...many, trigger: "mention" as const };
|
|
113
119
|
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" });
|
|
120
|
+
expect(parseCommand(`<@${ID}> dns list the zones`, mention)).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: false });
|
|
121
|
+
expect(parseCommand("dns list the zones", { ...mention, repliedToBot: true })).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: false });
|
|
116
122
|
expect(parseCommand("anything", { ...one, trigger: "mention" })).toEqual({ kind: "none" });
|
|
117
|
-
expect(parseCommand(`<@${ID}> anything`, { ...one, trigger: "mention" })).toEqual({ kind: "run", specialist: "arcane", task: "anything" });
|
|
123
|
+
expect(parseCommand(`<@${ID}> anything`, { ...one, trigger: "mention" })).toEqual({ kind: "run", specialist: "arcane", task: "anything", fresh: false });
|
|
118
124
|
});
|
|
119
125
|
|
|
120
126
|
it("writes help and prefix texts", () => {
|
|
@@ -236,3 +242,50 @@ describe("approval server", () => {
|
|
|
236
242
|
server.close();
|
|
237
243
|
});
|
|
238
244
|
});
|
|
245
|
+
|
|
246
|
+
describe("conversations", () => {
|
|
247
|
+
const now = 1_800_000_000_000;
|
|
248
|
+
it("continues on a reply or within the idle window, otherwise starts fresh", () => {
|
|
249
|
+
let state = startConversation({ conversations: {} }, "1:dns", "dns-a", now);
|
|
250
|
+
state = recordAnswer(state, "1:dns", ["m1"], now);
|
|
251
|
+
const input = { channelId: "1", specialist: "dns", fresh: false, now: now + 60_000 };
|
|
252
|
+
expect(resolveConversation(state, input, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-a", resumed: true });
|
|
253
|
+
expect(resolveConversation(state, { ...input, now: now + CONVERSATION_IDLE_MS + 1 }, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-b", resumed: false });
|
|
254
|
+
expect(resolveConversation(state, { ...input, now: now + CONVERSATION_IDLE_MS + 1, repliedTo: "m1" }, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-a", resumed: true });
|
|
255
|
+
expect(resolveConversation(state, { ...input, fresh: true }, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-b", resumed: false });
|
|
256
|
+
expect(resolveConversation(state, { ...input, channelId: "2" }, () => "dns-b").resumed).toBe(false);
|
|
257
|
+
expect(recordAnswer(state, "missing", ["x"], now)).toBe(state);
|
|
258
|
+
expect(forgetStale(state, now + CONVERSATION_MAX_AGE_MS + 1).conversations).toEqual({});
|
|
259
|
+
expect(forgetStale(state, now + 1).conversations["1:dns"]).toBeDefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("builds Pi-compatible ids and round-trips the state file", () => {
|
|
263
|
+
const id = newConversationId("security", Date.UTC(2026, 8, 16, 20, 55, 0), () => "abc123");
|
|
264
|
+
expect(id).toBe("security-20260916205500-abc123");
|
|
265
|
+
expect(id).toMatch(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/);
|
|
266
|
+
const dir = mkdtempSync(join(tmpdir(), "astro-conv-"));
|
|
267
|
+
process.env.ASTRO_DISCORD_DIR = dir;
|
|
268
|
+
try {
|
|
269
|
+
expect(loadState()).toEqual({ conversations: {} });
|
|
270
|
+
const state = recordAnswer(startConversation({ conversations: {} }, "1:dns", id, now), "1:dns", ["m1"], now);
|
|
271
|
+
saveState(state);
|
|
272
|
+
expect(loadState()).toEqual(state);
|
|
273
|
+
writeFileSync(join(dir, "conversations.json"), JSON.stringify({ conversations: { bad: { id: 1 }, ok: { id: "x", lastAt: 1, messageIds: [] } } }));
|
|
274
|
+
expect(Object.keys(loadState().conversations)).toEqual(["ok"]);
|
|
275
|
+
} finally {
|
|
276
|
+
delete process.env.ASTRO_DISCORD_DIR;
|
|
277
|
+
rmSync(dir, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("quotes", () => {
|
|
283
|
+
it("flattens content, embeds, and attachments and appends them to the task", () => {
|
|
284
|
+
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" }] };
|
|
285
|
+
expect(quoteText(alert)).toBe("Wazuh alert, level 12\nSystem running out of memory.\nagent: arcane\nrule 5108\nAttachments: log.txt");
|
|
286
|
+
expect(quoteText({ content: "x".repeat(5000) })).toHaveLength(4001);
|
|
287
|
+
expect(withQuotes("check this", [])).toBe("check this");
|
|
288
|
+
expect(withQuotes("check this", [{ label: "Forwarded message", message: { content: "" } }])).toBe("check this");
|
|
289
|
+
expect(withQuotes("check this", [{ label: "Forwarded message", message: alert }])).toMatch(/^check this\n\nForwarded message:\nWazuh alert/);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -2,13 +2,16 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { loadConfig as loadSpecialists } from "../specialist-gate/config.ts";
|
|
3
3
|
import { APPROVAL_TOKEN_ENV, APPROVAL_URL_ENV } from "../specialist-gate/index.ts";
|
|
4
4
|
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";
|
|
5
|
+
import { AGENT_ENV, type ChildSession, type DispatchDefaults, type RunResult, childRemaining, currentDepth, finalOutput, isFailed, runAgent } from "../astro-subagents/child.ts";
|
|
6
|
+
import { pruneSessions, sessionDirFor } from "../astro-subagents/sessions.ts";
|
|
6
7
|
import { ApprovalServer, type Ticket } from "./approvals.ts";
|
|
7
8
|
import { chunkMessage } from "./chunk.ts";
|
|
8
9
|
import { applyAdminCommand } from "./admin.ts";
|
|
9
10
|
import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./commands.ts";
|
|
10
11
|
import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, saveConfig } from "./config.ts";
|
|
12
|
+
import { type ConversationState, type ResolvedConversation, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, sessionsRoot, startConversation } from "./conversations.ts";
|
|
11
13
|
import { GatewayClient } from "./gateway.ts";
|
|
14
|
+
import { type Quote, type QuotedMessage, withQuotes } from "./quote.ts";
|
|
12
15
|
import { DiscordRest, type MessageComponent } from "./rest.ts";
|
|
13
16
|
|
|
14
17
|
const REACTION = { running: "⏳", waiting: "🔒", done: "✅", failed: "❌" } as const;
|
|
@@ -55,7 +58,10 @@ interface IncomingMessage {
|
|
|
55
58
|
guild_id?: string;
|
|
56
59
|
content?: string;
|
|
57
60
|
author?: { id: string; bot?: boolean };
|
|
58
|
-
|
|
61
|
+
/** The message this one replies to; Discord resolves it on every reply event. */
|
|
62
|
+
referenced_message?: QuotedMessage | null;
|
|
63
|
+
/** Copies of forwarded messages, without their authors. */
|
|
64
|
+
message_snapshots?: { message: QuotedMessage }[];
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
interface ComponentInteraction {
|
|
@@ -75,6 +81,7 @@ interface ActiveTask {
|
|
|
75
81
|
messageId: string;
|
|
76
82
|
userId: string;
|
|
77
83
|
startedAt: number;
|
|
84
|
+
conversation: ResolvedConversation;
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
function log(line: string): void {
|
|
@@ -105,6 +112,7 @@ class Bridge {
|
|
|
105
112
|
private readonly active = new Map<string, ActiveTask>();
|
|
106
113
|
private readonly approvalMessages = new Map<string, { channelId: string; messageId: string; userMessage: ActiveTask }>();
|
|
107
114
|
private config: DiscordConfig;
|
|
115
|
+
private conversations: ConversationState;
|
|
108
116
|
private botUserId = "";
|
|
109
117
|
/** Thread id to parent channel id; null marks a plain channel. */
|
|
110
118
|
private readonly parents = new Map<string, string | null>();
|
|
@@ -115,6 +123,7 @@ class Bridge {
|
|
|
115
123
|
this.ctx = ctx;
|
|
116
124
|
this.token = token;
|
|
117
125
|
this.config = config;
|
|
126
|
+
this.conversations = loadState();
|
|
118
127
|
this.rest = new DiscordRest(token);
|
|
119
128
|
this.approvals = new ApprovalServer({ onTicket: (t) => void this.askApproval(t), onExpired: (t) => void this.expireApproval(t) }, config.approvalTimeoutMinutes * 60_000);
|
|
120
129
|
}
|
|
@@ -220,12 +229,35 @@ class Bridge {
|
|
|
220
229
|
if (!canUse(this.config, userId, command.specialist)) return;
|
|
221
230
|
const agent = agents.find((a) => a.name === `astro.${command.specialist}`);
|
|
222
231
|
if (!agent) return;
|
|
223
|
-
const
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
this.conversations = forgetStale(this.conversations, now);
|
|
234
|
+
const conversation = resolveConversation(
|
|
235
|
+
this.conversations,
|
|
236
|
+
{ channelId: message.channel_id, specialist: command.specialist, repliedTo: message.referenced_message?.id, fresh: command.fresh, now },
|
|
237
|
+
() => newConversationId(command.specialist, now),
|
|
238
|
+
);
|
|
239
|
+
if (!conversation.resumed) this.conversations = startConversation(this.conversations, conversation.key, conversation.id, now);
|
|
240
|
+
saveState(this.conversations);
|
|
241
|
+
const task: ActiveTask = { specialist: command.specialist, channelId: message.channel_id, messageId: message.id, userId, startedAt: now, conversation };
|
|
242
|
+
const taskText = withQuotes(command.task, this.quotesFor(message, conversation.resumed));
|
|
224
243
|
const previous = this.queues.get(command.specialist) ?? Promise.resolve();
|
|
225
|
-
const next = previous.then(() => this.runTask(agent,
|
|
244
|
+
const next = previous.then(() => this.runTask(agent, taskText, task)).catch((err) => log(`run failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
226
245
|
this.queues.set(command.specialist, next);
|
|
227
246
|
}
|
|
228
247
|
|
|
248
|
+
/** Replied-to and forwarded messages to show the specialist; the bot's own answer only when the conversation holding it is not resumed. */
|
|
249
|
+
private quotesFor(message: IncomingMessage, resumed: boolean): Quote[] {
|
|
250
|
+
const quotes: Quote[] = [];
|
|
251
|
+
const replied = message.referenced_message;
|
|
252
|
+
if (replied) {
|
|
253
|
+
const mine = replied.author?.id === this.botUserId;
|
|
254
|
+
if (!mine) quotes.push({ label: `Replied-to message from ${replied.author?.username ?? replied.author?.id ?? "an unknown author"}`, message: replied });
|
|
255
|
+
else if (!resumed) quotes.push({ label: "My earlier answer, which this message replies to", message: replied });
|
|
256
|
+
}
|
|
257
|
+
for (const snapshot of message.message_snapshots ?? []) quotes.push({ label: "Forwarded message", message: snapshot.message });
|
|
258
|
+
return quotes;
|
|
259
|
+
}
|
|
260
|
+
|
|
229
261
|
private async runTask(agent: AgentConfig, taskText: string, task: ActiveTask): Promise<void> {
|
|
230
262
|
task.startedAt = Date.now();
|
|
231
263
|
this.active.set(task.specialist, task);
|
|
@@ -238,7 +270,9 @@ class Bridge {
|
|
|
238
270
|
await this.rest.triggerTyping(task.channelId).catch(() => undefined);
|
|
239
271
|
const typing = setInterval(() => void this.rest.triggerTyping(task.channelId).catch(() => undefined), 8000);
|
|
240
272
|
typing.unref();
|
|
241
|
-
log(`run astro.${task.specialist} for ${task.userId}: ${taskText.slice(0, 200)}`);
|
|
273
|
+
log(`run astro.${task.specialist} for ${task.userId} (${task.conversation.resumed ? "continuing" : "new conversation"} ${task.conversation.id}): ${taskText.slice(0, 200)}`);
|
|
274
|
+
const session: ChildSession = { dir: sessionDirFor(sessionsRoot(), task.specialist), id: task.conversation.id };
|
|
275
|
+
pruneSessions(session.dir);
|
|
242
276
|
const dirs = defaultDirs();
|
|
243
277
|
const { skills, missing } = resolveSkills(agent.skills, dirs);
|
|
244
278
|
if (missing.length > 0) log(`${agent.name}: skill(s) not found: ${missing.join(", ")}`);
|
|
@@ -263,6 +297,7 @@ class Bridge {
|
|
|
263
297
|
// The child must not become a second Discord bridge.
|
|
264
298
|
env: { [ACTIVATION_ENV]: "0", [APPROVAL_URL_ENV]: this.approvals.url, [APPROVAL_TOKEN_ENV]: this.approvals.token, ASTRO_CHANNEL: "discord" },
|
|
265
299
|
extraSystemPrompt: CHANNEL_RULES,
|
|
300
|
+
session,
|
|
266
301
|
onUpdate: (partial) => {
|
|
267
302
|
const progress = describeProgress(partial, seen);
|
|
268
303
|
seen = progress.seen;
|
|
@@ -286,26 +321,32 @@ class Bridge {
|
|
|
286
321
|
}
|
|
287
322
|
await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
|
|
288
323
|
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);
|
|
324
|
+
const answers = await this.deliver(task, placeholder, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`, attachment);
|
|
325
|
+
this.conversations = recordAnswer(this.conversations, task.conversation.key, answers, Date.now());
|
|
326
|
+
saveState(this.conversations);
|
|
290
327
|
}
|
|
291
328
|
|
|
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<
|
|
329
|
+
/** 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. */
|
|
330
|
+
private async deliver(task: ActiveTask, placeholder: string | undefined, text: string, attachment?: string): Promise<string[]> {
|
|
294
331
|
const oversized = attachment !== undefined || text.length > ATTACHMENT_THRESHOLD;
|
|
295
332
|
if (oversized) {
|
|
296
333
|
if (placeholder) await this.rest.deleteMessage(task.channelId, placeholder).catch(() => undefined);
|
|
297
334
|
const file = attachment !== undefined ? { name: `astro-${task.specialist}-stderr-${Date.now()}.txt`, content: attachment } : { name: `astro-${task.specialist}-${Date.now()}.md`, content: text };
|
|
298
335
|
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;
|
|
336
|
+
const posted = await this.rest.createMessage(task.channelId, { content, replyTo: task.messageId, file });
|
|
337
|
+
return [posted.id];
|
|
301
338
|
}
|
|
302
339
|
const chunks = chunkMessage(text);
|
|
303
340
|
if (chunks.length === 0) chunks.push(text);
|
|
304
|
-
|
|
305
|
-
|
|
341
|
+
const ids: string[] = [];
|
|
342
|
+
if (placeholder) {
|
|
343
|
+
await this.rest.editMessage(task.channelId, placeholder, chunks[0]).catch(() => undefined);
|
|
344
|
+
ids.push(placeholder);
|
|
345
|
+
} else ids.push((await this.rest.createMessage(task.channelId, { content: chunks[0], replyTo: task.messageId })).id);
|
|
306
346
|
for (let i = 1; i < chunks.length; i++) {
|
|
307
|
-
await this.rest.createMessage(task.channelId, { content: chunks[i] });
|
|
347
|
+
ids.push((await this.rest.createMessage(task.channelId, { content: chunks[i] })).id);
|
|
308
348
|
}
|
|
349
|
+
return ids;
|
|
309
350
|
}
|
|
310
351
|
|
|
311
352
|
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 }[];
|
|
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,12 @@ 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");
|
|
59
65
|
});
|
|
60
66
|
|
|
61
67
|
it("inlines skills after the agent prompt", () => {
|
|
@@ -71,16 +71,33 @@ 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;
|
|
79
91
|
}
|
|
80
92
|
|
|
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"
|
|
93
|
+
/** 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[] {
|
|
95
|
+
const args = ["--mode", "json", "-p"];
|
|
96
|
+
if (session) {
|
|
97
|
+
args.push("--session-dir", session.dir);
|
|
98
|
+
if (session.id) args.push("--session-id", session.id);
|
|
99
|
+
else if (session.continue) args.push("--continue");
|
|
100
|
+
} else args.push("--no-session");
|
|
84
101
|
const model = agent.model ?? defaults.model;
|
|
85
102
|
if (model) args.push("--model", model);
|
|
86
103
|
const thinking = agent.thinking ?? (agent.model ? undefined : defaults.thinkingLevel);
|
|
@@ -182,6 +199,8 @@ export interface RunOptions {
|
|
|
182
199
|
env?: Record<string, string>;
|
|
183
200
|
/** Text appended to the system prompt, for example channel rules. */
|
|
184
201
|
extraSystemPrompt?: string;
|
|
202
|
+
/** Saved conversation to continue or start; omitted for a one-off run. */
|
|
203
|
+
session?: ChildSession;
|
|
185
204
|
}
|
|
186
205
|
|
|
187
206
|
export async function runAgent(options: RunOptions): Promise<RunResult> {
|
|
@@ -206,7 +225,8 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
|
|
|
206
225
|
await fs.promises.writeFile(promptFile, prompt, { encoding: "utf-8", mode: 0o600 });
|
|
207
226
|
}
|
|
208
227
|
try {
|
|
209
|
-
|
|
228
|
+
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 });
|
|
210
230
|
const invocation = (options.invocation ?? piInvocation)(args);
|
|
211
231
|
const env = buildChildEnv(agent, options.depth, options.remaining, { ...process.env, ...options.env });
|
|
212
232
|
let aborted = false;
|
|
@@ -46,6 +46,7 @@ describe("astro-subagents extension", () => {
|
|
|
46
46
|
bundledSkills: join(root, "pkg", "skills"),
|
|
47
47
|
userAgents: join(root, "user", "agents"),
|
|
48
48
|
userSkills: join(root, "user", "skills"),
|
|
49
|
+
sessions: join(root, "sessions"),
|
|
49
50
|
};
|
|
50
51
|
for (const d of Object.values(dirs)) mkdirSync(d, { recursive: true });
|
|
51
52
|
writeFileSync(join(dirs.bundledAgents, "arcane.md"), "---\nname: arcane\ndescription: Operates Arcane\n---\nB");
|
|
@@ -70,6 +71,16 @@ describe("astro-subagents extension", () => {
|
|
|
70
71
|
expect(notify).toHaveBeenCalledWith(expect.stringContaining("depth limit"), "warning");
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
it("posts the /run result without a model turn and names the model in the footer", async () => {
|
|
75
|
+
const pi = makePi();
|
|
76
|
+
astroSubagents(pi as unknown as Parameters<typeof astroSubagents>[0], { dirs, env: {} });
|
|
77
|
+
const setStatus = vi.fn();
|
|
78
|
+
const ctx = { ui: { notify: vi.fn(), setStatus }, hasUI: true, cwd: root, model: { provider: "openai-codex", id: "gpt-6-astra" } };
|
|
79
|
+
await pi.commands.get("run")?.handler("astro.nobody -- x", ctx);
|
|
80
|
+
expect(setStatus).toHaveBeenCalledWith("subagent", expect.stringContaining("astro.nobody on openai-codex/gpt-6-astra"));
|
|
81
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "astro-subagents", content: expect.stringContaining("Unknown agent") }), { triggerTurn: false });
|
|
82
|
+
});
|
|
83
|
+
|
|
73
84
|
it("removes legacy loader copies on session start", async () => {
|
|
74
85
|
writeFileSync(join(dirs.userAgents, "astro.arcane.md"), "old copy");
|
|
75
86
|
writeFileSync(join(dirs.userAgents, "mine.md"), "---\nname: mine\ndescription: keep\n---\n");
|
|
@@ -82,8 +93,11 @@ describe("astro-subagents extension", () => {
|
|
|
82
93
|
});
|
|
83
94
|
|
|
84
95
|
it("parses /run arguments", () => {
|
|
85
|
-
expect(parseRunCommand("astro.arcane -- list projects")).toEqual({ agent: "astro.arcane", task: "list projects" });
|
|
86
|
-
expect(parseRunCommand("scout find auth code")).toEqual({ agent: "scout", task: "find auth code" });
|
|
96
|
+
expect(parseRunCommand("astro.arcane -- list projects")).toEqual({ agent: "astro.arcane", task: "list projects", continue: false });
|
|
97
|
+
expect(parseRunCommand("scout find auth code")).toEqual({ agent: "scout", task: "find auth code", continue: false });
|
|
98
|
+
expect(parseRunCommand("astro.security --continue -- do the next step")).toEqual({ agent: "astro.security", task: "do the next step", continue: true });
|
|
99
|
+
expect(parseRunCommand("astro.security --continue next")).toEqual({ agent: "astro.security", task: "next", continue: true });
|
|
100
|
+
expect(parseRunCommand("scout --continue")).toBeNull();
|
|
87
101
|
expect(parseRunCommand("scout")).toBeNull();
|
|
88
102
|
expect(pruneLegacyCopies(join(root, "missing"))).toEqual([]);
|
|
89
103
|
});
|
|
@@ -5,7 +5,8 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
5
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { type Static, Type } from "typebox";
|
|
7
7
|
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";
|
|
8
|
+
import { type ChildSession, childRemaining, currentDepth, type DispatchDefaults, finalOutput, isFailed, resultOutput, type RunResult, runAgent } from "./child.ts";
|
|
9
|
+
import { pruneSessions, sessionDirFor } from "./sessions.ts";
|
|
9
10
|
import { renderCall, renderResult, type SubagentDetails } from "./render.ts";
|
|
10
11
|
import { startTicker } from "./ticker.ts";
|
|
11
12
|
|
|
@@ -77,11 +78,14 @@ export function pruneLegacyCopies(userAgentsDir: string): string[] {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/** Parses `/run <agent> -- <task>` (the `--` is optional when the task has no leading dash). */
|
|
80
|
-
|
|
81
|
+
/** `/run <agent> [--continue] [--] <task>`; `--continue` resumes the agent's last saved session from this directory. */
|
|
82
|
+
export function parseRunCommand(input: string): { agent: string; task: string; continue: boolean } | null {
|
|
81
83
|
const trimmed = input.trim();
|
|
82
|
-
const match = /^(\S+)\s+(?:--\s+)?([\s\S]+)$/.exec(trimmed);
|
|
84
|
+
const match = /^(\S+)(\s+--continue)?\s+(?:--\s+)?([\s\S]+)$/.exec(trimmed);
|
|
83
85
|
if (!match) return null;
|
|
84
|
-
|
|
86
|
+
const task = match[3].trim();
|
|
87
|
+
if (task === "--continue") return null;
|
|
88
|
+
return { agent: match[1], task, continue: match[2] !== undefined };
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
export interface GateOptions {
|
|
@@ -105,6 +109,7 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
105
109
|
step: number | undefined,
|
|
106
110
|
signal: AbortSignal | undefined,
|
|
107
111
|
onUpdate: ((r: RunResult) => void) | undefined,
|
|
112
|
+
session?: ChildSession,
|
|
108
113
|
): Promise<RunResult> {
|
|
109
114
|
const agent = agents.find((a) => a.name === agentName);
|
|
110
115
|
if (!agent) {
|
|
@@ -136,9 +141,16 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
136
141
|
step,
|
|
137
142
|
signal,
|
|
138
143
|
onUpdate,
|
|
144
|
+
session,
|
|
139
145
|
});
|
|
140
146
|
}
|
|
141
147
|
|
|
148
|
+
/** Footer label: the agent and the model it will run on. */
|
|
149
|
+
function runLabel(ctx: ExtensionContext, agents: AgentConfig[], agentName: string): string {
|
|
150
|
+
const model = agents.find((a) => a.name === agentName)?.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
|
|
151
|
+
return model ? `${agentName} on ${model}` : agentName;
|
|
152
|
+
}
|
|
153
|
+
|
|
142
154
|
if (canDelegate) {
|
|
143
155
|
const initial = listAgents(process.cwd(), "user");
|
|
144
156
|
pi.registerTool({
|
|
@@ -152,7 +164,13 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
152
164
|
],
|
|
153
165
|
parameters: Params,
|
|
154
166
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
155
|
-
const label = params.chain?.length
|
|
167
|
+
const label = params.chain?.length
|
|
168
|
+
? `chain of ${params.chain.length}`
|
|
169
|
+
: params.tasks?.length
|
|
170
|
+
? `${params.tasks.length} agents`
|
|
171
|
+
: params.agent
|
|
172
|
+
? runLabel(ctx, listAgents(ctx.cwd, params.agentScope ?? "user").agents, params.agent)
|
|
173
|
+
: "subagent";
|
|
156
174
|
const stopTicker = startTicker(ctx, label);
|
|
157
175
|
try {
|
|
158
176
|
return await runTool(params, signal, onUpdate, ctx);
|
|
@@ -248,7 +266,7 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
248
266
|
}
|
|
249
267
|
|
|
250
268
|
pi.registerCommand("run", {
|
|
251
|
-
description: "Run an agent
|
|
269
|
+
description: "Run an agent: /run <agent> [--continue] -- <task>",
|
|
252
270
|
handler: async (args, ctx) => {
|
|
253
271
|
if (!canDelegate) {
|
|
254
272
|
ctx.ui.notify("subagents: this session may not delegate further (depth limit)", "warning");
|
|
@@ -256,23 +274,24 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
256
274
|
}
|
|
257
275
|
const parsed = parseRunCommand(args);
|
|
258
276
|
if (!parsed) {
|
|
259
|
-
ctx.ui.notify("usage: /run <agent> -- <task>", "warning");
|
|
277
|
+
ctx.ui.notify("usage: /run <agent> [--continue] -- <task>", "warning");
|
|
260
278
|
return;
|
|
261
279
|
}
|
|
262
280
|
const agents = listAgents(ctx.cwd, "user").agents;
|
|
263
|
-
|
|
281
|
+
// Every /run saves its session so a later --continue can pick the conversation up.
|
|
282
|
+
const session: ChildSession = { dir: sessionDirFor(dirs.sessions, parsed.agent), continue: parsed.continue };
|
|
283
|
+
pruneSessions(session.dir);
|
|
284
|
+
const stopTicker = startTicker(ctx, runLabel(ctx, agents, parsed.agent));
|
|
264
285
|
let result: RunResult;
|
|
265
286
|
try {
|
|
266
|
-
result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined);
|
|
287
|
+
result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined, session);
|
|
267
288
|
} finally {
|
|
268
289
|
stopTicker();
|
|
269
290
|
}
|
|
270
|
-
const status = isFailed(result) ? "failed" : "done"
|
|
291
|
+
const status = `${isFailed(result) ? "failed" : "done"}${parsed.continue ? ", continued" : ""}`;
|
|
271
292
|
if (isFailed(result)) ctx.ui.notify(`${parsed.agent} failed`, "warning");
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
{ deliverAs: "followUp", triggerTurn: true },
|
|
275
|
-
);
|
|
293
|
+
// No turn: the result joins the context for the next prompt without the parent model restating it.
|
|
294
|
+
pi.sendMessage({ customType: "astro-subagents", content: `Result from /run ${parsed.agent} (${status}). Task: ${parsed.task}\n\n${resultOutput(result)}`, display: true }, { triggerTurn: false });
|
|
276
295
|
},
|
|
277
296
|
});
|
|
278
297
|
|
|
@@ -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. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows
|
|
108
|
+
- Channels are configured one by one (`channels.<id>`): `trigger` is `always` (every message that names a specialist counts) or `mention` (only messages that start with `@Cortex` or reply to the bot); `specialists` is `*` or a list. A channel with one specialist needs no prefix: every message there is a task for it. In a channel with several, `dns list the zones` names the specialist; an addressed message without a name gets a reply asking for one. Threads inherit their parent channel's entry; unlisted channels are ignored. The bot replies at once with "<specialist> is working on your task" and shows the typing indicator until the answer replaces that reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows the specialists you may use in that channel and the commands (`status` lists running tasks; owners in the admin channel also see the `config` commands). 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.
|
|
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`.
|