@astrofoundry/pi-astro 0.30.0 → 0.31.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.
@@ -0,0 +1,311 @@
1
+ import { type ChannelSettings, type DiscordConfig, canUse, channelSpecialists } from "./config.ts";
2
+ import type { MessageComponent } from "./rest.ts";
3
+
4
+ /** The specialist behind the slash commands and Add buttons. */
5
+ export const MEDIA = "media";
6
+ /** Message flag for layout components (Section, Text Display); content and embeds are unavailable with it. */
7
+ export const IS_COMPONENTS_V2 = 1 << 15;
8
+ /** One list message: a header, ten sections of three components each, and a warning line stay within Discord's 40 components. */
9
+ export const LIST_ENTRIES = 10;
10
+
11
+ export type MediaKind = "movie" | "series";
12
+
13
+ export interface MediaEntry {
14
+ rank: number;
15
+ title: string;
16
+ year: number | null;
17
+ imdb_rating: string | null;
18
+ imdb_id: string | null;
19
+ in_library: boolean | null;
20
+ popularity?: number;
21
+ }
22
+
23
+ export interface MediaList {
24
+ movies: MediaEntry[];
25
+ series: MediaEntry[];
26
+ warnings: string[];
27
+ }
28
+
29
+ export interface AddResult {
30
+ imdb_id: string;
31
+ title: string | null;
32
+ status: string;
33
+ detail: string | null;
34
+ }
35
+
36
+ export interface AddReport {
37
+ results: AddResult[];
38
+ sabnzbd_paused: boolean | null;
39
+ }
40
+
41
+ const STRING = 3;
42
+ const INTEGER = 4;
43
+ const NUMBER = 10;
44
+ const GUILD_CONTEXT = 0;
45
+ const TYPE_CHOICES = [
46
+ { name: "movies", value: "movies" },
47
+ { name: "series", value: "series" },
48
+ ];
49
+
50
+ /** Slash commands of the bridge, registered as the application's global command list. */
51
+ export const MEDIA_COMMANDS = [
52
+ {
53
+ name: "trending",
54
+ type: 1,
55
+ description: "Trending movies and series this week, with Add buttons",
56
+ contexts: [GUILD_CONTEXT],
57
+ options: [
58
+ { type: STRING, name: "type", description: "Only movies or only series", choices: TYPE_CHOICES },
59
+ { type: NUMBER, name: "min_rating", description: "Drop movies below this IMDb rating (default 6.5, 0 keeps all)", min_value: 0, max_value: 10 },
60
+ { type: INTEGER, name: "count", description: "Entries per list (default 10)", min_value: 1, max_value: LIST_ENTRIES },
61
+ ],
62
+ },
63
+ {
64
+ name: "search",
65
+ type: 1,
66
+ description: "Search movies and series by title, with Add buttons",
67
+ contexts: [GUILD_CONTEXT],
68
+ options: [
69
+ { type: STRING, name: "title", description: "Full or partial title", required: true, min_length: 1, max_length: 100 },
70
+ { type: STRING, name: "type", description: "Only movies or only series", choices: TYPE_CHOICES },
71
+ ],
72
+ },
73
+ {
74
+ name: "downloads",
75
+ type: 1,
76
+ description: "SABnzbd queue: status, pause, or resume",
77
+ contexts: [GUILD_CONTEXT],
78
+ options: [
79
+ {
80
+ type: STRING,
81
+ name: "action",
82
+ description: "What to do with the download queue",
83
+ required: true,
84
+ choices: [
85
+ { name: "status", value: "status" },
86
+ { name: "pause", value: "pause" },
87
+ { name: "resume", value: "resume" },
88
+ ],
89
+ },
90
+ ],
91
+ },
92
+ ];
93
+
94
+ export interface CommandOption {
95
+ name: string;
96
+ type: number;
97
+ value?: string | number | boolean;
98
+ }
99
+
100
+ export type MediaCommand =
101
+ | { view: "trending"; args: string[]; only?: "movies" | "series" }
102
+ | { view: "search"; args: string[]; only?: "movies" | "series"; query: string }
103
+ | { view: "downloads"; args: string[] };
104
+
105
+ function value(options: readonly CommandOption[], name: string): string | number | undefined {
106
+ const found = options.find((o) => o.name === name)?.value;
107
+ return typeof found === "string" || typeof found === "number" ? found : undefined;
108
+ }
109
+
110
+ function listType(options: readonly CommandOption[]): "movies" | "series" | undefined {
111
+ const type = value(options, "type");
112
+ return type === "movies" || type === "series" ? type : undefined;
113
+ }
114
+
115
+ /** Wrapper arguments for one slash command, or null for a command this bridge does not own. */
116
+ export function commandArgs(name: string, options: readonly CommandOption[]): MediaCommand | null {
117
+ const only = listType(options);
118
+ const typeArgs = only ? ["--type", only] : [];
119
+ if (name === "trending") {
120
+ const count = value(options, "count");
121
+ const minRating = value(options, "min_rating");
122
+ const entries = typeof count === "number" && Number.isInteger(count) && count >= 1 && count <= LIST_ENTRIES ? count : LIST_ENTRIES;
123
+ const rating = typeof minRating === "number" ? ["--min-rating", String(minRating)] : [];
124
+ return { view: "trending", args: ["trending", "--max-entries", String(entries), ...typeArgs, ...rating], ...(only ? { only } : {}) };
125
+ }
126
+ if (name === "search") {
127
+ const title = value(options, "title");
128
+ if (typeof title !== "string" || title.trim().length === 0) return null;
129
+ return { view: "search", args: ["search", title.trim(), "--max-entries", String(LIST_ENTRIES), ...typeArgs], query: title.trim(), ...(only ? { only } : {}) };
130
+ }
131
+ if (name === "downloads") {
132
+ const action = value(options, "action");
133
+ if (action !== "status" && action !== "pause" && action !== "resume") return null;
134
+ return { view: "downloads", args: ["sabnzbd", action] };
135
+ }
136
+ return null;
137
+ }
138
+
139
+ function isEntry(value: unknown): value is MediaEntry {
140
+ const e = value as Partial<MediaEntry> | null;
141
+ return !!e && typeof e.rank === "number" && typeof e.title === "string";
142
+ }
143
+
144
+ /** The `trending` or `search` JSON of the media wrapper, or null when it is not one. */
145
+ export function parseMediaList(value: unknown): MediaList | null {
146
+ const r = value as { movies?: unknown; series?: unknown; warnings?: unknown } | null;
147
+ if (!r || !Array.isArray(r.movies) || !Array.isArray(r.series) || !r.movies.every(isEntry) || !r.series.every(isEntry)) return null;
148
+ const warnings = Array.isArray(r.warnings) ? r.warnings.filter((w): w is string => typeof w === "string") : [];
149
+ return { movies: r.movies, series: r.series, warnings };
150
+ }
151
+
152
+ export function parseAddReport(value: unknown): AddReport | null {
153
+ const r = value as { results?: unknown; sabnzbd_paused?: unknown } | null;
154
+ if (!r || !Array.isArray(r.results)) return null;
155
+ const results = r.results.filter((x): x is AddResult => typeof (x as AddResult | null)?.imdb_id === "string" && typeof (x as AddResult).status === "string");
156
+ return { results, sabnzbd_paused: typeof r.sabnzbd_paused === "boolean" ? r.sabnzbd_paused : null };
157
+ }
158
+
159
+ export function parseQueueState(value: unknown): boolean | null {
160
+ const paused = (value as { sabnzbd_paused?: unknown } | null)?.sabnzbd_paused;
161
+ return typeof paused === "boolean" ? paused : null;
162
+ }
163
+
164
+ const IMDB_ID = /^tt\d{1,12}$/;
165
+
166
+ function escapeDiscord(text: string): string {
167
+ return text.replace(/([\\*_~`|[\]])/g, "\\$1");
168
+ }
169
+
170
+ /** Title with year, linked to IMDb without a preview, as trendarr's `--discord` output writes it. */
171
+ function titleLink(entry: MediaEntry): string {
172
+ const label = escapeDiscord(`${entry.title}${entry.year === null ? "" : ` (${entry.year})`}`);
173
+ return entry.imdb_id !== null && IMDB_ID.test(entry.imdb_id) ? `[${label}](<https://www.imdb.com/title/${entry.imdb_id}/>)` : label;
174
+ }
175
+
176
+ /** trendarr's `--discord` line without the library flag, which the button beside it shows. */
177
+ export function entryLine(entry: MediaEntry, view: "trending" | "search"): string {
178
+ if (view === "trending") {
179
+ const rating = entry.imdb_rating === null ? "" : `${entry.imdb_rating} · `;
180
+ return `${entry.rank}. ${titleLink(entry)} — ${rating}🔥${Math.round(entry.popularity ?? 0)}`;
181
+ }
182
+ return `${entry.rank}. ${titleLink(entry)}${entry.imdb_rating === null ? "" : ` — ${entry.imdb_rating}`}`;
183
+ }
184
+
185
+ const PRIMARY = 1;
186
+ const SECONDARY = 2;
187
+ const SUCCESS = 3;
188
+ const DANGER = 4;
189
+
190
+ export function entryButton(kind: MediaKind, entry: MediaEntry): MessageComponent {
191
+ const id = entry.imdb_id !== null && IMDB_ID.test(entry.imdb_id) ? entry.imdb_id : null;
192
+ if (id === null) return { type: 2, style: SECONDARY, label: "No IMDb ID", custom_id: `media:none:${kind}:${entry.rank}`, disabled: true };
193
+ if (entry.in_library === true) return { type: 2, style: SECONDARY, label: "In library", custom_id: `media:lib:${kind}:${id}:${entry.rank}`, disabled: true };
194
+ // `in_library: null` means the library could not be read; trendarr's add answers already_exists if it is there.
195
+ return { type: 2, style: PRIMARY, label: "Add", custom_id: `media:add:${kind}:${id}:${entry.rank}` };
196
+ }
197
+
198
+ /** One Components V2 message: header, one section per entry with its button, and trendarr's warnings. */
199
+ export function listMessage(kind: MediaKind, entries: readonly MediaEntry[], view: "trending" | "search", query: string | undefined, warnings: readonly string[]): MessageComponent[] {
200
+ const title = kind === "movie" ? "**🎬 Movies**" : "**📺 Series**";
201
+ const scope = view === "trending" ? "trending this week" : `search “${escapeDiscord(query ?? "")}”`;
202
+ const components: MessageComponent[] = [{ type: 10, content: `${title} · ${scope}` }];
203
+ if (entries.length === 0) components.push({ type: 10, content: "_none_" });
204
+ for (const entry of entries.slice(0, LIST_ENTRIES)) {
205
+ components.push({ type: 9, components: [{ type: 10, content: entryLine(entry, view) }], accessory: entryButton(kind, entry) });
206
+ }
207
+ if (warnings.length > 0) components.push({ type: 10, content: `-# ⚠️ ${warnings.join(" · ").slice(0, 500)}` });
208
+ return components;
209
+ }
210
+
211
+ /** Every component in a message, nested ones and section accessories included. */
212
+ export function countComponents(components: readonly MessageComponent[]): number {
213
+ return components.reduce((n, c) => n + 1 + countComponents(c.components ?? []) + (c.accessory ? countComponents([c.accessory]) : 0), 0);
214
+ }
215
+
216
+ const ADD_ID = /^media:add:(movie|series):(tt\d{1,12}):\d{1,3}$/;
217
+
218
+ export function parseAddButton(customId: string): { kind: MediaKind; imdbId: string } | null {
219
+ const m = ADD_ID.exec(customId);
220
+ return m ? { kind: m[1] as MediaKind, imdbId: m[2] } : null;
221
+ }
222
+
223
+ export interface Outcome {
224
+ label: string;
225
+ style: number;
226
+ /** A finished button stays disabled; a failed one can be clicked again. */
227
+ disabled: boolean;
228
+ /** Shown only to the user who clicked. */
229
+ note?: string;
230
+ }
231
+
232
+ /** What the clicked button turns into after the add, from the wrapper's answer or its failure text. */
233
+ export function addOutcome(report: AddReport | null, kind: MediaKind, imdbId: string, failure?: string): Outcome {
234
+ if (!report) return { label: "Retry", style: DANGER, disabled: false, note: `Not added: ${failure ?? "the media check failed"}.` };
235
+ const result = report.results.find((r) => r.imdb_id === imdbId);
236
+ const name = result?.title ? `**${escapeDiscord(result.title)}**` : imdbId;
237
+ if (result?.status === "added") {
238
+ return { label: "Added", style: SUCCESS, disabled: true, ...(report.sabnzbd_paused === true ? { note: `Added ${name}; the search started, but downloads are paused. \`/downloads resume\` starts them.` } : {}) };
239
+ }
240
+ if (result?.status === "already_exists") return { label: "In library", style: SECONDARY, disabled: true };
241
+ if (result?.status === "not_found") return { label: "Not found", style: DANGER, disabled: true, note: `${name} was not found ${kind === "movie" ? "in Radarr" : "on TMDB or in Sonarr"}; nothing was added.` };
242
+ return { label: "Retry", style: DANGER, disabled: false, note: `Not added: ${result?.detail ?? (result ? result.status : "no result for this ID")}.` };
243
+ }
244
+
245
+ function mapButtons(components: readonly MessageComponent[], customId: string, replace: (button: MessageComponent) => MessageComponent): MessageComponent[] {
246
+ return components.map((c) => {
247
+ if (c.type === 2 && c.custom_id === customId) return replace(c);
248
+ const next: MessageComponent = { ...c };
249
+ if (c.components) next.components = mapButtons(c.components, customId, replace);
250
+ if (c.accessory) next.accessory = mapButtons([c.accessory], customId, replace)[0];
251
+ return next;
252
+ });
253
+ }
254
+
255
+ /** True while the message still shows that button enabled, so a second click on it does nothing. */
256
+ export function hasActiveButton(components: readonly MessageComponent[], customId: string): boolean {
257
+ return components.some((c) => (c.type === 2 && c.custom_id === customId && c.disabled !== true) || hasActiveButton(c.components ?? [], customId) || (c.accessory ? hasActiveButton([c.accessory], customId) : false));
258
+ }
259
+
260
+ /** The message components with only the clicked button replaced by its outcome. */
261
+ export function applyOutcome(components: readonly MessageComponent[], customId: string, outcome: Outcome): MessageComponent[] {
262
+ return mapButtons(components, customId, (button) => ({
263
+ ...button,
264
+ style: outcome.style,
265
+ label: outcome.label,
266
+ disabled: outcome.disabled,
267
+ custom_id: outcome.disabled ? customId.replace(/^media:add:/, "media:done:") : customId,
268
+ }));
269
+ }
270
+
271
+ export type MediaAccess = "ok" | "channel" | "user";
272
+
273
+ /** Media commands answer where the media specialist is present and for owners or users listed under `access.media`. */
274
+ export function mediaAccess(config: DiscordConfig, settings: ChannelSettings | null, configured: readonly string[], userId: string): MediaAccess {
275
+ if (!settings || !channelSpecialists(settings, configured).includes(MEDIA)) return "channel";
276
+ return canUse(config, userId, MEDIA) ? "ok" : "user";
277
+ }
278
+
279
+ export function mediaPointer(config: DiscordConfig, configured: readonly string[]): string {
280
+ const channels = Object.entries(config.channels)
281
+ .filter(([, settings]) => channelSpecialists(settings, configured).includes(MEDIA))
282
+ .map(([id]) => `<#${id}>`);
283
+ return channels.length > 0 ? `Media commands answer in ${channels.join(", ")}.` : "No channel has the media specialist yet.";
284
+ }
285
+
286
+ export function queueLine(paused: boolean): string {
287
+ return `SABnzbd paused: ${paused ? "yes" : "no"}`;
288
+ }
289
+
290
+ /**
291
+ * Runs tasks for one key strictly one after another; a failure does not stop
292
+ * the next. Button clicks on the same message go through one key, so each
293
+ * edit starts from the state the previous one left.
294
+ */
295
+ export class KeyedQueue {
296
+ private readonly tails = new Map<string, Promise<void>>();
297
+
298
+ run(key: string, task: () => Promise<void>): Promise<void> {
299
+ const next = (this.tails.get(key) ?? Promise.resolve()).then(task);
300
+ const tail = next.catch(() => undefined);
301
+ this.tails.set(key, tail);
302
+ void tail.then(() => {
303
+ if (this.tails.get(key) === tail) this.tails.delete(key);
304
+ });
305
+ return next;
306
+ }
307
+
308
+ get size(): number {
309
+ return this.tails.size;
310
+ }
311
+ }
@@ -6,7 +6,8 @@ import { configDir } from "./config.ts";
6
6
  export interface OpenTask {
7
7
  specialist: string;
8
8
  channelId: string;
9
- messageId: string;
9
+ /** Absent for a slash command or button click, which has no user message to answer. */
10
+ messageId?: string;
10
11
  placeholderId?: string;
11
12
  }
12
13
 
@@ -2,13 +2,26 @@ export const API = "https://discord.com/api/v10";
2
2
 
3
3
  export interface MessageComponent {
4
4
  type: number;
5
+ /** Set by Discord on every component it returns; kept unchanged when a message is edited. */
6
+ id?: number;
5
7
  components?: MessageComponent[];
8
+ /** Text of a Text Display. */
9
+ content?: string;
10
+ /** The button or thumbnail beside a Section's text. */
11
+ accessory?: MessageComponent;
6
12
  style?: number;
7
13
  label?: string;
8
14
  custom_id?: string;
9
15
  disabled?: boolean;
10
16
  }
11
17
 
18
+ /** A message body for interaction webhooks: plain `content`, or `components` with the IS_COMPONENTS_V2 flag. */
19
+ export interface WebhookMessage {
20
+ content?: string | null;
21
+ components?: MessageComponent[];
22
+ flags?: number;
23
+ }
24
+
12
25
  export interface CreateMessage {
13
26
  content: string;
14
27
  replyTo?: string;
@@ -99,6 +112,31 @@ export class DiscordRest {
99
112
  await this.call("POST", `/interactions/${interactionId}/${interactionToken}/callback`, JSON.stringify({ type: 4, data: { content, flags: 64, allowed_mentions: { parse: [] } } }), "application/json");
100
113
  }
101
114
 
115
+ /** Acknowledges a slash command; Discord shows "thinking" until the original response is edited (callback type 5). */
116
+ async deferReply(interactionId: string, interactionToken: string): Promise<void> {
117
+ await this.call("POST", `/interactions/${interactionId}/${interactionToken}/callback`, JSON.stringify({ type: 5 }), "application/json");
118
+ }
119
+
120
+ /** Acknowledges a component click without a loading state; the message is edited later through the original response (callback type 6). */
121
+ async deferUpdate(interactionId: string, interactionToken: string): Promise<void> {
122
+ await this.call("POST", `/interactions/${interactionId}/${interactionToken}/callback`, JSON.stringify({ type: 6 }), "application/json");
123
+ }
124
+
125
+ /** Edits the original response: the deferred reply of a command, or the message of a clicked component. */
126
+ async editOriginal(applicationId: string, interactionToken: string, message: WebhookMessage): Promise<void> {
127
+ await this.call("PATCH", `/webhooks/${applicationId}/${interactionToken}/messages/@original`, JSON.stringify({ ...message, allowed_mentions: { parse: [] } }), "application/json");
128
+ }
129
+
130
+ /** Another message for the same interaction; with flags 64 only the user who triggered it sees it. */
131
+ async followUp(applicationId: string, interactionToken: string, message: WebhookMessage): Promise<DiscordMessage> {
132
+ return (await this.call("POST", `/webhooks/${applicationId}/${interactionToken}`, JSON.stringify({ ...message, allowed_mentions: { parse: [] } }), "application/json")).json() as Promise<DiscordMessage>;
133
+ }
134
+
135
+ /** Replaces the application's global slash commands with this list. */
136
+ async overwriteGlobalCommands(applicationId: string, commands: readonly unknown[]): Promise<void> {
137
+ await this.call("PUT", `/applications/${applicationId}/commands`, JSON.stringify(commands), "application/json");
138
+ }
139
+
102
140
  async gatewayUrl(): Promise<string> {
103
141
  const data = (await (await this.call("GET", "/gateway/bot")).json()) as { url?: string };
104
142
  if (typeof data.url !== "string") throw new Error("Discord did not return a gateway url");
@@ -29,6 +29,8 @@ describe("approvals", () => {
29
29
  expect(isRisky("inference", ["hermes-restart", "api"])).toBe(true);
30
30
  expect(isRisky("backup", ["restic-snapshots"])).toBe(false);
31
31
  expect(isRisky("backup", ["vzdump-run", "107"])).toBe(true);
32
+ expect(isRisky("media", ["add-movies", "tt1160419"])).toBe(false);
33
+ expect(isRisky("media", ["sabnzbd", "pause"])).toBe(false);
32
34
  });
33
35
 
34
36
  it("creates a ticket and polls until decided", async () => {
@@ -35,6 +35,7 @@ export const RISKY: Readonly<Record<string, readonly RegExp[]>> = {
35
35
  backup: [/^(vzdump-run|host-backup-run|arcane-backup-run|gcs-run|restic-check-run|restic-restore|restore-clean)\b/],
36
36
  proxmox: [/^(set|snapshot-create|start)\b/],
37
37
  inference: [/^(nexus-restart|nexus-start|hermes-restart)\b/],
38
+ // media has no entry: trendarr cannot delete anything, and adds and queue toggles run on the caller's request.
38
39
  };
39
40
 
40
41
  export function isRisky(service: string, args: readonly string[]): boolean {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: media
3
+ description: How the media specialist finds trending titles, resolves and adds movies and series, and controls the SABnzbd queue through the media tool (trendarr).
4
+ ---
5
+
6
+ # Media specialist
7
+
8
+ `trendarr` is a CLI over TMDB (trending and search), OMDb (IMDb ratings), Radarr (movies), Sonarr (series), and SABnzbd (downloads). Radarr, Sonarr, and SABnzbd run in the `media` project on the Arcane VM (`10.0.40.30`). The `media` tool runs trendarr with its configuration for one call; new titles are added monitored, with the quality profile, root folder, and Sonarr monitor mode set by the operator, and a search starts at once. `["--help"]` lists the commands.
9
+
10
+ ## Commands
11
+
12
+ | Args | Effect |
13
+ |---|---|
14
+ | `["trending"]` | TMDB weekly trending, movies and series, 10 each, IMDb rating, `in_library` |
15
+ | `["trending", "--type", "movies", "--min-rating", "7", "--max-entries", "20"]` | options: `--type movies|series`, `--min-rating 0..10` (default 6.5, movies only; `0` keeps all), `--max-entries 1..50` |
16
+ | `["search", "breaking bad", "--type", "series"]` | title search, full or partial, ordered by TMDB relevance; `--type`, `--max-entries` |
17
+ | `["add-movies", "tt1160419", "tt0468569"]` | add to Radarr and search (at most 20 IDs) |
18
+ | `["add-series", "tt0903747"]` | IMDb → TVDB through TMDB, add to Sonarr and search |
19
+ | `["sabnzbd", "status"]`, `["sabnzbd", "pause"]`, `["sabnzbd", "resume"]` | queue pause state; `pause` and `resume` act, then report the new state |
20
+
21
+ ## Output
22
+
23
+ - `trending`, `search`: `{ "movies": [...], "series": [...], "warnings": [...] }`. Each item: `rank`, `title`, `year`, `imdb_rating`, `imdb_votes`, `rotten_tomatoes`, `metascore`, `genres`, `imdb_id`, `tmdb_id`, `tvdb_id`, `in_library`; trending items also `popularity`. Ratings are strings or null.
24
+ - `in_library: null` means Radarr or Sonarr could not answer or the series has no TVDB mapping: it is uncertainty, not absence. Try the add; its result decides.
25
+ - `add-movies`, `add-series`: `{ "results": [{ "imdb_id", "title", "status", "detail" }], "sabnzbd_paused": true|false|null, "warnings": [...] }`. `status` is `added` (search started), `already_exists`, `not_found` (nothing added), or `error` (read `detail`). The tool succeeds even when an ID fails; always read each status.
26
+ - `sabnzbd`: `{ "sabnzbd_paused": true|false }`.
27
+ - `warnings` carries trendarr's own warnings, such as an unreachable Radarr; mention them when they change the answer.
28
+
29
+ ## Resolving a title
30
+
31
+ 1. Search the title, with `--type` when the user said film or series.
32
+ 2. A year the user gives is authoritative. "TV show" or "series" decides the medium.
33
+ 3. Correct obvious slips when one result dominates ("Memories of a Geisha" → *Memoirs of a Geisha*), and say the canonical title in the reply.
34
+ 4. Prefer the well-known exact-title work over obscure or partial-name matches unless the user gave contrary detail. A movie and a series can share a title: decide by year and prominence, never by taking the first row.
35
+ 5. Ask one short question only when several plausible works remain and the choice changes what gets added.
36
+ 6. "All parts" of a franchise: search the franchise name, collect the mainline entries that carry the franchise title, deduplicate IDs, order them by year, and add them in one batch. Leave out documentaries, making-of features, shorts, TV adaptations, spin-offs set in the same universe, and announced but unreleased entries unless the user asks for them. Check that the number of results equals the number of entries you chose.
37
+
38
+ ## Trending
39
+
40
+ Answer "what is trending" with `trending` and list the items in this format, one line each, movies then series:
41
+
42
+ `{rank}. [{title} ({year})](<https://www.imdb.com/title/{imdb_id}/>) — {imdb_rating} · 🔥{popularity rounded} {✅ in library | ❌ not | ❓ unknown}`
43
+
44
+ Leave out the rating part when it is null. Add nothing else but one closing line that `/trending` shows the same list with Add buttons. When the user then names items by number ("add 2 and 5 from the movies"), map the numbers to that list's `imdb_id` values; if the list is not in this conversation, run `trending` again.
45
+
46
+ ## Failures
47
+
48
+ - A timeout or an `error` result: do not repeat the add at once. Search the title again; `in_library: true` means it landed. Retry once only when it did not.
49
+ - A search that fails on malformed provider data: report the failure and the title; you have no other lookup. Never guess the ID.
50
+ - `not_found`: say it was not found and nothing was added.
51
+ - A wrapper error naming a missing secret or config field is an operator task: quote it and stop.
52
+
53
+ ## Replies
54
+
55
+ Short and factual. Example: `Added **Dune: Part Three (2026)**. Search started. SABnzbd is not paused.` For a batch, one line per title. For an existing title: `**Title (Year)** is already in the library.` For failures, what was not added and why.
56
+
57
+ ## When to stop and report
58
+
59
+ - The task asks to delete, unmonitor, re-search, change quality profiles, root folders, or indexers, or to fix an import: those are Radarr, Sonarr, or SABnzbd operator tasks (the Arcane `media` project and its README).
60
+ - Radarr, Sonarr, or SABnzbd is unreachable on repeated calls: report which one; container health is `astro.arcane`'s area.
@@ -21,7 +21,7 @@ macOS cannot tell which agent made a call; the OS user protects the credential,
21
21
  ```text
22
22
  extensions/specialist-gate/ index.ts (tool registration, tool_call blocks, /specialists), config.ts, types.ts, index.test.ts
23
23
  specialists/lib/ zitadel.ts, secrets.ts, config.ts, proc.ts, ssh.ts, tunnel.ts, repo.ts, pinned.ts, sanitize.ts, audit.ts, argv.ts, main.ts, output.ts, paths.ts, errors.ts
24
- specialists/<name>/run.ts one wrapper per specialist (arcane, identity, network, dns, edge, security, backup, proxmox, inference)
24
+ specialists/<name>/run.ts one wrapper per specialist (arcane, identity, network, dns, edge, security, backup, proxmox, inference, media)
25
25
  specialists/wrappers.test.ts validators, tables, help
26
26
  specialists/provision/ provision.sh (Vaultwarden to files)
27
27
  specialists/install/ install.sh (package to ~/.specialists/bin, arcane-cli download)
@@ -55,6 +55,7 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
55
55
  - Every wrapper answers `--help` without reading config, so agents can discover subcommands before anything else is set up.
56
56
  - `main()` waits for stdout and stderr to flush before `process.exit`: on a pipe, exiting right after a write drops everything past the 64 KiB buffer, which cut large answers such as `projects list --all` until 2026-09-25 (`lib/flush.test.ts`).
57
57
  - `arcane update-report [--all]` is `lib/image-updates.ts` over `lib/registry.ts`: a fresh `images updates check --all`, then `projects list --all` for the stack and service of each image reference, then the registries' tag lists and manifest digests (anonymous bearer tokens from each registry's challenge; digests by HEAD, which Docker Hub does not count as a pull; lscr.io is read from ghcr.io). A floating or major-series tag is resolved to the release tag whose digest matches the running and the latest build, searching the newest tags of the three commonest release shapes; a fixed tag is compared with the newest tag of the same shape; a digest pin with the newest release. Checks older than one minute before the run count as stale and show as unknown. Private images (anonymous access refused with 401 or 403) are excluded from rows, counts, and table, listed in `privateImages`, and counted in the summary line. The table is fixed-width text for a code block, because Discord renders no Markdown tables.
58
+ - `media` runs trendarr unchanged: `trendarrCli` in `media.json` is its `dist/cli.js`, started with `process.execPath` because sudo's PATH has no node. Each call gets `HOME` set to a fresh `0700` directory under `~/.specialists/work/media/` that holds only `.trendarr/config.json` (`0600`, trendarr's own schema, built from the note fields and `media.json`) and is removed in `finally`; trendarr reads no environment variables and has no config flag, and Node's `os.homedir()` follows `$HOME` on POSIX. Every call passes `--json`; `init` is refused. An add prints trendarr's per-ID `results` and exits 0 even when trendarr exits 1 for a failed ID, because the gate shows only stderr on failure; other failures carry trendarr's stderr without its `warning:` lines, which go into the JSON as `warnings`. The bridge's slash commands and Add buttons call the same wrapper through the entry point (`extensions/astro-discord/media.ts`).
58
59
  - `arcane release-notes <stack>[/<service>]` is `lib/releases.ts`: a single-image check and `resolveRef` give the running and latest versions, `HttpRegistry.labels` reads `org.opencontainers.image.source` from the latest image (index to linux/amd64 manifest to config blob), and the anonymous GitHub releases API (60 requests an hour, up to 3 pages of 100) gives the releases in between, oldest first. `condenseNotes` keeps each release under 12,000 characters by section priority; `capTotal` drops the oldest releases past 40,000 characters in total.
59
60
 
60
61
  ## Gate
@@ -65,7 +66,7 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
65
66
 
66
67
  1. Identity: a Vaultwarden note named like the specialist; a Zitadel service user when the service accepts OIDC, else the service's own key; SSH key per target when the only path is SSH.
67
68
  2. `specialists/<name>/run.ts` with `main("<name>", command)`, typed subcommands, `--help`, and tables for any fixed remote commands. Add the entry script under `entry/remote/` and its copy in the homelab repository.
68
- 3. `install.sh`: add the name to the copy loop. `entry/specialist-cli.sh`: the name is already in the allowlist for the nine planned specialists; add new names there.
69
+ 3. `install.sh`: add the name to the copy loop and the `rm -rf` line. `entry/specialist-cli.sh`: add the name to the allowlist, then reinstall the root-owned copy on the host.
69
70
  4. `agents/<name>.md` with `tools: read, <name>, subagent`, `skills: <name>`, `systemPromptMode: replace`, `inheritProjectContext: false`, `inheritSkills: false`, `maxSubagentDepth: 1`. `skills/<name>/SKILL.md` with services, subcommands, output shapes, limits, and when to stop.
70
71
  5. Tests: validators and tables in `wrappers.test.ts`; anything shared in `lib/`.
71
72
  6. Host: note fields, `provision`, config file, `specialists.json` entry, target entry script. Document the fields in the homelab `03-cortex-macmini/ROTATION.md` and host facts in `03-cortex-macmini/README.md`.
@@ -77,4 +78,4 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
77
78
 
78
79
  ## Next
79
80
 
80
- All nine specialists exist; `network` writes firewall policies, `identity` deploys Pomerium routes, and Discord access is live. The Astrogate webhook route was built end to end through `identity`, `edge`, `network`, and `dns` on 2026-09-17. Open: Europa scheduled-task control for `inference` (needs Europa on to test Windows OpenSSH forced commands).
81
+ All ten specialists exist; `media` (trendarr, slash commands and Add buttons in Discord) was added on 2026-10-19; `network` writes firewall policies, `identity` deploys Pomerium routes, and Discord access is live. The Astrogate webhook route was built end to end through `identity`, `edge`, `network`, and `dns` on 2026-09-17. Open: Europa scheduled-task control for `inference` (needs Europa on to test Windows OpenSSH forced commands).
@@ -13,6 +13,7 @@ A specialist is a Pi subagent that is the only way to operate one area of the ho
13
13
  | `astro.backup` | vzdump, the shared Restic repository (Pulsar host config, Arcane app backups), GCS offsite from LXC 102 | Status, journals, and manual runs of every layer; `restic-snapshots|stats|ls`, `restic-restore` into `/var/tmp/spc-restore` on Pulsar, `restore-clean` |
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
+ | `astro.media` | trendarr over TMDB, OMDb, and the `media` project on VM 100 (Radarr, Sonarr, SABnzbd) | `trending`, `search`, `add-movies`, `add-series` (at most 20 IMDb IDs), `sabnzbd status|pause|resume`; always trendarr's JSON plus its warnings |
16
17
 
17
18
  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
19
 
@@ -24,11 +25,11 @@ Call one with `/run astro.<name> -- <task>` or through the `subagent` tool (both
24
25
  - Every call is logged in `/Users/specialist/.specialists/audit.log`.
25
26
  - The `specialist-gate` extension registers the tool only inside the matching specialist child and blocks other agents from calling the CLIs or reading `/Users/specialist`. `security-guard` carries the same blocks.
26
27
 
27
- Limits: `network` writes only firewall policies on UniFi. `security` is read-only. `identity` deploys only `config.yaml` to the DMZ. `edge` changes only `nginx.conf` on the VPS; `dns` writes only to the Technitium primary. `proxmox` cannot create, clone, or destroy guests, change disks, or touch the host. `backup` cannot change retention or schedules. `inference` cannot start Europa or change models.
28
+ Limits: `network` writes only firewall policies on UniFi. `security` is read-only. `identity` deploys only `config.yaml` to the DMZ. `edge` changes only `nginx.conf` on the VPS; `dns` writes only to the Technitium primary. `proxmox` cannot create, clone, or destroy guests, change disks, or touch the host. `backup` cannot change retention or schedules. `inference` cannot start Europa or change models. `media` adds titles and toggles the download queue; it cannot delete anything or change Radarr, Sonarr, or SABnzbd settings.
28
29
 
29
30
  ## Enable specialists on a host
30
31
 
31
- Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14`, git). Steps 1 to 3 are one-time on the services; 4 to 8 are per host.
32
+ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14`, git; for `media`, trendarr (`@astrofoundry/trendarr`) where the `specialist` user can read its `dist/cli.js`). Steps 1 to 3 are one-time on the services; 4 to 8 are per host.
32
33
 
33
34
  1. **Vaultwarden.** Account `specialists@monadeo.com` with **Can view** on collection `specialists` in organization `homelab`. Secure Notes with Hidden fields (`_B64` fields hold `base64 -i <keyfile> | tr -d '\n'`):
34
35
  - `arcane`: `ZITADEL_KEY_JSON`, `GITOPS_DEPLOY_KEY_B64`, `ZITADEL_USER_ID` (plain).
@@ -40,6 +41,7 @@ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14
40
41
  - `backup`: `SSH_KEY_PULSAR_B64`, `SSH_KEY_ARCANE_B64`.
41
42
  - `proxmox`: `PVE_TOKEN_ID` (plain, `spc-proxmox@pve!cortex`), `PVE_TOKEN_SECRET`, `SSH_KEY_PULSAR_B64`.
42
43
  - `inference`: `SSH_KEY_NEXUS_B64`, `SSH_KEY_HERMES_B64`, `SSH_KEY_PULSAR_B64`.
44
+ - `media`: `TMDB_ACCESS_TOKEN` (TMDB API Read Access Token), `OMDB_API_KEY`, `RADARR_API_KEY`, `SONARR_API_KEY` (Settings, General, Security; each app has one), `SABNZBD_API_KEY` (the API key, not the NZB key, which can only add NZBs and cannot read or pause the queue).
43
45
  2. **Identities.** Zitadel (`https://auth.37pla.net/ui/console`): service user `spc-arcane` with Access Token Type JWT and a JSON key; service user `spc-identity` with a Personal Access Token and the Instance Owner role. Technitium (both resolvers): a user with Zones View/Modify/Delete, Dashboard View, DNS Client View, and an API token each. Cloudflare: an account-owned API token with `Zone:Read` and `DNS:Edit` on both zones. GCP project `frontdoor-1337`: service account `spc-edge` with `roles/iap.tunnelResourceAccessor` and `roles/compute.viewer`, JSON key. Proxmox (`ssh pulsar`): user `spc-proxmox@pve`, role `SpecialistGuest` = `VM.Audit VM.PowerMgmt VM.Snapshot VM.Snapshot.Rollback VM.Config.CPU VM.Config.Memory VM.Config.Options VM.Config.Network VM.Config.CDROM VM.GuestAgent.Audit`, ACLs `PVEAuditor` on `/` and `SpecialistGuest` on `/vms` for both the user and the token `spc-proxmox@pve!cortex` (`-privsep 1`).
44
46
  3. **Services.** Arcane (Settings, Authentication, Federated Credentials): Issuer `https://auth.37pla.net`, Audience `387825541182390275`, Subject exact = `spc-arcane` user ID, Role Admin, Global, lifetime 300. UniFi: API key (Settings, API Keys) and policies allowing Cortex `10.0.40.37` to TCP 22 on `10.0.20.10`, `10.0.10.37`, `10.0.90.10`, TCP 5380 on `10.0.40.53`, `10.0.40.54`, TCP 8006 on `10.0.10.37`, and TCP 8081 on `10.0.60.5`. GitHub: `spc-arcane-gitops` as a write deploy key on `astronaute77/arcane`, `spc-edge-homelab` and `spc-identity-homelab` as write deploy keys on `astronaute77/homelab`.
45
47
  4. **Sudo and user**, as an administrator:
@@ -52,7 +54,7 @@ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14
52
54
  sudo install -m 0755 -o root -g wheel <package>/specialists/entry/specialist-cli.sh /usr/local/bin/specialist-cli
53
55
  brew install --cask gcloud-cli
54
56
  ```
55
- Add `cortex ALL=(specialist) NOPASSWD: ALL` as `/etc/sudoers.d/specialists-setup` (mode `0440`) for the duration of steps 5 to 7, then delete it.
57
+ Add `cortex ALL=(specialist) NOPASSWD: ALL` as `/etc/sudoers.d/specialists-setup` (mode `0440`) for the duration of steps 5 to 7, then delete it. `specialist-cli` holds the list of services it accepts; after an update that adds one (such as `media`), install it again with the same `sudo install` line.
56
58
  5. **Bootstrap**, as `specialist` (`sudo -u specialist -H zsh`, `umask 077`): `~/.config/specialists/client.env` with `BW_SERVER=https://vault.37pla.net`, `BW_CLIENTID`, `BW_CLIENTSECRET`; `~/.config/specialists/master-password`. Both `0600`.
57
59
  6. **Install and configure**, as `specialist`: `sh <package>/specialists/install/install.sh <package> 2.12.0` (`<package>` is the installed pi-astro directory, `~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro`). Write `~/.specialists/config/<name>.json` for every specialist:
58
60
  ```json
@@ -72,7 +74,12 @@ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14
72
74
  { "apiUrl": "https://10.0.10.37:8006", "certSha256": "<from: specialist-cli proxmox -- fingerprint>", "node": "pulsar", "pulsarHost": "10.0.10.37", "pulsarUser": "root" }
73
75
  { "nexusHost": "10.0.40.38", "nexusUser": "nexus", "hermesHost": "10.0.40.31", "hermesUser": "hermes", "pulsarHost": "10.0.10.37", "pulsarUser": "root",
74
76
  "europaHealthUrl": "http://10.0.60.5:8081/health", "hermesRepo": "NousResearch/hermes-agent" }
77
+ { "trendarrCli": "<absolute path to trendarr's dist/cli.js>",
78
+ "radarrUrl": "http://10.0.40.30:7878", "radarrQualityProfileId": 7, "radarrRootFolderPath": "/media/movies",
79
+ "sonarrUrl": "http://10.0.40.30:8989", "sonarrQualityProfileId": 7, "sonarrRootFolderPath": "/media/tv", "sonarrMonitor": "all",
80
+ "sabnzbdUrl": "http://10.0.40.30:8084", "maxEntries": 10 }
75
81
  ```
82
+ The `media` wrapper writes trendarr's `~/.trendarr/config.json` for each call into a private temporary HOME under `~/.specialists/work/media/` and removes it afterwards, so trendarr itself is never configured with `trendarr init`. Cortex reaches the three ports on `10.0.40.30` within its own VLAN.
76
83
  Then `sudo -n -u specialist -H /usr/local/bin/specialist-cli provision`; it lists every note and field.
77
84
  7. **SSH targets.** Each target gets an entry script from `entry/remote/` as `/usr/local/sbin/<entry>` (`0755`, root) and the public key in the right `authorized_keys` as
78
85
  `command="/usr/local/sbin/<entry>",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 ...`:
@@ -96,16 +103,18 @@ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14
96
103
  { "agent": "astro.security", "tool": "security", "service": "security", "timeoutMs": 120000 },
97
104
  { "agent": "astro.backup", "tool": "backup", "service": "backup", "timeoutMs": 3600000 },
98
105
  { "agent": "astro.proxmox", "tool": "proxmox", "service": "proxmox", "timeoutMs": 360000 },
99
- { "agent": "astro.inference", "tool": "inference", "service": "inference", "timeoutMs": 1260000 } ] }
106
+ { "agent": "astro.inference", "tool": "inference", "service": "inference", "timeoutMs": 1260000 },
107
+ { "agent": "astro.media", "tool": "media", "service": "media", "timeoutMs": 660000 } ] }
100
108
  ```
101
109
 
102
- Check: `sudo -n -u specialist -H /usr/local/bin/specialist-cli arcane --caller test -- projects list` prints JSON; the same for `identity -- dmz status`, `network -- unifi sites`, `dns -- technitium primary zones`, `edge -- vps status`, `security -- attention`, `backup -- vzdump-tasks`, `proxmox -- guests`, `inference -- nexus-status`; `/run astro.arcane -- list projects` works in Pi; `bash` with `arcane-cli --help` is blocked in a normal session.
110
+ Check: `sudo -n -u specialist -H /usr/local/bin/specialist-cli arcane --caller test -- projects list` prints JSON; the same for `identity -- dmz status`, `network -- unifi sites`, `dns -- technitium primary zones`, `edge -- vps status`, `security -- attention`, `backup -- vzdump-tasks`, `proxmox -- guests`, `inference -- nexus-status`, `media -- sabnzbd status`; `/run astro.arcane -- list projects` works in Pi; `bash` with `arcane-cli --help` is blocked in a normal session.
103
111
 
104
112
  ## Discord
105
113
 
106
114
  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
115
 
108
116
  - 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. When the bridge restarts, open approval requests are marked cancelled and running tasks interrupted in Discord; ask again. With `dailyReport` in the config, the bridge posts an "Arcane Daily Report:" message every morning in that channel: each service with an update as current to target version, from a fresh `update-report`; replying to it asks the arcane specialist with the report as context.
117
+ - Media: where the `media` specialist is present (`config channel <#channel> always media`), `/trending [type] [min_rating] [count]`, `/search <title> [type]`, and `/downloads <status|pause|resume>` run the wrapper without a model. Lists arrive as two messages, Movies and Series, each title with a button beside it: **Add** adds it at once and turns into Added, In library, or Not found (Retry after a failure); a greyed-out In library or No IMDb ID button needs nothing. Notes such as "downloads are paused" go only to the user who clicked. Owners and `access.media` users may use them; anyone else gets a refusal only they see, and in a channel without `media` the bot names the channels that have it. Plain messages in the channel still go to the agent, for requests by name ("add the new Dune"). At startup the bridge sets the three commands as the application's global commands (servers only).
109
118
  - 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
119
  - Approvals: a risky call (anything with `--confirm`, plus writes such as record or zone deletes, Zitadel changes, Pomerium deploys, service restarts, and on Arcane only destructive actions: `down`, deletes, prunes, image, volume, or network removal, `system upgrade`, `gitops delete`; help lookups never ask) 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
120
  - 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`.
@@ -6,7 +6,7 @@ set -euo pipefail
6
6
 
7
7
  service="${1:-}"; [[ -n "$service" ]] && shift
8
8
  case "$service" in
9
- arcane|identity|network|dns|edge|security|backup|proxmox|inference) ;;
9
+ arcane|identity|network|dns|edge|security|backup|proxmox|inference|media) ;;
10
10
  provision) exec /bin/sh "$HOME/.specialists/bin/provision.sh" ;;
11
11
  *) print -u2 "usage: specialist-cli <service> [--caller <agent>] [--] <args...>"; exit 2 ;;
12
12
  esac
@@ -11,9 +11,9 @@ bin="$root/bin"
11
11
 
12
12
  [ -d "$src/specialists/lib" ] || { echo "not a pi-astro package: $src" >&2; exit 2; }
13
13
  mkdir -p "$bin" "$root/config" "$root/secrets" "$root/work"
14
- rm -rf "$bin/lib" "$bin/arcane" "$bin/identity" "$bin/network" "$bin/dns" "$bin/edge" "$bin/security" "$bin/backup" "$bin/proxmox" "$bin/inference"
14
+ rm -rf "$bin/lib" "$bin/arcane" "$bin/identity" "$bin/network" "$bin/dns" "$bin/edge" "$bin/security" "$bin/backup" "$bin/proxmox" "$bin/inference" "$bin/media"
15
15
  cp -R "$src/specialists/lib" "$bin/lib"
16
- for s in arcane identity network dns edge security backup proxmox inference; do
16
+ for s in arcane identity network dns edge security backup proxmox inference media; do
17
17
  mkdir -p "$bin/$s"
18
18
  cp "$src/specialists/$s/run.ts" "$bin/$s/run.ts"
19
19
  done