Argus — Design Spec¶
Working name: Argus (the hundred-eyed watcher; rename at will) Date: 2026-06-09 Status: Draft for review One line: A small, observable, self-hosted control plane that runs recurring homelab/personal tasks (deterministic, single-prompt-LLM, and agent), captures all conversation + research into one memory, and — above all — lets you verify it's working at a glance.
1. Problem & Context¶
Every system tried so far (Hermes most recently) has been ineffective without significant ongoing work, and even then there's no way to tell if it's actually working. Hermes proved this in the worst way this week: a memory-capture poison loop failed silently for hours and was only caught because a UI looked stale.
The driver is therefore trust, decomposed as: 1. It works reliably with low ongoing effort, and 2. You can always verify it's working — silent failure is forbidden, and "it didn't even run" must be as visible as "it ran and broke."
Hermes is a ~40-feature agent framework of which roughly four features are actually used; the rest is surface area that breaks and can't be verified. memory-os is now effectively a separate system bolted on via fragile hooks. The goal is to keep the handful of primitives that matter, own them end to end, and make verifiability the point — not a feature.
2. Goals & Non-Goals¶
Goals¶
- A reliable scheduler for recurring tasks: deterministic code, single-prompt LLM, and (occasionally) agent tasks.
- Unified observability — push and pull — over every task run and the system's own health. No silent failure; no-shows are detected.
- One central memory of all conversations + distilled research/knowledge, auto-captured, auto-distilled, agent-queryable, and resurfaced before it's forgotten.
- Extensible by design: adding a capability is dropping a folder in a repo + redeploy. Especially via Telegram.
- Lean on already-trusted components; build only the thin connective tissue.
Non-Goals (the fluff we deliberately won't build)¶
- Not a new agent runtime or reasoning loop — but we do orchestrate a cost-first fallback across existing runtimes. The agent is Claude Code (interactive, desktop+phone) and, for headless/automated tasks, the chain
claude -p(Max) → Codex → API (Anthropic/Grok) via LiteLLM. We route across them; we don't rebuild them. (See F12.) - Not a fully-autonomous "agentic at scale" swarm. Agents are supervised accelerators; autonomous tasks are rare, narrow, and observable.
- Not a replacement for things that already work: metamcp (tools), SOPS (secrets), LiteLLM+Langfuse (model routing + LLM tracing), Komodo/Forgejo (deploys), Gatus (uptime), Hoarder (raw bookmarks).
- Not a broad multi-channel chat platform. Telegram is a narrow, extensible notify + light-command surface; real work happens in Claude Code.
- No kanban, computer-use, multi-provider chat gateways, skill curators, etc.
3. Requirements¶
Functional¶
- F1 — Scheduling. Run tasks on cron schedules. Two tiers: (a) extensible user tasks, (b) the app's own fixed internals.
- F2 — Task types. Deterministic (code/bash), single-prompt LLM (via LiteLLM), and agent (
claude -p+ metamcp tools). - F3 — Reporting contract. Every task run reports to the hub: name, start/end, exit status, output summary, optional
deliverintent, optional cost. Baseline reporting is enforced by the runner (cannot be skipped); rich reporting is opt-in viataskkit. - F4 — No-show detection. The hub knows each task's expected schedule and flags any task that was due and did not report.
- F5 — Push. Telegram digests (periodic "what ran / what's healthy") + immediate failure alerts + per-task
deliveroutputs (e.g. a bookmark summary to #research). - F6 — Pull. A web dashboard: every task, last run, ok/fail/skipped, duration, cost, output; system health; memory browse/search.
- F7 — Universal capture (the central hub). The memory store is the single sink for all context from any system — Claude Code and Codex sessions (across machines), Telegram, agent-task runs, research/bookmarks, and future sources. Everything routes in through one universal capture API with provenance (source, type, timestamp, project). It is the central memory hub, runtime-agnostic: no matter which engine did the work, the context lands in one place.
- F8 — Memory distill. Distil raw captures into tight, durable knowledge (facts, decisions, research syntheses). Summaries are short by default, expand-on-demand.
- F9 — Memory recall/inject. Recall relevant memory into new sessions (the inject hook) and answer agent/user queries against it.
- F10 — Content memory + resurfacing. Capture articles/bookmarks/podcasts/research (Telegram, Hoarder, etc.); two intents — triage (tight summary → store) and research synthesis (agent reads source against our goals, extracts what's worth keeping, writes synthesis into memory). Resurface saved-but-unread items so nothing is buried.
- F11 — Extensibility. New capability = new folder in
tasks/+ aconfig.inistanza + redeploy. Capabilities are git-tracked and reviewable; an agent (a CC session) can author/edit them. - F12 — Cost-first runtime fallback. Two layers. Agent tasks (especially coding / project work): try Claude Max (
claude -p) → Codex → API (Anthropic/Grok) via LiteLLM, falling forward on rate-limit / unavailability / error — flat-rate subscriptions exhausted before paying per-token. Single-prompt tasks: local-first via LiteLLM (local 5070 Ti → Grok → Anthropic).taskkit/the handler owns the runtime-level chain (Max→Codex→API); LiteLLM owns model-level fallback within the API tier. - F13 — Human-readable export. Any agent can, on demand or on a schedule, distill memory into human-readable Markdown — e.g. a summary written into an Obsidian vault. Memory is not a black box you only query through machines; it's exportable to something you read directly. (This doubles as a trust surface — another way to see what the system knows.)
- F14 — Agent-context layer (SOUL / rulebook / USER). Argus ships three persistent agent-governance files, adapted from the Hermes set that proved effective:
SOUL.md(identity, voice, ground-truth hierarchy),rulebook.md(hard rules + verification discipline — destructive-command confirmation, Tailscale-only, Forgejo-primary, "say I don't have verified evidence rather than confabulate"), andUSER.md(Aaron's static profile).taskkitinjects them into everyagent-type task, and they anchor the overnight-build prompt. They encode the verifiability ethos at the behavior level — the rulebook's ground-truth hierarchy is the no-silent-failure principle applied to the agent itself.
Non-Functional¶
- N1 — Verifiability (the prime directive). System state is always knowable with standard tools (
psql, the dashboard, container logs). The scheduler/hub prove their own liveness. - N2 — Low operational effort. Container-first, GitOps deploy (Komodo/Forgejo),
/healthz+ Gatus, SOPS secrets — consistent with the rest of the homelab. - N3 — Simplicity / legibility. Small enough to hold in your head. Single-binary hub.
- N4 — Cost-aware. Local-first LLM (5070 Ti via LiteLLM); per-token providers only as fallback; cost surfaced per LLM task.
- N5 — Recoverable. State in Postgres; backups; no precious un-versioned state.
4. Architecture¶
Container-first stack (Komodo/compose), four containers:
┌──────────────────────────────────────────────┐
│ Ofelia (scheduler) │
│ reads config.ini -> runs tasks/ on schedule │
└───────────────┬──────────────────────────────┘
│ job-run (isolated) per task
▼
┌──────────────────────────────────────────────┐
│ task-runner image (Python + taskkit + │
│ claude CLI + LiteLLM/metamcp access) │
│ wraps execution -> baseline report │
└───────────────┬──────────────────────────────┘
│ report(run) + deliver(intent) ┌─ LiteLLM ─> local 5070Ti / Grok / Anthropic
▼ │ (Langfuse traces + cost)
┌──────────────────────────────────────────────┐ │
│ Argus hub (Go single binary) │─┤ claude -p (agent tasks, metamcp tools)
│ - HTTP API (receives task reports) │ │
│ - status store + no-show watchdog │ └─ metamcp (tools), SOPS (secrets)
│ - robfig/cron internals (distill, │
│ resurface, watchdog) │
│ - HTMX dashboard (pull) │
│ - Telegram notifier (push / deliver) │
│ - memory capture/inject/recall API │
│ /healthz <- Gatus │
└───────────────┬──────────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Postgres + pgvector │
│ run-status + memory (FTS + vectors) │
└──────────────────────────────────────────────┘
Why these choices
- Go single-binary hub: trivial container, single artifact to reason about, fits the existing Go projects; Docker restart: unless-stopped + /healthz + Gatus answers "who watches the watcher" using infra you already run.
- Ofelia for user tasks: Docker-native cron, INI config that appends cleanly, runs each task isolated (job-run). It only provides the when; the trust layer is ours.
- robfig/cron inside the hub: the app's own fixed internal jobs (distill, resurface, no-show watchdog) — static, cohesive with hub logic.
- Postgres + pgvector: already operated on pve-yact-infra; with the stack containerized, the only historical reason to avoid PG (deploy friction) is gone. pgvector removes any "will I miss vector features" doubt (HNSW ANN, metadata-filtered search, concurrency). SQLite + sqlite-vec would be fine at personal scale, but PG is the lower-regret call here.
Hub's role vs. memory service (to be finalized in M0.5). The hub is the control plane: it operates and makes-verifiable everything — scheduling coordination, run-status, the no-show watchdog, the dashboard, Telegram. Memory is a separate bounded service, not hub internals. It owns its own API (capture / recall / distill / search), is talked to directly by CC sessions, Telegram, and agents, and is merely scheduled and surfaced by the hub like any other capability. This keeps the most important piece independently designable, testable, and replaceable — and stops the hub from becoming the kitchen-sink that Hermes was.
Runtime fallback (cost-first). Automated agent tasks — especially coding / project work — don't pin to one engine: the handler tries the flat-rate subscriptions first (claude -p Max, then Codex) and only falls to per-token API (Anthropic/Grok via LiteLLM) when those are rate-limited or unavailable. Single-prompt tasks go local-first through LiteLLM. It's the same preference order you apply by hand, encoded so tasks degrade gracefully instead of failing on a hit quota. And whichever engine runs the work, its context routes back to the one central memory (F7) — memory is runtime-agnostic.
Mechanism. Single-prompt fallback is already LiteLLM's job — its config chains local→Grok→Anthropic, so taskkit just calls LiteLLM and gets it free. Runtime fallback is a thin wrapper that runs claude -p → codex exec → API-via-LiteLLM in order, detecting a rate-limit/failure between each (non-zero exit + the 5-hour-cap signal, the way ralph-claude-code does) and logging which runtime actually ran (so the hub shows "task X fell to Codex — Max capped"). The overnight build invokes the same wrapper per loop iteration, so a quota hit falls over instead of stalling like vanilla Ralph. (Open: whether Fable 5 is reachable on the Max plan or API-only — it launched 2026-06-09 — which sets the top of the build's chain.)
5. Task / Capability Model¶
tasks/<name>/— one folder per capability, git-tracked. Contains the script (Python, or bash for trivial deterministic jobs) and any task-local config/tools the agent may use.config.ini— one file, the single source of when + how to invoke + deliver target. Read by both Ofelia (to schedule) and the hub (to know what to expect, for no-show detection). Append a stanza to add a task.- Per-task output channel. Each task stanza carries an optional
channelfield naming the Telegram channel its output is delivered to (e.g.channel = research). Omit it → the run is recorded for observability but nothing is pushed. This is how a task declares "send my result to #research" vs. "just log that I ran." - Three handler shapes:
code(deterministic),llm(single-prompt via LiteLLM),agent(claude -pwith metamcp). Templated so each is consistent. - Adding a capability: new folder +
config.inistanza + commit + redeploy (Komodo picks it up). Reviewable and versioned — deliberately not ad-hoc from a phone.
6. Observability Model¶
- Two enforcement levels: the
task-runnerwraps every execution → guaranteed baseline report (ran/exit/duration/stdout), language-agnostic.taskkit(1 import) adds structured output, cost,deliver, and memory helpers. - Hub is the single source of truth. Tasks report to the hub, never straight to Telegram. The hub records (→ dashboard/pull) and forwards (→ Telegram/push).
deliverintent is declared by the task; the hub owns sending. - No-show watchdog: hub compares expected schedule (from
config.ini) against received reports; a due-but-absent task raises an alert. This is the piece prior systems lacked. - LLM tracing: single-prompt calls go through LiteLLM → Langfuse traces + cost automatically.
claude -pagent runs do not trace in Langfuse — their observability is the hub's run record. The dashboard links out to Langfuse for the traced calls.
7. Memory Model¶
This is the truly central hub. Every system routes its context here — CC and Codex sessions, Telegram, agent-task runs, research/bookmarks, and whatever comes later — through one universal, provenance-tagged capture API. It is the single place all memory lives, independent of which tool or runtime produced it. The hard design problem (M0.5) is structure: holding heterogeneous context — chat turns, documents, research syntheses, task-run events — in one coherent, queryable, verifiable model without it becoming the unmaintainable tangle memory-os was.
- One unified knowledge store for distilled content: conversation facts, decisions, and research syntheses — all agent-queryable. The synthesis is the first-class object; the source (CC session, Telegram, Hoarder) is metadata.
- Pluggable capture sources: Claude Code session hook (repointed from the old cc_mem bridge to the hub API), Telegram channels, Hoarder, etc.
- Two content intents: triage (tight summary → store, local LLM) and research synthesis (agent reads source against our goals, extracts/relates, writes synthesis back). The research flow couples memory and agent: it recalls context, then writes new knowledge.
- Resurfacing is first-class and rides the same push/pull spine: push ("9 saved this week, 2 lines each"), pull ("what did I save about X").
- Substrate: Postgres + pgvector (FTS for keyword, vectors for semantic). Embeddings via the always-on local embed model through LiteLLM.
- Quality rule: short by default, expand-on-demand.
Architecture (research-grounded — see docs/research/2026-06-09-memory-architecture-research.md)¶
Memory is a 4-stage pipeline — capture → distill → store → recall — where every stage is independently observable. The research is emphatic that failures originate upstream (capture/distill), not just retrieval, and that the memory-os self-poisoning loop is a documented structural mechanism. The design defends against it explicitly:
- Dual-track store.
raw_itemsis append-only / immutable — the source of truth for every captured turn, message, article, synthesis, with full provenance (source, type, timestamp, project, session).distilled_notesis the mutable layer, always regenerable from raw. A logged reconciliation job replays raw → distilled to correct drift; a bad distilled entry is fixed by regenerating from clean source, never lost. - Grounded write-gate (the anti-poison defense). Distillation self-questions every candidate note ("what in the raw text supports this?") and discards + logs anything ungrounded; each note carries a source span/quote. API-error / system text is filtered at the gate. Recall output is never a capture source without re-grounding, and recalled items are never auto-promoted to higher confidence. This is exactly what would have stopped the memory-os loop.
- Inspectable note schema (A-MEM style):
{content, time, keywords, tags, context, source_ref, embedding, links, valid_from, valid_to, confidence}— human-readable attributes generated at ingest, directly auditable (and exportable to Obsidian, F13). Any post-ingest edit is a versioned, audited diff, never a silent rewrite. - Bi-temporal validity for staleness (
valid_from/valid_to/invalid_at) — timestamps/flags on distilled facts, not a separate graph DB. - Recall = hybrid + verified. pgvector (semantic) + Postgres full-text/BM25 (keyword) fused via RRF, with type-priors + recency/validity. Each session logs which notes were injected, and a cheap recall-impact check (RePCS-style) flags when injected context didn't actually move the model — so "is recall load-bearing?" is observable.
- Deletes are soft (tombstones), so post-deletion state is verifiable. No hard DELETE of memory.
- No blind maintenance. Every reconcile/dedup/resurface pass writes a snapshot + diff + trace; nothing runs "blind every few minutes." Dedup = deterministic hash/near-dup at the raw layer + audited, reversible merges at the distilled layer.
- Resurfacing = probabilistic, density-weighted, decay-on-shown (Readwise model) — a Postgres weighted-random draw +
shown_countyou can query to see exactly why something surfaced; optional FSRS closed-form schedule for items worth true spaced-repetition.
Layers & routing (what feeds what)¶
raw_items is the one source of truth — every capture and every task output lands there, immutable. Everything else is a regenerable view over it. The question that organizes the whole hub is how a raw item is split into views, and it turns on two axes, not one:
- Content —
source/kind: a chat turn, a podcast digest, a saved link, a synthesis. Gives the default routing. - Intent / directive — what the user wants done with it. Default-by-kind, but explicit when stated ("review this link for Argus applicability") — and intent that source/kind can't express is the part memory-os never modeled. An explicit directive overrides the default route and can spawn a task.
The views (derived layers):
| Layer | Does | Fed by | Mutable state (keyed by raw_id, since raw is immutable) |
|---|---|---|---|
facts (distilled_notes) |
atomic durable facts + importance, for recall |
turns, documents, fact-worthy syntheses | distill_state |
feed (feed_state) |
surface saved-but-unread, decay-on-shown | podcasts, bookmarks, saved links, unread syntheses | feed_state (shown_count, last_shown_at) |
| directed synthesis | agent answers a specific directive over source(s); writes a new kind=synthesis raw_item |
a captured directive or a cron task | the task-run record |
| wiki / entities (later) | curated per-entity/concept rollups | facts + syntheses | rebuild job |
Mutable per-item state must be a side table (distill_state, feed_state, …) — never a column on raw_items, because the immutability trigger forbids UPDATE. This is already the distill_state pattern.
Tasks are the engine, and they produce raw_items. The podcast cron and an ad-hoc "review this" are both tasks: they fetch/reason and write their output back into raw_items (a digest, a fetched article, a synthesis), which then routes to views like any capture. One capture can fan out to several views; a directive can spawn a task that creates more raw items.
- Podcast task →
kind=synthesisdigest → feed (surface it); its "things to look into" → leads (feed/todo); any hard facts → facts. Intent: capture highlights + suggestions without listening yet. - Telegram "review this for Argus" → spawns a task: fetch link (
kind=documentraw_item) + write a review (kind=synthesisraw_item) → routes to facts (ideas to steal,project=argus) and feed (surface the review). Intent: directed synthesis against a goal.
Routing is deterministic-first (by source/kind + presence of a directive); add LLM intent-classification only if the deterministic router proves insufficient. Build incrementally — every view is regenerable, so adding one later never reshapes what exists: facts (done) → feed (next, small) → the task / directed-synthesis engine (where the podcast and telegram-review paths converge).
Episodes — the distillation unit for conversations (research:
2026-06-10-episode-segmentation-research.md). kind=turn is captured per-turn
but NEVER distilled per-turn or per-session: sessions on a long-lived box never
end (one CC session resumed forever), so the stream is re-segmented into
episodes — variable-length spans closed by topic-shift OR size-cap (~20
turns) OR time-gap (>30 min), plus idle-close for a finished burst's tail. The
detector is tiered: deterministic guarantees (cap/gap) always on; embedding
cosine-drop against the rolling window (resident nomic embed tier) as the
topic-shift signal, boundaries anchored before USER turns; LLM judgment only if
calibration ever demands it. Every episode records boundary_reason +
boundary_score — why it cut is queryable. Distillation runs per CLOSED episode
(the live tail is never distilled); all non-turn kinds keep per-item treatment.
Derived artifacts (CC compaction summaries) are filtered at capture and never
re-enter raw as turns.
8. Components & Deployment¶
Two Go services (split by deployable concern — §4), plus scheduler, runner, store:
| Component | Tech | Role |
|---|---|---|
argus-hub |
Go binary (container) | control plane: status store, no-show watchdog, HTMX dashboard, Telegram, internal robfig/cron — the glue |
argus-memory |
Go binary (container) | the memory service: universal capture API, dual-track store, grounded distill, hybrid+verified recall; its own API, talked to directly by CC/Telegram/agents |
| Scheduler | Ofelia (pinned image) | schedules git-tracked tasks/ via config.ini |
| task-runner | Python image | consistent task runtime + taskkit + claude CLI; calls LiteLLM/metamcp |
| Store | Postgres + pgvector (container) | memory (raw + distilled + vectors) + run-status; dedicated, not shared |
| Health | /healthz per service + Gatus |
liveness backstop; hub also reports argus-memory health |
| Secrets | SOPS + age | secrets.enc.env, standard age recipient |
Host: a new pve-argus LXC on dumbledore, registered as a Komodo Periphery target (image = komodo-periphery-sops for the age key; the host must resolve git.aaronbrazier.com).
Deploy — self-contained in the argus repo during dev (fold into homelab-apps later):
- Everything lives in runonyourown/argus — code, Dockerfiles, compose.yaml, secrets.enc.env, .env.example, CI. One repo to build and reason about while developing (and for an agent to build in). Forgejo Actions on merge to main: (1) tests (Go + Python), (2) build argus-hub / argus-memory / task-runner images → push to Forgejo Packages, (3) validate gate (yamllint + docker compose config + SOPS-encrypted check + secret scan). Argus is the first custom-code app, so it establishes the build→Packages step.
- Komodo deploys directly from this repo — everything normal (Periphery on pve-argus, SOPS pre_deploy, poll/webhook on push): one Komodo Stack (declared as-code via a komodo/resource-sync.toml inside the argus repo, or created directly) → server = pve-argus, repo = runonyourown/argus, run_directory, file_paths = ["compose.yaml"], env_file_path = ".env", pre_deploy.command = "sops -d secrets.enc.env > .env", poll_for_updates = true. CI never deploys — Komodo reconciles on merge (fail-forward, no auto-rollback).
- Later (post-dev consolidation): move the stack declaration into homelab-apps/komodo/resource-sync.toml alongside the other apps. Not a dev task.
Dependencies (current endpoints): LiteLLM 192.168.1.28:4000, Langfuse 192.168.1.28:3002 (both on pve-llm-infra / LXC 111 — the replacement for the decommissioned pve-yact-infra; litellm-config.yaml defines the local/reasoning/fast/smart aliases, embed via llama.cpp on pve-gpu). metamcp on LXC 144. SOPS age key on Periphery at /etc/komodo/sops-age.key.
9. Milestones¶
Lead with memory — it's the most important and riskiest piece. Evaluate and design it first; operationalize the control plane around it.
- M0 — Skeleton: repo (Forgejo), compose stack (hub + PG + Ofelia + task-runner),
/healthz+ Gatus, SOPS wiring, deploy via Komodo. - M0.5 — Memory evaluation + hub-role definition: survey existing agent-memory architectures (what to adopt / avoid / steal), pin the boundary between the Argus hub (control plane) and the memory service (peer), and produce a detailed memory design. Precedes any memory build.
- M1 — Memory service core: capture (CC hook + Telegram → memory API) + store (PG + pgvector) + distill (tight, expand-on-demand) + recall/inject + browse/search. Port memory-os history so the agent retains what we've done and why.
- M2 — Content memory + resurfacing: Telegram triage + research-synthesis channels, Hoarder capture, resurfacing digest.
- M3 — Control plane + trust:
config.inischema,task-runnerwrapper (baseline report), status store, dashboard (pull), Telegram digest + failure alert (push), no-show watchdog. Operationalizes memory's distill/resurface tasks + migrates deterministic crons (e.g. IPAM regen). - M4 — LLM + agent tasks:
taskkit(LiteLLM + cost + deliver), anllmtask (podcast digest, tight), anagenttask (claude -p); confirm the Langfuse / claude-p observability seam. - M5 — Cutover: migrate remaining Hermes crons; retire Hermes once parity + trust are proven.
10. Risks & Open Questions¶
- Ofelia maintenance: lightly maintained — pin a known-good image; embedded
robfig/cronis the fallback if it rots. claude -pobservability gap: no Langfuse traces; mitigated by hub run records, but agent-task introspection is shallower than LLM tasks. Acceptable given agent tasks are rare.- Memory migration (decided: port). We port useful history out of memory-os (state.db sessions + distilled facts) — the agent needs to understand what we've done and why. Migration fidelity (raw vs. distilled, dedup, what's worth keeping) is part of the M0.5/M1 memory design.
- Capture hook coupling: the CC session hook must be rock-solid and fail-open (the old one's silent failure is the cautionary tale). Heartbeat the capture path itself.
- Memory structure (M0.5 research DONE — see
docs/research/). The schema, provenance, dual-track, write-gate, and staleness model are now decided (§7 Architecture). Four engineering questions remain for M1, which the literature motivates but doesn't specify: (1) cross-type recall ranking (chat vs. article vs. synthesis) — needs empirically-tuned hybrid + type-priors + RRF; (2) dedup thresholds (deterministic raw-layer hash/near-dup + audited distilled merges); (3) operator SLOs (capture-rate, grounding-rejection-rate, reconcile-diff count, recall-injection-used rate); (4) reconciliation cadence + keeping the reconcile job itself auditable. These are tuned empirically in M1, not designed up front. - Telegram command surface scope: start with notify + triage + research; resist creep.
- Name: "Argus" is a placeholder.