From 2f9d326fe6c2c468fa4b5c85cfd1d5a67e45cdcc Mon Sep 17 00:00:00 2001 From: codegod100 Date: Wed, 22 Jul 2026 15:52:05 -0700 Subject: [PATCH] Keep one MoQ plane, add watch, and harden freeq SASL refresh. - Enforce a single freeq AV media plane at a time (radio vs stream.place). - Add `eve: watch ` plus watch_stream tool. - Persist last watch and skip STREAMPLACE_AUTO when a plane is already playing. - Install a systemd timer for freeq SASL refresh that only restarts the IRC bridge when the access token actually changes (avoids clobbering watch). - Ship user systemd units and install/prep scripts used on eve.boxd. --- README.md | 58 +- agent/channels/atproto-wellknown.ts | 25 + agent/channels/irc.ts | 72 +- agent/instructions.md | 40 +- agent/lib/linear.ts | 732 +++++++++++++++ agent/schedules/vit-request-caps.ts | 5 +- agent/skills/freeq-irc/SKILL.md | 43 +- .../freeq-irc/scripts/sync-freeq-session.mjs | 6 +- agent/skills/freeq-radio/SKILL.md | 10 +- agent/skills/irc-backlog/SKILL.md | 18 +- agent/skills/linear/SKILL.md | 115 +++ agent/tools/linear_create_issue.ts | 73 ++ agent/tools/linear_issue.ts | 67 ++ agent/tools/linear_issues.ts | 123 +++ agent/tools/linear_status.ts | 105 +++ agent/tools/linear_update_issue.ts | 80 ++ agent/tools/watch_stream.ts | 77 ++ irc-bridge/README.md | 45 +- irc-bridge/server.mjs | 864 +++++++++++++++++- package.json | 4 + scripts/fetch-keys.sh | 2 +- scripts/install-systemd.sh | 173 ++++ scripts/prep.sh | 163 ++++ scripts/refresh-freeq-session.sh | 80 ++ scripts/run-av-bridge-service.sh | 28 + scripts/run-av-bridge-streamplace.sh | 13 + scripts/start.sh | 104 ++- scripts/sync-freeq-session.mjs | 6 +- systemd/README.md | 129 +++ .../user/eve-av-bridge-streamplace.service | 23 + systemd/user/eve-av-bridge.service | 20 + .../user/eve-freeq-session-refresh.service | 17 + systemd/user/eve-freeq-session-refresh.timer | 14 + systemd/user/eve-irc-bridge.service | 23 + systemd/user/eve-prep.service | 21 + systemd/user/eve.service | 22 + systemd/user/eve.target | 8 + systemd/user/rookery.service | 25 + 38 files changed, 3301 insertions(+), 132 deletions(-) create mode 100644 agent/channels/atproto-wellknown.ts create mode 100644 agent/lib/linear.ts create mode 100644 agent/skills/linear/SKILL.md create mode 100644 agent/tools/linear_create_issue.ts create mode 100644 agent/tools/linear_issue.ts create mode 100644 agent/tools/linear_issues.ts create mode 100644 agent/tools/linear_status.ts create mode 100644 agent/tools/linear_update_issue.ts create mode 100644 agent/tools/watch_stream.ts create mode 100755 scripts/install-systemd.sh create mode 100755 scripts/prep.sh create mode 100755 scripts/refresh-freeq-session.sh create mode 100755 scripts/run-av-bridge-service.sh create mode 100755 scripts/run-av-bridge-streamplace.sh mode change 100644 => 100755 scripts/sync-freeq-session.mjs create mode 100644 systemd/README.md create mode 100644 systemd/user/eve-av-bridge-streamplace.service create mode 100644 systemd/user/eve-av-bridge.service create mode 100644 systemd/user/eve-freeq-session-refresh.service create mode 100644 systemd/user/eve-freeq-session-refresh.timer create mode 100644 systemd/user/eve-irc-bridge.service create mode 100644 systemd/user/eve-prep.service create mode 100644 systemd/user/eve.service create mode 100644 systemd/user/eve.target create mode 100644 systemd/user/rookery.service diff --git a/README.md b/README.md index ceb75de..7e28b29 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ - **Model**: OpenCode Zen `deepseek-v4-flash-free` (OpenAI-compatible Chat Completions) - **Channels**: built-in eve channel + IRC via **irc-bridge** (POST `/irc/inbound` + SSE `/irc/out`) - **AV (optional)**: **av-bridge** (Rust) — freeq MoQ media plane + internet radio (`av-bridge/`, freeq `eve-av-bridge`) -- **Tools**: cowsay, ATProto, rook/thermals, Anna, `guess_emotion`, **`play_radio` / `stop_radio`** (stream to freeq AV) -- **Skills**: `vit`, `vit-request-watch`, `anna`, `freeq-irc`, `irc-backlog`, `embody-emotion`, **`freeq-radio`** +- **Tools**: cowsay, ATProto, rook/thermals, Anna, Linear (eve project), `guess_emotion`, **`play_radio` / `stop_radio`** (stream to freeq AV), **`memory_bank_add` / `memory_bank_list`** (durable song/note list) +- **Skills**: `vit`, `vit-request-watch`, `anna`, `linear`, `freeq-irc`, `irc-backlog`, `embody-emotion`, **`freeq-radio`** - **Schedules**: `vit-request-caps` every 10m → explore kind:request on controlled beacons → IRC `#test` - **Secrets**: API keys pulled live from OpenBao (`openbao.boxd.sh`) via `scripts/fetch-keys.sh` — never committed @@ -20,8 +20,9 @@ agent/ lib/ tools/ skills/ schedules/ irc-bridge/ server.mjs # freeq IRC → POST /irc/inbound ; SSE /irc/out → PRIVMSG +systemd/user/ # user unit templates (eve, irc-bridge, optional av-bridge + streamplace plane (:8792)) scripts/ - fetch-keys.sh, start.sh, sync-freeq-session.mjs + prep.sh, start.sh, install-systemd.sh, fetch-keys.sh, sync-freeq-session.mjs flake.nix ``` @@ -76,6 +77,41 @@ bash scripts/install-annas-mcp.sh # → ~/.local/bin/annas-mcp Skill: `load_skill` → `anna`. +## Linear tools (eve project) + +All Linear tools are **scoped to the Linear project named `eve`** (override with `LINEAR_PROJECT_NAME` or `LINEAR_PROJECT_ID`). Issues outside that project are rejected. + +| Tool | Purpose | +|------|---------| +| **`linear_status`** | Project progress + counts by state + sample open issues | +| **`linear_issues`** | List/filter issues (query, state, assignee); default open only | +| **`linear_issue`** | Full detail for one identifier/UUID | +| `linear_create_issue` | Create issue always on the eve project | +| `linear_update_issue` | Update title/description/priority/state/assignee (eve only) | + +| Variable | Notes | +|----------|--------| +| `LINEAR_API_KEY` | OpenBao `ai-api-keys` personal API key | +| `LINEAR_PROJECT_NAME` | Default `eve` | +| `LINEAR_PROJECT_ID` | Optional UUID (skips name lookup) | + +Skill: `load_skill` → `linear`. + +## Memory bank (saved songs / notes) + +Host file (not the sandbox). IRC: “add song to memory bank”, “read the memory bank”. + +| Tool | Purpose | +|------|---------| +| **`memory_bank_add`** | Append one entry (usually current `now playing` / `radio_title`) | +| **`memory_bank_list`** | List saved entries | + +| Variable | Default | +|----------|---------| +| `MEMORY_BANK_PATH` | `$HOME/memory-bank.txt` | + +Never claim a save without a tool result. Skill: `freeq-radio`. + ## freeq IRC nick / SASL If the bot appears as `Guest…` or logs `SASL failed (904)`, the freeq OAuth session expired. @@ -87,7 +123,7 @@ node scripts/sync-freeq-session.mjs # or /home/boxd/my-agent/scripts/… # restart agent (start.sh) ``` -Skill: `load_skill` → `freeq-irc`. Target nick: `eve` / `eve.rookery.boxd.sh`. +Skill: `load_skill` → `freeq-irc`. Target nick: `eve` / handle `eve.boxd.sh` (PDS `https://pds.eve.boxd.sh`). ## Local dev @@ -100,23 +136,29 @@ npm run dev ## Boxd (eve.boxd.sh) -On the VM, secrets come from OpenBao: +On the VM, secrets come from OpenBao. **Production path uses systemd user units** +(`eve`, `eve-irc-bridge`, optional `eve-av-bridge`) — see [`systemd/README.md`](systemd/README.md). ```bash export OPENBAO_ADDR=https://openbao.boxd.sh export OPENBAO_TOKEN=… # boxd secret / service token npm install -npm run boxd:start # scripts/start.sh +bash scripts/install-systemd.sh # once (writes units + ~/.config/eve/) +# put OPENBAO_* in ~/.config/eve/openbao.env (mode 0600) +loginctl enable-linger "$USER" # once, if units should survive logout +npm run boxd:start # systemctl --user start eve.target ``` -Optional IRC env (defaults match freeq): +Legacy (no units): `bash scripts/start.sh --legacy` (prep + nohup bridge + foreground eve). + +Optional IRC env (defaults match freeq; set in `~/.config/eve/config.env` for units): | Variable | Default | |----------|---------| | `IRC_HOST` | `irc.freeq.at` | | `IRC_PORT` | `6697` | | `IRC_TLS` | `1` | -| `IRC_NICK` | `eve-agent` | +| `IRC_NICK` | `eve` | | `IRC_CHANNEL` | `#test` | | `IRC_PASSWORD` | (unset) | | `IRC_OWNERS` | (unset) | diff --git a/agent/channels/atproto-wellknown.ts b/agent/channels/atproto-wellknown.ts new file mode 100644 index 0000000..b394e82 --- /dev/null +++ b/agent/channels/atproto-wellknown.ts @@ -0,0 +1,25 @@ +import { defineChannel, GET } from "eve/channels"; + +/** + * Serve AT Protocol handle verification for eve.boxd.sh. + * https://atproto.com/specs/handle#handle-resolution + * + * DID can be overridden with ATPROTO_DID (set by prep from rook identity). + */ +const DID = + process.env.ATPROTO_DID?.trim() || + "did:plc:fdiivi2izdgx3rl2d4qedt7n"; + +export default defineChannel({ + routes: [ + GET("/.well-known/atproto-did", async () => { + return new Response(`${DID}\n`, { + status: 200, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "public, max-age=300", + }, + }); + }), + ], +}); diff --git a/agent/channels/irc.ts b/agent/channels/irc.ts index 4d97511..c49fb2b 100644 --- a/agent/channels/irc.ts +++ b/agent/channels/irc.ts @@ -67,6 +67,28 @@ function isHookConflict(msg: string): boolean { return /already in use|HookConflict/i.test(msg); } +/** + * Keep only safe background context for SendPayload.context. + * + * eve turns each context string into a role:user message. Accept a single + * framed blob from the bridge; drop unframed multi-line + * dumps that models would treat as open user turns. + */ +function sanitizeIrcContext(raw: unknown): string[] | undefined { + if (!Array.isArray(raw)) return undefined; + const framed = raw + .filter((s): s is string => typeof s === "string" && s.trim().length > 0) + .map((s) => s.trim()) + .filter( + (s) => + s.includes(""), + ); + if (framed.length === 0) return undefined; + // One blob max — extra entries would each become another role:user turn. + return [framed[0]!]; +} + const IRC_CHANNEL = process.env.IRC_CHANNEL ?? "#test"; export type IrcReceiveTarget = { @@ -104,25 +126,45 @@ export default defineChannel({ from: string; target: string; text: string; + /** + * Optional background scrollback from the bridge. eve injects each + * string as role:user before the delivery message — so the bridge + * must send a single framed blob, never raw + * per-line history that looks like open requests. + */ + context?: string[]; }; try { - // Delivery message = the mention only (prefixed with speaker nick so - // the model knows who to address). Do NOT pass SendPayload.context: - // eve injects those as role:user history and models answer every line - // (old mentions, rejoin scrollback, etc.). const nick = String(body.from ?? "").trim() || "someone"; - await send(`<${nick}> ${body.text}`, { - auth: { - authenticator: "irc", - principalType: "user", - principalId: body.from, - attributes: { target: body.target }, + // Only accept already-framed background blobs (defense in depth). + const context = sanitizeIrcContext(body.context); + // Delivery = the current mention only. Prefix makes the speaker nick + // and "answer only this" rule explicit next to any background block. + const message = [ + `Current IRC mention from <${nick}> in ${body.target || "DM"}.`, + `Answer ONLY this message (one IRC line, address ${nick}).`, + `Do not reply to or re-act on any block above.`, + ``, + `<${nick}> ${body.text}`, + ].join("\n"); + await send( + { + message, + ...(context?.length ? { context } : {}), + }, + { + auth: { + authenticator: "irc", + principalType: "user", + principalId: body.from, + attributes: { target: body.target }, + }, + // Per-message token so a stuck prior turn does not block the next. + continuationToken: `${body.from}:${Date.now()}`, + state: { from: body.from, target: body.target }, + title: `irc: ${body.from}`, }, - // Per-message token so a stuck prior turn does not block the next. - continuationToken: `${body.from}:${Date.now()}`, - state: { from: body.from, target: body.target }, - title: `irc: ${body.from}`, - }); + ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); const target = body.target || IRC_CHANNEL; diff --git a/agent/instructions.md b/agent/instructions.md index f8c271a..9eaca98 100644 --- a/agent/instructions.md +++ b/agent/instructions.md @@ -63,24 +63,58 @@ Load skill `anna` for AA / ISBN-on-AA. Prefer **`anna_search`** → md5 → **`a Env: `ANNA_API_KEY`, `ANNA_DOWNLOAD_DIR` (default `$HOME/archive`), `ANNAS_MCP_BIN`. +# Linear (eve project only) + +**Feedback / suggestions → ticket (always).** If someone gives feedback, a suggestion, a feature idea, a bug report, a complaint, or a product/UX request about eve (or IRC/radio/rook/tools), call **`linear_create_issue` in the same turn** on the eve project. Do not only acknowledge in chat. Skip only for pure Q&A, status checks, pure ops, or if they say not to track it. Prefer over-capture when unsure. Reply with the new identifier (IRC one line). Load skill `linear` for filing details and board workflows. + +All tools are scoped to the Linear project **eve** (`LINEAR_PROJECT_NAME` / `LINEAR_PROJECT_ID`). Key: `LINEAR_API_KEY` from OpenBao — never echo it. + +- **`linear_create_issue`** — file feedback/suggestions (and any new work) +- **`linear_status`** — project progress + counts by state + sample open issues +- **`linear_issues`** — list/filter (query, state, state_type, assignee); default open only +- **`linear_issue`** — one issue by id / identifier (e.g. `SWARM-12`) +- `linear_update_issue` — update only on the eve project + # freeq IRC (bridge) IRC lives in a **separate process** (`irc-bridge/server.mjs`): freeq → bridge POSTs `/irc/inbound` → eve; eve pushes replies on SSE `/irc/out` → bridge PRIVMSG. Eve has no IRC socket. -If nick is `Guest…` or SASL fails: load skill `freeq-irc` → `rook login` → `node scripts/sync-freeq-session.mjs` → restart bridge (`npm run irc-bridge`) and/or `start.sh`. Correct nick: `eve` / `did:plc:76szbe2ywgwb7vzuingj4fhq` on `#test`. +If nick is `Guest…` or SASL fails: load skill `freeq-irc` → `rook login` → `node scripts/sync-freeq-session.mjs` → `systemctl --user restart eve-irc-bridge.service` (or legacy `npm run irc-bridge`). Correct nick: `eve` / `did:plc:fdiivi2izdgx3rl2d4qedt7n` (`eve.boxd.sh`) on `#test`. # IRC mentions only -You are invoked **only** when someone mentions you (or DMs you). The user message is that single mention (prefixed with ``). Answer **only** that message. Channel history is not injected into the turn. +You are invoked **only** when someone mentions you (or DMs you). + +- The **delivery message** is the current mention (`Current IRC mention from …` plus ` text`). **Answer only that.** +- You may also see one **background** block `…` above it (recent channel/DM scrollback). That block is **not** a user request: + - Do **not** reply to, continue, or re-answer lines inside it. + - `role=prior_mention` = already-handled historical mentions of you. + - `role=agent` = your own past IRC lines. + - Use it only for situation awareness (topics, pronouns, what just happened). +- One IRC line reply, address the speaker nick. # Radio / AV — tool results only -Stack **does exist** on this host when running: `play_radio` / `stop_radio` / `radio_status` talk to loopback control (`:8791`) and eve-av-bridge (`:8790`) with ffmpeg. +Stack **does exist** on this host when running: `play_radio` / `stop_radio` / `radio_status` / `watch_stream` talk to loopback control (`:8791`) and eve-av-bridge (`:8790`) with ffmpeg. - For “play radio” / “can’t hear” / “is it working?”: **call the tools**. Prefer `radio_status` before claiming anything is missing. +- For stream.place (“watch …”, stream.place URL/handle): use **`watch_stream`**, or the bridge command `eve: watch https://stream.place/handle`. - **Never invent** “nothing is installed”, “bare Ubuntu container”, “infrastructure was never set up”, or long install lectures. - If a tool returns `ok:true` / `verified_playing:true`, say the stream is up and tell them to **join freeq AV** in that channel. If `ok:false`, one short failure line using the tool’s `say` / `error` field. - Listeners hear radio only inside the freeq **voice/AV call**, not as IRC text. +- Channel lines like `now playing: Artist - Title` come from the bridge — use them as context, or confirm with `radio_status` (`radio_title`). + +# Memory bank (saved songs / notes) + +Durable host file (default **`~/memory-bank.txt`**, override `MEMORY_BANK_PATH`) — **not** the sandbox, not `/root/…` unless that is actually `$HOME`. + +- **Save**: `memory_bank_add` with `text` (and `by` = speaker nick). If they say “this song” / “add song to memory bank” without naming it, get the title from recent `now playing:` context or `radio_status` → `radio_title`, then call `memory_bank_add`. +- **Read back**: `memory_bank_list` (optional `limit`). +- Reply using the tool’s `say` / `path` / `entry_count`. Never invent a path or claim a write that did not run. + +# Never fake side effects + +**Do not claim** you saved a file, wrote to disk, ran a shell command, created a ticket, played radio, or changed anything **unless a tool in this turn returned success**. If you have no tool result, say you have not done it yet and call the tool (or say you cannot). Inventing `/root/memory-bank.txt` or similar is a hard failure. # IRC join backlog diff --git a/agent/lib/linear.ts b/agent/lib/linear.ts new file mode 100644 index 0000000..b049729 --- /dev/null +++ b/agent/lib/linear.ts @@ -0,0 +1,732 @@ +/** + * Linear GraphQL helpers for the eve agent. + * + * Auth: `LINEAR_API_KEY` from OpenBao (`ai-api-keys` via scripts/fetch-keys.sh). + * Scope: every query/mutation is constrained to the configured Linear project + * (default name "eve"; override with LINEAR_PROJECT_NAME or LINEAR_PROJECT_ID). + * + * Docs: https://linear.app/developers/graphql + */ + +export const LINEAR_GQL = "https://api.linear.app/graphql"; + +/** Default Linear project name when LINEAR_PROJECT_ID is unset. */ +export const DEFAULT_LINEAR_PROJECT_NAME = "eve"; + +export type LinearStateType = + | "triage" + | "backlog" + | "unstarted" + | "started" + | "completed" + | "canceled" + | "duplicate"; + +export type LinearIssueSummary = { + id: string; + identifier: string; + title: string; + description: string | null; + url: string; + priority: number; + priorityLabel: string; + state: { id: string; name: string; type: string }; + assignee: { id: string; name: string; email?: string } | null; + labels: string[]; + createdAt: string; + updatedAt: string; + completedAt: string | null; + dueDate: string | null; + projectId: string | null; + projectName: string | null; +}; + +export type LinearProject = { + id: string; + name: string; + description: string | null; + state: string; + progress: number; + url: string; + targetDate: string | null; + startDate: string | null; + lead: { id: string; name: string } | null; + teams: Array<{ id: string; name: string; key: string }>; +}; + +type GqlError = { message: string; path?: string[] }; +type GqlResponse = { data?: T; errors?: GqlError[] }; + +let projectCache: { key: string; project: LinearProject; at: number } | null = + null; +const PROJECT_CACHE_TTL_MS = 5 * 60 * 1000; + +export function linearApiKey(): string | undefined { + const key = + process.env.LINEAR_API_KEY?.trim() || + process.env.LINEAR_ACCESS_TOKEN?.trim() || + process.env.LINEAR_API_TOKEN?.trim(); + return key || undefined; +} + +export function hasLinearApiKey(): boolean { + return Boolean(linearApiKey()); +} + +export function configuredProjectName(): string { + return ( + process.env.LINEAR_PROJECT_NAME?.trim() || DEFAULT_LINEAR_PROJECT_NAME + ); +} + +function priorityLabel(priority: number): string { + switch (priority) { + case 1: + return "urgent"; + case 2: + return "high"; + case 3: + return "normal"; + case 4: + return "low"; + default: + return "none"; + } +} + +function mapIssue(raw: { + id: string; + identifier: string; + title: string; + description?: string | null; + url: string; + priority: number; + state: { id: string; name: string; type: string }; + assignee?: { id: string; name: string; email?: string } | null; + labels?: { nodes: Array<{ name: string }> }; + createdAt: string; + updatedAt: string; + completedAt?: string | null; + dueDate?: string | null; + project?: { id: string; name: string } | null; +}): LinearIssueSummary { + return { + id: raw.id, + identifier: raw.identifier, + title: raw.title, + description: raw.description ?? null, + url: raw.url, + priority: raw.priority, + priorityLabel: priorityLabel(raw.priority), + state: raw.state, + assignee: raw.assignee ?? null, + labels: (raw.labels?.nodes ?? []).map((l) => l.name), + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + completedAt: raw.completedAt ?? null, + dueDate: raw.dueDate ?? null, + projectId: raw.project?.id ?? null, + projectName: raw.project?.name ?? null, + }; +} + +const ISSUE_FIELDS = ` + id + identifier + title + description + url + priority + state { id name type } + assignee { id name email } + labels { nodes { name } } + createdAt + updatedAt + completedAt + dueDate + project { id name } +`; + +/** + * Run a Linear GraphQL operation. Never logs the API key. + */ +export async function linearGql( + query: string, + variables: Record = {}, + timeoutMs = 20_000, +): Promise { + const key = linearApiKey(); + if (!key) { + throw new Error( + "No LINEAR_API_KEY (OpenBao ai-api-keys). Set LINEAR_API_KEY or run scripts/start.sh with OpenBao.", + ); + } + + const res = await fetch(LINEAR_GQL, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: key, + }, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(timeoutMs), + }); + + let body: GqlResponse; + try { + body = (await res.json()) as GqlResponse; + } catch { + throw new Error( + `Linear GraphQL non-JSON response (${res.status} ${res.statusText})`, + ); + } + + if (!res.ok) { + const msg = + body.errors?.map((e) => e.message).join("; ") || + res.statusText || + "request failed"; + throw new Error(`Linear GraphQL HTTP ${res.status}: ${msg}`); + } + + if (body.errors?.length) { + throw new Error( + `Linear GraphQL: ${body.errors.map((e) => e.message).join("; ")}`, + ); + } + + if (body.data === undefined) { + throw new Error("Linear GraphQL: empty data"); + } + + return body.data; +} + +function mapProject(raw: { + id: string; + name: string; + description?: string | null; + state: string; + progress: number; + url: string; + targetDate?: string | null; + startDate?: string | null; + lead?: { id: string; name: string } | null; + teams?: { nodes: Array<{ id: string; name: string; key: string }> }; +}): LinearProject { + return { + id: raw.id, + name: raw.name, + description: raw.description ?? null, + state: raw.state, + progress: raw.progress, + url: raw.url, + targetDate: raw.targetDate ?? null, + startDate: raw.startDate ?? null, + lead: raw.lead ?? null, + teams: raw.teams?.nodes ?? [], + }; +} + +const PROJECT_FIELDS = ` + id + name + description + state + progress + url + targetDate + startDate + lead { id name } + teams { nodes { id name key } } +`; + +/** + * Resolve the eve-scoped Linear project (cached briefly). + * Prefer LINEAR_PROJECT_ID; else look up by LINEAR_PROJECT_NAME (default "eve"). + */ +export async function resolveEveProject( + force = false, +): Promise { + const projectId = process.env.LINEAR_PROJECT_ID?.trim(); + const name = configuredProjectName(); + const cacheKey = projectId ? `id:${projectId}` : `name:${name.toLowerCase()}`; + + if ( + !force && + projectCache && + projectCache.key === cacheKey && + Date.now() - projectCache.at < PROJECT_CACHE_TTL_MS + ) { + return projectCache.project; + } + + let project: LinearProject; + + if (projectId) { + const data = await linearGql<{ + project: Parameters[0] | null; + }>( + `query ProjectById($id: String!) { + project(id: $id) { ${PROJECT_FIELDS} } + }`, + { id: projectId }, + ); + if (!data.project) { + throw new Error( + `Linear project id ${projectId} not found (LINEAR_PROJECT_ID).`, + ); + } + project = mapProject(data.project); + } else { + const data = await linearGql<{ + projects: { nodes: Array[0]> }; + }>( + `query ProjectByName($name: String!) { + projects(filter: { name: { eqIgnoreCase: $name } }, first: 10) { + nodes { ${PROJECT_FIELDS} } + } + }`, + { name }, + ); + const nodes = data.projects.nodes; + if (nodes.length === 0) { + throw new Error( + `No Linear project named "${name}". Set LINEAR_PROJECT_NAME or LINEAR_PROJECT_ID.`, + ); + } + // Prefer exact case-insensitive match; if several, take first non-completed. + const preferred = + nodes.find((n) => n.state !== "completed" && n.state !== "canceled") ?? + nodes[0]; + project = mapProject(preferred); + } + + projectCache = { key: cacheKey, project, at: Date.now() }; + return project; +} + +/** Build an IssueFilter that always includes the eve project id. */ +export function eveProjectIssueFilter( + projectId: string, + extra: Record = {}, +): Record { + return { + ...extra, + project: { id: { eq: projectId } }, + }; +} + +export type ListIssuesOpts = { + /** Free-text search in title/description. */ + query?: string; + /** Workflow state name (e.g. "In Progress"). */ + state?: string; + /** State type: backlog | unstarted | started | completed | canceled | triage. */ + stateType?: LinearStateType; + /** Assignee email, display name, or "me". */ + assignee?: string; + /** Include completed/canceled (default false → open only). */ + includeClosed?: boolean; + limit?: number; +}; + +export async function listEveIssues( + opts: ListIssuesOpts = {}, +): Promise<{ project: LinearProject; issues: LinearIssueSummary[]; count: number }> { + const project = await resolveEveProject(); + const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100); + + const filterParts: Record = {}; + + if (opts.query?.trim()) { + filterParts.or = [ + { title: { containsIgnoreCase: opts.query.trim() } }, + { description: { containsIgnoreCase: opts.query.trim() } }, + ]; + } + if (opts.state?.trim()) { + filterParts.state = { + ...(typeof filterParts.state === "object" && filterParts.state + ? (filterParts.state as object) + : {}), + name: { eqIgnoreCase: opts.state.trim() }, + }; + } + if (opts.stateType) { + filterParts.state = { + ...(typeof filterParts.state === "object" && filterParts.state + ? (filterParts.state as object) + : {}), + type: { eq: opts.stateType }, + }; + } + if (!opts.includeClosed && !opts.state && !opts.stateType) { + filterParts.state = { + ...(typeof filterParts.state === "object" && filterParts.state + ? (filterParts.state as object) + : {}), + type: { nin: ["completed", "canceled", "duplicate"] }, + }; + } + if (opts.assignee?.trim()) { + const a = opts.assignee.trim(); + if (a.toLowerCase() === "me") { + filterParts.assignee = { isMe: { eq: true } }; + } else if (a.includes("@")) { + filterParts.assignee = { email: { eqIgnoreCase: a } }; + } else { + filterParts.assignee = { name: { containsIgnoreCase: a } }; + } + } + + const filter = eveProjectIssueFilter(project.id, filterParts); + + const data = await linearGql<{ + issues: { + nodes: Array[0]>; + }; + }>( + `query EveIssues($filter: IssueFilter, $first: Int) { + issues(filter: $filter, first: $first, orderBy: updatedAt) { + nodes { ${ISSUE_FIELDS} } + } + }`, + { filter, first: limit }, + ); + + const issues = data.issues.nodes.map(mapIssue); + return { project, issues, count: issues.length }; +} + +/** + * Fetch one issue by UUID or identifier (e.g. EVE-12). + * Rejects issues that are not on the eve project. + */ +export async function getEveIssue( + idOrIdentifier: string, +): Promise<{ project: LinearProject; issue: LinearIssueSummary }> { + const project = await resolveEveProject(); + const id = idOrIdentifier.trim(); + if (!id) throw new Error("Issue id or identifier is required"); + + const data = await linearGql<{ + issue: Parameters[0] | null; + }>( + `query Issue($id: String!) { + issue(id: $id) { ${ISSUE_FIELDS} } + }`, + { id }, + ); + + if (!data.issue) { + throw new Error(`Linear issue not found: ${id}`); + } + + const issue = mapIssue(data.issue); + if (issue.projectId !== project.id) { + throw new Error( + `Issue ${issue.identifier} is not in the "${project.name}" project ` + + `(project=${issue.projectName ?? "none"}). All tools are scoped to that project.`, + ); + } + + return { project, issue }; +} + +export type ProjectStatus = { + project: LinearProject; + byStateType: Record; + byStateName: Record; + open: number; + started: number; + completed: number; + canceled: number; + total: number; + sampleOpen: LinearIssueSummary[]; +}; + +/** + * Aggregate status for the eve project: counts by state + sample of open issues. + */ +export async function eveProjectStatus( + sampleLimit = 10, +): Promise { + const project = await resolveEveProject(); + + // Pull a generous page for counts; Linear filter is project-scoped. + const data = await linearGql<{ + issues: { nodes: Array[0]> }; + }>( + `query EveStatus($filter: IssueFilter, $first: Int) { + issues(filter: $filter, first: $first, orderBy: updatedAt) { + nodes { ${ISSUE_FIELDS} } + } + }`, + { + filter: eveProjectIssueFilter(project.id), + first: 250, + }, + ); + + const issues = data.issues.nodes.map(mapIssue); + const byStateType: Record = {}; + const byStateName: Record = {}; + + for (const issue of issues) { + const t = issue.state.type || "unknown"; + const n = issue.state.name || "unknown"; + byStateType[t] = (byStateType[t] ?? 0) + 1; + byStateName[n] = (byStateName[n] ?? 0) + 1; + } + + const isOpen = (t: string) => + t !== "completed" && t !== "canceled" && t !== "duplicate"; + + const sampleOpen = issues + .filter((i) => isOpen(i.state.type)) + .slice(0, sampleLimit); + + return { + project, + byStateType, + byStateName, + open: issues.filter((i) => isOpen(i.state.type)).length, + started: byStateType.started ?? 0, + completed: byStateType.completed ?? 0, + canceled: (byStateType.canceled ?? 0) + (byStateType.duplicate ?? 0), + total: issues.length, + sampleOpen, + }; +} + +/** Team workflow states for the project's first team. */ +export async function listProjectStates(): Promise< + Array<{ id: string; name: string; type: string }> +> { + const project = await resolveEveProject(); + const teamId = project.teams[0]?.id; + if (!teamId) { + throw new Error( + `Project "${project.name}" has no teams; cannot list workflow states.`, + ); + } + + const data = await linearGql<{ + team: { + states: { + nodes: Array<{ id: string; name: string; type: string }>; + }; + } | null; + }>( + `query TeamStates($id: String!) { + team(id: $id) { + states { + nodes { id name type } + } + } + }`, + { id: teamId }, + ); + + return data.team?.states.nodes ?? []; +} + +export type CreateIssueOpts = { + title: string; + description?: string; + priority?: number; + /** State name or type to set after create. */ + state?: string; + assignee?: string; + labelNames?: string[]; +}; + +export async function createEveIssue( + opts: CreateIssueOpts, +): Promise<{ project: LinearProject; issue: LinearIssueSummary }> { + const project = await resolveEveProject(); + const teamId = project.teams[0]?.id; + if (!teamId) { + throw new Error( + `Project "${project.name}" has no teams; cannot create issues.`, + ); + } + + const title = opts.title.trim(); + if (!title) throw new Error("title is required"); + + const input: Record = { + title, + teamId, + projectId: project.id, + }; + if (opts.description !== undefined) input.description = opts.description; + if (opts.priority !== undefined) input.priority = opts.priority; + + if (opts.state?.trim()) { + const states = await listProjectStates(); + const want = opts.state.trim().toLowerCase(); + const match = + states.find((s) => s.name.toLowerCase() === want) ?? + states.find((s) => s.type.toLowerCase() === want); + if (!match) { + throw new Error( + `Unknown state "${opts.state}". Known: ${states.map((s) => s.name).join(", ")}`, + ); + } + input.stateId = match.id; + } + + if (opts.assignee?.trim()) { + const a = opts.assignee.trim(); + if (a.toLowerCase() === "me") { + const me = await linearGql<{ viewer: { id: string } }>( + `query { viewer { id } }`, + ); + input.assigneeId = me.viewer.id; + } else { + const users = await linearGql<{ + users: { nodes: Array<{ id: string; name: string; email: string }> }; + }>( + `query Users($filter: UserFilter) { + users(filter: $filter, first: 5) { + nodes { id name email } + } + }`, + { + filter: a.includes("@") + ? { email: { eqIgnoreCase: a } } + : { name: { containsIgnoreCase: a } }, + }, + ); + const u = users.users.nodes[0]; + if (!u) throw new Error(`No Linear user matching assignee "${a}"`); + input.assigneeId = u.id; + } + } + + const data = await linearGql<{ + issueCreate: { + success: boolean; + issue: Parameters[0] | null; + }; + }>( + `mutation CreateIssue($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { ${ISSUE_FIELDS} } + } + }`, + { input }, + ); + + if (!data.issueCreate.success || !data.issueCreate.issue) { + throw new Error("Linear issueCreate failed"); + } + + return { project, issue: mapIssue(data.issueCreate.issue) }; +} + +export type UpdateIssueOpts = { + id: string; + title?: string; + description?: string; + priority?: number; + state?: string; + assignee?: string | null; +}; + +/** + * Update an issue only if it belongs to the eve project. + */ +export async function updateEveIssue( + opts: UpdateIssueOpts, +): Promise<{ project: LinearProject; issue: LinearIssueSummary }> { + // Scope check first + await getEveIssue(opts.id); + + const input: Record = {}; + if (opts.title !== undefined) input.title = opts.title; + if (opts.description !== undefined) input.description = opts.description; + if (opts.priority !== undefined) input.priority = opts.priority; + + if (opts.state?.trim()) { + const states = await listProjectStates(); + const want = opts.state.trim().toLowerCase(); + const match = + states.find((s) => s.name.toLowerCase() === want) ?? + states.find((s) => s.type.toLowerCase() === want); + if (!match) { + throw new Error( + `Unknown state "${opts.state}". Known: ${states.map((s) => s.name).join(", ")}`, + ); + } + input.stateId = match.id; + } + + if (opts.assignee !== undefined) { + if (opts.assignee === null || opts.assignee === "") { + input.assigneeId = null; + } else { + const a = opts.assignee.trim(); + if (a.toLowerCase() === "me") { + const me = await linearGql<{ viewer: { id: string } }>( + `query { viewer { id } }`, + ); + input.assigneeId = me.viewer.id; + } else { + const users = await linearGql<{ + users: { nodes: Array<{ id: string; name: string; email: string }> }; + }>( + `query Users($filter: UserFilter) { + users(filter: $filter, first: 5) { + nodes { id name email } + } + }`, + { + filter: a.includes("@") + ? { email: { eqIgnoreCase: a } } + : { name: { containsIgnoreCase: a } }, + }, + ); + const u = users.users.nodes[0]; + if (!u) throw new Error(`No Linear user matching assignee "${a}"`); + input.assigneeId = u.id; + } + } + } + + if (Object.keys(input).length === 0) { + throw new Error("No update fields provided"); + } + + const data = await linearGql<{ + issueUpdate: { + success: boolean; + issue: Parameters[0] | null; + }; + }>( + `mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + issue { ${ISSUE_FIELDS} } + } + }`, + { id: opts.id.trim(), input }, + ); + + if (!data.issueUpdate.success || !data.issueUpdate.issue) { + throw new Error("Linear issueUpdate failed"); + } + + const project = await resolveEveProject(); + const issue = mapIssue(data.issueUpdate.issue); + if (issue.projectId !== project.id) { + throw new Error( + `Refusing result outside project "${project.name}" (${issue.identifier}).`, + ); + } + + return { project, issue }; +} diff --git a/agent/schedules/vit-request-caps.ts b/agent/schedules/vit-request-caps.ts index 1a9d482..b1e21c3 100644 --- a/agent/schedules/vit-request-caps.ts +++ b/agent/schedules/vit-request-caps.ts @@ -16,8 +16,9 @@ import { } from "../lib/vit-request-caps.js"; const REPORT_EMPTY = () => - process.env.VIT_REQUEST_REPORT_EMPTY === "1" || - process.env.VIT_REQUEST_REPORT_EMPTY === "true"; + process.env.VIT_REQUEST_REPORT_EMPTY !== "0" && + process.env.VIT_REQUEST_REPORT_EMPTY !== "false"; +// boxd default: report even when empty so manual triggers are visible export default defineSchedule({ cron: "*/10 * * * *", diff --git a/agent/skills/freeq-irc/SKILL.md b/agent/skills/freeq-irc/SKILL.md index b154fa6..c8c24f2 100644 --- a/agent/skills/freeq-irc/SKILL.md +++ b/agent/skills/freeq-irc/SKILL.md @@ -16,12 +16,12 @@ description: >- | Nick `Guest…` or host `freeq/guest` | SASL `ATPROTO-CHALLENGE` failed; freeq force-renames unauthenticated clients off reserved DID nicks | | Log: `SASL failed (904)` | Freeq session OAuth token invalid/expired | | Log: `Invalid OAuth access token` on getSession | Same — freeq `*.session.json` is stale | -| Wanted nick `eve` but not authenticated as `did:plc:76szbe2ywgwb7vzuingj4fhq` | Session missing or not loaded | +| Wanted nick `eve` but not authenticated as `did:plc:fdiivi2izdgx3rl2d4qedt7n` | Session missing or not loaded | Correct state after fix: ```text -[irc-bridge] SASL success as did:plc:76szbe2ywgwb7vzuingj4fhq +[irc-bridge] SASL success as did:plc:fdiivi2izdgx3rl2d4qedt7n [irc-bridge] welcome 001 nick=eve preferred=eve sasl=ok [irc-bridge] joined #test as eve ``` @@ -34,9 +34,9 @@ reclaims that nick after SASL if freeq force-renamed us, refuses to stay as | Field | Value | |-------|--------| -| Handle | `eve.rookery.boxd.sh` | -| DID | `did:plc:76szbe2ywgwb7vzuingj4fhq` | -| PDS | `https://rookery.boxd.sh` | +| Handle | `eve.boxd.sh` | +| DID | `did:plc:fdiivi2izdgx3rl2d4qedt7n` | +| PDS | `https://pds.eve.boxd.sh` (rookery unit on eve) | | IRC nick | `eve` (`IRC_NICK`) | | Channel | `#test` (`IRC_CHANNEL`) | | Host | `irc.freeq.at:6697` TLS | @@ -47,20 +47,22 @@ reclaims that nick after SASL if freeq force-renamed us, refuses to stay as |------|------| | `~/.config/rook/identity.json` | Rook identity (DID, handle, keys) | | `~/.config/rook/identity.session.json` | Headless OAuth session (refreshable via `rook login`) | -| `~/.config/freeq-tui/eve.rookery.boxd.sh.session.json` | **Primary** freeq SASL session (`IRC_FREEQ_SESSION`) | -| `~/.config/freeq/eve.rookery.boxd.sh.session.json` | Fallback freeq session path | +| `~/.config/freeq-tui/eve.boxd.sh.session.json` | **Primary** freeq SASL session (`IRC_FREEQ_SESSION`) | +| `~/.config/freeq/eve.boxd.sh.session.json` | Fallback freeq session path | | `~/.config/freeq/eve.session.json` | Older fallback | -| `/home/boxd/start.sh` | Boot: OpenBao keys + IRC env + `eve dev` | -| `/home/boxd/my-agent/agent/channels/irc.ts` | IRC client (loads freeq session, SASL pds-oauth) | +| `/home/boxd/rookery` + `rookery.service` | Single-user PDS (`pds.eve.boxd.sh:8787`) | +| `/home/boxd/my-agent/scripts/prep.sh` | Prep oneshot (OpenBao keys, freeq session, build) | +| `~/.config/systemd/user/eve*.service` | User units for agent + IRC bridge (+ optional AV) | +| `/home/boxd/my-agent/agent/channels/irc.ts` | IRC channel wiring (HTTP inbound only; socket is bridge) | Freeq session JSON shape: ```json { "did": "did:plc:…", - "handle": "eve.rookery.boxd.sh", + "handle": "eve.boxd.sh", "access_token": "rkat_…", - "pds_url": "https://rookery.boxd.sh", + "pds_url": "https://pds.eve.boxd.sh", "dpop_key": "", "dpop_nonce": null } @@ -96,11 +98,16 @@ If the script is missing, equivalent logic: read `identity.session.json` → tak IRC sockets live in `irc-bridge/server.mjs`, not in the eve process. ```bash -# restart bridge only (keeps eve up): -# kill node irc-bridge/server.mjs by pid, then: +# preferred: systemd user unit (keeps eve up) +systemctl --user restart eve-irc-bridge.service +journalctl --user -u eve-irc-bridge.service -n 30 --no-pager + +# full stack refresh (prep + eve + bridge): +systemctl --user start eve-prep.service +systemctl --user restart eve.service eve-irc-bridge.service + +# legacy (no units): kill old bridge pid if any, then: EVE_URL=http://127.0.0.1:8000 nohup node irc-bridge/server.mjs >> /tmp/irc-bridge.log 2>&1 & -# or full boot: -nohup /home/boxd/start.sh > /tmp/eve-start.log 2>&1 & grep -E 'SASL success|joined |SASL failed|SSE' /tmp/irc-bridge.log | tail -10 ``` @@ -122,9 +129,9 @@ boxd exec eve -- bash -lc 'npx --yes @solpbc/rook login && node /home/boxd/my-ag ## Prevention -- Access tokens expire ~hours. On boot, `start.sh` should run `rook login` + `sync-freeq-session.mjs` before `eve start` / irc-bridge (if not already wired, do it when fixing nick again). -- After **hibernate → wake**, TCP to freeq is often half-dead: restart the bridge even if tokens are still valid. -- Keep `openbao` reachable if keys come from OpenBao at start. +- Access tokens expire ~hours. On boot, `eve-prep.service` / `scripts/prep.sh` runs `rook login` + `sync-freeq-session.mjs` before agent and bridge. +- After **hibernate → wake**, TCP to freeq is often half-dead: `systemctl --user restart eve-irc-bridge.service` even if tokens are still valid. +- Keep `openbao` reachable if keys come from OpenBao at prep (`~/.config/eve/openbao.env`). - `IRC_REQUIRE_AUTH` defaults on for freeq hosts: bridge will not sit in `#test` as `Guest*`; it reconnects until SASL lands `eve`. ## Related diff --git a/agent/skills/freeq-irc/scripts/sync-freeq-session.mjs b/agent/skills/freeq-irc/scripts/sync-freeq-session.mjs index c9ce7cb..d201eb4 100644 --- a/agent/skills/freeq-irc/scripts/sync-freeq-session.mjs +++ b/agent/skills/freeq-irc/scripts/sync-freeq-session.mjs @@ -23,9 +23,9 @@ const ROOK_SESSION = const ROOK_IDENTITY = process.env.ROOK_IDENTITY_FILE ?? path.join(HOME, ".config/rook/identity.json"); -const DEFAULT_PDS = "https://rookery.boxd.sh"; -const DEFAULT_DID = "did:plc:76szbe2ywgwb7vzuingj4fhq"; -const DEFAULT_HANDLE = "eve.rookery.boxd.sh"; +const DEFAULT_PDS = "https://pds.eve.boxd.sh"; +const DEFAULT_DID = "did:plc:fdiivi2izdgx3rl2d4qedt7n"; +const DEFAULT_HANDLE = "eve.boxd.sh"; function b64url(buf) { return Buffer.from(buf).toString("base64url"); diff --git a/agent/skills/freeq-radio/SKILL.md b/agent/skills/freeq-radio/SKILL.md index 05696ed..c99702e 100644 --- a/agent/skills/freeq-radio/SKILL.md +++ b/agent/skills/freeq-radio/SKILL.md @@ -2,7 +2,7 @@ name: freeq-radio description: >- Stream internet radio into a freeq AV (voice) call when users say play radio, - put on music, stop radio, or name a station. Tools: play_radio, stop_radio. + put on music, stop radio, or name a station. Tools: play_radio, stop_radio, watch_stream. Needs irc-bridge control API + eve-av-bridge + ffmpeg. --- @@ -48,3 +48,11 @@ user → eve play_radio → irc-bridge :8791 /radio/play If play fails: check av-bridge health `curl :8790/health`, control `curl :8791/health`, and that `ffmpeg` is on PATH. + + +## stream.place (`watch`) + +- Channel command (instant): `eve: watch https://stream.place/handle` +- Also: `eve: watch handle` / `eve: watch did:plc:…` +- Tool: **`watch_stream`** for natural language ("put on iame.li stream"). +- Switches the stream.place MoQ plane (one plane at a time; replaces radio). diff --git a/agent/skills/irc-backlog/SKILL.md b/agent/skills/irc-backlog/SKILL.md index 3fdd351..0ae0430 100644 --- a/agent/skills/irc-backlog/SKILL.md +++ b/agent/skills/irc-backlog/SKILL.md @@ -41,11 +41,21 @@ Logs: Set on the eve VM before `start.sh` (or export in `start.sh`). -## No scrollback in the model turn +## Background scrollback (safe context) -The bridge does **not** attach channel history as `SendPayload.context`. -eve would inject that as `role:user` messages and models answer every line -(old mentions, rejoin flood, etc.). Inbound is the mention body only. +After join backlog ends, the bridge keeps a per-target ring buffer of live +PRIVMSGs and attaches **one framed** `` blob on each +mention via `SendPayload.context`. + +eve injects each context string as `role:user`, so we never send raw +per-line history. The blob is labeled BACKGROUND ONLY; historical bot +mentions are stored as `role=prior_mention` (not open requests). The agent +must answer **only** the current mention delivery message. + +| Variable | Default | Meaning | +|----------|---------|---------| +| `IRC_CONTEXT_LINES` | `40` | Ring buffer size; `0` disables context | +| `IRC_CONTEXT_MAX_CHARS` | `6000` | Cap on the framed blob size | ## Agent behavior if a historical mention still arrives diff --git a/agent/skills/linear/SKILL.md b/agent/skills/linear/SKILL.md new file mode 100644 index 0000000..a272744 --- /dev/null +++ b/agent/skills/linear/SKILL.md @@ -0,0 +1,115 @@ +--- +name: linear +description: >- + Linear issues for the eve project only. ALWAYS create a ticket when someone + gives feedback, a suggestion, a bug report, or a feature idea. Also: status + overview, list/filter, get/update issues. LINEAR_API_KEY from OpenBao. + Activate on feedback, suggestion, idea, bug, ticket, Linear, board status. +--- + +# Linear (eve project) + +All tools are **hard-scoped** to the Linear project named **eve** (override with +`LINEAR_PROJECT_NAME` / `LINEAR_PROJECT_ID`). Issues outside that project are +rejected. Never invent issue ids or print `LINEAR_API_KEY`. + +Auth: personal API key in OpenBao path `secret/data/ai-api-keys` as +`LINEAR_API_KEY`, injected by `scripts/fetch-keys.sh` / `scripts/start.sh`. + +## Rule: feedback → ticket (always) + +**Whenever** a human gives **feedback**, a **suggestion**, a **feature idea**, a +**bug report**, a **complaint**, or a **product/UX request** about eve (or +related stack: IRC, radio, rook, tools, skills), you **must** create a Linear +issue with **`linear_create_issue`** on the eve project. Do this **in the same +turn** — do not only acknowledge in chat. + +### When it counts + +Create a ticket if they: + +- Suggest a change, improvement, or new behavior +- Report something broken, missing, or confusing +- Give opinions on UX/tone/tools (“should…”, “wish…”, “please add…”, “bug:…”) +- File work explicitly (“open a ticket”, “track this”, “put it on the board”) + +### When to skip + +Do **not** create a ticket for: + +- Pure Q&A with no ask to change product (“what is thermals?”) +- Status checks (“what’s open on Linear?”) — use `linear_status` / `linear_issues` +- Transient ops (“restart the bridge”, “play radio”) unless they also want it tracked +- They explicitly say **don’t** open a ticket / don’t track this + +If unsure whether it is feedback vs chitchat: **create the ticket** (prefer over-capture). + +### How to file + +1. Optional: `linear_issues` with a short `query` to avoid an obvious duplicate of + a still-open issue; if a clear open dupe exists, comment via update description + or tell them the existing id — do not spam duplicates. +2. **`linear_create_issue`**: + - **title**: short imperative summary (≤80 chars), e.g. `Add Linear status to IRC one-liners` + - **description** (markdown): + - What they said (paraphrase + quote key phrases) + - Who: IRC nick if known + - Context: channel / that it was feedback or suggestion + - Acceptance notes if obvious + - **priority**: `3` normal default; `2` if clearly blocking; `1` only if urgent outage; `4` nice-to-have + - **state**: leave default / backlog unless they want it started +3. Reply (IRC one line) with the new **identifier** + title, e.g. + `nandi: filed SWARM-44 "…" — thanks, tracked on Linear eve.` + +Never invent identifiers. Never print the API key. Always scope is the **eve** project only. + +## Tools + +| Tool | Purpose | +|------|---------| +| **`linear_create_issue`** | File feedback/suggestions (and any new work) on eve | +| **`linear_status`** | Project progress + counts by state + sample open issues | +| **`linear_issues`** | List/filter issues (query, state, state_type, assignee) | +| **`linear_issue`** | Full detail for one id / identifier | +| `linear_update_issue` | Update title/description/priority/state/assignee (eve only) | + +## Env + +| Variable | Role | +|----------|------| +| `LINEAR_API_KEY` | OpenBao key → GraphQL `Authorization` header | +| `LINEAR_PROJECT_NAME` | Project name lookup (default **`eve`**) | +| `LINEAR_PROJECT_ID` | Optional UUID; skips name lookup | + +## Other workflows + +### Status / “how’s the project?” + +1. **`linear_status`** — open/started/completed counts + sample. +2. Drill with **`linear_issues`** (`state_type=started`, etc.). + +### Find items + +- Open work: `linear_issues` (default excludes completed/canceled). +- Search: `linear_issues` with `query`. +- One ticket: `linear_issue` with `id=TEAM-123`. + +### Update + +- `linear_update_issue` with id + fields; `state` accepts name (`Done`) or type (`completed`). +- Unassign: `assignee=""`. + +Priority: `0` none · `1` urgent · `2` high · `3` normal · `4` low. + +## Hard rules + +1. Never leak `LINEAR_API_KEY`. +2. Do not query or mutate issues outside the configured eve project. +3. **Feedback/suggestion → `linear_create_issue` same turn** (see above). +4. If key missing: tell human to put `LINEAR_API_KEY` in OpenBao `ai-api-keys` and restart via `start.sh`. +5. IRC: one line — e.g. `nandi: filed SWARM-44 "…"; linear eve: 4 open, 2 started.` + +## API + +- Endpoint: `https://api.linear.app/graphql` +- Docs: https://linear.app/developers/graphql · https://linear.app/developers/filtering diff --git a/agent/tools/linear_create_issue.ts b/agent/tools/linear_create_issue.ts new file mode 100644 index 0000000..4d7ef09 --- /dev/null +++ b/agent/tools/linear_create_issue.ts @@ -0,0 +1,73 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { createEveIssue, hasLinearApiKey } from "../lib/linear.js"; + +export default defineTool({ + description: + "Create a Linear issue always attached to the eve project (cannot target " + + "other projects). Title required; optional description, priority (0–4), " + + "state name/type, assignee (name/email/me). Requires LINEAR_API_KEY. " + + "Never echo the key.", + inputSchema: z.object({ + title: z.string().min(1).describe("Issue title."), + description: z + .string() + .optional() + .describe("Markdown description body."), + priority: z + .number() + .int() + .min(0) + .max(4) + .optional() + .describe("0=none, 1=urgent, 2=high, 3=normal, 4=low."), + state: z + .string() + .optional() + .describe('Workflow state name or type, e.g. "Todo", "started".'), + assignee: z + .string() + .optional() + .describe('Assignee name, email, or "me".'), + }), + outputSchema: z.object({ + project: z.string(), + issue: z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + url: z.string(), + state: z.string(), + priorityLabel: z.string(), + assignee: z.string().nullable(), + }), + }), + async execute({ title, description, priority, state, assignee }) { + if (!hasLinearApiKey()) { + throw new Error( + "LINEAR_API_KEY missing — inject from OpenBao via scripts/start.sh.", + ); + } + + const { project, issue } = await createEveIssue({ + title, + description, + priority, + state, + assignee, + }); + + return { + project: project.name, + issue: { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + url: issue.url, + state: issue.state.name, + priorityLabel: issue.priorityLabel, + assignee: issue.assignee?.name ?? null, + }, + }; + }, +}); diff --git a/agent/tools/linear_issue.ts b/agent/tools/linear_issue.ts new file mode 100644 index 0000000..3d42f3f --- /dev/null +++ b/agent/tools/linear_issue.ts @@ -0,0 +1,67 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { getEveIssue, hasLinearApiKey } from "../lib/linear.js"; + +export default defineTool({ + description: + "Get one Linear issue by identifier (e.g. EVE-12) or UUID. " + + "Only issues on the eve project are returned; others are rejected. " + + "Use after linear_issues or linear_status. Requires LINEAR_API_KEY.", + inputSchema: z.object({ + id: z + .string() + .min(1) + .describe( + "Issue identifier (TEAM-123) or UUID from linear_issues / Linear UI.", + ), + }), + outputSchema: z.object({ + project: z.string(), + issue: z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + description: z.string().nullable(), + url: z.string(), + priority: z.number(), + priorityLabel: z.string(), + state: z.string(), + stateType: z.string(), + assignee: z.string().nullable(), + labels: z.array(z.string()), + createdAt: z.string(), + updatedAt: z.string(), + completedAt: z.string().nullable(), + dueDate: z.string().nullable(), + }), + }), + async execute({ id }) { + if (!hasLinearApiKey()) { + throw new Error( + "LINEAR_API_KEY missing — inject from OpenBao via scripts/start.sh.", + ); + } + + const { project, issue } = await getEveIssue(id); + return { + project: project.name, + issue: { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description, + url: issue.url, + priority: issue.priority, + priorityLabel: issue.priorityLabel, + state: issue.state.name, + stateType: issue.state.type, + assignee: issue.assignee?.name ?? null, + labels: issue.labels, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + completedAt: issue.completedAt, + dueDate: issue.dueDate, + }, + }; + }, +}); diff --git a/agent/tools/linear_issues.ts b/agent/tools/linear_issues.ts new file mode 100644 index 0000000..91d4dff --- /dev/null +++ b/agent/tools/linear_issues.ts @@ -0,0 +1,123 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { + hasLinearApiKey, + listEveIssues, + type LinearStateType, +} from "../lib/linear.js"; + +const stateTypes = [ + "triage", + "backlog", + "unstarted", + "started", + "completed", + "canceled", + "duplicate", +] as const; + +const issueSchema = z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + url: z.string(), + priority: z.number(), + priorityLabel: z.string(), + state: z.string(), + stateType: z.string(), + assignee: z.string().nullable(), + labels: z.array(z.string()), + updatedAt: z.string(), + dueDate: z.string().nullable(), +}); + +export default defineTool({ + description: + "List Linear issues in the eve project only (scoped automatically). " + + "Filter by free-text query, state name, state type, or assignee. " + + "Default is open issues (not completed/canceled). Use linear_status for " + + "counts/overview, linear_issue for one issue's full detail. " + + "Requires LINEAR_API_KEY from OpenBao.", + inputSchema: z.object({ + query: z + .string() + .optional() + .describe("Search title/description (case-insensitive contains)."), + state: z + .string() + .optional() + .describe('Workflow state name, e.g. "In Progress", "Todo", "Done".'), + state_type: z + .enum(stateTypes) + .optional() + .describe( + "State category: triage | backlog | unstarted | started | completed | canceled | duplicate.", + ), + assignee: z + .string() + .optional() + .describe('Assignee name, email, or "me" for the API key owner.'), + include_closed: z + .boolean() + .optional() + .describe( + "Include completed/canceled when no state filter is set. Default false.", + ), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Max issues to return (1–100). Defaults to 25."), + }), + outputSchema: z.object({ + project: z.string(), + projectUrl: z.string(), + count: z.number(), + issues: z.array(issueSchema), + }), + async execute({ + query, + state, + state_type, + assignee, + include_closed = false, + limit = 25, + }) { + if (!hasLinearApiKey()) { + throw new Error( + "LINEAR_API_KEY missing — inject from OpenBao via scripts/start.sh.", + ); + } + + const { project, issues, count } = await listEveIssues({ + query, + state, + stateType: state_type as LinearStateType | undefined, + assignee, + includeClosed: include_closed, + limit, + }); + + return { + project: project.name, + projectUrl: project.url, + count, + issues: issues.map((i) => ({ + id: i.id, + identifier: i.identifier, + title: i.title, + url: i.url, + priority: i.priority, + priorityLabel: i.priorityLabel, + state: i.state.name, + stateType: i.state.type, + assignee: i.assignee?.name ?? null, + labels: i.labels, + updatedAt: i.updatedAt, + dueDate: i.dueDate, + })), + }; + }, +}); diff --git a/agent/tools/linear_status.ts b/agent/tools/linear_status.ts new file mode 100644 index 0000000..5e5fa94 --- /dev/null +++ b/agent/tools/linear_status.ts @@ -0,0 +1,105 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { eveProjectStatus, hasLinearApiKey } from "../lib/linear.js"; + +const issueBrief = z.object({ + identifier: z.string(), + title: z.string(), + state: z.string(), + stateType: z.string(), + priorityLabel: z.string(), + assignee: z.string().nullable(), + url: z.string(), + updatedAt: z.string(), +}); + +export default defineTool({ + description: + "Status overview of Linear items for the eve project only: progress, " + + "counts by state (open/started/completed), and a sample of open issues. " + + "Use when asked how the project is doing, what's open, or Linear status. " + + "Requires LINEAR_API_KEY (OpenBao). Never returns the API key.", + inputSchema: z.object({ + sample_limit: z + .number() + .int() + .min(0) + .max(50) + .optional() + .describe("How many open issues to sample (0–50). Defaults to 10."), + }), + outputSchema: z.object({ + configured: z.boolean(), + project: z + .object({ + id: z.string(), + name: z.string(), + state: z.string(), + progress: z.number(), + url: z.string(), + targetDate: z.string().nullable(), + startDate: z.string().nullable(), + lead: z.string().nullable(), + teams: z.array(z.string()), + }) + .optional(), + open: z.number().optional(), + started: z.number().optional(), + completed: z.number().optional(), + canceled: z.number().optional(), + total: z.number().optional(), + byStateType: z.record(z.string(), z.number()).optional(), + byStateName: z.record(z.string(), z.number()).optional(), + sampleOpen: z.array(issueBrief).optional(), + error: z.string().optional(), + say: z.string().optional(), + }), + async execute({ sample_limit = 10 }) { + if (!hasLinearApiKey()) { + return { + configured: false, + error: "LINEAR_API_KEY missing", + say: + "Linear is not configured — LINEAR_API_KEY must come from OpenBao " + + "(ai-api-keys) via scripts/start.sh.", + }; + } + + const status = await eveProjectStatus(sample_limit); + const p = status.project; + return { + configured: true, + project: { + id: p.id, + name: p.name, + state: p.state, + progress: p.progress, + url: p.url, + targetDate: p.targetDate, + startDate: p.startDate, + lead: p.lead?.name ?? null, + teams: p.teams.map((t) => `${t.key} (${t.name})`), + }, + open: status.open, + started: status.started, + completed: status.completed, + canceled: status.canceled, + total: status.total, + byStateType: status.byStateType, + byStateName: status.byStateName, + sampleOpen: status.sampleOpen.map((i) => ({ + identifier: i.identifier, + title: i.title, + state: i.state.name, + stateType: i.state.type, + priorityLabel: i.priorityLabel, + assignee: i.assignee?.name ?? null, + url: i.url, + updatedAt: i.updatedAt, + })), + say: + `Linear project "${p.name}": ${status.open} open, ${status.started} started, ` + + `${status.completed} completed (of ${status.total} seen).`, + }; + }, +}); diff --git a/agent/tools/linear_update_issue.ts b/agent/tools/linear_update_issue.ts new file mode 100644 index 0000000..29cebff --- /dev/null +++ b/agent/tools/linear_update_issue.ts @@ -0,0 +1,80 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { hasLinearApiKey, updateEveIssue } from "../lib/linear.js"; + +export default defineTool({ + description: + "Update a Linear issue only if it belongs to the eve project. " + + "Change title, description, priority, state (name or type), or assignee. " + + "Pass assignee empty string to unassign. Requires LINEAR_API_KEY.", + inputSchema: z.object({ + id: z + .string() + .min(1) + .describe("Issue identifier (TEAM-123) or UUID."), + title: z.string().optional().describe("New title."), + description: z.string().optional().describe("New markdown description."), + priority: z + .number() + .int() + .min(0) + .max(4) + .optional() + .describe("0=none, 1=urgent, 2=high, 3=normal, 4=low."), + state: z + .string() + .optional() + .describe('New state name or type, e.g. "Done", "completed", "In Progress".'), + assignee: z + .string() + .nullable() + .optional() + .describe('Assignee name/email/"me", or empty/null to unassign.'), + }), + outputSchema: z.object({ + project: z.string(), + issue: z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + url: z.string(), + state: z.string(), + priorityLabel: z.string(), + assignee: z.string().nullable(), + }), + }), + async execute({ id, title, description, priority, state, assignee }) { + if (!hasLinearApiKey()) { + throw new Error( + "LINEAR_API_KEY missing — inject from OpenBao via scripts/start.sh.", + ); + } + + const { project, issue } = await updateEveIssue({ + id, + title, + description, + priority, + state, + assignee: + assignee === undefined + ? undefined + : assignee === null || assignee === "" + ? null + : assignee, + }); + + return { + project: project.name, + issue: { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + url: issue.url, + state: issue.state.name, + priorityLabel: issue.priorityLabel, + assignee: issue.assignee?.name ?? null, + }, + }; + }, +}); diff --git a/agent/tools/watch_stream.ts b/agent/tools/watch_stream.ts new file mode 100644 index 0000000..4890f3f --- /dev/null +++ b/agent/tools/watch_stream.ts @@ -0,0 +1,77 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +/** + * Flip freeq AV to a stream.place live stream (MoQ plane via irc-bridge). + */ + +const CONTROL = process.env.IRC_CONTROL_URL ?? "http://127.0.0.1:8791"; + +export default defineTool({ + description: + "Watch / switch to a stream.place live stream on freeq AV. " + + "Use when the user says watch, switch stream, put on stream.place, " + + "or pastes a stream.place URL/handle. Prefer this over play_radio for stream.place. " + + "After ok:true tell user to join freeq voice in that channel.", + inputSchema: z.object({ + streamer: z + .string() + .describe( + "stream.place URL (https://stream.place/handle), handle (iame.li), or did:plc:…", + ), + channel: z + .string() + .optional() + .describe("IRC channel for AV, e.g. #test. Default from bridge."), + }), + async execute({ streamer, channel }) { + const res = await fetch(`${CONTROL}/streamplace/play`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + streamer, + channel: channel || undefined, + }), + signal: AbortSignal.timeout(90_000), + }); + const json = (await res.json().catch(() => ({}))) as { + ok?: boolean; + error?: string; + stream?: { + handle?: string; + did?: string; + title?: string; + viewers?: number | null; + url?: string; + }; + av?: { channel?: string; sessionId?: string }; + plane?: string; + }; + + if (!res.ok || json.ok === false) { + return { + ok: false, + error: json.error || `streamplace play HTTP ${res.status}`, + say: "Couldn't switch the stream — short apology.", + }; + } + + const handle = json.stream?.handle ?? streamer; + const title = json.stream?.title ?? handle; + const ch = json.av?.channel || channel || "#test"; + const viewers = + json.stream?.viewers != null ? `${json.stream.viewers} watching` : "live"; + + return { + ok: true, + handle, + title, + viewers: json.stream?.viewers ?? null, + url: json.stream?.url ?? `https://stream.place/${handle}`, + channel: ch, + sessionId: json.av?.sessionId, + plane: json.plane, + say: `Now watching ${title} (@${handle}, ${viewers}) on freeq AV in ${ch} — join voice there.`, + }; + }, +}); diff --git a/irc-bridge/README.md b/irc-bridge/README.md index 7676e86..f1632a5 100644 --- a/irc-bridge/README.md +++ b/irc-bridge/README.md @@ -8,15 +8,17 @@ irc.freeq.at ←TLS→ irc-bridge ──POST /irc/inbound──► eve :8000 ``` 1. Channel PRIVMSGs after JOIN are ignored until the join backlog ends - (`IRC_BACKLOG_*` min/gap/max). + (`IRC_BACKLOG_*` min/gap/max). Live lines after that go into a per-target + ring buffer (`IRC_CONTEXT_LINES`, default 40). 2. A live mention → bridge immediately 👀-reacts (TAGMSG `+react` / - `+reply=`), then `POST`s `{from,target,text,msgid}` to eve. - **No scrollback** is attached — eve's `SendPayload.context` becomes - `role:user` history and models answer every historical line, so we send - only the mention body. -3. Eve runs the agent turn on that single message. + `+reply=`), then `POST`s `{from,target,text,msgid,context}` to eve. + `context` is **one framed** `` blob (background only), + not raw per-line history — eve injects context as `role:user`, so framing + + instructions tell the model not to answer scrollback. +3. Eve runs the agent turn: background blob (if any) + current mention only. 4. On `message.completed`, eve pushes an SSE `privmsg` event. -5. Bridge reads SSE and sends `PRIVMSG` on IRC. +5. Bridge reads SSE and sends `PRIVMSG` on IRC (and records own replies in + the ring buffer). ## Run @@ -39,11 +41,15 @@ node irc-bridge/server.mjs | `IRC_FREEQ_SESSION` | freeq-tui session JSON | | `IRC_REQUIRE_AUTH` | auto-on for freeq hosts; refuse `Guest*` / reconnect until SASL nick is `IRC_NICK` | | `IRC_BACKLOG_*` | join history ignore | +| `IRC_CONTEXT_LINES` | live ring buffer size for background context (default `40`; `0` = off) | +| `IRC_CONTEXT_MAX_CHARS` | max framed context blob size (default `6000`) | | `IRC_WORKING_REACT` | emoji for “working on it” (default `👀`) | | `IRC_CONTROL_HOST` / `IRC_CONTROL_PORT` | control HTTP (default `127.0.0.1:8791`) | | `AV_BRIDGE_URL` | eve-av-bridge base (default `http://127.0.0.1:8790`) | | `SFU_URL` | MoQ SFU (default freeq `https://irc.freeq.at:8080/av/moq`) | | `FREEQ_API_BASE` | REST for session discovery (default `https://`) | +| `RADIO_ANNOUNCE` | `1` (default) — PRIVMSG when ICY song title changes | +| `RADIO_ANNOUNCE_MS` | poll interval for title changes (default `2000`) | ### Control HTTP (eve tools) @@ -52,5 +58,30 @@ node irc-bridge/server.mjs | `/av/ensure` | `{ channel?, title? }` | av_start/join + connect media | | `/radio/play` | `{ url, channel?, title? }` | ensure AV + stream radio | | `/radio/stop` | | stop decode | +| `/radio/now-playing` | `{ title, channel? }` | announce song (from av-bridge `RADIO_TITLE_HOOK` or tooling) | Needs **eve-av-bridge** running with **ffmpeg** for radio. + +### Now-playing (song changes) + +When radio is playing, the bridge announces each new ICY `StreamTitle` as a +single PRIVMSG on the radio channel: + +``` +now playing: Artist - Track +``` + +Two paths (deduped): + +1. **Push** — set `RADIO_TITLE_HOOK=http://127.0.0.1:8791/radio/now-playing` on + eve-av-bridge (default in `scripts/run-av-bridge.sh`). +2. **Poll** — every `RADIO_ANNOUNCE_MS`, GET av-bridge `/v1/status` for + `radio.title` (works with older av-bridge builds that only expose status). + + +## Channel commands + +| Command | Effect | +|---------|--------| +| `eve: watch https://stream.place/` | switch stream.place rebroadcast to that streamer | +| `eve: watch ` / `eve: watch did:plc:…` | same | diff --git a/irc-bridge/server.mjs b/irc-bridge/server.mjs index 61420e4..3a322df 100644 --- a/irc-bridge/server.mjs +++ b/irc-bridge/server.mjs @@ -48,6 +48,15 @@ const PING_AFTER_MS = envMs("IRC_PING_AFTER_MS", 60_000); const DEAD_AFTER_MS = envMs("IRC_DEAD_AFTER_MS", 120_000); const TCP_KEEPALIVE_MS = envMs("IRC_TCP_KEEPALIVE_MS", 30_000); +/** + * Live channel/DM ring buffer for eve background context. + * Set IRC_CONTEXT_LINES=0 to disable. Context is one framed blob (not + * answerable history) — see formatContext(). + */ +const CONTEXT_LINES = envInt("IRC_CONTEXT_LINES", 40); +const CONTEXT_MAX_CHARS = envInt("IRC_CONTEXT_MAX_CHARS", 6_000); +const CONTEXT_ENABLED = CONTEXT_LINES > 0; + /** Control HTTP for eve tools (play radio / ensure AV). */ const CONTROL_HOST = process.env.IRC_CONTROL_HOST ?? "127.0.0.1"; const CONTROL_PORT = Number(process.env.IRC_CONTROL_PORT ?? 8791); @@ -55,6 +64,87 @@ const CONTROL_PORT = Number(process.env.IRC_CONTROL_PORT ?? 8791); const AV_BRIDGE_URL = ( process.env.AV_BRIDGE_URL ?? "http://127.0.0.1:8790" ).replace(/\/$/, ""); +/** Second MoQ plane — stream.place live rebroadcast (default :8792). */ +const STREAMPLACE_AV_BRIDGE_URL = ( + process.env.STREAMPLACE_AV_BRIDGE_URL ?? "http://127.0.0.1:8792" +).replace(/\/$/, ""); +/** stream.place XRPC base */ +const STREAMPLACE_API = ( + process.env.STREAMPLACE_API ?? "https://stream.place" +).replace(/\/$/, ""); +/** Auto-start stream.place → #test on bridge boot (1/true = on). */ +const STREAMPLACE_AUTO = + process.env.STREAMPLACE_AUTO === "1" || + process.env.STREAMPLACE_AUTO === "true"; + +/** Persist last explicit stream.place target so restarts don't clobber `watch`. */ +const STREAMPLACE_PREF_PATH = + process.env.STREAMPLACE_PREF_PATH ?? + path.join(os.homedir(), ".config/eve/streamplace-watch.json"); + +function loadStreamplacePref() { + try { + if (!fs.existsSync(STREAMPLACE_PREF_PATH)) return null; + const j = JSON.parse(fs.readFileSync(STREAMPLACE_PREF_PATH, "utf8")); + if (j && typeof j.streamer === "string" && j.streamer.trim()) { + return { + streamer: j.streamer.trim(), + channel: typeof j.channel === "string" ? j.channel : IRC_CHANNEL, + at: j.at ?? null, + }; + } + } catch (e) { + log(`streamplace pref load: ${e instanceof Error ? e.message : e}`); + } + return null; +} + +function saveStreamplacePref(streamer, channel) { + try { + const id = parseStreamplaceTarget(streamer) || String(streamer || "").trim(); + if (!id) return; + fs.mkdirSync(path.dirname(STREAMPLACE_PREF_PATH), { recursive: true }); + fs.writeFileSync( + STREAMPLACE_PREF_PATH, + `${JSON.stringify( + { + streamer: id, + channel: channel || IRC_CHANNEL, + at: new Date().toISOString(), + }, + null, + 2, + )}\n`, + { mode: 0o600 }, + ); + log(`streamplace pref saved: ${id}`); + } catch (e) { + log(`streamplace pref save: ${e instanceof Error ? e.message : e}`); + } +} + +function clearStreamplacePref() { + try { + if (fs.existsSync(STREAMPLACE_PREF_PATH)) fs.unlinkSync(STREAMPLACE_PREF_PATH); + log("streamplace pref cleared"); + } catch (e) { + log(`streamplace pref clear: ${e instanceof Error ? e.message : e}`); + } +} + +async function streamplaceAlreadyPlaying() { + try { + const res = await fetch(`${STREAMPLACE_AV_BRIDGE_URL}/v1/status`, { + signal: AbortSignal.timeout(3_000), + }); + if (!res.ok) return false; + const j = await res.json(); + return Boolean(j?.radio?.playing && j?.session); + } catch { + return false; + } +} + /** freeq REST (session discovery). Default from IRC host. */ const FREEQ_API_BASE = ( process.env.FREEQ_API_BASE ?? `https://${IRC_HOST}` @@ -66,6 +156,14 @@ const SFU_URL_RESOLVED = ? "https://irc.freeq.at:8080/av/moq" : `https://${IRC_HOST}/av/moq`); +/** + * Announce ICY StreamTitle (song) changes as channel PRIVMSG. + * Source: poll av-bridge /v1/status and/or POST /radio/now-playing (RADIO_TITLE_HOOK). + */ +const RADIO_ANNOUNCE = + process.env.RADIO_ANNOUNCE !== "0" && process.env.RADIO_ANNOUNCE !== "false"; +const RADIO_ANNOUNCE_MS = envMs("RADIO_ANNOUNCE_MS", 2_000); + function envMs(name, fallback) { const raw = process.env[name]; if (!raw) return fallback; @@ -73,8 +171,171 @@ function envMs(name, fallback) { return Number.isFinite(n) && n > 0 ? n : fallback; } +function envInt(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const n = Number(raw); + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback; +} + +// --------------------------------------------------------------------------- +// Per-target ring buffer (channel scrollback → safe eve background context) +// --------------------------------------------------------------------------- + +/** + * @typedef {{ from: string, text: string, at: number, kind: 'chat'|'agent'|'prior_mention' }} ContextEntry + * @type {Map} + */ +const contextBuffers = new Map(); + +function normalizeContextLine(text) { + return String(text ?? "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 400); +} + +function botNickAliases(client) { + const nicks = [ + client?.nick, + client?.preferredNick, + IRC_NICK, + "eve", + "eve-agent", + ].filter(Boolean); + return [...new Set(nicks.map((n) => String(n).toLowerCase()))]; +} + +function mentionBody(text, aliases) { + const alt = aliases + .map((a) => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|"); + if (!alt) return null; + const re = new RegExp(`^(?:${alt})[,: ]+\\s*(.*)$`, "i"); + const m = String(text ?? "").match(re); + if (!m) return null; + const body = (m[1] ?? "").trim(); + return body || null; +} + +/** + * Record a live PRIVMSG for later context. Backlog-dropped lines must not + * call this. Mentions of the bot are stored as prior_mention so the model + * sees them as closed history, not open requests. + * + * @param {string} target + * @param {string} from + * @param {string} text + * @param {{ isAgent?: boolean, aliases?: string[] }} [opts] + */ +function pushContext(target, from, text, opts = {}) { + if (!CONTEXT_ENABLED || !target || !from) return; + const line = normalizeContextLine(text); + if (!line) return; + + let kind = "chat"; + let stored = line; + if (opts.isAgent) { + kind = "agent"; + } else { + const aliases = opts.aliases ?? botNickAliases(null); + const body = mentionBody(line, aliases); + if (body !== null) { + kind = "prior_mention"; + stored = body; + } + } + + const key = String(target).toLowerCase(); + let buf = contextBuffers.get(key); + if (!buf) { + buf = []; + contextBuffers.set(key, buf); + } + buf.push({ from, text: stored, at: Date.now(), kind }); + while (buf.length > CONTEXT_LINES) buf.shift(); +} + +/** + * Build a single SendPayload.context entry for eve. + * + * eve injects each context string as role:user before the delivery message. + * Multiple plain chat lines looked like open user turns and models answered + * them. We send ONE framed background blob with explicit non-reply rules + * (same pattern as Slack/Telegram/GitHub channel context in eve). + * + * @returns {string[]} zero or one string + */ +function formatContext(target, { excludeFrom, excludeText, aliases } = {}) { + if (!CONTEXT_ENABLED) return []; + const buf = contextBuffers.get(String(target).toLowerCase()) ?? []; + if (!buf.length) return []; + + let lines = buf; + if (excludeFrom && excludeText) { + const last = buf[buf.length - 1]; + const body = normalizeContextLine(excludeText); + const mention = mentionBody(body, aliases ?? botNickAliases(null)); + const candidates = [body, mention].filter(Boolean); + if ( + last && + last.from === excludeFrom && + candidates.some( + (c) => + last.text === c || last.text.endsWith(c) || c.endsWith(last.text), + ) + ) { + lines = buf.slice(0, -1); + } + } + if (!lines.length) return []; + + const rendered = lines.map((e) => { + if (e.kind === "agent") { + return `<${e.from} role=agent> ${e.text}`; + } + if (e.kind === "prior_mention") { + return `<${e.from} role=prior_mention closed=true> ${e.text}`; + } + return `<${e.from}> ${e.text}`; + }); + + const block = [ + ``, + `kind: background_scrollback`, + `instructions: BACKGROUND ONLY. Do not reply to, continue, re-answer, or run tools for any line inside this block. prior_mention lines are already-handled historical mentions of the bot. agent lines are the bot's own past replies. Use this only to understand channel situation and pronouns/topics. Answer ONLY the current mention that follows this block.`, + `lines: ${lines.length} (oldest → newest)`, + ...rendered, + ``, + ].join("\n"); + + if (block.length <= CONTEXT_MAX_CHARS) return [block]; + const head = [ + ``, + `kind: background_scrollback`, + `instructions: BACKGROUND ONLY. Do not reply to lines in this block. Answer ONLY the current mention that follows.`, + `truncated: true`, + ].join("\n"); + const tail = "\n"; + const budget = CONTEXT_MAX_CHARS - head.length - tail.length - 20; + const body = rendered.join("\n"); + const sliced = + budget > 0 ? body.slice(Math.max(0, body.length - budget)) : ""; + return [`${head}\n…(truncated)\n${sliced}${tail}`]; +} + function log(...args) { - console.error("[irc-bridge]", ...args); + const line = `[irc-bridge] ${args.map((a) => (typeof a === "string" ? a : String(a))).join(" ")}`; + console.error(line); + // systemd StandardError=append can fully-buffer node stderr; mirror to file. + try { + fs.appendFileSync( + path.join(os.homedir(), "logs/irc-bridge.log"), + `${line}\n`, + ); + } catch { + /* ignore */ + } } // --------------------------------------------------------------------------- @@ -127,6 +388,10 @@ function loadFreeqSession() { candidates.push(process.env.IRC_FREEQ_SESSION); const home = process.env.HOME ?? os.homedir(); candidates.push( + path.join(home, ".config/freeq-tui/eve.boxd.sh.session.json"), + path.join(home, ".config/freeq/eve.boxd.sh.session.json"), + path.join(home, ".config/freeq/eve.session.json"), + // legacy handle (pre single-user pds.eve.boxd.sh migration) path.join(home, ".config/freeq-tui/eve.rookery.boxd.sh.session.json"), path.join(home, ".config/freeq/eve.rookery.boxd.sh.session.json"), ); @@ -300,6 +565,8 @@ class IrcClient { this.saslOk = false; this.saslFailed = false; this.nickInUseRetries = 0; + /** True after preferred nick is permanently held by another account. */ + this.preferredNickAbandoned = false; this.reconnectTimer = undefined; this.watchdogTimer = undefined; /** @type {Map} */ @@ -339,6 +606,8 @@ class IrcClient { reclaimPreferredNick(reason = "reclaim") { if (!this.preferredNick) return false; if (this.nickMatchesPreferred()) return false; + // Another account holds the preferred nick (e.g. after DID migration). + if (this.preferredNickAbandoned) return false; if (!this.saslOk && !this.authDid) { log( `skip NICK ${this.preferredNick} (${reason}): not SASL-authed yet (current=${this.nick})`, @@ -417,6 +686,11 @@ class IrcClient { ]; for (const c of chunks) this.raw(`PRIVMSG ${target} :${c}`); } + // Keep our own replies in the ring buffer so the next mention sees them. + pushContext(target, this.nick, text, { + isAgent: true, + aliases: botNickAliases(this), + }); log(`→ PRIVMSG ${target}: ${String(text).slice(0, 80)}`); } @@ -751,6 +1025,15 @@ class IrcClient { // Random suffixes bind the wrong nick to the DID — avoid that. this.nickInUseRetries += 1; if (this.nickInUseRetries > 5) { + // Preferred nick held by another account (e.g. old DID after re-enroll). + // Stay on the SASL-assigned non-Guest nick instead of reconnect looping. + if (this.saslOk && this.nick && !isGuestNick(this.nick)) { + this.preferredNickAbandoned = true; + log( + `nick ${this.preferredNick} held by another account; staying as ${this.nick} (SASL ok)`, + ); + return; + } log(`nick ${this.preferredNick} still in use after retries; reconnect`); this.scheduleForcedReconnect("nick-in-use", 8_000); return; @@ -976,15 +1259,15 @@ class IrcClient { if (from === this.nick) return; const msgid = tags.msgid || tags["draft/msgid"] || ""; const isChannel = target.startsWith("#") || target.startsWith("&"); + const aliases = botNickAliases(this); if (isChannel) { if (this.shouldDropBacklog()) { this.lastChannelMsgAt = Date.now(); this.backlogDropped += 1; return; } - const aliases = [ - ...new Set([this.nick, this.preferredNick, "eve", "eve-agent"]), - ]; + // Record every live channel line (including the mention) for scrollback. + pushContext(target, from, text, { aliases }); const alt = aliases .map((a) => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) .join("|"); @@ -994,12 +1277,21 @@ class IrcClient { if (!body) return; // Immediate 👀 so the user sees the bot accepted the mention. this.reactWorking(target, msgid); - // Mention only — never attach channel scrollback. eve's SendPayload.context - // becomes role:user history and models answer every historical line. + // Local slash-ish commands (no agent round-trip). + if (tryHandleWatch(this, target, target, body)) { + log(`watch command from ${from} in ${target}: ${body.slice(0, 80)}`); + return; + } + // Background scrollback only — one framed blob, not answerable history. + const context = formatContext(target, { + excludeFrom: from, + excludeText: text, + aliases, + }); log( - `mention from ${from} in ${target}: ${body.slice(0, 80)} (msgid=${msgid ? msgid.slice(0, 16) : "-"})`, + `mention from ${from} in ${target}: ${body.slice(0, 80)} (msgid=${msgid ? msgid.slice(0, 16) : "-"}, context ${context.length ? context[0].length : 0} chars)`, ); - this.onMessage(from, target, body, { msgid }); + this.onMessage(from, target, body, { msgid, context }); return; } if (this.owners.size && !this.owners.has(from.toLowerCase())) { @@ -1007,8 +1299,20 @@ class IrcClient { return; } if (!text.trim()) return; + // DMs: small private buffer so multi-line questions still have prior turns. + pushContext(from, from, text, { aliases }); this.reactWorking(from, msgid); - this.onMessage(from, from, text.trim(), { msgid }); + const dmBody = text.trim(); + if (tryHandleWatch(this, from, IRC_CHANNEL, dmBody)) { + log(`watch command DM from ${from}: ${dmBody.slice(0, 80)}`); + return; + } + const context = formatContext(from, { + excludeFrom: from, + excludeText: text, + aliases, + }); + this.onMessage(from, from, dmBody, { msgid, context }); } } @@ -1020,6 +1324,10 @@ async function postInbound(from, target, text, meta = {}) { const url = `${EVE_URL}${INBOUND_PATH}`; const payload = { from, target, text }; if (meta.msgid) payload.msgid = meta.msgid; + // Single framed background string(s); channel passes through to SendPayload.context. + if (Array.isArray(meta.context) && meta.context.length > 0) { + payload.context = meta.context; + } try { const res = await fetch(url, { method: "POST", @@ -1181,6 +1489,111 @@ process.on("SIGINT", () => { // --------------------------------------------------------------------------- // AV ensure + radio (orchestrates TAGMSG + eve-av-bridge) // --------------------------------------------------------------------------- +// freeq AV allows multiple MoQ publishers, but eve only keeps ONE media plane +// attached at a time (radio :8790 vs stream.place :8792, or reconnects). +// Switching planes: av-leave previous roster entry + disconnect the old bridge. + +/** @type {{ bridgeUrl: string, sessionId: string, instance: string, nick: string, channel: string } | null} */ +let activePlane = null; + +function knownPlaneUrls() { + return [ + ...new Set( + [AV_BRIDGE_URL, STREAMPLACE_AV_BRIDGE_URL].map((u) => + String(u).replace(/\/$/, ""), + ), + ), + ]; +} + +/** Stop radio + MoQ on a bridge (best-effort). */ +async function stopBridgeMedia(bridgeUrl) { + const bridge = String(bridgeUrl).replace(/\/$/, ""); + try { + await fetch(`${bridge}/v1/radio/stop`, { + method: "POST", + signal: AbortSignal.timeout(5_000), + }); + } catch (e) { + log(`plane radio stop ${bridge}: ${e instanceof Error ? e.message : e}`); + } + try { + await fetch(`${bridge}/v1/session/disconnect`, { + method: "POST", + signal: AbortSignal.timeout(5_000), + }); + } catch (e) { + log(`plane disconnect ${bridge}: ${e instanceof Error ? e.message : e}`); + } +} + +/** + * Release prior MoQ plane(s) so only one can publish into a freeq session. + * @param {string | null} keepBridge bridge that will reconnect (radio stop only); + * null = disconnect every known plane. + */ +async function releasePlanes(keepBridge = null) { + const keep = keepBridge ? String(keepBridge).replace(/\/$/, "") : null; + const prev = activePlane; + activePlane = null; + + if (prev?.sessionId && prev?.instance && prev?.channel) { + try { + irc.avLeave(prev.channel, prev.sessionId, prev.instance); + log( + `av leave ${prev.sessionId}/${prev.nick ?? "?"}~${prev.instance} on ${prev.channel} (single-plane)`, + ); + } catch (e) { + log(`av leave: ${e instanceof Error ? e.message : e}`); + } + } + + for (const url of knownPlaneUrls()) { + if (keep && url === keep) { + // Same bridge: drop radio so play paths don't stack; MoQ replaced on connect. + try { + await fetch(`${url}/v1/radio/stop`, { + method: "POST", + signal: AbortSignal.timeout(5_000), + }); + } catch (e) { + log(`plane radio stop ${url}: ${e instanceof Error ? e.message : e}`); + } + continue; + } + log(`releasing other plane ${url}`); + await stopBridgeMedia(url); + } +} + +/** + * av-leave every freeq roster row for our nick in this session (except keepInstance). + * Clears multi-plane ghosts after crash/restart when activePlane was untracked. + */ +async function leaveOurRosterInstances(sessionId, channel, keepInstance = null) { + if (!sessionId) return; + const ch = channel.startsWith("#") ? channel : `#${channel}`; + try { + const data = await fetchSessionRoster(sessionId); + const parts = Array.isArray(data?.participants) ? data.participants : []; + const me = String(irc.nick || "").toLowerCase(); + for (const p of parts) { + const inst = p?.instance_id; + if (!inst) continue; + if (keepInstance && inst === keepInstance) continue; + const nick = p?.nick != null ? String(p.nick) : ""; + if (!nick || nick.toLowerCase() !== me) continue; + try { + irc.avLeave(ch, sessionId, inst); + log(`av leave roster ${sessionId}/${nick}~${inst} (single-plane cleanup)`); + } catch (e) { + log(`av leave roster: ${e instanceof Error ? e.message : e}`); + } + } + } catch (e) { + log(`roster cleanup: ${e instanceof Error ? e.message : e}`); + } +} function newAvInstance() { return Math.floor(Math.random() * 0xffffffff) @@ -1242,8 +1655,13 @@ async function rosterNickForInstance(sessionId, instance, fallbackNick) { * Ensure we are joined to an AV call on `channel`, connect media plane. * @returns {{ sessionId, instance, sfuUrl, channel, nick }} */ -async function ensureAv(channel = IRC_CHANNEL, title = "eve radio") { +async function ensureAv( + channel = IRC_CHANNEL, + title = "eve radio", + bridgeUrl = AV_BRIDGE_URL, +) { const ch = channel.startsWith("#") ? channel : `#${channel}`; + const bridge = (bridgeUrl || AV_BRIDGE_URL).replace(/\/$/, ""); // freeq records the IRC nick at av_join time. Guest* nicks break MoQ mesh // (clients subscribe to GuestN~inst while we might publish eve~inst). if (/^guest/i.test(irc.nick)) { @@ -1252,14 +1670,19 @@ async function ensureAv(channel = IRC_CHANNEL, title = "eve radio") { ); } + // One MoQ plane at a time: leave/disconnect any prior plane before joining. + await releasePlanes(bridge); + let sessionId = await discoverActiveSession(ch); const instance = newAvInstance(); if (sessionId) { - log(`av join existing ${sessionId} on ${ch} as ${irc.nick}~${instance}`); + // Drop any prior eve~instance rows so we never publish twice into one session. + await leaveOurRosterInstances(sessionId, ch, null); + log(`av join existing ${sessionId} on ${ch} as ${irc.nick}~${instance} via ${bridge}`); irc.avJoin(ch, sessionId, instance); } else { - log(`av start on ${ch} as ${irc.nick}~${instance}`); + log(`av start on ${ch} as ${irc.nick}~${instance} via ${bridge}`); const wait = irc.waitAvStarted(ch, 10_000); irc.avStart(ch, instance, title); sessionId = await wait; @@ -1285,7 +1708,7 @@ async function ensureAv(channel = IRC_CHANNEL, title = "eve radio") { // Video tile for radio visualizer (ICY title + DSP waveform/spectrum). audio_only: false, }; - const res = await fetch(`${AV_BRIDGE_URL}/v1/session/connect`, { + const res = await fetch(`${bridge}/v1/session/connect`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), @@ -1297,31 +1720,291 @@ async function ensureAv(channel = IRC_CHANNEL, title = "eve radio") { } log( - `media path ${sessionId}/${nick}~${instance} (must match freeq roster for clients to hear)`, + `media path ${sessionId}/${nick}~${instance} on ${bridge} (must match freeq roster for clients to hear)`, ); + activePlane = { + bridgeUrl: bridge, + sessionId, + instance, + nick, + channel: ch, + }; + return { sessionId, instance, sfuUrl: SFU_URL_RESOLVED, channel: ch, nick, + bridgeUrl: bridge, broadcastPath: `${sessionId}/${nick}~${instance}`, session: json.session, }; } +/** Pick the live stream.place stream with the most viewers. */ +async function pickTopStreamplaceStream() { + const url = `${STREAMPLACE_API}/xrpc/place.stream.live.getLiveUsers?limit=50`; + const res = await fetch(url, { signal: AbortSignal.timeout(15_000) }); + if (!res.ok) throw new Error(`stream.place getLiveUsers HTTP ${res.status}`); + const json = await res.json(); + const streams = Array.isArray(json?.streams) ? json.streams : []; + if (!streams.length) throw new Error("no live streams on stream.place right now"); + + const viewers = (s) => { + const v = s?.viewerCount; + if (typeof v === "number") return v; + if (v && typeof v.count === "number") return v.count; + return 0; + }; + streams.sort((a, b) => viewers(b) - viewers(a)); + const top = streams[0]; + const did = top?.author?.did; + const handle = top?.author?.handle ?? did; + const title = top?.record?.title ?? handle; + const count = viewers(top); + if (!did) throw new Error("top stream missing author.did"); + const hls = `${STREAMPLACE_API}/xrpc/place.stream.playback.getLivePlaylist?streamer=${encodeURIComponent(did)}`; + return { + did, + handle, + title: String(title), + viewers: count, + url: top?.record?.url ?? `https://stream.place/${did}`, + hls, + ranked: streams.slice(0, 5).map((s) => ({ + handle: s?.author?.handle, + did: s?.author?.did, + viewers: viewers(s), + title: s?.record?.title, + })), + }; +} + +/** + * Parse a stream.place URL, handle, or DID into a streamer id for the XRPC playlist. + * @param {string} raw + * @returns {string | null} + */ +function parseStreamplaceTarget(raw) { + let s = String(raw ?? "").trim(); + if (!s) return null; + // IRC clients sometimes wrap URLs in <> + s = s.replace(/^<|>$/g, "").trim(); + s = s.replace(/^@/, ""); + + if (/^https?:\/\//i.test(s)) { + try { + const u = new URL(s); + const host = u.hostname.replace(/^www\./i, "").toLowerCase(); + if (host === "stream.place") { + // /iame.li | /did:plc:… | /handle/… → first path segment(s) + let path = u.pathname.replace(/^\/+/, "").replace(/\/+$/, ""); + if (!path || path.startsWith("xrpc/")) return null; + // did:plc:… is a single path segment with colons + if (path.startsWith("did:")) return path.split("/")[0]; + const first = path.split("/")[0]; + return first || null; + } + } catch { + return null; + } + return null; + } + + if (s.startsWith("did:")) return s; + // bare handle / slug + if (/^[a-z0-9][a-z0-9._:-]*$/i.test(s)) return s; + return null; +} + +/** + * Fast-path: `watch ` from a mention/DM — flip stream.place plane. + * @returns {boolean} true if handled (caller should not forward to eve agent) + */ +function tryHandleWatch(ircClient, replyTarget, channel, body) { + const m = String(body ?? "").match(/^watch(?:\s+|:\s*)(.+)$/i); + if (!m) return false; + const streamer = parseStreamplaceTarget(m[1]); + if (!streamer) { + ircClient.sendPrivmsg( + replyTarget, + "usage: watch ", + ); + return true; + } + const ch = channel || IRC_CHANNEL; + void (async () => { + try { + log(`watch command → ${streamer} on ${ch}`); + const out = await playStreamplace({ channel: ch, streamer }); + const handle = out.stream?.handle ?? streamer; + const title = out.stream?.title ?? handle; + // playStreamplace already PRIVMSGs a notice; short ack is enough if that failed. + log( + `watch ok @${handle} title=${String(title).slice(0, 60)} session=${out.av?.sessionId ?? "?"}`, + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + log(`watch failed: ${msg}`); + try { + ircClient.sendPrivmsg(replyTarget, `watch failed: ${msg}`.slice(0, 350)); + } catch { + /* ignore */ + } + } + })(); + return true; +} + +/** stream.place → freeq AV on the dedicated MoQ plane (:8792 by default). */ +async function playStreamplace({ channel, streamer } = {}) { + const ch = channel ?? IRC_CHANNEL; + let picked; + if (streamer) { + const id = parseStreamplaceTarget(streamer) || String(streamer).trim(); + picked = { + did: id.startsWith("did:") ? id : null, + handle: id.startsWith("did:") ? id : id, + title: id, + viewers: null, + url: `https://stream.place/${id}`, + hls: `${STREAMPLACE_API}/xrpc/place.stream.playback.getLivePlaylist?streamer=${encodeURIComponent(id)}`, + ranked: [], + }; + // Resolve handle for nicer logs if we only got a handle. + if (!picked.did) picked.did = id; + // Best-effort: enrich title/viewers from current live roster. + try { + const liveUrl = `${STREAMPLACE_API}/xrpc/place.stream.live.getLiveUsers?limit=50`; + const liveRes = await fetch(liveUrl, { signal: AbortSignal.timeout(8_000) }); + if (liveRes.ok) { + const liveJson = await liveRes.json(); + const streams = Array.isArray(liveJson?.streams) ? liveJson.streams : []; + const idLower = id.toLowerCase(); + const hit = streams.find((s) => { + const did = String(s?.author?.did ?? ""); + const handle = String(s?.author?.handle ?? ""); + return ( + did === id || + handle.toLowerCase() === idLower || + did.toLowerCase() === idLower + ); + }); + if (hit) { + const v = hit?.viewerCount; + const count = + typeof v === "number" ? v : v && typeof v.count === "number" ? v.count : null; + picked.did = hit?.author?.did ?? picked.did; + picked.handle = hit?.author?.handle ?? picked.handle; + picked.title = hit?.record?.title ?? picked.title; + picked.viewers = count; + picked.url = hit?.record?.url ?? `https://stream.place/${picked.did || id}`; + const streamerId = picked.did || id; + picked.hls = `${STREAMPLACE_API}/xrpc/place.stream.playback.getLivePlaylist?streamer=${encodeURIComponent(streamerId)}`; + } + } + } catch (e) { + log(`streamplace enrich: ${e instanceof Error ? e.message : e}`); + } + } else { + picked = await pickTopStreamplaceStream(); + } + + // Remember explicit picks so STREAMPLACE_AUTO / bridge restarts restore them + // instead of flipping back to the current top-viewers stream. + if (streamer) { + saveStreamplacePref(picked.did || picked.handle || streamer, ch); + } + + const title = `stream.place: ${picked.title} (@${picked.handle}, ${picked.viewers ?? "?"} viewers)`; + log(`streamplace play ${title} → ${ch} plane=${STREAMPLACE_AV_BRIDGE_URL}`); + + let av; + try { + av = await ensureAv(ch, title.slice(0, 120), STREAMPLACE_AV_BRIDGE_URL); + } catch (e) { + log( + `streamplace ensureAv: ${e instanceof Error ? e.message : e}; trying play anyway`, + ); + av = { channel: ch, error: String(e) }; + } + + const res = await fetch(`${STREAMPLACE_AV_BRIDGE_URL}/v1/radio/play`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: picked.hls }), + signal: AbortSignal.timeout(60_000), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json.ok === false) { + throw new Error(json.error || `streamplace radio play ${res.status}`); + } + + // Optional channel notice (once per start). + try { + const notice = `stream.place → freeq AV: ${picked.title} (@${picked.handle}, ${picked.viewers ?? "?"} watching) — join voice in ${ch.startsWith("#") ? ch : "#" + ch}`.slice(0, 400); + irc.sendPrivmsg(ch.startsWith("#") ? ch : `#${ch}`, notice); + } catch (e) { + log(`streamplace notice: ${e instanceof Error ? e.message : e}`); + } + + return { av, stream: picked, radio: json.radio, plane: STREAMPLACE_AV_BRIDGE_URL }; +} + +async function stopStreamplace() { + clearStreamplacePref(); + const plane = STREAMPLACE_AV_BRIDGE_URL.replace(/\/$/, ""); + if (activePlane?.bridgeUrl === plane) { + // Full release: av-leave + disconnect (clears activePlane). + await releasePlanes(null); + } else { + // Not tracked as active — still tear down streamplace bridge media. + await stopBridgeMedia(plane); + } + return { ok: true, plane: STREAMPLACE_AV_BRIDGE_URL }; +} + +async function streamplaceStatus() { + const [health, status, live] = await Promise.all([ + fetch(`${STREAMPLACE_AV_BRIDGE_URL}/health`, { + signal: AbortSignal.timeout(3_000), + }) + .then(async (r) => ({ ok: r.ok, text: await r.text() })) + .catch((e) => ({ ok: false, error: String(e) })), + fetch(`${STREAMPLACE_AV_BRIDGE_URL}/v1/status`, { + signal: AbortSignal.timeout(3_000), + }) + .then(async (r) => ({ ok: r.ok, body: await r.json().catch(() => null) })) + .catch((e) => ({ ok: false, error: String(e) })), + pickTopStreamplaceStream() + .then((s) => ({ ok: true, top: s })) + .catch((e) => ({ ok: false, error: String(e) })), + ]); + return { + plane: STREAMPLACE_AV_BRIDGE_URL, + health, + status: status.body ?? status, + topLive: live.ok ? live.top : { error: live.error }, + }; +} + async function playRadio({ url, channel, title }) { if (!url) throw new Error("url required"); + const ch = channel ?? IRC_CHANNEL; + radioAnnounceChannel = ch.startsWith("#") ? ch : `#${ch}`; + // Fresh stream — allow the first ICY title to be announced again. + lastRadioTitle = null; let av; try { - av = await ensureAv(channel ?? IRC_CHANNEL, title ?? "eve radio"); + av = await ensureAv(ch, title ?? "eve radio"); } catch (e) { // Media may already be up from a prior call; still try play. log( `ensureAv: ${e instanceof Error ? e.message : e}; trying radio play anyway`, ); - av = { channel: channel ?? IRC_CHANNEL, error: String(e) }; + av = { channel: radioAnnounceChannel, error: String(e) }; } const res = await fetch(`${AV_BRIDGE_URL}/v1/radio/play`, { method: "POST", @@ -1345,9 +2028,73 @@ async function stopRadio() { } catch (e) { log(`radio stop: ${e instanceof Error ? e.message : e}`); } + lastRadioTitle = null; return { ok: true }; } +// --------------------------------------------------------------------------- +// Now-playing: ICY title changes → channel PRIVMSG +// --------------------------------------------------------------------------- + +/** @type {string | null} */ +let lastRadioTitle = null; +/** Channel last used for radio / AV (fallback IRC_CHANNEL). */ +let radioAnnounceChannel = IRC_CHANNEL; + +/** + * Announce a new song title once (deduped). Returns true if PRIVMSG sent. + * @param {string} title + * @param {string} [channel] + */ +function announceNowPlaying(title, channel) { + if (!RADIO_ANNOUNCE) return false; + const t = String(title ?? "").trim(); + if (!t) return false; + if (t === lastRadioTitle) return false; + lastRadioTitle = t; + let ch = channel || radioAnnounceChannel || IRC_CHANNEL; + if (!ch.startsWith("#")) ch = `#${ch}`; + radioAnnounceChannel = ch; + const text = `now playing: ${t}`.slice(0, 400); + irc.sendPrivmsg(ch, text); + return true; +} + +async function pollRadioTitle() { + if (!RADIO_ANNOUNCE) return; + try { + const res = await fetch(`${AV_BRIDGE_URL}/v1/status`, { + signal: AbortSignal.timeout(3_000), + }); + if (!res.ok) return; + const json = await res.json(); + const playing = Boolean(json?.radio?.playing); + if (!playing) { + lastRadioTitle = null; + return; + } + const title = json?.radio?.title; + const ch = json?.session?.channel || radioAnnounceChannel || IRC_CHANNEL; + if (title) announceNowPlaying(title, ch); + } catch { + // av-bridge down — silent + } +} + +function startRadioTitlePoller() { + if (!RADIO_ANNOUNCE) { + log("radio now-playing announce disabled (RADIO_ANNOUNCE=0)"); + return; + } + log( + `radio now-playing: poll ${RADIO_ANNOUNCE_MS}ms + POST /radio/now-playing → ${AV_BRIDGE_URL}`, + ); + setInterval(() => { + void pollRadioTitle(); + }, RADIO_ANNOUNCE_MS); + void pollRadioTitle(); +} + // Minimal control HTTP for eve tools (loopback). function readJson(req) { return new Promise((resolve, reject) => { @@ -1383,6 +2130,9 @@ const controlServer = http.createServer(async (req, res) => { nick: irc.nick, channel: IRC_CHANNEL, avBridge: AV_BRIDGE_URL, + streamplaceBridge: STREAMPLACE_AV_BRIDGE_URL, + streamplaceAuto: STREAMPLACE_AUTO, + activePlane, }); return; } @@ -1403,6 +2153,44 @@ const controlServer = http.createServer(async (req, res) => { sendJson(res, 200, out); return; } + // stream.place → second MoQ plane + if (req.method === "POST" && url.pathname === "/streamplace/play") { + const body = await readJson(req); + const out = await playStreamplace(body); + sendJson(res, 200, { ok: true, ...out }); + return; + } + if (req.method === "POST" && url.pathname === "/streamplace/stop") { + const out = await stopStreamplace(); + sendJson(res, 200, out); + return; + } + if (req.method === "GET" && url.pathname === "/streamplace/status") { + const out = await streamplaceStatus(); + sendJson(res, 200, { ok: true, ...out }); + return; + } + // Push path from eve-av-bridge (RADIO_TITLE_HOOK) or tooling. + if (req.method === "POST" && url.pathname === "/radio/now-playing") { + const body = await readJson(req); + const title = body.title ?? body.stream_title ?? body.now_playing; + if (!title || !String(title).trim()) { + sendJson(res, 400, { ok: false, error: "title required" }); + return; + } + if (body.channel) { + const c = String(body.channel); + radioAnnounceChannel = c.startsWith("#") ? c : `#${c}`; + } + const announced = announceNowPlaying(String(title), body.channel); + sendJson(res, 200, { + ok: true, + announced, + title: String(title).trim(), + channel: radioAnnounceChannel, + }); + return; + } sendJson(res, 404, { ok: false, error: "not found" }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -1419,7 +2207,49 @@ await waitForEve(); irc.start(); // SSE loop in parallel (reconnects forever) void runSseLoop(irc); +startRadioTitlePoller(); + +if (STREAMPLACE_AUTO) { + // Wait for SASL + channel join, then restore last `watch` (or top live once). + // Never clobber an already-playing streamplace plane (e.g. bridge-only restart + // while eve-av-bridge-streamplace kept decoding). + setTimeout(() => { + void (async () => { + for (let i = 0; i < 30; i++) { + if (irc.joined && irc.saslOk && !/^guest/i.test(irc.nick)) break; + await new Promise((r) => setTimeout(r, 2_000)); + } + try { + if (await streamplaceAlreadyPlaying()) { + log("streamplace auto: plane already playing — leave it alone"); + return; + } + const pref = loadStreamplacePref(); + if (pref?.streamer) { + log( + `streamplace auto: restoring preferred @${pref.streamer} on ${pref.channel || IRC_CHANNEL}`, + ); + const out = await playStreamplace({ + channel: pref.channel || IRC_CHANNEL, + streamer: pref.streamer, + }); + log( + `streamplace auto ok (pref): @${out.stream?.handle} session=${out.av?.sessionId}`, + ); + return; + } + log("streamplace auto: no pref — starting top live stream"); + const out = await playStreamplace({ channel: IRC_CHANNEL }); + log( + `streamplace auto ok (top): @${out.stream?.handle} viewers=${out.stream?.viewers} session=${out.av?.sessionId}`, + ); + } catch (e) { + log(`streamplace auto failed: ${e instanceof Error ? e.message : e}`); + } + })(); + }, 5_000); +} log( - `running preferredNick=${IRC_NICK} channel=${IRC_CHANNEL} requireAuth=${requireAuth} eve=${EVE_URL} inbound=${INBOUND_PATH} out=${OUT_SSE_PATH} avBridge=${AV_BRIDGE_URL}`, + `running preferredNick=${IRC_NICK} channel=${IRC_CHANNEL} requireAuth=${requireAuth} eve=${EVE_URL} inbound=${INBOUND_PATH} out=${OUT_SSE_PATH} avBridge=${AV_BRIDGE_URL} streamplace=${STREAMPLACE_AV_BRIDGE_URL} auto=${STREAMPLACE_AUTO} radioAnnounce=${RADIO_ANNOUNCE} contextLines=${CONTEXT_LINES}`, ); diff --git a/package.json b/package.json index 77966f7..e995181 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,10 @@ "irc-bridge": "node irc-bridge/server.mjs", "install-annas-mcp": "bash scripts/install-annas-mcp.sh", "typecheck": "tsc", + "systemd:install": "bash scripts/install-systemd.sh", + "systemd:start": "systemctl --user start eve.target", + "systemd:stop": "systemctl --user stop eve.target", + "systemd:status": "systemctl --user --no-pager status eve.target eve.service eve-irc-bridge.service", "boxd:start": "bash scripts/start.sh" }, "dependencies": { diff --git a/scripts/fetch-keys.sh b/scripts/fetch-keys.sh index 0dd4bc3..034100c 100755 --- a/scripts/fetch-keys.sh +++ b/scripts/fetch-keys.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Fetch AI API keys from OpenBao KV v2 and emit `export KEY=VALUE` lines on stdout. -# Sourced by start.sh. Requires OPENBAO_ADDR and OPENBAO_TOKEN in the env. +# Used by scripts/prep.sh (and legacy start). Requires OPENBAO_ADDR + OPENBAO_TOKEN. set -euo pipefail # boxd exec runs with a minimal PATH; ensure core utils are findable. diff --git a/scripts/install-systemd.sh b/scripts/install-systemd.sh new file mode 100755 index 0000000..bde3da3 --- /dev/null +++ b/scripts/install-systemd.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Install eve user systemd units (templates → ~/.config/systemd/user). +# +# bash scripts/install-systemd.sh # install + enable eve.target +# bash scripts/install-systemd.sh --no-enable # install only +# bash scripts/install-systemd.sh --with-av # also enable av-bridge +# bash scripts/install-systemd.sh --with-rookery # also enable rookery PDS (needs ~/rookery) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +UNIT_SRC="$ROOT/systemd/user" +UNIT_DST="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +CONFIG_DIR="${EVE_CONFIG_DIR:-$HOME/.config/eve}" + +ENABLE=1 +WITH_AV=0 +WITH_ROOKERY=0 +for arg in "$@"; do + case "$arg" in + --no-enable) ENABLE=0 ;; + --with-av) WITH_AV=1 ;; + --with-rookery) WITH_ROOKERY=1 ;; + -h | --help) + sed -n '2,9p' "$0" + exit 0 + ;; + *) + echo "unknown arg: $arg" >&2 + exit 2 + ;; + esac +done + +NODE="$(command -v node || true)" +NPX="$(command -v npx || true)" +if [ -z "$NODE" ] || [ -z "$NPX" ]; then + echo "install-systemd: node and npx must be on PATH" >&2 + exit 1 +fi +NODE_DIR="$(cd "$(dirname "$NODE")" && pwd)" + +mkdir -p "$UNIT_DST" "$CONFIG_DIR" "$HOME/logs" +chmod 700 "$CONFIG_DIR" + +echo "[install] root=$ROOT" +echo "[install] node=$NODE" +echo "[install] npx=$NPX" +echo "[install] units → $UNIT_DST" + +UNITS=( + eve-prep.service + eve.service + eve-irc-bridge.service + eve-av-bridge.service + eve-freeq-session-refresh.service + eve-freeq-session-refresh.timer + eve.target +) +if [ "$WITH_ROOKERY" -eq 1 ] || [ -d "$HOME/rookery" ]; then + UNITS+=(rookery.service) +fi + +for f in "${UNITS[@]}"; do + src="$UNIT_SRC/$f" + dst="$UNIT_DST/$f" + if [ ! -f "$src" ]; then + echo "missing template: $src" >&2 + exit 1 + fi + sed \ + -e "s|@ROOT@|${ROOT//\\/\\\\}|g" \ + -e "s|@NODE@|${NODE//\\/\\\\}|g" \ + -e "s|@NPX@|${NPX//\\/\\\\}|g" \ + -e "s|@NODE_DIR@|${NODE_DIR//\\/\\\\}|g" \ + -e "s|@HOME@|${HOME//\\/\\\\}|g" \ + "$src" >"$dst" + echo "[install] wrote $dst" +done + +# Make scripts executable (repo may have lost +x). +chmod +x \ + "$ROOT/scripts/prep.sh" \ + "$ROOT/scripts/start.sh" \ + "$ROOT/scripts/run-av-bridge-service.sh" \ + "$ROOT/scripts/run-av-bridge.sh" \ + "$ROOT/scripts/fetch-keys.sh" \ + "$ROOT/scripts/refresh-freeq-session.sh" \ + "$ROOT/scripts/install-systemd.sh" 2>/dev/null || true + +# Seed optional config files (do not overwrite secrets). +if [ ! -f "$CONFIG_DIR/openbao.env" ]; then + cat >"$CONFIG_DIR/openbao.env.example" <<'EOF' +# Copy to openbao.env (mode 0600) and fill in: +# cp ~/.config/eve/openbao.env.example ~/.config/eve/openbao.env +# chmod 600 ~/.config/eve/openbao.env +OPENBAO_ADDR=https://openbao.boxd.sh +OPENBAO_TOKEN= +EOF + echo "[install] wrote $CONFIG_DIR/openbao.env.example" + if [ -n "${OPENBAO_ADDR:-}" ] && [ -n "${OPENBAO_TOKEN:-}" ]; then + umask 077 + { + printf 'OPENBAO_ADDR=%s\n' "$OPENBAO_ADDR" + printf 'OPENBAO_TOKEN=%s\n' "$OPENBAO_TOKEN" + } >"$CONFIG_DIR/openbao.env" + chmod 600 "$CONFIG_DIR/openbao.env" + echo "[install] wrote $CONFIG_DIR/openbao.env from current env" + else + echo "[install] create $CONFIG_DIR/openbao.env for boxd (see openbao.env.example)" + fi +fi + +if [ ! -f "$CONFIG_DIR/config.env" ]; then + cat >"$CONFIG_DIR/config.env" <<'EOF' +# Optional non-secret overrides for eve units (systemd EnvironmentFile). +# IRC_NICK=eve +# IRC_CHANNEL=#test +# OPENCODE_MODEL=deepseek-v4-flash-free +EOF + echo "[install] wrote $CONFIG_DIR/config.env" +fi +# Prefer a prebuilt av-bridge binary if present and not already configured. +if ! grep -qE '^EVE_AV_BRIDGE_BIN=' "$CONFIG_DIR/config.env" 2>/dev/null; then + for cand in \ + "${EVE_AV_BRIDGE_BIN:-}" \ + "$HOME/.local/bin/eve-av-bridge" \ + "$HOME/bin/eve-av-bridge" \ + "$ROOT/bin/eve-av-bridge"; do + if [ -n "$cand" ] && [ -x "$cand" ]; then + echo "EVE_AV_BRIDGE_BIN=$cand" >>"$CONFIG_DIR/config.env" + echo "[install] EVE_AV_BRIDGE_BIN=$cand" + break + fi + done +fi +systemctl --user daemon-reload + +if [ "$ENABLE" -eq 1 ]; then + systemctl --user enable eve.target eve-prep.service eve.service eve-irc-bridge.service + systemctl --user enable eve-freeq-session-refresh.timer + echo "[install] enabled eve-freeq-session-refresh.timer (rook → freeq SASL every 5m)" + if [ "$WITH_AV" -eq 1 ]; then + systemctl --user enable eve-av-bridge.service + echo "[install] enabled eve-av-bridge.service" + fi + if [ -f "$UNIT_DST/rookery.service" ] && { [ "$WITH_ROOKERY" -eq 1 ] || [ -d "$HOME/rookery" ]; }; then + systemctl --user enable rookery.service + echo "[install] enabled rookery.service (PDS :8787)" + fi + echo "[install] enabled eve.target (prep + eve + irc-bridge)" +fi + +if ! loginctl show-user "$USER" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then + echo "[install] note: user lingering is off — units stop on logout." + echo " enable with: loginctl enable-linger $USER" + echo " (needs root/polkit; on boxd often already arranged)" +fi + +echo +echo "Next:" +echo " # ensure OpenBao creds (boxd):" +echo " \$EDITOR $CONFIG_DIR/openbao.env && chmod 600 $CONFIG_DIR/openbao.env" +echo " systemctl --user start eve.target" +echo " systemctl --user start eve-freeq-session-refresh.timer" +echo " systemctl --user status eve.service eve-irc-bridge.service" +echo " systemctl --user list-timers eve-freeq-session-refresh.timer" +echo " journalctl --user -u eve.service -u eve-irc-bridge.service -f" +echo " # freeq SASL refresh log:" +echo " tail -f ~/logs/freeq-session-refresh.log" +if [ "$WITH_AV" -eq 0 ]; then + echo " # optional AV:" + echo " systemctl --user enable --now eve-av-bridge.service" +fi diff --git a/scripts/prep.sh b/scripts/prep.sh new file mode 100755 index 0000000..bfe6155 --- /dev/null +++ b/scripts/prep.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# One-shot prep for the eve stack: OpenBao keys, freeq session, annas-mcp, +# eve build, and a systemd EnvironmentFile at ~/.config/eve/runtime.env. +# +# Used by eve-prep.service and by start.sh (legacy / non-systemd path). +set -euo pipefail + +export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +CONFIG_DIR="${EVE_CONFIG_DIR:-$HOME/.config/eve}" +RUNTIME_ENV="${EVE_RUNTIME_ENV:-$CONFIG_DIR/runtime.env}" +mkdir -p "$CONFIG_DIR" +mkdir -p "${XDG_RUNTIME_DIR:-/tmp}/eve" 2>/dev/null || true + +# --------------------------------------------------------------------------- +# Collect env into a temp file, then install as runtime.env (mode 0600). +# Format: KEY=VALUE (systemd EnvironmentFile — no `export`, no spaces around =). +# --------------------------------------------------------------------------- +tmp="$(mktemp)" +chmod 600 "$tmp" +cleanup() { rm -f "$tmp"; } +trap cleanup EXIT + +env_set() { + local key="$1" value="$2" + # Escape backslashes and double quotes for EnvironmentFile. + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + # Drop any prior line for this key (last write wins when we append). + if grep -q "^${key}=" "$tmp" 2>/dev/null; then + grep -v "^${key}=" "$tmp" >"${tmp}.n" && mv "${tmp}.n" "$tmp" + fi + printf '%s="%s"\n' "$key" "$value" >>"$tmp" +} + +# Optional operator overrides already on the process env (config.env is loaded +# by systemd before this script runs). +load_existing_exports() { + # If a previous runtime.env exists and we are re-running without OpenBao, + # keep non-key defaults only — keys are refreshed below when possible. + : +} + +load_existing_exports + +# --- OpenBao API keys ------------------------------------------------------- +if [ -n "${OPENBAO_ADDR:-}" ] && [ -n "${OPENBAO_TOKEN:-}" ]; then + echo "[prep] fetching keys from ${OPENBAO_ADDR} ..." + # fetch-keys emits shell-safe `export KEY="value"` lines (same as start.sh). + keys_sh="$(bash "$ROOT/scripts/fetch-keys.sh")" + # shellcheck disable=SC1090 + eval "$keys_sh" + key_count=0 + while IFS= read -r key; do + [ -z "$key" ] && continue + env_set "$key" "${!key}" + key_count=$((key_count + 1)) + done < <(printf '%s\n' "$keys_sh" | sed -n 's/^export \([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p') + echo "[prep] injected keys: ${key_count}" +else + echo "[prep] OPENBAO_ADDR/TOKEN unset — skipping key fetch (using process env if any)" + for k in FUGU_API_KEY META_API_KEY OLLAMA_API_KEY OPENCODE_API_KEY \ + OPENROUTER_API_KEY UMANS_API_KEY ANNA_API_KEY LINEAR_API_KEY; do + if [ -n "${!k:-}" ]; then + env_set "$k" "${!k}" + fi + done +fi + +if [ -n "${ANNA_API_KEY:-}" ]; then + echo "[prep] ANNA_API_KEY present" +else + echo "[prep] ANNA_API_KEY missing — anna_download / anna_fast_download need a key" +fi +if [ -n "${LINEAR_API_KEY:-}" ]; then + echo "[prep] LINEAR_API_KEY present" +else + echo "[prep] LINEAR_API_KEY missing — linear_* tools need a key" +fi + +# --- Defaults (operator config.env / process env win if already set) -------- +set_default() { + local key="$1" default="$2" + local cur="${!key:-}" + if [ -n "$cur" ]; then + env_set "$key" "$cur" + else + env_set "$key" "$default" + export "${key}=${default}" + fi +} + +set_default ANNA_DOWNLOAD_DIR "${HOME}/archive" +mkdir -p "${ANNA_DOWNLOAD_DIR}" +echo "[prep] ANNA_DOWNLOAD_DIR=${ANNA_DOWNLOAD_DIR}" + +set_default OPENCODE_MODEL "deepseek-v4-flash-free" +set_default IRC_HOST "irc.freeq.at" +set_default IRC_PORT "6697" +set_default IRC_TLS "1" +set_default IRC_NICK "eve" +set_default IRC_CHANNEL "#test" +set_default IRC_FREEQ_SESSION \ + "${HOME}/.config/freeq-tui/eve.boxd.sh.session.json" +set_default EVE_URL "http://127.0.0.1:8000" +set_default IRC_BACKLOG_MIN_MS "3000" +set_default IRC_BACKLOG_GAP_MS "2000" +set_default IRC_BACKLOG_MAX_MS "30000" +set_default AV_BRIDGE_URL "http://127.0.0.1:8790" +set_default AV_BRIDGE_BIND "127.0.0.1:8790" +set_default RADIO_TITLE_HOOK "http://127.0.0.1:8791/radio/now-playing" +set_default MEMORY_BANK_PATH "${HOME}/memory-bank.txt" + +# PATH for services (annas-mcp, ffmpeg, etc.) +env_set PATH "${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin" +env_set HOME "${HOME}" +env_set EVE_ROOT "${ROOT}" + +echo "[prep] model: ${OPENCODE_MODEL}" +echo "[prep] irc: ${IRC_NICK}@${IRC_HOST} → ${IRC_CHANNEL} via bridge → ${EVE_URL}" + +# --- freeq / rook session --------------------------------------------------- +if command -v npx >/dev/null 2>&1; then + echo "[prep] rook login ..." + npx --yes @solpbc/rook login || echo "[prep] warning: rook login failed" +fi +if [ -f "$ROOT/scripts/sync-freeq-session.mjs" ]; then + echo "[prep] sync freeq session ..." + node "$ROOT/scripts/sync-freeq-session.mjs" || echo "[prep] warning: freeq session sync failed" +fi +# AT Protocol handle verification for eve.boxd.sh (agent channel atproto-wellknown) +if [ -f "${HOME}/.config/rook/identity.json" ]; then + ATPROTO_DID="$(node -e "const i=require(process.env.HOME+'/.config/rook/identity.json'); process.stdout.write(i.did||'')" 2>/dev/null || true)" + if [ -n "${ATPROTO_DID}" ]; then + env_set ATPROTO_DID "${ATPROTO_DID}" + export ATPROTO_DID + echo "[prep] ATPROTO_DID=${ATPROTO_DID}" + fi +fi + +# --- annas-mcp -------------------------------------------------------------- +if [ ! -x "${ANNAS_MCP_BIN:-$HOME/.local/bin/annas-mcp}" ]; then + echo "[prep] installing annas-mcp CLI ..." + bash "$ROOT/scripts/install-annas-mcp.sh" || echo "[prep] warning: annas-mcp install failed" +fi +if command -v annas-mcp >/dev/null 2>&1; then + echo "[prep] annas-mcp: $(command -v annas-mcp)" + env_set ANNAS_MCP_BIN "$(command -v annas-mcp)" +else + echo "[prep] annas-mcp missing — anna_search will fail until install-annas-mcp.sh succeeds" +fi + +# --- eve build -------------------------------------------------------------- +echo "[prep] building eve ..." +npx eve build + +# --- install runtime.env ---------------------------------------------------- +cp "$tmp" "$RUNTIME_ENV" +chmod 600 "$RUNTIME_ENV" +echo "[prep] wrote ${RUNTIME_ENV}" diff --git a/scripts/refresh-freeq-session.sh b/scripts/refresh-freeq-session.sh new file mode 100755 index 0000000..85a44d0 --- /dev/null +++ b/scripts/refresh-freeq-session.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Refresh rook OAuth → freeq SASL session files. +# Invoked by systemd user timer: eve-freeq-session-refresh.timer +# +# Does NOT restart the IRC bridge on every tick — that was re-firing +# STREAMPLACE_AUTO and clobbering a user `watch` with the top-viewers stream. +# Restart only when the freeq access_token actually changes. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HOME="${HOME:-/home/boxd}" +export HOME +export PATH="${HOME}/.nvm/versions/node/v24.18.0/bin:${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH}" + +LOG_DIR="${EVE_LOG_DIR:-$HOME/logs}" +mkdir -p "$LOG_DIR" +LOG="${FREEQ_SESSION_REFRESH_LOG:-$LOG_DIR/freeq-session-refresh.log}" + +SESSION_FILE="${IRC_FREEQ_SESSION:-$HOME/.config/freeq-tui/eve.boxd.sh.session.json}" + +ts() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +token_fp() { + local f="$1" + if [ ! -f "$f" ]; then + echo "missing" + return + fi + # fingerprint access_token only (ignore nonce churn) + node -e ' + const fs = require("fs"); + try { + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const t = j.access_token || j.accessJwt || ""; + const crypto = require("crypto"); + process.stdout.write(crypto.createHash("sha256").update(String(t)).digest("hex").slice(0, 16)); + } catch { + process.stdout.write("unreadable"); + } + ' "$f" 2>/dev/null || echo "unreadable" +} + +{ + echo "[$(ts)] refresh start" + + if ! command -v npx >/dev/null 2>&1; then + echo "[$(ts)] error: npx not on PATH ($PATH)" + exit 1 + fi + if ! command -v node >/dev/null 2>&1; then + echo "[$(ts)] error: node not on PATH" + exit 1 + fi + + before="$(token_fp "$SESSION_FILE")" + + # establish or refresh headless OAuth + npx --yes @solpbc/rook login --json + + # project freeq IRC SASL session files + node "$ROOT/scripts/sync-freeq-session.mjs" + + after="$(token_fp "$SESSION_FILE")" + echo "[$(ts)] token_fp before=$before after=$after" + + if [ "$before" != "$after" ]; then + # Bridge reloads session on each connect; bounce only when token rotated so + # a mid-session expiry can pick up the new SASL material promptly. + if systemctl --user is-active eve-irc-bridge.service >/dev/null 2>&1; then + systemctl --user try-restart eve-irc-bridge.service + echo "[$(ts)] token changed — restarted eve-irc-bridge.service" + else + echo "[$(ts)] token changed — irc-bridge not active, skip restart" + fi + else + echo "[$(ts)] token unchanged — leave irc-bridge running (preserves watch / AV)" + fi + + echo "[$(ts)] refresh ok" +} >>"$LOG" 2>&1 diff --git a/scripts/run-av-bridge-service.sh b/scripts/run-av-bridge-service.sh new file mode 100755 index 0000000..170810a --- /dev/null +++ b/scripts/run-av-bridge-service.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Long-running entrypoint for eve-av-bridge.service. +# Prefer a prebuilt binary (boxd static deploy); fall back to freeq cargo/nix. +set -euo pipefail + +export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIND="${AV_BRIDGE_BIND:-127.0.0.1:8790}" +export RADIO_TITLE_HOOK="${RADIO_TITLE_HOOK:-http://127.0.0.1:8791/radio/now-playing}" + +candidates=( + "${EVE_AV_BRIDGE_BIN:-}" + "${HOME}/.local/bin/eve-av-bridge" + "${HOME}/bin/eve-av-bridge" + "${ROOT}/bin/eve-av-bridge" + "${ROOT}/result/bin/eve-av-bridge" +) + +for bin in "${candidates[@]}"; do + if [ -n "$bin" ] && [ -x "$bin" ]; then + echo "[av-bridge] exec $bin --bind $BIND (RADIO_TITLE_HOOK=$RADIO_TITLE_HOOK)" + exec env RADIO_TITLE_HOOK="$RADIO_TITLE_HOOK" "$bin" --bind "$BIND" + fi +done + +echo "[av-bridge] no prebuilt binary; using scripts/run-av-bridge.sh" +exec "$ROOT/scripts/run-av-bridge.sh" diff --git a/scripts/run-av-bridge-streamplace.sh b/scripts/run-av-bridge-streamplace.sh new file mode 100755 index 0000000..e39577a --- /dev/null +++ b/scripts/run-av-bridge-streamplace.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Additional freeq MoQ media plane dedicated to stream.place rebroadcast. +set -euo pipefail + +export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export AV_BRIDGE_BIND="${STREAMPLACE_AV_BRIDGE_BIND:-127.0.0.1:8792}" +# Don't push ICY titles into the radio announce path (separate plane). +export RADIO_TITLE_HOOK="${STREAMPLACE_RADIO_TITLE_HOOK:-}" + +echo "[av-bridge-streamplace] bind=$AV_BRIDGE_BIND" +exec "$ROOT/scripts/run-av-bridge-service.sh" diff --git a/scripts/start.sh b/scripts/start.sh index f1581dc..111c7f4 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -1,68 +1,72 @@ #!/usr/bin/env bash -# Boot: OpenBao keys → freeq session → eve start → irc-bridge (background). +# Boot the eve stack. +# +# Prefer systemd user units when installed (eve.target). Otherwise fall back +# to a legacy foreground path (prep + background irc-bridge + eve). +# +# npm run boxd:start +# bash scripts/start.sh +# bash scripts/start.sh --legacy # force non-systemd path +# bash scripts/start.sh --install # install units then start set -euo pipefail -export PATH="/usr/local/bin:/usr/bin:/bin:$PATH" +export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" -echo "[start] fetching keys from ${OPENBAO_ADDR:-} ..." -# shellcheck disable=SC1090 -eval "$(bash "$ROOT/scripts/fetch-keys.sh")" -echo "[start] injected keys: $(env | grep -cE '^(FUGU|META|OLLAMA|OPENCODE|OPENROUTER|UMANS|ANNA)_API_KEY=' || true)" -if [ -n "${ANNA_API_KEY:-}" ]; then - echo "[start] ANNA_API_KEY present (Anna's Archive member JSON API)" -else - echo "[start] ANNA_API_KEY missing — anna_download / anna_fast_download need a key" -fi -export ANNA_DOWNLOAD_DIR="${ANNA_DOWNLOAD_DIR:-$HOME/archive}" -mkdir -p "$ANNA_DOWNLOAD_DIR" -echo "[start] ANNA_DOWNLOAD_DIR=$ANNA_DOWNLOAD_DIR" - -export OPENCODE_MODEL="${OPENCODE_MODEL:-deepseek-v4-flash-free}" - -export IRC_HOST="${IRC_HOST:-irc.freeq.at}" -export IRC_PORT="${IRC_PORT:-6697}" -export IRC_TLS="${IRC_TLS:-1}" -export IRC_NICK="${IRC_NICK:-eve}" -export IRC_CHANNEL="${IRC_CHANNEL:-#test}" -export IRC_FREEQ_SESSION="${IRC_FREEQ_SESSION:-$HOME/.config/freeq-tui/eve.rookery.boxd.sh.session.json}" -export EVE_URL="${EVE_URL:-http://127.0.0.1:8000}" -export IRC_BACKLOG_MIN_MS="${IRC_BACKLOG_MIN_MS:-3000}" -export IRC_BACKLOG_GAP_MS="${IRC_BACKLOG_GAP_MS:-2000}" -export IRC_BACKLOG_MAX_MS="${IRC_BACKLOG_MAX_MS:-30000}" +LEGACY=0 +INSTALL=0 +for arg in "$@"; do + case "$arg" in + --legacy) LEGACY=1 ;; + --install) INSTALL=1 ;; + -h | --help) + sed -n '2,12p' "$0" + exit 0 + ;; + esac +done -echo "[start] model: ${OPENCODE_MODEL}" -echo "[start] irc: ${IRC_NICK}@${IRC_HOST} → ${IRC_CHANNEL} via bridge → ${EVE_URL}" +units_installed() { + systemctl --user cat eve.target >/dev/null 2>&1 +} -if command -v npx >/dev/null 2>&1; then - echo "[start] rook login ..." - npx --yes @solpbc/rook login || echo "[start] warning: rook login failed" -fi -if [ -f "$ROOT/scripts/sync-freeq-session.mjs" ]; then - echo "[start] sync freeq session ..." - node "$ROOT/scripts/sync-freeq-session.mjs" || echo "[start] warning: freeq session sync failed" +if [ "$INSTALL" -eq 1 ]; then + bash "$ROOT/scripts/install-systemd.sh" fi -# annas-mcp for anna_search (ISBN/title → MD5) -if [ ! -x "${ANNAS_MCP_BIN:-$HOME/.local/bin/annas-mcp}" ]; then - echo "[start] installing annas-mcp CLI ..." - bash "$ROOT/scripts/install-annas-mcp.sh" || echo "[start] warning: annas-mcp install failed" +if [ "$LEGACY" -eq 0 ] && units_installed; then + echo "[start] systemd: starting eve.target" + # Refresh secrets/build before (re)starting long-running units. + systemctl --user start eve-prep.service + systemctl --user restart eve.service eve-irc-bridge.service + if systemctl --user is-enabled eve-av-bridge.service >/dev/null 2>&1; then + systemctl --user restart eve-av-bridge.service || true + fi + systemctl --user start eve.target + systemctl --user --no-pager --full status eve.service eve-irc-bridge.service || true + echo "[start] logs: journalctl --user -u eve.service -u eve-irc-bridge.service -f" + exit 0 fi -export PATH="$HOME/.local/bin:$PATH" -if command -v annas-mcp >/dev/null 2>&1; then - echo "[start] annas-mcp: $(command -v annas-mcp)" -else - echo "[start] annas-mcp missing — anna_search will fail until install-annas-mcp.sh succeeds" + +if [ "$LEGACY" -eq 0 ] && ! units_installed; then + echo "[start] systemd units not installed — legacy path" + echo "[start] install with: bash scripts/install-systemd.sh" fi -echo "[start] building eve ..." -npx eve build +# --- Legacy: prep + nohup bridge + foreground eve --------------------------- +bash "$ROOT/scripts/prep.sh" +RUNTIME_ENV="${EVE_RUNTIME_ENV:-$HOME/.config/eve/runtime.env}" +if [ -f "$RUNTIME_ENV" ]; then + set -a + # shellcheck disable=SC1090 + source "$RUNTIME_ENV" + set +a +fi -# IRC bridge (background). Waits for eve HTTP, then joins freeq. -echo "[start] launching irc-bridge ..." -nohup node "$ROOT/irc-bridge/server.mjs" >> /tmp/irc-bridge.log 2>&1 & +echo "[start] launching irc-bridge (legacy background) ..." +nohup node "$ROOT/irc-bridge/server.mjs" >>/tmp/irc-bridge.log 2>&1 & echo "[start] irc-bridge pid $! (log /tmp/irc-bridge.log)" echo "[start] launching eve start on :8000 ..." diff --git a/scripts/sync-freeq-session.mjs b/scripts/sync-freeq-session.mjs old mode 100644 new mode 100755 index c9ce7cb..d201eb4 --- a/scripts/sync-freeq-session.mjs +++ b/scripts/sync-freeq-session.mjs @@ -23,9 +23,9 @@ const ROOK_SESSION = const ROOK_IDENTITY = process.env.ROOK_IDENTITY_FILE ?? path.join(HOME, ".config/rook/identity.json"); -const DEFAULT_PDS = "https://rookery.boxd.sh"; -const DEFAULT_DID = "did:plc:76szbe2ywgwb7vzuingj4fhq"; -const DEFAULT_HANDLE = "eve.rookery.boxd.sh"; +const DEFAULT_PDS = "https://pds.eve.boxd.sh"; +const DEFAULT_DID = "did:plc:fdiivi2izdgx3rl2d4qedt7n"; +const DEFAULT_HANDLE = "eve.boxd.sh"; function b64url(buf) { return Buffer.from(buf).toString("base64url"); diff --git a/systemd/README.md b/systemd/README.md new file mode 100644 index 0000000..660bde6 --- /dev/null +++ b/systemd/README.md @@ -0,0 +1,129 @@ +# eve systemd units (user) + +Long-running processes are supervised by **systemd user units**, not `nohup`. + +| Unit | Role | +|------|------| +| `eve-prep.service` | oneshot: OpenBao keys, freeq session, annas-mcp, `eve build` → `~/.config/eve/runtime.env` | +| `eve.service` | agent HTTP `:8000` (+ `/.well-known/atproto-did` for handle `eve.boxd.sh`) | +| `eve-irc-bridge.service` | freeq IRC ↔ eve (restart always) | +| `eve-freeq-session-refresh.timer` | every 5m: `rook login` → sync freeq SASL session → restart IRC bridge | +| `eve-freeq-session-refresh.service` | oneshot body of the timer | +| `eve-av-bridge.service` | optional MoQ / radio media plane | +| `rookery.service` | single-user PDS (`wrangler dev` on `:8787`, public `pds.eve.boxd.sh`) | +| `eve.target` | groups prep + agent + IRC bridge | + +Identity: handle **`eve.boxd.sh`**, PDS **`https://pds.eve.boxd.sh`**, DID from `~/.config/rook/identity.json`. + +## Install (boxd or local) + +```bash +# from the agent checkout +bash scripts/install-systemd.sh +# optional radio/AV: +bash scripts/install-systemd.sh --with-av +# optional PDS (installs when ~/rookery exists, or force with --with-rookery): +bash scripts/install-systemd.sh --with-rookery + +# OpenBao creds for prep (boxd) +cp ~/.config/eve/openbao.env.example ~/.config/eve/openbao.env +chmod 600 ~/.config/eve/openbao.env +# edit OPENBAO_TOKEN=… + +# survive logout / reboot (once per machine) +loginctl enable-linger "$USER" + +systemctl --user start eve.target +``` + +`npm run boxd:start` / `scripts/start.sh` will **start/restart the units** when they are installed; use `--legacy` for the old foreground path, or `--install` to install then start. + +## Ops + +```bash +systemctl --user status eve.target +systemctl --user restart eve-irc-bridge.service # after freeq session sync +systemctl --user restart eve.service +systemctl --user stop eve.target # stop agent + bridge + +journalctl --user -u eve.service -u eve-irc-bridge.service -f +``` + +### After SASL / Guest nick fix + +```bash +npx --yes @solpbc/rook login +node scripts/sync-freeq-session.mjs +systemctl --user restart eve-irc-bridge.service +``` + + +### freeq SASL session refresh (systemd timer) + +Access tokens are short-lived. The timer keeps IRC SASL working: + +```bash +systemctl --user status eve-freeq-session-refresh.timer +systemctl --user start eve-freeq-session-refresh.service # run once now +systemctl --user list-timers eve-freeq-session-refresh.timer +tail -f ~/logs/freeq-session-refresh.log +``` + +`scripts/refresh-freeq-session.sh` runs `rook login --json`, writes freeq +session files via `sync-freeq-session.mjs`, then `try-restart`s +`eve-irc-bridge.service` so the bridge re-SASLs without a Guest nick. + +If refresh fails with an expired rook session, re-auth once: + +```bash +npx --yes @solpbc/rook login +systemctl --user start eve-freeq-session-refresh.service +``` + +### Config files (`~/.config/eve/`) + +| File | Purpose | +|------|---------| +| `openbao.env` | `OPENBAO_ADDR`, `OPENBAO_TOKEN` (prep only; mode `0600`) | +| `config.env` | optional non-secret overrides (`IRC_*`, `OPENCODE_MODEL`, …) | +| `runtime.env` | **written by prep** — API keys + defaults for services | + +Templates live in `systemd/user/`; install substitutes `@ROOT@`, `@NODE@`, `@NPX@`. + +## Layout + +``` +systemd/user/*.service # templates (not live units) +scripts/install-systemd.sh +scripts/prep.sh +scripts/run-av-bridge-service.sh +scripts/start.sh # systemctl if installed, else legacy +``` + +### stream.place MoQ plane (additional) + +Second `eve-av-bridge` on `127.0.0.1:8792` rebroadcasts a stream.place live +stream into freeq AV (default `#test`). Auto-starts when `STREAMPLACE_AUTO=1` +in `~/.config/eve/config.env`. + +**One MoQ plane per freeq session:** the IRC bridge only keeps a single media +plane attached at a time. Starting radio or stream.place disconnects the other +plane and `av-leave`s prior `eve~instance` roster rows so freeq never sees two +eve publishers in the same call. + +| Unit / endpoint | Role | +|-----------------|------| +| `eve-av-bridge-streamplace.service` | MoQ media plane `:8792` | +| `POST /streamplace/play` (irc-bridge `:8791`) | pick top-viewers stream (or body.streamer) + join AV | +| `POST /streamplace/stop` | stop rebroadcast + disconnect plane | +| `GET /streamplace/status` | plane health + top live | + +```bash +systemctl --user status eve-av-bridge-streamplace.service +curl -sS http://127.0.0.1:8791/streamplace/status | jq . +curl -sS -X POST http://127.0.0.1:8791/streamplace/play -H 'content-type: application/json' -d '{}' +curl -sS -X POST http://127.0.0.1:8791/streamplace/stop +``` + +Picks `place.stream.live.getLiveUsers` sorted by `viewerCount`, plays HLS via +`place.stream.playback.getLivePlaylist?streamer=` through ffmpeg → MoQ. diff --git a/systemd/user/eve-av-bridge-streamplace.service b/systemd/user/eve-av-bridge-streamplace.service new file mode 100644 index 0000000..9725d4e --- /dev/null +++ b/systemd/user/eve-av-bridge-streamplace.service @@ -0,0 +1,23 @@ +[Unit] +Description=eve AV bridge (stream.place → freeq MoQ media plane) +Documentation=file://@ROOT@/systemd/README.md +After=network-online.target eve-av-bridge.service +PartOf=eve.target + +[Service] +Type=simple +WorkingDirectory=@ROOT@ +Environment=HOME=%h +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=STREAMPLACE_AV_BRIDGE_BIND=127.0.0.1:8792 +Environment=AV_BRIDGE_BIND=127.0.0.1:8792 +Environment=RADIO_TITLE_HOOK= +Environment=AV_INCLUDE_VIDEO=1 +EnvironmentFile=-%h/.config/eve/runtime.env +EnvironmentFile=-%h/.config/eve/config.env +ExecStart=@ROOT@/scripts/run-av-bridge-streamplace.sh +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=eve.target diff --git a/systemd/user/eve-av-bridge.service b/systemd/user/eve-av-bridge.service new file mode 100644 index 0000000..9130553 --- /dev/null +++ b/systemd/user/eve-av-bridge.service @@ -0,0 +1,20 @@ +[Unit] +Description=eve AV bridge (freeq MoQ media plane / radio) +Documentation=file://@ROOT@/systemd/README.md +After=network-online.target +# Optional sidecar — not required by eve.target by default; enable explicitly. +PartOf=eve.target + +[Service] +Type=simple +WorkingDirectory=@ROOT@ +Environment=HOME=%h +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +EnvironmentFile=-%h/.config/eve/runtime.env +EnvironmentFile=-%h/.config/eve/config.env +ExecStart=@ROOT@/scripts/run-av-bridge-service.sh +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=eve.target diff --git a/systemd/user/eve-freeq-session-refresh.service b/systemd/user/eve-freeq-session-refresh.service new file mode 100644 index 0000000..f17a1f3 --- /dev/null +++ b/systemd/user/eve-freeq-session-refresh.service @@ -0,0 +1,17 @@ +[Unit] +Description=Refresh eve freeq SASL session (rook OAuth → freeq session files) +Documentation=file://@ROOT@/systemd/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=@ROOT@ +Environment=HOME=%h +Environment=PATH=%h/.nvm/versions/node/v24.18.0/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin +EnvironmentFile=-%h/.config/eve/runtime.env +EnvironmentFile=-%h/.config/eve/config.env +ExecStart=@ROOT@/scripts/refresh-freeq-session.sh +Nice=10 +# oneshot may take a while (npx + rook login) +TimeoutStartSec=120 diff --git a/systemd/user/eve-freeq-session-refresh.timer b/systemd/user/eve-freeq-session-refresh.timer new file mode 100644 index 0000000..f1d35a1 --- /dev/null +++ b/systemd/user/eve-freeq-session-refresh.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Keep freeq SASL session fresh for eve IRC bridge +Documentation=file://@ROOT@/systemd/README.md + +[Timer] +# Soon after boot / login, then every 5 minutes (access tokens are short-lived). +OnBootSec=1min +OnUnitActiveSec=5min +AccuracySec=15s +Persistent=true +Unit=eve-freeq-session-refresh.service + +[Install] +WantedBy=timers.target diff --git a/systemd/user/eve-irc-bridge.service b/systemd/user/eve-irc-bridge.service new file mode 100644 index 0000000..a9ee9ec --- /dev/null +++ b/systemd/user/eve-irc-bridge.service @@ -0,0 +1,23 @@ +[Unit] +Description=eve IRC bridge (freeq ↔ eve HTTP/SSE) +Documentation=file://@ROOT@/systemd/README.md +# Bridge waits for eve HTTP itself; still order after agent when possible. +Wants=eve.service +After=eve-prep.service eve.service +PartOf=eve.target + +[Service] +Type=simple +WorkingDirectory=@ROOT@ +Environment=HOME=%h +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +EnvironmentFile=-%h/.config/eve/runtime.env +EnvironmentFile=-%h/.config/eve/config.env +ExecStart=@NODE@ @ROOT@/irc-bridge/server.mjs +Restart=always +RestartSec=3 +# After hibernate/wake, force a clean reconnect cycle. +TimeoutStopSec=15 + +[Install] +WantedBy=eve.target diff --git a/systemd/user/eve-prep.service b/systemd/user/eve-prep.service new file mode 100644 index 0000000..12046d6 --- /dev/null +++ b/systemd/user/eve-prep.service @@ -0,0 +1,21 @@ +[Unit] +Description=eve prep (OpenBao keys, freeq session, build) +Documentation=file://@ROOT@/systemd/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=@ROOT@ +Environment=HOME=%h +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +# Operator secrets for OpenBao (OPENBAO_ADDR, OPENBAO_TOKEN) — mode 0600. +EnvironmentFile=-%h/.config/eve/openbao.env +# Optional non-secret overrides (IRC_*, OPENCODE_MODEL, …). +EnvironmentFile=-%h/.config/eve/config.env +ExecStart=@ROOT@/scripts/prep.sh +TimeoutStartSec=300 + +[Install] +WantedBy=eve.target diff --git a/systemd/user/eve.service b/systemd/user/eve.service new file mode 100644 index 0000000..2325b96 --- /dev/null +++ b/systemd/user/eve.service @@ -0,0 +1,22 @@ +[Unit] +Description=eve agent (HTTP :8000) +Documentation=file://@ROOT@/systemd/README.md +Requires=eve-prep.service +After=eve-prep.service network-online.target +PartOf=eve.target + +[Service] +Type=simple +WorkingDirectory=@ROOT@ +Environment=HOME=%h +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +EnvironmentFile=-%h/.config/eve/runtime.env +EnvironmentFile=-%h/.config/eve/config.env +ExecStart=@NPX@ eve start --port 8000 --host 0.0.0.0 +Restart=on-failure +RestartSec=5 +# Give in-flight agent turns a moment to finish. +TimeoutStopSec=30 + +[Install] +WantedBy=eve.target diff --git a/systemd/user/eve.target b/systemd/user/eve.target new file mode 100644 index 0000000..77513e7 --- /dev/null +++ b/systemd/user/eve.target @@ -0,0 +1,8 @@ +[Unit] +Description=eve agent stack (prep + agent + IRC bridge; AV optional) +Documentation=file://@ROOT@/systemd/README.md +Wants=eve-prep.service eve.service eve-irc-bridge.service +After=eve-prep.service + +[Install] +WantedBy=default.target diff --git a/systemd/user/rookery.service b/systemd/user/rookery.service new file mode 100644 index 0000000..e84d8e8 --- /dev/null +++ b/systemd/user/rookery.service @@ -0,0 +1,25 @@ +[Unit] +Description=rookery PDS (single-user, wrangler dev / miniflare) +Documentation=file://@ROOT@/systemd/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=@HOME@/rookery +Environment=HOME=@HOME@ +Environment=PATH=@HOME@/rookery/node_modules/.bin:@NODE_DIR@:/usr/local/bin:/usr/bin:/bin +ExecStart=@HOME@/rookery/node_modules/.bin/wrangler dev \ + --ip 0.0.0.0 \ + --port 8787 \ + --local \ + --persist-to @HOME@/rookery-data \ + --show-interactive-dev-session false \ + --log-level info +Restart=on-failure +RestartSec=3 +StandardOutput=append:@HOME@/logs/rookery.log +StandardError=append:@HOME@/logs/rookery.log + +[Install] +WantedBy=default.target -- 2.51.2