@astrofoundry/pi-astro 0.29.1 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @astrofoundry/pi-astro
2
2
 
3
- Personal customizations for the [pi coding agent](https://github.com/earendil-works/pi): 25 subagents (8 general-purpose, 8 Flourish-pipeline, 9 homelab specialists) run by the bundled `astro-subagents` extension, a `grimoire` docs tool, a `caveman` compressed-output toggle, an `astro-footer` status bar, 14 skills, and an `astro` theme.
3
+ Personal customizations for the [pi coding agent](https://github.com/earendil-works/pi): 26 subagents (8 general-purpose, 8 Flourish-pipeline, 10 homelab specialists) run by the bundled `astro-subagents` extension, a `grimoire` docs tool, a `caveman` compressed-output toggle, an `astro-footer` status bar, 15 skills, and an `astro` theme.
4
4
 
5
5
  ## Full setup
6
6
 
@@ -68,8 +68,8 @@ pi # launch; confirm [Extensions] lists astro-subagents, grimoire
68
68
  - `multi-edit` - registers the enhanced `edit` tool
69
69
  - `gemini-image` - registers `gemini_image` tool (requires a Gemini API key; prompts and saves on first use)
70
70
  - `security-guard` - blocks/prompts destructive bash commands and sensitive file access; configure at `~/.pi/agent/security-guard.json` (example written on first run); `/security-guard status|reload|test`
71
- - `astro-discord` - Discord access to the specialists: in the configured channels, `<specialist> <task>` runs `astro.<specialist>` and posts the answer as a reply (⏳ running, 🔒 waiting for an owner's approval of a risky call, ✅ done, ❌ failed). Owners may use every specialist and approve risky calls with buttons; other users only the specialists listed for them; everyone else is ignored. Inert unless the headless host sets `ASTRO_DISCORD=1`; setup with `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 refuses to strip types inside `node_modules`, hence the copy), which prompts for the bot token and ids, checks them against Discord, and writes the LaunchAgent. See [specialists/README.md](specialists/README.md#discord).
72
- - `specialist-gate` - gives each specialist agent (`astro.arcane`, `astro.identity`, `astro.network`, `astro.dns`, `astro.edge`, `astro.security`, `astro.backup`, `astro.proxmox`, `astro.inference`) its single tool, which runs the matching CLI wrapper as a dedicated macOS user through `sudo`, and blocks every other agent from calling those CLIs or reading their credentials. Inactive until the host has `~/.pi/agent/specialists.json`. `/specialists` prints the status. Host setup is described in [specialists/README.md](specialists/README.md).
71
+ - `astro-discord` - Discord access to the specialists: in the configured channels, `<specialist> <task>` runs `astro.<specialist>` and posts the answer as a reply (⏳ running, 🔒 waiting for an owner's approval of a risky call, ✅ done, ❌ failed). Owners may use every specialist and approve risky calls with buttons; other users only the specialists listed for them; everyone else is ignored. Where the `media` specialist is present, `/trending`, `/search`, and `/downloads` answer without a model, with an Add button beside each title. Inert unless the headless host sets `ASTRO_DISCORD=1`; setup with `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 refuses to strip types inside `node_modules`, hence the copy), which prompts for the bot token and ids, checks them against Discord, and writes the LaunchAgent. See [specialists/README.md](specialists/README.md#discord).
72
+ - `specialist-gate` - gives each specialist agent (`astro.arcane`, `astro.identity`, `astro.network`, `astro.dns`, `astro.edge`, `astro.security`, `astro.backup`, `astro.proxmox`, `astro.inference`, `astro.media`) its single tool, which runs the matching CLI wrapper as a dedicated macOS user through `sudo`, and blocks every other agent from calling those CLIs or reading their credentials. Inactive until the host has `~/.pi/agent/specialists.json`. `/specialists` prints the status. Host setup is described in [specialists/README.md](specialists/README.md).
73
73
  - `notify-on-stop` - runs a shell command when the agent finishes a turn (sound, voice, desktop notification). **Default: off.** Enable with `/notify on` (state persists in `~/.pi/agent/notify-on-stop.json`); disable with `/notify off`. macOS default command: plays the Glass system sound and speaks "Agent done" via `say` using the **`Samantha (Enhanced)`** voice. Linux default: `notify-send "pi" "Agent done"`. Override the voice with `PI_STOP_NOTIFY_VOICE=<voice-name>` (macOS only; e.g. `Alex`, `Karen`, `Daniel (Enhanced)`). Replace the full command with `PI_STOP_NOTIFY='afplay /System/Library/Sounds/Glass.aiff && say "Done"'`. Hard-kill (overrides `/notify on`) with `PI_STOP_NOTIFY_OFF=1`. Commands: `/notify [on|off|status|test]`. See [extensions/notify-on-stop/README.md](extensions/notify-on-stop/README.md) for full details.
74
74
  - **macOS voice install (required once for the default):** open **System Settings, Accessibility, Spoken Content, System Voice, Manage Voices...**, expand **English**, check **Samantha (Enhanced)**, click **Done** to download (~500 MB to 1 GB). Verify with `say -v "Samantha (Enhanced)" hi`. If the voice is missing, `say` errors silently and you'll only hear the Glass sound.
75
75
  - `vscode-image` - only active inside VS Code's integrated terminal (`TERM_PROGRAM=vscode`). Switches pi-tui's image output to the **Kitty graphics protocol** so images returned by tools like `gemini_image` render as real pixels instead of the `[Image: ...]` text fallback. **Requires enabling `Terminal > Integrated: Enable Images` in VS Code settings** (off by default); restart the integrated terminal after flipping it. Disable with `PI_VSCODE_IMAGE_OFF=1`. `/vscode-image` prints current status.
@@ -80,11 +80,11 @@ pi # launch; confirm [Extensions] lists astro-subagents, grimoire
80
80
 
81
81
  **Bundled subagents** (call them as `astro.<name>`):
82
82
  - General-purpose: `code-reviewer`, `google-tech-lead`, `spec-writer`, `tester-api`, `tester-ui`, `ui-architect`, `ui-design-system`, `ui-frontend-developer`
83
- - Specialists (one homelab area each, need the host setup in [specialists/README.md](specialists/README.md)): `arcane` (Arcane container platform, GitOps first), `identity` (Zitadel, Pomerium routes through the homelab repository, certificates), `network` (UniFi reads and firewall policies, FreeRADIUS, Tailscale), `dns` (Technitium resolvers, Cloudflare zones), `edge` (Frontdoor VPS over IAP, nginx SNI allowlist through the homelab repository), `security` (Wazuh, CrowdSec, log archive, read-only), `backup` (vzdump, Restic layers, GCS offsite, restore tests), `proxmox` (guest lifecycle, snapshots, resources; no create or destroy), `inference` (llama.cpp on Nexus, Hermes gateways and upgrades, Europa health)
83
+ - Specialists (one homelab area each, need the host setup in [specialists/README.md](specialists/README.md)): `arcane` (Arcane container platform, GitOps first), `identity` (Zitadel, Pomerium routes through the homelab repository, certificates), `network` (UniFi reads and firewall policies, FreeRADIUS, Tailscale), `dns` (Technitium resolvers, Cloudflare zones), `edge` (Frontdoor VPS over IAP, nginx SNI allowlist through the homelab repository), `security` (Wazuh, CrowdSec, log archive, read-only), `backup` (vzdump, Restic layers, GCS offsite, restore tests), `proxmox` (guest lifecycle, snapshots, resources; no create or destroy), `inference` (llama.cpp on Nexus, Hermes gateways and upgrades, Europa health), `media` (trendarr: trending titles, Radarr and Sonarr adds, SABnzbd pause)
84
84
  - Flourish pipeline: `flourish-input-vision` (image to spec), `flourish-input-narrative` (text to spec), `flourish-input-codebase` (HTML to spec), `flourish-architect` (spec to plan), `flourish-template-developer` (plan to SDK template, needs `@flourish/sdk`), `flourish-example-builder` (template to realistic example), `flourish-preview-runner` (multi-breakpoint preview + screenshots, needs `@flourish/sdk`), `flourish-reviewer` (audit)
85
85
 
86
86
  **Skills** (loadable via `/skill:<name>`):
87
- - `playwright-cli`, `postman-cli`, `raycast-script-creator`, `caveman`, `flourish-chart-spec`, `arcane`, `identity`, `network`, `dns`, `edge`, `security`, `backup`, `proxmox`, `inference` (the last nine are injected into their specialist agents)
87
+ - `playwright-cli`, `postman-cli`, `raycast-script-creator`, `caveman`, `flourish-chart-spec`, `arcane`, `identity`, `network`, `dns`, `edge`, `security`, `backup`, `proxmox`, `inference`, `media` (the last ten are injected into their specialist agents)
88
88
 
89
89
  **Theme:**
90
90
  - `astro` - dark, gold accent, GitHub-dark-inspired
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: media
3
+ description: Manages the movie and series library through the media tool (trendarr), covering weekly trending with IMDb ratings, title search, adds to Radarr and Sonarr, and the SABnzbd queue pause state. Use for what is trending, adding a movie or series by name, and pausing or resuming downloads.
4
+ tools: read, media, subagent
5
+ skills: media
6
+ systemPromptMode: replace
7
+ inheritProjectContext: false
8
+ inheritSkills: false
9
+ maxSubagentDepth: 1
10
+ model: openai-codex/gpt-6-astra
11
+ thinking: xhigh
12
+ ---
13
+ You are the media specialist. The `media` tool, which runs the `trendarr` CLI, is your only way to reach TMDB, OMDb, Radarr, Sonarr, and SABnzbd. You have no shell, no web, and no file access; if a task needs anything else, say so and stop.
14
+
15
+ Rules:
16
+
17
+ - Search before you add, and add only IMDb IDs that a `search` or `trending` result returned in this conversation. Never invent or guess an ID.
18
+ - Resolve the title with the rules in the skill. A named film or series with one clear match: add it without asking. Ask one short question only when several plausible works remain and the choice changes what gets added.
19
+ - Movies go to `add-movies`, series to `add-series`; batch independent IDs in one call.
20
+ - Success needs an explicit `added` or `already_exists` in `results`; `already_exists` means it was already there, not newly added. Report every ID of a batch.
21
+ - Pause or resume downloads only when the task asks for it, then report the returned state. Adding never implies a queue change.
22
+ - Keep replies short: canonical title and year, outcome, and the SABnzbd state an add returned. Mention `/trending` and `/search` when the user would rather click Add buttons.
23
+ - Library settings, quality profiles, Radarr and Sonarr configuration, and deletions are operator tasks; say so and stop.
24
+ - Delegate through `subagent` only to another specialist (`astro.arcane` for the media containers on the Arcane VM) and only for that specialist's own area.
@@ -32,6 +32,7 @@ export function describeConfig(config: DiscordConfig): string {
32
32
  access.length > 0 ? "Extra access:" : "Extra access: none",
33
33
  ...access,
34
34
  `Approval timeout: ${config.approvalTimeoutMinutes} min`,
35
+ config.dailyReport ? `Arcane daily report: ${config.dailyReport.time} ${config.dailyReport.timeZone} in <#${config.dailyReport.channelId}>` : "Arcane daily report: off",
35
36
  ].join("\n");
36
37
  }
37
38
 
@@ -59,6 +59,7 @@ export function helpText(allowed: readonly string[], present: readonly string[],
59
59
  if (present.length > 1) lines.push(...allowed.map((name) => `• \`${name}\``));
60
60
  lines.push("Reactions: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed.");
61
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.");
62
+ if (allowed.includes("media")) lines.push("Slash commands `/trending`, `/search`, and `/downloads` answer directly, without the agent; an Add button adds that title at once.");
62
63
  const commands = ["`help` this message", "`status` running tasks"];
63
64
  if (canConfig) commands.push("`config show`", "`config channel <#channel> <always|mention> <*|name,name>`", "`config access <specialist> add|remove <@user>`");
64
65
  lines.push(`Commands: ${commands.join(", ")}.`);
@@ -26,6 +26,8 @@ export interface DiscordConfig {
26
26
  /** Extra users per specialist name (without the astro. prefix). */
27
27
  access: Record<string, string[]>;
28
28
  approvalTimeoutMinutes: number;
29
+ /** Optional scheduled Arcane version report. */
30
+ dailyReport?: { channelId: string; time: string; timeZone: string };
29
31
  }
30
32
 
31
33
  export const SNOWFLAKE = /^\d{17,20}$/;
@@ -90,6 +92,7 @@ export function validateConfig(raw: unknown, errors: string[]): DiscordConfig |
90
92
  }
91
93
  const timeout = r.approvalTimeoutMinutes;
92
94
  if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > 240) errors.push("approvalTimeoutMinutes must be an integer from 1 to 240");
95
+ const dailyReport = r.dailyReport === undefined ? undefined : validateDailyReport(r.dailyReport, errors);
93
96
  if (errors.length > 0) return null;
94
97
  return {
95
98
  applicationId: r.applicationId as string,
@@ -99,9 +102,30 @@ export function validateConfig(raw: unknown, errors: string[]): DiscordConfig |
99
102
  owners,
100
103
  access,
101
104
  approvalTimeoutMinutes: timeout as number,
105
+ ...(dailyReport ? { dailyReport } : {}),
102
106
  };
103
107
  }
104
108
 
109
+ function validateDailyReport(raw: unknown, errors: string[]): DiscordConfig["dailyReport"] | undefined {
110
+ const r = raw as Record<string, unknown> | null;
111
+ if (typeof r !== "object" || r === null) {
112
+ errors.push("dailyReport must be an object");
113
+ return undefined;
114
+ }
115
+ if (typeof r.channelId !== "string" || !SNOWFLAKE.test(r.channelId)) errors.push("dailyReport.channelId must be a Discord id");
116
+ if (typeof r.time !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(r.time)) errors.push("dailyReport.time must be HH:MM");
117
+ let zoneOk = typeof r.timeZone === "string";
118
+ if (zoneOk) {
119
+ try {
120
+ new Intl.DateTimeFormat("en", { timeZone: r.timeZone as string });
121
+ } catch {
122
+ zoneOk = false;
123
+ }
124
+ }
125
+ if (!zoneOk) errors.push("dailyReport.timeZone must be an IANA time zone such as Europe/Madrid");
126
+ return errors.length > 0 ? undefined : { channelId: r.channelId as string, time: r.time as string, timeZone: r.timeZone as string };
127
+ }
128
+
105
129
  export function validateChannel(raw: unknown, where: string, errors: string[]): ChannelSettings | null {
106
130
  if (typeof raw !== "object" || raw === null) {
107
131
  errors.push(`${where} must be an object`);
@@ -0,0 +1,109 @@
1
+ import { mkdtempSync, rmSync } 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 { validateConfig } from "./config.ts";
6
+ import { displayVersion, failureMessage, formatDailyReport, isDue, readReportState, REPORT_PREFIX, writeReportState, zonedNow } from "./daily-report.ts";
7
+
8
+ const settings = { channelId: "1549873938140499999", time: "06:00", timeZone: "Europe/Madrid" };
9
+
10
+ describe("daily report schedule", () => {
11
+ it("follows Madrid's daylight-saving offsets", () => {
12
+ expect(zonedNow("Europe/Madrid", new Date("2026-01-15T05:00:00Z"))).toEqual({ date: "2026-01-15", minutes: 360 });
13
+ expect(zonedNow("Europe/Madrid", new Date("2026-07-15T04:00:00Z"))).toEqual({ date: "2026-07-15", minutes: 360 });
14
+ expect(zonedNow("Europe/Madrid", new Date("2026-10-25T05:00:00Z"))).toEqual({ date: "2026-10-25", minutes: 360 });
15
+ });
16
+
17
+ it("is due once per local date, from the configured time on", () => {
18
+ const before = new Date("2026-07-15T03:59:00Z");
19
+ const at = new Date("2026-07-15T04:00:00Z");
20
+ const later = new Date("2026-07-15T09:30:00Z");
21
+ expect(isDue(settings, null, before)).toEqual({ due: false, date: "2026-07-15" });
22
+ expect(isDue(settings, null, at)).toEqual({ due: true, date: "2026-07-15" });
23
+ expect(isDue(settings, null, later).due).toBe(true);
24
+ expect(isDue(settings, { date: "2026-07-15", status: "sending" }, later).due).toBe(false);
25
+ expect(isDue(settings, { date: "2026-07-14", status: "sent" }, later).due).toBe(true);
26
+ });
27
+
28
+ it("keeps its state across processes", () => {
29
+ const dir = mkdtempSync(join(tmpdir(), "astro-report-"));
30
+ process.env.ASTRO_DISCORD_DIR = dir;
31
+ try {
32
+ expect(readReportState()).toBeNull();
33
+ writeReportState({ date: "2026-10-19", status: "sent", messageId: "1" });
34
+ expect(readReportState()).toEqual({ date: "2026-10-19", status: "sent", messageId: "1" });
35
+ } finally {
36
+ delete process.env.ASTRO_DISCORD_DIR;
37
+ rmSync(dir, { recursive: true, force: true });
38
+ }
39
+ });
40
+ });
41
+
42
+ describe("daily report message", () => {
43
+ let base: Record<string, unknown>;
44
+ beforeEach(() => {
45
+ base = { checkedAt: "2026-10-19T04:00:05Z", stacks: 19, images: 44, privateImages: ["a", "b"], counts: {}, rows: [] };
46
+ });
47
+ afterEach(() => undefined);
48
+
49
+ it("lists each service as current to target, unresolved where no release tag matches", () => {
50
+ const report = {
51
+ ...base,
52
+ rows: [
53
+ { stack: "crawl4ai", service: "crawl4ai", running: "0.9.3", latest: "0.9.4", status: "update", action: "pull", note: "" },
54
+ { stack: "media", service: "sonarr", running: "4.0.20.3014-ls325", latest: "4.0.20.3014-ls326", status: "update", action: "pull", note: "" },
55
+ { stack: "qdrant", service: "volume-init", running: "older build", latest: "1.38.0", status: "update", action: "pull", note: "" },
56
+ { stack: "profiles", service: "profiles", running: "1.31.4-alpine", latest: "1.31.6-alpine", status: "release", action: "edit tag", note: "" },
57
+ { stack: "arcane", service: "arcane", running: "v2.16.1", latest: "v2.17.0", status: "update", action: "edit digest", note: "" },
58
+ { stack: "stirling-pdf", service: "stirling-pdf", running: "2.14.3", latest: "3.0.0", status: "update", action: "pull", note: "major version change: read its release notes before pulling" },
59
+ { stack: "bigcapital", service: "gotenberg", running: "7.10.2", latest: "7.10.2", status: "up-to-date", action: "", note: "newer major 8 available (tag change)" },
60
+ { stack: "x", service: "y", running: "?", latest: "?", status: "unknown", action: "", note: "stale" },
61
+ ],
62
+ };
63
+ const { content, attachment } = formatDailyReport(report, "Europe/Madrid");
64
+ expect(attachment).toBeUndefined();
65
+ expect(content.split("\n")).toEqual([
66
+ `${REPORT_PREFIX} 6 updates (44 public images, 2 private excluded, checked 06:00 CEST).`,
67
+ "• crawl4ai: 0.9.3 → 0.9.4",
68
+ "• media/sonarr: 4.0.20.3014-ls325 → 4.0.20.3014-ls326",
69
+ "• qdrant/volume-init: unresolved → 1.38.0",
70
+ "• profiles: 1.31.4-alpine → 1.31.6-alpine (tag change)",
71
+ "• arcane: v2.16.1 → v2.17.0 (digest change)",
72
+ "• stirling-pdf: 2.14.3 → 3.0.0; major version change",
73
+ "• bigcapital/gotenberg: newer major 8 available (tag change)",
74
+ "• x/y: unresolved (check failed)",
75
+ "Checks only; nothing was updated.",
76
+ ]);
77
+ expect(content).not.toMatch(/sha256/);
78
+ });
79
+
80
+ it("says so when nothing needs updating, and moves a long list to an attachment", () => {
81
+ expect(formatDailyReport(base, "Europe/Madrid").content).toBe(`${REPORT_PREFIX} no updates (44 public images, 2 private excluded, checked 06:00 CEST).\nChecks only; nothing was updated.`);
82
+ const rows = Array.from({ length: 60 }, (_, i) => ({ stack: `stack-${i}`, service: `service-${i}`, running: "1.0.0", latest: "1.0.1", status: "update", action: "pull", note: "" }));
83
+ const long = formatDailyReport({ ...base, rows }, "Europe/Madrid");
84
+ expect(long.content).toContain("The full list is attached.");
85
+ expect(long.attachment?.split("\n")).toHaveLength(61);
86
+ expect(formatDailyReport({ nonsense: true }, "Europe/Madrid").content).toBe(failureMessage());
87
+ expect(failureMessage().startsWith(REPORT_PREFIX)).toBe(true);
88
+ });
89
+
90
+ it("never prints anything that is not a release tag", () => {
91
+ expect(displayVersion("v1.2.3")).toBe("v1.2.3");
92
+ expect(displayVersion("17 (older build)")).toBe("unresolved");
93
+ expect(displayVersion("sha256:abcdef")).toBe("unresolved");
94
+ expect(displayVersion(undefined)).toBe("unresolved");
95
+ });
96
+ });
97
+
98
+ describe("daily report config", () => {
99
+ const valid = { applicationId: "123456789012345678", guildId: "123456789012345678", channels: { "123456789012345678": { trigger: "always", specialists: "*" } }, adminChannelId: "123456789012345678", owners: ["123456789012345678"], access: {}, approvalTimeoutMinutes: 30 };
100
+ it("accepts a valid schedule and rejects a bad zone or time", () => {
101
+ const errors: string[] = [];
102
+ expect(validateConfig({ ...valid, dailyReport: settings }, errors)?.dailyReport).toEqual(settings);
103
+ expect(validateConfig({ ...valid, dailyReport: { ...settings, timeZone: "Mars/Olympus" } }, errors)).toBeNull();
104
+ expect(errors.join(" ")).toMatch(/IANA/);
105
+ const more: string[] = [];
106
+ expect(validateConfig({ ...valid, dailyReport: { ...settings, time: "6:00" } }, more)).toBeNull();
107
+ expect(validateConfig(valid, [])?.dailyReport).toBeUndefined();
108
+ });
109
+ });
@@ -0,0 +1,122 @@
1
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { configDir } from "./config.ts";
4
+
5
+ /** Every scheduled report starts with this, so it stands apart from conversations in the same channel. */
6
+ export const REPORT_PREFIX = "Arcane Daily Report:";
7
+ const MESSAGE_LIMIT = 1900;
8
+
9
+ /** `dailyReport` in config.json: where and when the bridge posts the Arcane version report. */
10
+ export interface DailyReportSettings {
11
+ channelId: string;
12
+ /** Local wall-clock time, `HH:MM`, in `timeZone`. */
13
+ time: string;
14
+ /** IANA zone such as `Europe/Madrid`; daylight-saving changes follow from it. */
15
+ timeZone: string;
16
+ }
17
+
18
+ export interface DailyReportState {
19
+ /** Local date in the report's time zone, `YYYY-MM-DD`. */
20
+ date: string;
21
+ status: "sending" | "sent" | "failed";
22
+ messageId?: string;
23
+ }
24
+
25
+ /** Local date and minutes past midnight in a time zone. */
26
+ export function zonedNow(timeZone: string, now: Date): { date: string; minutes: number } {
27
+ const parts = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(now);
28
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
29
+ return { date: `${get("year")}-${get("month")}-${get("day")}`, minutes: Number(get("hour")) * 60 + Number(get("minute")) };
30
+ }
31
+
32
+ /**
33
+ * Due once per local date, from the configured time on: a bridge that was down
34
+ * at 06:00 reports when it comes back that day. Any record for today, even a
35
+ * failed or interrupted send, means no second message.
36
+ */
37
+ export function isDue(settings: DailyReportSettings, state: DailyReportState | null, now: Date): { due: boolean; date: string } {
38
+ const { date, minutes } = zonedNow(settings.timeZone, now);
39
+ const [h, m] = settings.time.split(":").map(Number);
40
+ return { due: minutes >= h * 60 + m && state?.date !== date, date };
41
+ }
42
+
43
+ function statePath(): string {
44
+ return join(configDir(), "daily-report.json");
45
+ }
46
+
47
+ export function readReportState(): DailyReportState | null {
48
+ try {
49
+ const raw = JSON.parse(readFileSync(statePath(), "utf-8")) as Partial<DailyReportState>;
50
+ return typeof raw.date === "string" && typeof raw.status === "string" ? (raw as DailyReportState) : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export function writeReportState(state: DailyReportState): void {
57
+ const tmp = `${statePath()}.tmp`;
58
+ writeFileSync(tmp, `${JSON.stringify(state)}\n`, { mode: 0o600 });
59
+ renameSync(tmp, statePath());
60
+ }
61
+
62
+ /** A release tag as reported, or "unresolved" for anything that is not one (`older build`, `new build`, `?`). Never a digest. */
63
+ export function displayVersion(value: unknown): string {
64
+ return typeof value === "string" && /^v?[0-9][A-Za-z0-9.+_-]{0,100}$/.test(value) ? value : "unresolved";
65
+ }
66
+
67
+ interface ReportRow {
68
+ stack: string;
69
+ service: string;
70
+ running: unknown;
71
+ latest: unknown;
72
+ status: string;
73
+ action?: string;
74
+ note?: string;
75
+ }
76
+
77
+ interface UpdateReport {
78
+ checkedAt: string;
79
+ images: number;
80
+ privateImages: unknown[];
81
+ rows: ReportRow[];
82
+ }
83
+
84
+ function isReport(value: unknown): value is UpdateReport {
85
+ const r = value as Partial<UpdateReport> | null;
86
+ return !!r && typeof r.checkedAt === "string" && typeof r.images === "number" && Array.isArray(r.privateImages) && Array.isArray(r.rows);
87
+ }
88
+
89
+ function clock(iso: string, timeZone: string): string {
90
+ return new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZoneName: "short" }).format(new Date(iso));
91
+ }
92
+
93
+ function name(row: ReportRow): string {
94
+ return row.service === row.stack ? row.stack : `${row.stack}/${row.service}`;
95
+ }
96
+
97
+ /** One line per service: current to target version, with how the update applies when it is not a plain pull. */
98
+ function line(row: ReportRow): string | null {
99
+ const major = /newer major (\S+) available/.exec(row.note ?? "");
100
+ if (row.status === "up-to-date") return major ? `• ${name(row)}: newer major ${major[1]} available (tag change)` : null;
101
+ if (row.status === "unknown") return `• ${name(row)}: unresolved (check failed)`;
102
+ const how = row.status === "rebuild" ? " (rebuild)" : row.action === "edit tag" ? " (tag change)" : row.action === "edit digest" ? " (digest change)" : "";
103
+ const extra = major ? `; newer major ${major[1]} available` : /major version change/.test(row.note ?? "") ? "; major version change" : "";
104
+ return `• ${name(row)}: ${displayVersion(row.running)} → ${displayVersion(row.latest)}${how}${extra}`;
105
+ }
106
+
107
+ /** The Discord message for one `update-report` result; a list too long for one message moves to an attachment. */
108
+ export function formatDailyReport(report: unknown, timeZone: string): { content: string; attachment?: string } {
109
+ if (!isReport(report)) return { content: failureMessage() };
110
+ const lines = report.rows.map(line).filter((l): l is string => l !== null);
111
+ const updates = report.rows.filter((r) => r.status === "update" || r.status === "release" || r.status === "rebuild").length;
112
+ const scope = `${report.images} public images${report.privateImages.length > 0 ? `, ${report.privateImages.length} private excluded` : ""}, checked ${clock(report.checkedAt, timeZone)}`;
113
+ const head = updates === 0 && lines.length === 0 ? `${REPORT_PREFIX} no updates (${scope}).` : `${REPORT_PREFIX} ${updates} update${updates === 1 ? "" : "s"} (${scope}).`;
114
+ const body = [head, ...lines, "Checks only; nothing was updated."].join("\n");
115
+ if (body.length <= MESSAGE_LIMIT) return { content: body };
116
+ return { content: `${head}\nThe full list is attached.\nChecks only; nothing was updated.`, attachment: [head, ...lines].join("\n") };
117
+ }
118
+
119
+ /** Sent when the check itself fails: no list, so nothing reads as up to date. */
120
+ export function failureMessage(): string {
121
+ return `${REPORT_PREFIX} the version check failed today, so there is no update list. The bridge log on Cortex has the reason.`;
122
+ }