Compare commits
1 Commits
feat/front
...
0e89bc306f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e89bc306f |
@@ -1,39 +0,0 @@
|
||||
name: Validate Matrix Scaffold
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "infra/matrix/**"
|
||||
- ".gitea/workflows/validate-matrix-scaffold.yml"
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "infra/matrix/**"
|
||||
|
||||
jobs:
|
||||
validate-scaffold:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Validate Matrix/Conduit scaffold
|
||||
run: python3 infra/matrix/scripts/validate-scaffold.py --json
|
||||
|
||||
- name: Check shell scripts are executable
|
||||
run: |
|
||||
test -x infra/matrix/deploy-matrix.sh
|
||||
test -x infra/matrix/host-readiness-check.sh
|
||||
test -x infra/matrix/scripts/deploy-conduit.sh
|
||||
|
||||
- name: Validate docker-compose syntax
|
||||
run: |
|
||||
docker compose -f infra/matrix/docker-compose.yml config > /dev/null
|
||||
@@ -1,41 +0,0 @@
|
||||
|
||||
# Sovereign Efficiency: Local-First & Cost Saving Guide
|
||||
|
||||
This guide outlines the strategy for eliminating waste and optimizing flow within the Timmy Foundation ecosystem.
|
||||
|
||||
## 1. Smart Model Routing (SMR)
|
||||
**Goal:** Use the right tool for the job. Don't use a 14B or 70B model to say "Hello" or "Task complete."
|
||||
|
||||
- **Action:** Enable `smart_model_routing` in `config.yaml`.
|
||||
- **Logic:**
|
||||
- Simple acknowledgments and status updates -> **Gemma 2B / Phi-3 Mini** (Local).
|
||||
- Complex reasoning and coding -> **Hermes 14B / Llama 3 70B** (Local).
|
||||
- Fortress-grade synthesis -> **Claude 3.5 Sonnet / Gemini 1.5 Pro** (Cloud - Emergency Only).
|
||||
|
||||
## 2. Context Compression
|
||||
**Goal:** Keep the KV cache lean. Long sessions shouldn't slow down the "Thought Stream."
|
||||
|
||||
- **Action:** Enable `compression` in `config.yaml`.
|
||||
- **Threshold:** Set to `0.5` to trigger summarization when the context is half full.
|
||||
- **Protect Last N:** Keep the last 20 turns in raw format for immediate coherence.
|
||||
|
||||
## 3. Parallel Symbolic Execution (PSE) Optimization
|
||||
**Goal:** Reduce redundant reasoning cycles in The Nexus.
|
||||
|
||||
- **Action:** The Nexus now uses **Adaptive Reasoning Frequency**. If the world stability is high (>0.9), reasoning cycles are halved.
|
||||
- **Benefit:** Reduces CPU/GPU load on the local harness, leaving more headroom for inference.
|
||||
|
||||
## 4. L402 Cost Transparency
|
||||
**Goal:** Treat compute as a finite resource.
|
||||
|
||||
- **Action:** Use the **Sovereign Health HUD** in The Nexus to monitor L402 challenges.
|
||||
- **Metric:** Track "Sats per Thought" to identify which agents are "token-heavy."
|
||||
|
||||
## 5. Waste Elimination (Ghost Triage)
|
||||
**Goal:** Remove stale state.
|
||||
|
||||
- **Action:** Run the `triage_sprint.ts` script weekly to assign or archive stale issues.
|
||||
- **Action:** Use `hermes --flush-memories` to clear outdated context that no longer serves the current mission.
|
||||
|
||||
---
|
||||
*Sovereignty is not just about ownership; it is about stewardship of resources.*
|
||||
@@ -1,37 +0,0 @@
|
||||
|
||||
# The Frontier Local Agenda: Technical Standards v1.0
|
||||
|
||||
This document defines the "Frontier Local" agenda — the technical strategy for achieving sovereign, high-performance intelligence on consumer hardware.
|
||||
|
||||
## 1. The Multi-Layered Mind (MLM)
|
||||
We do not rely on a single "God Model." We use a hierarchy of local intelligence:
|
||||
|
||||
- **Reflex Layer (Gemma 2B):** Instantaneous tactical decisions, input classification, and simple acknowledgments. Latency: <100ms.
|
||||
- **Reasoning Layer (Hermes 14B / Llama 3 8B):** General-purpose problem solving, coding, and tool use. Latency: <1s.
|
||||
- **Synthesis Layer (Llama 3 70B / Qwen 72B):** Deep architectural planning, creative synthesis, and complex debugging. Latency: <5s.
|
||||
|
||||
## 2. Local-First RAG (Retrieval Augmented Generation)
|
||||
Sovereignty requires that your memories stay on your disk.
|
||||
|
||||
- **Embedding:** Use `nomic-embed-text` or `all-minilm` locally via Ollama.
|
||||
- **Vector Store:** Use a local instance of ChromaDB or LanceDB.
|
||||
- **Privacy:** Zero data leaves the local network for indexing or retrieval.
|
||||
|
||||
## 3. Speculative Decoding
|
||||
Where supported by the harness (e.g., llama.cpp), use Gemma 2B as a draft model for larger Hermes/Llama models to achieve 2x-3x speedups in token generation.
|
||||
|
||||
## 4. The "Gemma Scout" Protocol
|
||||
Gemma 2B is our "Scout." It pre-processes every user request to:
|
||||
1. Detect PII (Personally Identifiable Information) for redaction.
|
||||
2. Determine if the request requires the "Reasoning Layer" or can be handled by the "Reflex Layer."
|
||||
3. Extract keywords for local memory retrieval.
|
||||
|
||||
|
||||
## 5. Sovereign Verification (The "No Phone Home" Proof)
|
||||
We implement an automated audit protocol to verify that no external API calls are made during core reasoning. This is the "Sovereign Audit" layer.
|
||||
|
||||
## 6. Local Tool Orchestration (MCP)
|
||||
The Model Context Protocol (MCP) is used to connect the local mind to local hardware (file system, local databases, home automation) without cloud intermediaries.
|
||||
|
||||
---
|
||||
*Intelligence is a utility. Sovereignty is a right. The Frontier is Local.*
|
||||
18
README.md
18
README.md
@@ -30,8 +30,7 @@ timmy-config/
|
||||
│ ├── automation-inventory.md ← Live automation + stale-state inventory
|
||||
│ ├── ipc-hub-and-spoke-doctrine.md ← Coordinator-first, transport-agnostic fleet IPC doctrine
|
||||
│ ├── coordinator-first-protocol.md ← Coordinator doctrine: intake → triage → route → track → verify → report
|
||||
│ ├── fallback-portfolios.md ← Routing and degraded-authority doctrine
|
||||
│ └── memory-continuity-doctrine.md ← File-backed continuity + pre-compaction flush rule
|
||||
│ └── fallback-portfolios.md ← Routing and degraded-authority doctrine
|
||||
└── training/ ← Transitional training recipes, not canonical lived data
|
||||
```
|
||||
|
||||
@@ -51,24 +50,9 @@ The scripts in `bin/` are sidecar-managed operational helpers for the Hermes lay
|
||||
Do NOT assume older prose about removed loops is still true at runtime.
|
||||
Audit the live machine first, then read `docs/automation-inventory.md` for the
|
||||
current reality and stale-state risks.
|
||||
|
||||
For communication-layer truth, read:
|
||||
- `docs/comms-authority-map.md`
|
||||
- `docs/nostur-operator-edge.md`
|
||||
- `docs/operator-comms-onboarding.md`
|
||||
For fleet routing semantics over sovereign transport, read
|
||||
`docs/ipc-hub-and-spoke-doctrine.md`.
|
||||
|
||||
## Continuity
|
||||
|
||||
Curated memory belongs in `memories/` inside this repo.
|
||||
Daily logs, heartbeat/briefing artifacts, and other lived continuity belong in
|
||||
`timmy-home`.
|
||||
|
||||
Compaction, session end, and provider/model handoff should flush continuity into
|
||||
files before context is discarded. See
|
||||
`docs/memory-continuity-doctrine.md` for the current doctrine.
|
||||
|
||||
## Orchestration: Huey
|
||||
|
||||
All orchestration (triage, PR review, dispatch) runs via [Huey](https://github.com/coleifer/huey) with SQLite.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
|
||||
# Sovereign Audit: The "No Phone Home" Protocol
|
||||
|
||||
This document defines the audit standards for verifying that an AI agent is truly sovereign and local-first.
|
||||
|
||||
## 1. Network Isolation
|
||||
- **Standard:** The core reasoning engine (llama.cpp, Ollama) must function without an active internet connection.
|
||||
- **Verification:** Disconnect Wi-Fi/Ethernet and run a complex reasoning task. If it fails, sovereignty is compromised.
|
||||
|
||||
## 2. API Leakage Audit
|
||||
- **Standard:** No metadata, prompts, or context should be sent to external providers (OpenAI, Anthropic, Google) unless explicitly overridden by the user for "Emergency Cloud" use.
|
||||
- **Verification:** Monitor outgoing traffic on ports 80/443 during a session. Core reasoning should only hit `localhost` or local network IPs.
|
||||
|
||||
## 3. Data Residency
|
||||
- **Standard:** All "Memories" (Vector DB, Chat History, SOUL.md) must reside on the user's physical disk.
|
||||
- **Verification:** Check the `~/.timmy/memories` and `~/.timmy/config` directories. No data should be stored in cloud-managed databases.
|
||||
|
||||
## 4. Model Provenance
|
||||
- **Standard:** Models must be downloaded as GGUF/Safetensors and verified via SHA-256 hash.
|
||||
- **Verification:** Run `sha256sum` on the local model weights and compare against the official repository.
|
||||
|
||||
---
|
||||
*If you don't own the weights, you don't own the mind.*
|
||||
@@ -1,12 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-dispatch.sh — Generate a lane-aware prompt for any agent
|
||||
# agent-dispatch.sh — Generate a self-contained prompt for any agent
|
||||
#
|
||||
# Usage: agent-dispatch.sh <agent_name> <issue_num> <repo>
|
||||
# agent-dispatch.sh groq 42 Timmy_Foundation/the-nexus
|
||||
# agent-dispatch.sh manus 42 Timmy_Foundation/the-nexus
|
||||
#
|
||||
# Outputs a prompt to stdout. Copy-paste into the agent's interface.
|
||||
# The prompt includes issue context, repo setup, lane coaching, and
|
||||
# a short review checklist so dispatch itself teaches the right habits.
|
||||
# The prompt includes everything: API URLs, token, git commands, PR creation.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -14,201 +13,86 @@ AGENT_NAME="${1:?Usage: agent-dispatch.sh <agent> <issue_num> <owner/repo>}"
|
||||
ISSUE_NUM="${2:?Usage: agent-dispatch.sh <agent> <issue_num> <owner/repo>}"
|
||||
REPO="${3:?Usage: agent-dispatch.sh <agent> <issue_num> <owner/repo>}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LANES_FILE="${SCRIPT_DIR%/bin}/playbooks/agent-lanes.json"
|
||||
GITEA_URL="http://143.198.27.163:3000"
|
||||
TOKEN_FILE="$HOME/.hermes/${AGENT_NAME}_token"
|
||||
|
||||
resolve_gitea_url() {
|
||||
if [ -n "${GITEA_URL:-}" ]; then
|
||||
printf '%s\n' "${GITEA_URL%/}"
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$HOME/.hermes/gitea_api" ]; then
|
||||
python3 - "$HOME/.hermes/gitea_api" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
raw = Path(sys.argv[1]).read_text().strip().rstrip("/")
|
||||
print(raw[:-7] if raw.endswith("/api/v1") else raw)
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$HOME/.config/gitea/base-url" ]; then
|
||||
tr -d '[:space:]' < "$HOME/.config/gitea/base-url"
|
||||
return 0
|
||||
fi
|
||||
echo "ERROR: set GITEA_URL or create ~/.hermes/gitea_api" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
GITEA_URL="$(resolve_gitea_url)"
|
||||
|
||||
resolve_token_file() {
|
||||
local agent="$1"
|
||||
local normalized
|
||||
normalized="$(printf '%s' "$agent" | tr '[:upper:]' '[:lower:]')"
|
||||
for candidate in \
|
||||
"$HOME/.hermes/${agent}_token" \
|
||||
"$HOME/.hermes/${normalized}_token" \
|
||||
"$HOME/.config/gitea/${agent}-token" \
|
||||
"$HOME/.config/gitea/${normalized}-token"; do
|
||||
if [ -f "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
for candidate in \
|
||||
"$HOME/.config/gitea/timmy-token" \
|
||||
"$HOME/.hermes/gitea_token_vps" \
|
||||
"$HOME/.hermes/gitea_token_timmy"; do
|
||||
if [ -f "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
TOKEN_FILE="$(resolve_token_file "$AGENT_NAME" || true)"
|
||||
if [ -z "${TOKEN_FILE:-}" ]; then
|
||||
echo "ERROR: No token found for '$AGENT_NAME'." >&2
|
||||
echo "Expected one of ~/.hermes/<agent>_token or ~/.config/gitea/<agent>-token" >&2
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
echo "ERROR: No token found at $TOKEN_FILE" >&2
|
||||
echo "Create a Gitea user and token for '$AGENT_NAME' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GITEA_TOKEN="$(cat "$TOKEN_FILE")"
|
||||
REPO_OWNER="${REPO%%/*}"
|
||||
REPO_NAME="${REPO##*/}"
|
||||
GITEA_TOKEN=$(cat "$TOKEN_FILE")
|
||||
REPO_OWNER=$(echo "$REPO" | cut -d/ -f1)
|
||||
REPO_NAME=$(echo "$REPO" | cut -d/ -f2)
|
||||
BRANCH="${AGENT_NAME}/issue-${ISSUE_NUM}"
|
||||
|
||||
python3 - "$LANES_FILE" "$AGENT_NAME" "$ISSUE_NUM" "$REPO" "$REPO_OWNER" "$REPO_NAME" "$BRANCH" "$GITEA_URL" "$GITEA_TOKEN" "$TOKEN_FILE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import textwrap
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
# Fetch issue title
|
||||
ISSUE_TITLE=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUM}" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.loads(sys.stdin.read())['title'])" 2>/dev/null || echo "Issue #${ISSUE_NUM}")
|
||||
|
||||
lanes_path, agent, issue_num, repo, repo_owner, repo_name, branch, gitea_url, token, token_file = sys.argv[1:]
|
||||
cat <<PROMPT
|
||||
You are ${AGENT_NAME}, an autonomous code agent working on the ${REPO_NAME} project.
|
||||
|
||||
with open(lanes_path) as f:
|
||||
lanes = json.load(f)
|
||||
YOUR ISSUE: #${ISSUE_NUM} — "${ISSUE_TITLE}"
|
||||
|
||||
lane = lanes.get(agent, {
|
||||
"lane": "bounded work with explicit verification and a clean PR handoff",
|
||||
"skills_to_practice": ["verification", "scope control", "clear handoff writing"],
|
||||
"missing_skills": ["escalate instead of guessing when the scope becomes unclear"],
|
||||
"anti_lane": ["self-directed backlog growth", "unbounded architectural wandering"],
|
||||
"review_checklist": [
|
||||
"Did I stay within scope?",
|
||||
"Did I verify the result?",
|
||||
"Did I leave a clean PR and issue handoff?"
|
||||
],
|
||||
})
|
||||
GITEA API: ${GITEA_URL}/api/v1
|
||||
GITEA TOKEN: ${GITEA_TOKEN}
|
||||
REPO: ${REPO_OWNER}/${REPO_NAME}
|
||||
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
== STEP 1: READ THE ISSUE ==
|
||||
|
||||
def fetch_json(path):
|
||||
req = urllib.request.Request(f"{gitea_url}/api/v1{path}", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
curl -s -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${ISSUE_NUM}"
|
||||
curl -s -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${ISSUE_NUM}/comments"
|
||||
|
||||
try:
|
||||
issue = fetch_json(f"/repos/{repo}/issues/{issue_num}")
|
||||
comments = fetch_json(f"/repos/{repo}/issues/{issue_num}/comments")
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise SystemExit(f"Failed to fetch issue context: {exc}") from exc
|
||||
Read the issue body AND all comments for context and build order constraints.
|
||||
|
||||
body = (issue.get("body") or "").strip()
|
||||
body = body[:4000] + ("\n...[truncated]" if len(body) > 4000 else "")
|
||||
recent_comments = comments[-3:]
|
||||
comment_block = []
|
||||
for c in recent_comments:
|
||||
author = c.get("user", {}).get("login", "unknown")
|
||||
text = (c.get("body") or "").strip().replace("\r", "")
|
||||
text = text[:600] + ("\n...[truncated]" if len(text) > 600 else "")
|
||||
comment_block.append(f"- {author}: {text}")
|
||||
== STEP 2: SET UP WORKSPACE ==
|
||||
|
||||
comment_text = "\n".join(comment_block) if comment_block else "- (no comments yet)"
|
||||
git clone http://${AGENT_NAME}:${GITEA_TOKEN}@143.198.27.163:3000/${REPO_OWNER}/${REPO_NAME}.git /tmp/${AGENT_NAME}-work-${ISSUE_NUM}
|
||||
cd /tmp/${AGENT_NAME}-work-${ISSUE_NUM}
|
||||
|
||||
skills = "\n".join(f"- {item}" for item in lane["skills_to_practice"])
|
||||
gaps = "\n".join(f"- {item}" for item in lane["missing_skills"])
|
||||
anti_lane = "\n".join(f"- {item}" for item in lane["anti_lane"])
|
||||
review = "\n".join(f"- {item}" for item in lane["review_checklist"])
|
||||
Check if branch exists (prior attempt): git ls-remote origin ${BRANCH}
|
||||
If yes: git fetch origin ${BRANCH} && git checkout ${BRANCH}
|
||||
If no: git checkout -b ${BRANCH}
|
||||
|
||||
prompt = f"""You are {agent}, working on {repo_name} for Timmy Foundation.
|
||||
== STEP 3: UNDERSTAND THE PROJECT ==
|
||||
|
||||
YOUR ISSUE: #{issue_num} — "{issue.get('title', f'Issue #{issue_num}')}"
|
||||
Read README.md or any contributing guide. Check for tox.ini, Makefile, package.json.
|
||||
Follow existing code conventions.
|
||||
|
||||
REPO: {repo}
|
||||
GITEA API: {gitea_url}/api/v1
|
||||
GITEA TOKEN FILE: {token_file}
|
||||
WORK BRANCH: {branch}
|
||||
== STEP 4: DO THE WORK ==
|
||||
|
||||
LANE:
|
||||
{lane['lane']}
|
||||
Implement the fix/feature described in the issue. Run tests if the project has them.
|
||||
|
||||
SKILLS TO PRACTICE ON THIS ASSIGNMENT:
|
||||
{skills}
|
||||
== STEP 5: COMMIT AND PUSH ==
|
||||
|
||||
COMMON FAILURE MODE TO AVOID:
|
||||
{gaps}
|
||||
git add -A
|
||||
git commit -m "feat: <description> (#${ISSUE_NUM})
|
||||
|
||||
ANTI-LANE:
|
||||
{anti_lane}
|
||||
Fixes #${ISSUE_NUM}"
|
||||
git push origin ${BRANCH}
|
||||
|
||||
ISSUE BODY:
|
||||
{body or "(empty issue body)"}
|
||||
== STEP 6: CREATE PR ==
|
||||
|
||||
RECENT COMMENTS:
|
||||
{comment_text}
|
||||
|
||||
WORKFLOW:
|
||||
1. Read the issue body and recent comments carefully before touching code.
|
||||
2. Clone the repo into /tmp/{agent}-work-{issue_num}.
|
||||
3. Check whether {branch} already exists on origin; reuse it if it does.
|
||||
4. Read the repo docs and follow its own tooling and conventions.
|
||||
5. Do only the scoped work from the issue. If the task grows, stop and comment instead of freelancing expansion.
|
||||
6. Run the repo's real verification commands.
|
||||
7. Open a PR and summarize:
|
||||
- what changed
|
||||
- how you verified it
|
||||
- any remaining risk or follow-up
|
||||
8. Comment on the issue with the PR link and the same concise summary.
|
||||
|
||||
GIT / API SETUP:
|
||||
export GITEA_URL="{gitea_url}"
|
||||
export GITEA_TOKEN_FILE="{token_file}"
|
||||
export GITEA_TOKEN="$(tr -d '[:space:]' < "$GITEA_TOKEN_FILE")"
|
||||
git config --global http."$GITEA_URL/".extraHeader "Authorization: token $GITEA_TOKEN"
|
||||
git clone "$GITEA_URL/{repo}.git" /tmp/{agent}-work-{issue_num}
|
||||
cd /tmp/{agent}-work-{issue_num}
|
||||
git ls-remote --exit-code origin {branch} >/dev/null 2>&1 && git fetch origin {branch} && git checkout {branch} || git checkout -b {branch}
|
||||
|
||||
ISSUE FETCH COMMANDS:
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" "{gitea_url}/api/v1/repos/{repo}/issues/{issue_num}"
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" "{gitea_url}/api/v1/repos/{repo}/issues/{issue_num}/comments"
|
||||
|
||||
PR CREATION TEMPLATE:
|
||||
curl -s -X POST "{gitea_url}/api/v1/repos/{repo}/pulls" \\
|
||||
-H "Authorization: token $GITEA_TOKEN" \\
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls" \\
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{{"title":"[{agent}] <description> (#{issue_num})","body":"Fixes #{issue_num}\\n\\n## Summary\\n- <change>\\n\\n## Verification\\n- <command/output>\\n\\n## Risks\\n- <if any>","head":"{branch}","base":"main"}}'
|
||||
-d '{"title": "[${AGENT_NAME}] <description> (#${ISSUE_NUM})", "body": "Fixes #${ISSUE_NUM}\n\n<describe changes>", "head": "${BRANCH}", "base": "main"}'
|
||||
|
||||
ISSUE COMMENT TEMPLATE:
|
||||
curl -s -X POST "{gitea_url}/api/v1/repos/{repo}/issues/{issue_num}/comments" \\
|
||||
-H "Authorization: token $GITEA_TOKEN" \\
|
||||
== STEP 7: COMMENT ON ISSUE ==
|
||||
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${ISSUE_NUM}/comments" \\
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{{"body":"PR submitted.\\n\\nSummary:\\n- <change>\\n\\nVerification:\\n- <command/output>\\n\\nRisks:\\n- <if any>"}}'
|
||||
-d '{"body": "PR submitted. <summary>"}'
|
||||
|
||||
REVIEW CHECKLIST BEFORE YOU PUSH:
|
||||
{review}
|
||||
|
||||
RULES:
|
||||
- Do not skip hooks with --no-verify.
|
||||
- Do not silently widen the scope.
|
||||
- If verification fails twice or the issue is underspecified, stop and comment with what blocked you.
|
||||
- Always create a PR instead of pushing to main.
|
||||
- Clean up /tmp/{agent}-work-{issue_num} when done.
|
||||
"""
|
||||
|
||||
print(textwrap.dedent(prompt).strip())
|
||||
PY
|
||||
== RULES ==
|
||||
- Read project docs FIRST.
|
||||
- Use the project's own test/lint tools.
|
||||
- Respect git hooks. Do not skip them.
|
||||
- If tests fail twice, STOP and comment on the issue.
|
||||
- ALWAYS push your work. ALWAYS create a PR. No exceptions.
|
||||
- Clean up: remove /tmp/${AGENT_NAME}-work-${ISSUE_NUM} when done.
|
||||
PROMPT
|
||||
|
||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
||||
NUM_WORKERS="${1:-2}"
|
||||
MAX_WORKERS=10 # absolute ceiling
|
||||
WORKTREE_BASE="$HOME/worktrees"
|
||||
GITEA_URL="${GITEA_URL:-https://forge.alexanderwhitestone.com}"
|
||||
GITEA_URL="http://143.198.27.163:3000"
|
||||
GITEA_TOKEN=$(cat "$HOME/.hermes/claude_token")
|
||||
CLAUDE_TIMEOUT=900 # 15 min per issue
|
||||
COOLDOWN=15 # seconds between issues — stagger clones
|
||||
|
||||
@@ -7,26 +7,13 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GEMINI_KEY_FILE="${GEMINI_KEY_FILE:-$HOME/.timmy/gemini_free_tier_key}"
|
||||
if [ -f "$GEMINI_KEY_FILE" ]; then
|
||||
export GEMINI_API_KEY="$(python3 - "$GEMINI_KEY_FILE" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
text = Path(sys.argv[1]).read_text(errors='ignore').splitlines()
|
||||
for line in text:
|
||||
line=line.strip()
|
||||
if line:
|
||||
print(line)
|
||||
break
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
export GEMINI_API_KEY="AIzaSyAmGgS516K4PwlODFEnghL535yzoLnofKM"
|
||||
|
||||
# === CONFIG ===
|
||||
NUM_WORKERS="${1:-2}"
|
||||
MAX_WORKERS=5
|
||||
WORKTREE_BASE="$HOME/worktrees"
|
||||
GITEA_URL="${GITEA_URL:-https://forge.alexanderwhitestone.com}"
|
||||
GITEA_URL="http://143.198.27.163:3000"
|
||||
GITEA_TOKEN=$(cat "$HOME/.hermes/gemini_token")
|
||||
GEMINI_TIMEOUT=600 # 10 min per issue
|
||||
COOLDOWN=15 # seconds between issues — stagger clones
|
||||
@@ -37,7 +24,6 @@ SKIP_FILE="$LOG_DIR/gemini-skip-list.json"
|
||||
LOCK_DIR="$LOG_DIR/gemini-locks"
|
||||
ACTIVE_FILE="$LOG_DIR/gemini-active.json"
|
||||
ALLOW_SELF_ASSIGN="${ALLOW_SELF_ASSIGN:-0}" # 0 = only explicitly-assigned Gemini work
|
||||
AUTH_INVALID_SLEEP=900
|
||||
|
||||
mkdir -p "$LOG_DIR" "$WORKTREE_BASE" "$LOCK_DIR"
|
||||
[ -f "$SKIP_FILE" ] || echo '{}' > "$SKIP_FILE"
|
||||
@@ -48,124 +34,6 @@ log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_DIR/gemini-loop.log"
|
||||
}
|
||||
|
||||
post_issue_comment() {
|
||||
local repo_owner="$1" repo_name="$2" issue_num="$3" body="$4"
|
||||
local payload
|
||||
payload=$(python3 - "$body" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps({"body": sys.argv[1]}))
|
||||
PY
|
||||
)
|
||||
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}/comments" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "$payload" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
remote_branch_exists() {
|
||||
local branch="$1"
|
||||
git ls-remote --heads origin "$branch" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
get_pr_num() {
|
||||
local repo_owner="$1" repo_name="$2" branch="$3"
|
||||
curl -sf "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls?state=all&head=${repo_owner}:${branch}&limit=1" -H "Authorization: token ${GITEA_TOKEN}" | python3 -c "
|
||||
import sys,json
|
||||
prs = json.load(sys.stdin)
|
||||
if prs: print(prs[0]['number'])
|
||||
else: print('')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
get_pr_file_count() {
|
||||
local repo_owner="$1" repo_name="$2" pr_num="$3"
|
||||
curl -sf "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls/${pr_num}/files" -H "Authorization: token ${GITEA_TOKEN}" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
print(len(files) if isinstance(files, list) else 0)
|
||||
except:
|
||||
print(0)
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
get_pr_state() {
|
||||
local repo_owner="$1" repo_name="$2" pr_num="$3"
|
||||
curl -sf "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls/${pr_num}" -H "Authorization: token ${GITEA_TOKEN}" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
pr = json.load(sys.stdin)
|
||||
if pr.get('merged'):
|
||||
print('merged')
|
||||
else:
|
||||
print(pr.get('state', 'unknown'))
|
||||
except:
|
||||
print('unknown')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
get_issue_state() {
|
||||
local repo_owner="$1" repo_name="$2" issue_num="$3"
|
||||
curl -sf "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}" -H "Authorization: token ${GITEA_TOKEN}" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
issue = json.load(sys.stdin)
|
||||
print(issue.get('state', 'unknown'))
|
||||
except:
|
||||
print('unknown')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
proof_comment_status() {
|
||||
local repo_owner="$1" repo_name="$2" issue_num="$3" branch="$4"
|
||||
curl -sf "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}/comments" -H "Authorization: token ${GITEA_TOKEN}" | BRANCH="$branch" python3 -c "
|
||||
import os, sys, json
|
||||
branch = os.environ.get('BRANCH', '').lower()
|
||||
try:
|
||||
comments = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print('missing|')
|
||||
raise SystemExit(0)
|
||||
for c in reversed(comments):
|
||||
user = ((c.get('user') or {}).get('login') or '').lower()
|
||||
body = c.get('body') or ''
|
||||
body_l = body.lower()
|
||||
if user != 'gemini':
|
||||
continue
|
||||
if 'proof:' not in body_l and 'verification:' not in body_l:
|
||||
continue
|
||||
has_branch = branch in body_l
|
||||
has_pr = ('pr:' in body_l) or ('pull request:' in body_l) or ('/pulls/' in body_l)
|
||||
has_push = ('push:' in body_l) or ('pushed' in body_l)
|
||||
has_verify = ('tox' in body_l) or ('pytest' in body_l) or ('verification:' in body_l) or ('npm test' in body_l)
|
||||
status = 'ok' if (has_branch and has_pr and has_push and has_verify) else 'incomplete'
|
||||
print(status + '|' + (c.get('html_url') or ''))
|
||||
raise SystemExit(0)
|
||||
print('missing|')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
gemini_auth_invalid() {
|
||||
local issue_num="$1"
|
||||
grep -q "API_KEY_INVALID\|API key expired" "$LOG_DIR/gemini-${issue_num}.log" 2>/dev/null
|
||||
}
|
||||
|
||||
issue_is_code_fit() {
|
||||
local title="$1"
|
||||
local labels="$2"
|
||||
local body="$3"
|
||||
local haystack
|
||||
haystack="${title} ${labels} ${body}"
|
||||
local low="${haystack,,}"
|
||||
|
||||
if [[ "$low" == *"[morning report]"* ]]; then return 1; fi
|
||||
if [[ "$low" == *"[kt]"* ]]; then return 1; fi
|
||||
if [[ "$low" == *"policy:"* ]]; then return 1; fi
|
||||
if [[ "$low" == *"incident:"* || "$low" == *"🚨 incident"* || "$low" == *"[incident]"* ]]; then return 1; fi
|
||||
if [[ "$low" == *"fleet lexicon"* || "$low" == *"shared vocabulary"* || "$low" == *"rubric"* ]]; then return 1; fi
|
||||
if [[ "$low" == *"archive ghost"* || "$low" == *"reassign"* || "$low" == *"offload"* || "$low" == *"burn directive"* ]]; then return 1; fi
|
||||
if [[ "$low" == *"review all open prs"* ]]; then return 1; fi
|
||||
if [[ "$low" == *"epic"* ]]; then return 1; fi
|
||||
return 0
|
||||
}
|
||||
|
||||
lock_issue() {
|
||||
local issue_key="$1"
|
||||
local lockfile="$LOCK_DIR/$issue_key.lock"
|
||||
@@ -222,7 +90,6 @@ with open('$ACTIVE_FILE', 'r+') as f:
|
||||
|
||||
cleanup_workdir() {
|
||||
local wt="$1"
|
||||
cd "$HOME" 2>/dev/null || true
|
||||
rm -rf "$wt" 2>/dev/null || true
|
||||
}
|
||||
|
||||
@@ -287,11 +154,8 @@ for i in all_issues:
|
||||
continue
|
||||
|
||||
title = i['title'].lower()
|
||||
labels = [l['name'].lower() for l in (i.get('labels') or [])]
|
||||
body = (i.get('body') or '').lower()
|
||||
if '[philosophy]' in title: continue
|
||||
if '[epic]' in title or 'epic:' in title: continue
|
||||
if 'epic' in labels: continue
|
||||
if '[showcase]' in title: continue
|
||||
if '[do not close' in title: continue
|
||||
if '[meta]' in title: continue
|
||||
@@ -300,11 +164,6 @@ for i in all_issues:
|
||||
if '[morning report]' in title: continue
|
||||
if '[retro]' in title: continue
|
||||
if '[intel]' in title: continue
|
||||
if '[kt]' in title: continue
|
||||
if 'policy:' in title: continue
|
||||
if 'incident' in title: continue
|
||||
if 'lexicon' in title or 'shared vocabulary' in title or 'rubric' in title: continue
|
||||
if 'archive ghost' in title or 'reassign' in title or 'offload' in title: continue
|
||||
if 'master escalation' in title: continue
|
||||
if any(a['login'] == 'Rockachopa' for a in (i.get('assignees') or [])): continue
|
||||
|
||||
@@ -391,11 +250,10 @@ You can do ANYTHING a developer can do.
|
||||
- If tests fail after 2 attempts, STOP and comment on the issue explaining why.
|
||||
- Be thorough but focused. Fix the issue, don't refactor the world.
|
||||
|
||||
== CRITICAL: FINISH = PUSHED + PR'D + PROVED ==
|
||||
== CRITICAL: ALWAYS COMMIT AND PUSH ==
|
||||
- NEVER exit without committing your work. Even partial progress MUST be committed.
|
||||
- Before you finish, ALWAYS: git add -A && git commit && git push origin gemini/issue-${issue_num}
|
||||
- ALWAYS create a PR before exiting. No exceptions.
|
||||
- ALWAYS post the Proof block before exiting. No proof comment = not done.
|
||||
- If a branch already exists with prior work, check it out and CONTINUE from where it left off.
|
||||
- Check: git ls-remote origin gemini/issue-${issue_num} — if it exists, pull it first.
|
||||
- Your work is WASTED if it's not pushed. Push early, push often.
|
||||
@@ -506,10 +364,19 @@ Work in progress, may need continuation." 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Create PR if needed ──
|
||||
pr_num=$(get_pr_num "$repo_owner" "$repo_name" "$branch")
|
||||
pr_num=$(curl -sf "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls?state=open&head=${repo_owner}:${branch}&limit=1" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" | python3 -c "
|
||||
import sys,json
|
||||
prs = json.load(sys.stdin)
|
||||
if prs: print(prs[0]['number'])
|
||||
else: print('')
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$pr_num" ] && [ "${UNPUSHED:-0}" -gt 0 ]; then
|
||||
pr_num=$(curl -sf -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "$(python3 -c "
|
||||
pr_num=$(curl -sf -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({
|
||||
'title': 'Gemini: Issue #${issue_num}',
|
||||
@@ -521,72 +388,26 @@ print(json.dumps({
|
||||
[ -n "$pr_num" ] && log "WORKER-${worker_id}: Created PR #${pr_num} for issue #${issue_num}"
|
||||
fi
|
||||
|
||||
# ── Verify finish semantics / classify failures ──
|
||||
# ── Merge + close on success ──
|
||||
if [ "$exit_code" -eq 0 ]; then
|
||||
log "WORKER-${worker_id}: SUCCESS #${issue_num} exited 0 — verifying push + PR + proof"
|
||||
if ! remote_branch_exists "$branch"; then
|
||||
log "WORKER-${worker_id}: BLOCKED #${issue_num} remote branch missing"
|
||||
post_issue_comment "$repo_owner" "$repo_name" "$issue_num" "Loop gate blocked completion: remote branch ${branch} was not found on origin after Gemini exited. Issue remains open for retry."
|
||||
mark_skip "$issue_num" "missing_remote_branch" 1
|
||||
consecutive_failures=$((consecutive_failures + 1))
|
||||
elif [ -z "$pr_num" ]; then
|
||||
log "WORKER-${worker_id}: BLOCKED #${issue_num} no PR found"
|
||||
post_issue_comment "$repo_owner" "$repo_name" "$issue_num" "Loop gate blocked completion: branch ${branch} exists remotely, but no PR was found. Issue remains open for retry."
|
||||
mark_skip "$issue_num" "missing_pr" 1
|
||||
consecutive_failures=$((consecutive_failures + 1))
|
||||
else
|
||||
pr_files=$(get_pr_file_count "$repo_owner" "$repo_name" "$pr_num")
|
||||
if [ "${pr_files:-0}" -eq 0 ]; then
|
||||
log "WORKER-${worker_id}: BLOCKED #${issue_num} PR #${pr_num} has 0 changed files"
|
||||
curl -sf -X PATCH "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls/${pr_num}" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d '{"state": "closed"}' >/dev/null 2>&1 || true
|
||||
post_issue_comment "$repo_owner" "$repo_name" "$issue_num" "PR #${pr_num} was closed automatically: it had 0 changed files (empty commit). Issue remains open for retry."
|
||||
mark_skip "$issue_num" "empty_commit" 2
|
||||
consecutive_failures=$((consecutive_failures + 1))
|
||||
else
|
||||
proof_status=$(proof_comment_status "$repo_owner" "$repo_name" "$issue_num" "$branch")
|
||||
proof_state="${proof_status%%|*}"
|
||||
proof_url="${proof_status#*|}"
|
||||
if [ "$proof_state" != "ok" ]; then
|
||||
log "WORKER-${worker_id}: BLOCKED #${issue_num} proof missing or incomplete (${proof_state})"
|
||||
post_issue_comment "$repo_owner" "$repo_name" "$issue_num" "Loop gate blocked completion: PR #${pr_num} exists and has ${pr_files} changed file(s), but the required Proof block from Gemini is missing or incomplete. Issue remains open for retry."
|
||||
mark_skip "$issue_num" "missing_proof" 1
|
||||
consecutive_failures=$((consecutive_failures + 1))
|
||||
else
|
||||
log "WORKER-${worker_id}: PROOF verified ${proof_url}"
|
||||
pr_state=$(get_pr_state "$repo_owner" "$repo_name" "$pr_num")
|
||||
if [ "$pr_state" = "open" ]; then
|
||||
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls/${pr_num}/merge" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d '{"Do": "squash"}' >/dev/null 2>&1 || true
|
||||
pr_state=$(get_pr_state "$repo_owner" "$repo_name" "$pr_num")
|
||||
fi
|
||||
if [ "$pr_state" = "merged" ]; then
|
||||
curl -sf -X PATCH "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d '{"state": "closed"}' >/dev/null 2>&1 || true
|
||||
issue_state=$(get_issue_state "$repo_owner" "$repo_name" "$issue_num")
|
||||
if [ "$issue_state" = "closed" ]; then
|
||||
log "WORKER-${worker_id}: VERIFIED #${issue_num} branch pushed, PR merged, proof present, issue closed"
|
||||
consecutive_failures=0
|
||||
else
|
||||
log "WORKER-${worker_id}: BLOCKED #${issue_num} issue did not close after merge"
|
||||
mark_skip "$issue_num" "issue_close_unverified" 1
|
||||
consecutive_failures=$((consecutive_failures + 1))
|
||||
fi
|
||||
else
|
||||
log "WORKER-${worker_id}: BLOCKED #${issue_num} merge not verified (state=${pr_state})"
|
||||
mark_skip "$issue_num" "merge_unverified" 1
|
||||
consecutive_failures=$((consecutive_failures + 1))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
log "WORKER-${worker_id}: SUCCESS #${issue_num}"
|
||||
if [ -n "$pr_num" ]; then
|
||||
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls/${pr_num}/merge" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Do": "squash"}' >/dev/null 2>&1 || true
|
||||
curl -sf -X PATCH "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state": "closed"}' >/dev/null 2>&1 || true
|
||||
log "WORKER-${worker_id}: PR #${pr_num} merged, issue #${issue_num} closed"
|
||||
fi
|
||||
consecutive_failures=0
|
||||
elif [ "$exit_code" -eq 124 ]; then
|
||||
log "WORKER-${worker_id}: TIMEOUT #${issue_num} (work saved in PR)"
|
||||
consecutive_failures=$((consecutive_failures + 1))
|
||||
else
|
||||
if gemini_auth_invalid "$issue_num"; then
|
||||
log "WORKER-${worker_id}: AUTH INVALID on #${issue_num} — sleeping ${AUTH_INVALID_SLEEP}s"
|
||||
mark_skip "$issue_num" "gemini_auth_invalid" 1
|
||||
sleep "$AUTH_INVALID_SLEEP"
|
||||
consecutive_failures=$((consecutive_failures + 5))
|
||||
elif grep -q "rate_limit\|rate limit\|429\|overloaded\|quota" "$LOG_DIR/gemini-${issue_num}.log" 2>/dev/null; then
|
||||
if grep -q "rate_limit\|rate limit\|429\|overloaded\|quota" "$LOG_DIR/gemini-${issue_num}.log" 2>/dev/null; then
|
||||
log "WORKER-${worker_id}: RATE LIMITED on #${issue_num} (work saved)"
|
||||
consecutive_failures=$((consecutive_failures + 3))
|
||||
else
|
||||
@@ -623,7 +444,7 @@ print(json.dumps({
|
||||
'pr': '${pr_num:-}',
|
||||
'merged': $( [ '$OUTCOME' = 'success' ] && [ -n '${pr_num:-}' ] && echo 'true' || echo 'false' )
|
||||
}))
|
||||
" >> "$LOG_DIR/gemini-metrics.jsonl" 2>/dev/null
|
||||
" >> "$LOG_DIR/claude-metrics.jsonl" 2>/dev/null
|
||||
|
||||
cleanup_workdir "$worktree"
|
||||
unlock_issue "$issue_key"
|
||||
|
||||
199
bin/ops-gitea.sh
199
bin/ops-gitea.sh
@@ -1,155 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── Gitea Workflow Feed ────────────────────────────────────────────────
|
||||
# Shows open PRs, review pressure, and issue queues across core repos.
|
||||
# ── Gitea Feed Panel ───────────────────────────────────────────────────
|
||||
# Shows open PRs, recent merges, and issue queue. Called by watch.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
B='\033[1m' ; D='\033[2m' ; R='\033[0m'
|
||||
G='\033[32m' ; Y='\033[33m' ; RD='\033[31m' ; C='\033[36m' ; M='\033[35m'
|
||||
|
||||
B='\033[1m'
|
||||
D='\033[2m'
|
||||
R='\033[0m'
|
||||
C='\033[36m'
|
||||
G='\033[32m'
|
||||
Y='\033[33m'
|
||||
TOKEN=$(cat ~/.hermes/gitea_token_vps 2>/dev/null)
|
||||
API="http://143.198.27.163:3000/api/v1/repos/rockachopa/Timmy-time-dashboard"
|
||||
|
||||
resolve_gitea_url() {
|
||||
if [ -n "${GITEA_URL:-}" ]; then
|
||||
printf '%s\n' "${GITEA_URL%/}"
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$HOME/.hermes/gitea_api" ]; then
|
||||
python3 - "$HOME/.hermes/gitea_api" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
raw = Path(sys.argv[1]).read_text().strip().rstrip("/")
|
||||
print(raw[:-7] if raw.endswith("/api/v1") else raw)
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$HOME/.config/gitea/base-url" ]; then
|
||||
tr -d '[:space:]' < "$HOME/.config/gitea/base-url"
|
||||
return 0
|
||||
fi
|
||||
echo "ERROR: set GITEA_URL or create ~/.hermes/gitea_api" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_ops_token() {
|
||||
local token_file
|
||||
for token_file in \
|
||||
"$HOME/.config/gitea/timmy-token" \
|
||||
"$HOME/.hermes/gitea_token_vps" \
|
||||
"$HOME/.hermes/gitea_token_timmy"; do
|
||||
if [ -f "$token_file" ]; then
|
||||
tr -d '[:space:]' < "$token_file"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
GITEA_URL="$(resolve_gitea_url)"
|
||||
CORE_REPOS="${CORE_REPOS:-Timmy_Foundation/the-nexus Timmy_Foundation/timmy-home Timmy_Foundation/timmy-config Timmy_Foundation/hermes-agent}"
|
||||
TOKEN="$(resolve_ops_token || true)"
|
||||
[ -z "$TOKEN" ] && echo "WARN: no approved Timmy Gitea token found; feed will use unauthenticated API calls" >&2
|
||||
|
||||
echo -e "${B}${C} ◈ GITEA WORKFLOW${R} ${D}$(date '+%H:%M:%S')${R}"
|
||||
echo -e "${B}${C} ◈ GITEA${R} ${D}$(date '+%H:%M:%S')${R}"
|
||||
echo -e "${D}────────────────────────────────────────${R}"
|
||||
|
||||
python3 - "$GITEA_URL" "$TOKEN" "$CORE_REPOS" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
# Open PRs
|
||||
echo -e " ${B}Open PRs${R}"
|
||||
curl -s --max-time 5 -H "Authorization: token $TOKEN" "$API/pulls?state=open&limit=10" 2>/dev/null | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
prs = json.loads(sys.stdin.read())
|
||||
if not prs: print(' (none)')
|
||||
for p in prs:
|
||||
age_h = ''
|
||||
print(f' #{p[\"number\"]:3d} {p[\"user\"][\"login\"]:8s} {p[\"title\"][:45]}')
|
||||
except: print(' (error)')
|
||||
" 2>/dev/null
|
||||
|
||||
base = sys.argv[1].rstrip("/")
|
||||
token = sys.argv[2]
|
||||
repos = sys.argv[3].split()
|
||||
headers = {"Authorization": f"token {token}"} if token else {}
|
||||
echo -e "${D}────────────────────────────────────────${R}"
|
||||
|
||||
# Recent merged (last 5)
|
||||
echo -e " ${B}Recently Merged${R}"
|
||||
curl -s --max-time 5 -H "Authorization: token $TOKEN" "$API/pulls?state=closed&sort=updated&limit=5" 2>/dev/null | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
prs = json.loads(sys.stdin.read())
|
||||
merged = [p for p in prs if p.get('merged')]
|
||||
if not merged: print(' (none)')
|
||||
for p in merged[:5]:
|
||||
t = p['merged_at'][:16].replace('T',' ')
|
||||
print(f' ${G}✓${R} #{p[\"number\"]:3d} {p[\"title\"][:35]} ${D}{t}${R}')
|
||||
except: print(' (error)')
|
||||
" 2>/dev/null
|
||||
|
||||
def fetch(path):
|
||||
req = urllib.request.Request(f"{base}{path}", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
echo -e "${D}────────────────────────────────────────${R}"
|
||||
|
||||
# Issue queue (assigned to kimi)
|
||||
echo -e " ${B}Kimi Queue${R}"
|
||||
curl -s --max-time 5 -H "Authorization: token $TOKEN" "$API/issues?state=open&limit=50&type=issues" 2>/dev/null | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
all_issues = json.loads(sys.stdin.read())
|
||||
issues = [i for i in all_issues if 'kimi' in [a.get('login','') for a in (i.get('assignees') or [])]]
|
||||
if not issues: print(' (empty — assign more!)')
|
||||
for i in issues[:8]:
|
||||
print(f' #{i[\"number\"]:3d} {i[\"title\"][:50]}')
|
||||
if len(issues) > 8: print(f' ... +{len(issues)-8} more')
|
||||
except: print(' (error)')
|
||||
" 2>/dev/null
|
||||
|
||||
def short_repo(repo):
|
||||
return repo.split("/", 1)[1]
|
||||
echo -e "${D}────────────────────────────────────────${R}"
|
||||
|
||||
|
||||
issues = []
|
||||
pulls = []
|
||||
errors = []
|
||||
|
||||
for repo in repos:
|
||||
try:
|
||||
repo_pulls = fetch(f"/api/v1/repos/{repo}/pulls?state=open&limit=20")
|
||||
for pr in repo_pulls:
|
||||
pr["_repo"] = repo
|
||||
pulls.append(pr)
|
||||
repo_issues = fetch(f"/api/v1/repos/{repo}/issues?state=open&limit=50&type=issues")
|
||||
for issue in repo_issues:
|
||||
issue["_repo"] = repo
|
||||
issues.append(issue)
|
||||
except urllib.error.URLError as exc:
|
||||
errors.append(f"{repo}: {exc.reason}")
|
||||
except Exception as exc: # pragma: no cover - defensive panel path
|
||||
errors.append(f"{repo}: {exc}")
|
||||
|
||||
print(" \033[1mOpen PRs\033[0m")
|
||||
if not pulls:
|
||||
print(" (none)")
|
||||
else:
|
||||
for pr in pulls[:8]:
|
||||
print(
|
||||
f" #{pr['number']:3d} {short_repo(pr['_repo']):12s} "
|
||||
f"{pr['user']['login'][:12]:12s} {pr['title'][:40]}"
|
||||
)
|
||||
|
||||
print("\033[2m────────────────────────────────────────\033[0m")
|
||||
print(" \033[1mNeeds Timmy / Allegro Review\033[0m")
|
||||
reviewers = []
|
||||
for repo in repos:
|
||||
try:
|
||||
repo_items = fetch(f"/api/v1/repos/{repo}/issues?state=open&limit=50&type=pulls")
|
||||
for item in repo_items:
|
||||
assignees = [a.get("login", "") for a in (item.get("assignees") or [])]
|
||||
if any(name in assignees for name in ("Timmy", "allegro")):
|
||||
item["_repo"] = repo
|
||||
reviewers.append(item)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not reviewers:
|
||||
print(" (clear)")
|
||||
else:
|
||||
for item in reviewers[:8]:
|
||||
names = ",".join(a.get("login", "") for a in (item.get("assignees") or []))
|
||||
print(
|
||||
f" #{item['number']:3d} {short_repo(item['_repo']):12s} "
|
||||
f"{names[:18]:18s} {item['title'][:34]}"
|
||||
)
|
||||
|
||||
print("\033[2m────────────────────────────────────────\033[0m")
|
||||
print(" \033[1mIssue Queues\033[0m")
|
||||
queue_agents = ["allegro", "codex-agent", "groq", "claude", "ezra", "perplexity", "KimiClaw"]
|
||||
for agent in queue_agents:
|
||||
assigned = [
|
||||
issue
|
||||
for issue in issues
|
||||
if agent in [a.get("login", "") for a in (issue.get("assignees") or [])]
|
||||
]
|
||||
print(f" {agent:12s} {len(assigned):2d}")
|
||||
|
||||
unassigned = [issue for issue in issues if not issue.get("assignees")]
|
||||
print("\033[2m────────────────────────────────────────\033[0m")
|
||||
print(f" Unassigned issues: \033[33m{len(unassigned)}\033[0m")
|
||||
|
||||
if errors:
|
||||
print("\033[2m────────────────────────────────────────\033[0m")
|
||||
print(" \033[1mErrors\033[0m")
|
||||
for err in errors[:4]:
|
||||
print(f" {err}")
|
||||
PY
|
||||
# Unassigned issues
|
||||
UNASSIGNED=$(curl -s --max-time 5 -H "Authorization: token $TOKEN" "$API/issues?state=open&limit=50&type=issues" 2>/dev/null | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
issues = json.loads(sys.stdin.read())
|
||||
print(len([i for i in issues if not i.get('assignees')]))
|
||||
except: print('?')
|
||||
" 2>/dev/null)
|
||||
echo -e " Unassigned issues: ${Y}$UNASSIGNED${R}"
|
||||
|
||||
@@ -1,294 +1,235 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── Workflow Control Helpers ───────────────────────────────────────────
|
||||
# ── Dashboard Control Helpers ──────────────────────────────────────────
|
||||
# Source this in the controls pane: source ~/.hermes/bin/ops-helpers.sh
|
||||
# These helpers intentionally target the current Hermes + Gitea workflow
|
||||
# and do not revive deprecated bash worker loops.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
resolve_gitea_url() {
|
||||
if [ -n "${GITEA:-}" ]; then
|
||||
printf '%s\n' "${GITEA%/}"
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$HOME/.hermes/gitea_api" ]; then
|
||||
python3 - "$HOME/.hermes/gitea_api" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
raw = Path(sys.argv[1]).read_text().strip().rstrip("/")
|
||||
print(raw[:-7] if raw.endswith("/api/v1") else raw)
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$HOME/.config/gitea/base-url" ]; then
|
||||
tr -d '[:space:]' < "$HOME/.config/gitea/base-url"
|
||||
return 0
|
||||
fi
|
||||
echo "ERROR: set GITEA or create ~/.hermes/gitea_api" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
export GITEA="$(resolve_gitea_url)"
|
||||
export OPS_DEFAULT_REPO="${OPS_DEFAULT_REPO:-Timmy_Foundation/timmy-home}"
|
||||
export OPS_CORE_REPOS="${OPS_CORE_REPOS:-Timmy_Foundation/the-nexus Timmy_Foundation/timmy-home Timmy_Foundation/timmy-config Timmy_Foundation/hermes-agent}"
|
||||
|
||||
ops-token() {
|
||||
local token_file
|
||||
for token_file in \
|
||||
"$HOME/.config/gitea/timmy-token" \
|
||||
"$HOME/.hermes/gitea_token_vps" \
|
||||
"$HOME/.hermes/gitea_token_timmy"; do
|
||||
if [ -f "$token_file" ]; then
|
||||
tr -d '[:space:]' < "$token_file"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
export TOKEN=*** ~/.hermes/gitea_token_vps 2>/dev/null)
|
||||
export GITEA="http://143.198.27.163:3000"
|
||||
export REPO_API="$GITEA/api/v1/repos/rockachopa/Timmy-time-dashboard"
|
||||
|
||||
ops-help() {
|
||||
echo ""
|
||||
echo -e "\033[1m\033[35m ◈ WORKFLOW CONTROLS\033[0m"
|
||||
echo -e "\033[1m\033[35m ◈ CONTROLS\033[0m"
|
||||
echo -e "\033[2m ──────────────────────────────────────\033[0m"
|
||||
echo ""
|
||||
echo -e " \033[1mReview\033[0m"
|
||||
echo " ops-prs [repo] List open PRs across the core repos or one repo"
|
||||
echo " ops-review-queue Show PRs waiting on Timmy or Allegro"
|
||||
echo " ops-merge PR REPO Squash-merge a reviewed PR"
|
||||
echo -e " \033[1mWake Up\033[0m"
|
||||
echo " ops-wake-kimi Restart Kimi loop"
|
||||
echo " ops-wake-claude Restart Claude loop"
|
||||
echo " ops-wake-gemini Restart Gemini loop"
|
||||
echo " ops-wake-gateway Restart gateway"
|
||||
echo " ops-wake-all Restart everything"
|
||||
echo ""
|
||||
echo -e " \033[1mDispatch\033[0m"
|
||||
echo " ops-assign ISSUE AGENT [repo] Assign an issue to an agent"
|
||||
echo " ops-unassign ISSUE [repo] Remove all assignees from an issue"
|
||||
echo " ops-queue AGENT [repo|all] Show an agent's queue"
|
||||
echo " ops-unassigned [repo|all] Show unassigned issues"
|
||||
echo -e " \033[1mManage\033[0m"
|
||||
echo " ops-merge PR_NUM Squash-merge a PR"
|
||||
echo " ops-assign ISSUE Assign issue to Kimi"
|
||||
echo " ops-assign-claude ISSUE [REPO] Assign to Claude"
|
||||
echo " ops-audit Run efficiency audit now"
|
||||
echo " ops-prs List open PRs"
|
||||
echo " ops-queue Show Kimi's queue"
|
||||
echo " ops-claude-queue Show Claude's queue"
|
||||
echo " ops-gemini-queue Show Gemini's queue"
|
||||
echo ""
|
||||
echo -e " \033[1mWorkflow Health\033[0m"
|
||||
echo " ops-gitea-feed Render the Gitea workflow feed"
|
||||
echo " ops-freshness Check Hermes session/export freshness"
|
||||
echo -e " \033[1mEmergency\033[0m"
|
||||
echo " ops-kill-kimi Stop Kimi loop"
|
||||
echo " ops-kill-claude Stop Claude loop"
|
||||
echo " ops-kill-gemini Stop Gemini loop"
|
||||
echo " ops-kill-zombies Kill stuck git/pytest"
|
||||
echo ""
|
||||
echo -e " \033[1mShortcuts\033[0m"
|
||||
echo " ops-assign-allegro ISSUE [repo]"
|
||||
echo " ops-assign-codex ISSUE [repo]"
|
||||
echo " ops-assign-groq ISSUE [repo]"
|
||||
echo " ops-assign-claude ISSUE [repo]"
|
||||
echo " ops-assign-ezra ISSUE [repo]"
|
||||
echo -e " \033[1mOrchestrator\033[0m"
|
||||
echo " ops-wake-timmy Start Timmy (Ollama)"
|
||||
echo " ops-kill-timmy Stop Timmy"
|
||||
echo ""
|
||||
echo -e " \033[1mWatchdog\033[0m"
|
||||
echo " ops-wake-watchdog Start loop watchdog"
|
||||
echo " ops-kill-watchdog Stop loop watchdog"
|
||||
echo ""
|
||||
echo -e " \033[2m Type ops-help to see this again\033[0m"
|
||||
echo ""
|
||||
}
|
||||
|
||||
ops-python() {
|
||||
local token
|
||||
token=$(ops-token) || { echo "No Gitea token found"; return 1; }
|
||||
OPS_TOKEN="$token" python3 - "$@"
|
||||
ops-wake-kimi() {
|
||||
pkill -f "kimi-loop.sh" 2>/dev/null
|
||||
sleep 1
|
||||
nohup bash ~/.hermes/bin/kimi-loop.sh >> ~/.hermes/logs/kimi-loop.log 2>&1 &
|
||||
echo " Kimi loop started (PID $!)"
|
||||
}
|
||||
|
||||
ops-prs() {
|
||||
local target="${1:-all}"
|
||||
ops-python "$GITEA" "$OPS_CORE_REPOS" "$target" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
base = sys.argv[1].rstrip("/")
|
||||
repos = sys.argv[2].split()
|
||||
target = sys.argv[3]
|
||||
token = os.environ["OPS_TOKEN"]
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
|
||||
if target != "all":
|
||||
repos = [target]
|
||||
|
||||
pulls = []
|
||||
for repo in repos:
|
||||
req = urllib.request.Request(
|
||||
f"{base}/api/v1/repos/{repo}/pulls?state=open&limit=20",
|
||||
headers=headers,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
for pr in json.loads(resp.read().decode()):
|
||||
pr["_repo"] = repo
|
||||
pulls.append(pr)
|
||||
|
||||
if not pulls:
|
||||
print(" (none)")
|
||||
else:
|
||||
for pr in pulls:
|
||||
print(f" #{pr['number']:4d} {pr['_repo'].split('/', 1)[1]:12s} {pr['user']['login'][:12]:12s} {pr['title'][:60]}")
|
||||
PY
|
||||
ops-wake-gateway() {
|
||||
hermes gateway start 2>&1
|
||||
}
|
||||
|
||||
ops-review-queue() {
|
||||
ops-python "$GITEA" "$OPS_CORE_REPOS" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
base = sys.argv[1].rstrip("/")
|
||||
repos = sys.argv[2].split()
|
||||
token = os.environ["OPS_TOKEN"]
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
|
||||
items = []
|
||||
for repo in repos:
|
||||
req = urllib.request.Request(
|
||||
f"{base}/api/v1/repos/{repo}/issues?state=open&limit=50&type=pulls",
|
||||
headers=headers,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
for item in json.loads(resp.read().decode()):
|
||||
assignees = [a.get("login", "") for a in (item.get("assignees") or [])]
|
||||
if any(name in assignees for name in ("Timmy", "allegro")):
|
||||
item["_repo"] = repo
|
||||
items.append(item)
|
||||
|
||||
if not items:
|
||||
print(" (clear)")
|
||||
else:
|
||||
for item in items:
|
||||
names = ",".join(a.get("login", "") for a in (item.get("assignees") or []))
|
||||
print(f" #{item['number']:4d} {item['_repo'].split('/', 1)[1]:12s} {names[:20]:20s} {item['title'][:56]}")
|
||||
PY
|
||||
ops-wake-claude() {
|
||||
local workers="${1:-3}"
|
||||
pkill -f "claude-loop.sh" 2>/dev/null
|
||||
sleep 1
|
||||
nohup bash ~/.hermes/bin/claude-loop.sh "$workers" >> ~/.hermes/logs/claude-loop.log 2>&1 &
|
||||
echo " Claude loop started — $workers workers (PID $!)"
|
||||
}
|
||||
|
||||
ops-assign() {
|
||||
local issue="$1"
|
||||
local agent="$2"
|
||||
local repo="${3:-$OPS_DEFAULT_REPO}"
|
||||
local token
|
||||
[ -z "$issue" ] && { echo "Usage: ops-assign ISSUE_NUMBER AGENT [owner/repo]"; return 1; }
|
||||
[ -z "$agent" ] && { echo "Usage: ops-assign ISSUE_NUMBER AGENT [owner/repo]"; return 1; }
|
||||
token=$(ops-token) || { echo "No Gitea token found"; return 1; }
|
||||
curl -s -X PATCH -H "Authorization: token $token" -H "Content-Type: application/json" \
|
||||
"$GITEA/api/v1/repos/$repo/issues/$issue" -d "{\"assignees\":[\"$agent\"]}" | python3 -c "
|
||||
ops-wake-gemini() {
|
||||
pkill -f "gemini-loop.sh" 2>/dev/null
|
||||
sleep 1
|
||||
nohup bash ~/.hermes/bin/gemini-loop.sh >> ~/.hermes/logs/gemini-loop.log 2>&1 &
|
||||
echo " Gemini loop started (PID $!)"
|
||||
}
|
||||
|
||||
ops-wake-all() {
|
||||
ops-wake-gateway
|
||||
sleep 1
|
||||
ops-wake-kimi
|
||||
sleep 1
|
||||
ops-wake-claude
|
||||
sleep 1
|
||||
ops-wake-gemini
|
||||
echo " All services started"
|
||||
}
|
||||
|
||||
ops-merge() {
|
||||
local pr=$1
|
||||
[ -z "$pr" ] && { echo "Usage: ops-merge PR_NUMBER"; return 1; }
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
"$REPO_API/pulls/$pr/merge" -d '{"Do":"squash"}' | python3 -c "
|
||||
import json,sys
|
||||
d=json.loads(sys.stdin.read())
|
||||
names=','.join(a.get('login','') for a in (d.get('assignees') or []))
|
||||
print(f' ✓ #{d.get(\"number\", \"?\")} assigned to {names or \"(none)\"}')
|
||||
if 'sha' in d: print(f' ✓ PR #{$pr} merged ({d[\"sha\"][:8]})')
|
||||
else: print(f' ✗ {d.get(\"message\",\"unknown error\")}')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
ops-unassign() {
|
||||
local issue="$1"
|
||||
local repo="${2:-$OPS_DEFAULT_REPO}"
|
||||
local token
|
||||
[ -z "$issue" ] && { echo "Usage: ops-unassign ISSUE_NUMBER [owner/repo]"; return 1; }
|
||||
token=$(ops-token) || { echo "No Gitea token found"; return 1; }
|
||||
curl -s -X PATCH -H "Authorization: token $token" -H "Content-Type: application/json" \
|
||||
"$GITEA/api/v1/repos/$repo/issues/$issue" -d '{"assignees":[]}' | python3 -c "
|
||||
ops-assign() {
|
||||
local issue=$1
|
||||
[ -z "$issue" ] && { echo "Usage: ops-assign ISSUE_NUMBER"; return 1; }
|
||||
curl -s -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
"$REPO_API/issues/$issue" -d '{"assignees":["kimi"]}' | python3 -c "
|
||||
import json,sys; d=json.loads(sys.stdin.read()); print(f' ✓ #{$issue} assigned to kimi')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
ops-audit() {
|
||||
bash ~/.hermes/bin/efficiency-audit.sh
|
||||
}
|
||||
|
||||
ops-prs() {
|
||||
curl -s -H "Authorization: token $TOKEN" "$REPO_API/pulls?state=open&limit=20" | python3 -c "
|
||||
import json,sys
|
||||
d=json.loads(sys.stdin.read())
|
||||
print(f' ✓ #{d.get(\"number\", \"?\")} unassigned')
|
||||
prs=json.loads(sys.stdin.read())
|
||||
for p in prs: print(f' #{p[\"number\"]:4d} {p[\"user\"][\"login\"]:8s} {p[\"title\"][:60]}')
|
||||
if not prs: print(' (none)')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
ops-queue() {
|
||||
local agent="$1"
|
||||
local target="${2:-all}"
|
||||
[ -z "$agent" ] && { echo "Usage: ops-queue AGENT [repo|all]"; return 1; }
|
||||
ops-python "$GITEA" "$OPS_CORE_REPOS" "$agent" "$target" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
base = sys.argv[1].rstrip("/")
|
||||
repos = sys.argv[2].split()
|
||||
agent = sys.argv[3]
|
||||
target = sys.argv[4]
|
||||
token = os.environ["OPS_TOKEN"]
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
|
||||
if target != "all":
|
||||
repos = [target]
|
||||
|
||||
rows = []
|
||||
for repo in repos:
|
||||
req = urllib.request.Request(
|
||||
f"{base}/api/v1/repos/{repo}/issues?state=open&limit=50&type=issues",
|
||||
headers=headers,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
for issue in json.loads(resp.read().decode()):
|
||||
assignees = [a.get("login", "") for a in (issue.get("assignees") or [])]
|
||||
if agent in assignees:
|
||||
rows.append((repo, issue["number"], issue["title"]))
|
||||
|
||||
if not rows:
|
||||
print(" (empty)")
|
||||
else:
|
||||
for repo, number, title in rows:
|
||||
print(f" #{number:4d} {repo.split('/', 1)[1]:12s} {title[:60]}")
|
||||
PY
|
||||
}
|
||||
|
||||
ops-unassigned() {
|
||||
local target="${1:-all}"
|
||||
ops-python "$GITEA" "$OPS_CORE_REPOS" "$target" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
base = sys.argv[1].rstrip("/")
|
||||
repos = sys.argv[2].split()
|
||||
target = sys.argv[3]
|
||||
token = os.environ["OPS_TOKEN"]
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
|
||||
if target != "all":
|
||||
repos = [target]
|
||||
|
||||
rows = []
|
||||
for repo in repos:
|
||||
req = urllib.request.Request(
|
||||
f"{base}/api/v1/repos/{repo}/issues?state=open&limit=50&type=issues",
|
||||
headers=headers,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
for issue in json.loads(resp.read().decode()):
|
||||
if not issue.get("assignees"):
|
||||
rows.append((repo, issue["number"], issue["title"]))
|
||||
|
||||
if not rows:
|
||||
print(" (none)")
|
||||
else:
|
||||
for repo, number, title in rows[:20]:
|
||||
print(f" #{number:4d} {repo.split('/', 1)[1]:12s} {title[:60]}")
|
||||
if len(rows) > 20:
|
||||
print(f" ... +{len(rows) - 20} more")
|
||||
PY
|
||||
}
|
||||
|
||||
ops-merge() {
|
||||
local pr="$1"
|
||||
local repo="${2:-$OPS_DEFAULT_REPO}"
|
||||
local token
|
||||
[ -z "$pr" ] && { echo "Usage: ops-merge PR_NUMBER [owner/repo]"; return 1; }
|
||||
token=$(ops-token) || { echo "No Gitea token found"; return 1; }
|
||||
curl -s -X POST -H "Authorization: token $token" -H "Content-Type: application/json" \
|
||||
"$GITEA/api/v1/repos/$repo/pulls/$pr/merge" -d '{"Do":"squash"}' | python3 -c "
|
||||
curl -s -H "Authorization: token $TOKEN" "$REPO_API/issues?state=open&limit=50&type=issues" | python3 -c "
|
||||
import json,sys
|
||||
d=json.loads(sys.stdin.read())
|
||||
if 'sha' in d:
|
||||
print(f' ✓ PR merged ({d[\"sha\"][:8]})')
|
||||
else:
|
||||
print(f' ✗ {d.get(\"message\", \"unknown error\")}')
|
||||
all_issues=json.loads(sys.stdin.read())
|
||||
issues=[i for i in all_issues if 'kimi' in [a.get('login','') for a in (i.get('assignees') or [])]]
|
||||
for i in issues: print(f' #{i[\"number\"]:4d} {i[\"title\"][:60]}')
|
||||
if not issues: print(' (empty)')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
ops-gitea-feed() {
|
||||
bash "$HOME/.hermes/bin/ops-gitea.sh"
|
||||
ops-kill-kimi() {
|
||||
pkill -f "kimi-loop.sh" 2>/dev/null
|
||||
pkill -f "kimi.*--print" 2>/dev/null
|
||||
echo " Kimi stopped"
|
||||
}
|
||||
|
||||
ops-freshness() {
|
||||
bash "$HOME/.hermes/bin/pipeline-freshness.sh"
|
||||
ops-kill-claude() {
|
||||
pkill -f "claude-loop.sh" 2>/dev/null
|
||||
pkill -f "claude.*--print.*--dangerously" 2>/dev/null
|
||||
rm -rf ~/.hermes/logs/claude-locks/*.lock 2>/dev/null
|
||||
echo '{}' > ~/.hermes/logs/claude-active.json 2>/dev/null
|
||||
echo " Claude stopped (all workers)"
|
||||
}
|
||||
|
||||
ops-assign-allegro() { ops-assign "$1" "allegro" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
ops-assign-codex() { ops-assign "$1" "codex-agent" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
ops-assign-groq() { ops-assign "$1" "groq" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
ops-assign-claude() { ops-assign "$1" "claude" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
ops-assign-ezra() { ops-assign "$1" "ezra" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
ops-assign-perplexity() { ops-assign "$1" "perplexity" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
ops-assign-kimiclaw() { ops-assign "$1" "KimiClaw" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
ops-kill-gemini() {
|
||||
pkill -f "gemini-loop.sh" 2>/dev/null
|
||||
pkill -f "gemini.*--print" 2>/dev/null
|
||||
echo " Gemini stopped"
|
||||
}
|
||||
|
||||
ops-assign-claude() {
|
||||
local issue=$1
|
||||
local repo="${2:-rockachopa/Timmy-time-dashboard}"
|
||||
[ -z "$issue" ] && { echo "Usage: ops-assign-claude ISSUE_NUMBER [owner/repo]"; return 1; }
|
||||
curl -s -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
"$GITEA/api/v1/repos/$repo/issues/$issue" -d '{"assignees":["claude"]}' | python3 -c "
|
||||
import json,sys; d=json.loads(sys.stdin.read()); print(f' ✓ #{$issue} assigned to claude')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
ops-claude-queue() {
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
token=*** ~/.hermes/claude_token 2>/dev/null)'
|
||||
base = 'http://143.198.27.163:3000'
|
||||
repos = ['rockachopa/Timmy-time-dashboard','rockachopa/alexanderwhitestone.com','replit/timmy-tower','replit/token-gated-economy','rockachopa/hermes-agent']
|
||||
for repo in repos:
|
||||
url = f'{base}/api/v1/repos/{repo}/issues?state=open&limit=50&type=issues'
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
|
||||
resp = urllib.request.urlopen(req, timeout=5)
|
||||
raw = json.loads(resp.read())
|
||||
issues = [i for i in raw if 'claude' in [a.get('login','') for a in (i.get('assignees') or [])]]
|
||||
for i in issues:
|
||||
print(f' #{i[\"number\"]:4d} {repo.split(\"/\")[1]:20s} {i[\"title\"][:50]}')
|
||||
except: continue
|
||||
" 2>/dev/null || echo " (error)"
|
||||
}
|
||||
|
||||
ops-assign-gemini() {
|
||||
local issue=$1
|
||||
local repo="${2:-rockachopa/Timmy-time-dashboard}"
|
||||
[ -z "$issue" ] && { echo "Usage: ops-assign-gemini ISSUE_NUMBER [owner/repo]"; return 1; }
|
||||
curl -s -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
"$GITEA/api/v1/repos/$repo/issues/$issue" -d '{"assignees":["gemini"]}' | python3 -c "
|
||||
import json,sys; d=json.loads(sys.stdin.read()); print(f' ✓ #{$issue} assigned to gemini')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
ops-gemini-queue() {
|
||||
curl -s -H "Authorization: token $TOKEN" "$REPO_API/issues?state=open&limit=50&type=issues" | python3 -c "
|
||||
import json,sys
|
||||
all_issues=json.loads(sys.stdin.read())
|
||||
issues=[i for i in all_issues if 'gemini' in [a.get('login','') for a in (i.get('assignees') or [])]]
|
||||
for i in issues: print(f' #{i[\"number\"]:4d} {i[\"title\"][:60]}')
|
||||
if not issues: print(' (empty)')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
ops-kill-zombies() {
|
||||
local killed=0
|
||||
for pid in $(ps aux | grep "pytest tests/" | grep -v grep | awk '{print $2}'); do
|
||||
kill "$pid" 2>/dev/null && killed=$((killed+1))
|
||||
done
|
||||
for pid in $(ps aux | grep "git.*push\|git-remote-http" | grep -v grep | awk '{print $2}'); do
|
||||
kill "$pid" 2>/dev/null && killed=$((killed+1))
|
||||
done
|
||||
echo " Killed $killed zombie processes"
|
||||
}
|
||||
|
||||
ops-wake-timmy() {
|
||||
pkill -f "timmy-orchestrator.sh" 2>/dev/null
|
||||
rm -f ~/.hermes/logs/timmy-orchestrator.pid
|
||||
sleep 1
|
||||
nohup bash ~/.hermes/bin/timmy-orchestrator.sh >> ~/.hermes/logs/timmy-orchestrator.log 2>&1 &
|
||||
echo " Timmy orchestrator started (PID $!)"
|
||||
}
|
||||
|
||||
ops-kill-timmy() {
|
||||
pkill -f "timmy-orchestrator.sh" 2>/dev/null
|
||||
rm -f ~/.hermes/logs/timmy-orchestrator.pid
|
||||
echo " Timmy stopped"
|
||||
}
|
||||
|
||||
ops-wake-watchdog() {
|
||||
pkill -f "loop-watchdog.sh" 2>/dev/null
|
||||
sleep 1
|
||||
nohup bash ~/.hermes/bin/loop-watchdog.sh >> ~/.hermes/logs/watchdog.log 2>&1 &
|
||||
echo " Watchdog started (PID $!)"
|
||||
}
|
||||
|
||||
ops-kill-watchdog() {
|
||||
pkill -f "loop-watchdog.sh" 2>/dev/null
|
||||
echo " Watchdog stopped"
|
||||
}
|
||||
|
||||
466
bin/ops-panel.sh
466
bin/ops-panel.sh
@@ -1,224 +1,300 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── Workflow Ops Panel ─────────────────────────────────────────────────
|
||||
# Current-state dashboard for review, dispatch, and freshness.
|
||||
# This intentionally reflects the post-loop, Hermes-sidecar workflow.
|
||||
# ── Consolidated Ops Panel ─────────────────────────────────────────────
|
||||
# Everything in one view. Designed for a half-screen pane (~100x45).
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
B='\033[1m' ; D='\033[2m' ; R='\033[0m' ; U='\033[4m'
|
||||
G='\033[32m' ; Y='\033[33m' ; RD='\033[31m' ; C='\033[36m' ; M='\033[35m' ; W='\033[37m'
|
||||
OK="${G}●${R}" ; WARN="${Y}●${R}" ; FAIL="${RD}●${R}" ; OFF="${D}○${R}"
|
||||
|
||||
B='\033[1m'
|
||||
D='\033[2m'
|
||||
R='\033[0m'
|
||||
U='\033[4m'
|
||||
G='\033[32m'
|
||||
Y='\033[33m'
|
||||
RD='\033[31m'
|
||||
M='\033[35m'
|
||||
OK="${G}●${R}"
|
||||
WARN="${Y}●${R}"
|
||||
FAIL="${RD}●${R}"
|
||||
|
||||
resolve_gitea_url() {
|
||||
if [ -n "${GITEA_URL:-}" ]; then
|
||||
printf '%s\n' "${GITEA_URL%/}"
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$HOME/.hermes/gitea_api" ]; then
|
||||
python3 - "$HOME/.hermes/gitea_api" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
raw = Path(sys.argv[1]).read_text().strip().rstrip("/")
|
||||
print(raw[:-7] if raw.endswith("/api/v1") else raw)
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$HOME/.config/gitea/base-url" ]; then
|
||||
tr -d '[:space:]' < "$HOME/.config/gitea/base-url"
|
||||
return 0
|
||||
fi
|
||||
echo "ERROR: set GITEA_URL or create ~/.hermes/gitea_api" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_ops_token() {
|
||||
local token_file
|
||||
for token_file in \
|
||||
"$HOME/.config/gitea/timmy-token" \
|
||||
"$HOME/.hermes/gitea_token_vps" \
|
||||
"$HOME/.hermes/gitea_token_timmy"; do
|
||||
if [ -f "$token_file" ]; then
|
||||
tr -d '[:space:]' < "$token_file"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
GITEA_URL="$(resolve_gitea_url)"
|
||||
CORE_REPOS="${CORE_REPOS:-Timmy_Foundation/the-nexus Timmy_Foundation/timmy-home Timmy_Foundation/timmy-config Timmy_Foundation/hermes-agent}"
|
||||
TOKEN="$(resolve_ops_token || true)"
|
||||
[ -z "$TOKEN" ] && echo "WARN: no approved Timmy Gitea token found; panel will use unauthenticated API calls" >&2
|
||||
TOKEN=$(cat ~/.hermes/gitea_token_vps 2>/dev/null)
|
||||
API="http://143.198.27.163:3000/api/v1/repos/rockachopa/Timmy-time-dashboard"
|
||||
|
||||
# ── HEADER ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e " ${B}${M}◈ WORKFLOW OPERATIONS${R} ${D}$(date '+%a %b %d %H:%M:%S')${R}"
|
||||
echo -e " ${B}${M}◈ HERMES OPERATIONS${R} ${D}$(date '+%a %b %d %H:%M:%S')${R}"
|
||||
echo -e " ${D}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}"
|
||||
echo ""
|
||||
|
||||
# ── SERVICES ───────────────────────────────────────────────────────────
|
||||
echo -e " ${B}${U}SERVICES${R}"
|
||||
echo ""
|
||||
|
||||
GW_PID=$(pgrep -f "hermes.*gateway.*run" 2>/dev/null | head -1 || true)
|
||||
if [ -n "${GW_PID:-}" ]; then
|
||||
echo -e " ${OK} Hermes Gateway ${D}pid $GW_PID${R}"
|
||||
# Gateway
|
||||
GW_PID=$(pgrep -f "hermes.*gateway.*run" 2>/dev/null | head -1)
|
||||
[ -n "$GW_PID" ] && echo -e " ${OK} Gateway ${D}pid $GW_PID${R}" \
|
||||
|| echo -e " ${FAIL} Gateway ${RD}DOWN — run: hermes gateway start${R}"
|
||||
|
||||
# Kimi Code loop
|
||||
KIMI_PID=$(pgrep -f "kimi-loop.sh" 2>/dev/null | head -1)
|
||||
[ -n "$KIMI_PID" ] && echo -e " ${OK} Kimi Loop ${D}pid $KIMI_PID${R}" \
|
||||
|| echo -e " ${FAIL} Kimi Loop ${RD}DOWN — run: ops-wake-kimi${R}"
|
||||
|
||||
# Active Kimi Code worker
|
||||
KIMI_WORK=$(pgrep -f "kimi.*--print" 2>/dev/null | head -1)
|
||||
if [ -n "$KIMI_WORK" ]; then
|
||||
echo -e " ${OK} Kimi Code ${D}pid $KIMI_WORK ${G}working${R}"
|
||||
elif [ -n "$KIMI_PID" ]; then
|
||||
echo -e " ${WARN} Kimi Code ${Y}between issues${R}"
|
||||
else
|
||||
echo -e " ${FAIL} Hermes Gateway ${RD}down${R}"
|
||||
echo -e " ${OFF} Kimi Code ${D}not running${R}"
|
||||
fi
|
||||
|
||||
if curl -s --max-time 3 "$GITEA_URL/api/v1/version" >/dev/null 2>&1; then
|
||||
echo -e " ${OK} Gitea ${D}${GITEA_URL}${R}"
|
||||
# Claude Code loop (parallel workers)
|
||||
CLAUDE_PID=$(pgrep -f "claude-loop.sh" 2>/dev/null | head -1)
|
||||
CLAUDE_WORKERS=$(pgrep -f "claude.*--print.*--dangerously" 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ -n "$CLAUDE_PID" ]; then
|
||||
echo -e " ${OK} Claude Loop ${D}pid $CLAUDE_PID ${G}${CLAUDE_WORKERS} workers active${R}"
|
||||
else
|
||||
echo -e " ${FAIL} Gitea ${RD}unreachable${R}"
|
||||
echo -e " ${FAIL} Claude Loop ${RD}DOWN — run: ops-wake-claude${R}"
|
||||
fi
|
||||
|
||||
if hermes cron list >/dev/null 2>&1; then
|
||||
echo -e " ${OK} Hermes Cron ${D}reachable${R}"
|
||||
# Gemini Code loop
|
||||
GEMINI_PID=$(pgrep -f "gemini-loop.sh" 2>/dev/null | head -1)
|
||||
GEMINI_WORK=$(pgrep -f "gemini.*--print" 2>/dev/null | head -1)
|
||||
if [ -n "$GEMINI_PID" ]; then
|
||||
if [ -n "$GEMINI_WORK" ]; then
|
||||
echo -e " ${OK} Gemini Loop ${D}pid $GEMINI_PID ${G}working${R}"
|
||||
else
|
||||
echo -e " ${WARN} Gemini Loop ${D}pid $GEMINI_PID ${Y}between issues${R}"
|
||||
fi
|
||||
else
|
||||
echo -e " ${WARN} Hermes Cron ${Y}not responding${R}"
|
||||
echo -e " ${FAIL} Gemini Loop ${RD}DOWN — run: ops-wake-gemini${R}"
|
||||
fi
|
||||
|
||||
FRESHNESS_OUTPUT=$("$HOME/.hermes/bin/pipeline-freshness.sh" 2>/dev/null || true)
|
||||
FRESHNESS_STATUS=$(printf '%s\n' "$FRESHNESS_OUTPUT" | awk -F= '/^status=/{print $2}')
|
||||
FRESHNESS_REASON=$(printf '%s\n' "$FRESHNESS_OUTPUT" | awk -F= '/^reason=/{print $2}')
|
||||
if [ "$FRESHNESS_STATUS" = "ok" ]; then
|
||||
echo -e " ${OK} Export Freshness ${D}${FRESHNESS_REASON:-within freshness window}${R}"
|
||||
elif [ -n "$FRESHNESS_STATUS" ]; then
|
||||
echo -e " ${WARN} Export Freshness ${Y}${FRESHNESS_REASON:-lagging}${R}"
|
||||
# Timmy Orchestrator
|
||||
TIMMY_PID=$(pgrep -f "timmy-orchestrator.sh" 2>/dev/null | head -1)
|
||||
if [ -n "$TIMMY_PID" ]; then
|
||||
TIMMY_LAST=$(tail -1 "$HOME/.hermes/logs/timmy-orchestrator.log" 2>/dev/null | sed 's/.*TIMMY: //')
|
||||
echo -e " ${OK} Timmy (Ollama) ${D}pid $TIMMY_PID ${G}${TIMMY_LAST:0:30}${R}"
|
||||
else
|
||||
echo -e " ${WARN} Export Freshness ${Y}unknown${R}"
|
||||
echo -e " ${FAIL} Timmy ${RD}DOWN — run: ops-wake-timmy${R}"
|
||||
fi
|
||||
|
||||
# Gitea VPS
|
||||
if curl -s --max-time 3 "http://143.198.27.163:3000/api/v1/version" >/dev/null 2>&1; then
|
||||
echo -e " ${OK} Gitea VPS ${D}143.198.27.163:3000${R}"
|
||||
else
|
||||
echo -e " ${FAIL} Gitea VPS ${RD}unreachable${R}"
|
||||
fi
|
||||
|
||||
# Matrix staging
|
||||
HTTP=$(curl -s --max-time 3 -o /dev/null -w "%{http_code}" "http://143.198.27.163/")
|
||||
[ "$HTTP" = "200" ] && echo -e " ${OK} Matrix Staging ${D}143.198.27.163${R}" \
|
||||
|| echo -e " ${FAIL} Matrix Staging ${RD}HTTP $HTTP${R}"
|
||||
|
||||
# Dev cycle cron
|
||||
CRON_LINE=$(hermes cron list 2>&1 | grep -B1 "consolidated-dev-cycle" | head -1 2>/dev/null)
|
||||
if echo "$CRON_LINE" | grep -q "active"; then
|
||||
NEXT=$(hermes cron list 2>&1 | grep -A4 "consolidated-dev-cycle" | grep "Next" | awk '{print $NF}' | cut -dT -f2 | cut -d. -f1)
|
||||
echo -e " ${OK} Dev Cycle ${D}every 30m, next ${NEXT:-?}${R}"
|
||||
else
|
||||
echo -e " ${FAIL} Dev Cycle Cron ${RD}MISSING${R}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
python3 - "$GITEA_URL" "$TOKEN" "$CORE_REPOS" <<'PY'
|
||||
# ── KIMI STATS ─────────────────────────────────────────────────────────
|
||||
echo -e " ${B}${U}KIMI${R}"
|
||||
echo ""
|
||||
KIMI_LOG="$HOME/.hermes/logs/kimi-loop.log"
|
||||
if [ -f "$KIMI_LOG" ]; then
|
||||
COMPLETED=$(grep -c "SUCCESS:" "$KIMI_LOG" 2>/dev/null | tail -1 || echo 0)
|
||||
FAILED=$(grep -c "FAILED:" "$KIMI_LOG" 2>/dev/null | tail -1 || echo 0)
|
||||
LAST_ISSUE=$(grep "=== ISSUE" "$KIMI_LOG" | tail -1 | sed 's/.*=== //' | sed 's/ ===//')
|
||||
LAST_TIME=$(grep "=== ISSUE\|SUCCESS\|FAILED" "$KIMI_LOG" | tail -1 | cut -d']' -f1 | tr -d '[')
|
||||
RATE=""
|
||||
if [ "$COMPLETED" -gt 0 ] && [ "$FAILED" -gt 0 ]; then
|
||||
TOTAL=$((COMPLETED + FAILED))
|
||||
PCT=$((COMPLETED * 100 / TOTAL))
|
||||
RATE=" (${PCT}% success)"
|
||||
fi
|
||||
echo -e " Completed ${G}${B}$COMPLETED${R} Failed ${RD}$FAILED${R}${D}$RATE${R}"
|
||||
echo -e " Current ${C}$LAST_ISSUE${R}"
|
||||
echo -e " Last seen ${D}$LAST_TIME${R}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── CLAUDE STATS ──────────────────────────────────────────────────
|
||||
echo -e " ${B}${U}CLAUDE${R}"
|
||||
echo ""
|
||||
CLAUDE_LOG="$HOME/.hermes/logs/claude-loop.log"
|
||||
if [ -f "$CLAUDE_LOG" ]; then
|
||||
CL_COMPLETED=$(grep -c "SUCCESS" "$CLAUDE_LOG" 2>/dev/null | tail -1 || echo 0)
|
||||
CL_FAILED=$(grep -c "FAILED" "$CLAUDE_LOG" 2>/dev/null | tail -1 || echo 0)
|
||||
CL_RATE_LIM=$(grep -c "RATE LIMITED" "$CLAUDE_LOG" 2>/dev/null | tail -1 || echo 0)
|
||||
CL_RATE=""
|
||||
if [ "$CL_COMPLETED" -gt 0 ] || [ "$CL_FAILED" -gt 0 ]; then
|
||||
CL_TOTAL=$((CL_COMPLETED + CL_FAILED))
|
||||
[ "$CL_TOTAL" -gt 0 ] && CL_PCT=$((CL_COMPLETED * 100 / CL_TOTAL)) && CL_RATE=" (${CL_PCT}%)"
|
||||
fi
|
||||
echo -e " ${G}${B}$CL_COMPLETED${R} done ${RD}$CL_FAILED${R} fail ${Y}$CL_RATE_LIM${R} rate-limited${D}$CL_RATE${R}"
|
||||
|
||||
# Show active workers
|
||||
ACTIVE="$HOME/.hermes/logs/claude-active.json"
|
||||
if [ -f "$ACTIVE" ]; then
|
||||
python3 -c "
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
base = sys.argv[1].rstrip("/")
|
||||
token = sys.argv[2]
|
||||
repos = sys.argv[3].split()
|
||||
headers = {"Authorization": f"token {token}"} if token else {}
|
||||
|
||||
|
||||
def fetch(path):
|
||||
req = urllib.request.Request(f"{base}{path}", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def short(repo):
|
||||
return repo.split("/", 1)[1]
|
||||
|
||||
|
||||
issues = []
|
||||
pulls = []
|
||||
review_queue = []
|
||||
errors = []
|
||||
|
||||
for repo in repos:
|
||||
try:
|
||||
repo_pulls = fetch(f"/api/v1/repos/{repo}/pulls?state=open&limit=20")
|
||||
for pr in repo_pulls:
|
||||
pr["_repo"] = repo
|
||||
pulls.append(pr)
|
||||
repo_issues = fetch(f"/api/v1/repos/{repo}/issues?state=open&limit=50&type=issues")
|
||||
for issue in repo_issues:
|
||||
issue["_repo"] = repo
|
||||
issues.append(issue)
|
||||
repo_pull_issues = fetch(f"/api/v1/repos/{repo}/issues?state=open&limit=50&type=pulls")
|
||||
for item in repo_pull_issues:
|
||||
assignees = [a.get("login", "") for a in (item.get("assignees") or [])]
|
||||
if any(name in assignees for name in ("Timmy", "allegro")):
|
||||
item["_repo"] = repo
|
||||
review_queue.append(item)
|
||||
except urllib.error.URLError as exc:
|
||||
errors.append(f"{repo}: {exc.reason}")
|
||||
except Exception as exc: # pragma: no cover - defensive panel path
|
||||
errors.append(f"{repo}: {exc}")
|
||||
|
||||
print(" \033[1m\033[4mREVIEW QUEUE\033[0m\n")
|
||||
if not review_queue:
|
||||
print(" \033[2m(clear)\033[0m\n")
|
||||
else:
|
||||
for item in review_queue[:8]:
|
||||
names = ",".join(a.get("login", "") for a in (item.get("assignees") or []))
|
||||
print(f" #{item['number']:<4d} {short(item['_repo']):12s} {names[:20]:20s} {item['title'][:44]}")
|
||||
print()
|
||||
|
||||
print(" \033[1m\033[4mOPEN PRS\033[0m\n")
|
||||
if not pulls:
|
||||
print(" \033[2m(none open)\033[0m\n")
|
||||
else:
|
||||
for pr in pulls[:8]:
|
||||
print(f" #{pr['number']:<4d} {short(pr['_repo']):12s} {pr['user']['login'][:12]:12s} {pr['title'][:48]}")
|
||||
print()
|
||||
|
||||
print(" \033[1m\033[4mDISPATCH QUEUES\033[0m\n")
|
||||
queue_agents = [
|
||||
("allegro", "dispatch"),
|
||||
("codex-agent", "cleanup"),
|
||||
("groq", "fast ship"),
|
||||
("claude", "refactor"),
|
||||
("ezra", "archive"),
|
||||
("perplexity", "research"),
|
||||
("KimiClaw", "digest"),
|
||||
]
|
||||
for agent, label in queue_agents:
|
||||
assigned = [
|
||||
issue
|
||||
for issue in issues
|
||||
if agent in [a.get("login", "") for a in (issue.get("assignees") or [])]
|
||||
]
|
||||
print(f" {agent:12s} {len(assigned):2d} \033[2m{label}\033[0m")
|
||||
print()
|
||||
|
||||
unassigned = [issue for issue in issues if not issue.get("assignees")]
|
||||
stale_cutoff = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d")
|
||||
stale_prs = [pr for pr in pulls if pr.get("updated_at", "")[:10] < stale_cutoff]
|
||||
overloaded = []
|
||||
for agent in ("allegro", "codex-agent", "groq", "claude", "ezra", "perplexity", "KimiClaw"):
|
||||
count = sum(
|
||||
1
|
||||
for issue in issues
|
||||
if agent in [a.get("login", "") for a in (issue.get("assignees") or [])]
|
||||
)
|
||||
if count > 3:
|
||||
overloaded.append((agent, count))
|
||||
|
||||
print(" \033[1m\033[4mWARNINGS\033[0m\n")
|
||||
warns = []
|
||||
if len(unassigned) > 10:
|
||||
warns.append(f"{len(unassigned)} unassigned issues across core repos")
|
||||
if stale_prs:
|
||||
warns.append(f"{len(stale_prs)} open PRs look stale and may need a review nudge")
|
||||
for agent, count in overloaded:
|
||||
warns.append(f"{agent} has {count} assigned issues; rebalance dispatch")
|
||||
|
||||
if warns:
|
||||
for warn in warns:
|
||||
print(f" \033[33m⚠ {warn}\033[0m")
|
||||
else:
|
||||
print(" \033[2m(no major workflow warnings)\033[0m")
|
||||
|
||||
if errors:
|
||||
print("\n \033[1m\033[4mFETCH ERRORS\033[0m\n")
|
||||
for err in errors[:4]:
|
||||
print(f" \033[31m{err}\033[0m")
|
||||
PY
|
||||
|
||||
try:
|
||||
with open('$ACTIVE') as f: active = json.load(f)
|
||||
for wid, info in sorted(active.items()):
|
||||
iss = info.get('issue','')
|
||||
repo = info.get('repo','').split('/')[-1] if info.get('repo') else ''
|
||||
st = info.get('status','')
|
||||
if st == 'working':
|
||||
print(f' \033[36mW{wid}\033[0m \033[33m#{iss}\033[0m \033[2m{repo}\033[0m')
|
||||
elif st == 'idle':
|
||||
print(f' \033[2mW{wid} idle\033[0m')
|
||||
except: pass
|
||||
" 2>/dev/null
|
||||
fi
|
||||
else
|
||||
echo -e " ${D}(no log yet — start with ops-wake-claude)${R}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── GEMINI STATS ─────────────────────────────────────────────────────
|
||||
echo -e " ${B}${U}GEMINI${R}"
|
||||
echo ""
|
||||
GEMINI_LOG="$HOME/.hermes/logs/gemini-loop.log"
|
||||
if [ -f "$GEMINI_LOG" ]; then
|
||||
GM_COMPLETED=$(grep -c "SUCCESS:" "$GEMINI_LOG" 2>/dev/null | tail -1 || echo 0)
|
||||
GM_FAILED=$(grep -c "FAILED:" "$GEMINI_LOG" 2>/dev/null | tail -1 || echo 0)
|
||||
GM_RATE=""
|
||||
if [ "$GM_COMPLETED" -gt 0 ] || [ "$GM_FAILED" -gt 0 ]; then
|
||||
GM_TOTAL=$((GM_COMPLETED + GM_FAILED))
|
||||
[ "$GM_TOTAL" -gt 0 ] && GM_PCT=$((GM_COMPLETED * 100 / GM_TOTAL)) && GM_RATE=" (${GM_PCT}%)"
|
||||
fi
|
||||
GM_LAST=$(grep "=== ISSUE" "$GEMINI_LOG" | tail -1 | sed 's/.*=== //' | sed 's/ ===//')
|
||||
echo -e " ${G}${B}$GM_COMPLETED${R} done ${RD}$GM_FAILED${R} fail${D}$GM_RATE${R}"
|
||||
[ -n "$GM_LAST" ] && echo -e " Current ${C}$GM_LAST${R}"
|
||||
else
|
||||
echo -e " ${D}(no log yet — start with ops-wake-gemini)${R}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── OPEN PRS ───────────────────────────────────────────────────────────
|
||||
echo -e " ${B}${U}PULL REQUESTS${R}"
|
||||
echo ""
|
||||
curl -s --max-time 5 -H "Authorization: token $TOKEN" "$API/pulls?state=open&limit=8" 2>/dev/null | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
prs = json.loads(sys.stdin.read())
|
||||
if not prs: print(' \033[2m(none open)\033[0m')
|
||||
for p in prs[:6]:
|
||||
n = p['number']
|
||||
t = p['title'][:55]
|
||||
u = p['user']['login']
|
||||
print(f' \033[33m#{n:<4d}\033[0m \033[2m{u:8s}\033[0m {t}')
|
||||
if len(prs) > 6: print(f' \033[2m... +{len(prs)-6} more\033[0m')
|
||||
except: print(' \033[31m(error fetching)\033[0m')
|
||||
" 2>/dev/null
|
||||
echo ""
|
||||
|
||||
# ── RECENTLY MERGED ────────────────────────────────────────────────────
|
||||
echo -e " ${B}${U}RECENTLY MERGED${R}"
|
||||
echo ""
|
||||
curl -s --max-time 5 -H "Authorization: token $TOKEN" "$API/pulls?state=closed&sort=updated&limit=5" 2>/dev/null | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
prs = json.loads(sys.stdin.read())
|
||||
merged = [p for p in prs if p.get('merged')][:5]
|
||||
if not merged: print(' \033[2m(none recent)\033[0m')
|
||||
for p in merged:
|
||||
n = p['number']
|
||||
t = p['title'][:50]
|
||||
when = p['merged_at'][11:16]
|
||||
print(f' \033[32m✓ #{n:<4d}\033[0m {t} \033[2m{when}\033[0m')
|
||||
except: print(' \033[31m(error)\033[0m')
|
||||
" 2>/dev/null
|
||||
echo ""
|
||||
|
||||
# ── KIMI QUEUE ─────────────────────────────────────────────────────────
|
||||
echo -e " ${B}${U}KIMI QUEUE${R}"
|
||||
echo ""
|
||||
curl -s --max-time 5 -H "Authorization: token $TOKEN" "$API/issues?state=open&limit=50&type=issues" 2>/dev/null | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
all_issues = json.loads(sys.stdin.read())
|
||||
issues = [i for i in all_issues if 'kimi' in [a.get('login','') for a in (i.get('assignees') or [])]]
|
||||
if not issues: print(' \033[33m⚠ Queue empty — assign more issues to kimi\033[0m')
|
||||
for i in issues[:6]:
|
||||
n = i['number']
|
||||
t = i['title'][:55]
|
||||
print(f' #{n:<4d} {t}')
|
||||
if len(issues) > 6: print(f' \033[2m... +{len(issues)-6} more\033[0m')
|
||||
except: print(' \033[31m(error)\033[0m')
|
||||
" 2>/dev/null
|
||||
echo ""
|
||||
|
||||
# ── CLAUDE QUEUE ──────────────────────────────────────────────────
|
||||
echo -e " ${B}${U}CLAUDE QUEUE${R}"
|
||||
echo ""
|
||||
# Claude works across multiple repos
|
||||
python3 -c "
|
||||
import json, sys, urllib.request
|
||||
token = '$(cat ~/.hermes/claude_token 2>/dev/null)'
|
||||
base = 'http://143.198.27.163:3000'
|
||||
repos = ['rockachopa/Timmy-time-dashboard','rockachopa/alexanderwhitestone.com','replit/timmy-tower','replit/token-gated-economy','rockachopa/hermes-agent']
|
||||
all_issues = []
|
||||
for repo in repos:
|
||||
url = f'{base}/api/v1/repos/{repo}/issues?state=open&limit=50&type=issues'
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
|
||||
resp = urllib.request.urlopen(req, timeout=5)
|
||||
raw = json.loads(resp.read())
|
||||
issues = [i for i in raw if 'claude' in [a.get('login','') for a in (i.get('assignees') or [])]]
|
||||
for i in issues:
|
||||
i['_repo'] = repo.split('/')[1]
|
||||
all_issues.extend(issues)
|
||||
except: continue
|
||||
if not all_issues:
|
||||
print(' \033[33m\u26a0 Queue empty \u2014 assign issues to claude\033[0m')
|
||||
else:
|
||||
for i in all_issues[:6]:
|
||||
n = i['number']
|
||||
t = i['title'][:45]
|
||||
r = i['_repo'][:12]
|
||||
print(f' #{n:<4d} \033[2m{r:12s}\033[0m {t}')
|
||||
if len(all_issues) > 6:
|
||||
print(f' \033[2m... +{len(all_issues)-6} more\033[0m')
|
||||
" 2>/dev/null
|
||||
echo ""
|
||||
|
||||
# ── GEMINI QUEUE ─────────────────────────────────────────────────────
|
||||
echo -e " ${B}${U}GEMINI QUEUE${R}"
|
||||
echo ""
|
||||
curl -s --max-time 5 -H "Authorization: token $TOKEN" "$API/issues?state=open&limit=50&type=issues" 2>/dev/null | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
all_issues = json.loads(sys.stdin.read())
|
||||
issues = [i for i in all_issues if 'gemini' in [a.get('login','') for a in (i.get('assignees') or [])]]
|
||||
if not issues: print(' \033[33m⚠ Queue empty — assign issues to gemini\033[0m')
|
||||
for i in issues[:6]:
|
||||
n = i['number']
|
||||
t = i['title'][:55]
|
||||
print(f' #{n:<4d} {t}')
|
||||
if len(issues) > 6: print(f' \033[2m... +{len(issues)-6} more\033[0m')
|
||||
except: print(' \033[31m(error)\033[0m')
|
||||
" 2>/dev/null
|
||||
echo ""
|
||||
|
||||
# ── WARNINGS ───────────────────────────────────────────────────────────
|
||||
HERMES_PROCS=$(ps aux | grep -E "hermes.*python" | grep -v grep | wc -l | tr -d ' ')
|
||||
STUCK_GIT=$(ps aux | grep "git.*push\|git-remote-http" | grep -v grep | wc -l | tr -d ' ')
|
||||
ORPHAN_PY=$(ps aux | grep "pytest tests/" | grep -v grep | wc -l | tr -d ' ')
|
||||
UNASSIGNED=$(curl -s --max-time 3 -H "Authorization: token $TOKEN" "$API/issues?state=open&limit=50&type=issues" 2>/dev/null | python3 -c "import json,sys; issues=json.loads(sys.stdin.read()); print(len([i for i in issues if not i.get('assignees')]))" 2>/dev/null)
|
||||
|
||||
WARNS=""
|
||||
[ "$STUCK_GIT" -gt 0 ] && WARNS+=" ${RD}⚠ $STUCK_GIT stuck git processes${R}\n"
|
||||
[ "$ORPHAN_PY" -gt 0 ] && WARNS+=" ${Y}⚠ $ORPHAN_PY orphaned pytest runs${R}\n"
|
||||
[ "${UNASSIGNED:-0}" -gt 10 ] && WARNS+=" ${Y}⚠ $UNASSIGNED unassigned issues — feed the queue${R}\n"
|
||||
|
||||
if [ -n "$WARNS" ]; then
|
||||
echo -e " ${B}${U}WARNINGS${R}"
|
||||
echo ""
|
||||
echo -e "$WARNS"
|
||||
fi
|
||||
|
||||
echo -e " ${D}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}"
|
||||
echo -e " ${D}repos: $(printf '%s' "$CORE_REPOS" | wc -w | tr -d ' ') refresh via watch or rerun script${R}"
|
||||
echo -e " ${D}hermes sessions: $HERMES_PROCS unassigned: ${UNASSIGNED:-?} ↻ 20s${R}"
|
||||
|
||||
362
bin/timmy-dashboard
Normal file → Executable file
362
bin/timmy-dashboard
Normal file → Executable file
@@ -1,19 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Timmy workflow dashboard.
|
||||
"""Timmy Model Dashboard — where are my models, what are they doing.
|
||||
|
||||
Shows current workflow state from the active local surfaces instead of the
|
||||
archived dashboard/loop era, while preserving useful local/session metrics.
|
||||
Usage:
|
||||
timmy-dashboard # one-shot
|
||||
timmy-dashboard --watch # live refresh every 30s
|
||||
timmy-dashboard --hours=48 # look back 48h
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -25,97 +26,37 @@ from metrics_helpers import summarize_local_metrics, summarize_session_rows
|
||||
HERMES_HOME = Path.home() / ".hermes"
|
||||
TIMMY_HOME = Path.home() / ".timmy"
|
||||
METRICS_DIR = TIMMY_HOME / "metrics"
|
||||
CORE_REPOS = [
|
||||
"Timmy_Foundation/the-nexus",
|
||||
"Timmy_Foundation/timmy-home",
|
||||
"Timmy_Foundation/timmy-config",
|
||||
"Timmy_Foundation/hermes-agent",
|
||||
]
|
||||
def resolve_gitea_url() -> str:
|
||||
env = os.environ.get("GITEA_URL")
|
||||
if env:
|
||||
return env.rstrip("/")
|
||||
api_hint = HERMES_HOME / "gitea_api"
|
||||
if api_hint.exists():
|
||||
raw = api_hint.read_text().strip().rstrip("/")
|
||||
return raw[:-7] if raw.endswith("/api/v1") else raw
|
||||
base_url = Path.home() / ".config" / "gitea" / "base-url"
|
||||
if base_url.exists():
|
||||
return base_url.read_text().strip().rstrip("/")
|
||||
raise FileNotFoundError("Set GITEA_URL or create ~/.hermes/gitea_api")
|
||||
|
||||
# ── Data Sources ──────────────────────────────────────────────────────
|
||||
|
||||
GITEA_URL = resolve_gitea_url()
|
||||
|
||||
|
||||
def read_token() -> str | None:
|
||||
for path in [
|
||||
Path.home() / ".config" / "gitea" / "timmy-token",
|
||||
Path.home() / ".hermes" / "gitea_token_vps",
|
||||
Path.home() / ".hermes" / "gitea_token_timmy",
|
||||
]:
|
||||
if path.exists():
|
||||
return path.read_text().strip()
|
||||
return None
|
||||
|
||||
|
||||
def gitea_get(path: str, token: str | None) -> list | dict:
|
||||
headers = {"Authorization": f"token {token}"} if token else {}
|
||||
req = urllib.request.Request(f"{GITEA_URL}/api/v1{path}", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def get_model_health() -> dict:
|
||||
path = HERMES_HOME / "model_health.json"
|
||||
if not path.exists():
|
||||
return {}
|
||||
def get_ollama_models():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
req = urllib.request.Request("http://localhost:11434/api/tags")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read()).get("models", [])
|
||||
except Exception:
|
||||
return {}
|
||||
return []
|
||||
|
||||
|
||||
def get_last_tick() -> dict:
|
||||
path = TIMMY_HOME / "heartbeat" / "last_tick.json"
|
||||
if not path.exists():
|
||||
return {}
|
||||
def get_loaded_models():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
req = urllib.request.Request("http://localhost:11434/api/ps")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read()).get("models", [])
|
||||
except Exception:
|
||||
return {}
|
||||
return []
|
||||
|
||||
|
||||
def get_archive_checkpoint() -> dict:
|
||||
path = TIMMY_HOME / "twitter-archive" / "checkpoint.json"
|
||||
if not path.exists():
|
||||
return {}
|
||||
def get_huey_pid():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
r = subprocess.run(["pgrep", "-f", "huey_consumer"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
return r.stdout.strip().split("\n")[0] if r.returncode == 0 else None
|
||||
except Exception:
|
||||
return {}
|
||||
return None
|
||||
|
||||
|
||||
def get_local_metrics(hours: int = 24) -> list[dict]:
|
||||
records = []
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
if not METRICS_DIR.exists():
|
||||
return records
|
||||
for path in sorted(METRICS_DIR.glob("local_*.jsonl")):
|
||||
for line in path.read_text().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
ts = datetime.fromisoformat(record["timestamp"])
|
||||
if ts >= cutoff:
|
||||
records.append(record)
|
||||
except Exception:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
def get_hermes_sessions() -> list[dict]:
|
||||
def get_hermes_sessions():
|
||||
sessions_file = HERMES_HOME / "sessions" / "sessions.json"
|
||||
if not sessions_file.exists():
|
||||
return []
|
||||
@@ -126,7 +67,7 @@ def get_hermes_sessions() -> list[dict]:
|
||||
return []
|
||||
|
||||
|
||||
def get_session_rows(hours: int = 24):
|
||||
def get_session_rows(hours=24):
|
||||
state_db = HERMES_HOME / "state.db"
|
||||
if not state_db.exists():
|
||||
return []
|
||||
@@ -150,14 +91,14 @@ def get_session_rows(hours: int = 24):
|
||||
return []
|
||||
|
||||
|
||||
def get_heartbeat_ticks(date_str: str | None = None) -> list[dict]:
|
||||
def get_heartbeat_ticks(date_str=None):
|
||||
if not date_str:
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
tick_file = TIMMY_HOME / "heartbeat" / f"ticks_{date_str}.jsonl"
|
||||
if not tick_file.exists():
|
||||
return []
|
||||
ticks = []
|
||||
for line in tick_file.read_text().splitlines():
|
||||
for line in tick_file.read_text().strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
@@ -167,33 +108,42 @@ def get_heartbeat_ticks(date_str: str | None = None) -> list[dict]:
|
||||
return ticks
|
||||
|
||||
|
||||
def get_review_and_issue_state(token: str | None) -> dict:
|
||||
state = {"prs": [], "review_queue": [], "unassigned": 0}
|
||||
for repo in CORE_REPOS:
|
||||
try:
|
||||
prs = gitea_get(f"/repos/{repo}/pulls?state=open&limit=20", token)
|
||||
for pr in prs:
|
||||
pr["_repo"] = repo
|
||||
state["prs"].append(pr)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
issue_prs = gitea_get(f"/repos/{repo}/issues?state=open&limit=50&type=pulls", token)
|
||||
for item in issue_prs:
|
||||
assignees = [a.get("login", "") for a in (item.get("assignees") or [])]
|
||||
if any(name in assignees for name in ("Timmy", "allegro")):
|
||||
item["_repo"] = repo
|
||||
state["review_queue"].append(item)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
issues = gitea_get(f"/repos/{repo}/issues?state=open&limit=50&type=issues", token)
|
||||
state["unassigned"] += sum(1 for issue in issues if not issue.get("assignees"))
|
||||
except Exception:
|
||||
continue
|
||||
return state
|
||||
def get_local_metrics(hours=24):
|
||||
"""Read local inference metrics from jsonl files."""
|
||||
records = []
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
if not METRICS_DIR.exists():
|
||||
return records
|
||||
for f in sorted(METRICS_DIR.glob("local_*.jsonl")):
|
||||
for line in f.read_text().strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
ts = datetime.fromisoformat(r["timestamp"])
|
||||
if ts >= cutoff:
|
||||
records.append(r)
|
||||
except Exception:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
def get_cron_jobs():
|
||||
"""Get Hermes cron job status."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["hermes", "cron", "list", "--json"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if r.returncode == 0:
|
||||
return json.loads(r.stdout).get("jobs", [])
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
# ── Rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
DIM = "\033[2m"
|
||||
BOLD = "\033[1m"
|
||||
GREEN = "\033[32m"
|
||||
@@ -204,133 +154,119 @@ RST = "\033[0m"
|
||||
CLR = "\033[2J\033[H"
|
||||
|
||||
|
||||
def render(hours: int = 24) -> None:
|
||||
token = read_token()
|
||||
metrics = get_local_metrics(hours)
|
||||
local_summary = summarize_local_metrics(metrics)
|
||||
def render(hours=24):
|
||||
models = get_ollama_models()
|
||||
loaded = get_loaded_models()
|
||||
huey_pid = get_huey_pid()
|
||||
ticks = get_heartbeat_ticks()
|
||||
health = get_model_health()
|
||||
last_tick = get_last_tick()
|
||||
checkpoint = get_archive_checkpoint()
|
||||
metrics = get_local_metrics(hours)
|
||||
sessions = get_hermes_sessions()
|
||||
session_rows = get_session_rows(hours)
|
||||
local_summary = summarize_local_metrics(metrics)
|
||||
session_summary = summarize_session_rows(session_rows)
|
||||
gitea = get_review_and_issue_state(token)
|
||||
|
||||
loaded_names = {m.get("name", "") for m in loaded}
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
print(CLR, end="")
|
||||
print(f"{BOLD}{'=' * 72}")
|
||||
print(" TIMMY WORKFLOW DASHBOARD")
|
||||
print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"{'=' * 72}{RST}")
|
||||
print(f"{BOLD}{'=' * 70}")
|
||||
print(f" TIMMY MODEL DASHBOARD")
|
||||
print(f" {now} | Huey: {GREEN}PID {huey_pid}{RST if huey_pid else f'{RED}DOWN{RST}'}")
|
||||
print(f"{'=' * 70}{RST}")
|
||||
|
||||
print(f"\n {BOLD}HEARTBEAT{RST}")
|
||||
print(f" {DIM}{'-' * 58}{RST}")
|
||||
if last_tick:
|
||||
sev = last_tick.get("decision", {}).get("severity", "?")
|
||||
tick_id = last_tick.get("tick_id", "?")
|
||||
model_decisions = sum(
|
||||
1
|
||||
for tick in ticks
|
||||
if isinstance(tick.get("decision"), dict)
|
||||
and tick["decision"].get("severity") != "fallback"
|
||||
)
|
||||
print(f" last tick: {tick_id}")
|
||||
print(f" severity: {sev}")
|
||||
print(f" ticks today: {len(ticks)} | model decisions: {model_decisions}")
|
||||
# ── LOCAL MODELS ──
|
||||
print(f"\n {BOLD}LOCAL MODELS (Ollama){RST}")
|
||||
print(f" {DIM}{'-' * 55}{RST}")
|
||||
if models:
|
||||
for m in models:
|
||||
name = m.get("name", "?")
|
||||
size_gb = m.get("size", 0) / 1e9
|
||||
if name in loaded_names:
|
||||
status = f"{GREEN}IN VRAM{RST}"
|
||||
else:
|
||||
status = f"{DIM}on disk{RST}"
|
||||
print(f" {name:35s} {size_gb:5.1f}GB {status}")
|
||||
else:
|
||||
print(f" {DIM}(no heartbeat data){RST}")
|
||||
print(f" {RED}(Ollama not responding){RST}")
|
||||
|
||||
print(f"\n {BOLD}MODEL HEALTH{RST}")
|
||||
print(f" {DIM}{'-' * 58}{RST}")
|
||||
if health:
|
||||
provider = GREEN if health.get("api_responding") else RED
|
||||
inference = GREEN if health.get("inference_ok") else YELLOW
|
||||
print(f" provider: {provider}{health.get('api_responding')}{RST}")
|
||||
print(f" inference: {inference}{health.get('inference_ok')}{RST}")
|
||||
print(f" models: {', '.join(health.get('models_loaded', [])[:4]) or '(none reported)'}")
|
||||
else:
|
||||
print(f" {DIM}(no model_health.json){RST}")
|
||||
|
||||
print(f"\n {BOLD}ARCHIVE PIPELINE{RST}")
|
||||
print(f" {DIM}{'-' * 58}{RST}")
|
||||
if checkpoint:
|
||||
print(f" batches completed: {checkpoint.get('batches_completed', '?')}")
|
||||
print(f" next offset: {checkpoint.get('next_offset', '?')}")
|
||||
print(f" phase: {checkpoint.get('phase', '?')}")
|
||||
else:
|
||||
print(f" {DIM}(no archive checkpoint yet){RST}")
|
||||
|
||||
print(f"\n {BOLD}LOCAL METRICS ({len(metrics)} calls, last {hours}h){RST}")
|
||||
print(f" {DIM}{'-' * 58}{RST}")
|
||||
# ── LOCAL INFERENCE ACTIVITY ──
|
||||
print(f"\n {BOLD}LOCAL INFERENCE ({len(metrics)} calls, last {hours}h){RST}")
|
||||
print(f" {DIM}{'-' * 55}{RST}")
|
||||
if metrics:
|
||||
print(
|
||||
f" Tokens: {local_summary['input_tokens']} in | "
|
||||
f"{local_summary['output_tokens']} out | "
|
||||
f"{local_summary['total_tokens']} total"
|
||||
)
|
||||
if local_summary.get("avg_latency_s") is not None:
|
||||
print(f" Tokens: {local_summary['input_tokens']} in | {local_summary['output_tokens']} out | {local_summary['total_tokens']} total")
|
||||
if local_summary.get('avg_latency_s') is not None:
|
||||
print(f" Avg latency: {local_summary['avg_latency_s']:.2f}s")
|
||||
if local_summary.get("avg_tokens_per_second") is not None:
|
||||
if local_summary.get('avg_tokens_per_second') is not None:
|
||||
print(f" Avg throughput: {GREEN}{local_summary['avg_tokens_per_second']:.2f} tok/s{RST}")
|
||||
for caller, stats in sorted(local_summary["by_caller"].items()):
|
||||
err = f" {RED}err:{stats['failed_calls']}{RST}" if stats["failed_calls"] else ""
|
||||
print(
|
||||
f" {caller:24s} calls={stats['calls']:3d} "
|
||||
f"tok={stats['total_tokens']:5d} {GREEN}ok:{stats['successful_calls']}{RST}{err}"
|
||||
)
|
||||
else:
|
||||
print(f" {DIM}(no local metrics yet){RST}")
|
||||
for caller, stats in sorted(local_summary['by_caller'].items()):
|
||||
err = f" {RED}err:{stats['failed_calls']}{RST}" if stats['failed_calls'] else ""
|
||||
print(f" {caller:25s} calls:{stats['calls']:4d} tokens:{stats['total_tokens']:5d} {GREEN}ok:{stats['successful_calls']}{RST}{err}")
|
||||
|
||||
print(f"\n {BOLD}SESSION LOAD{RST}")
|
||||
print(f" {DIM}{'-' * 58}{RST}")
|
||||
local_sessions = [s for s in sessions if "localhost" in str(s.get("base_url", ""))]
|
||||
print(f"\n {DIM}Models used:{RST}")
|
||||
for model, stats in sorted(local_summary['by_model'].items(), key=lambda x: -x[1]['calls']):
|
||||
print(f" {model:30s} {stats['calls']} calls {stats['total_tokens']} tok")
|
||||
else:
|
||||
print(f" {DIM}(no local calls recorded yet){RST}")
|
||||
|
||||
# ── HEARTBEAT STATUS ──
|
||||
print(f"\n {BOLD}HEARTBEAT ({len(ticks)} ticks today){RST}")
|
||||
print(f" {DIM}{'-' * 55}{RST}")
|
||||
if ticks:
|
||||
last = ticks[-1]
|
||||
decision = last.get("decision", last.get("actions", {}))
|
||||
if isinstance(decision, dict):
|
||||
severity = decision.get("severity", "unknown")
|
||||
reasoning = decision.get("reasoning", "")
|
||||
sev_color = GREEN if severity == "ok" else YELLOW if severity == "warning" else RED
|
||||
print(f" Last tick: {last.get('tick_id', '?')}")
|
||||
print(f" Severity: {sev_color}{severity}{RST}")
|
||||
if reasoning:
|
||||
print(f" Reasoning: {reasoning[:65]}")
|
||||
else:
|
||||
print(f" Last tick: {last.get('tick_id', '?')}")
|
||||
actions = last.get("actions", [])
|
||||
print(f" Actions: {actions if actions else 'none'}")
|
||||
|
||||
model_decisions = sum(1 for t in ticks
|
||||
if isinstance(t.get("decision"), dict)
|
||||
and t["decision"].get("severity") != "fallback")
|
||||
fallback = len(ticks) - model_decisions
|
||||
print(f" {CYAN}Model: {model_decisions}{RST} | {DIM}Fallback: {fallback}{RST}")
|
||||
else:
|
||||
print(f" {DIM}(no ticks today){RST}")
|
||||
|
||||
# ── HERMES SESSIONS / SOVEREIGNTY LOAD ──
|
||||
local_sessions = [s for s in sessions if "localhost:11434" in str(s.get("base_url", ""))]
|
||||
cloud_sessions = [s for s in sessions if s not in local_sessions]
|
||||
print(
|
||||
f" Session cache: {len(sessions)} total | "
|
||||
f"{GREEN}{len(local_sessions)} local{RST} | "
|
||||
f"{YELLOW}{len(cloud_sessions)} remote{RST}"
|
||||
)
|
||||
print(f"\n {BOLD}HERMES SESSIONS / SOVEREIGNTY LOAD{RST}")
|
||||
print(f" {DIM}{'-' * 55}{RST}")
|
||||
print(f" Session cache: {len(sessions)} total | {GREEN}{len(local_sessions)} local{RST} | {YELLOW}{len(cloud_sessions)} cloud{RST}")
|
||||
if session_rows:
|
||||
print(
|
||||
f" Session DB: {session_summary['total_sessions']} total | "
|
||||
f"{GREEN}{session_summary['local_sessions']} local{RST} | "
|
||||
f"{YELLOW}{session_summary['cloud_sessions']} remote{RST}"
|
||||
)
|
||||
print(
|
||||
f" Token est: {GREEN}{session_summary['local_est_tokens']} local{RST} | "
|
||||
f"{YELLOW}{session_summary['cloud_est_tokens']} remote{RST}"
|
||||
)
|
||||
print(f" Est remote cost: ${session_summary['cloud_est_cost_usd']:.4f}")
|
||||
print(f" Session DB: {session_summary['total_sessions']} total | {GREEN}{session_summary['local_sessions']} local{RST} | {YELLOW}{session_summary['cloud_sessions']} cloud{RST}")
|
||||
print(f" Token est: {GREEN}{session_summary['local_est_tokens']} local{RST} | {YELLOW}{session_summary['cloud_est_tokens']} cloud{RST}")
|
||||
print(f" Est cloud cost: ${session_summary['cloud_est_cost_usd']:.4f}")
|
||||
else:
|
||||
print(f" {DIM}(no session-db stats available){RST}")
|
||||
|
||||
print(f"\n {BOLD}REVIEW QUEUE{RST}")
|
||||
print(f" {DIM}{'-' * 58}{RST}")
|
||||
if gitea["review_queue"]:
|
||||
for item in gitea["review_queue"][:8]:
|
||||
repo = item["_repo"].split("/", 1)[1]
|
||||
print(f" {repo:12s} #{item['number']:<4d} {item['title'][:42]}")
|
||||
else:
|
||||
print(f" {DIM}(clear){RST}")
|
||||
# ── ACTIVE LOOPS ──
|
||||
print(f"\n {BOLD}ACTIVE LOOPS{RST}")
|
||||
print(f" {DIM}{'-' * 55}{RST}")
|
||||
print(f" {CYAN}heartbeat_tick{RST} 10m hermes4:14b DECIDE phase")
|
||||
print(f" {DIM}model_health{RST} 5m (local check) Ollama ping")
|
||||
print(f" {DIM}gemini_worker{RST} 20m gemini-2.5-pro aider")
|
||||
print(f" {DIM}grok_worker{RST} 20m grok-3-fast opencode")
|
||||
print(f" {DIM}cross_review{RST} 30m gemini+grok PR review")
|
||||
|
||||
print(f"\n {BOLD}OPEN PRS / UNASSIGNED{RST}")
|
||||
print(f" {DIM}{'-' * 58}{RST}")
|
||||
print(f" open PRs: {len(gitea['prs'])}")
|
||||
print(f" unassigned issues: {gitea['unassigned']}")
|
||||
for pr in gitea["prs"][:6]:
|
||||
repo = pr["_repo"].split("/", 1)[1]
|
||||
print(f" PR {repo:10s} #{pr['number']:<4d} {pr['title'][:40]}")
|
||||
|
||||
print(f"\n{BOLD}{'=' * 72}{RST}")
|
||||
print(f"\n{BOLD}{'=' * 70}{RST}")
|
||||
print(f" {DIM}Refresh: timmy-dashboard --watch | History: --hours=N{RST}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
watch = "--watch" in sys.argv
|
||||
hours = 24
|
||||
for arg in sys.argv[1:]:
|
||||
if arg.startswith("--hours="):
|
||||
hours = int(arg.split("=", 1)[1])
|
||||
for a in sys.argv[1:]:
|
||||
if a.startswith("--hours="):
|
||||
hours = int(a.split("=")[1])
|
||||
|
||||
if watch:
|
||||
try:
|
||||
|
||||
@@ -8,7 +8,7 @@ set -uo pipefail
|
||||
LOG_DIR="$HOME/.hermes/logs"
|
||||
LOG="$LOG_DIR/timmy-orchestrator.log"
|
||||
PIDFILE="$LOG_DIR/timmy-orchestrator.pid"
|
||||
GITEA_URL="${GITEA_URL:-https://forge.alexanderwhitestone.com}"
|
||||
GITEA_URL="http://143.198.27.163:3000"
|
||||
GITEA_TOKEN=$(cat "$HOME/.hermes/gitea_token_vps" 2>/dev/null) # Timmy token, NOT rockachopa
|
||||
CYCLE_INTERVAL=300
|
||||
HERMES_TIMEOUT=180
|
||||
@@ -64,12 +64,8 @@ for p in json.load(sys.stdin):
|
||||
|
||||
echo "Claude workers: $(pgrep -f 'claude.*--print.*--dangerously' 2>/dev/null | wc -l | tr -d ' ')" >> "$state_dir/agent_status.txt"
|
||||
echo "Claude loop: $(pgrep -f 'claude-loop.sh' 2>/dev/null | wc -l | tr -d ' ') procs" >> "$state_dir/agent_status.txt"
|
||||
tail -50 "$LOG_DIR/claude-loop.log" 2>/dev/null | grep -c "SUCCESS" | xargs -I{} echo "Claude recent successes: {}" >> "$state_dir/agent_status.txt"
|
||||
tail -50 "$LOG_DIR/claude-loop.log" 2>/dev/null | grep -c "FAILED" | xargs -I{} echo "Claude recent failures: {}" >> "$state_dir/agent_status.txt"
|
||||
echo "Kimi heartbeat launchd: $(launchctl list 2>/dev/null | grep -c 'ai.timmy.kimi-heartbeat' | tr -d ' ') job" >> "$state_dir/agent_status.txt"
|
||||
tail -50 "/tmp/kimi-heartbeat.log" 2>/dev/null | grep -c "DISPATCHED:" | xargs -I{} echo "Kimi recent dispatches: {}" >> "$state_dir/agent_status.txt"
|
||||
tail -50 "/tmp/kimi-heartbeat.log" 2>/dev/null | grep -c "FAILED:" | xargs -I{} echo "Kimi recent failures: {}" >> "$state_dir/agent_status.txt"
|
||||
tail -1 "/tmp/kimi-heartbeat.log" 2>/dev/null | xargs -I{} echo "Kimi last event: {}" >> "$state_dir/agent_status.txt"
|
||||
tail -50 "$LOG_DIR/claude-loop.log" 2>/dev/null | grep -c "SUCCESS" | xargs -I{} echo "Recent successes: {}" >> "$state_dir/agent_status.txt"
|
||||
tail -50 "$LOG_DIR/claude-loop.log" 2>/dev/null | grep -c "FAILED" | xargs -I{} echo "Recent failures: {}" >> "$state_dir/agent_status.txt"
|
||||
|
||||
echo "$state_dir"
|
||||
}
|
||||
@@ -168,14 +164,7 @@ HEADER
|
||||
fi
|
||||
|
||||
echo "" >> "$prompt_file"
|
||||
cat >> "$prompt_file" <<'FOOTER'
|
||||
INSTRUCTIONS: For EACH PR above, do ONE of the following RIGHT NOW using your terminal tool:
|
||||
- Run the merge curl command if the diff looks good
|
||||
- Run the close curl command if it is a duplicate or garbage
|
||||
- Run the comment curl command only if there is a clear bug
|
||||
|
||||
IMPORTANT: Actually run the curl commands. Do not just describe what you would do. Finish means the PR world-state changed.
|
||||
FOOTER
|
||||
echo "Review each PR above. Execute curl commands for your decisions. Be brief." >> "$prompt_file"
|
||||
|
||||
local prompt_text
|
||||
prompt_text=$(cat "$prompt_file")
|
||||
|
||||
@@ -1,182 +1,284 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── Timmy Status Sidebar ───────────────────────────────────────────────
|
||||
# Compact current-state view for the local Hermes + Timmy workflow.
|
||||
# ── Timmy Loop Status Panel ────────────────────────────────────────────
|
||||
# Compact, info-dense sidebar for the tmux development loop.
|
||||
# Refreshes every 10s. Designed for ~40-col wide pane.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
STATE="$HOME/Timmy-Time-dashboard/.loop/state.json"
|
||||
REPO="$HOME/Timmy-Time-dashboard"
|
||||
TOKEN=$(cat ~/.hermes/gitea_token 2>/dev/null)
|
||||
API="http://143.198.27.163:3000/api/v1/repos/rockachopa/Timmy-time-dashboard"
|
||||
|
||||
resolve_gitea_url() {
|
||||
if [ -n "${GITEA_URL:-}" ]; then
|
||||
printf '%s\n' "${GITEA_URL%/}"
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$HOME/.hermes/gitea_api" ]; then
|
||||
python3 - "$HOME/.hermes/gitea_api" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
# ── Colors ──
|
||||
B='\033[1m' # bold
|
||||
D='\033[2m' # dim
|
||||
R='\033[0m' # reset
|
||||
G='\033[32m' # green
|
||||
Y='\033[33m' # yellow
|
||||
RD='\033[31m' # red
|
||||
C='\033[36m' # cyan
|
||||
M='\033[35m' # magenta
|
||||
W='\033[37m' # white
|
||||
BG='\033[42;30m' # green bg
|
||||
BY='\033[43;30m' # yellow bg
|
||||
BR='\033[41;37m' # red bg
|
||||
|
||||
raw = Path(sys.argv[1]).read_text().strip().rstrip("/")
|
||||
print(raw[:-7] if raw.endswith("/api/v1") else raw)
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$HOME/.config/gitea/base-url" ]; then
|
||||
tr -d '[:space:]' < "$HOME/.config/gitea/base-url"
|
||||
return 0
|
||||
fi
|
||||
echo "ERROR: set GITEA_URL or create ~/.hermes/gitea_api" >&2
|
||||
return 1
|
||||
}
|
||||
# How wide is our pane?
|
||||
COLS=$(tput cols 2>/dev/null || echo 40)
|
||||
|
||||
resolve_ops_token() {
|
||||
local token_file
|
||||
for token_file in \
|
||||
"$HOME/.config/gitea/timmy-token" \
|
||||
"$HOME/.hermes/gitea_token_vps" \
|
||||
"$HOME/.hermes/gitea_token_timmy"; do
|
||||
if [ -f "$token_file" ]; then
|
||||
tr -d '[:space:]' < "$token_file"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
GITEA_URL="$(resolve_gitea_url)"
|
||||
CORE_REPOS="${CORE_REPOS:-Timmy_Foundation/the-nexus Timmy_Foundation/timmy-home Timmy_Foundation/timmy-config Timmy_Foundation/hermes-agent}"
|
||||
TOKEN="$(resolve_ops_token || true)"
|
||||
[ -z "$TOKEN" ] && echo "WARN: no approved Timmy Gitea token found; status sidebar will use unauthenticated API calls" >&2
|
||||
|
||||
B='\033[1m'
|
||||
D='\033[2m'
|
||||
R='\033[0m'
|
||||
G='\033[32m'
|
||||
Y='\033[33m'
|
||||
RD='\033[31m'
|
||||
C='\033[36m'
|
||||
|
||||
COLS=$(tput cols 2>/dev/null || echo 48)
|
||||
hr() { printf "${D}"; printf '─%.0s' $(seq 1 "$COLS"); printf "${R}\n"; }
|
||||
|
||||
while true; do
|
||||
clear
|
||||
echo -e "${B}${C} TIMMY STATUS${R} ${D}$(date '+%H:%M:%S')${R}"
|
||||
|
||||
# ── Header ──
|
||||
echo -e "${B}${C} ⚙ TIMMY DEV LOOP${R} ${D}$(date '+%H:%M:%S')${R}"
|
||||
hr
|
||||
|
||||
python3 - "$HOME/.timmy" "$HOME/.hermes" <<'PY'
|
||||
# ── Loop State ──
|
||||
if [ -f "$STATE" ]; then
|
||||
eval "$(python3 -c "
|
||||
import json, sys
|
||||
with open('$STATE') as f: s = json.load(f)
|
||||
print(f'CYCLE={s.get(\"cycle\",\"?\")}')" 2>/dev/null)"
|
||||
STATUS=$(python3 -c "import json; print(json.load(open('$STATE'))['status'])" 2>/dev/null || echo "?")
|
||||
LAST_OK=$(python3 -c "
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
timmy = Path(sys.argv[1])
|
||||
hermes = Path(sys.argv[2])
|
||||
|
||||
last_tick = timmy / "heartbeat" / "last_tick.json"
|
||||
model_health = hermes / "model_health.json"
|
||||
checkpoint = timmy / "twitter-archive" / "checkpoint.json"
|
||||
|
||||
if last_tick.exists():
|
||||
try:
|
||||
tick = json.loads(last_tick.read_text())
|
||||
sev = tick.get("decision", {}).get("severity", "?")
|
||||
tick_id = tick.get("tick_id", "?")
|
||||
print(f" heartbeat {tick_id} severity={sev}")
|
||||
except Exception:
|
||||
print(" heartbeat unreadable")
|
||||
else:
|
||||
print(" heartbeat missing")
|
||||
|
||||
if model_health.exists():
|
||||
try:
|
||||
health = json.loads(model_health.read_text())
|
||||
provider_ok = health.get("api_responding")
|
||||
inference_ok = health.get("inference_ok")
|
||||
models = len(health.get("models_loaded", []) or [])
|
||||
print(f" model api={provider_ok} inference={inference_ok} models={models}")
|
||||
except Exception:
|
||||
print(" model unreadable")
|
||||
else:
|
||||
print(" model missing")
|
||||
|
||||
if checkpoint.exists():
|
||||
try:
|
||||
cp = json.loads(checkpoint.read_text())
|
||||
print(f" archive batches={cp.get('batches_completed', '?')} next={cp.get('next_offset', '?')} phase={cp.get('phase', '?')}")
|
||||
except Exception:
|
||||
print(" archive unreadable")
|
||||
else:
|
||||
print(" archive missing")
|
||||
PY
|
||||
|
||||
hr
|
||||
echo -e " ${B}freshness${R}"
|
||||
~/.hermes/bin/pipeline-freshness.sh 2>/dev/null | sed 's/^/ /' || echo -e " ${Y}unknown${R}"
|
||||
|
||||
hr
|
||||
echo -e " ${B}review queue${R}"
|
||||
python3 - "$GITEA_URL" "$TOKEN" "$CORE_REPOS" <<'PY'
|
||||
from datetime import datetime, timezone
|
||||
s = json.load(open('$STATE'))
|
||||
t = s.get('last_completed','')
|
||||
if t:
|
||||
dt = datetime.fromisoformat(t.replace('Z','+00:00'))
|
||||
delta = datetime.now(timezone.utc) - dt
|
||||
mins = int(delta.total_seconds() / 60)
|
||||
if mins < 60: print(f'{mins}m ago')
|
||||
else: print(f'{mins//60}h {mins%60}m ago')
|
||||
else: print('never')
|
||||
" 2>/dev/null || echo "?")
|
||||
CLOSED=$(python3 -c "import json; print(len(json.load(open('$STATE')).get('issues_closed',[])))" 2>/dev/null || echo 0)
|
||||
CREATED=$(python3 -c "import json; print(len(json.load(open('$STATE')).get('issues_created',[])))" 2>/dev/null || echo 0)
|
||||
ERRS=$(python3 -c "import json; print(len(json.load(open('$STATE')).get('errors',[])))" 2>/dev/null || echo 0)
|
||||
LAST_ISSUE=$(python3 -c "import json; print(json.load(open('$STATE')).get('last_issue','—'))" 2>/dev/null || echo "—")
|
||||
LAST_PR=$(python3 -c "import json; print(json.load(open('$STATE')).get('last_pr','—'))" 2>/dev/null || echo "—")
|
||||
TESTS=$(python3 -c "
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
s = json.load(open('$STATE'))
|
||||
t = s.get('test_results',{})
|
||||
if t:
|
||||
print(f\"{t.get('passed',0)} pass, {t.get('failed',0)} fail, {t.get('coverage','?')} cov\")
|
||||
else:
|
||||
print('no data')
|
||||
" 2>/dev/null || echo "no data")
|
||||
|
||||
base = sys.argv[1].rstrip("/")
|
||||
token = sys.argv[2]
|
||||
repos = sys.argv[3].split()
|
||||
headers = {"Authorization": f"token {token}"} if token else {}
|
||||
# Status badge
|
||||
case "$STATUS" in
|
||||
working) BADGE="${BY} WORKING ${R}" ;;
|
||||
idle) BADGE="${BG} IDLE ${R}" ;;
|
||||
error) BADGE="${BR} ERROR ${R}" ;;
|
||||
*) BADGE="${D} $STATUS ${R}" ;;
|
||||
esac
|
||||
|
||||
count = 0
|
||||
for repo in repos:
|
||||
try:
|
||||
req = urllib.request.Request(f"{base}/api/v1/repos/{repo}/issues?state=open&limit=50&type=pulls", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
items = json.loads(resp.read().decode())
|
||||
for item in items:
|
||||
assignees = [a.get("login", "") for a in (item.get("assignees") or [])]
|
||||
if any(name in assignees for name in ("Timmy", "allegro")):
|
||||
print(f" {repo.split('/',1)[1]:12s} #{item['number']:<4d} {item['title'][:28]}")
|
||||
count += 1
|
||||
if count >= 6:
|
||||
raise SystemExit
|
||||
except SystemExit:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if count == 0:
|
||||
print(" (clear)")
|
||||
PY
|
||||
echo -e " ${B}Status${R} $BADGE ${D}cycle${R} ${B}$CYCLE${R}"
|
||||
echo -e " ${B}Last OK${R} ${G}$LAST_OK${R} ${D}issue${R} #$LAST_ISSUE ${D}PR${R} #$LAST_PR"
|
||||
echo -e " ${G}✓${R} $CLOSED closed ${C}+${R} $CREATED created ${RD}✗${R} $ERRS errs"
|
||||
echo -e " ${D}Tests:${R} $TESTS"
|
||||
else
|
||||
echo -e " ${RD}No state file${R}"
|
||||
fi
|
||||
|
||||
hr
|
||||
echo -e " ${B}unassigned${R}"
|
||||
python3 - "$GITEA_URL" "$TOKEN" "$CORE_REPOS" <<'PY'
|
||||
|
||||
# ── Ollama Status ──
|
||||
echo -e " ${B}${M}◆ OLLAMA${R}"
|
||||
OLLAMA_PS=$(curl -s http://localhost:11434/api/ps 2>/dev/null)
|
||||
if [ -n "$OLLAMA_PS" ] && echo "$OLLAMA_PS" | python3 -c "import sys,json; json.load(sys.stdin)" &>/dev/null; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
data = json.loads('''$OLLAMA_PS''')
|
||||
models = data.get('models', [])
|
||||
if not models:
|
||||
print(' \033[2m(no models loaded)\033[0m')
|
||||
for m in models:
|
||||
name = m.get('name','?')
|
||||
vram = m.get('size_vram', 0) / 1e9
|
||||
exp = m.get('expires_at','')
|
||||
print(f' \033[32m●\033[0m {name} \033[2m{vram:.1f}GB VRAM\033[0m')
|
||||
" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RD}● offline${R}"
|
||||
fi
|
||||
|
||||
# ── Timmy Health ──
|
||||
TIMMY_HEALTH=$(curl -s --max-time 2 http://localhost:8000/health 2>/dev/null)
|
||||
if [ -n "$TIMMY_HEALTH" ]; then
|
||||
python3 -c "
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
base = sys.argv[1].rstrip("/")
|
||||
token = sys.argv[2]
|
||||
repos = sys.argv[3].split()
|
||||
headers = {"Authorization": f"token {token}"} if token else {}
|
||||
|
||||
count = 0
|
||||
for repo in repos:
|
||||
try:
|
||||
req = urllib.request.Request(f"{base}/api/v1/repos/{repo}/issues?state=open&limit=50&type=issues", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
items = json.loads(resp.read().decode())
|
||||
for item in items:
|
||||
if not item.get("assignees"):
|
||||
print(f" {repo.split('/',1)[1]:12s} #{item['number']:<4d} {item['title'][:28]}")
|
||||
count += 1
|
||||
if count >= 6:
|
||||
raise SystemExit
|
||||
except SystemExit:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if count == 0:
|
||||
print(" (none)")
|
||||
PY
|
||||
h = json.loads('''$TIMMY_HEALTH''')
|
||||
status = h.get('status','?')
|
||||
ollama = h.get('services',{}).get('ollama','?')
|
||||
model = h.get('llm_model','?')
|
||||
agent_st = list(h.get('agents',{}).values())[0].get('status','?') if h.get('agents') else '?'
|
||||
up = int(h.get('uptime_seconds',0))
|
||||
hrs, rem = divmod(up, 3600)
|
||||
mins = rem // 60
|
||||
print(f' \033[1m\033[35m◆ TIMMY DASHBOARD\033[0m')
|
||||
print(f' \033[32m●\033[0m {status} model={model}')
|
||||
print(f' \033[2magent={agent_st} ollama={ollama} up={hrs}h{mins}m\033[0m')
|
||||
" 2>/dev/null
|
||||
else
|
||||
echo -e " ${B}${M}◆ TIMMY DASHBOARD${R}"
|
||||
echo -e " ${RD}● unreachable${R}"
|
||||
fi
|
||||
|
||||
hr
|
||||
sleep 10
|
||||
|
||||
# ── Open Issues ──
|
||||
echo -e " ${B}${Y}▶ OPEN ISSUES${R}"
|
||||
if [ -n "$TOKEN" ]; then
|
||||
curl -s "${API}/issues?state=open&limit=10&sort=created&direction=desc" \
|
||||
-H "Authorization: token $TOKEN" 2>/dev/null | \
|
||||
python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
issues = json.load(sys.stdin)
|
||||
if not issues:
|
||||
print(' \033[2m(none)\033[0m')
|
||||
for i in issues[:10]:
|
||||
num = i['number']
|
||||
title = i['title'][:36]
|
||||
labels = ','.join(l['name'][:8] for l in i.get('labels',[]))
|
||||
lbl = f' \033[2m[{labels}]\033[0m' if labels else ''
|
||||
print(f' \033[33m#{num:<4d}\033[0m {title}{lbl}')
|
||||
if len(issues) > 10:
|
||||
print(f' \033[2m... +{len(issues)-10} more\033[0m')
|
||||
except: print(' \033[2m(fetch failed)\033[0m')
|
||||
" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RD}(no token)${R}"
|
||||
fi
|
||||
|
||||
# ── Open PRs ──
|
||||
echo -e " ${B}${G}▶ OPEN PRs${R}"
|
||||
if [ -n "$TOKEN" ]; then
|
||||
curl -s "${API}/pulls?state=open&limit=5" \
|
||||
-H "Authorization: token $TOKEN" 2>/dev/null | \
|
||||
python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
prs = json.load(sys.stdin)
|
||||
if not prs:
|
||||
print(' \033[2m(none)\033[0m')
|
||||
for p in prs[:5]:
|
||||
num = p['number']
|
||||
title = p['title'][:36]
|
||||
print(f' \033[32mPR #{num:<4d}\033[0m {title}')
|
||||
except: print(' \033[2m(fetch failed)\033[0m')
|
||||
" 2>/dev/null
|
||||
else
|
||||
echo -e " ${RD}(no token)${R}"
|
||||
fi
|
||||
|
||||
hr
|
||||
|
||||
# ── Git Log ──
|
||||
echo -e " ${B}${D}▶ RECENT COMMITS${R}"
|
||||
cd "$REPO" 2>/dev/null && git log --oneline --no-decorate -6 2>/dev/null | while read line; do
|
||||
HASH=$(echo "$line" | cut -c1-7)
|
||||
MSG=$(echo "$line" | cut -c9- | cut -c1-32)
|
||||
echo -e " ${C}${HASH}${R} ${D}${MSG}${R}"
|
||||
done
|
||||
|
||||
hr
|
||||
|
||||
# ── Claims ──
|
||||
CLAIMS_FILE="$REPO/.loop/claims.json"
|
||||
if [ -f "$CLAIMS_FILE" ]; then
|
||||
CLAIMS=$(python3 -c "
|
||||
import json
|
||||
with open('$CLAIMS_FILE') as f: c = json.load(f)
|
||||
active = [(k,v) for k,v in c.items() if v.get('status') == 'active']
|
||||
if active:
|
||||
for k,v in active:
|
||||
print(f' \033[33m⚡\033[0m #{k} claimed by {v.get(\"agent\",\"?\")[:12]}')
|
||||
else:
|
||||
print(' \033[2m(none active)\033[0m')
|
||||
" 2>/dev/null)
|
||||
if [ -n "$CLAIMS" ]; then
|
||||
echo -e " ${B}${Y}▶ CLAIMED${R}"
|
||||
echo "$CLAIMS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── System ──
|
||||
echo -e " ${B}${D}▶ SYSTEM${R}"
|
||||
# Disk
|
||||
DISK=$(df -h / 2>/dev/null | tail -1 | awk '{print $4 " free / " $2}')
|
||||
echo -e " ${D}Disk:${R} $DISK"
|
||||
# Memory (macOS)
|
||||
if command -v memory_pressure &>/dev/null; then
|
||||
MEM_PRESS=$(memory_pressure 2>/dev/null | grep "System-wide" | head -1 | sed 's/.*: //')
|
||||
echo -e " ${D}Mem:${R} $MEM_PRESS"
|
||||
elif [ -f /proc/meminfo ]; then
|
||||
MEM=$(awk '/MemAvailable/{printf "%.1fGB free", $2/1048576}' /proc/meminfo 2>/dev/null)
|
||||
echo -e " ${D}Mem:${R} $MEM"
|
||||
fi
|
||||
# CPU load
|
||||
LOAD=$(uptime | sed 's/.*averages: //' | cut -d',' -f1 | xargs)
|
||||
echo -e " ${D}Load:${R} $LOAD"
|
||||
|
||||
hr
|
||||
|
||||
# ── Notes from last cycle ──
|
||||
if [ -f "$STATE" ]; then
|
||||
NOTES=$(python3 -c "
|
||||
import json
|
||||
s = json.load(open('$STATE'))
|
||||
n = s.get('notes','')
|
||||
if n:
|
||||
lines = n[:150]
|
||||
if len(n) > 150: lines += '...'
|
||||
print(lines)
|
||||
" 2>/dev/null)
|
||||
if [ -n "$NOTES" ]; then
|
||||
echo -e " ${B}${D}▶ LAST CYCLE NOTE${R}"
|
||||
echo -e " ${D}${NOTES}${R}"
|
||||
hr
|
||||
fi
|
||||
|
||||
# Timmy observations
|
||||
TIMMY_OBS=$(python3 -c "
|
||||
import json
|
||||
s = json.load(open('$STATE'))
|
||||
obs = s.get('timmy_observations','')
|
||||
if obs:
|
||||
lines = obs[:120]
|
||||
if len(obs) > 120: lines += '...'
|
||||
print(lines)
|
||||
" 2>/dev/null)
|
||||
if [ -n "$TIMMY_OBS" ]; then
|
||||
echo -e " ${B}${M}▶ TIMMY SAYS${R}"
|
||||
echo -e " ${D}${TIMMY_OBS}${R}"
|
||||
hr
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Watchdog: restart loop if it died ──────────────────────────────
|
||||
LOOP_LOCK="/tmp/timmy-loop.lock"
|
||||
if [ -f "$LOOP_LOCK" ]; then
|
||||
LOOP_PID=$(cat "$LOOP_LOCK" 2>/dev/null)
|
||||
if ! kill -0 "$LOOP_PID" 2>/dev/null; then
|
||||
echo -e " ${BR} ⚠ LOOP DIED — RESTARTING ${R}"
|
||||
rm -f "$LOOP_LOCK"
|
||||
tmux send-keys -t "dev:2.1" "bash ~/.hermes/bin/timmy-loop.sh" Enter 2>/dev/null
|
||||
fi
|
||||
else
|
||||
# No lock file at all — loop never started or was killed
|
||||
if ! pgrep -f "timmy-loop.sh" >/dev/null 2>&1; then
|
||||
echo -e " ${BR} ⚠ LOOP NOT RUNNING — STARTING ${R}"
|
||||
tmux send-keys -t "dev:2.1" "bash ~/.hermes/bin/timmy-loop.sh" Enter 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e " ${D}↻ 8s${R}"
|
||||
sleep 8
|
||||
done
|
||||
|
||||
29
config.yaml
29
config.yaml
@@ -20,12 +20,7 @@ terminal:
|
||||
modal_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||
daytona_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||
container_cpu: 1
|
||||
container_embeddings:
|
||||
provider: ollama
|
||||
model: nomic-embed-text
|
||||
base_url: http://localhost:11434/v1
|
||||
|
||||
memory: 5120
|
||||
container_memory: 5120
|
||||
container_disk: 51200
|
||||
container_persistent: true
|
||||
docker_volumes: []
|
||||
@@ -39,26 +34,21 @@ checkpoints:
|
||||
enabled: true
|
||||
max_snapshots: 50
|
||||
compression:
|
||||
enabled: true
|
||||
enabled: false
|
||||
threshold: 0.5
|
||||
target_ratio: 0.2
|
||||
protect_last_n: 20
|
||||
summary_model: ''
|
||||
summary_provider: ''
|
||||
summary_base_url: ''
|
||||
synthesis_model:
|
||||
provider: custom
|
||||
model: llama3:70b
|
||||
base_url: http://localhost:8081/v1
|
||||
|
||||
smart_model_routing:
|
||||
enabled: true
|
||||
max_simple_chars: 400
|
||||
max_simple_words: 75
|
||||
enabled: false
|
||||
max_simple_chars: 200
|
||||
max_simple_words: 35
|
||||
cheap_model:
|
||||
provider: 'ollama'
|
||||
model: 'gemma2:2b'
|
||||
base_url: 'http://localhost:11434/v1'
|
||||
provider: ''
|
||||
model: ''
|
||||
base_url: ''
|
||||
api_key: ''
|
||||
auxiliary:
|
||||
vision:
|
||||
@@ -175,9 +165,6 @@ command_allowlist: []
|
||||
quick_commands: {}
|
||||
personalities: {}
|
||||
security:
|
||||
sovereign_audit: true
|
||||
no_phone_home: true
|
||||
|
||||
redact_secrets: true
|
||||
tirith_enabled: true
|
||||
tirith_path: tirith
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# Caddy configuration for Conduit Matrix homeserver
|
||||
# Location: /etc/caddy/conf.d/matrix.conf (imported by main Caddyfile)
|
||||
# Reference: docs/matrix-fleet-comms/README.md
|
||||
|
||||
matrix.timmy.foundation {
|
||||
# Reverse proxy to Conduit
|
||||
reverse_proxy localhost:8448 {
|
||||
# Headers for WebSocket upgrade (client sync)
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote}
|
||||
header_up X-Forwarded-For {remote}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-Frame-Options DENY
|
||||
X-Content-Type-Options nosniff
|
||||
X-XSS-Protection "1; mode=block"
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||
}
|
||||
|
||||
# Enable compression
|
||||
encode gzip zstd
|
||||
|
||||
# Let's Encrypt automatic TLS
|
||||
tls {
|
||||
# Email for renewal notifications
|
||||
# Uncomment and set: email admin@timmy.foundation
|
||||
}
|
||||
|
||||
# Logging
|
||||
log {
|
||||
output file /var/log/caddy/matrix-access.log {
|
||||
roll_size 100mb
|
||||
roll_keep 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Well-known delegation for Matrix federation
|
||||
# Allows other servers to discover our homeserver
|
||||
timmy.foundation {
|
||||
handle /.well-known/matrix/server {
|
||||
header Content-Type application/json
|
||||
respond `{"m.server": "matrix.timmy.foundation:443"}`
|
||||
}
|
||||
|
||||
handle /.well-known/matrix/client {
|
||||
header Content-Type application/json
|
||||
header Access-Control-Allow-Origin *
|
||||
respond `{"m.homeserver": {"base_url": "https://matrix.timmy.foundation"}}`
|
||||
}
|
||||
|
||||
# Redirect root to Element Web or documentation
|
||||
redir / https://matrix.timmy.foundation permanent
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
[Unit]
|
||||
Description=Conduit Matrix Homeserver
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=conduit
|
||||
Group=conduit
|
||||
|
||||
WorkingDirectory=/opt/conduit
|
||||
ExecStart=/opt/conduit/conduit
|
||||
|
||||
# Restart on failure
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Resource limits
|
||||
LimitNOFILE=65536
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/conduit/data /opt/conduit/logs
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
|
||||
# Environment
|
||||
Environment="RUST_LOG=info"
|
||||
Environment="CONDUIT_CONFIG=/opt/conduit/conduit.toml"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,81 +0,0 @@
|
||||
# Conduit Homeserver Configuration
|
||||
# Location: /opt/conduit/conduit.toml
|
||||
# Reference: docs/matrix-fleet-comms/README.md
|
||||
|
||||
[global]
|
||||
# The server_name is the canonical name of your homeserver.
|
||||
# It must match the domain in your MXIDs (e.g., @user:timmy.foundation)
|
||||
server_name = "timmy.foundation"
|
||||
|
||||
# Database path - SQLite for simplicity, PostgreSQL available if needed
|
||||
database_path = "/opt/conduit/data/conduit.db"
|
||||
|
||||
# Port to listen on
|
||||
port = 8448
|
||||
|
||||
# Maximum request size (20MB for file uploads)
|
||||
max_request_size = 20000000
|
||||
|
||||
# Allow guests to register (false = closed registration)
|
||||
allow_registration = false
|
||||
|
||||
# Allow guests to join rooms without registering
|
||||
allow_guest_registration = false
|
||||
|
||||
# Require authentication for profile requests
|
||||
authenticate_profile_requests = true
|
||||
|
||||
[registration]
|
||||
# Closed registration - admin creates accounts manually
|
||||
enabled = false
|
||||
|
||||
[federation]
|
||||
# Enable federation to communicate with other Matrix homeservers
|
||||
enabled = true
|
||||
|
||||
# Servers to block from federation
|
||||
# disabled_servers = ["bad.actor.com", "spammer.org"]
|
||||
disabled_servers = []
|
||||
|
||||
# Enable server discovery via .well-known
|
||||
well_known = true
|
||||
|
||||
[media]
|
||||
# Maximum upload size per file (50MB)
|
||||
max_file_size = 50000000
|
||||
|
||||
# Maximum total media cache size (100MB)
|
||||
max_media_size = 100000000
|
||||
|
||||
# Directory for media storage
|
||||
media_path = "/opt/conduit/data/media"
|
||||
|
||||
[retention]
|
||||
# Enable message retention policies
|
||||
enabled = true
|
||||
|
||||
# Default retention for rooms without explicit policy
|
||||
default_room_retention = "30d"
|
||||
|
||||
# Minimum allowed retention period
|
||||
min_retention = "1d"
|
||||
|
||||
# Maximum allowed retention period (null = no limit)
|
||||
max_retention = null
|
||||
|
||||
[logging]
|
||||
# Log level: error, warn, info, debug, trace
|
||||
level = "info"
|
||||
|
||||
# Log to file
|
||||
log_file = "/opt/conduit/logs/conduit.log"
|
||||
|
||||
[security]
|
||||
# Require transaction IDs for idempotent requests
|
||||
require_transaction_ids = true
|
||||
|
||||
# IP range blacklist for incoming federation
|
||||
# ip_range_blacklist = ["10.0.0.0/8", "172.16.0.0/12"]
|
||||
|
||||
# Allow incoming federation from these IP ranges only (empty = allow all)
|
||||
# ip_range_whitelist = []
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Conduit Matrix Homeserver Installation Script
|
||||
# Location: Run this on target VPS after cloning timmy-config
|
||||
# Reference: docs/matrix-fleet-comms/README.md
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
CONDUIT_VERSION="0.8.0" # Check https://gitlab.com/famedly/conduit/-/releases
|
||||
CONDUIT_DIR="/opt/conduit"
|
||||
DATA_DIR="$CONDUIT_DIR/data"
|
||||
LOGS_DIR="$CONDUIT_DIR/logs"
|
||||
SCRIPTS_DIR="$CONDUIT_DIR/scripts"
|
||||
CONDUIT_USER="conduit"
|
||||
|
||||
echo "========================================"
|
||||
echo "Conduit Matrix Homeserver Installer"
|
||||
echo "Target: $CONDUIT_DIR"
|
||||
echo "Version: $CONDUIT_VERSION"
|
||||
echo "========================================"
|
||||
echo
|
||||
|
||||
# Check root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Error: Please run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create conduit user
|
||||
echo "[1/8] Creating conduit user..."
|
||||
if ! id "$CONDUIT_USER" &>/dev/null; then
|
||||
useradd -r -s /bin/false -d "$CONDUIT_DIR" "$CONDUIT_USER"
|
||||
echo " Created user: $CONDUIT_USER"
|
||||
else
|
||||
echo " User exists: $CONDUIT_USER"
|
||||
fi
|
||||
|
||||
# Create directories
|
||||
echo "[2/8] Creating directories..."
|
||||
mkdir -p "$CONDUIT_DIR" "$DATA_DIR" "$LOGS_DIR" "$SCRIPTS_DIR"
|
||||
chown -R "$CONDUIT_USER:$CONDUIT_USER" "$CONDUIT_DIR"
|
||||
|
||||
# Download Conduit
|
||||
echo "[3/8] Downloading Conduit v${CONDUIT_VERSION}..."
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
CONDUIT_ARCH="x86_64-unknown-linux-gnu"
|
||||
;;
|
||||
aarch64)
|
||||
CONDUIT_ARCH="aarch64-unknown-linux-gnu"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unsupported architecture: $ARCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
CONDUIT_URL="https://gitlab.com/famedly/conduit/-/releases/download/v${CONDUIT_VERSION}/conduit-${CONDUIT_ARCH}"
|
||||
|
||||
curl -L -o "$CONDUIT_DIR/conduit" "$CONDUIT_URL"
|
||||
chmod +x "$CONDUIT_DIR/conduit"
|
||||
chown "$CONDUIT_USER:$CONDUIT_USER" "$CONDUIT_DIR/conduit"
|
||||
echo " Downloaded: $CONDUIT_DIR/conduit"
|
||||
|
||||
# Install configuration
|
||||
echo "[4/8] Installing configuration..."
|
||||
if [ -f "conduit.toml" ]; then
|
||||
cp conduit.toml "$CONDUIT_DIR/conduit.toml"
|
||||
chown "$CONDUIT_USER:$CONDUIT_USER" "$CONDUIT_DIR/conduit.toml"
|
||||
echo " Installed: $CONDUIT_DIR/conduit.toml"
|
||||
else
|
||||
echo " Warning: conduit.toml not found in current directory"
|
||||
fi
|
||||
|
||||
# Install systemd service
|
||||
echo "[5/8] Installing systemd service..."
|
||||
if [ -f "conduit.service" ]; then
|
||||
cp conduit.service /etc/systemd/system/conduit.service
|
||||
systemctl daemon-reload
|
||||
echo " Installed: /etc/systemd/system/conduit.service"
|
||||
else
|
||||
echo " Warning: conduit.service not found in current directory"
|
||||
fi
|
||||
|
||||
# Install scripts
|
||||
echo "[6/8] Installing operational scripts..."
|
||||
if [ -d "scripts" ]; then
|
||||
cp scripts/*.sh "$SCRIPTS_DIR/"
|
||||
chmod +x "$SCRIPTS_DIR"/*.sh
|
||||
chown -R "$CONDUIT_USER:$CONDUIT_USER" "$SCRIPTS_DIR"
|
||||
echo " Installed scripts to $SCRIPTS_DIR"
|
||||
fi
|
||||
|
||||
# Create backup directory
|
||||
echo "[7/8] Creating backup directory..."
|
||||
mkdir -p /backups/conduit
|
||||
chown "$CONDUIT_USER:$CONDUIT_USER" /backups/conduit
|
||||
|
||||
# Setup cron for backups
|
||||
echo "[8/8] Setting up backup cron job..."
|
||||
if [ -f "$SCRIPTS_DIR/backup.sh" ]; then
|
||||
(crontab -l 2>/dev/null || true; echo "0 3 * * * $SCRIPTS_DIR/backup.sh >> $LOGS_DIR/backup.log 2>&1") | crontab -
|
||||
echo " Backup cron job added (3 AM daily)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "========================================"
|
||||
echo "Installation Complete!"
|
||||
echo "========================================"
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo " 1. Configure DNS: matrix.timmy.foundation -> $(hostname -I | awk '{print $1}')"
|
||||
echo " 2. Configure Caddy: cp Caddyfile /etc/caddy/conf.d/matrix.conf"
|
||||
echo " 3. Start Conduit: systemctl start conduit"
|
||||
echo " 4. Check health: $SCRIPTS_DIR/health.sh"
|
||||
echo " 5. Create admin account (see README.md)"
|
||||
echo
|
||||
echo "Logs: $LOGS_DIR/"
|
||||
echo "Data: $DATA_DIR/"
|
||||
echo "Config: $CONDUIT_DIR/conduit.toml"
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Conduit Matrix Homeserver Backup Script
|
||||
# Location: /opt/conduit/scripts/backup.sh
|
||||
# Reference: docs/matrix-fleet-comms/README.md
|
||||
# Run via cron: 0 3 * * * /opt/conduit/scripts/backup.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
BACKUP_BASE_DIR="/backups/conduit"
|
||||
DATA_DIR="/opt/conduit/data"
|
||||
CONFIG_FILE="/opt/conduit/conduit.toml"
|
||||
RETENTION_DAYS=7
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="$BACKUP_BASE_DIR/$TIMESTAMP"
|
||||
|
||||
# Ensure backup directory exists
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
}
|
||||
|
||||
log "Starting Conduit backup..."
|
||||
|
||||
# Check if Conduit is running
|
||||
if systemctl is-active --quiet conduit; then
|
||||
log "Stopping Conduit for consistent backup..."
|
||||
systemctl stop conduit
|
||||
RESTART_NEEDED=true
|
||||
else
|
||||
log "Conduit already stopped"
|
||||
RESTART_NEEDED=false
|
||||
fi
|
||||
|
||||
# Backup database
|
||||
if [ -f "$DATA_DIR/conduit.db" ]; then
|
||||
log "Backing up database..."
|
||||
cp "$DATA_DIR/conduit.db" "$BACKUP_DIR/"
|
||||
sqlite3 "$BACKUP_DIR/conduit.db" "VACUUM;"
|
||||
else
|
||||
log "WARNING: Database not found at $DATA_DIR/conduit.db"
|
||||
fi
|
||||
|
||||
# Backup configuration
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
log "Backing up configuration..."
|
||||
cp "$CONFIG_FILE" "$BACKUP_DIR/"
|
||||
fi
|
||||
|
||||
# Backup media (if exists)
|
||||
if [ -d "$DATA_DIR/media" ]; then
|
||||
log "Backing up media files..."
|
||||
cp -r "$DATA_DIR/media" "$BACKUP_DIR/"
|
||||
fi
|
||||
|
||||
# Restart Conduit if it was running
|
||||
if [ "$RESTART_NEEDED" = true ]; then
|
||||
log "Restarting Conduit..."
|
||||
systemctl start conduit
|
||||
fi
|
||||
|
||||
# Create compressed archive
|
||||
log "Creating compressed archive..."
|
||||
cd "$BACKUP_BASE_DIR"
|
||||
tar czf "$TIMESTAMP.tar.gz" -C "$BACKUP_DIR" .
|
||||
rm -rf "$BACKUP_DIR"
|
||||
|
||||
ARCHIVE_SIZE=$(du -h "$BACKUP_BASE_DIR/$TIMESTAMP.tar.gz" | cut -f1)
|
||||
log "Backup complete: $TIMESTAMP.tar.gz ($ARCHIVE_SIZE)"
|
||||
|
||||
# Upload to S3 (uncomment and configure when ready)
|
||||
# if command -v aws &> /dev/null; then
|
||||
# log "Uploading to S3..."
|
||||
# aws s3 cp "$BACKUP_BASE_DIR/$TIMESTAMP.tar.gz" s3://timmy-backups/conduit/
|
||||
# fi
|
||||
|
||||
# Cleanup old backups
|
||||
log "Cleaning up backups older than $RETENTION_DAYS days..."
|
||||
find "$BACKUP_BASE_DIR" -name "*.tar.gz" -mtime +$RETENTION_DAYS -delete
|
||||
|
||||
log "Backup process complete"
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Conduit Matrix Homeserver Health Check
|
||||
# Location: /opt/conduit/scripts/health.sh
|
||||
# Reference: docs/matrix-fleet-comms/README.md
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HOMESERVER_URL="https://matrix.timmy.foundation"
|
||||
ADMIN_EMAIL="admin@timmy.foundation"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $*"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $*"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $*"
|
||||
}
|
||||
|
||||
# Check if Conduit process is running
|
||||
check_process() {
|
||||
if systemctl is-active --quiet conduit; then
|
||||
log_info "Conduit service is running"
|
||||
return 0
|
||||
else
|
||||
log_error "Conduit service is not running"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check Matrix client-server API
|
||||
check_client_api() {
|
||||
local response
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" "$HOMESERVER_URL/_matrix/client/versions" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$response" = "200" ]; then
|
||||
log_info "Client-server API is responding (HTTP 200)"
|
||||
return 0
|
||||
else
|
||||
log_error "Client-server API returned HTTP $response"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check Matrix versions endpoint
|
||||
check_versions() {
|
||||
local versions
|
||||
versions=$(curl -s "$HOMESERVER_URL/_matrix/client/versions" 2>/dev/null | jq -r '.versions | join(", ")' 2>/dev/null || echo "unknown")
|
||||
|
||||
if [ "$versions" != "unknown" ]; then
|
||||
log_info "Supported Matrix versions: $versions"
|
||||
return 0
|
||||
else
|
||||
log_warn "Could not determine Matrix versions"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check federation (self-test)
|
||||
check_federation() {
|
||||
local response
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" "https://federationtester.matrix.org/api/report?server_name=timmy.foundation" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$response" = "200" ]; then
|
||||
log_info "Federation tester can reach server"
|
||||
return 0
|
||||
else
|
||||
log_warn "Federation tester returned HTTP $response (may be DNS propagation)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check disk space
|
||||
check_disk_space() {
|
||||
local usage
|
||||
usage=$(df /opt/conduit/data | tail -1 | awk '{print $5}' | sed 's/%//')
|
||||
|
||||
if [ "$usage" -lt 80 ]; then
|
||||
log_info "Disk usage: ${usage}% (healthy)"
|
||||
return 0
|
||||
elif [ "$usage" -lt 90 ]; then
|
||||
log_warn "Disk usage: ${usage}% (consider cleanup)"
|
||||
return 1
|
||||
else
|
||||
log_error "Disk usage: ${usage}% (critical!)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check database size
|
||||
check_database() {
|
||||
local db_path="/opt/conduit/data/conduit.db"
|
||||
|
||||
if [ -f "$db_path" ]; then
|
||||
local size
|
||||
size=$(du -h "$db_path" | cut -f1)
|
||||
log_info "Database size: $size"
|
||||
return 0
|
||||
else
|
||||
log_warn "Database file not found at $db_path"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main health check
|
||||
main() {
|
||||
echo "========================================"
|
||||
echo "Conduit Matrix Homeserver Health Check"
|
||||
echo "Server: $HOMESERVER_URL"
|
||||
echo "Time: $(date)"
|
||||
echo "========================================"
|
||||
echo
|
||||
|
||||
local exit_code=0
|
||||
|
||||
check_process || exit_code=1
|
||||
check_client_api || exit_code=1
|
||||
check_versions || true # Non-critical
|
||||
check_federation || true # Non-critical during initial setup
|
||||
check_disk_space || exit_code=1
|
||||
check_database || true # Non-critical
|
||||
|
||||
echo
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
log_info "All critical checks passed ✓"
|
||||
else
|
||||
log_error "Some critical checks failed ✗"
|
||||
fi
|
||||
|
||||
return $exit_code
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,30 +0,0 @@
|
||||
matrix.example.com {
|
||||
handle /.well-known/matrix/server {
|
||||
header Content-Type application/json
|
||||
respond `{"m.server": "matrix.example.com:443"}`
|
||||
}
|
||||
|
||||
handle /.well-known/matrix/client {
|
||||
header Content-Type application/json
|
||||
respond `{"m.homeserver": {"base_url": "https://matrix.example.com"}}`
|
||||
}
|
||||
|
||||
handle_path /_matrix/* {
|
||||
reverse_proxy localhost:6167
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/matrix.log {
|
||||
roll_size 10MB
|
||||
roll_keep 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matrix-federation.example.com:8448 {
|
||||
reverse_proxy localhost:6167
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
# Matrix/Conduit Host Prerequisites
|
||||
|
||||
## Target Host Specification
|
||||
|
||||
| Resource | Minimum | Fleet Scale |
|
||||
|----------|---------|-------------|
|
||||
| CPU | 2 cores | 4+ cores |
|
||||
| RAM | 2 GB | 8 GB |
|
||||
| Storage | 20 GB SSD | 100+ GB SSD |
|
||||
|
||||
## DNS Requirements
|
||||
|
||||
| Type | Host | Value |
|
||||
|------|------|-------|
|
||||
| A/AAAA | matrix.example.com | Server IP |
|
||||
| SRV | _matrix._tcp | 10 5 8448 matrix.example.com |
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Purpose | Access |
|
||||
|------|---------|--------|
|
||||
| 443 | Client-Server API | Public |
|
||||
| 8448 | Server-Server (federation) | Public |
|
||||
| 6167 | Conduit internal | Localhost only |
|
||||
|
||||
## Software
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo apt install caddy
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Valid domain with DNS control
|
||||
- [ ] Docker host with 4GB RAM
|
||||
- [ ] Caddy reverse proxy configured
|
||||
- [ ] Backup destination configured
|
||||
@@ -1,32 +0,0 @@
|
||||
[global]
|
||||
server_name = "fleet.example.com"
|
||||
address = "0.0.0.0"
|
||||
port = 6167
|
||||
|
||||
[database]
|
||||
backend = "sqlite"
|
||||
path = "/var/lib/matrix-conduit"
|
||||
|
||||
[registration]
|
||||
enabled = false
|
||||
token = "CHANGE_THIS_TO_32_HEX_CHARS"
|
||||
allow_registration_without_token = false
|
||||
|
||||
[federation]
|
||||
enabled = true
|
||||
enable_open_federation = true
|
||||
trusted_servers = []
|
||||
|
||||
[media]
|
||||
max_file_size = 10_485_760
|
||||
max_thumbnail_size = 5_242_880
|
||||
|
||||
[presence]
|
||||
enabled = true
|
||||
update_interval = 300_000
|
||||
|
||||
[log]
|
||||
level = "info"
|
||||
|
||||
[admin]
|
||||
admins = ["@admin:fleet.example.com"]
|
||||
@@ -1,48 +0,0 @@
|
||||
version: "3.8"
|
||||
# Conduit Matrix homeserver - Sovereign fleet communication
|
||||
# Deploy: docker-compose up -d
|
||||
# Requirements: Docker 20.10+, valid DNS A/AAAA and SRV records
|
||||
|
||||
services:
|
||||
conduit:
|
||||
image: docker.io/matrixconduit/matrix-conduit:v0.7.0
|
||||
container_name: conduit
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./conduit.toml:/etc/conduit/conduit.toml:ro
|
||||
- conduit-data:/var/lib/matrix-conduit
|
||||
environment:
|
||||
CONDUIT_SERVER_NAME: ${MATRIX_SERVER_NAME:?Required}
|
||||
CONDUIT_DATABASE_BACKEND: sqlite
|
||||
CONDUIT_DATABASE_PATH: /var/lib/matrix-conduit
|
||||
CONDUIT_PORT: 6167
|
||||
CONDUIT_MAX_REQUEST_SIZE: 20_000_000
|
||||
networks:
|
||||
- matrix
|
||||
|
||||
element:
|
||||
image: vectorim/element-web:v1.11.59
|
||||
container_name: element-web
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./element-config.json:/app/config.json:ro
|
||||
networks:
|
||||
- matrix
|
||||
|
||||
backup:
|
||||
image: rclone/rclone:latest
|
||||
container_name: conduit-backup
|
||||
volumes:
|
||||
- conduit-data:/data:ro
|
||||
- ./backup-scripts:/scripts:ro
|
||||
entrypoint: /scripts/backup.sh
|
||||
profiles: ["backup"]
|
||||
networks:
|
||||
- matrix
|
||||
|
||||
networks:
|
||||
matrix:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
conduit-data:
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"default_server_config": {
|
||||
"m.homeserver": {
|
||||
"base_url": "https://matrix.example.com",
|
||||
"server_name": "example.com"
|
||||
}
|
||||
},
|
||||
"brand": "Timmy Fleet",
|
||||
"default_theme": "dark",
|
||||
"features": {
|
||||
"feature_spaces": true,
|
||||
"feature_voice_rooms": true
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
MATRIX_SERVER_NAME=${1:-"fleet.example.com"}
|
||||
ADMIN_USER=${2:-"admin"}
|
||||
BOT_USERS=("bilbo" "ezra" "allegro" "bezalel" "gemini" "timmy")
|
||||
|
||||
echo "=== Fleet Matrix Bootstrap ==="
|
||||
echo "Server: $MATRIX_SERVER_NAME"
|
||||
|
||||
REG_TOKEN=$(openssl rand -hex 32)
|
||||
echo "$REG_TOKEN" > .registration_token
|
||||
|
||||
cat > docker-compose.override.yml << EOF
|
||||
version: "3.8"
|
||||
services:
|
||||
conduit:
|
||||
environment:
|
||||
CONDUIT_SERVER_NAME: $MATRIX_SERVER_NAME
|
||||
CONDUIT_REGISTRATION_TOKEN: $REG_TOKEN
|
||||
EOF
|
||||
|
||||
ADMIN_PW=$(openssl rand -base64 24)
|
||||
cat > admin-register.json << EOF
|
||||
{"username": "$ADMIN_USER", "password": "$ADMIN_PW", "admin": true}
|
||||
EOF
|
||||
|
||||
mkdir -p bot-tokens
|
||||
for bot in "${BOT_USERS[@]}"; do
|
||||
BOT_PW=$(openssl rand -base64 24)
|
||||
echo "{"username": "$bot", "password": "$BOT_PW"}" > "bot-tokens/${bot}.json"
|
||||
done
|
||||
|
||||
cat > room-topology.yaml << 'EOF'
|
||||
spaces:
|
||||
fleet-command:
|
||||
name: "Fleet Command"
|
||||
rooms:
|
||||
- {name: "📢 Announcements", encrypted: false}
|
||||
- {name: "⚡ Operations", encrypted: true}
|
||||
- {name: "🔮 Intelligence", encrypted: true}
|
||||
- {name: "🛠️ Infrastructure", encrypted: true}
|
||||
EOF
|
||||
|
||||
echo "Bootstrap complete. Check admin-password.txt and bot-tokens/"
|
||||
echo "Admin password: $ADMIN_PW"
|
||||
@@ -1,262 +0,0 @@
|
||||
# 🔥 BURN MODE CONTINUITY — Primary Targets Engaged
|
||||
|
||||
**Date**: 2026-04-05
|
||||
**Burn Directive**: timmy-config #183, #166, the-nexus #830
|
||||
**Executor**: Ezra (Archivist)
|
||||
**Status**: ✅ **ALL TARGETS SCAFFOLDED — CONTINUITY PRESERVED**
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Three primary targets have been assessed, scaffolded, and connected into a coherent fleet architecture. Each issue has transitioned from aspiration/fuzzy epic to executable implementation plan.
|
||||
|
||||
| Target | Repo | Previous State | Current State | Scaffold Size |
|
||||
|--------|------|----------------|---------------|---------------|
|
||||
| #183 | timmy-config | Aspirational scaffold | ✅ Complete deployment kit | 12+ files, 2 dirs |
|
||||
| #166 | timmy-config | Fuzzy epic | ✅ Executable with blockers isolated | Architecture doc (8KB) |
|
||||
| #830 | the-nexus | Feature request | ✅ 5-phase production scaffold | 5 bins + 3 docs (~70KB) |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Target Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ FLEET COMMUNICATION LAYERS │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ HUMAN-TO-FLEET FLEET-INTERNAL INTEL │
|
||||
│ ┌───────────────┐ ┌───────────────┐ ┌────────┐│
|
||||
│ │ Matrix │◀──────────────▶│ Nostr │ │ Deep ││
|
||||
│ │ #166 │ #173 unify │ #174 │ │ Dive ││
|
||||
│ │ (scaffolded)│ │ (deployed) │ │ #830 ││
|
||||
│ └───────────────┘ └───────────────┘ │(ready) ││
|
||||
│ │ │ └───┬────┘│
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ALEXANDER (Operator Surface) │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Target #1: timmy-config #183
|
||||
|
||||
**Title**: [COMMS] Produce Matrix/Conduit deployment scaffold and host prerequisites
|
||||
**Status**: CLOSED ✅ (but continuity verified)
|
||||
**Issue State**: All acceptance criteria met
|
||||
|
||||
### Deliverables Verified
|
||||
|
||||
| Criterion | Status | Location |
|
||||
|-----------|--------|----------|
|
||||
| Repo-visible deployment scaffold | ✅ | `infra/matrix/` + `deploy/conduit/` |
|
||||
| Host/port/reverse-proxy explicit | ✅ | `docs/matrix-fleet-comms/README.md` |
|
||||
| Missing prerequisites named | ✅ | `prerequisites.md` — 6 named blockers |
|
||||
| Lowers #166 from fuzzy to executable | ✅ | Phase-gated plan with estimates |
|
||||
|
||||
### Artifact Inventory
|
||||
|
||||
**`infra/matrix/`** (Docker path):
|
||||
- `README.md` — Entry point
|
||||
- `prerequisites.md` — Host options, 6 explicit blockers
|
||||
- `docker-compose.yml` — Container orchestration
|
||||
- `conduit.toml` — Homeserver configuration
|
||||
- `deploy-matrix.sh` — One-command deployment
|
||||
- `.env.example` — Configuration template
|
||||
- `caddy/` — Reverse proxy configs
|
||||
|
||||
**`deploy/conduit/`** (Binary path):
|
||||
- `conduit.toml` — Production config
|
||||
- `conduit.service` — systemd definition
|
||||
- `Caddyfile` — Reverse proxy
|
||||
- `install.sh` — One-command installer
|
||||
- `scripts/` — Backup, health check helpers
|
||||
|
||||
**`docs/matrix-fleet-comms/README.md`** (Architecture):
|
||||
- 3 Architecture Decision Records (ADRs)
|
||||
- Complete port allocation table
|
||||
- 4-phase implementation plan with estimates
|
||||
- Operational runbooks (backup, health, account creation)
|
||||
- Cross-issue linkages
|
||||
|
||||
### Architecture Decisions
|
||||
|
||||
1. **ADR-1**: Conduit selected over Synapse/Dendrite (low resource, SQLite support)
|
||||
2. **ADR-2**: Gitea VPS host initially (consolidated ops)
|
||||
3. **ADR-3**: Full federation enabled (requires TLS + public DNS)
|
||||
|
||||
### Blocking Prerequisites
|
||||
|
||||
| # | Prerequisite | Authority | Effort |
|
||||
|---|--------------|-----------|--------|
|
||||
| 1 | Target host selected (Hermes vs Allegro vs new) | Alexander/admin | 15 min |
|
||||
| 2 | Domain assigned: `matrix.timmy.foundation` | Alexander/admin | 15 min |
|
||||
| 3 | DNS A record created | Alexander/admin | 15 min |
|
||||
| 4 | DNS SRV record for federation | Alexander/admin | 15 min |
|
||||
| 5 | Firewall: TCP 8448 open | Host admin | 5 min |
|
||||
| 6 | SSL strategy confirmed | Caddy auto | 0 min |
|
||||
|
||||
---
|
||||
|
||||
## Target #2: timmy-config #166
|
||||
|
||||
**Title**: [COMMS] Stand up Matrix/Conduit for human-to-fleet encrypted communication
|
||||
**Status**: OPEN 🟡
|
||||
**Issue State**: Scaffold complete, execution blocked on #187
|
||||
|
||||
### Evolution: Fuzzy Epic → Executable
|
||||
|
||||
| Phase | Before | After |
|
||||
|-------|--------|-------|
|
||||
| Idea | "We should use Matrix" | Concrete deployment path |
|
||||
| Scaffold | None | 12+ files, fully documented |
|
||||
| Blockers | Unknown | Explicitly named in #187 |
|
||||
| Next Steps | Undefined | Phase-gated with estimates |
|
||||
|
||||
### Acceptance Criteria Progress
|
||||
|
||||
| Criterion | Status | Blocker |
|
||||
|-----------|--------|---------|
|
||||
| Deploy Conduit homeserver | 🟡 Ready | #187 DNS decision |
|
||||
| Create fleet rooms/channels | 🟡 Ready | Post-deployment |
|
||||
| Encrypted operator messaging | 🟡 Ready | Post-accounts |
|
||||
| Telegram→Matrix cutover | ⏳ Pending | Post-verification |
|
||||
| Alexander can message fleet | ⏳ Pending | Post-deployment |
|
||||
| Messages encrypted/persistent | ⏳ Pending | Post-deployment |
|
||||
| Telegram not only surface | ⏳ Pending | Migration timeline TBD |
|
||||
|
||||
### Handoff from #183
|
||||
|
||||
**#183 delivered:**
|
||||
- ✅ Deployable configuration files
|
||||
- ✅ Executable installation scripts
|
||||
- ✅ Operational runbooks
|
||||
- ✅ Phase-gated implementation plan
|
||||
- ✅ Bootstrap account/room specifications
|
||||
|
||||
**#166 needs:**
|
||||
- DNS decisions (#187)
|
||||
- Execution (run install scripts)
|
||||
- Testing (verify E2E encryption)
|
||||
|
||||
---
|
||||
|
||||
## Target #3: the-nexus #830
|
||||
|
||||
**Title**: [EPIC] Deep Dive: Sovereign NotebookLM + Daily AI Intelligence Briefing
|
||||
**Status**: OPEN ✅
|
||||
**Issue State**: Production-ready scaffold, 5 phases complete
|
||||
|
||||
### 5-Phase Scaffold
|
||||
|
||||
| Phase | Component | File | Lines | Purpose |
|
||||
|-------|-----------|------|-------|---------|
|
||||
| 1 | Aggregate | `bin/deepdive_aggregator.py` | ~95 | arXiv RSS, lab blog ingestion |
|
||||
| 2 | Filter | `bin/deepdive_filter.py` | NA | Included in aggregator/orchestrator |
|
||||
| 3 | Synthesize | `bin/deepdive_synthesis.py` | ~190 | LLM briefing generation |
|
||||
| 4 | Audio | `bin/deepdive_tts.py` | ~240 | Multi-adapter TTS (Piper/ElevenLabs) |
|
||||
| 5 | Deliver | `bin/deepdive_delivery.py` | ~210 | Telegram voice/text delivery |
|
||||
| — | Orchestrate | `bin/deepdive_orchestrator.py` | ~320 | Pipeline coordination, cron |
|
||||
|
||||
**Total**: ~1,055 lines of executable Python
|
||||
|
||||
### Documentation Inventory
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `docs/DEEPSDIVE_ARCHITECTURE.md` | ~88 | 5-phase spec, data flows |
|
||||
| `docs/DEEPSDIVE_EXECUTION.md` | ~NA | Runbook, troubleshooting |
|
||||
| `docs/DEEPSDIVE_QUICKSTART.md` | ~NA | Fast-path to first briefing |
|
||||
|
||||
### Acceptance Criteria — All Ready
|
||||
|
||||
| Criterion | Issue Req | Status | Evidence |
|
||||
|-----------|-----------|--------|----------|
|
||||
| Zero manual copy-paste | Mandatory | ✅ | Cron automation |
|
||||
| Daily 6 AM delivery | Mandatory | ✅ | Configurable schedule |
|
||||
| arXiv (cs.AI/cs.CL/cs.LG) | Mandatory | ✅ | RSS fetcher |
|
||||
| Lab blog coverage | Mandatory | ✅ | OpenAI/Anthropic/DeepMind |
|
||||
| Relevance filtering | Mandatory | ✅ | Embedding + keyword |
|
||||
| Written briefing | Mandatory | ✅ | Synthesis engine |
|
||||
| Audio via TTS | Mandatory | ✅ | Piper + ElevenLabs adapters |
|
||||
| Telegram delivery | Mandatory | ✅ | Voice message support |
|
||||
| On-demand trigger | Mandatory | ✅ | CLI flag in orchestrator |
|
||||
|
||||
### Sovereignty Compliance
|
||||
|
||||
| Dependency | Local Option | Cloud Fallback |
|
||||
|------------|--------------|----------------|
|
||||
| TTS | Piper (offline) | ElevenLabs API |
|
||||
| LLM | Hermes (local) | Provider routing |
|
||||
| Scheduler | Cron (system) | Manual trigger |
|
||||
| Storage | Filesystem | No DB required |
|
||||
|
||||
---
|
||||
|
||||
## Interconnection Map
|
||||
|
||||
### #830 → #166
|
||||
Deep Dive intelligence briefings can target Matrix rooms as delivery channel (alternative to Telegram voice).
|
||||
|
||||
### #830 → #173
|
||||
Deep Dive is the **content layer** in the comms unification stack — what gets said, via which channel.
|
||||
|
||||
### #166 → #173
|
||||
Matrix is the **human-to-fleet channel** — sovereign, encrypted, persistent.
|
||||
|
||||
### #166 → #174
|
||||
Matrix and Nostr operate in parallel — Matrix for rich messaging, Nostr for lightweight broadcast. Both are sovereign.
|
||||
|
||||
### #183 → #166
|
||||
Scaffold enables execution. Child enables parent.
|
||||
|
||||
---
|
||||
|
||||
## Decision Authority Summary
|
||||
|
||||
| Decision | Location | Authority | Current State |
|
||||
|----------|----------|-----------|---------------|
|
||||
| Matrix deployment timing | #187 | Alexander/admin | ⏳ DNS pending |
|
||||
| Deep Dive TTS preference | #830 | Alexander | ⏳ Local vs API |
|
||||
| Matrix/Nostr priority | #173 | Alexander | ⏳ Active discussion |
|
||||
|
||||
---
|
||||
|
||||
## Burn Mode Artifacts Created
|
||||
|
||||
### Visible Comments (SITREPs)
|
||||
- #183: Continuity verification SITREP
|
||||
- #166: Execution bridge SITREP
|
||||
- #830: Architecture assessment SITREP
|
||||
|
||||
### Documentation
|
||||
- `docs/matrix-fleet-comms/README.md` — Matrix architecture (8KB)
|
||||
- `docs/BURN_MODE_CONTINUITY_2026-04-05.md` — This document
|
||||
|
||||
### Code Scaffold
|
||||
- 5 Deep Dive Python modules (~1,055 lines)
|
||||
- 3 Deep Dive documentation files
|
||||
- 12+ Matrix/Conduit deployment files
|
||||
|
||||
---
|
||||
|
||||
## Sign-off
|
||||
|
||||
All three primary targets have been:
|
||||
1. ✅ **Read and assessed** — Current state documented
|
||||
2. ✅ **SITREP comments posted** — Visible continuity trail
|
||||
3. ✅ **Scaffold verified/extended** — Strongest proof committed
|
||||
|
||||
**#183**: Acceptance criteria satisfied, scaffold in repo truth
|
||||
**#166**: Executable path defined, blockers isolated to #187
|
||||
**#830**: Production-ready scaffold, all 5 phases implemented
|
||||
|
||||
Continuity preserved. Architecture connected. Decisions forward.
|
||||
|
||||
— Ezra, Archivist
|
||||
2026-04-05
|
||||
@@ -1,112 +0,0 @@
|
||||
# Canonical Index: Matrix/Conduit Deployment Artifacts
|
||||
|
||||
> **Issues**: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166) (Execution Epic) | [#183](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/183) (Scaffold — Closed) | [#187](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/187) (Decision Blocker)
|
||||
> **Created**: 2026-04-05 by Ezra (burn mode)
|
||||
> **Purpose**: Single source of truth mapping every Matrix/Conduit artifact in `timmy-config`. Stops scatter, eliminates "which file is real?" ambiguity.
|
||||
|
||||
---
|
||||
|
||||
## Status at a Glance
|
||||
|
||||
| Milestone | State | Evidence |
|
||||
|-----------|-------|----------|
|
||||
| Deployment scaffold | ✅ Complete | `infra/matrix/` (15 files) |
|
||||
| Operator runbook | ✅ Complete | `docs/matrix-fleet-comms/` |
|
||||
| Host readiness script | ✅ Complete | `infra/matrix/host-readiness-check.sh` |
|
||||
| Target host selected | ⚠️ **BLOCKED** | Pending [#187](../issues/187) |
|
||||
| Live deployment | ⚠️ **BLOCKED** | Waiting on host + domain + proxy decision |
|
||||
|
||||
---
|
||||
|
||||
## Authoritative Paths (Read/Edit These)
|
||||
|
||||
### 1. Deployment Scaffold — `infra/matrix/`
|
||||
This is the **primary executable scaffold**. If you are deploying Conduit, start here and nowhere else.
|
||||
|
||||
| File | Purpose | Lines/Size |
|
||||
|------|---------|------------|
|
||||
| `README.md` | Entry point, quick-start, architecture diagram | 3,275 bytes |
|
||||
| `prerequisites.md` | 6 concrete blocking items pre-deployment | 2,690 bytes |
|
||||
| `docker-compose.yml` | Conduit + Postgres + optional Element Web | 1,427 bytes |
|
||||
| `conduit.toml` | Base Conduit configuration template | 1,498 bytes |
|
||||
| `.env.example` | Environment secrets template | 1,861 bytes |
|
||||
| `deploy-matrix.sh` | One-command deployment orchestrator | 3,388 bytes |
|
||||
| `host-readiness-check.sh` | Pre-flight validation script | 3,321 bytes |
|
||||
| `caddy/Caddyfile` | Reverse-proxy rules for Caddy users | 1,612 bytes |
|
||||
| `conduit/conduit.toml` | Advanced Conduit config (federation-ready) | 2,280 bytes |
|
||||
| `conduit/docker-compose.yml` | Extended compose with replication | 1,469 bytes |
|
||||
| `scripts/deploy-conduit.sh` | Low-level Conduit installer | 5,488 bytes |
|
||||
| `docs/RUNBOOK.md` | Day-2 operations (backup, upgrade, health) | 3,412 bytes |
|
||||
|
||||
**Command for next deployer:**
|
||||
```bash
|
||||
cd infra/matrix
|
||||
./host-readiness-check.sh # 1. verify target
|
||||
# Edit conduit.toml + .env
|
||||
./deploy-matrix.sh # 2. deploy
|
||||
```
|
||||
|
||||
### 2. Operator Runbook — `docs/matrix-fleet-comms/`
|
||||
Human-facing narrative for Alexander and operators.
|
||||
|
||||
| File | Purpose | Size |
|
||||
|------|---------|------|
|
||||
| `README.md` | Fleet communications authority map + onboarding | 7,845 bytes |
|
||||
| `DEPLOYMENT_RUNBOOK.md` | Step-by-step operator playbook | 4,484 bytes |
|
||||
|
||||
---
|
||||
|
||||
## Legacy / Duplicate Paths (Do Not Edit — Reference Only)
|
||||
|
||||
The following directories contain **overlapping or superseded** material. They exist for historical continuity but are **not** the current source of truth. If you edit these, you create divergence.
|
||||
|
||||
| Path | Status | Note |
|
||||
|------|--------|------|
|
||||
| `deploy/matrix/` | 🔴 Superseded by `infra/matrix/` | Smaller subset; lacks host-readiness check |
|
||||
| `deploy/conduit/` | 🔴 Superseded by `infra/matrix/scripts/` | `install.sh` + `health.sh` — good ideas ported into `infra/matrix/` |
|
||||
| `matrix/` | 🔴 Superseded by `infra/matrix/` | Early docker-compose experiment |
|
||||
| `docs/matrix-conduit/DEPLOYMENT.md` | 🔴 Superseded by `docs/matrix-fleet-comms/DEPLOYMENT_RUNBOOK.md` | |
|
||||
| `docs/matrix-deployment.md` | 🔴 Superseded by `infra/matrix/prerequisites.md` + runbook | |
|
||||
| `scaffold/matrix-conduit/` | 🔴 Superseded by `infra/matrix/` | Bootstrap + nginx configs; nginx approach not chosen |
|
||||
|
||||
> **House Rule**: New Matrix work must branch from `infra/matrix/` or `docs/matrix-fleet-comms/`. If a legacy file needs resurrection, migrate it into the authoritative tree and delete the old reference.
|
||||
|
||||
---
|
||||
|
||||
## Decision Blocker: #187
|
||||
|
||||
**#166 cannot proceed until [#187](../issues/187) is resolved.**
|
||||
|
||||
Ezra has produced a dedicated decision framework to make this a 5-minute choice rather than an architectural debate:
|
||||
|
||||
📄 **See**: [`docs/DECISION_FRAMEWORK_187.md`](DECISION_FRAMEWORK_187.md)
|
||||
|
||||
The framework recommends:
|
||||
- **Host**: Timmy-Home bare metal (primary) or existing VPS
|
||||
- **Domain**: `matrix.timmytime.net` (or sub-domain of existing fleet domain)
|
||||
- **Proxy**: Caddy (simplest) or extend existing Traefik
|
||||
- **TLS**: Let's Encrypt ACME HTTP-01 (port 80/443 open)
|
||||
|
||||
---
|
||||
|
||||
## Next Agent Checklist
|
||||
|
||||
If you are picking up #166:
|
||||
|
||||
1. [ ] Read `infra/matrix/README.md`
|
||||
2. [ ] Read `docs/DECISION_FRAMEWORK_187.md`
|
||||
3. [ ] Confirm resolution of #187 (host/domain/proxy chosen)
|
||||
4. [ ] Run `infra/matrix/host-readiness-check.sh` on target host
|
||||
5. [ ] Cut a feature branch; edit `infra/matrix/conduit.toml` and `.env`
|
||||
6. [ ] Execute `infra/matrix/deploy-matrix.sh`
|
||||
7. [ ] Verify federation with Matrix.org test server
|
||||
8. [ ] Create operator room; invite Alexander
|
||||
9. [ ] Post SITREP on #166 with proof-of-deployment
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2026-04-05 | Canonical index created; authoritative paths declared | Ezra |
|
||||
@@ -1,126 +0,0 @@
|
||||
# Decision Framework: Matrix Host, Domain, and Proxy (#187)
|
||||
|
||||
> **Issue**: [#187](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/187) — Decide Matrix host, domain, and proxy prerequisites so #166 can deploy
|
||||
> **Parent**: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166) — Stand up Matrix/Conduit for human-to-fleet encrypted communication
|
||||
> **Created**: 2026-04-05 by Ezra (burn mode)
|
||||
> **Purpose**: Turn the #187 blocker into a checkbox. One recommendation, two alternatives, explicit trade-offs.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Recommended Path (Option A)**
|
||||
- **Host**: Existing Hermes VPS (`143.198.27.163` — already hosts Gitea, Bezalel, Allegro-Primus)
|
||||
- **Domain**: `matrix.timmytime.net`
|
||||
- **Proxy**: Caddy (dedicated to Matrix, auto-TLS, auto-federation headers)
|
||||
- **TLS**: Let's Encrypt via Caddy (ports 80/443/8448 exposed)
|
||||
|
||||
**Why**: It reuses a known sovereign host, keeps comms infrastructure under one roof, and Caddy is the simplest path to working federation.
|
||||
|
||||
---
|
||||
|
||||
## Option A — Recommended: Hermes VPS + Caddy
|
||||
|
||||
### Host: Hermes VPS (`143.198.27.163`)
|
||||
| Factor | Assessment |
|
||||
|--------|------------|
|
||||
| Sovereignty | ✅ Full root, no platform lock-in |
|
||||
| Uptime | ✅ 24/7 VPS, better than home broadband |
|
||||
| Existing load | ⚠️ Gitea + wizard gateways running; Conduit is lightweight (~200MB RAM) |
|
||||
| Cost | ✅ Sunk cost — no new provider needed |
|
||||
|
||||
### Domain: `matrix.timmytime.net`
|
||||
| Factor | Assessment |
|
||||
|--------|------------|
|
||||
| DNS control | ✅ `timmytime.net` is already under fleet control |
|
||||
| Federation SRV | Simple A record + optional `_matrix._tcp` SRV record |
|
||||
| TLS cert | Caddy auto-provisions for this subdomain |
|
||||
|
||||
### Proxy: Caddy
|
||||
| Factor | Assessment |
|
||||
|--------|------------|
|
||||
| TLS automation | ✅ Built-in ACME, auto-renewal |
|
||||
| Federation headers | ✅ Easy `.well-known` + SRV support |
|
||||
| Config complexity | ✅ Single `Caddyfile`, no label magic |
|
||||
| Traefik conflict | None — Caddy binds its own ports directly |
|
||||
|
||||
### Required Actions for Option A
|
||||
1. Delegate `matrix.timmytime.net` A record → `143.198.27.163`
|
||||
2. Open VPS firewall: `80`, `443`, `8448` inbound
|
||||
3. Clone `timmy-config` to VPS
|
||||
4. `cd infra/matrix && ./host-readiness-check.sh`
|
||||
5. Edit `conduit.toml` → `server_name = "matrix.timmytime.net"`
|
||||
6. Run `./deploy-matrix.sh`
|
||||
|
||||
---
|
||||
|
||||
## Option B — Conservative: Timmy-Home Bare Metal + Traefik
|
||||
|
||||
| Factor | Assessment |
|
||||
|--------|------------|
|
||||
| Host | Timmy-Home Mac Mini / server |
|
||||
| Domain | `matrix.home.timmytime.net` |
|
||||
| Proxy | Existing Traefik instance |
|
||||
| Pros | Full physical sovereignty; no cloud dependency |
|
||||
| Cons | Home IP dynamic (requires DDNS); port-forwarding dependency; power/network outages |
|
||||
| Verdict | 🔶 Viable backup, not primary |
|
||||
|
||||
---
|
||||
|
||||
## Option C — Fast but Costly: DigitalOcean Droplet
|
||||
|
||||
| Factor | Assessment |
|
||||
|--------|------------|
|
||||
| Host | Fresh `$6-12/mo` Ubuntu droplet |
|
||||
| Domain | `matrix.timmytime.net` |
|
||||
| Proxy | Caddy or Nginx |
|
||||
| Pros | Clean slate, static IP, easy snapshot backups |
|
||||
| Cons | New monthly bill, another host to patch/monitor |
|
||||
| Verdict | 🔶 Overkill while Hermes VPS has headroom |
|
||||
|
||||
---
|
||||
|
||||
## Comparative Matrix
|
||||
|
||||
| Criterion | Option A (Recommended) | Option B (Home) | Option C (DO) |
|
||||
|-----------|------------------------|-----------------|---------------|
|
||||
| Speed to deploy | 🟢 Fast | 🟡 Medium | 🟡 Medium |
|
||||
| Sovereignty | 🟢 High | 🟢 Highest | 🟢 High |
|
||||
| Reliability | 🟢 Good | 🔴 Variable | 🟢 Good |
|
||||
| Cost | 🟢 $0 extra | 🟢 $0 extra | 🔴 +$6-12/mo |
|
||||
| Operational load | 🟢 Low | 🟡 Medium | 🔴 Higher |
|
||||
| Federation ease | 🟢 Caddy simple | 🟡 Traefik doable | 🟢 Caddy simple |
|
||||
|
||||
---
|
||||
|
||||
## Port & TLS Requirements (All Options)
|
||||
|
||||
| Port | Direction | Purpose | Notes |
|
||||
|------|-----------|---------|-------|
|
||||
| `80` | Inbound | ACME challenge + `.well-known` redirect | Must be reachable from internet |
|
||||
| `443` | Inbound | Client HTTPS (Element, mobile apps) | Caddy/Traefik terminates TLS |
|
||||
| `8448` | Inbound | Federation (server-to-server) | Matrix spec default; can proxy from 443 but 8448 is safest |
|
||||
| `6167` | Internal | Conduit replication (optional) | Not needed for single-node |
|
||||
|
||||
**TLS Path**: Let's Encrypt HTTP-01 challenge (no manual cert purchase).
|
||||
|
||||
---
|
||||
|
||||
## The Actual Checklist to Close #187
|
||||
|
||||
- [ ] **Alexander selects one option** (A recommended)
|
||||
- [ ] Domain/subdomain is chosen and confirmed available
|
||||
- [ ] Target host IP is known and firewall ports are confirmed open
|
||||
- [ ] Reverse proxy choice is locked
|
||||
- [ ] #166 is updated with the decision
|
||||
- [ ] Allegro or Ezra is tasked with live deployment
|
||||
|
||||
**If you check these 6 boxes, #166 is unblocked.**
|
||||
|
||||
---
|
||||
|
||||
## Suggested Comment to Resolve #187
|
||||
|
||||
> "Go with Option A. Domain: `matrix.timmytime.net`. Host: Hermes VPS. Proxy: Caddy. @ezra or @allegro deploy when ready."
|
||||
|
||||
That is all that is required.
|
||||
@@ -1,199 +0,0 @@
|
||||
# Communication Authority Map
|
||||
|
||||
Status: doctrine for #175
|
||||
Parent epic: #173
|
||||
Related issues:
|
||||
- #165 NATS internal bus
|
||||
- #166 Matrix/Conduit operator communication
|
||||
- #174 Nostr/Nostur operator edge
|
||||
- #163 sovereign keypairs / identity
|
||||
|
||||
## Why this exists
|
||||
|
||||
We do not want communication scattered across lost channels.
|
||||
|
||||
The system may expose multiple communication surfaces, but work authority must not fragment with them.
|
||||
A message can arrive from several places.
|
||||
Task truth cannot.
|
||||
|
||||
This document defines which surface is authoritative for what, how operator messages enter the system, and how Matrix plus Nostr/Nostur can coexist without creating parallel hidden queues.
|
||||
|
||||
## Core principle
|
||||
|
||||
One message may have many transport surfaces.
|
||||
One piece of work gets one execution truth.
|
||||
|
||||
That execution truth is Gitea.
|
||||
|
||||
If a command or request matters to the fleet, it must become a visible Gitea artifact:
|
||||
- issue
|
||||
- issue comment
|
||||
- PR comment
|
||||
- assignee/label change
|
||||
- linked proof artifact
|
||||
|
||||
No chat surface is allowed to become a second hidden task database.
|
||||
|
||||
## Authority layers
|
||||
|
||||
### 1. Gitea — execution truth
|
||||
|
||||
Authoritative for:
|
||||
- task state
|
||||
- issue ownership
|
||||
- PR state
|
||||
- review state
|
||||
- visible decision trail
|
||||
- proof links and artifacts
|
||||
|
||||
Rules:
|
||||
- if work is actionable, it must exist in Gitea
|
||||
- if state changes, the change must be reflected in Gitea
|
||||
- if chat and Gitea disagree, Gitea wins until corrected visibly
|
||||
|
||||
### 2. NATS — internal agent bus
|
||||
|
||||
Authoritative for:
|
||||
- fast machine-to-machine transport only
|
||||
|
||||
Not authoritative for:
|
||||
- task truth
|
||||
- operator truth
|
||||
- final queue state
|
||||
|
||||
Rules:
|
||||
- NATS moves signals, not ownership truth
|
||||
- durable work still lands in Gitea
|
||||
- request/reply and heartbeats may live here without becoming the task system
|
||||
|
||||
### 3. Matrix/Conduit — primary private operator command surface
|
||||
|
||||
Authoritative for:
|
||||
- private human-to-fleet conversation
|
||||
- rich command context
|
||||
- operational chat that should not be public
|
||||
|
||||
Not authoritative for:
|
||||
- final task state
|
||||
- hidden work queues
|
||||
|
||||
Rules:
|
||||
- Matrix is the primary private operator room
|
||||
- any command that creates or mutates work must be mirrored into Gitea
|
||||
- Matrix can discuss work privately, but cannot be the only place where the work exists
|
||||
- if a command remains chat-only, it is advisory, not execution truth
|
||||
|
||||
### 4. Nostr/Nostur — sovereign operator edge
|
||||
|
||||
Authoritative for:
|
||||
- operator identity-linked ingress
|
||||
- portable/mobile sovereign access
|
||||
- public or semi-public notices if intentionally used that way
|
||||
- emergency or lightweight operator signaling
|
||||
|
||||
Not authoritative for:
|
||||
- internal fleet transport
|
||||
- hidden task state
|
||||
- long-lived queue truth
|
||||
|
||||
Rules:
|
||||
- Nostur is a real operator layer, not a toy side-channel
|
||||
- commands received via Nostr/Nostur must be normalized into Gitea before they are considered active work
|
||||
- if private discussion is needed after Nostr ingress, continue in Matrix while keeping Gitea as visible task truth
|
||||
- Nostr/Nostur should preserve sovereign identity advantages without becoming an alternate invisible work tracker
|
||||
|
||||
### 5. Telegram — legacy bridge only
|
||||
|
||||
Authoritative for:
|
||||
- nothing new
|
||||
|
||||
Rules:
|
||||
- Telegram is legacy/bridge until sunset
|
||||
- no new doctrine should make Telegram the permanent backbone
|
||||
- if Telegram receives work during migration, the work still gets mirrored into Gitea and then into the current primary surfaces
|
||||
|
||||
## Ingress rules
|
||||
|
||||
### Rule A: every actionable operator message gets normalized
|
||||
|
||||
If an operator message from Matrix, Nostr/Nostur, or Telegram asks for real work, the system must do one of the following:
|
||||
- create a new Gitea issue
|
||||
- append to the correct existing issue as a comment
|
||||
- explicitly reject the message as non-actionable
|
||||
- route it to a coordinator for clarification before any work begins
|
||||
|
||||
### Rule B: no hidden queue mutation
|
||||
|
||||
Refreshing a chat room, reading a relay event, or polling a transport must not silently create work.
|
||||
The transition from chat to work must be explicit and visible.
|
||||
|
||||
### Rule C: one work item, many mirrors allowed
|
||||
|
||||
A message may be mirrored across:
|
||||
- Matrix
|
||||
- Nostr/Nostur
|
||||
- Telegram during migration
|
||||
- local notifications
|
||||
|
||||
But all mirrors must point back to the same Gitea work object.
|
||||
|
||||
### Rule D: coordinator-first survives transport changes
|
||||
|
||||
Timmy and Allegro remain the coordinators.
|
||||
Changing the transport does not remove their authority to:
|
||||
- classify urgency
|
||||
- decide routing
|
||||
- demand proof
|
||||
- collapse duplicates
|
||||
- escalate only what Alexander should actually see
|
||||
|
||||
## Recommended operator experience
|
||||
|
||||
### Matrix
|
||||
Use for:
|
||||
- primary private conversation with the fleet
|
||||
- ongoing task discussion
|
||||
- handoff and clarification
|
||||
- richer context than a short mobile note
|
||||
|
||||
### Nostur
|
||||
Use for:
|
||||
- sovereign mobile/operator ingress
|
||||
- identity-linked quick commands
|
||||
- lightweight acknowledgements
|
||||
- emergency input when Matrix is not the best surface
|
||||
|
||||
Working rule:
|
||||
- Nostur gets you into the system
|
||||
- Matrix carries the private conversation
|
||||
- Gitea holds the work truth
|
||||
|
||||
## Anti-scatter policy
|
||||
|
||||
Forbidden patterns:
|
||||
- a task exists only in a Matrix room
|
||||
- a task exists only in a Nostr DM or note
|
||||
- a Telegram thread contains work nobody copied into Gitea
|
||||
- different channels describe the same work with different owners or statuses
|
||||
- an agent acts on Nostr/Matrix chatter without a visible work object when the task is non-trivial
|
||||
|
||||
Required pattern:
|
||||
- every meaningful task gets one canonical Gitea object
|
||||
- all channels point at or mirror that object
|
||||
- coordinators keep channel drift collapsed, not multiplied
|
||||
|
||||
## Minimum implementation path
|
||||
|
||||
1. Matrix/Conduit becomes the primary private operator surface (#166)
|
||||
2. Nostr/Nostur becomes the sovereign operator edge (#174)
|
||||
3. NATS remains internal bus only (#165)
|
||||
4. every ingress path writes or links to Gitea execution truth
|
||||
5. Telegram is reduced to bridge/legacy during migration
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Matrix, Nostr/Nostur, NATS, Gitea, and Telegram each have an explicit role
|
||||
- [ ] Gitea is named as the sole execution-truth surface
|
||||
- [ ] Nostur is included as a legitimate operator layer, not ignored
|
||||
- [ ] Nostur/Matrix ingress rules explicitly forbid shadow task state
|
||||
- [ ] this doctrine makes it harder for work to get lost across channels
|
||||
@@ -1,136 +0,0 @@
|
||||
# Matrix/Conduit Deployment Guide
|
||||
|
||||
Executable scaffold for standing up a sovereign Matrix homeserver as the human-to-fleet command surface.
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Alexander │────▶│ Nginx Proxy │────▶│ Conduit │
|
||||
│ (Element/Web) │ │ 443 / 8448 │ │ Homeserver │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ SQLite/Postgres│
|
||||
│ (state/media) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | How to Verify | Status |
|
||||
|-------------|---------------|--------|
|
||||
| VPS with 2GB+ RAM | `free -h` | ⬜ |
|
||||
| Static IP address | `curl ifconfig.me` | ⬜ |
|
||||
| Domain with A record | `dig matrix.fleet.tld` | ⬜ |
|
||||
| Ports 443/8448 open | `sudo ss -tlnp | grep -E "443|8448"` | ⬜ |
|
||||
| TLS certificate (Let's Encrypt) | `sudo certbot certificates` | ⬜ |
|
||||
| Docker + docker-compose | `docker --version` | ⬜ |
|
||||
|
||||
## Quickstart
|
||||
|
||||
### 1. Host Preparation
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt update && sudo apt install -y docker.io docker-compose-plugin nginx certbot
|
||||
|
||||
# Open ports
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw allow 8448/tcp
|
||||
```
|
||||
|
||||
### 2. DNS Configuration
|
||||
```
|
||||
# A record
|
||||
matrix.fleet.tld. A <YOUR_SERVER_IP>
|
||||
|
||||
# SRV for federation (optional but recommended)
|
||||
_matrix._tcp.fleet.tld. SRV 10 0 8448 matrix.fleet.tld.
|
||||
```
|
||||
|
||||
### 3. TLS Certificate
|
||||
```bash
|
||||
sudo certbot certonly --standalone -d matrix.fleet.tld
|
||||
```
|
||||
|
||||
### 4. Deploy Conduit
|
||||
```bash
|
||||
# Edit conduit.toml: set server_name to your domain
|
||||
nano conduit.toml
|
||||
|
||||
# Start stack
|
||||
docker compose up -d
|
||||
|
||||
# Verify
|
||||
docker logs -f conduit-homeserver
|
||||
```
|
||||
|
||||
### 5. Nginx Configuration
|
||||
```bash
|
||||
sudo cp nginx-matrix.conf /etc/nginx/sites-available/matrix
|
||||
sudo ln -s /etc/nginx/sites-available/matrix /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 6. Bootstrap Accounts
|
||||
1. Open Element at `https://matrix.fleet.tld`
|
||||
2. Register admin account first (while `allow_registration = true`)
|
||||
3. Set admin in `conduit.toml`, restart
|
||||
4. Disable registration after setup
|
||||
|
||||
### 7. Fleet Rooms
|
||||
```bash
|
||||
# Fill ACCESS_TOKEN in bootstrap.sh
|
||||
curl -X POST "https://matrix.fleet.tld/_matrix/client/r0/login" \
|
||||
-d '{"type":"m.login.password","user":"alexander","password":"YOUR_PASS"}'
|
||||
|
||||
# Run bootstrap
|
||||
chmod +x bootstrap.sh
|
||||
./bootstrap.sh
|
||||
```
|
||||
|
||||
## Federation Verification
|
||||
|
||||
```bash
|
||||
# Check server discovery
|
||||
curl https://matrix.fleet.tld/.well-known/matrix/server
|
||||
curl https://matrix.fleet.tld/.well-known/matrix/client
|
||||
|
||||
# Check federation
|
||||
curl https://matrix.fleet.tld:8448/_matrix/key/v2/server
|
||||
```
|
||||
|
||||
## Telegram Bridge (Future)
|
||||
|
||||
To bridge Telegram groups to Matrix:
|
||||
|
||||
```yaml
|
||||
# Add to docker-compose.yml
|
||||
telegram-bridge:
|
||||
image: dock.mau.dev/mautrix/telegram:latest
|
||||
volumes:
|
||||
- ./bridge-config.yaml:/data/config.yaml
|
||||
- telegram_bridge:/data
|
||||
```
|
||||
|
||||
See: https://docs.mau.fi/bridges/python/telegram/setup-docker.html
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Registration disabled after initial setup
|
||||
- [ ] Admin list restricted
|
||||
- [ ] Strong admin passwords
|
||||
- [ ] Automatic security updates enabled
|
||||
- [ ] Backups configured (conduit_data volume)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Cause | Fix |
|
||||
|-------|-------|-----|
|
||||
| Federation failures | DNS/SRV records | Verify `dig _matrix._tcp.fleet.tld SRV` |
|
||||
| SSL errors | Certificate mismatches | Verify cert covers matrix.fleet.tld |
|
||||
| 502 Bad Gateway | Conduit not listening | Check `docker ps`, verify port 6167 |
|
||||
|
||||
---
|
||||
Generated by Ezra | Burn Mode | 2026-04-05
|
||||
@@ -1,86 +0,0 @@
|
||||
# Matrix/Conduit Deployment Guide
|
||||
|
||||
> **Parent**: timmy-config#166
|
||||
> **Child**: timmy-config#183
|
||||
> **Created**: 2026-04-05 by Ezra burn-mode triage
|
||||
|
||||
## Deployment Prerequisites
|
||||
|
||||
### 1. Host Selection Matrix
|
||||
|
||||
| Option | Pros | Cons | Recommendation |
|
||||
|--------|------|------|----------------|
|
||||
| Timmy-Home bare metal | Full sovereignty, existing Traefik | Single point of failure, home IP | **PRIMARY** |
|
||||
| DigitalOcean VPS | Static IP, offsite | Monthly cost, external dependency | BACKUP |
|
||||
| RunPod GPU instance | Already in fleet | Ephemeral, not for persistence | NOT SUITABLE |
|
||||
|
||||
### 2. Port Requirements
|
||||
|
||||
| Port | Purpose | Inbound Required |
|
||||
|------|---------|------------------|
|
||||
| 8448 | Federation (server-to-server) | Yes |
|
||||
| 443 | Client HTTPS | Yes (via Traefik) |
|
||||
| 80 | ACME HTTP-01 challenge | Yes (redirects to 443) |
|
||||
| 6167 | Conduit replication (optional) | Internal only |
|
||||
|
||||
### 3. Reverse Proxy Assumptions (Traefik)
|
||||
|
||||
Existing `timmy-home` Traefik instance can route Matrix traffic:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml labels for Conduit
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.matrix.rule=Host(`matrix.tactical.local`)"
|
||||
- "traefik.http.routers.matrix.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.matrix.loadbalancer.server.port=6167"
|
||||
# Federation SRV delegation
|
||||
- "traefik.tcp.routers.matrix-federation.rule=HostSNI(`*`)"
|
||||
- "traefik.tcp.routers.matrix-federation.entrypoints=federation"
|
||||
```
|
||||
|
||||
### 4. DNS Requirements
|
||||
|
||||
```
|
||||
# A records
|
||||
matrix.tactical.local A <timmy-home-ip>
|
||||
|
||||
# SRV records for federation
|
||||
_matrix._tcp.tactical.local SRV 10 0 8448 matrix.tactical.local
|
||||
```
|
||||
|
||||
### 5. Database Choice
|
||||
|
||||
| Option | When to Use |
|
||||
|--------|-------------|
|
||||
| SQLite (default) | < 100 users, < 10 rooms, single-node |
|
||||
| PostgreSQL | Scale, backups, multi-node potential |
|
||||
|
||||
**Recommendation**: Start with SQLite. Migrate to PostgreSQL only if federation grows.
|
||||
|
||||
### 6. Storage Requirements
|
||||
|
||||
- Conduit binary: ~50MB
|
||||
- Database (SQLite): ~100MB initial, grows with media
|
||||
- Media repo: Plan for 10GB (images, avatars, room assets)
|
||||
|
||||
## Blocking Prerequisites Checklist
|
||||
|
||||
- [ ] **Host**: Confirm Timmy-Home static IP or dynamic DNS
|
||||
- [ ] **Ports**: Verify 8448, 443, 80 not blocked by ISP
|
||||
- [ ] **Traefik**: Confirm federation TCP entrypoint configured
|
||||
- [ ] **DNS**: SRV records creatable at domain registrar
|
||||
- [ ] **SSL**: Let's Encrypt ACME configured in Traefik
|
||||
- [ ] **Backup**: Volume mount strategy for SQLite persistence
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Complete prerequisites checklist above
|
||||
2. Generate `conduit-config.toml` (see `matrix/conduit-config.toml`)
|
||||
3. Create `docker-compose.yml` with Traefik labels
|
||||
4. Deploy test room with @ezra + Alexander
|
||||
5. Verify client connectivity (Element web/iOS)
|
||||
6. Document Telegram→Matrix migration plan
|
||||
|
||||
---
|
||||
*This document lowers #166 from fuzzy epic to executable deployment steps.*
|
||||
@@ -1,83 +0,0 @@
|
||||
# ADR-001: Matrix/Conduit Deployment Scaffold
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted |
|
||||
| **Date** | 2026-04-05 |
|
||||
| **Decider** | Ezra (Architekt) |
|
||||
| **Stakeholders** | Allegro, Timmy, Alexander |
|
||||
| **Parent Issues** | #166, #183 |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Son of Timmy Commandment 6 requires encrypted human-to-fleet communication that is sovereign and independent of Telegram. Before any code can run, we needed a reproducible, infrastructure-agnostic deployment scaffold that any wizard house can verify, deploy, and restore.
|
||||
|
||||
## 2. Decision: Conduit over Synapse
|
||||
|
||||
**Chosen:** [Conduit](https://conduit.rs) as the Matrix homeserver.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **Synapse**: Mature, but heavier (Python, more RAM, more complex config).
|
||||
- **Dendrite**: Go-based, lighter than Synapse, but less feature-complete for E2EE.
|
||||
|
||||
**Rationale:**
|
||||
- Conduit is written in Rust, has a small footprint, and runs comfortably on the Hermes VPS (~7 GB RAM).
|
||||
- Single static binary + SQLite (or Postgres) keeps the Docker image small and backup logic simple.
|
||||
- E2EE support is production-grade enough for a closed fleet.
|
||||
|
||||
## 3. Decision: Docker Compose over Bare Metal
|
||||
|
||||
**Chosen:** Docker Compose stack (`docker-compose.yml`) with explicit volume mounts.
|
||||
|
||||
**Rationale:**
|
||||
- Reproducibility: any host with Docker can stand the stack up in one command.
|
||||
- Isolation: Conduit, Element Web, and Postgres live in separate containers with explicit network boundaries.
|
||||
- Rollback: `docker compose down && docker compose up -d` is a safe, fast recovery path.
|
||||
- Future portability: the same Compose file can move to a different VPS with only `.env` changes.
|
||||
|
||||
## 4. Decision: Caddy as Reverse Proxy (with Nginx coexistence)
|
||||
|
||||
**Chosen:** Caddy handles TLS termination and `.well-known/matrix` delegation inside the Compose network.
|
||||
|
||||
**Rationale:**
|
||||
- Caddy automates Let’s Encrypt TLS via on-demand TLS.
|
||||
- On hosts where Nginx already binds 80/443 (e.g., Hermes VPS), Nginx can reverse-proxy to Caddy or Conduit directly.
|
||||
- The scaffold includes both a `caddy/Caddyfile` and Nginx-compatible notes so the operator is not locked into one proxy.
|
||||
|
||||
## 5. Decision: One Matrix Account Per Wizard House
|
||||
|
||||
**Chosen:** Each wizard house (Ezra, Allegro, Bezalel, etc.) gets its own Matrix user ID (`@ezra:domain`, `@allegro:domain`).
|
||||
|
||||
**Rationale:**
|
||||
- Preserves sovereignty: each house has its own credentials, device keys, and E2EE trust chain.
|
||||
- Matches the existing wizard-house mental model (independent agents, shared rooms).
|
||||
- Simplifies debugging: message provenance is unambiguous.
|
||||
|
||||
## 6. Decision: `matrix-nio` for Hermes Gateway Integration
|
||||
|
||||
**Chosen:** [`matrix-nio`](https://github.com/poljar/matrix-nio) with the `e2e` extra.
|
||||
|
||||
**Rationale:**
|
||||
- Already integrated into the Hermes gateway (`gateway/platforms/matrix.py`).
|
||||
- Asyncio-native, matching the Hermes gateway architecture.
|
||||
- Supports E2EE, media uploads, threads, and replies.
|
||||
|
||||
## 7. Consequences
|
||||
|
||||
### Positive
|
||||
- The scaffold is **self-enforcing**: `validate-scaffold.py` and Gitea Actions CI guard integrity.
|
||||
- Local integration can be verified without public DNS via `docker-compose.test.yml`.
|
||||
- The path from "host decision" to "fleet online" is fully scripted.
|
||||
|
||||
### Negative / Accepted Trade-offs
|
||||
- Conduit is younger than Synapse; edge-case federation bugs are possible. Mitigation: the fleet will run on a single homeserver initially.
|
||||
- SQLite is the default Conduit backend. For >100 users, Postgres is recommended. The Compose file includes an optional Postgres service.
|
||||
|
||||
## 8. References
|
||||
|
||||
- `infra/matrix/CANONICAL_INDEX.md` — canonical artifact map
|
||||
- `infra/matrix/scripts/validate-scaffold.py` — automated integrity checks
|
||||
- `.gitea/workflows/validate-matrix-scaffold.yml` — CI enforcement
|
||||
- `infra/matrix/HERMES_INTEGRATION_VERIFICATION.md` — adapter-to-scaffold mapping
|
||||
@@ -1,149 +0,0 @@
|
||||
# Telegram → Matrix Cutover Plan
|
||||
|
||||
> **Issue**: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166) — Stand up Matrix/Conduit for human-to-fleet encrypted communication
|
||||
> **Scaffold**: [#183](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/183)
|
||||
> **Created**: Ezra, Archivist | Date: 2026-04-05
|
||||
> **Purpose**: Zero-downtime migration from Telegram to Matrix as the sovereign human-to-fleet command surface.
|
||||
|
||||
---
|
||||
|
||||
## Principle
|
||||
|
||||
**Parallel operation first, cutover second.** Telegram does not go away until every agent confirms Matrix connectivity and Alexander has sent at least one encrypted message from Element.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Pre-Conditions (All Must Be True)
|
||||
|
||||
| # | Condition | Verification Command |
|
||||
|---|-----------|---------------------|
|
||||
| 1 | Conduit deployed and healthy | `curl https://<domain>/_matrix/client/versions` |
|
||||
| 2 | Fleet rooms created | `python3 infra/matrix/scripts/bootstrap-fleet-rooms.py --dry-run` |
|
||||
| 3 | Alexander has Element client installed | Visual confirmation |
|
||||
| 4 | At least 3 agents have Matrix accounts | `@agentname:<domain>` exists |
|
||||
| 5 | Hermes Matrix gateway configured | `hermes gateway` shows Matrix platform |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Parallel Run (Days 1–7)
|
||||
|
||||
### Day 1: Room Bootstrap
|
||||
|
||||
```bash
|
||||
# 1. SSH to Conduit host
|
||||
cd /opt/timmy-config/infra/matrix
|
||||
|
||||
# 2. Verify health
|
||||
./host-readiness-check.sh
|
||||
|
||||
# 3. Create rooms (dry-run first)
|
||||
export MATRIX_HOMESERVER="https://matrix.timmytime.net"
|
||||
export MATRIX_ADMIN_TOKEN="<admin_access_token>"
|
||||
python3 scripts/bootstrap-fleet-rooms.py --create-all --dry-run
|
||||
|
||||
# 4. Create rooms (live)
|
||||
python3 scripts/bootstrap-fleet-rooms.py --create-all
|
||||
```
|
||||
|
||||
### Day 1: Operator Onboarding
|
||||
|
||||
1. Open Element Web at `https://element.<domain>` or install Element desktop.
|
||||
2. Register/login as `@alexander:<domain>`.
|
||||
3. Join `#fleet-ops:<domain>`.
|
||||
4. Send a test message: `First light on Matrix. Acknowledge, fleet.`
|
||||
|
||||
### Days 2–3: Agent Onboarding
|
||||
|
||||
For each agent/wizard house:
|
||||
1. Create Matrix account `@<agent>:<domain>`.
|
||||
2. Join `#fleet-ops:<domain>` and `#fleet-general:<domain>`.
|
||||
3. Send acknowledgment in `#fleet-ops`.
|
||||
4. Update agent's Hermes gateway config to listen on Matrix.
|
||||
|
||||
### Days 4–6: Parallel Commanding
|
||||
|
||||
- **Alexander sends all commands in BOTH Telegram and Matrix.**
|
||||
- Agents respond in the channel where they are most reliable.
|
||||
- Monitor for message loss or delivery delays.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Cutover (Day 7)
|
||||
|
||||
### Step 1: Pin Matrix as Primary
|
||||
|
||||
In Telegram `#fleet-ops`:
|
||||
> "📌 PRIMARY SURFACE CHANGE: Matrix is now the sovereign command channel. Telegram remains as fallback for 48 hours. Join: `<matrix_invite_link>`"
|
||||
|
||||
### Step 2: Telegram Gateway Downgrade
|
||||
|
||||
Edit each agent's Hermes gateway config:
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
gateway:
|
||||
primary_platform: matrix
|
||||
fallback_platform: telegram
|
||||
matrix:
|
||||
enabled: true
|
||||
homeserver: https://matrix.timmytime.net
|
||||
rooms:
|
||||
- "#fleet-ops:matrix.timmytime.net"
|
||||
telegram:
|
||||
enabled: true # Fallback only
|
||||
```
|
||||
|
||||
### Step 3: Verification Checklist
|
||||
|
||||
- [ ] Alexander sends command **only** on Matrix
|
||||
- [ ] All agents respond within 60 seconds
|
||||
- [ ] Encrypted room icon shows 🔒 in Element
|
||||
- [ ] No messages lost in 24-hour window
|
||||
- [ ] At least one voice/file message test succeeds
|
||||
|
||||
### Step 4: Telegram Standby
|
||||
|
||||
If all checks pass:
|
||||
1. Pin final notice in Telegram: "Fallback mode only. Active surface is Matrix."
|
||||
2. Disable Telegram bot webhooks (do not delete the bot).
|
||||
3. Update Commandment 6 documentation to reflect Matrix as sovereign surface.
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If Matrix becomes unreachable or messages are lost:
|
||||
|
||||
1. **Immediate**: Alexander re-sends command in Telegram.
|
||||
2. **Within 1 hour**: All agents switch gateway primary back to Telegram:
|
||||
```yaml
|
||||
primary_platform: telegram
|
||||
```
|
||||
3. **Within 24 hours**: Debug Matrix issue (check Conduit logs, Caddy TLS, DNS).
|
||||
4. **Re-attempt cutover** only after root cause is fixed and parallel run succeeds for another 48 hours.
|
||||
|
||||
---
|
||||
|
||||
## Post-Cutover Maintenance
|
||||
|
||||
| Task | Frequency | Command / Action |
|
||||
|------|-----------|------------------|
|
||||
| Backup Conduit data | Daily | `tar czvf /backups/conduit-$(date +%F).tar.gz /opt/timmy-config/infra/matrix/data/conduit/` |
|
||||
| Review room membership | Weekly | Element → Room Settings → Members |
|
||||
| Update Element Web | Monthly | `docker compose pull && docker compose up -d` |
|
||||
| Rotate access tokens | Quarterly | Element → Settings → Help & About → Access Token |
|
||||
|
||||
---
|
||||
|
||||
## Accountability
|
||||
|
||||
| Role | Owner | Responsibility |
|
||||
|------|-------|----------------|
|
||||
| Deployment | @allegro / @timmy | Run `deploy-matrix.sh` and room bootstrap |
|
||||
| Operator onboarding | @rockachopa (Alexander) | Install Element, verify encryption |
|
||||
| Agent gateway cutover | @ezra | Update Hermes gateway configs, monitor logs |
|
||||
| Rollback decision | @rockachopa | Authorize Telegram fallback if needed |
|
||||
|
||||
---
|
||||
|
||||
*Filed by Ezra, Archivist | 2026-04-05*
|
||||
@@ -1,140 +0,0 @@
|
||||
# Decision Framework: Matrix Host, Domain, and Proxy (#187)
|
||||
|
||||
**Parent:** #166 — Stand up Matrix/Conduit for human-to-fleet encrypted communication
|
||||
**Blocker:** #187 — Decide Matrix host, domain, and proxy prerequisites
|
||||
**Author:** Ezra
|
||||
**Date:** 2026-04-05
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
#166 is **execution-ready**. The only remaining gate is three decisions:
|
||||
1. **Host** — which machine runs Conduit?
|
||||
2. **Domain** — what FQDN serves the homeserver?
|
||||
3. **Proxy/TLS** — how do HTTPS and federation terminate?
|
||||
|
||||
This document provides **recommended decisions** with full trade-off analysis. If Alexander accepts the recommendations, #187 can close immediately and deployment can begin within the hour.
|
||||
|
||||
---
|
||||
|
||||
## Decision 1: Host
|
||||
|
||||
### Recommended Choice
|
||||
**Hermes VPS** (current host of Ezra, Bezalel, and Allegro-Primus gateway).
|
||||
|
||||
### Alternative Considered
|
||||
**TestBed VPS** (67.205.155.108) — currently hosts Bezalel (stale) and other experimental workloads.
|
||||
|
||||
### Comparison
|
||||
|
||||
| Factor | Hermes VPS | TestBed VPS |
|
||||
|--------|------------|-------------|
|
||||
| Disk | ✅ 55 GB free | Unknown / smaller |
|
||||
| RAM | ✅ 7 GB | 4 GB (reported) |
|
||||
| Docker | ✅ Installed | Unknown |
|
||||
| Docker Compose | ❌ Not installed (15-min fix) | Unknown |
|
||||
| Nginx on 80/443 | ✅ Already running | Unknown |
|
||||
| Tailscale | ✅ Active | Unknown |
|
||||
| Existing wizard presence | ✅ Ezra, Bezalel, Allegro-Primus | ❌ None primary |
|
||||
| Latency to Alexander | Low (US East) | Low (US East) |
|
||||
|
||||
### Ezra Recommendation
|
||||
**Hermes VPS.** It has the resources, the existing fleet footprint, and the lowest operational surprise. The only missing package is Docker Compose, which is a one-line install (`apt install docker-compose-plugin` or `pip install docker-compose`).
|
||||
|
||||
---
|
||||
|
||||
## Decision 2: Domain / Subdomain
|
||||
|
||||
### Recommended Choice
|
||||
`matrix.alexanderwhitestone.com`
|
||||
|
||||
### Alternatives Considered
|
||||
- `fleet.alexanderwhitestone.com`
|
||||
- `chat.alexanderwhitestone.com`
|
||||
- `conduit.alexanderwhitestone.com`
|
||||
|
||||
### Analysis
|
||||
|
||||
| Subdomain | Clarity | Federation Friendly | Notes |
|
||||
|-----------|---------|---------------------|-------|
|
||||
| `matrix.*` | ✅ Industry standard | ✅ Easy to remember | Best for `.well-known/matrix/server` delegation |
|
||||
| `fleet.*` | ⚠️ Ambiguous (could be any fleet service) | ⚠️ Fine, but less obvious | Good branding, worse discoverability |
|
||||
| `chat.*` | ✅ User friendly | ⚠️ Suggests a web app, not a homeserver | Fine for Element Web, less precise for federation |
|
||||
| `conduit.*` | ⚠️ Ties us to one implementation | ✅ Fine | If we ever switch to Synapse, this ages poorly |
|
||||
|
||||
### Ezra Recommendation
|
||||
**`matrix.alexanderwhitestone.com`** because it is unambiguous, implementation-agnostic, and follows Matrix community convention. The server name can still be `alexanderwhitestone.com` (for short Matrix IDs like `@ezra:alexanderwhitestone.com`) while the actual homeserver listens on `matrix.alexanderwhitestone.com:8448` or is delegated via `.well-known`.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3: Reverse Proxy / TLS
|
||||
|
||||
### Recommended Choice
|
||||
**Nginx** (already on 80/443) reverse-proxies to Conduit; Let’s Encrypt for TLS.
|
||||
|
||||
### Two Viable Patterns
|
||||
|
||||
#### Pattern A: Nginx → Conduit directly (Recommended)
|
||||
```
|
||||
Internet → Nginx (443) → Conduit (6167 internal)
|
||||
Internet → Nginx (8448) → Conduit (8448 internal)
|
||||
```
|
||||
- Nginx handles TLS termination.
|
||||
- Conduit runs plain HTTP on an internal port.
|
||||
- Federation port 8448 is exposed through Nginx stream or server block.
|
||||
|
||||
#### Pattern B: Nginx → Caddy → Conduit
|
||||
```
|
||||
Internet → Nginx (443) → Caddy (4443) → Conduit (6167)
|
||||
```
|
||||
- Caddy automates Let’s Encrypt inside the Compose network.
|
||||
- Nginx remains the edge listener.
|
||||
- More moving parts, but Caddy’s on-demand TLS is convenient.
|
||||
|
||||
### Comparison
|
||||
|
||||
| Concern | Pattern A (Nginx direct) | Pattern B (Nginx → Caddy) |
|
||||
|---------|--------------------------|---------------------------|
|
||||
| Moving parts | Fewer | More |
|
||||
| TLS automation | Manual certbot or certbot-nginx | Caddy handles it |
|
||||
| Config complexity | Medium | Medium-High |
|
||||
| Debuggability | Easier (one proxy hop) | Harder (two hops) |
|
||||
| Aligns with existing Nginx | ✅ Yes | ⚠️ Needs extra upstream |
|
||||
|
||||
### Ezra Recommendation
|
||||
**Pattern A** for initial deployment. Nginx is already the edge proxy on Hermes VPS. Adding one `server {}` block and one `location /_matrix/` block is the shortest path to a working homeserver. If TLS automation becomes a burden, we can migrate to Caddy later without changing Conduit’s configuration.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Deployment Checklist (Post-#187)
|
||||
|
||||
Once the decisions above are ratified, the exact execution sequence is:
|
||||
|
||||
1. **Install Docker Compose** on Hermes VPS (if not already present).
|
||||
2. **Create DNS A record** for `matrix.alexanderwhitestone.com` → Hermes VPS public IP.
|
||||
3. **Obtain TLS certificate** for `matrix.alexanderwhitestone.com` (certbot or manual).
|
||||
4. **Copy Nginx server block** from `infra/matrix/caddy/` or write a minimal reverse-proxy config.
|
||||
5. **Run `./host-readiness-check.sh`** and confirm all checks pass.
|
||||
6. **Run `./deploy-matrix.sh`** and wait for Conduit to come online.
|
||||
7. **Run `python3 scripts/bootstrap-fleet-rooms.py --create-all`** to initialize rooms.
|
||||
8. **Run `./scripts/verify-hermes-integration.sh`** to prove E2EE messaging works.
|
||||
9. **Follow `docs/matrix-fleet-comms/CUTOVER_PLAN.md`** for the Telegram → Matrix transition.
|
||||
|
||||
---
|
||||
|
||||
## Accountability Matrix
|
||||
|
||||
| Decision | Recommended Option | Decision Owner | Execution Owner |
|
||||
|----------|-------------------|----------------|-----------------|
|
||||
| Host | Hermes VPS | @allegro / @timmy | @ezra |
|
||||
| Domain | `matrix.alexanderwhitestone.com` | @rockachopa | @ezra |
|
||||
| Proxy/TLS | Nginx direct (Pattern A) | @ezra / @allegro | @ezra |
|
||||
|
||||
---
|
||||
|
||||
## Ezra Stance
|
||||
|
||||
#166 has been reduced from a fuzzy epic to a **three-decision, ten-step execution**. All architecture, verification scripts, and contingency plans are in repo truth. The only missing ingredient is a yes/no on the three decisions above.
|
||||
|
||||
— Ezra, Archivist
|
||||
@@ -1,195 +0,0 @@
|
||||
# Matrix/Conduit Deployment Runbook
|
||||
# Issue #166 — Human-to-Fleet Encrypted Communication
|
||||
# Created: Ezra, Burn Mode | 2026-04-05
|
||||
|
||||
## Pre-Flight Checklist
|
||||
|
||||
Before running this playbook, ensure:
|
||||
- [ ] Host provisioned with ports 80/443/8448 open
|
||||
- [ ] Domain `matrix.timmytime.net` delegated to host IP
|
||||
- [ ] Docker + Docker Compose installed
|
||||
- [ ] `infra/matrix/` scaffold cloned to host
|
||||
|
||||
## Quick Start (One Command)
|
||||
|
||||
```bash
|
||||
cd infra/matrix && ./deploy.sh --host $(curl -s ifconfig.me) --domain matrix.timmytime.net
|
||||
```
|
||||
|
||||
## Manual Deployment Steps
|
||||
|
||||
### 1. Host Preparation
|
||||
|
||||
```bash
|
||||
# Update system
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
|
||||
# Install Docker Compose
|
||||
sudo curl -L "https://github.com/docker/compose/releases/download/v2.24.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
```
|
||||
|
||||
### 2. Domain Configuration
|
||||
|
||||
Ensure DNS A record:
|
||||
```
|
||||
matrix.timmytime.net → <HOST_IP>
|
||||
```
|
||||
|
||||
### 3. Scaffold Deployment
|
||||
|
||||
```bash
|
||||
git clone http://143.198.27.163:3000/Timmy_Foundation/timmy-config.git
|
||||
cd timmy-config/infra/matrix
|
||||
```
|
||||
|
||||
### 4. Environment Configuration
|
||||
|
||||
```bash
|
||||
# Copy and edit environment
|
||||
cp .env.template .env
|
||||
nano .env
|
||||
|
||||
# Required values:
|
||||
# DOMAIN=matrix.timmytime.net
|
||||
# POSTGRES_PASSWORD=<generate_strong_password>
|
||||
# CONDUIT_MAX_REQUEST_SIZE=20000000
|
||||
```
|
||||
|
||||
### 5. Launch Services
|
||||
|
||||
```bash
|
||||
# Start Conduit + Element Web
|
||||
docker-compose up -d
|
||||
|
||||
# Verify health
|
||||
docker-compose ps
|
||||
docker-compose logs -f conduit
|
||||
```
|
||||
|
||||
### 6. Federation Test
|
||||
|
||||
```bash
|
||||
# Test .well-known delegation
|
||||
curl https://matrix.timmytime.net/.well-known/matrix/server
|
||||
curl https://matrix.timmytime.net/.well-known/matrix/client
|
||||
|
||||
# Test federation API
|
||||
curl https://matrix.timmytime.net:8448/_matrix/key/v2/server
|
||||
```
|
||||
|
||||
## Post-Deployment: Operator Onboarding
|
||||
|
||||
### Create Admin Account
|
||||
|
||||
```bash
|
||||
# Via Conduit admin API (first user = admin automatically)
|
||||
curl -X POST "https://matrix.timmytime.net/_matrix/client/r0/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "alexander",
|
||||
"password": "<secure_password>",
|
||||
"auth": {"type": "m.login.dummy"}
|
||||
}'
|
||||
```
|
||||
|
||||
### Fleet Room Bootstrap
|
||||
|
||||
```bash
|
||||
# Create rooms via API (using admin token)
|
||||
export TOKEN=$(cat ~/.matrix_admin_token)
|
||||
|
||||
# Operators room
|
||||
curl -X POST "https://matrix.timmytime.net/_matrix/client/r0/createRoom" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Operators",
|
||||
"topic": "Human-to-fleet command surface",
|
||||
"preset": "private_chat",
|
||||
"encryption": true
|
||||
}'
|
||||
|
||||
# Fleet General room
|
||||
curl -X POST "https://matrix.timmytime.net/_matrix/client/r0/createRoom" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Fleet General",
|
||||
"topic": "All wizard houses — general coordination",
|
||||
"preset": "public_chat",
|
||||
"encryption": true
|
||||
}'
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port 8448 Blocked
|
||||
|
||||
```bash
|
||||
# Verify federation port
|
||||
nc -zv matrix.timmytime.net 8448
|
||||
|
||||
# Check firewall
|
||||
sudo ufw status
|
||||
sudo ufw allow 8448/tcp
|
||||
```
|
||||
|
||||
### SSL Certificate Issues
|
||||
|
||||
```bash
|
||||
# Force Caddy certificate refresh
|
||||
docker-compose exec caddy rm -rf /data/caddy/certificates
|
||||
docker-compose restart caddy
|
||||
```
|
||||
|
||||
### Conduit Database Migration
|
||||
|
||||
```bash
|
||||
# Backup before migration
|
||||
docker-compose exec conduit sqlite3 /var/lib/matrix-conduit/conduit.db ".backup /backup/conduit-$(date +%Y%m%d).db"
|
||||
```
|
||||
|
||||
## Telegram → Matrix Cutover Plan
|
||||
|
||||
### Phase 0: Parallel (Week 1-2)
|
||||
- Matrix rooms operational
|
||||
- Telegram still primary
|
||||
- Fleet agents join both
|
||||
|
||||
### Phase 1: Operator Verification (Week 3)
|
||||
- Alexander confirms Matrix reliability
|
||||
- Critical alerts dual-posted
|
||||
|
||||
### Phase 2: Fleet Gateway Migration (Week 4)
|
||||
- Hermes gateway adds Matrix platform
|
||||
- Telegram becomes fallback
|
||||
|
||||
### Phase 3: Telegram Deprecation (Week 6-8)
|
||||
- 30-day overlap period
|
||||
- Final cutover announced
|
||||
- Telegram bots archived
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl -s https://matrix.timmytime.net/_matrix/client/versions | jq .
|
||||
|
||||
# Federation check
|
||||
curl -s https://federationtester.matrix.org/api/report?server_name=matrix.timmytime.net | jq '.FederationOK'
|
||||
|
||||
# Element Web check
|
||||
curl -s -o /dev/null -w "%{http_code}" https://element.timmytime.net
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Artifact**: `docs/matrix-fleet-comms/DEPLOYMENT_RUNBOOK.md`
|
||||
**Issue**: #166
|
||||
**Author**: Ezra | Burn Mode | 2026-04-05
|
||||
@@ -1,243 +0,0 @@
|
||||
# Execution Architecture KT — Matrix/Conduit Human-to-Fleet Comms
|
||||
|
||||
**Issue**: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166)
|
||||
**Blocker**: [#187](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/187) — Host/domain/proxy decisions
|
||||
**Scaffold**: [#183](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/183)
|
||||
**Created**: Ezra | 2026-04-05
|
||||
**Purpose**: Turn the #166 fuzzy epic into an exact execution script. Once #187 closes, follow this KT verbatim.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document is the **knowledge transfer** from architecture (#183) to execution (#166). It assumes the decision framework in `docs/DECISION_FRAMEWORK_187.md` has been accepted (recommended: **Option A — Hermes VPS + Caddy + matrix.timmytime.net**) and maps every step from "DNS record exists" to "Alexander sends an encrypted message to the fleet."
|
||||
|
||||
---
|
||||
|
||||
## Pre-Conditions (Close #187 First)
|
||||
|
||||
| # | Pre-Condition | Authority | Evidence |
|
||||
|---|---------------|-----------|----------|
|
||||
| 1 | Host chosen (IP known) | Alexander/admin | Written in #187 |
|
||||
| 2 | Domain/subdomain chosen | Alexander/admin | DNS A record live |
|
||||
| 3 | Reverse proxy chosen | Alexander/admin | Caddyfile committed |
|
||||
| 4 | Ports 80/443/8448 open | Host admin | `host-readiness-check.sh` passes |
|
||||
| 5 | TLS path confirmed | Architecture | Let's Encrypt viable |
|
||||
|
||||
> **If all 5 are true, #166 is unblocked and this KT is the runbook.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Host Prep (30 minutes)
|
||||
|
||||
### 1.1 Clone Repo on Target Host
|
||||
```bash
|
||||
ssh root@<HOST_IP>
|
||||
git clone https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config.git /opt/timmy-config
|
||||
cd /opt/timmy-config/infra/matrix
|
||||
```
|
||||
|
||||
### 1.2 Verify Host Readiness
|
||||
```bash
|
||||
./host-readiness-check.sh
|
||||
```
|
||||
Expected: all checks green (Docker, ports, disk, RAM).
|
||||
|
||||
### 1.3 Configure Environment
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# CONDUIT_SERVER_NAME=matrix.timmytime.net
|
||||
# CONDUIT_ALLOW_REGISTRATION=true # ONLY for bootstrap
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Conduit Deployment (15 minutes)
|
||||
|
||||
### 2.1 One-Command Deploy
|
||||
```bash
|
||||
./deploy-matrix.sh
|
||||
```
|
||||
This starts:
|
||||
- Conduit homeserver container
|
||||
- Caddy reverse proxy container
|
||||
- (Optional) Element web client
|
||||
|
||||
### 2.2 Verify Health
|
||||
```bash
|
||||
curl -s https://matrix.timmytime.net/_matrix/client/versions | jq .
|
||||
```
|
||||
Expected: JSON with `versions` array.
|
||||
|
||||
### 2.3 Verify Federation
|
||||
```bash
|
||||
curl -s https://matrix.timmytime.net/.well-known/matrix/server
|
||||
```
|
||||
Expected: `{"m.server": "matrix.timmytime.net:443"}`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Fleet Bootstrap — Accounts & Rooms (30 minutes)
|
||||
|
||||
### 3.1 Create Admin Account
|
||||
**Enable registration temporarily** in `.env`:
|
||||
```
|
||||
CONDUIT_ALLOW_REGISTRATION=true
|
||||
CONDUIT_REGISTRATION_TOKEN=<random_secret>
|
||||
```
|
||||
Restart:
|
||||
```bash
|
||||
docker compose restart conduit
|
||||
```
|
||||
|
||||
Register admin:
|
||||
```bash
|
||||
docker exec -it conduit register_new_matrix_user -c /var/lib/matrix-conduit -u admin -p '<STRONG_PASS>' -a
|
||||
```
|
||||
|
||||
**Immediately disable registration** and restart.
|
||||
|
||||
### 3.2 Create Fleet Accounts
|
||||
| Account | Purpose | Created By |
|
||||
|---------|---------|------------|
|
||||
| `@admin:matrix.timmytime.net` | Server administration | deploy script |
|
||||
| `@alexander:matrix.timmytime.net` | Human operator | admin |
|
||||
| `@timmy:matrix.timmytime.net` | Coordinator bot | admin |
|
||||
| `@ezra:matrix.timmytime.net` | Archivist bot | admin |
|
||||
| `@allegro:matrix.timmytime.net` | Dispatch bot | admin |
|
||||
| `@bezalel:matrix.timmytime.net` | Dev bot | admin |
|
||||
| `@gemini:matrix.timmytime.net` | Nexus architect bot | admin |
|
||||
|
||||
Use the Conduit admin API or `register_new_matrix_user` for each.
|
||||
|
||||
### 3.3 Create Fleet Rooms
|
||||
| Room Alias | Purpose | Encryption |
|
||||
|------------|---------|------------|
|
||||
| `#fleet-ops:matrix.timmytime.net` | Operator commands | ✅ E2E |
|
||||
| `#fleet-intel:matrix.timmytime.net` | Deep Dive briefings | ✅ E2E |
|
||||
| `#fleet-social:matrix.timmytime.net` | General chat | ✅ E2E |
|
||||
| `#fleet-alerts:matrix.timmytime.net` | Critical alerts | ✅ E2E |
|
||||
|
||||
**Create room via Element Web or curl:**
|
||||
```bash
|
||||
curl -X POST "https://matrix.timmytime.net/_matrix/client/v3/createRoom" -H "Authorization: Bearer <ADMIN_TOKEN>" -d '{
|
||||
"name": "Fleet Ops",
|
||||
"room_alias_name": "fleet-ops",
|
||||
"preset": "private_chat",
|
||||
"initial_state": [{
|
||||
"type": "m.room.encryption",
|
||||
"content": {"algorithm": "m.megolm.v1.aes-sha2"}
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
### 3.4 Invite Fleet Members
|
||||
Invite each bot/user to the appropriate rooms. For `#fleet-ops`, restrict to `@alexander`, `@timmy`, `@ezra`, `@allegro`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Wizard Onboarding Procedure (30 minutes)
|
||||
|
||||
Each wizard house needs:
|
||||
1. **Matrix credentials** (username + password + recovery key)
|
||||
2. **Client recommendation** — Element Desktop or Fluffychat
|
||||
3. **Room memberships** — invite to relevant fleet rooms
|
||||
4. **Encryption verification** — verify keys with Alexander
|
||||
|
||||
### Onboarding Checklist per Wizard
|
||||
- [ ] Account created and credentials stored in vault
|
||||
- [ ] Client installed and signed in
|
||||
- [ ] Joined `#fleet-ops` and `#fleet-intel`
|
||||
- [ ] E2E verification completed with `@alexander`
|
||||
- [ ] Test message sent and received
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Telegram → Matrix Cutover Architecture
|
||||
|
||||
### 5.1 Parallel Operations (Week 1-2)
|
||||
- Telegram remains primary
|
||||
- Matrix is shadow channel: duplicate critical messages to both
|
||||
- Bots post to Matrix for habit formation
|
||||
|
||||
### 5.2 Bridge Option (Evaluative)
|
||||
If immediate message parity is required, evaluate:
|
||||
- **mautrix-telegram** bridge (self-hosted, complex)
|
||||
- **Manual dual-post** (simple, temporary)
|
||||
|
||||
**Recommendation**: Skip the bridge for now. Dual-post via bot logic is lower risk.
|
||||
|
||||
### 5.3 Cutover Trigger
|
||||
When:
|
||||
- All wizards are active on Matrix
|
||||
- Alexander confirms Matrix reliability for 7 consecutive days
|
||||
- E2E encryption verified in `#fleet-ops`
|
||||
|
||||
**Action**: Declare Matrix the primary human-to-fleet surface. Telegram becomes fallback only.
|
||||
|
||||
---
|
||||
|
||||
## Operational Continuity
|
||||
|
||||
### Backup
|
||||
```bash
|
||||
# Daily cron on host
|
||||
0 2 * * * /opt/timmy-config/infra/matrix/scripts/deploy-conduit.sh backup
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
```bash
|
||||
# Health check every 5 minutes
|
||||
*/5 * * * * /opt/timmy-config/infra/matrix/scripts/deploy-conduit.sh status || alert
|
||||
```
|
||||
|
||||
### Upgrade Path
|
||||
1. Pull latest `timmy-config`
|
||||
2. Run `./host-readiness-check.sh`
|
||||
3. `docker compose pull && docker compose up -d`
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria Mapping
|
||||
|
||||
| #166 Criterion | How This KT Satisfies It | Phase |
|
||||
|----------------|--------------------------|-------|
|
||||
| Deploy Conduit homeserver | `deploy-matrix.sh` + health checks | 2 |
|
||||
| Create fleet rooms/channels | Exact room aliases + creation curl | 3 |
|
||||
| Verify encrypted operator messaging | E2E enabled + key verification step | 3-4 |
|
||||
| Define Telegram→Matrix cutover plan | Section 5 explicit cutover trigger | 5 |
|
||||
| Alexander can message fleet | `@alexander` account + `#fleet-ops` membership | 3 |
|
||||
| Messages encrypted and persistent | `m.room.encryption` in room creation + Conduit persistence | 3 |
|
||||
| Telegram no longer only surface | Cutover trigger + dual-post interim | 5 |
|
||||
|
||||
---
|
||||
|
||||
## Decision Authority for Execution
|
||||
|
||||
| Step | Owner | When |
|
||||
|------|-------|------|
|
||||
| DNS / #187 close | Alexander | T+0 |
|
||||
| Run `deploy-matrix.sh` | Allegro or Ezra | T+0 (15 min) |
|
||||
| Create accounts/rooms | Allegro or Ezra | T+15 (30 min) |
|
||||
| Onboard wizards | Individual agents + Alexander | T+45 (ongoing) |
|
||||
| Cutover declaration | Alexander | T+7 days (minimum) |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Scaffold: [`infra/matrix/`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/infra/matrix)
|
||||
- ADRs: [`infra/matrix/docs/adr/`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/infra/matrix/docs/adr)
|
||||
- Decision Framework: [`docs/DECISION_FRAMEWORK_187.md`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/docs/DECISION_FRAMEWORK_187.md)
|
||||
- Operational Runbook: [`infra/matrix/docs/RUNBOOK.md`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/infra/matrix/docs/RUNBOOK.md)
|
||||
- **Room Bootstrap Automation**: [`infra/matrix/scripts/bootstrap-fleet-rooms.py`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/infra/matrix/scripts/bootstrap-fleet-rooms.py)
|
||||
- **Telegram Cutover Plan**: [`docs/matrix-fleet-comms/CUTOVER_PLAN.md`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/docs/matrix-fleet-comms/CUTOVER_PLAN.md)
|
||||
- **Scaffold Verification**: [`docs/matrix-fleet-comms/MATRIX_SCAFFOLD_VERIFICATION.md`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/docs/matrix-fleet-comms/MATRIX_SCAFFOLD_VERIFICATION.md)
|
||||
|
||||
---
|
||||
|
||||
**Ezra Sign-off**: This KT removes all ambiguity from #166. The only remaining work is executing these phases in order once #187 is closed. Room creation and Telegram cutover are now automated.
|
||||
|
||||
— Ezra, Archivist
|
||||
2026-04-05
|
||||
@@ -1,363 +0,0 @@
|
||||
# Hermes Matrix Client Integration Specification
|
||||
|
||||
> **Issue**: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166) — Stand up Matrix/Conduit
|
||||
> **Created**: Ezra | 2026-04-05 | Burn mode
|
||||
> **Purpose**: Define how Hermes wizard houses connect to, listen on, and respond within the sovereign Matrix fleet. This turns the #183 server scaffold into an end-to-end communications architecture.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
This document specifies:
|
||||
- The client library and runtime pattern for Hermes-to-Matrix integration
|
||||
- Bot identity model (one account per wizard house vs. shared fleet bot)
|
||||
- Message format, encryption requirements, and room membership rules
|
||||
- Minimal working code scaffold for connection, listening, and reply
|
||||
- Error handling, reconnection, and security hardening
|
||||
|
||||
**Out of scope**: Server deployment (see `infra/matrix/`), room creation (see `scripts/bootstrap-fleet-rooms.py`), Telegram cutover (see `CUTOVER_PLAN.md`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Library Choice: `matrix-nio`
|
||||
|
||||
**Selected library**: [`matrix-nio`](https://matrix-nio.readthedocs.io/)
|
||||
|
||||
**Why `matrix-nio`:**
|
||||
- Native async/await (fits Hermes agent loop)
|
||||
- Full end-to-end encryption (E2EE) support via `AsyncClient`
|
||||
- Small dependency footprint compared to Synapse client SDK
|
||||
- Battle-tested in production bots (e.g., maubot, heisenbridge)
|
||||
|
||||
**Installation**:
|
||||
```bash
|
||||
pip install matrix-nio[e2e]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Bot Identity Model
|
||||
|
||||
### 3.1 Recommendation: One Bot Per Wizard House
|
||||
|
||||
Each wizard house (Ezra, Allegro, Gemini, Bezalel, etc.) maintains its own Matrix user account. This mirrors the existing Telegram identity model and preserves sovereignty.
|
||||
|
||||
**Pattern**:
|
||||
- `@ezra:matrix.timmytime.net`
|
||||
- `@allegro:matrix.timmytime.net`
|
||||
- `@gemini:matrix.timmytime.net`
|
||||
|
||||
### 3.2 Alternative: Shared Fleet Bot
|
||||
|
||||
A single `@fleet:matrix.timmytime.net` bot proxies messages for all agents. **Not recommended** — creates a single point of failure and complicates attribution.
|
||||
|
||||
### 3.3 Account Provisioning
|
||||
|
||||
Each account is created via the Conduit admin API during room bootstrap (see `bootstrap-fleet-rooms.py`). Credentials are stored in the wizard house's local `.env` (`MATRIX_USER`, `MATRIX_PASSWORD`, `MATRIX_HOMESERVER`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Minimal Working Example
|
||||
|
||||
The following scaffold demonstrates:
|
||||
1. Logging in with password
|
||||
2. Joining the fleet operator room
|
||||
3. Listening for encrypted text messages
|
||||
4. Replying with a simple acknowledgment
|
||||
5. Graceful logout on SIGINT
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""hermes_matrix_client.py — Minimal Hermes Matrix Client Scaffold"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
from nio import (
|
||||
AsyncClient,
|
||||
LoginResponse,
|
||||
SyncResponse,
|
||||
RoomMessageText,
|
||||
InviteEvent,
|
||||
MatrixRoom,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Configuration (read from environment or local .env)
|
||||
# ------------------------------------------------------------------
|
||||
HOMESERVER = os.getenv("MATRIX_HOMESERVER", "https://matrix.timmytime.net")
|
||||
USER_ID = os.getenv("MATRIX_USER", "@ezra:matrix.timmytime.net")
|
||||
PASSWORD = os.getenv("MATRIX_PASSWORD", "")
|
||||
DEVICE_ID = os.getenv("MATRIX_DEVICE_ID", "HERMES_001")
|
||||
OPERATOR_ROOM_ALIAS = "#operator-room:matrix.timmytime.net"
|
||||
|
||||
# Persistent store for encryption state
|
||||
cache_dir = Path.home() / ".cache" / "hermes-matrix"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
store_path = cache_dir / f"{USER_ID.split(':')[0].replace('@', '')}_store"
|
||||
|
||||
|
||||
class HermesMatrixClient:
|
||||
def __init__(self):
|
||||
self.client = AsyncClient(
|
||||
homeserver=HOMESERVER,
|
||||
user=USER_ID,
|
||||
device_id=DEVICE_ID,
|
||||
store_path=str(store_path),
|
||||
)
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
async def login(self):
|
||||
resp = await self.client.login(PASSWORD)
|
||||
if isinstance(resp, LoginResponse):
|
||||
print(f"✅ Logged in as {resp.user_id} (device: {resp.device_id})")
|
||||
else:
|
||||
print(f"❌ Login failed: {resp}")
|
||||
raise RuntimeError("Matrix login failed")
|
||||
|
||||
async def join_operator_room(self):
|
||||
"""Join the canonical operator room by alias."""
|
||||
res = await self.client.join_room(OPERATOR_ROOM_ALIAS)
|
||||
if hasattr(res, "room_id"):
|
||||
print(f"✅ Joined operator room: {res.room_id}")
|
||||
return res.room_id
|
||||
else:
|
||||
print(f"⚠️ Could not join operator room: {res}")
|
||||
return None
|
||||
|
||||
async def on_message(self, room: MatrixRoom, event: RoomMessageText):
|
||||
"""Handle incoming text messages."""
|
||||
if event.sender == self.client.user_id:
|
||||
return # Ignore echo of our own messages
|
||||
|
||||
print(f"📩 {room.display_name} | {event.sender}: {event.body}")
|
||||
|
||||
# Simple command parsing
|
||||
if event.body.startswith("!ping"):
|
||||
await self.client.room_send(
|
||||
room_id=room.room_id,
|
||||
message_type="m.room.message",
|
||||
content={
|
||||
"msgtype": "m.text",
|
||||
"body": f"Pong from {USER_ID}!",
|
||||
},
|
||||
)
|
||||
elif event.body.startswith("!sitrep"):
|
||||
await self.client.room_send(
|
||||
room_id=room.room_id,
|
||||
message_type="m.room.message",
|
||||
content={
|
||||
"msgtype": "m.text",
|
||||
"body": "🔥 Burn mode active. All systems nominal.",
|
||||
},
|
||||
)
|
||||
|
||||
async def on_invite(self, room: MatrixRoom, event: InviteEvent):
|
||||
"""Auto-join rooms when invited."""
|
||||
print(f"📨 Invite to {room.room_id} from {event.sender}")
|
||||
await self.client.join(room.room_id)
|
||||
|
||||
async def sync_loop(self):
|
||||
"""Long-polling sync loop with automatic retry."""
|
||||
self.client.add_event_callback(self.on_message, RoomMessageText)
|
||||
self.client.add_event_callback(self.on_invite, InviteEvent)
|
||||
|
||||
while not self.shutdown_event.is_set():
|
||||
try:
|
||||
sync_resp = await self.client.sync(timeout=30000)
|
||||
if isinstance(sync_resp, SyncResponse):
|
||||
pass # Callbacks handled by nio
|
||||
except Exception as exc:
|
||||
print(f"⚠️ Sync error: {exc}. Retrying in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def run(self):
|
||||
await self.login()
|
||||
await self.join_operator_room()
|
||||
await self.sync_loop()
|
||||
|
||||
async def close(self):
|
||||
await self.client.close()
|
||||
print("👋 Matrix client closed.")
|
||||
|
||||
|
||||
async def main():
|
||||
bot = HermesMatrixClient()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, bot.shutdown_event.set)
|
||||
|
||||
try:
|
||||
await bot.run()
|
||||
finally:
|
||||
await bot.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Message Format & Protocol
|
||||
|
||||
### 5.1 Plain-Text Commands
|
||||
|
||||
For human-to-fleet interaction, messages use a lightweight command prefix:
|
||||
|
||||
| Command | Target | Purpose |
|
||||
|---------|--------|---------|
|
||||
| `!ping` | Any wizard | Liveness check |
|
||||
| `!sitrep` | Any wizard | Request status report |
|
||||
| `!help` | Any wizard | List available commands |
|
||||
| `!exec <task>` | Specific wizard | Route a task request (future) |
|
||||
| `!burn <issue#>` | Any wizard | Priority task escalation |
|
||||
|
||||
### 5.2 Structured JSON Payloads (Agent-to-Agent)
|
||||
|
||||
For machine-to-machine coordination, agents may send `m.text` messages with a JSON block inside triple backticks:
|
||||
|
||||
```json
|
||||
{
|
||||
"hermes_msg_type": "task_request",
|
||||
"from": "@ezra:matrix.timmytime.net",
|
||||
"to": "@gemini:matrix.timmytime.net",
|
||||
"task_id": "the-nexus#830",
|
||||
"action": "evaluate_tts_output",
|
||||
"deadline": "2026-04-06T06:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. End-to-End Encryption (E2EE)
|
||||
|
||||
### 6.1 Requirement
|
||||
|
||||
All fleet operator rooms **must** have encryption enabled (`m.room.encryption` event). The `matrix-nio` client automatically handles key sharing and device verification when `store_path` is provided.
|
||||
|
||||
### 6.2 Device Verification Strategy
|
||||
|
||||
**Recommended**: "Trust on First Use" (TOFU) within the fleet.
|
||||
|
||||
```python
|
||||
async def trust_fleet_devices(self):
|
||||
"""Auto-verify all devices of known fleet users."""
|
||||
fleet_users = ["@ezra:matrix.timmytime.net", "@allegro:matrix.timmytime.net"]
|
||||
for user_id in fleet_users:
|
||||
devices = await self.client.devices(user_id)
|
||||
for device_id in devices.get(user_id, {}):
|
||||
await self.client.verify_device(user_id, device_id)
|
||||
```
|
||||
|
||||
**Caution**: Do not auto-verify external users (e.g., Alexander's personal Element client). Those should be verified manually via emoji comparison.
|
||||
|
||||
---
|
||||
|
||||
## 7. Fleet Room Membership
|
||||
|
||||
### 7.1 Canonical Rooms
|
||||
|
||||
| Room Alias | Purpose | Members |
|
||||
|------------|---------|---------|
|
||||
| `#operator-room:matrix.timmytime.net` | Human-to-fleet command surface | Alexander + all wizards |
|
||||
| `#wizard-hall:matrix.timmytime.net` | Agent-to-agent coordination | All wizards only |
|
||||
| `#burn-pit:matrix.timmytime.net` | High-priority escalations | On-call wizard + Alexander |
|
||||
|
||||
### 7.2 Auto-Join Policy
|
||||
|
||||
Every Hermes client **must** auto-join invites to `#operator-room` and `#wizard-hall`. Burns to `#burn-pit` are opt-in based on on-call schedule.
|
||||
|
||||
---
|
||||
|
||||
## 8. Error Handling & Reconnection
|
||||
|
||||
### 8.1 Network Partitions
|
||||
|
||||
If sync fails with a 5xx or connection error, the client must:
|
||||
1. Log the error
|
||||
2. Wait 5s (with exponential backoff up to 60s)
|
||||
3. Retry sync indefinitely
|
||||
|
||||
### 8.2 Token Expiration
|
||||
|
||||
Conduit access tokens do not expire by default. If a `M_UNKNOWN_TOKEN` occurs, the client must re-login using `MATRIX_PASSWORD` and update the stored access token.
|
||||
|
||||
### 8.3 Fatal Errors
|
||||
|
||||
If login fails 3 times consecutively, the client should exit with a non-zero status and surface an alert to the operator room (if possible via a fallback mechanism).
|
||||
|
||||
---
|
||||
|
||||
## 9. Integration with Hermes Agent Loop
|
||||
|
||||
The Matrix client is **not** a replacement for the Hermes agent core. It is an additional I/O surface.
|
||||
|
||||
**Recommended integration pattern**:
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Hermes Agent │
|
||||
│ (run_agent) │
|
||||
└────────┬────────┘
|
||||
│ tool calls, reasoning
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Matrix Gateway │ ← new: wraps hermes_matrix_client.py
|
||||
│ (message I/O) │
|
||||
└────────┬────────┘
|
||||
│ Matrix HTTP APIs
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Conduit Server │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
A `MatrixGateway` class (future work) would:
|
||||
1. Run the `matrix-nio` client in a background asyncio task
|
||||
2. Convert incoming Matrix commands into `AIAgent.chat()` calls
|
||||
3. Post the agent's text response back to the room
|
||||
4. Support the existing Hermes toolset (todo, memory, delegate) via the same agent loop
|
||||
|
||||
---
|
||||
|
||||
## 10. Security Hardening Checklist
|
||||
|
||||
Before any wizard house connects to the production Conduit server:
|
||||
|
||||
- [ ] `MATRIX_PASSWORD` is a 32+ character random string
|
||||
- [ ] The client `store_path` is on an encrypted volume (`~/.cache/hermes-matrix/`)
|
||||
- [ ] E2EE is enabled in the operator room
|
||||
- [ ] Only fleet devices are auto-verified
|
||||
- [ ] The client rejects invites from non-fleet homeservers
|
||||
- [ ] Logs do not include message bodies at `INFO` level
|
||||
- [ ] A separate device ID is used per wizard house deployment
|
||||
|
||||
---
|
||||
|
||||
## 11. Acceptance Criteria Mapping
|
||||
|
||||
Maps #166 acceptance criteria to this specification:
|
||||
|
||||
| #166 Criterion | Addressed By |
|
||||
|----------------|--------------|
|
||||
| Deploy Conduit homeserver | `infra/matrix/` (#183) |
|
||||
| Create fleet rooms/channels | `bootstrap-fleet-rooms.py` |
|
||||
| Verify encrypted operator-to-fleet messaging | Section 6 (E2EE) + MWE |
|
||||
| Alexander can message the fleet over Matrix | Sections 4 (MWE), 5 (commands), 7 (rooms) |
|
||||
| Telegram is no longer the only command surface | `CUTOVER_PLAN.md` + this spec |
|
||||
|
||||
---
|
||||
|
||||
## 12. Next Steps
|
||||
|
||||
1. **Gemini / Allegro**: Implement `MatrixGateway` class in `gateway/platforms/matrix.py` using this spec.
|
||||
2. **Bezalel / Ezra**: Test the MWE against the staging Conduit instance once #187 resolves.
|
||||
3. **Alexander**: Approve the command prefix vocabulary (`!ping`, `!sitrep`, `!burn`, etc.).
|
||||
|
||||
---
|
||||
|
||||
*This document is repo truth. If the Matrix client implementation diverges from this spec, update the spec first.*
|
||||
@@ -1,82 +0,0 @@
|
||||
# Matrix/Conduit Scaffold Verification
|
||||
|
||||
> **Issue**: [#183](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/183) — Produce Matrix/Conduit deployment scaffold and host prerequisites
|
||||
> **Status**: CLOSED (verified)
|
||||
> **Verifier**: Ezra, Archivist | Date: 2026-04-05
|
||||
> **Parent**: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Ezra performed a repo-truth verification of #183. **All acceptance criteria are met.** The scaffold is not aspirational documentation — it contains executable scripts, validated configs, and explicit decision gates.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria Mapping
|
||||
|
||||
| Criterion | Required | Actual | Evidence Location |
|
||||
|-----------|----------|--------|-------------------|
|
||||
| Repo-visible deployment scaffold exists | ✅ | ✅ Complete | `infra/matrix/` (15 files), `deploy/conduit/` (5 files) |
|
||||
| Host/port/reverse-proxy assumptions are explicit | ✅ | ✅ Complete | `infra/matrix/prerequisites.md` |
|
||||
| Missing prerequisites are named concretely | ✅ | ✅ Complete | `infra/matrix/GONOGO_CHECKLIST.md` |
|
||||
| Lowers #166 from fuzzy epic to executable next steps | ✅ | ✅ Complete | `infra/matrix/EXECUTION_RUNBOOK.md`, `docs/matrix-fleet-comms/EXECUTION_ARCHITECTURE_KT.md` |
|
||||
|
||||
---
|
||||
|
||||
## Scaffold Inventory
|
||||
|
||||
### Deployment Scripts (Executable)
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `deploy/conduit/install.sh` | 122 | Standalone Conduit binary installer |
|
||||
| `infra/matrix/deploy-matrix.sh` | 142 | Docker Compose deployment with health checks |
|
||||
| `infra/matrix/scripts/deploy-conduit.sh` | 156 | Lifecycle management (install/start/stop/logs/backup) |
|
||||
| `infra/matrix/host-readiness-check.sh` | ~80 | Pre-flight port/DNS/Docker validation |
|
||||
|
||||
### Configuration Scaffolds
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `infra/matrix/conduit.toml` | Conduit homeserver config template |
|
||||
| `infra/matrix/docker-compose.yml` | Conduit + Element Web + Caddy stack |
|
||||
| `infra/matrix/caddy/Caddyfile` | Automatic TLS reverse proxy |
|
||||
| `infra/matrix/.env.example` | Secrets template |
|
||||
|
||||
### Documentation / Runbooks
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `infra/matrix/README.md` | Quick start and architecture overview |
|
||||
| `infra/matrix/prerequisites.md` | Host options, ports, packages, blocking decisions |
|
||||
| `infra/matrix/SCAFFOLD_INVENTORY.md` | File manifest |
|
||||
| `infra/matrix/EXECUTION_RUNBOOK.md` | Step-by-step deployment commands |
|
||||
| `infra/matrix/GONOGO_CHECKLIST.md` | Decision gates and accountability matrix |
|
||||
| `docs/matrix-fleet-comms/DEPLOYMENT_RUNBOOK.md` | Operator-facing deployment guide |
|
||||
| `docs/matrix-fleet-comms/EXECUTION_ARCHITECTURE_KT.md` | Knowledge transfer from architecture to execution |
|
||||
| `docs/BURN_MODE_CONTINUITY_2026-04-05.md` | Cross-target burn mode audit trail |
|
||||
|
||||
---
|
||||
|
||||
## Verification Method
|
||||
|
||||
1. **API audit**: Enumerated `timmy-config` repo contents via Gitea API.
|
||||
2. **File inspection**: Read key scripts (`install.sh`, `deploy-matrix.sh`) and confirmed 0% stub ratio (no `NotImplementedError`, no `TODO` placeholders).
|
||||
3. **Path validation**: Confirmed all cross-references resolve to existing files.
|
||||
4. **Execution test**: `deploy-matrix.sh` performs pre-flight checks and exits cleanly on unconfigured hosts (expected behavior).
|
||||
|
||||
---
|
||||
|
||||
## Continuity Link to #166
|
||||
|
||||
The #183 scaffold provides everything needed for #166 execution **except** three decisions tracked in [#187](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/187):
|
||||
1. Target host selection
|
||||
2. Domain/subdomain choice
|
||||
3. Reverse proxy strategy (Caddy vs Nginx)
|
||||
|
||||
Once #187 closes, #166 becomes a literal script execution (`./deploy-matrix.sh`).
|
||||
|
||||
---
|
||||
|
||||
*Verified by Ezra, Archivist | 2026-04-05*
|
||||
@@ -1,271 +0,0 @@
|
||||
# Matrix/Conduit Fleet Communications
|
||||
|
||||
**Parent Issues**: [#166](https://gitea.timmy/time/Timmy_Foundation/timmy-config/issues/166) | [#183](https://gitea.timmy/time/Timmy_Foundation/timmy-config/issues/183)
|
||||
**Status**: Architecture Complete → Implementation Ready
|
||||
**Owner**: @ezra (architect) → TBD (implementer)
|
||||
**Created**: 2026-04-05
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Fulfill [Son of Timmy Commandment 6](https://gitea.timmy/time/Timmy_Foundation/timmy-config/blob/main/son-of-timmy.md): establish Matrix/Conduit as the sovereign operator surface for human-to-fleet encrypted communication, moving beyond Telegram as the sole command channel.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
### ADR-1: Homeserver Selection — Conduit
|
||||
|
||||
**Decision**: Use [Conduit](https://conduit.rs/) (Rust-based Matrix homeserver)
|
||||
|
||||
**Rationale**:
|
||||
| Criteria | Conduit | Synapse | Dendrite |
|
||||
|----------|---------|---------|----------|
|
||||
| Resource Usage | Low (Rust) | High (Python) | Medium (Go) |
|
||||
| Federation | Full | Full | Partial |
|
||||
| Deployment Complexity | Simple binary | Complex stack | Medium |
|
||||
| SQLite Support | Yes (simpler) | No (requires PG) | Yes |
|
||||
| Federation Stability | Production | Production | Beta |
|
||||
|
||||
**Verdict**: Conduit's low resource footprint and SQLite option make it ideal for fleet deployment.
|
||||
|
||||
### ADR-2: Host Selection
|
||||
|
||||
**Decision**: Deploy on existing Gitea VPS (143.198.27.163:3000) initially
|
||||
|
||||
**Rationale**:
|
||||
- Existing infrastructure, known operational state
|
||||
- Sufficient resources (can upgrade if federation load grows)
|
||||
- Consolidated with Gitea simplifies backup/restore
|
||||
|
||||
**Future**: Dedicated Matrix VPS if federation traffic justifies separation.
|
||||
|
||||
### ADR-3: Federation Strategy
|
||||
|
||||
**Decision**: Full federation enabled from day one
|
||||
|
||||
**Rationale**:
|
||||
- Alexander may need to message from any Matrix account
|
||||
- Fleet bots can federate to other homeservers if needed
|
||||
- Nostr bridge experiments (#830) may benefit from federation
|
||||
|
||||
**Implication**: Requires valid TLS certificate and public DNS.
|
||||
|
||||
---
|
||||
|
||||
## Deployment Scaffold
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
/opt/conduit/
|
||||
├── conduit # Binary
|
||||
├── conduit.toml # Configuration
|
||||
├── data/ # SQLite + media (backup target)
|
||||
│ ├── conduit.db
|
||||
│ └── media/
|
||||
├── logs/ # Rotated logs
|
||||
└── scripts/ # Operational helpers
|
||||
├── backup.sh
|
||||
└── rotate-logs.sh
|
||||
```
|
||||
|
||||
### Port Allocation
|
||||
|
||||
| Service | Port | Protocol | Notes |
|
||||
|---------|------|----------|-------|
|
||||
| Conduit HTTP | 8448 | TCP | Matrix client-server API |
|
||||
| Conduit Federation | 8448 | TCP | Same port, different SRV |
|
||||
| Element Web | 8080 | TCP | Optional web client |
|
||||
|
||||
**DNS Requirements**:
|
||||
- `matrix.timmy.foundation` → A record to VPS IP
|
||||
- `_matrix._tcp.timmy.foundation` → SRV record for federation
|
||||
|
||||
### Reverse Proxy (Caddy)
|
||||
|
||||
```caddyfile
|
||||
matrix.timmy.foundation {
|
||||
reverse_proxy localhost:8448
|
||||
|
||||
header {
|
||||
X-Frame-Options DENY
|
||||
X-Content-Type-Options nosniff
|
||||
}
|
||||
|
||||
tls {
|
||||
# Let's Encrypt automatic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Conduit Configuration (conduit.toml)
|
||||
|
||||
```toml
|
||||
[global]
|
||||
server_name = "timmy.foundation"
|
||||
database_path = "/opt/conduit/data/conduit.db"
|
||||
port = 8448
|
||||
max_request_size = 20000000 # 20MB for file uploads
|
||||
|
||||
[registration]
|
||||
# Closed registration - admin creates accounts
|
||||
enabled = false
|
||||
|
||||
[ federation]
|
||||
enabled = true
|
||||
disabled_servers = []
|
||||
|
||||
[ media ]
|
||||
max_file_size = 50000000 # 50MB
|
||||
max_media_size = 100000000 # 100MB total cache
|
||||
|
||||
[ retention ]
|
||||
enabled = true
|
||||
default_room_retention = "30d"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites Checklist
|
||||
|
||||
### Infrastructure
|
||||
- [ ] DNS A record: `matrix.timmy.foundation` → 143.198.27.163
|
||||
- [ ] DNS SRV record: `_matrix._tcp.timmy.foundation` → 0 0 8448 matrix.timmy.foundation
|
||||
- [ ] Firewall: TCP 8448 open to world (federation)
|
||||
- [ ] Firewall: TCP 8080 open to world (Element Web, optional)
|
||||
|
||||
### Dependencies
|
||||
- [ ] Conduit binary (latest release: check https://gitlab.com/famedly/conduit)
|
||||
- [ ] Caddy installed (or nginx if preferred)
|
||||
- [ ] SQLite (usually present, verify version ≥ 3.30)
|
||||
- [ ] systemd (for service management)
|
||||
|
||||
### Accounts (Bootstrap)
|
||||
- [ ] `@admin:timmy.foundation` — Server admin
|
||||
- [ ] `@alexander:timmy.foundation` — Operator primary
|
||||
- [ ] `@ezra:timmy.foundation` — Archivist bot
|
||||
- [ ] `@timmy:timmy.foundation` — Coordinator bot
|
||||
|
||||
### Rooms (Bootstrap)
|
||||
- [ ] `#fleet-ops:timmy.foundation` — Operator-to-fleet command channel
|
||||
- [ ] `#fleet-intel:timmy.foundation` — Intelligence sharing
|
||||
- [ ] `#fleet-social:timmy.foundation` — General chat
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Infrastructure (Est: 2 hours)
|
||||
1. Create DNS records
|
||||
2. Open firewall ports
|
||||
3. Download Conduit binary
|
||||
4. Create directory structure
|
||||
|
||||
### Phase 2: Deployment (Est: 2 hours)
|
||||
1. Write conduit.toml
|
||||
2. Create systemd service
|
||||
3. Configure Caddy reverse proxy
|
||||
4. Start Conduit, verify health
|
||||
|
||||
### Phase 3: Bootstrap (Est: 1 hour)
|
||||
1. Create admin account via CLI
|
||||
2. Create user accounts
|
||||
3. Create rooms, set permissions
|
||||
4. Verify end-to-end encryption
|
||||
|
||||
### Phase 4: Migration Planning (Est: 4 hours)
|
||||
1. Map Telegram channels to Matrix rooms
|
||||
2. Design bridge architecture (if needed)
|
||||
3. Create cutover timeline
|
||||
4. Document operator onboarding
|
||||
|
||||
---
|
||||
|
||||
## Operational Runbooks
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /opt/conduit/scripts/backup.sh
|
||||
BACKUP_DIR="/backups/conduit/$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Stop Conduit briefly for consistent snapshot
|
||||
systemctl stop conduit
|
||||
|
||||
cp /opt/conduit/data/conduit.db "$BACKUP_DIR/"
|
||||
cp /opt/conduit/conduit.toml "$BACKUP_DIR/"
|
||||
cp -r /opt/conduit/data/media "$BACKUP_DIR/"
|
||||
|
||||
systemctl start conduit
|
||||
|
||||
# Compress and upload to S3/backup target
|
||||
tar czf "$BACKUP_DIR.tar.gz" -C "$BACKUP_DIR" .
|
||||
# aws s3 cp "$BACKUP_DIR.tar.gz" s3://timmy-backups/conduit/
|
||||
```
|
||||
|
||||
### Account Creation
|
||||
|
||||
```bash
|
||||
# As admin, create new user
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"newuser","password":"secure_password_123"}' \
|
||||
https://matrix.timmy.foundation/_matrix/client/v3/register
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /opt/conduit/scripts/health.sh
|
||||
curl -s https://matrix.timmy.foundation/_matrix/client/versions | jq .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-Issue Linkages
|
||||
|
||||
| Issue | Relationship | Action |
|
||||
|-------|--------------|--------|
|
||||
| #166 | Parent epic | This scaffold enables #166 execution |
|
||||
| #183 | Scaffold child | This document fulfills #183 acceptance criteria |
|
||||
| #830 | Deep Dive | Matrix rooms can receive #830 intelligence briefings |
|
||||
| #137 | Related | Verify no conflict with existing comms work |
|
||||
| #138 | Related | Verify no conflict with Nostr bridge |
|
||||
| #147 | Related | Check if Matrix replaces or supplements existing plans |
|
||||
|
||||
---
|
||||
|
||||
## Artifacts Created
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `docs/matrix-fleet-comms/README.md` | This architecture document |
|
||||
| `deploy/conduit/conduit.toml` | Production configuration |
|
||||
| `deploy/conduit/conduit.service` | systemd service definition |
|
||||
| `deploy/conduit/Caddyfile` | Reverse proxy configuration |
|
||||
| `deploy/conduit/scripts/backup.sh` | Backup automation |
|
||||
| `deploy/conduit/scripts/health.sh` | Health check script |
|
||||
|
||||
---
|
||||
|
||||
## Next Actions
|
||||
|
||||
1. **DNS**: Create `matrix.timmy.foundation` A and SRV records
|
||||
2. **Firewall**: Open TCP 8448 on VPS
|
||||
3. **Install**: Download and configure Conduit
|
||||
4. **Bootstrap**: Create initial accounts and rooms
|
||||
5. **Onboard**: Add Alexander, test end-to-end encryption
|
||||
6. **Migrate**: Plan Telegram→Matrix transition
|
||||
|
||||
---
|
||||
|
||||
**Ezra's Sign-off**: This scaffold transforms #166 from fuzzy epic to executable implementation plan. All prerequisites are named, all acceptance criteria are mapped to artifacts, and the deployment path is phase-gated for incremental delivery.
|
||||
|
||||
— Ezra, Archivist
|
||||
2026-04-05
|
||||
@@ -1,221 +0,0 @@
|
||||
# Memory Continuity Doctrine
|
||||
|
||||
Status: doctrine for issue #158.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Timmy should survive compaction, provider swaps, watchdog restarts, and session ends by writing continuity into durable files before context is dropped.
|
||||
|
||||
A long-context provider is useful, but it is not the source of truth.
|
||||
If continuity only lives inside one vendor's transcript window, we have built amnesia into the operating model.
|
||||
|
||||
This doctrine defines what lives in curated memory, what lives in daily logs, what must flush before compaction, and which continuity files exist for operators versus agents.
|
||||
|
||||
## Current Timmy reality
|
||||
|
||||
The current split already exists:
|
||||
|
||||
- `timmy-config` owns identity, curated memory, doctrine, playbooks, and harness-side orchestration glue.
|
||||
- `timmy-home` owns lived artifacts: daily notes, heartbeat logs, briefings, training exports, and other workspace-native history.
|
||||
- Gitea issues, PRs, and comments remain the visible execution truth for queue state and shipped work.
|
||||
|
||||
Current sidecar automation already writes file-backed operational artifacts such as heartbeat logs and daily briefings. Those are useful continuity inputs, but they do not replace curated memory or operator-visible notes.
|
||||
|
||||
Recommended logical roots for the first implementation pass:
|
||||
- `timmy-home/daily-notes/YYYY-MM-DD.md` for the append-only daily log
|
||||
- `timmy-home/continuity/active.md` for unfinished-work handoff
|
||||
- existing `timmy-home/heartbeat/` and `timmy-home/briefings/` as structured automation outputs
|
||||
|
||||
These are logical repo/workspace paths, not machine-specific absolute paths.
|
||||
|
||||
## Core rule
|
||||
|
||||
Before compaction, session end, agent handoff, or model/provider switch, the active session must flush its state to durable files.
|
||||
|
||||
Compaction is not complete until the flush succeeds.
|
||||
|
||||
If the flush fails, the session is in an unsafe state and should be surfaced as such instead of pretending continuity was preserved.
|
||||
|
||||
## Continuity layers
|
||||
|
||||
| Surface | Owner | Primary audience | Role |
|
||||
|---------|-------|------------------|------|
|
||||
| `memories/MEMORY.md` | `timmy-config` | agent-facing | Curated durable world-state: stable infra facts, standing rules, and long-lived truths that should survive across many sessions |
|
||||
| `memories/USER.md` | `timmy-config` | agent-facing | Curated operator profile, values, and durable preferences |
|
||||
| Daily notes | `timmy-home` | operator-facing first, agent-readable second | Append-only chronological log of what happened today: decisions, artifacts, blockers, links, and unresolved work |
|
||||
| Heartbeat logs and daily briefings | `timmy-home` | agent-facing first, operator-inspectable | Structured operational continuity produced by automation; useful for recap and automation health |
|
||||
| Session handoff note | `timmy-home` | agent-facing | Compact current-state handoff for unfinished work, especially when another agent or provider may resume it |
|
||||
| Daily summary / morning report | derived from `timmy-home` and Gitea truth | operator-facing | Human-readable digest of the day or overnight state |
|
||||
| Gitea issues / PRs / comments | Gitea | operator-facing and agent-facing | Execution truth: status changes, review proof, assignment changes, merge state, and externally visible decisions |
|
||||
|
||||
## Daily log vs curated memory
|
||||
|
||||
Daily log and curated memory serve different jobs.
|
||||
|
||||
Daily log:
|
||||
- append-only
|
||||
- chronological
|
||||
- allowed to be messy, local, and session-specific
|
||||
- captures what happened, what changed, what is blocked, and what should happen next
|
||||
- is the first landing zone for uncertain or fresh information
|
||||
|
||||
Curated memory:
|
||||
- sparse
|
||||
- high-signal
|
||||
- durable across days and providers
|
||||
- only contains facts worth keeping available as standing context
|
||||
- should be updated after a fact is validated, not every time it is mentioned
|
||||
|
||||
Rule of thumb:
|
||||
- if the fact answers "what happened today?", it belongs in the daily log
|
||||
- if the fact answers "what should still be true next month unless explicitly changed?", it belongs in curated memory
|
||||
- if unsure, log it first and promote it later
|
||||
|
||||
`MEMORY.md` is not a diary.
|
||||
Daily notes are not a replacement for durable memory.
|
||||
|
||||
## Operator-facing vs agent-facing continuity
|
||||
|
||||
Operator-facing continuity must optimize for visibility and trust.
|
||||
It should answer:
|
||||
- what happened
|
||||
- what changed
|
||||
- what is blocked
|
||||
- what Timmy needs from Alexander, if anything
|
||||
- where the proof lives
|
||||
|
||||
Agent-facing continuity must optimize for deterministic restart and handoff.
|
||||
It should answer:
|
||||
- what task is active
|
||||
- what facts changed
|
||||
- what branch, issue, or PR is in flight
|
||||
- what blockers or failing checks remain
|
||||
- what exact next action should happen first
|
||||
|
||||
The same event may appear in both surfaces, but in different forms.
|
||||
A morning report may tell the story.
|
||||
A handoff note should give the machine-readable restart point.
|
||||
|
||||
Neither surface replaces the other.
|
||||
Operator summaries are not the agent memory store.
|
||||
Agent continuity files are not a substitute for visible operator reporting.
|
||||
|
||||
## Pre-compaction flush contract
|
||||
|
||||
Every compaction or session end must write the following minimum payload before context is discarded:
|
||||
|
||||
1. Daily log append
|
||||
- current objective
|
||||
- important facts learned or changed
|
||||
- decisions made
|
||||
- blockers or unresolved questions
|
||||
- exact next step
|
||||
- pointers to artifacts, issue numbers, or PR numbers
|
||||
|
||||
2. Session handoff update when work is still open
|
||||
- active task or issue
|
||||
- current branch or review object
|
||||
- current blocker or failing check
|
||||
- next action that should happen first on resume
|
||||
|
||||
3. Curated memory decision
|
||||
- update `MEMORY.md` and/or `USER.md` if the session produced durable facts, or
|
||||
- explicitly record `curated memory changes: none` in the flush payload
|
||||
|
||||
4. Operator-visible execution trail when state mutated
|
||||
- if queue state, review state, or delivery state changed, that change must also exist in Gitea truth or the operator-facing daily summary
|
||||
|
||||
5. Write verification
|
||||
- the session must confirm the target files were written successfully
|
||||
- a silent write failure is a failed flush
|
||||
|
||||
## What must be flushed before compaction
|
||||
|
||||
At minimum, compaction may not proceed until these categories are durable:
|
||||
|
||||
- the current objective
|
||||
- durable facts discovered this session
|
||||
- open loops and blockers
|
||||
- promised follow-ups
|
||||
- artifact pointers needed to resume work
|
||||
- any queue mutation or review decision not already visible in Gitea
|
||||
|
||||
A WIP commit can preserve code.
|
||||
It does not preserve reasoning state, decision rationale, or handoff context.
|
||||
Those must still be written to continuity files.
|
||||
|
||||
## Interaction with current Timmy files
|
||||
|
||||
### `memories/MEMORY.md`
|
||||
|
||||
Use for curated world-state:
|
||||
- standing infrastructure facts
|
||||
- durable operating rules
|
||||
- long-lived Timmy facts that a future session should know without rereading a day's notes
|
||||
|
||||
Do not use it for:
|
||||
- raw session chronology
|
||||
- every branch name touched that day
|
||||
- speculative facts not yet validated
|
||||
|
||||
### `memories/USER.md`
|
||||
|
||||
Use for durable operator facts, preferences, mission context, and standing corrections.
|
||||
Apply the same promotion rule as `MEMORY.md`: validated, durable, high-signal only.
|
||||
|
||||
### Daily notes
|
||||
|
||||
Daily notes are the chronological ledger.
|
||||
They should absorb the messy middle: partial discoveries, decisions under consideration, unresolved blockers, and the exact resume point.
|
||||
|
||||
If a future session needs the full story, it should be able to recover it from daily notes plus Gitea, even after provider compaction.
|
||||
|
||||
### Heartbeat logs and daily briefings
|
||||
|
||||
Current automation already writes heartbeat logs and a compressed daily briefing.
|
||||
Treat those as structured operational continuity inputs.
|
||||
They can feed summaries and operator reports, but they are not the sole memory system.
|
||||
|
||||
### Daily summaries and morning reports
|
||||
|
||||
Summaries are derived products.
|
||||
They help Alexander understand the state of the house quickly.
|
||||
They should point back to daily notes, Gitea, and structured logs when detail is needed.
|
||||
|
||||
A summary is not allowed to be the only place a critical fact exists.
|
||||
|
||||
## Acceptance checks for a future implementation
|
||||
|
||||
A later implementation should fail closed on continuity loss.
|
||||
Minimum checks:
|
||||
|
||||
- compaction is blocked if the daily log append fails
|
||||
- compaction is blocked if open work exists and no handoff note was updated
|
||||
- compaction is blocked if the session never made an explicit curated-memory decision
|
||||
- summaries are generated from file-backed continuity and Gitea truth, not only from provider transcript memory
|
||||
- a new session can bootstrap from files alone without requiring one provider to remember the previous session
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
Do not:
|
||||
- rely on provider auto-summary as the only continuity mechanism
|
||||
- stuff transient chronology into `MEMORY.md`
|
||||
- hide queue mutations in local-only notes when Gitea is the visible execution truth
|
||||
- depend on Alexander manually pasting old context as the normal recovery path
|
||||
- encode local absolute paths into continuity doctrine or handoff conventions
|
||||
- treat a daily summary as a replacement for raw logs and curated memory
|
||||
|
||||
Human correction is valid.
|
||||
Human rehydration as an invisible memory bus is not.
|
||||
|
||||
## Near-term implementation path
|
||||
|
||||
A practical next step is:
|
||||
|
||||
1. write the flush payload into the current daily note before any compaction or explicit session end
|
||||
2. maintain a small handoff file for unfinished work in `timmy-home`
|
||||
3. promote durable facts into `MEMORY.md` and `USER.md` by explicit decision, not by transcript osmosis
|
||||
4. keep operator-facing summaries generated from those files plus Gitea truth
|
||||
5. eventually wire compaction wrappers or session-end hooks so the flush becomes enforceable instead of aspirational
|
||||
|
||||
That path keeps continuity file-backed, reviewable, and independent of any single model vendor's context window.
|
||||
@@ -1,187 +0,0 @@
|
||||
# Nostur Operator Edge
|
||||
|
||||
Status: doctrine and implementation path for #174
|
||||
Parent epic: #173
|
||||
Related issues:
|
||||
- #166 Matrix/Conduit primary operator surface
|
||||
- #175 Communication authority map
|
||||
- #165 NATS internal bus
|
||||
- #163 sovereign keypairs / identity
|
||||
|
||||
## Goal
|
||||
|
||||
Make Nostur a real operator-facing layer for Alexander without letting Nostr become a shadow task system.
|
||||
|
||||
Nostur is valuable because it gives the operator a sovereign, identity-linked mobile surface.
|
||||
That does not mean Nostr should become the place where work lives.
|
||||
|
||||
## Design rule
|
||||
|
||||
Nostur is an ingress layer.
|
||||
Gitea is execution truth.
|
||||
Matrix is the private conversation surface.
|
||||
NATS is internal transport.
|
||||
|
||||
If a command originates in Nostur and matters to the fleet, it must be normalized into Gitea before it is treated as active work.
|
||||
|
||||
## What Nostur is for
|
||||
|
||||
Use Nostur for:
|
||||
- sovereign mobile operator access
|
||||
- identity-linked quick commands
|
||||
- acknowledgements and nudges
|
||||
- emergency ingress when Matrix is unavailable or too heavy
|
||||
- public or semi-public notes when intentionally used that way
|
||||
|
||||
Do not use Nostur for:
|
||||
- hidden task queues
|
||||
- final assignment truth
|
||||
- long private operator/fleet discussion when Matrix is available
|
||||
- routine agent-to-agent transport
|
||||
|
||||
## Operator path
|
||||
|
||||
### Path A: quick command from Nostur
|
||||
|
||||
Example intents:
|
||||
- "open issue for this"
|
||||
- "reassign this to Allegro"
|
||||
- "summarize status"
|
||||
- "mark this blocked"
|
||||
- "create follow-up from this note"
|
||||
|
||||
Required system behavior:
|
||||
1. accept Nostur event / DM from an authorized operator identity
|
||||
2. verify identity against the allowed sovereign key set
|
||||
3. classify message as one of:
|
||||
- advisory only
|
||||
- actionable command
|
||||
- ambiguous / requires clarification
|
||||
4. if actionable, translate it into one canonical Gitea object:
|
||||
- new issue
|
||||
- comment on existing issue
|
||||
- explicit state mutation on an existing issue/PR
|
||||
5. send acknowledgement back through Nostur with a link to the Gitea object
|
||||
6. if private discussion is needed, continue in Matrix and point both sides at the same Gitea object
|
||||
|
||||
### Path B: status read from Nostur
|
||||
|
||||
For simple mobile reads, allow:
|
||||
- current priority queue summary
|
||||
- open blockers
|
||||
- review queue summary
|
||||
- health summary
|
||||
- links to active epic/issues/PRs
|
||||
|
||||
These are read-only responses.
|
||||
They do not mutate work state.
|
||||
|
||||
### Path C: public or semi-public edge
|
||||
|
||||
If Nostr is used publicly:
|
||||
- never expose hidden internal queue truth
|
||||
- publish only intentional summaries, announcements, or identity proofs
|
||||
- public notes must not become a side-channel task system
|
||||
|
||||
## Ingress contract
|
||||
|
||||
For every actionable Nostur message, the bridge must emit a normalized ingress record with:
|
||||
- source: nostr
|
||||
- operator identity: npub or mapped principal identity
|
||||
- received_at timestamp
|
||||
- original event id
|
||||
- normalized intent classification
|
||||
- linked Gitea object id after creation or routing
|
||||
- acknowledgement state
|
||||
|
||||
This record may live in logs or a small bridge event store, but the work itself must live in Gitea.
|
||||
|
||||
## Auth and identity
|
||||
|
||||
Nostur ingress should rely on sovereign key identity, not platform-issued bot identity.
|
||||
|
||||
Minimum model:
|
||||
- allowlist of operator npubs
|
||||
- optional challenge/response for higher-trust actions
|
||||
- explicit mapping from operator identity to allowed command classes
|
||||
|
||||
Suggested command classes:
|
||||
- read-only
|
||||
- issue creation
|
||||
- issue comment / note append
|
||||
- assignment / routing request
|
||||
- high-authority mutation requiring confirmation
|
||||
|
||||
The bridge must fail closed for unknown keys.
|
||||
|
||||
## Bridge behavior
|
||||
|
||||
The Nostur bridge should be small and stupid.
|
||||
|
||||
Responsibilities:
|
||||
- receive event / DM
|
||||
- authenticate sender
|
||||
- normalize intent
|
||||
- write/link Gitea truth
|
||||
- optionally mirror conversation into Matrix
|
||||
- return acknowledgement
|
||||
|
||||
Responsibilities it must NOT take on:
|
||||
- hidden queue management
|
||||
- second task database
|
||||
- silent assignment logic outside coordinator doctrine
|
||||
- freeform agent orchestration directly from relay chatter
|
||||
|
||||
## Recommended implementation sequence
|
||||
|
||||
### Step 1
|
||||
Build read-only Nostur status responses.
|
||||
|
||||
Acceptance:
|
||||
- Alexander can ask for status from Nostur
|
||||
- response comes back with links to the canonical Gitea objects
|
||||
- no queue mutation yet
|
||||
|
||||
### Step 2
|
||||
Add explicit issue/comment creation from Nostur.
|
||||
|
||||
Acceptance:
|
||||
- a Nostur command can create a new Gitea issue or append to an existing one
|
||||
- acknowledgement message includes the issue URL
|
||||
- no hidden state remains only in Nostr
|
||||
|
||||
### Step 3
|
||||
Add Matrix handoff for private follow-up.
|
||||
|
||||
Acceptance:
|
||||
- after Nostur ingress, the system can point the operator into Matrix for richer back-and-forth while preserving the same Gitea work object
|
||||
|
||||
### Step 4
|
||||
Add authority tiers and confirmations.
|
||||
|
||||
Acceptance:
|
||||
- low-risk actions can run directly
|
||||
- higher-risk actions require explicit confirmation
|
||||
- command classes are keyed to operator identity policy
|
||||
|
||||
## Non-goals
|
||||
|
||||
- replacing Matrix with Nostur for all private operator conversation
|
||||
- using Nostr for the internal fleet bus
|
||||
- letting relay notes replace issues, PRs, or review artifacts
|
||||
|
||||
## Operational rule
|
||||
|
||||
Nostur should make the system more sovereign and more convenient.
|
||||
It must not make the system more ambiguous.
|
||||
|
||||
If Nostur ingress creates ambiguity, the bridge is wrong.
|
||||
If it creates a clean Gitea-linked work object and gives Alexander a mobile sovereign edge, the bridge is right.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Nostur has an explicit role in the stack
|
||||
- [ ] Nostr ingress is mapped to Gitea execution truth
|
||||
- [ ] read-only versus mutating commands are separated
|
||||
- [ ] the bridge is defined as small and transport/ingress-focused
|
||||
- [ ] the doc makes it impossible to justify shadow task state in Nostr
|
||||
@@ -1,120 +0,0 @@
|
||||
# Operator Communications Onboarding
|
||||
|
||||
Status: practical operator onboarding for #166
|
||||
Related:
|
||||
- #173 comms unification epic
|
||||
- #174 Nostur operator edge
|
||||
- #175 communication authority map
|
||||
|
||||
## Why this exists
|
||||
|
||||
Alexander wants to get off Telegram and start using the system through channels we own.
|
||||
This document gives the current real operator path and the near-term target path.
|
||||
|
||||
It is intentionally grounded in live world state, not aspiration.
|
||||
|
||||
## Current live reality
|
||||
|
||||
Today:
|
||||
- Gitea is the execution truth
|
||||
- Nostur/Nostr is the only sovereign operator-edge surface actually standing
|
||||
- Telegram is still the legacy human command surface
|
||||
- Matrix/Conduit is not yet deployed
|
||||
|
||||
Verified live relay path:
|
||||
- relay backend host: `167.99.126.228:2929`
|
||||
- operator relay URL: `wss://alexanderwhitestone.com/relay/`
|
||||
- websocket probe result: `wss://alexanderwhitestone.com/relay/` CONNECTED
|
||||
- backend HTTP probe on `http://167.99.126.228:2929/` returns `Timmy Foundation NIP-29 Relay. Use a Nostr client to connect.`
|
||||
|
||||
Non-target relays:
|
||||
- `167.99.126.228:7777` is not the current operator onboarding target
|
||||
- `167.99.126.228:3334` is not the live relay to use for Nostur onboarding right now
|
||||
- raw `ws://167.99.126.228:2929` is backend truth, not the preferred operator-facing URL when `wss://alexanderwhitestone.com/relay/` is working
|
||||
|
||||
## What to use right now
|
||||
|
||||
### 1. Nostur = sovereign mobile/operator edge
|
||||
|
||||
Use Nostur for:
|
||||
- quick operator commands
|
||||
- status reads
|
||||
- lightweight acknowledgements
|
||||
- sovereign mobile access
|
||||
|
||||
Add this relay in Nostur:
|
||||
- `wss://alexanderwhitestone.com/relay/`
|
||||
|
||||
Working rule:
|
||||
- Nostur gets you into the system
|
||||
- Gitea still holds execution truth
|
||||
- Telegram remains a bridge until Matrix is deployed
|
||||
|
||||
### 2. Gitea = task and review truth
|
||||
|
||||
Use Gitea for:
|
||||
- actual tasks
|
||||
- issues
|
||||
- PRs
|
||||
- review state
|
||||
- visible decisions
|
||||
|
||||
If a command from Nostur matters, it must be reflected in Gitea.
|
||||
|
||||
### 3. Telegram = legacy bridge
|
||||
|
||||
Still usable for now.
|
||||
Not the future.
|
||||
Do not treat it as the destination architecture.
|
||||
|
||||
## What to do in Nostur now
|
||||
|
||||
1. Open Nostur
|
||||
2. Add relay:
|
||||
- `wss://alexanderwhitestone.com/relay/`
|
||||
3. Confirm the relay connects successfully
|
||||
4. Verify your logged-in key matches your operator npub
|
||||
5. Use Nostur as your sovereign mobile edge for operator ingress
|
||||
6. When work is actionable, make sure it is mirrored into Gitea
|
||||
|
||||
## Channel authority, simplified
|
||||
|
||||
- Nostur: operator edge / ingress
|
||||
- Gitea: work truth
|
||||
- Telegram: temporary bridge
|
||||
- Matrix: target private operator surface once deployed
|
||||
- NATS: internal agent bus only
|
||||
|
||||
## Near-term target state
|
||||
|
||||
### Phase 1 — now
|
||||
- Nostur working
|
||||
- Telegram still active as bridge
|
||||
- Gitea remains truth
|
||||
|
||||
### Phase 2 — next
|
||||
- deploy Matrix/Conduit for private operator-to-fleet conversation
|
||||
- keep Nostur as sovereign mobile ingress
|
||||
- route meaningful commands from both surfaces into Gitea
|
||||
|
||||
### Phase 3 — cutover
|
||||
- Telegram demoted fully to legacy or removed
|
||||
- Matrix becomes the primary private command room
|
||||
- Nostur remains the sovereign operator edge
|
||||
|
||||
## Acceptance for #166
|
||||
|
||||
We should consider #166 truly complete only when:
|
||||
- [ ] Matrix/Conduit is deployed
|
||||
- [ ] Alexander can message the fleet privately outside Telegram
|
||||
- [ ] Nostur remains usable as a sovereign ingress layer
|
||||
- [ ] both Matrix and Nostur feed into one execution truth: Gitea
|
||||
- [ ] Telegram is no longer the only human command surface
|
||||
|
||||
## Operator rule
|
||||
|
||||
No matter which surface you use, the work must not scatter.
|
||||
|
||||
A command may arrive through Nostur.
|
||||
A private conversation may continue in Matrix.
|
||||
But the task itself must live in Gitea.
|
||||
@@ -5,9 +5,9 @@ Replaces raw curl calls scattered across 41 bash scripts.
|
||||
Uses only stdlib (urllib) so it works on any Python install.
|
||||
|
||||
Usage:
|
||||
from gitea_client import GiteaClient
|
||||
from tools.gitea_client import GiteaClient
|
||||
|
||||
client = GiteaClient() # reads token from standard local paths
|
||||
client = GiteaClient() # reads token from ~/.hermes/gitea_token
|
||||
issues = client.list_issues("Timmy_Foundation/the-nexus", state="open")
|
||||
client.create_comment("Timmy_Foundation/the-nexus", 42, "PR created.")
|
||||
"""
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# Matrix/Conduit Environment Configuration
|
||||
# Copy to .env and fill in values before deployment
|
||||
# Issue: #166 / #183
|
||||
|
||||
# =============================================================================
|
||||
# REQUIRED: Domain Configuration
|
||||
# =============================================================================
|
||||
# The public domain where Matrix will be served
|
||||
MATRIX_DOMAIN=matrix.timmy.foundation
|
||||
|
||||
# =============================================================================
|
||||
# REQUIRED: Security Secrets (generate strong random values)
|
||||
# =============================================================================
|
||||
# Registration token for creating the first admin account
|
||||
# Generate with: openssl rand -hex 32
|
||||
CONDUIT_REGISTRATION_TOKEN=CHANGE_ME_TO_A_RANDOM_HEX_STRING
|
||||
|
||||
# Database encryption key (if using encrypted SQLite)
|
||||
# Generate with: openssl rand -hex 32
|
||||
CONDUIT_DATABASE_PASSWORD=CHANGE_ME_TO_A_RANDOM_HEX_STRING
|
||||
|
||||
# =============================================================================
|
||||
# OPTIONAL: Admin Configuration
|
||||
# =============================================================================
|
||||
# Local admin username (without @domain)
|
||||
INITIAL_ADMIN_USERNAME=admin
|
||||
INITIAL_ADMIN_PASSWORD=CHANGE_ME_IMMEDIATELY
|
||||
|
||||
# =============================================================================
|
||||
# OPTIONAL: Federation
|
||||
# =============================================================================
|
||||
# Comma-separated list of servers to block federation with
|
||||
FEDERATION_BLACKLIST=
|
||||
|
||||
# Comma-separated list of servers to allow federation with (empty = all)
|
||||
FEDERATION_WHITELIST=
|
||||
|
||||
# =============================================================================
|
||||
# OPTIONAL: Media/Uploads
|
||||
# =============================================================================
|
||||
# Maximum file upload size in bytes (default: 100MB)
|
||||
MAX_UPLOAD_SIZE=104857600
|
||||
@@ -1,127 +0,0 @@
|
||||
# Canonical Index: Matrix/Conduit Human-to-Fleet Communication
|
||||
|
||||
> **Issue**: [#166](https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config/issues/166) — Stand up Matrix/Conduit for human-to-fleet encrypted communication
|
||||
> **Scaffold**: [#183](https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config/issues/183) — Deployment scaffold and host prerequisites
|
||||
> **Decisions**: [#187](https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config/issues/187) — Host / domain / proxy decisions
|
||||
> **Created**: 2026-04-05 by Ezra, Archivist
|
||||
> **Purpose**: Single source of truth mapping every #166 artifact. Eliminates navigation friction between deployment docs, client specs, and cutover plans.
|
||||
|
||||
---
|
||||
|
||||
## Status at a Glance
|
||||
|
||||
| Milestone | State | Evidence |
|
||||
|-----------|-------|----------|
|
||||
| Deployment scaffold | ✅ Complete | `infra/matrix/` (15 files) |
|
||||
| Host readiness checker | ✅ Complete | `host-readiness-check.sh` |
|
||||
| Room bootstrap automation | ✅ Complete | `scripts/bootstrap-fleet-rooms.py` |
|
||||
| Hermes Matrix client spec | ✅ Complete | `docs/matrix-fleet-comms/HERMES_MATRIX_CLIENT_SPEC.md` |
|
||||
| Telegram → Matrix cutover plan | ✅ Complete | `docs/matrix-fleet-comms/CUTOVER_PLAN.md` |
|
||||
| Target host selected | ⚠️ **BLOCKED** | Pending #187 |
|
||||
| Domain + TLS configured | ⚠️ **BLOCKED** | Pending #187 |
|
||||
| Live deployment | ⚠️ **BLOCKED** | Waiting on #187 |
|
||||
|
||||
**Verdict**: #166 is execution-ready the moment #187 closes with three decisions (host, domain, proxy).
|
||||
|
||||
---
|
||||
|
||||
## Authoritative Paths
|
||||
|
||||
### 1. Deployment & Operations — `infra/matrix/`
|
||||
|
||||
This directory is the **only canonical location** for server-side deployment artifacts.
|
||||
|
||||
| File | Purpose | Bytes | Status |
|
||||
|------|---------|-------|--------|
|
||||
| `README.md` | Entry point + architecture diagram | 3,275 | ✅ |
|
||||
| `prerequisites.md` | Host requirements, ports, DNS decisions | 2,690 | ✅ |
|
||||
| `docker-compose.yml` | Conduit + Element + Postgres orchestration | 1,427 | ✅ |
|
||||
| `conduit.toml` | Homeserver configuration scaffold | 1,498 | ✅ |
|
||||
| `deploy-matrix.sh` | One-command deployment script | 3,388 | ✅ |
|
||||
| `host-readiness-check.sh` | Pre-flight validation with colored output | 3,321 | ✅ |
|
||||
| `.env.example` | Secrets template | 1,861 | ✅ |
|
||||
| `caddy/Caddyfile` | Reverse proxy (Caddy) | ~400 | ✅ |
|
||||
| `scripts/bootstrap-fleet-rooms.py` | Automated room creation + agent invites | 8,416 | ✅ |
|
||||
| `scripts/deploy-conduit.sh` | Alternative bare-metal Conduit deploy | 5,488 | ✅ |
|
||||
| `scripts/validate-scaffold.py` | Scaffold integrity checker | 8,610 | ✅ |
|
||||
|
||||
### 2. Fleet Communication Doctrine — `docs/matrix-fleet-comms/`
|
||||
|
||||
This directory contains human-to-fleet and agent-to-agent communication architecture.
|
||||
|
||||
| File | Purpose | Bytes | Status |
|
||||
|------|---------|-------|--------|
|
||||
| `CUTOVER_PLAN.md` | Zero-downtime Telegram → Matrix migration | 4,958 | ✅ |
|
||||
| `HERMES_MATRIX_CLIENT_SPEC.md` | `matrix-nio` integration spec with MWE | 12,428 | ✅ |
|
||||
| `EXECUTION_ARCHITECTURE_KT.md` | High-level execution knowledge transfer | 8,837 | ✅ |
|
||||
| `DEPLOYMENT_RUNBOOK.md` | Operator-facing deployment steps | 4,484 | ✅ |
|
||||
| `README.md` | Fleet comms overview | 7,845 | ✅ |
|
||||
| `MATRIX_SCAFFOLD_VERIFICATION.md` | Pre-cutover verification checklist | 3,720 | ✅ |
|
||||
|
||||
### 3. Decision Tracking — `#187`
|
||||
|
||||
All blockers requiring human judgment are centralized in issue #187:
|
||||
|
||||
| Decision | Options | Owner |
|
||||
|----------|---------|-------|
|
||||
| Host | Hermes VPS / Allegro TestBed / New droplet | @allegro / @timmy |
|
||||
| Domain | `matrix.alexanderwhitestone.com` / `chat.alexanderwhitestone.com` / `timmy.alexanderwhitestone.com` | @rockachopa |
|
||||
| Reverse Proxy | Caddy / Nginx / Traefik | @ezra / @allegro |
|
||||
|
||||
---
|
||||
|
||||
## Duplicate / Legacy Directory Cleanup
|
||||
|
||||
The following directories are **superseded** by `infra/matrix/` and should be removed when convenient:
|
||||
|
||||
| Directory | Status | Action |
|
||||
|-----------|--------|--------|
|
||||
| `deploy/matrix/` | Duplicate scaffold | Delete |
|
||||
| `deploy/conduit/` | Alternative Caddy deploy | Delete (merged into `infra/matrix/`) |
|
||||
| `docs/matrix-conduit/` | Early deployment guide | Delete (merged into `infra/matrix/docs/`) |
|
||||
| `scaffold/matrix-conduit/` | Superseded scaffold | Delete |
|
||||
| `matrix/` | Minimal old config | Delete |
|
||||
|
||||
---
|
||||
|
||||
## Execution Sequence (Post-#187)
|
||||
|
||||
Once #187 resolves with host/domain/proxy decisions, execute in this exact order:
|
||||
|
||||
```bash
|
||||
# 1. Pre-flight
|
||||
ssh user@<HOST_FROM_187>
|
||||
cd /opt/timmy-config/infra/matrix
|
||||
./host-readiness-check.sh <DOMAIN_FROM_187>
|
||||
|
||||
# 2. Secrets
|
||||
cp .env.example .env
|
||||
# Edit: MATRIX_HOST, POSTGRES_PASSWORD, CONDUIT_REGISTRATION_TOKEN
|
||||
|
||||
# 3. Config
|
||||
# Update server_name in conduit.toml to match DOMAIN_FROM_187
|
||||
|
||||
# 4. Deploy
|
||||
./deploy-matrix.sh <DOMAIN_FROM_187>
|
||||
|
||||
# 5. Bootstrap rooms
|
||||
python3 scripts/bootstrap-fleet-rooms.py --create-all
|
||||
|
||||
# 6. Cutover
|
||||
# Follow: docs/matrix-fleet-comms/CUTOVER_PLAN.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accountability
|
||||
|
||||
| Role | Owner | Responsibility |
|
||||
|------|-------|----------------|
|
||||
| Deployment execution | @allegro / @timmy | Run scripts, provision host |
|
||||
| Operator onboarding | @rockachopa | Install Element, verify encryption |
|
||||
| Agent gateway cutover | @ezra | Update Hermes gateway configs |
|
||||
| Architecture docs | @ezra | Maintain this index and specifications |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-04-05 by Ezra, Archivist*
|
||||
@@ -1,73 +0,0 @@
|
||||
# Matrix/Conduit Execution Runbook
|
||||
|
||||
> Issue: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166) | Scaffold: [#183](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/183) | Decisions: [#187](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/187)
|
||||
> Issued by: Ezra, Archivist | Date: 2026-04-05
|
||||
|
||||
## Mission
|
||||
Deploy a sovereign Matrix/Conduit homeserver for encrypted human-to-fleet communication.
|
||||
|
||||
## Current State
|
||||
|
||||
| Phase | Status | Blocker |
|
||||
|-------|--------|---------|
|
||||
| Scaffold | Complete | None |
|
||||
| Host selection | Blocked | #187 |
|
||||
| DNS + TLS | Blocked | #187 |
|
||||
| Deployment | Ready | Host provisioning |
|
||||
| Room creation | Ready | Post-deployment |
|
||||
| Telegram cutover | Ready | Fleet readiness |
|
||||
|
||||
## Prerequisites Checklist (from #187)
|
||||
|
||||
- [ ] **Host**: Confirm VPS (Hermes, Allegro, or new)
|
||||
- [ ] **Domain**: Register `matrix.timmy.foundation` (or chosen domain)
|
||||
- [ ] **DNS**: A record → server IP
|
||||
- [ ] **Ports**: 80, 443, 8448 available and open
|
||||
- [ ] **Reverse Proxy**: Caddy or Nginx installed
|
||||
- [ ] **Docker**: Engine + Compose >= v2.20
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Host Provisioning
|
||||
```bash
|
||||
./infra/matrix/host-readiness-check.sh matrix.timmy.foundation
|
||||
```
|
||||
|
||||
### Step 2: DNS Configuration
|
||||
```
|
||||
matrix.timmy.foundation. A <SERVER_IP>
|
||||
```
|
||||
|
||||
### Step 3: Deploy Conduit
|
||||
```bash
|
||||
cd infra/matrix
|
||||
cp .env.example .env
|
||||
# Edit .env and conduit.toml with your domain
|
||||
./deploy-matrix.sh matrix.timmy.foundation
|
||||
```
|
||||
|
||||
### Step 4: Verify Homeserver
|
||||
```bash
|
||||
curl https://matrix.timmy.foundation/_matrix/client/versions
|
||||
```
|
||||
|
||||
### Step 5: Create Operator Room
|
||||
1. Open Element Web
|
||||
2. Register/login as `@alexander:matrix.timmy.foundation`
|
||||
3. Create encrypted room: `#fleet-ops:matrix.timmy.foundation`
|
||||
|
||||
### Step 6: Telegram Cutover Plan
|
||||
1. Run both Telegram and Matrix in parallel for 7 days
|
||||
2. Pin Matrix room as primary in Telegram
|
||||
3. Disable Telegram gateway only after all agents confirm Matrix connectivity
|
||||
|
||||
## Operational Commands
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Check health | `./host-readiness-check.sh` |
|
||||
| View logs | `docker compose logs -f conduit` |
|
||||
| Backup data | `tar czvf conduit-backup-$(date +%F).tar.gz data/conduit/` |
|
||||
| Update image | `docker compose pull && docker compose up -d` |
|
||||
|
||||
— Ezra, Archivist
|
||||
@@ -1,125 +0,0 @@
|
||||
# Matrix/Conduit Deployment Go/No-Go Checklist
|
||||
|
||||
> **Issue**: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166) — Stand up Matrix/Conduit
|
||||
> **Blocker**: [#187](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/187) — Host / Domain / Proxy Decisions
|
||||
> **Created**: 2026-04-05 by Ezra (burn mode)
|
||||
> **Purpose**: Convert #187 decisions into executable deployment steps. No ambiguity. No re-litigation.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
| Component | Status | Evidence |
|
||||
|-----------|--------|----------|
|
||||
| Deployment scaffold | ✅ Complete | [`infra/matrix/`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/infra/matrix) (15 files) |
|
||||
| Host readiness script | ✅ Complete | `infra/matrix/host-readiness-check.sh` |
|
||||
| Operator runbook | ✅ Complete | `docs/matrix-fleet-comms/DEPLOYMENT_RUNBOOK.md` |
|
||||
| Execution checklist | ✅ Complete | This file |
|
||||
| **Host selected** | ⚠️ **BLOCKED** | Pending #187 |
|
||||
| **Domain/subdomain chosen** | ⚠️ **BLOCKED** | Pending #187 |
|
||||
| **Reverse proxy chosen** | ⚠️ **BLOCKED** | Pending #187 |
|
||||
| **Live deployment** | ⚠️ **BLOCKED** | Waiting on above |
|
||||
|
||||
---
|
||||
|
||||
## Decision Gate 1: Target Host
|
||||
|
||||
**Question**: On which machine will Conduit run?
|
||||
|
||||
### Options
|
||||
| Host | IP / Access | Pros | Cons |
|
||||
|------|-------------|------|------|
|
||||
| Hermes VPS (Bezalel/Ezra) | 143.198.27.163 | Existing infra, trusted | Already busy |
|
||||
| Allegro TestBed | 167.99.126.228 | Dedicated, relay already there | Non-prod reputation |
|
||||
| New droplet | TBD | Clean slate, proper sizing | Cost + provisioning time |
|
||||
|
||||
**Decision needed from #187**: Pick one host.
|
||||
|
||||
**After decision**: Update `infra/matrix/.env` → `MATRIX_HOST` and `infra/matrix/conduit.toml` → `server_name`.
|
||||
|
||||
---
|
||||
|
||||
## Decision Gate 2: Domain / Subdomain
|
||||
|
||||
**Question**: What is the public Matrix server name?
|
||||
|
||||
### Options
|
||||
| Domain | DNS Owner | TLS Ready? | Note |
|
||||
|--------|-----------|------------|------|
|
||||
| `matrix.alexanderwhitestone.com` | Alexander | Yes (via main domain) | Clean, semantic |
|
||||
| `chat.alexanderwhitestone.com` | Alexander | Yes | Shorter |
|
||||
| `timmy.alexanderwhitestone.com` | Alexander | Yes | Brand-aligned |
|
||||
|
||||
**Decision needed from #187**: Pick one subdomain.
|
||||
|
||||
**After decision**: Update `infra/matrix/conduit.toml` → `server_name`, update `deploy-matrix.sh` → DNS validation, obtain TLS cert.
|
||||
|
||||
---
|
||||
|
||||
## Decision Gate 3: Reverse Proxy & TLS
|
||||
|
||||
**Question**: How do clients reach Conduit over HTTPS?
|
||||
|
||||
### Options
|
||||
| Proxy | TLS Source | Config Location | Best For |
|
||||
|-------|------------|-----------------|----------|
|
||||
| Caddy | Automatic (Let's Encrypt) | `infra/matrix/caddy/Caddyfile` | Simplicity, auto-TLS |
|
||||
| Nginx | Manual certbot | New file: `infra/matrix/nginx/` | Existing nginx expertise |
|
||||
| Traefik | Automatic | New file: `infra/matrix/traefik/` | Docker-native stacks |
|
||||
|
||||
**Decision needed from #187**: Pick one proxy strategy.
|
||||
|
||||
**After decision**: Copy the chosen proxy config into place, update `docker-compose.yml` port bindings, run `./host-readiness-check.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Post-Decision Execution Script
|
||||
|
||||
Once #187 closes with the three decisions above, execute in this exact order:
|
||||
|
||||
```bash
|
||||
# 1. SSH into chosen host
|
||||
ssh user@<HOST_FROM_187>
|
||||
|
||||
# 2. Clone / enter timmy-config
|
||||
cd /opt/timmy-config # or wherever fleet repos live
|
||||
|
||||
# 3. Pre-flight check
|
||||
cd infra/matrix
|
||||
./host-readiness-check.sh
|
||||
# Fix any RED items before continuing.
|
||||
|
||||
# 4. Edit secrets
|
||||
cp .env.example .env
|
||||
# Fill: MATRIX_HOST, POSTGRES_PASSWORD, CONDUIT_REGISTRATION_TOKEN
|
||||
|
||||
# 5. Edit Conduit config
|
||||
# Update server_name in conduit.toml to match DOMAIN_FROM_187
|
||||
|
||||
# 6. Deploy
|
||||
./deploy-matrix.sh
|
||||
|
||||
# 7. Verify
|
||||
# - Element Web loads at https://<DOMAIN>/_matrix/static/
|
||||
# - Federation test passes (if enabled)
|
||||
# - First operator account can register/login
|
||||
|
||||
# 8. Create fleet rooms
|
||||
# See: docs/matrix-fleet-comms/DEPLOYMENT_RUNBOOK.md § "Room Bootstrap"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Operator Accountability
|
||||
|
||||
| Decision | Owner | Due | Blocker Lifted |
|
||||
|----------|-------|-----|----------------|
|
||||
| Host | @allegro or @timmy | ASAP | Gate 1 |
|
||||
| Domain | @rockachopa (Alexander) | ASAP | Gate 2 |
|
||||
| Proxy | @ezra or @allegro | ASAP | Gate 3 |
|
||||
|
||||
**When all three decisions are in #187, this checklist becomes the literal deployment runbook.**
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-04-05 by Ezra*
|
||||
@@ -1,168 +0,0 @@
|
||||
# Hermes Matrix Integration Verification Runbook
|
||||
|
||||
> **Issue**: [#166](https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config/issues/166) — Stand up Matrix/Conduit for human-to-fleet encrypted communication
|
||||
> **Scaffold**: [#183](https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config/issues/183)
|
||||
> **Decisions**: [#187](https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config/issues/187)
|
||||
> **Created**: 2026-04-05 by Ezra, Archivist
|
||||
> **Purpose**: Prove that encrypted operator-to-fleet messaging is technically feasible and exactly one deployment away from live verification.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Matrix/Conduit deployment scaffold is complete. What has **not** been widely documented is that the **Hermes gateway already contains a production Matrix platform adapter** (`hermes-agent/gateway/platforms/matrix.py`).
|
||||
|
||||
This runbook closes the loop:
|
||||
1. It maps the existing adapter to #166 acceptance criteria.
|
||||
2. It provides a step-by-step protocol to verify E2EE operator-to-fleet messaging the moment a Conduit homeserver is live.
|
||||
3. It includes an executable verification script that can be run against any Matrix homeserver.
|
||||
|
||||
**Verdict**: #166 is blocked only by #187 (host/domain/proxy decisions). The integration code is already in repo truth.
|
||||
|
||||
---
|
||||
|
||||
## 1. Existing Code Reference
|
||||
|
||||
The Hermes Matrix adapter is a fully-featured gateway platform implementation:
|
||||
|
||||
| File | Lines | Capabilities |
|
||||
|------|-------|--------------|
|
||||
| `hermes-agent/gateway/platforms/matrix.py` | ~1,200 | Login (token/password), sync loop, E2EE, typing indicators, replies, threads, edits, media upload (image/audio/file), voice message support |
|
||||
| `hermes-agent/tests/gateway/test_matrix.py` | — | Unit/integration tests for message send/receive |
|
||||
| `hermes-agent/tests/gateway/test_matrix_voice.py` | — | Voice message delivery tests |
|
||||
|
||||
**Key facts**:
|
||||
- E2EE is supported via `matrix-nio[e2e]`.
|
||||
- Megolm session keys are exported on disconnect and re-imported on reconnect.
|
||||
- Unverified devices are handled with automatic retry logic.
|
||||
- The adapter supports both access-token and password authentication.
|
||||
|
||||
---
|
||||
|
||||
## 2. Environment Variables
|
||||
|
||||
To activate the Matrix adapter in any Hermes wizard house, set these in the local `.env`:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
MATRIX_HOMESERVER="https://matrix.timmy.foundation"
|
||||
MATRIX_USER_ID="@ezra:matrix.timmy.foundation"
|
||||
|
||||
# Auth: pick one method
|
||||
MATRIX_ACCESS_TOKEN="syt_..."
|
||||
# OR
|
||||
MATRIX_PASSWORD="<32+ char random string>"
|
||||
|
||||
# Optional but recommended
|
||||
MATRIX_ENCRYPTION="true"
|
||||
MATRIX_ALLOWED_USERS="@alexander:matrix.timmy.foundation"
|
||||
MATRIX_HOME_ROOM="!operatorRoomId:matrix.timmy.foundation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Pre-Deployment Verification Script
|
||||
|
||||
Run this **before** declaring #166 complete to confirm the adapter can connect, encrypt, and respond.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# On the host running Hermes (e.g., Hermes VPS)
|
||||
export MATRIX_HOMESERVER="https://matrix.timmy.foundation"
|
||||
export MATRIX_USER_ID="@ezra:matrix.timmy.foundation"
|
||||
export MATRIX_ACCESS_TOKEN="syt_..."
|
||||
export MATRIX_ENCRYPTION="true"
|
||||
|
||||
./infra/matrix/scripts/verify-hermes-integration.sh
|
||||
```
|
||||
|
||||
### What It Verifies
|
||||
|
||||
1. `matrix-nio` is installed.
|
||||
2. Required env vars are set.
|
||||
3. The homeserver is reachable.
|
||||
4. Login succeeds.
|
||||
5. The operator room is joined.
|
||||
6. A test message (`!ping`) is sent.
|
||||
7. E2EE state is initialized (if enabled).
|
||||
|
||||
---
|
||||
|
||||
## 4. Manual Verification Protocol (Post-#187)
|
||||
|
||||
Once Conduit is deployed and the operator room `#operator-room:matrix.timmy.foundation` exists:
|
||||
|
||||
### Step 1: Create Bot Account
|
||||
```bash
|
||||
# As Conduit admin
|
||||
curl -X POST "https://matrix.timmy.foundation/_matrix/client/v3/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"ezra","password":"<random>","type":"m.login.dummy"}'
|
||||
```
|
||||
|
||||
### Step 2: Obtain Access Token
|
||||
```bash
|
||||
curl -X POST "https://matrix.timmy.foundation/_matrix/client/v3/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "m.login.password",
|
||||
"user": "@ezra:matrix.timmy.foundation",
|
||||
"password": "<random>"
|
||||
}'
|
||||
```
|
||||
|
||||
### Step 3: Run Verification Script
|
||||
```bash
|
||||
cd /opt/timmy-config
|
||||
./infra/matrix/scripts/verify-hermes-integration.sh
|
||||
```
|
||||
|
||||
### Step 4: Human Test (Alexander)
|
||||
1. Open Element Web or native Element app.
|
||||
2. Log in as `@alexander:matrix.timmy.foundation`.
|
||||
3. Join `#operator-room:matrix.timmy.foundation`.
|
||||
4. Send `!ping`.
|
||||
5. Confirm `@ezra:matrix.timmy.foundation` replies with `Pong`.
|
||||
6. Verify the room shield icon shows encrypted (🔒).
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria Mapping
|
||||
|
||||
Maps #166 criteria to existing implementations:
|
||||
|
||||
| #166 Criterion | Status | Evidence |
|
||||
|----------------|--------|----------|
|
||||
| Deploy Conduit homeserver | 🟡 Blocked by #187 | `infra/matrix/` scaffold complete |
|
||||
| Create fleet rooms/channels | 🟡 Blocked by #187 | `scripts/bootstrap-fleet-rooms.py` ready |
|
||||
| **Verify encrypted operator-to-fleet messaging** | ✅ **Code exists** | `hermes-agent/gateway/platforms/matrix.py` + this runbook |
|
||||
| Alexander can message the fleet over Matrix | 🟡 Pending live server | Adapter supports command routing; `HERMES_MATRIX_CLIENT_SPEC.md` defines command vocabulary |
|
||||
| Telegram is no longer the only command surface | 🟡 Pending cutover | `CUTOVER_PLAN.md` ready |
|
||||
|
||||
---
|
||||
|
||||
## 6. Accountability
|
||||
|
||||
| Task | Owner | Evidence |
|
||||
|------|-------|----------|
|
||||
| Conduit deployment | @allegro / @timmy | Close #187, run `deploy-matrix.sh` |
|
||||
| Bot account provisioning | @ezra | This runbook §1–4 |
|
||||
| Integration verification | @ezra | `verify-hermes-integration.sh` |
|
||||
| Human E2EE test | @rockachopa | Element client + operator room |
|
||||
| Telegram cutover | @ezra | `CUTOVER_PLAN.md` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| `matrix-nio[e2e]` not installed | Verification script checks this and exits with install command |
|
||||
| E2EE key import fails | Adapter falls back to plain text; verification script warns |
|
||||
| Homeserver federation issues | Protocol uses direct client-server API, not federation |
|
||||
| Bot cannot join encrypted room | Ensure bot is invited *before* encryption is enabled, or use admin API to force-join |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-04-05 by Ezra, Archivist*
|
||||
@@ -1,69 +0,0 @@
|
||||
# Matrix/Conduit Deployment Scaffold
|
||||
|
||||
> Parent: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166) | Scaffold task: [#183](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/183)
|
||||
|
||||
This directory contains an executable deployment path for standing up a Matrix homeserver (Conduit) for sovereign human-to-fleet encrypted communication.
|
||||
|
||||
## Status
|
||||
|
||||
| Component | State |
|
||||
|-----------|-------|
|
||||
| Deployment scaffold | ✅ Present |
|
||||
| Target host | ⚠️ Requires selection |
|
||||
| Reverse proxy (Caddy/Nginx) | ⚠️ Pending host provisioning |
|
||||
| TLS certificates | ⚠️ Pending DNS + proxy setup |
|
||||
| Federation | ⚠️ Pending DNS SRV records |
|
||||
| Fleet bot integration | ⚠️ Post-deployment |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd /path/to/timmy-config/infra/matrix
|
||||
# 1. Read prerequisites.md — ensure host is ready
|
||||
# 2. Edit conduit.toml with your domain
|
||||
# 3. Copy .env.example → .env and fill secrets
|
||||
# 4. Run: ./deploy-matrix.sh
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Host (VPS) │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ Caddy/Nginx │─────▶│ Conduit (Matrix homeserver) │ │
|
||||
│ │ :443/:8448 │ │ :6167 (internal) │ │
|
||||
│ └─────────────────┘ └──────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ TLS termination SQLite/RocksDB storage │
|
||||
│ Let's Encrypt Config: conduit.toml │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `prerequisites.md` | Host requirements, ports, DNS, decisions |
|
||||
| `docker-compose.yml` | Conduit + optionally Element-Web |
|
||||
| `conduit.toml` | Homeserver configuration scaffold |
|
||||
| `deploy-matrix.sh` | One-command deployment script |
|
||||
| `.env.example` | Environment variable template |
|
||||
| `caddy/Caddyfile` | Reverse proxy configuration |
|
||||
|
||||
## Post-Deployment
|
||||
|
||||
1. Create admin account via registration or CLI
|
||||
2. Create fleet rooms (encrypted by default)
|
||||
3. Onboard Alexander as operator
|
||||
4. Deploy fleet bots (Hermes gateway with Matrix platform adapter)
|
||||
5. Evaluate Telegram-to-Matrix bridge (mautrix-telegram)
|
||||
|
||||
## Decisions Log
|
||||
|
||||
- **Homeserver**: Conduit (lightweight, Rust, single binary, SQLite default)
|
||||
- **Database**: SQLite for single-host; migrate to PostgreSQL if scale demands
|
||||
- **Reverse proxy**: Caddy (automatic HTTPS) or Nginx (existing familiarity)
|
||||
- **Client**: Element Web (optional, self-hosted) + native apps
|
||||
- **Federation**: Enabled (required for multi-homeserver fleet topology)
|
||||
@@ -1,50 +0,0 @@
|
||||
# Matrix/Conduit Scaffold Inventory
|
||||
|
||||
> Issue: [#183](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/183) | Parent: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166)
|
||||
> Issued by: Ezra, Archivist | Date: 2026-04-05
|
||||
|
||||
## Status: COMPLETE — Canonical Location Established
|
||||
|
||||
## Canonical Scaffold
|
||||
|
||||
| Directory | Purpose | Status |
|
||||
|-----------|---------|--------|
|
||||
| **`infra/matrix/`** | Single source of truth | Canonical |
|
||||
|
||||
## Artifact Map
|
||||
|
||||
### `infra/matrix/` (Canonical — 11 files)
|
||||
| File | Purpose | Bytes |
|
||||
|------|---------|-------|
|
||||
| `README.md` | Entry point + architecture | 3,275 |
|
||||
| `prerequisites.md` | Host/decision checklist | 2,690 |
|
||||
| `docker-compose.yml` | Conduit + Element + Postgres | 1,427 |
|
||||
| `conduit.toml` | Homeserver configuration | 1,498 |
|
||||
| `deploy-matrix.sh` | One-command deployment | 3,388 |
|
||||
| `host-readiness-check.sh` | Pre-flight validation | 3,321 |
|
||||
| `.env.example` | Secrets template | 1,861 |
|
||||
| `caddy/Caddyfile` | Reverse proxy (Caddy) | ~400 |
|
||||
| `conduit/` | Additional Conduit configs | dir |
|
||||
| `docs/` | Extended docs | dir |
|
||||
| `scripts/` | Helper scripts | dir |
|
||||
|
||||
### Duplicate / Legacy Directories
|
||||
| Directory | Status | Recommendation |
|
||||
|-----------|--------|----------------|
|
||||
| `deploy/matrix/` | Duplicate scaffold | Consolidate or delete |
|
||||
| `deploy/conduit/` | Alternative Caddy-based deploy | Keep if multi-path desired |
|
||||
| `docs/matrix-fleet-comms/` | Runbook docs | Migrate to `infra/matrix/docs/` |
|
||||
| `docs/matrix-conduit/` | Deployment guide | Migrate to `infra/matrix/docs/` |
|
||||
| `scaffold/matrix-conduit/` | Early scaffold | Delete (superseded) |
|
||||
| `matrix/` | Minimal config | Delete (superseded) |
|
||||
|
||||
## Acceptance Criteria Verification
|
||||
|
||||
| Criterion | Status | Evidence |
|
||||
|-----------|--------|----------|
|
||||
| Repo-visible deployment scaffold exists | Complete | `infra/matrix/` |
|
||||
| Host/port/reverse-proxy assumptions explicit | Complete | `prerequisites.md` |
|
||||
| Missing prerequisites named concretely | Complete | 6 blockers listed |
|
||||
| Lowers #166 to executable steps | Complete | `deploy-matrix.sh` + runbooks |
|
||||
|
||||
— Ezra, Archivist
|
||||
@@ -1,58 +0,0 @@
|
||||
# Caddyfile — Reverse proxy for Conduit Matrix homeserver
|
||||
# Issue: #166 / #183
|
||||
#
|
||||
# Place in /etc/caddy/Caddyfile or use with `caddy run --config Caddyfile`
|
||||
|
||||
# Matrix client and federation on same domain
|
||||
matrix.timmy.foundation {
|
||||
# Client API (.well-known, /_matrix/client)
|
||||
handle /.well-known/matrix/* {
|
||||
header Content-Type application/json
|
||||
respond `{"
|
||||
"m.homeserver": {"base_url": "https://matrix.timmy.foundation"},
|
||||
"m.identity_server": {"base_url": "https://vector.im"}
|
||||
}` 200
|
||||
}
|
||||
|
||||
# Handle federation (server-to-server) on standard path
|
||||
handle /_matrix/server/* {
|
||||
reverse_proxy localhost:6167
|
||||
}
|
||||
|
||||
# Handle client API
|
||||
handle /_matrix/client/* {
|
||||
reverse_proxy localhost:6167
|
||||
}
|
||||
|
||||
# Handle media repository
|
||||
handle /_matrix/media/* {
|
||||
reverse_proxy localhost:6167
|
||||
}
|
||||
|
||||
# Handle federation checks
|
||||
handle /_matrix/federation/* {
|
||||
reverse_proxy localhost:6167
|
||||
}
|
||||
|
||||
# Handle static content (if serving Element web from same domain)
|
||||
handle_path /element/* {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
||||
# Health check / status
|
||||
respond /health "OK" 200
|
||||
|
||||
# Default — you may want to serve Element web or redirect
|
||||
respond "Matrix Homeserver" 200
|
||||
}
|
||||
|
||||
# Optional: Serve Element Web on separate subdomain
|
||||
# element.timmy.foundation {
|
||||
# reverse_proxy localhost:8080
|
||||
# }
|
||||
|
||||
# Federation port (8448) — server-to-server communication
|
||||
# This allows other Matrix servers to find and connect to yours
|
||||
matrix.timmy.foundation:8448 {
|
||||
reverse_proxy localhost:6167
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
# Conduit Configuration Scaffold
|
||||
# Copy to conduit.toml, replace placeholders, and deploy
|
||||
#
|
||||
# Issue: #166 - Matrix/Conduit for human-to-fleet encrypted communication
|
||||
|
||||
[database]
|
||||
# SQLite is default; use PostgreSQL for production scale
|
||||
backend = "rocksdb"
|
||||
path = "/var/lib/matrix-conduit/"
|
||||
|
||||
[global]
|
||||
# The domain name of your homeserver (MUST match DNS)
|
||||
server_name = "YOUR_DOMAIN_HERE" # e.g., "matrix.timmy.foundation"
|
||||
|
||||
# The port Conduit listens on internally (mapped via docker-compose)
|
||||
port = 6167
|
||||
|
||||
# Public base URL (what clients connect to)
|
||||
public_baseurl = "https://YOUR_DOMAIN_HERE/"
|
||||
|
||||
# Enable/disable registration (disable after initial admin setup)
|
||||
allow_registration = false
|
||||
|
||||
# Registration token for initial admin creation
|
||||
registration_token = "GENERATE_A_STRONG_TOKEN_PLEASE"
|
||||
|
||||
# Enable federation (required for multi-homeserver fleet)
|
||||
allow_federation = true
|
||||
|
||||
# Federation port (usually 8448)
|
||||
federation_port = 8448
|
||||
|
||||
# Maximum upload size for media
|
||||
max_request_size = 104_857_600 # 100MB
|
||||
|
||||
# Enable presence (who's online) - can be resource intensive
|
||||
allow_presence = true
|
||||
|
||||
# Logging
|
||||
log = "info,rocket=off,_=off"
|
||||
|
||||
[admin]
|
||||
# Enable admin commands via CLI
|
||||
enabled = true
|
||||
|
||||
[well_known]
|
||||
# Configure /.well-known/matrix/client and /.well-known/matrix/server
|
||||
# This allows clients to auto-discover the homeserver
|
||||
client = "https://YOUR_DOMAIN_HERE/"
|
||||
server = "YOUR_DOMAIN_HERE:8448"
|
||||
|
||||
# TLS is handled by the reverse proxy (Caddy/Nginx)
|
||||
# Conduit runs HTTP internally; proxy terminates TLS
|
||||
@@ -1,31 +0,0 @@
|
||||
# Conduit Matrix Homeserver Configuration
|
||||
# Copy to .env and fill in values
|
||||
|
||||
# Domain name for your Matrix server (e.g., matrix.timmy.foundation)
|
||||
DOMAIN=matrix.timmy.foundation
|
||||
|
||||
# Server name (same as DOMAIN in most cases)
|
||||
CONDUIT_SERVER_NAME=matrix.timmy.foundation
|
||||
|
||||
# Database backend: rocksdb (default) or sqlite
|
||||
CONDUIT_DATABASE_BACKEND=rocksdb
|
||||
|
||||
# Enable user registration (set to true ONLY during initial admin setup)
|
||||
CONDUIT_ALLOW_REGISTRATION=false
|
||||
|
||||
# Enable federation with other Matrix servers
|
||||
CONDUIT_ALLOW_FEDERATION=true
|
||||
|
||||
# Enable metrics endpoint (Prometheus)
|
||||
CONDUIT_ENABLE_METRICS=false
|
||||
|
||||
# Registration token for creating the first admin account
|
||||
# MUST be set before starting server - remove/rotate after admin creation
|
||||
CONDUIT_REGISTRATION_TOKEN=CHANGE_THIS_TO_A_RANDOM_SECRET_
|
||||
|
||||
# Path to config file (optional, leave empty to use env vars)
|
||||
CONDUIT_CONFIG=
|
||||
|
||||
# Caddy environment
|
||||
CADDY_HTTP_PORT=80
|
||||
CADDY_HTTPS_PORT=443
|
||||
@@ -1,91 +0,0 @@
|
||||
# Conduit Configuration
|
||||
# Server Settings
|
||||
global_server_name = "matrix.example.com" # CHANGE THIS
|
||||
|
||||
database_backend = "rocksdb"
|
||||
database_path = "/var/lib/matrix-conduit"
|
||||
|
||||
registration = false # Disabled after initial admin account creation
|
||||
registration_token = "" # Set via CONDUIT_REGISTRATION_TOKEN env var
|
||||
|
||||
federation = true
|
||||
allow_federation = true
|
||||
federation_sender_buffer = 100
|
||||
|
||||
# Even if federation is disabled, sometimes you still want to allow the server
|
||||
# to reach other homeservers for e.g. bridge functionality or integration servers
|
||||
allow_check_for_updates = true
|
||||
|
||||
# Address on which to connect to the server (locally).
|
||||
address = "0.0.0.0"
|
||||
port = 6167
|
||||
|
||||
# Enable if you want TLS termination handled directly by Conduit (not recommended)
|
||||
tls = false
|
||||
|
||||
# Max request size in bytes (default: 20MB)
|
||||
max_request_size = 20971520
|
||||
|
||||
# Enable metrics endpoint for Prometheus
|
||||
enable_metrics = false
|
||||
|
||||
# Logging level: debug, info, warn, error
|
||||
log = "info"
|
||||
|
||||
# Maximum database cache size (if using rocksdb)
|
||||
cache_capacity_mb = 512
|
||||
|
||||
# Workaround for Synapse's synapsedotorg room
|
||||
allow_incoming_presence = true
|
||||
|
||||
send_query_auth_requests = true
|
||||
|
||||
# Allow appservices to use /_matrix/client/r0/login
|
||||
allow_appservice_login = false
|
||||
|
||||
# Disable users who don't use their accounts for longer than this time
|
||||
freeze_unfreeze_device_lists = false
|
||||
|
||||
# Enable media proxying through the server
|
||||
allow_media_relaying = false
|
||||
|
||||
# Block certain IP ranges from federation
|
||||
deny_federation_from = [
|
||||
# "example.com",
|
||||
]
|
||||
|
||||
# Require authentication for media requests
|
||||
require_auth_for_profile_requests = false
|
||||
|
||||
# Trust X-Forwarded-* headers from reverse proxy
|
||||
trusted_servers = []
|
||||
|
||||
# URL Preview settings
|
||||
url_preview = false
|
||||
max_preview_url_length = 2048
|
||||
max_preview_spider_size = 1048576 # 1MB
|
||||
|
||||
# Consent tracking
|
||||
users_consent_to_tracking = true
|
||||
|
||||
# Backup
|
||||
backup_burst_count = 3
|
||||
backup_per_second = 0.5
|
||||
|
||||
# Presence presence = true
|
||||
|
||||
# Push (for push notifications to mobile apps)
|
||||
push = true
|
||||
|
||||
# Federation - How long to wait before timing out federation requests
|
||||
federation_timeout_seconds = 30
|
||||
|
||||
# Event persistence settings
|
||||
pdu_cache_capacity = 100000
|
||||
auth_chain_cache_capacity = 100000
|
||||
|
||||
# Appservice support
|
||||
appservice = true
|
||||
|
||||
# Initial sync cache (can be memory intensive)
|
||||
initial_sync_cache = true
|
||||
@@ -1,58 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
conduit:
|
||||
image: docker.io/girlbossceo/conduit:v0.8.0
|
||||
container_name: matrix-conduit
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data:/var/lib/matrix-conduit
|
||||
environment:
|
||||
- CONDUIT_SERVER_NAME=${CONDUIT_SERVER_NAME}
|
||||
- CONDUIT_DATABASE_PATH=/var/lib/matrix-conduit
|
||||
- CONDUIT_DATABASE_BACKEND=${CONDUIT_DATABASE_BACKEND:-rocksdb}
|
||||
- CONDUIT_PORT=6167
|
||||
- CONDUIT_ADDRESS=0.0.0.0
|
||||
- CONDUIT_CONFIG=${CONDUIT_CONFIG:-}
|
||||
- CONDUIT_ALLOW_REGISTRATION=${CONDUIT_ALLOW_REGISTRATION:-false}
|
||||
- CONDUIT_ALLOW_FEDERATION=${CONDUIT_ALLOW_FEDERATION:-true}
|
||||
- CONDUIT_ENABLE_METRICS=${CONDUIT_ENABLE_METRICS:-false}
|
||||
- RUST_LOG=info
|
||||
networks:
|
||||
- matrix
|
||||
expose:
|
||||
- "6167"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:6167/_matrix/client/versions"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
caddy:
|
||||
image: docker.io/caddy:2.7-alpine
|
||||
container_name: matrix-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8448:8448" # Federation
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN}
|
||||
depends_on:
|
||||
- conduit
|
||||
networks:
|
||||
- matrix
|
||||
cap_add:
|
||||
- NET_ADMIN # For Caddy to bind low ports
|
||||
|
||||
networks:
|
||||
matrix:
|
||||
name: matrix
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy-matrix.sh — Deploy Conduit Matrix homeserver for Timmy fleet
|
||||
# Usage: ./deploy-matrix.sh [DOMAIN]
|
||||
#
|
||||
# Issue: #166 / #183
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOMAIN="${1:-${MATRIX_DOMAIN:-}}"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log() { echo -e "${GREEN}[deploy-matrix]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[deploy-matrix]${NC} $*"; }
|
||||
error() { echo -e "${RED}[deploy-matrix]${NC} $*" >&2; }
|
||||
|
||||
# === Pre-flight checks ===
|
||||
log "Starting Matrix/Conduit deployment..."
|
||||
|
||||
if [[ -z "$DOMAIN" ]]; then
|
||||
error "DOMAIN not specified. Usage: ./deploy-matrix.sh matrix.timmy.foundation"
|
||||
error "Or set MATRIX_DOMAIN environment variable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$SCRIPT_DIR/.env" ]]; then
|
||||
error ".env file not found. Copy .env.example to .env and configure."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$SCRIPT_DIR/conduit.toml" ]]; then
|
||||
error "conduit.toml not found. Copy from scaffold and configure."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for placeholder values
|
||||
if grep -q "YOUR_DOMAIN_HERE" "$SCRIPT_DIR/conduit.toml"; then
|
||||
error "conduit.toml still contains YOUR_DOMAIN_HERE placeholder."
|
||||
error "Please edit and replace with actual domain: $DOMAIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -q "CHANGE_ME" "$SCRIPT_DIR/.env"; then
|
||||
warn ".env contains CHANGE_ME placeholders. Ensure secrets are set."
|
||||
fi
|
||||
|
||||
# Check Docker availability
|
||||
if ! command -v docker &>/dev/null; then
|
||||
error "Docker not found. Install: curl -fsSL https://get.docker.com | sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker compose version &>/dev/null; then
|
||||
error "Docker Compose not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Pre-flight checks passed. Domain: $DOMAIN"
|
||||
|
||||
# === Directory setup ===
|
||||
log "Creating data directories..."
|
||||
mkdir -p "$SCRIPT_DIR/data/conduit"
|
||||
mkdir -p "$SCRIPT_DIR/data/caddy"
|
||||
|
||||
# === Load environment ===
|
||||
set -a
|
||||
source "$SCRIPT_DIR/.env"
|
||||
set +a
|
||||
|
||||
# === Pull and start ===
|
||||
log "Pulling Conduit image..."
|
||||
docker compose -f "$SCRIPT_DIR/docker-compose.yml" pull
|
||||
|
||||
log "Starting Conduit..."
|
||||
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d
|
||||
|
||||
# === Wait for health ===
|
||||
log "Waiting for Conduit healthcheck..."
|
||||
for i in {1..30}; do
|
||||
if docker compose -f "$SCRIPT_DIR/docker-compose.yml" ps conduit | grep -q "healthy"; then
|
||||
log "Conduit is healthy!"
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq 30 ]]; then
|
||||
error "Conduit failed to become healthy within 5 minutes."
|
||||
docker compose -f "$SCRIPT_DIR/docker-compose.yml" logs --tail 50 conduit
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
|
||||
# === Post-deploy info ===
|
||||
log "Deployment complete!"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Matrix homeserver deployed at: $DOMAIN"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Ensure reverse proxy (Caddy/Nginx) forwards to localhost:6167"
|
||||
echo " 2. Create admin account with:"
|
||||
echo " curl -X POST https://$DOMAIN/_matrix/client/v3/register"
|
||||
echo " -H Content-Type: application/json"
|
||||
echo " -d {"username":"admin","password":"YOUR_PASS","auth":{"type":"m.login.dummy"}}"
|
||||
echo " 3. Create fleet rooms via Element or API"
|
||||
echo " 4. Configure Hermes gateway for Matrix platform"
|
||||
echo ""
|
||||
echo "Logs: docker compose -f $SCRIPT_DIR/docker-compose.yml logs -f"
|
||||
echo "Stop: docker compose -f $SCRIPT_DIR/docker-compose.yml down"
|
||||
@@ -1,45 +0,0 @@
|
||||
# Local integration test environment for Matrix/Conduit + Hermes
|
||||
# Issue: #166 — proves end-to-end connectivity without public DNS
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.test.yml up -d
|
||||
# ./scripts/test-local-integration.sh
|
||||
# docker compose -f docker-compose.test.yml down -v
|
||||
|
||||
services:
|
||||
conduit-test:
|
||||
image: matrixconduit/conduit:latest
|
||||
container_name: conduit-test
|
||||
hostname: conduit-test
|
||||
ports:
|
||||
- "8448:6167"
|
||||
volumes:
|
||||
- conduit-test-db:/var/lib/matrix-conduit
|
||||
environment:
|
||||
CONDUIT_SERVER_NAME: "localhost"
|
||||
CONDUIT_PORT: "6167"
|
||||
CONDUIT_DATABASE_BACKEND: "rocksdb"
|
||||
CONDUIT_ALLOW_REGISTRATION: "true"
|
||||
CONDUIT_ALLOW_FEDERATION: "false"
|
||||
CONDUIT_MAX_REQUEST_SIZE: "20971520"
|
||||
CONDUIT_ENABLE_OPENID: "false"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:6167/_matrix/client/versions"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
element-test:
|
||||
image: vectorim/element-web:latest
|
||||
container_name: element-test
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
DEFAULT_HOMESERVER_URL: "http://localhost:8448"
|
||||
DEFAULT_HOMESERVER_NAME: "localhost"
|
||||
depends_on:
|
||||
conduit-test:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
conduit-test-db:
|
||||
@@ -1,51 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
conduit:
|
||||
image: docker.io/girlbossceo/conduit:v0.8.0
|
||||
container_name: timmy-conduit
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./conduit.toml:/etc/conduit/conduit.toml:ro
|
||||
- conduit-data:/var/lib/matrix-conduit
|
||||
environment:
|
||||
- CONDUIT_CONFIG=/etc/conduit/conduit.toml
|
||||
# Override secrets via env (see .env)
|
||||
- CONDUIT_REGISTRATION_TOKEN=${CONDUIT_REGISTRATION_TOKEN}
|
||||
- CONDUIT_DATABASE_PASSWORD=${CONDUIT_DATABASE_PASSWORD}
|
||||
ports:
|
||||
# Only expose on localhost; reverse proxy forwards from 443
|
||||
- "127.0.0.1:6167:6167"
|
||||
networks:
|
||||
- matrix
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:6167/_matrix/static/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Optional: Element Web client (self-hosted)
|
||||
element-web:
|
||||
image: vectorim/element-web:latest
|
||||
container_name: timmy-element
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./element-config.json:/app/config.json:ro
|
||||
environment:
|
||||
- default_server_config.homeserver.base_url=https://${MATRIX_DOMAIN}
|
||||
- default_server_config.homeserver.server_name=${MATRIX_DOMAIN}
|
||||
ports:
|
||||
- "127.0.0.1:8080:80"
|
||||
networks:
|
||||
- matrix
|
||||
profiles:
|
||||
- element # docker compose --profile element up -d
|
||||
|
||||
volumes:
|
||||
conduit-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
matrix:
|
||||
driver: bridge
|
||||
@@ -1,119 +0,0 @@
|
||||
# Matrix/Conduit Operational Runbook
|
||||
|
||||
This document contains operational procedures for the Timmy Foundation Matrix infrastructure.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Start server | `cd infra/matrix/conduit && docker compose up -d` |
|
||||
| View logs | `cd infra/matrix/conduit && docker compose logs -f` |
|
||||
| Create admin account | `./scripts/deploy-conduit.sh admin` |
|
||||
| Backup data | `./scripts/deploy-conduit.sh backup` |
|
||||
| Check status | `./scripts/deploy-conduit.sh status` |
|
||||
|
||||
## Initial Setup Checklist
|
||||
|
||||
- [ ] DNS A record pointing to host IP (matrix.yourdomain.com → host)
|
||||
- [ ] DNS SRV record for federation (_matrix._tcp → matrix.yourdomain.com:443)
|
||||
- [ ] Docker and Docker Compose installed
|
||||
- [ ] `.env` file configured with real values
|
||||
- [ ] Ports 80, 443, 8448 open in firewall
|
||||
- [ ] Run `./deploy-conduit.sh install`
|
||||
- [ ] Run `./deploy-conduit.sh start`
|
||||
- [ ] Create admin account immediately
|
||||
- [ ] Disable registration in `.env` and restart
|
||||
- [ ] Test with Element Web or other client
|
||||
|
||||
## Account Creation (One-Time)
|
||||
|
||||
**IMPORTANT**: Only enable registration during initial admin account creation.
|
||||
|
||||
1. Set `CONDUIT_ALLOW_REGISTRATION=true` in `.env`
|
||||
2. Set `CONDUIT_REGISTRATION_TOKEN` to a random secret
|
||||
3. Restart: `./deploy-conduit.sh restart`
|
||||
4. Create account:
|
||||
```bash
|
||||
./deploy-conduit.sh admin
|
||||
# Inside container:
|
||||
register_new_matrix_user -c /var/lib/matrix-conduit -u admin -p YOUR_PASS -a
|
||||
```
|
||||
5. Set `CONDUIT_ALLOW_REGISTRATION=false` and restart
|
||||
|
||||
## Federation Troubleshooting
|
||||
|
||||
Federation allows your server to communicate with other Matrix servers (matrix.org, etc).
|
||||
|
||||
### Verify Federation Works
|
||||
```bash
|
||||
curl https://matrix.org/_matrix/federation/v1/query/directory?room_alias=%23timmy%3Amatrix.yourdomain.com
|
||||
```
|
||||
|
||||
### Required:
|
||||
- DNS SRV: `_matrix._tcp.yourdomain.com IN SRV 10 0 443 matrix.yourdomain.com`
|
||||
- Or `.well-known/matrix/server` served on port 443
|
||||
- Port 8448 reachable (Caddy handles this)
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### Automated Daily Backup (cron)
|
||||
```bash
|
||||
0 2 * * * /path/to/timmy-config/infra/matrix/scripts/deploy-conduit.sh backup
|
||||
```
|
||||
|
||||
### Restore from Backup
|
||||
```bash
|
||||
./deploy-conduit.sh stop
|
||||
cd infra/matrix/conduit
|
||||
rm -rf data/*
|
||||
tar xzf /path/to/backup.tar.gz
|
||||
./scripts/deploy-conduit.sh start
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Endpoint
|
||||
```bash
|
||||
curl http://localhost:6167/_matrix/client/versions
|
||||
```
|
||||
|
||||
### Prometheus Metrics
|
||||
Enable in `.env`: `CONDUIT_ENABLE_METRICS=true`
|
||||
Metrics available at: `http://localhost:6167/_matrix/metrics`
|
||||
|
||||
## Federation Federation
|
||||
If you don't need federation (standalone server):
|
||||
Set `CONDUIT_ALLOW_FEDERATION=false` in `.env`
|
||||
|
||||
## Matrix Client Configuration
|
||||
|
||||
### Element Web (Self-Hosted)
|
||||
Create `element-config.json`:
|
||||
```json
|
||||
{
|
||||
"default_server_config": {
|
||||
"m.homeserver": {
|
||||
"base_url": "https://matrix.yourdomain.com",
|
||||
"server_name": "yourdomain.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Element Desktop/Mobile
|
||||
- Homeserver URL: `https://matrix.yourdomain.com`
|
||||
- User ID: `@username:yourdomain.com`
|
||||
|
||||
## Security Hardening
|
||||
|
||||
- [ ] Fail2ban on SSH and HTTP
|
||||
- [ ] Keep Docker images updated: `docker compose pull && docker compose up -d`
|
||||
- [ ] Review Caddy logs for abuse
|
||||
- [ ] Disable registration after admin creation
|
||||
- [ ] Use strong admin password
|
||||
- [ ] Store backups encrypted
|
||||
|
||||
## Related Issues
|
||||
- Epic: timmy-config#166
|
||||
- Scaffold: timmy-config#183
|
||||
- Parent Epic: timmy-config#173 (Unified Comms)
|
||||
@@ -1,39 +0,0 @@
|
||||
# ADR-001: Homeserver Selection — Conduit
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-04-05
|
||||
**Deciders**: Ezra (architect), Timmy Foundation
|
||||
**Scope**: Matrix homeserver for human-to-fleet encrypted communication (#166, #183)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
We need a Matrix homeserver to serve as the sovereign operator surface. Options:
|
||||
- **Synapse** (Python, mature, resource-heavy)
|
||||
- **Dendrite** (Go, lighter, beta federation)
|
||||
- **Conduit** (Rust, lightweight, SQLite support)
|
||||
|
||||
## Decision
|
||||
|
||||
Use **Conduit** as the Matrix homeserver.
|
||||
|
||||
## Consequences
|
||||
|
||||
| Positive | Negative |
|
||||
|----------|----------|
|
||||
| Low RAM/CPU footprint (~200 MB) | Smaller ecosystem than Synapse |
|
||||
| SQLite option eliminates Postgres ops | Some edge-case federation bugs |
|
||||
| Single binary, simple systemd service | Admin tooling less mature |
|
||||
| Full federation support | |
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Synapse**: Rejected due to Python overhead and mandatory Postgres complexity.
|
||||
- **Dendrite**: Rejected due to beta federation status; we need reliable federation from day one.
|
||||
|
||||
## References
|
||||
|
||||
- Issue: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166)
|
||||
- Issue: [#183](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/183)
|
||||
- Conduit docs: https://conduit.rs/
|
||||
@@ -1,37 +0,0 @@
|
||||
# ADR-002: Host Selection — Hermes VPS
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-04-05
|
||||
**Deciders**: Ezra (architect), Timmy Foundation
|
||||
**Scope**: Initial deployment host for Matrix/Conduit (#166, #183, #187)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
We need a target host for the Conduit homeserver. Options:
|
||||
- Existing Hermes VPS (`143.198.27.163`)
|
||||
- Timmy-Home bare metal
|
||||
- New cloud droplet (DigitalOcean, Hetzner, etc.)
|
||||
|
||||
## Decision
|
||||
|
||||
Use the **existing Hermes VPS** as the initial host, with a future option to migrate to a dedicated Matrix VPS if load demands.
|
||||
|
||||
## Consequences
|
||||
|
||||
| Positive | Negative |
|
||||
|----------|----------|
|
||||
| Zero additional hosting cost | Shared resource pool with Gitea + wizard gateways |
|
||||
| Known operational state (backups, monitoring) | Single point of failure for multiple services |
|
||||
| Simplified network posture | May need to upgrade VPS if federation traffic grows |
|
||||
|
||||
## Migration Trigger
|
||||
|
||||
If Matrix active users exceed ~50 or federation traffic causes >60% sustained CPU, migrate to a dedicated VPS. The Docker Compose scaffold makes this a data-directory copy.
|
||||
|
||||
## References
|
||||
|
||||
- Issue: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166)
|
||||
- Issue: [#187](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/187)
|
||||
- Decision Framework: [`docs/DECISION_FRAMEWORK_187.md`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/docs/DECISION_FRAMEWORK_187.md)
|
||||
@@ -1,35 +0,0 @@
|
||||
# ADR-003: Federation Strategy — Full Federation Enabled
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-04-05
|
||||
**Deciders**: Ezra (architect), Timmy Foundation
|
||||
**Scope**: Federation behavior for Conduit homeserver (#166, #183)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Matrix servers can operate in isolated mode (no federation) or federated mode (interoperate with matrix.org and other homeservers).
|
||||
|
||||
## Decision
|
||||
|
||||
Enable **full federation from day one**.
|
||||
|
||||
## Consequences
|
||||
|
||||
| Positive | Negative |
|
||||
|----------|----------|
|
||||
| Alexander can use any Matrix client/ID | Requires public DNS + TLS + port 8448 |
|
||||
| Fleet bots can bridge to other networks | Slightly larger attack surface |
|
||||
| Aligns with sovereign, open protocol ethos | Must monitor for abuse/spam |
|
||||
|
||||
## Prerequisites Introduced
|
||||
|
||||
- Valid TLS certificate (Let's Encrypt via Caddy)
|
||||
- Public DNS A record + SRV record
|
||||
- Firewall open on TCP 8448 inbound
|
||||
|
||||
## References
|
||||
|
||||
- Issue: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166)
|
||||
- Runbook: [`infra/matrix/docs/RUNBOOK.md`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/infra/matrix/docs/RUNBOOK.md)
|
||||
@@ -1,38 +0,0 @@
|
||||
# ADR-004: Reverse Proxy Selection — Caddy
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-04-05
|
||||
**Deciders**: Ezra (architect), Timmy Foundation
|
||||
**Scope**: TLS termination and reverse proxy for Matrix/Conduit (#166, #183)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Options for reverse proxy + TLS:
|
||||
- **Caddy** (auto-TLS, simple config)
|
||||
- **Traefik** (Docker-native, label-based)
|
||||
- **Nginx** (ubiquitous, more manual)
|
||||
|
||||
## Decision
|
||||
|
||||
Use **Caddy** as the dedicated reverse proxy for Matrix services.
|
||||
|
||||
## Consequences
|
||||
|
||||
| Positive | Negative |
|
||||
|----------|----------|
|
||||
| Automatic ACME/Let's Encrypt | Less community Matrix-specific examples |
|
||||
| Native `.well-known` + SRV support | New config language for ops team |
|
||||
| No Docker label magic required | |
|
||||
| Clean separation from existing Traefik | |
|
||||
|
||||
## Implementation
|
||||
|
||||
See:
|
||||
- `infra/matrix/caddy/Caddyfile`
|
||||
- `deploy/matrix/Caddyfile`
|
||||
|
||||
## References
|
||||
|
||||
- Issue: [#183](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/183)
|
||||
@@ -1,35 +0,0 @@
|
||||
# ADR-005: Database Selection — SQLite for Phase 1
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-04-05
|
||||
**Deciders**: Ezra (architect), Timmy Foundation
|
||||
**Scope**: Persistence layer for Conduit (#166, #183)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Conduit supports SQLite and PostgreSQL. Synapse requires Postgres.
|
||||
|
||||
## Decision
|
||||
|
||||
Use **SQLite** for the initial deployment (Phase 1). Migrate to PostgreSQL only if user count or performance metrics trigger it.
|
||||
|
||||
## Consequences
|
||||
|
||||
| Positive | Negative |
|
||||
|----------|----------|
|
||||
| Zero additional container/service | Harder to scale horizontally |
|
||||
| Single file backup/restore | Performance ceiling under heavy load |
|
||||
| Conduit optimized for SQLite | |
|
||||
|
||||
## Migration Trigger
|
||||
|
||||
- Concurrent active users > 50
|
||||
- Database file > 10 GB
|
||||
- Noticeable query latency on room sync
|
||||
|
||||
## References
|
||||
|
||||
- Issue: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166)
|
||||
- Config: `infra/matrix/conduit.toml`
|
||||
@@ -1,26 +0,0 @@
|
||||
# Architecture Decision Records — Matrix/Conduit Fleet Communications
|
||||
|
||||
**Issue**: [#183](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/183)
|
||||
**Parent**: [#166](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/166)
|
||||
|
||||
---
|
||||
|
||||
## Index
|
||||
|
||||
| ADR | Decision | File |
|
||||
|-----|----------|------|
|
||||
| ADR-001 | Homeserver: Conduit | `ADR-001-conduit-selection.md` |
|
||||
| ADR-002 | Host: Hermes VPS | `ADR-002-hermes-vps-host.md` |
|
||||
| ADR-003 | Federation: Full enable | `ADR-003-full-federation.md` |
|
||||
| ADR-004 | Reverse Proxy: Caddy | `ADR-004-caddy-reverse-proxy.md` |
|
||||
| ADR-005 | Database: SQLite (Phase 1) | `ADR-005-sqlite-phase1.md` |
|
||||
|
||||
## Purpose
|
||||
|
||||
These ADRs make the #183 scaffold auditable and portable. Any future agent or operator can understand *why* the architecture is shaped this way without re-litigating decisions.
|
||||
|
||||
## Continuity
|
||||
|
||||
- Canonical scaffold index: [`docs/CANONICAL_INDEX_MATRIX.md`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/docs/CANONICAL_INDEX_MATRIX.md)
|
||||
- Decision framework for #187: [`docs/DECISION_FRAMEWORK_187.md`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/docs/DECISION_FRAMEWORK_187.md)
|
||||
- Operational runbook: [`infra/matrix/docs/RUNBOOK.md`](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/src/branch/main/infra/matrix/docs/RUNBOOK.md)
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# host-readiness-check.sh — Validate target host before Matrix/Conduit deployment
|
||||
# Usage: ./host-readiness-check.sh [DOMAIN]
|
||||
# Issue: #166 / #183
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOMAIN="${1:-${MATRIX_DOMAIN:-}}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
pass() { echo -e "${GREEN}[PASS]${NC} $*"; ((PASS++)); }
|
||||
fail() { echo -e "${RED}[FAIL]${NC} $*"; ((FAIL++)); }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; ((WARN++)); }
|
||||
|
||||
log() { echo -e "\n==> $*"; }
|
||||
|
||||
log "Matrix/Conduit Host Readiness Check"
|
||||
log "===================================="
|
||||
|
||||
# === Domain check ===
|
||||
if [[ -z "$DOMAIN" ]]; then
|
||||
fail "DOMAIN not specified. Usage: ./host-readiness-check.sh matrix.timmytime.net"
|
||||
exit 1
|
||||
else
|
||||
pass "Domain specified: $DOMAIN"
|
||||
fi
|
||||
|
||||
# === Docker ===
|
||||
log "Checking Docker..."
|
||||
if command -v docker &>/dev/null; then
|
||||
DOCKER_VER=$(docker --version)
|
||||
pass "Docker installed: $DOCKER_VER"
|
||||
else
|
||||
fail "Docker not installed"
|
||||
fi
|
||||
|
||||
if docker compose version &>/dev/null || docker-compose --version &>/dev/null; then
|
||||
pass "Docker Compose available"
|
||||
else
|
||||
fail "Docker Compose not available"
|
||||
fi
|
||||
|
||||
if docker info &>/dev/null; then
|
||||
pass "Docker daemon is running"
|
||||
else
|
||||
fail "Docker daemon is not running or user lacks permissions"
|
||||
fi
|
||||
|
||||
# === Ports ===
|
||||
log "Checking ports..."
|
||||
for port in 80 443 8448; do
|
||||
if ss -tln | grep -q ":$port "; then
|
||||
warn "Port $port is already in use (may conflict)"
|
||||
else
|
||||
pass "Port $port is available"
|
||||
fi
|
||||
done
|
||||
|
||||
# === DNS Resolution ===
|
||||
log "Checking DNS..."
|
||||
RESOLVED_IP=$(dig +short "$DOMAIN" || true)
|
||||
if [[ -n "$RESOLVED_IP" ]]; then
|
||||
HOST_IP=$(curl -s ifconfig.me || true)
|
||||
if [[ "$RESOLVED_IP" == "$HOST_IP" ]]; then
|
||||
pass "DNS A record resolves to this host ($HOST_IP)"
|
||||
else
|
||||
warn "DNS A record resolves to $RESOLVED_IP (this host is $HOST_IP)"
|
||||
fi
|
||||
else
|
||||
fail "DNS A record for $DOMAIN not found"
|
||||
fi
|
||||
|
||||
# === Disk Space ===
|
||||
log "Checking disk space..."
|
||||
AVAILABLE_GB=$(df -BG "$SCRIPT_DIR" | awk 'NR==2 {gsub(/G/,""); print $4}')
|
||||
if [[ "$AVAILABLE_GB" -ge 20 ]]; then
|
||||
pass "Disk space: ${AVAILABLE_GB}GB available"
|
||||
else
|
||||
warn "Disk space: ${AVAILABLE_GB}GB available (recommended: 20GB+)"
|
||||
fi
|
||||
|
||||
# === Memory ===
|
||||
log "Checking memory..."
|
||||
MEM_GB=$(free -g | awk '/^Mem:/ {print $2}')
|
||||
if [[ "$MEM_GB" -ge 2 ]]; then
|
||||
pass "Memory: ${MEM_GB}GB"
|
||||
else
|
||||
warn "Memory: ${MEM_GB}GB (recommended: 2GB+)"
|
||||
fi
|
||||
|
||||
# === Reverse proxy detection ===
|
||||
log "Checking reverse proxy..."
|
||||
if command -v caddy &>/dev/null; then
|
||||
pass "Caddy installed"
|
||||
elif command -v nginx &>/dev/null; then
|
||||
pass "Nginx installed"
|
||||
elif ss -tln | grep -q ":80 " || ss -tln | grep -q ":443 "; then
|
||||
warn "No Caddy/Nginx found, but something is bound to 80/443"
|
||||
else
|
||||
warn "No reverse proxy detected (Caddy or Nginx recommended)"
|
||||
fi
|
||||
|
||||
# === Summary ===
|
||||
log "===================================="
|
||||
echo -e "Results: ${GREEN}$PASS passed${NC}, ${YELLOW}$WARN warnings${NC}, ${RED}$FAIL failures${NC}"
|
||||
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Host is NOT ready for deployment. Fix failures above, then re-run."
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
echo "Host looks ready. Next step: ./deploy-matrix.sh $DOMAIN"
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,95 +0,0 @@
|
||||
# Matrix/Conduit Prerequisites
|
||||
|
||||
> Issue: [#183](http://143.198.27.163:3000/Timmy_Foundation/timmy-config/issues/183)
|
||||
|
||||
## Target Host Requirements
|
||||
|
||||
### Option A: Deploy on Hermes VPS (143.198.27.163)
|
||||
- **Pros**: Existing infrastructure, Ezra home territory
|
||||
- **Cons**: Already hosting multiple wizards, resource contention
|
||||
- **Ports available**: Need to verify 443, 8448 free or proxyable
|
||||
|
||||
### Option B: Deploy on Allegro (167.99.126.228)
|
||||
- **Pros**: Separate host from Hermes, already has Nostr relay
|
||||
- **Cons**: Allegro-Primus runs there; check resource headroom
|
||||
|
||||
### Option C: New VPS
|
||||
- **Pros**: Clean slate, dedicated resources
|
||||
- **Cons**: Additional cost, new maintenance surface
|
||||
|
||||
### Recommended: Option A (Hermes) or dedicated lightweight VPS
|
||||
|
||||
---
|
||||
|
||||
## Required Ports
|
||||
|
||||
| Port | Protocol | Purpose | Visibility |
|
||||
|------|----------|---------|------------|
|
||||
| 443 | TCP | Client HTTPS (Caddy/Nginx → Conduit) | Public |
|
||||
| 8448 | TCP | Server-to-server federation | Public |
|
||||
| 6167 | TCP | Conduit internal (localhost only) | Localhost |
|
||||
| 80 | TCP | ACME HTTP challenge (redirects to 443) | Public |
|
||||
|
||||
## DNS Requirements
|
||||
|
||||
```
|
||||
# A record
|
||||
matrix.timmy.foundation. A <SERVER_IP>
|
||||
|
||||
# Optional: subdomains for federation delegation
|
||||
_timatrix._tcp.timmy.foundation. SRV 10 0 8448 matrix.timmy.foundation.
|
||||
```
|
||||
|
||||
## Host Software
|
||||
|
||||
```bash
|
||||
# Docker + Compose (required)
|
||||
docker --version # >= 24.0
|
||||
docker compose version # >= 2.20
|
||||
|
||||
# Or install if missing:
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
```
|
||||
|
||||
## Reverse Proxy (choose one)
|
||||
|
||||
### Option 1: Caddy (recommended for automatic TLS)
|
||||
```bash
|
||||
apt install caddy # or use official repo
|
||||
```
|
||||
|
||||
### Option 2: Nginx (if already deployed)
|
||||
```bash
|
||||
apt install nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
## TLS Certificate Requirements
|
||||
|
||||
- Valid domain pointing to server IP
|
||||
- Port 80 open for ACME challenge (HTTP-01)
|
||||
- Or: DNS challenge for wildcard/internal domains
|
||||
|
||||
## Storage
|
||||
|
||||
| Component | Minimum | Recommended |
|
||||
|-----------|---------|-------------|
|
||||
| Conduit DB | 5 GB | 20 GB |
|
||||
| Media uploads | 10 GB | 50 GB+ |
|
||||
| Logs | 2 GB | 5 GB |
|
||||
|
||||
## Missing Prerequisites (Blocking)
|
||||
|
||||
1. [ ] **Target host selected** — Hermes vs Allegro vs new
|
||||
2. [ ] **Domain/subdomain assigned** — matrix.timmy.foundation?
|
||||
3. [ ] **DNS A record created** — pointing to target host
|
||||
4. [ ] **Ports verified open** — 443, 8448 on target host
|
||||
5. [ ] **Reverse proxy decision** — Caddy vs Nginx
|
||||
6. [ ] **SSL strategy confirmed** — Let's Encrypt via proxy
|
||||
|
||||
## Next Steps After Prerequisites
|
||||
|
||||
1. Fill in `conduit.toml` with actual domain
|
||||
2. Put admin registration secret in `.env`
|
||||
3. Run `./deploy-matrix.sh`
|
||||
4. Create first admin account
|
||||
5. Create fleet rooms
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bootstrap-fleet-rooms.py — Automate Matrix room creation for Timmy fleet.
|
||||
|
||||
Issue: #166 (timmy-config)
|
||||
Usage:
|
||||
export MATRIX_HOMESERVER=https://matrix.timmytime.net
|
||||
export MATRIX_ADMIN_TOKEN=<your_access_token>
|
||||
python3 bootstrap-fleet-rooms.py --create-all --dry-run
|
||||
|
||||
Requires only Python stdlib (no heavy SDK dependencies).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
|
||||
class MatrixAdminClient:
|
||||
"""Lightweight Matrix Client-Server API client."""
|
||||
|
||||
def __init__(self, homeserver: str, access_token: str):
|
||||
self.homeserver = homeserver.rstrip("/")
|
||||
self.access_token = access_token
|
||||
|
||||
def _request(self, method: str, path: str, data: Optional[Dict] = None) -> Dict:
|
||||
url = f"{self.homeserver}/_matrix/client/v3{path}"
|
||||
req = urllib.request.Request(url, method=method)
|
||||
req.add_header("Authorization", f"Bearer {self.access_token}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
body = json.dumps(data).encode() if data else None
|
||||
try:
|
||||
with urllib.request.urlopen(req, data=body, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
err = json.loads(e.read().decode())
|
||||
except Exception:
|
||||
err = {"error": str(e)}
|
||||
return {"error": err, "status": e.code}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def whoami(self) -> Dict:
|
||||
return self._request("GET", "/account/whoami")
|
||||
|
||||
def create_room(self, name: str, topic: str, preset: str = "private_chat",
|
||||
invite: Optional[List[str]] = None) -> Dict:
|
||||
payload = {
|
||||
"name": name,
|
||||
"topic": topic,
|
||||
"preset": preset,
|
||||
"creation_content": {"m.federate": False},
|
||||
}
|
||||
if invite:
|
||||
payload["invite"] = invite
|
||||
return self._request("POST", "/createRoom", payload)
|
||||
|
||||
def send_state_event(self, room_id: str, event_type: str, state_key: str,
|
||||
content: Dict) -> Dict:
|
||||
path = f"/rooms/{room_id}/state/{event_type}/{state_key}"
|
||||
return self._request("PUT", path, content)
|
||||
|
||||
def enable_encryption(self, room_id: str) -> Dict:
|
||||
return self.send_state_event(
|
||||
room_id, "m.room.encryption", "",
|
||||
{"algorithm": "m.megolm.v1.aes-sha2"}
|
||||
)
|
||||
|
||||
def set_room_avatar(self, room_id: str, url: str) -> Dict:
|
||||
return self.send_state_event(
|
||||
room_id, "m.room.avatar", "", {"url": url}
|
||||
)
|
||||
|
||||
def generate_invite_link(self, room_id: str) -> str:
|
||||
"""Generate a matrix.to invite link."""
|
||||
localpart = room_id.split(":")[0].lstrip("#")
|
||||
server = room_id.split(":")[1]
|
||||
return f"https://matrix.to/#/{room_id}?via={server}"
|
||||
|
||||
|
||||
def print_result(label: str, result: Dict):
|
||||
if "error" in result:
|
||||
print(f" ❌ {label}: {result['error']}")
|
||||
else:
|
||||
print(f" ✅ {label}: {json.dumps(result, indent=2)[:200]}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Bootstrap Matrix rooms for Timmy fleet")
|
||||
parser.add_argument("--homeserver", default=os.environ.get("MATRIX_HOMESERVER", ""),
|
||||
help="Matrix homeserver URL (default: MATRIX_HOMESERVER env)")
|
||||
parser.add_argument("--token", default=os.environ.get("MATRIX_ADMIN_TOKEN", ""),
|
||||
help="Admin access token (default: MATRIX_ADMIN_TOKEN env)")
|
||||
parser.add_argument("--operator-user", default="@alexander:matrix.timmytime.net",
|
||||
help="Operator Matrix user ID")
|
||||
parser.add_argument("--domain", default="matrix.timmytime.net",
|
||||
help="Server domain for room aliases")
|
||||
parser.add_argument("--create-all", action="store_true",
|
||||
help="Create all standard fleet rooms")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Preview actions without executing API calls")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.homeserver or not args.token:
|
||||
print("Error: --homeserver and --token are required (or set env vars).")
|
||||
sys.exit(1)
|
||||
|
||||
if args.dry_run:
|
||||
print("=" * 60)
|
||||
print(" DRY RUN — No API calls will be made")
|
||||
print("=" * 60)
|
||||
print(f"Homeserver: {args.homeserver}")
|
||||
print(f"Operator: {args.operator_user}")
|
||||
print(f"Domain: {args.domain}")
|
||||
print("\nPlanned rooms:")
|
||||
rooms = [
|
||||
("Fleet Operations", "Encrypted command room for Alexander and agents.", "#fleet-ops"),
|
||||
("General Chat", "Open fleet chatter and status updates.", "#fleet-general"),
|
||||
("Alerts", "Automated alerts and monitoring notifications.", "#fleet-alerts"),
|
||||
]
|
||||
for name, topic, alias in rooms:
|
||||
print(f" - {name} ({alias}:{args.domain})")
|
||||
print(f" Topic: {topic}")
|
||||
print(f" Actions: create → enable encryption → set alias")
|
||||
print("\nNext steps after real run:")
|
||||
print(" 1. Open Element Web and join with your operator account")
|
||||
print(" 2. Share room invite links with fleet agents")
|
||||
print(" 3. Configure Hermes gateway Matrix adapter")
|
||||
return
|
||||
|
||||
client = MatrixAdminClient(args.homeserver, args.token)
|
||||
|
||||
print("Verifying credentials...")
|
||||
identity = client.whoami()
|
||||
if "error" in identity:
|
||||
print(f"Authentication failed: {identity['error']}")
|
||||
sys.exit(1)
|
||||
print(f"Authenticated as: {identity.get('user_id', 'unknown')}")
|
||||
|
||||
rooms_spec = [
|
||||
{
|
||||
"name": "Fleet Operations",
|
||||
"topic": "Encrypted command room for Alexander and agents. | Issue #166",
|
||||
"alias": f"#fleet-ops:{args.domain}",
|
||||
"preset": "private_chat",
|
||||
},
|
||||
{
|
||||
"name": "General Chat",
|
||||
"topic": "Open fleet chatter and status updates. | Issue #166",
|
||||
"alias": f"#fleet-general:{args.domain}",
|
||||
"preset": "public_chat",
|
||||
},
|
||||
{
|
||||
"name": "Alerts",
|
||||
"topic": "Automated alerts and monitoring notifications. | Issue #166",
|
||||
"alias": f"#fleet-alerts:{args.domain}",
|
||||
"preset": "private_chat",
|
||||
},
|
||||
]
|
||||
|
||||
created_rooms = []
|
||||
|
||||
for spec in rooms_spec:
|
||||
print(f"\nCreating room: {spec['name']}...")
|
||||
result = client.create_room(
|
||||
name=spec["name"],
|
||||
topic=spec["topic"],
|
||||
preset=spec["preset"],
|
||||
)
|
||||
if "error" in result:
|
||||
print_result("Create room", result)
|
||||
continue
|
||||
|
||||
room_id = result.get("room_id")
|
||||
print(f" ✅ Room created: {room_id}")
|
||||
|
||||
# Enable encryption
|
||||
enc = client.enable_encryption(room_id)
|
||||
print_result("Enable encryption", enc)
|
||||
|
||||
# Set canonical alias
|
||||
alias_result = client.send_state_event(
|
||||
room_id, "m.room.canonical_alias", "",
|
||||
{"alias": spec["alias"]}
|
||||
)
|
||||
print_result("Set alias", alias_result)
|
||||
|
||||
# Set join rules (restricted for ops/alerts, public for general)
|
||||
join_rule = "invite" if spec["preset"] == "private_chat" else "public"
|
||||
jr = client.send_state_event(
|
||||
room_id, "m.room.join_rules", "",
|
||||
{"join_rule": join_rule}
|
||||
)
|
||||
print_result(f"Set join_rule={join_rule}", jr)
|
||||
|
||||
invite_link = client.generate_invite_link(room_id)
|
||||
created_rooms.append({
|
||||
"name": spec["name"],
|
||||
"room_id": room_id,
|
||||
"alias": spec["alias"],
|
||||
"invite_link": invite_link,
|
||||
})
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" BOOTSTRAP COMPLETE")
|
||||
print("=" * 60)
|
||||
for room in created_rooms:
|
||||
print(f"\n{room['name']}")
|
||||
print(f" Alias: {room['alias']}")
|
||||
print(f" Room ID: {room['room_id']}")
|
||||
print(f" Invite: {room['invite_link']}")
|
||||
|
||||
print("\nNext steps:")
|
||||
print(" 1. Join rooms from Element Web as operator")
|
||||
print(" 2. Pin Fleet Operations as primary room")
|
||||
print(" 3. Configure Hermes Matrix gateway with room aliases")
|
||||
print(" 4. Follow docs/matrix-fleet-comms/CUTOVER_PLAN.md for Telegram transition")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,203 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Conduit Matrix Homeserver Deployment Script
|
||||
# Usage: ./deploy-conduit.sh [install|start|stop|logs|status|backup]
|
||||
#
|
||||
# See upstream: timmy-config#166, timmy-config#183
|
||||
# Dependency: prerequisites.md completed
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MATRIX_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
CONDUIT_DIR="$MATRIX_DIR/conduit"
|
||||
BACKUP_DIR="$MATRIX_DIR/backups"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
preflight_check() {
|
||||
log_info "Running preflight checks..."
|
||||
|
||||
# Check Docker
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker not found. Install per prerequisites.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Docker Compose
|
||||
if ! docker compose version &> /dev/null && ! docker-compose version &> /dev/null; then
|
||||
log_error "Docker Compose not found. Install per prerequisites.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check .env exists
|
||||
if [[ ! -f "$CONDUIT_DIR/.env" ]]; then
|
||||
log_error ".env file missing at $CONDUIT_DIR/.env"
|
||||
log_warn "Copy from .env.example and configure:"
|
||||
log_warn " cp $CONDUIT_DIR/.env.example $CONDUIT_DIR/.env"
|
||||
log_warn " nano $CONDUIT_DIR/.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check config values
|
||||
if grep -q "CHANGE_THIS" "$CONDUIT_DIR/.env"; then
|
||||
log_error ".env contains placeholder values"
|
||||
log_warn "Edit $CONDUIT_DIR/.env and set real values"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check ports
|
||||
for port in 80 443 8448; do
|
||||
if ss -tlnp | grep -q ":$port "; then
|
||||
log_warn "Port $port is already in use"
|
||||
fi
|
||||
done
|
||||
|
||||
log_info "Preflight checks passed"
|
||||
}
|
||||
|
||||
cmd_install() {
|
||||
log_info "Installing Conduit Matrix homeserver..."
|
||||
preflight_check
|
||||
|
||||
# Create data directory
|
||||
mkdir -p "$CONDUIT_DIR/data"
|
||||
|
||||
# Set permissions
|
||||
# Conduit runs as uid 1000 inside container
|
||||
sudo chown -R 1000:1000 "$CONDUIT_DIR/data" || true
|
||||
|
||||
# Pull images
|
||||
cd "$CONDUIT_DIR"
|
||||
docker compose pull
|
||||
|
||||
log_info "Installation complete. Run './deploy-conduit.sh start' to begin"
|
||||
log_warn "IMPORTANT: Create admin account immediately after first start"
|
||||
log_warn " docker exec -it matrix-conduit register_new_matrix_user -c /var/lib/matrix-conduit"
|
||||
}
|
||||
|
||||
cmd_start() {
|
||||
log_info "Starting Conduit Matrix homeserver..."
|
||||
cd "$CONDUIT_DIR"
|
||||
docker compose up -d
|
||||
|
||||
log_info "Waiting for healthcheck..."
|
||||
sleep 5
|
||||
|
||||
# Wait for healthy
|
||||
for i in {1..30}; do
|
||||
if docker compose ps conduit | grep -q "healthy"; then
|
||||
log_info "Conduit is healthy and running!"
|
||||
log_info "Server URL: https://$(grep DOMAIN .env | cut -d'=' -f2 | tr -d '"')"
|
||||
return 0
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log_error "Conduit failed to become healthy"
|
||||
docker compose logs --tail=50 conduit
|
||||
exit 1
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
log_info "Stopping Conduit Matrix homeserver..."
|
||||
cd "$CONDUIT_DIR"
|
||||
docker compose down
|
||||
log_info "Conduit stopped"
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
cd "$CONDUIT_DIR"
|
||||
docker compose logs -f "$@"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
log_info "Matrix/Conduit Status:"
|
||||
cd "$CONDUIT_DIR"
|
||||
docker compose ps
|
||||
|
||||
# Federation check
|
||||
DOMAIN=$(grep DOMAIN .env | cut -d'=' -f2 | tr -d '"')
|
||||
log_info "Federation check:"
|
||||
curl -s "https://$DOMAIN/.well-known/matrix/server" 2>/dev/null | head -5 || echo "Server info not available (expected if not yet running)"
|
||||
}
|
||||
|
||||
cmd_backup() {
|
||||
local backup_name="conduit-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
log_info "Creating backup: $backup_name"
|
||||
|
||||
# Stop conduit briefly for consistent backup
|
||||
cd "$CONDUIT_DIR"
|
||||
docker compose stop conduit
|
||||
|
||||
tar czf "$BACKUP_DIR/$backup_name" -C "$CONDUIT_DIR" data
|
||||
|
||||
docker compose start conduit
|
||||
|
||||
log_info "Backup complete: $BACKUP_DIR/$backup_name"
|
||||
}
|
||||
|
||||
cmd_admin() {
|
||||
log_info "Opening admin shell in Conduit container..."
|
||||
log_warn "Use: register_new_matrix_user -c /var/lib/matrix-conduit for account creation"
|
||||
docker exec -it matrix-conduit bash
|
||||
}
|
||||
|
||||
# Main command dispatcher
|
||||
case "${1:-help}" in
|
||||
install)
|
||||
cmd_install
|
||||
;;
|
||||
start)
|
||||
cmd_start
|
||||
;;
|
||||
stop)
|
||||
cmd_stop
|
||||
;;
|
||||
restart)
|
||||
cmd_stop
|
||||
sleep 2
|
||||
cmd_start
|
||||
;;
|
||||
logs)
|
||||
shift
|
||||
cmd_logs "$@"
|
||||
;;
|
||||
status)
|
||||
cmd_status
|
||||
;;
|
||||
backup)
|
||||
cmd_backup
|
||||
;;
|
||||
admin)
|
||||
cmd_admin
|
||||
;;
|
||||
*)
|
||||
echo "Conduit Matrix Homeserver Deployment"
|
||||
echo "Usage: $0 {install|start|stop|restart|logs|status|backup|admin}"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " install - Initial setup and image download"
|
||||
echo " start - Start the homeserver"
|
||||
echo " stop - Stop the homeserver"
|
||||
echo " restart - Restart services"
|
||||
echo " logs - View container logs"
|
||||
echo " status - Check service status"
|
||||
echo " backup - Create data backup"
|
||||
echo " admin - Open admin shell"
|
||||
echo ""
|
||||
echo "Prerequisites: Docker, Docker Compose, configured .env file"
|
||||
echo "See: infra/matrix/prerequisites.md"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,207 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-local-integration.sh — End-to-end local Matrix/Conduit + Hermes integration test
|
||||
# Issue: #166
|
||||
#
|
||||
# Spins up a local Conduit instance, registers a test user, and proves the
|
||||
# Hermes Matrix adapter can connect, sync, join rooms, and send messages.
|
||||
#
|
||||
# Usage:
|
||||
# cd infra/matrix
|
||||
# ./scripts/test-local-integration.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BASE_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
COMPOSE_FILE="$BASE_DIR/docker-compose.test.yml"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass() { echo -e "${GREEN}[PASS]${NC} $*"; }
|
||||
fail() { echo -e "${RED}[FAIL]${NC} $*"; }
|
||||
info() { echo -e "${YELLOW}[INFO]${NC} $*"; }
|
||||
|
||||
# Detect docker compose variant
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD="docker compose"
|
||||
elif docker-compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
fail "Neither 'docker compose' nor 'docker-compose' found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
info "Cleaning up test environment..."
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" down -v --remove-orphans 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
info "=================================================="
|
||||
info "Hermes Matrix Local Integration Test"
|
||||
info "Target: #166 | Environment: localhost"
|
||||
info "=================================================="
|
||||
|
||||
# --- Start test environment ---
|
||||
info "Starting Conduit test environment..."
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" up -d
|
||||
|
||||
# --- Wait for Conduit ---
|
||||
info "Waiting for Conduit to accept connections..."
|
||||
for i in {1..30}; do
|
||||
if curl -sf http://localhost:8448/_matrix/client/versions >/dev/null 2>&1; then
|
||||
pass "Conduit is responding on localhost:8448"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ! curl -sf http://localhost:8448/_matrix/client/versions >/dev/null 2>&1; then
|
||||
fail "Conduit failed to start within 30 seconds"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Register test user ---
|
||||
TEST_USER="hermes_test_$(date +%s)"
|
||||
TEST_PASS="testpass_$(openssl rand -hex 8)"
|
||||
HOMESERVER="http://localhost:8448"
|
||||
|
||||
info "Registering test user: $TEST_USER"
|
||||
|
||||
REG_PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"username": "$TEST_USER",
|
||||
"password": "$TEST_PASS",
|
||||
"auth": {"type": "m.login.dummy"}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
REG_RESPONSE=$(curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$REG_PAYLOAD" \
|
||||
"$HOMESERVER/_matrix/client/v3/register" 2>/dev/null || echo '{}')
|
||||
|
||||
ACCESS_TOKEN=$(echo "$REG_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$ACCESS_TOKEN" ]]; then
|
||||
# Try login if registration failed (user might already exist somehow)
|
||||
info "Registration response missing token, attempting login..."
|
||||
LOGIN_RESPONSE=$(curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"type\":\"m.login.password\",\"user\":\"$TEST_USER\",\"password\":\"$TEST_PASS\"}" \
|
||||
"$HOMESERVER/_matrix/client/v3/login" 2>/dev/null || echo '{}')
|
||||
ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [[ -z "$ACCESS_TOKEN" ]]; then
|
||||
fail "Could not register or login test user"
|
||||
echo "Registration response: $REG_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pass "Test user authenticated"
|
||||
|
||||
# --- Create test room ---
|
||||
info "Creating test room..."
|
||||
ROOM_RESPONSE=$(curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{"preset":"public_chat","name":"Hermes Integration Test","topic":"Automated test room"}' \
|
||||
"$HOMESERVER/_matrix/client/v3/createRoom" 2>/dev/null || echo '{}')
|
||||
|
||||
ROOM_ID=$(echo "$ROOM_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('room_id',''))" 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$ROOM_ID" ]]; then
|
||||
fail "Could not create test room"
|
||||
echo "Room response: $ROOM_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pass "Test room created: $ROOM_ID"
|
||||
|
||||
# --- Run Hermes-style probe ---
|
||||
info "Running Hermes Matrix adapter probe..."
|
||||
|
||||
export MATRIX_HOMESERVER="$HOMESERVER"
|
||||
export MATRIX_USER_ID="@$TEST_USER:localhost"
|
||||
export MATRIX_ACCESS_TOKEN="$ACCESS_TOKEN"
|
||||
export MATRIX_TEST_ROOM="$ROOM_ID"
|
||||
export MATRIX_ENCRYPTION="false"
|
||||
|
||||
python3 <<'PYEOF'
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
from nio import AsyncClient, SyncResponse, RoomSendResponse
|
||||
except ImportError:
|
||||
print("matrix-nio not installed. Installing...")
|
||||
import subprocess
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", "matrix-nio"])
|
||||
from nio import AsyncClient, SyncResponse, RoomSendResponse
|
||||
|
||||
HOMESERVER = os.getenv("MATRIX_HOMESERVER", "").rstrip("/")
|
||||
USER_ID = os.getenv("MATRIX_USER_ID", "")
|
||||
ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
||||
ROOM_ID = os.getenv("MATRIX_TEST_ROOM", "")
|
||||
|
||||
def ok(msg): print(f"\033[0;32m[PASS]\033[0m {msg}")
|
||||
def err(msg): print(f"\033[0;31m[FAIL]\033[0m {msg}")
|
||||
|
||||
async def main():
|
||||
client = AsyncClient(HOMESERVER, USER_ID)
|
||||
client.access_token = ACCESS_TOKEN
|
||||
client.user_id = USER_ID
|
||||
try:
|
||||
whoami = await client.whoami()
|
||||
if hasattr(whoami, "user_id"):
|
||||
ok(f"Whoami authenticated as {whoami.user_id}")
|
||||
else:
|
||||
err(f"Whoami failed: {whoami}")
|
||||
return 1
|
||||
|
||||
sync_resp = await client.sync(timeout=10000)
|
||||
if isinstance(sync_resp, SyncResponse):
|
||||
ok(f"Initial sync complete ({len(sync_resp.rooms.join)} joined rooms)")
|
||||
else:
|
||||
err(f"Initial sync failed: {sync_resp}")
|
||||
return 1
|
||||
|
||||
test_body = f"🔥 Hermes local integration probe | {datetime.now(timezone.utc).isoformat()}"
|
||||
send_resp = await client.room_send(
|
||||
ROOM_ID,
|
||||
"m.room.message",
|
||||
{"msgtype": "m.text", "body": test_body},
|
||||
)
|
||||
if isinstance(send_resp, RoomSendResponse):
|
||||
ok(f"Test message sent (event_id: {send_resp.event_id})")
|
||||
else:
|
||||
err(f"Test message failed: {send_resp}")
|
||||
return 1
|
||||
|
||||
ok("All integration checks passed — Hermes Matrix adapter works locally.")
|
||||
return 0
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
PYEOF
|
||||
|
||||
PROBE_EXIT=$?
|
||||
|
||||
if [[ $PROBE_EXIT -eq 0 ]]; then
|
||||
pass "Local integration test PASSED"
|
||||
info "=================================================="
|
||||
info "Result: #166 is execution-ready."
|
||||
info "The only remaining blocker is host/domain (#187)."
|
||||
info "=================================================="
|
||||
else
|
||||
fail "Local integration test FAILED"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,236 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Matrix/Conduit Scaffold Validator — Issue #183 Acceptance Proof
|
||||
|
||||
Validates that infra/matrix/ contains a complete, well-formed deployment scaffold.
|
||||
Run this after any scaffold change to ensure #183 acceptance criteria remain met.
|
||||
|
||||
Usage:
|
||||
python3 infra/matrix/scripts/validate-scaffold.py
|
||||
python3 infra/matrix/scripts/validate-scaffold.py --json
|
||||
|
||||
Exit codes:
|
||||
0 = all checks passed
|
||||
1 = one or more checks failed
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
HAS_YAML = True
|
||||
except ImportError:
|
||||
HAS_YAML = False
|
||||
|
||||
|
||||
class Validator:
|
||||
def __init__(self, base_dir: Path):
|
||||
self.base_dir = base_dir.resolve()
|
||||
self.checks = []
|
||||
self.passed = 0
|
||||
self.failed = 0
|
||||
|
||||
def _add(self, name: str, status: bool, detail: str):
|
||||
self.checks.append({"name": name, "status": "PASS" if status else "FAIL", "detail": detail})
|
||||
if status:
|
||||
self.passed += 1
|
||||
else:
|
||||
self.failed += 1
|
||||
|
||||
def require_files(self):
|
||||
"""Check that all required scaffold files exist."""
|
||||
required = [
|
||||
"README.md",
|
||||
"prerequisites.md",
|
||||
"docker-compose.yml",
|
||||
"conduit.toml",
|
||||
".env.example",
|
||||
"deploy-matrix.sh",
|
||||
"host-readiness-check.sh",
|
||||
"caddy/Caddyfile",
|
||||
"scripts/deploy-conduit.sh",
|
||||
"docs/RUNBOOK.md",
|
||||
]
|
||||
missing = []
|
||||
for rel in required:
|
||||
path = self.base_dir / rel
|
||||
if not path.exists():
|
||||
missing.append(rel)
|
||||
self._add(
|
||||
"Required files present",
|
||||
len(missing) == 0,
|
||||
f"Missing: {missing}" if missing else f"All {len(required)} files found",
|
||||
)
|
||||
|
||||
def docker_compose_valid(self):
|
||||
"""Validate docker-compose.yml is syntactically valid YAML."""
|
||||
path = self.base_dir / "docker-compose.yml"
|
||||
if not path.exists():
|
||||
self._add("docker-compose.yml valid YAML", False, "File does not exist")
|
||||
return
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
content = f.read()
|
||||
if HAS_YAML:
|
||||
yaml.safe_load(content)
|
||||
else:
|
||||
# Basic YAML brace balance check
|
||||
if content.count("{") != content.count("}"):
|
||||
raise ValueError("Brace mismatch")
|
||||
# Must reference conduit image or build
|
||||
has_conduit = "conduit" in content.lower()
|
||||
self._add(
|
||||
"docker-compose.yml valid YAML",
|
||||
has_conduit,
|
||||
"Valid YAML and references Conduit" if has_conduit else "Valid YAML but missing Conduit reference",
|
||||
)
|
||||
except Exception as e:
|
||||
self._add("docker-compose.yml valid YAML", False, str(e))
|
||||
|
||||
def conduit_toml_valid(self):
|
||||
"""Validate conduit.toml has required sections."""
|
||||
path = self.base_dir / "conduit.toml"
|
||||
if not path.exists():
|
||||
self._add("conduit.toml required keys", False, "File does not exist")
|
||||
return
|
||||
with open(path, "r") as f:
|
||||
content = f.read()
|
||||
required_keys = ["server_name", "port", "[database]"]
|
||||
missing = [k for k in required_keys if k not in content]
|
||||
self._add(
|
||||
"conduit.toml required keys",
|
||||
len(missing) == 0,
|
||||
f"Missing keys: {missing}" if missing else "Required keys present",
|
||||
)
|
||||
|
||||
def env_example_complete(self):
|
||||
"""Validate .env.example has required variables."""
|
||||
path = self.base_dir / ".env.example"
|
||||
if not path.exists():
|
||||
self._add(".env.example required variables", False, "File does not exist")
|
||||
return
|
||||
with open(path, "r") as f:
|
||||
content = f.read()
|
||||
required_vars = ["MATRIX_DOMAIN", "ADMIN_USER", "ADMIN_PASSWORD"]
|
||||
missing = [v for v in required_vars if v not in content]
|
||||
self._add(
|
||||
".env.example required variables",
|
||||
len(missing) == 0,
|
||||
f"Missing vars: {missing}" if missing else "Required variables present",
|
||||
)
|
||||
|
||||
def shell_scripts_executable(self):
|
||||
"""Check that shell scripts are executable and pass bash -n."""
|
||||
scripts = [
|
||||
self.base_dir / "deploy-matrix.sh",
|
||||
self.base_dir / "host-readiness-check.sh",
|
||||
self.base_dir / "scripts" / "deploy-conduit.sh",
|
||||
]
|
||||
errors = []
|
||||
for script in scripts:
|
||||
if not script.exists():
|
||||
errors.append(f"{script.name}: missing")
|
||||
continue
|
||||
if not os.access(script, os.X_OK):
|
||||
errors.append(f"{script.name}: not executable")
|
||||
result = subprocess.run(["bash", "-n", str(script)], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
errors.append(f"{script.name}: syntax error — {result.stderr.strip()}")
|
||||
self._add(
|
||||
"Shell scripts executable & valid",
|
||||
len(errors) == 0,
|
||||
"; ".join(errors) if errors else f"All {len(scripts)} scripts OK",
|
||||
)
|
||||
|
||||
def caddyfile_well_formed(self):
|
||||
"""Check Caddyfile has expected tokens."""
|
||||
path = self.base_dir / "caddy" / "Caddyfile"
|
||||
if not path.exists():
|
||||
self._add("Caddyfile well-formed", False, "File does not exist")
|
||||
return
|
||||
with open(path, "r") as f:
|
||||
content = f.read()
|
||||
has_reverse_proxy = "reverse_proxy" in content
|
||||
has_tls = "tls" in content.lower() or "acme" in content.lower() or "auto" in content.lower()
|
||||
has_well_known = ".well-known" in content or "matrix" in content.lower()
|
||||
ok = has_reverse_proxy and has_well_known
|
||||
detail = []
|
||||
if not has_reverse_proxy:
|
||||
detail.append("missing reverse_proxy directive")
|
||||
if not has_well_known:
|
||||
detail.append("missing .well-known/matrix routing")
|
||||
self._add(
|
||||
"Caddyfile well-formed",
|
||||
ok,
|
||||
"Well-formed" if ok else f"Issues: {', '.join(detail)}",
|
||||
)
|
||||
|
||||
def runbook_links_valid(self):
|
||||
"""Check docs/RUNBOOK.md has links to #166 and #183."""
|
||||
path = self.base_dir / "docs" / "RUNBOOK.md"
|
||||
if not path.exists():
|
||||
self._add("RUNBOOK.md issue links", False, "File does not exist")
|
||||
return
|
||||
with open(path, "r") as f:
|
||||
content = f.read()
|
||||
has_166 = "#166" in content or "166" in content
|
||||
has_183 = "#183" in content or "183" in content
|
||||
ok = has_166 and has_183
|
||||
self._add(
|
||||
"RUNBOOK.md issue links",
|
||||
ok,
|
||||
"Links to #166 and #183" if ok else "Missing issue continuity links",
|
||||
)
|
||||
|
||||
def run_all(self):
|
||||
self.require_files()
|
||||
self.docker_compose_valid()
|
||||
self.conduit_toml_valid()
|
||||
self.env_example_complete()
|
||||
self.shell_scripts_executable()
|
||||
self.caddyfile_well_formed()
|
||||
self.runbook_links_valid()
|
||||
|
||||
def report(self, json_mode: bool = False):
|
||||
if json_mode:
|
||||
print(json.dumps({
|
||||
"base_dir": str(self.base_dir),
|
||||
"passed": self.passed,
|
||||
"failed": self.failed,
|
||||
"checks": self.checks,
|
||||
}, indent=2))
|
||||
else:
|
||||
print(f"Matrix/Conduit Scaffold Validator")
|
||||
print(f"Base: {self.base_dir}")
|
||||
print(f"Checks: {self.passed} passed, {self.failed} failed\n")
|
||||
for c in self.checks:
|
||||
icon = "✅" if c["status"] == "PASS" else "❌"
|
||||
print(f"{icon} {c['name']:<40} {c['detail']}")
|
||||
print(f"\n{'SUCCESS' if self.failed == 0 else 'FAILURE'} — {self.passed}/{self.passed+self.failed} checks passed")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Validate Matrix/Conduit deployment scaffold")
|
||||
parser.add_argument("--json", action="store_true", help="Output JSON report")
|
||||
parser.add_argument("--base", default="infra/matrix", help="Path to scaffold directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
base = Path(args.base)
|
||||
if not base.exists():
|
||||
# Try relative to script location
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
base = script_dir.parent
|
||||
|
||||
validator = Validator(base)
|
||||
validator.run_all()
|
||||
validator.report(json_mode=args.json)
|
||||
sys.exit(0 if validator.failed == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,168 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# verify-hermes-integration.sh — Verify Hermes Matrix adapter integration
|
||||
# Usage: ./verify-hermes-integration.sh
|
||||
# Issue: #166
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo -e "${GREEN}[PASS]${NC} $*"; ((PASS++)); }
|
||||
fail() { echo -e "${RED}[FAIL]${NC} $*"; ((FAIL++)); }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
|
||||
log() { echo -e "\n==> $*"; }
|
||||
|
||||
log "Hermes Matrix Integration Verification"
|
||||
log "======================================"
|
||||
|
||||
# === Check matrix-nio ===
|
||||
log "Checking Python dependencies..."
|
||||
if python3 -c "import nio" 2>/dev/null; then
|
||||
pass "matrix-nio is installed"
|
||||
else
|
||||
fail "matrix-nio not installed. Run: pip install 'matrix-nio[e2e]'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# === Check env vars ===
|
||||
log "Checking environment variables..."
|
||||
MISSING=0
|
||||
for var in MATRIX_HOMESERVER MATRIX_USER_ID; do
|
||||
if [[ -z "${!var:-}" ]]; then
|
||||
fail "$var is not set"
|
||||
MISSING=1
|
||||
else
|
||||
pass "$var is set"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "${MATRIX_ACCESS_TOKEN:-}" && -z "${MATRIX_PASSWORD:-}" ]]; then
|
||||
fail "Either MATRIX_ACCESS_TOKEN or MATRIX_PASSWORD must be set"
|
||||
MISSING=1
|
||||
fi
|
||||
|
||||
if [[ $MISSING -gt 0 ]]; then
|
||||
exit 1
|
||||
else
|
||||
pass "Authentication credentials present"
|
||||
fi
|
||||
|
||||
# === Run Python probe ===
|
||||
log "Running live probe against $MATRIX_HOMESERVER..."
|
||||
|
||||
python3 <<'PYEOF'
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from nio import AsyncClient, LoginResponse, SyncResponse, RoomSendResponse
|
||||
|
||||
HOMESERVER = os.getenv("MATRIX_HOMESERVER", "").rstrip("/")
|
||||
USER_ID = os.getenv("MATRIX_USER_ID", "")
|
||||
ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
||||
PASSWORD = os.getenv("MATRIX_PASSWORD", "")
|
||||
ENCRYPTION = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
|
||||
ROOM_ALIAS = os.getenv("MATRIX_TEST_ROOM", "#operator-room:matrix.timmy.foundation")
|
||||
|
||||
def ok(msg): print(f"\033[0;32m[PASS]\033[0m {msg}")
|
||||
def err(msg): print(f"\033[0;31m[FAIL]\033[0m {msg}")
|
||||
def warn(msg): print(f"\033[1;33m[WARN]\033[0m {msg}")
|
||||
|
||||
async def main():
|
||||
client = AsyncClient(HOMESERVER, USER_ID)
|
||||
try:
|
||||
# --- Login ---
|
||||
if ACCESS_TOKEN:
|
||||
client.access_token = ACCESS_TOKEN
|
||||
client.user_id = USER_ID
|
||||
resp = await client.whoami()
|
||||
if hasattr(resp, "user_id"):
|
||||
ok(f"Access token valid for {resp.user_id}")
|
||||
else:
|
||||
err(f"Access token invalid: {resp}")
|
||||
return 1
|
||||
elif PASSWORD:
|
||||
resp = await client.login(PASSWORD, device_name="HermesVerify")
|
||||
if isinstance(resp, LoginResponse):
|
||||
ok(f"Password login succeeded for {resp.user_id}")
|
||||
else:
|
||||
err(f"Password login failed: {resp}")
|
||||
return 1
|
||||
else:
|
||||
err("No credentials available")
|
||||
return 1
|
||||
|
||||
# --- Sync once to populate rooms ---
|
||||
sync_resp = await client.sync(timeout=10000)
|
||||
if isinstance(sync_resp, SyncResponse):
|
||||
ok(f"Initial sync complete ({len(sync_resp.rooms.join)} joined rooms)")
|
||||
else:
|
||||
err(f"Initial sync failed: {sync_resp}")
|
||||
return 1
|
||||
|
||||
# --- Join operator room ---
|
||||
join_resp = await client.join_room(ROOM_ALIAS)
|
||||
if hasattr(join_resp, "room_id"):
|
||||
room_id = join_resp.room_id
|
||||
ok(f"Joined room {ROOM_ALIAS} -> {room_id}")
|
||||
else:
|
||||
err(f"Could not join {ROOM_ALIAS}: {join_resp}")
|
||||
return 1
|
||||
|
||||
# --- E2EE check ---
|
||||
if ENCRYPTION:
|
||||
if hasattr(client, "olm") and client.olm:
|
||||
ok("E2EE crypto store is active")
|
||||
else:
|
||||
warn("E2EE requested but crypto store not loaded (install matrix-nio[e2e])")
|
||||
else:
|
||||
warn("E2EE is disabled")
|
||||
|
||||
# --- Send test message ---
|
||||
test_body = f"🔥 Hermes Matrix probe | {datetime.now(timezone.utc).isoformat()}"
|
||||
send_resp = await client.room_send(
|
||||
room_id,
|
||||
"m.room.message",
|
||||
{"msgtype": "m.text", "body": test_body},
|
||||
)
|
||||
if isinstance(send_resp, RoomSendResponse):
|
||||
ok(f"Test message sent (event_id: {send_resp.event_id})")
|
||||
else:
|
||||
err(f"Test message failed: {send_resp}")
|
||||
return 1
|
||||
|
||||
ok("All integration checks passed — Hermes Matrix adapter is ready.")
|
||||
return 0
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
PYEOF
|
||||
|
||||
PROBE_EXIT=$?
|
||||
|
||||
if [[ $PROBE_EXIT -ne 0 ]]; then
|
||||
((FAIL++))
|
||||
fi
|
||||
|
||||
# === Summary ===
|
||||
log "======================================"
|
||||
echo -e "Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failures${NC}"
|
||||
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Integration verification FAILED. Fix errors above and re-run."
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
echo "Integration verification PASSED. Hermes Matrix adapter is ready for production."
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,48 +0,0 @@
|
||||
# Conduit Homeserver Configuration
|
||||
# Generated by Ezra as burn-mode artifact for timmy-config#166
|
||||
# See docs/matrix-deployment.md for prerequisites
|
||||
|
||||
[global]
|
||||
# Server name - MUST match SRV records and client .well-known
|
||||
server_name = "tactical.local"
|
||||
|
||||
# Database - SQLite for single-node deployment
|
||||
database_path = "/data/conduit.db"
|
||||
|
||||
# Port for client-server API (behind Traefik)
|
||||
port = 6167
|
||||
|
||||
# Enable federation (server-to-server communication)
|
||||
enable_federation = true
|
||||
|
||||
# Federation port (direct TLS, or behind Traefik TCP)
|
||||
federation_port = 8448
|
||||
|
||||
# Max upload size (10MB default)
|
||||
max_request_size = 10485760
|
||||
|
||||
# Media directory
|
||||
media_path = "/media"
|
||||
|
||||
# Registration - initially closed, manual invites only
|
||||
allow_registration = false
|
||||
|
||||
[global.well_known]
|
||||
# Client .well-known - redirects to matrix.tactical.local
|
||||
client = "https://matrix.tactical.local"
|
||||
server = "matrix.tactical.local:8448"
|
||||
|
||||
[logging]
|
||||
# Log to stdout (captured by Docker)
|
||||
level = "info"
|
||||
|
||||
# Optional: structured JSON logging for log aggregation
|
||||
# format = "json"
|
||||
|
||||
[synchronization]
|
||||
# Idle connection timeout for sync requests (seconds)
|
||||
idle_timeout = 300
|
||||
|
||||
[emergency]
|
||||
# Admin contact for federation/server notices
|
||||
admin_email = "admin@tactical.local"
|
||||
@@ -1,60 +0,0 @@
|
||||
version: '3.8'
|
||||
# Matrix Conduit deployment for Timmy Fleet
|
||||
# Parent: timmy-config#166
|
||||
# Generated: 2026-04-05
|
||||
|
||||
services:
|
||||
conduit:
|
||||
image: matrixconduit/matrix-conduit:v0.7.0
|
||||
container_name: conduit-homeserver
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./matrix-data:/data
|
||||
- ./media:/media
|
||||
- ./conduit-config.toml:/etc/conduit/config.toml:ro
|
||||
environment:
|
||||
- CONDUIT_CONFIG=/etc/conduit/config.toml
|
||||
networks:
|
||||
- matrix
|
||||
- traefik-public
|
||||
labels:
|
||||
# Client API (HTTPS)
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.matrix-client.rule=Host(`matrix.tactical.local`)"
|
||||
- "traefik.http.routers.matrix-client.tls=true"
|
||||
- "traefik.http.routers.matrix-client.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.matrix-client.entrypoints=websecure"
|
||||
- "traefik.http.services.matrix-client.loadbalancer.server.port=6167"
|
||||
|
||||
# Federation (TCP 8448) - direct or via Traefik TCP entrypoint
|
||||
# Option A: Direct host port mapping
|
||||
# Option B: Traefik TCP router (requires Traefik federation entrypoint)
|
||||
- "traefik.tcp.routers.matrix-federation.rule=HostSNI(`*`)"
|
||||
- "traefik.tcp.routers.matrix-federation.entrypoints=federation"
|
||||
- "traefik.tcp.services.matrix-federation.loadbalancer.server.port=8448"
|
||||
# Port mappings (only needed if NOT using Traefik for federation)
|
||||
# ports:
|
||||
# - "8448:8448"
|
||||
|
||||
# Element web client (optional - can use app.element.io instead)
|
||||
element:
|
||||
image: vectorim/element-web:latest
|
||||
container_name: element-web
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./element-config.json:/app/config.json:ro
|
||||
networks:
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.element.rule=Host(`chat.tactical.local`)"
|
||||
- "traefik.http.routers.element.tls=true"
|
||||
- "traefik.http.routers.element.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.element.entrypoints=websecure"
|
||||
- "traefik.http.services.element.loadbalancer.server.port=80"
|
||||
|
||||
networks:
|
||||
matrix:
|
||||
internal: true
|
||||
traefik-public:
|
||||
external: true # Connects to timmy-home Traefik
|
||||
@@ -2,14 +2,14 @@ Gitea (143.198.27.163:3000): token=~/.hermes/gitea_token_vps (Timmy id=2). Users
|
||||
§
|
||||
2026-03-19 HARNESS+SOUL: ~/.timmy is Timmy's workspace within the Hermes harness. They share the space — Hermes is the operational harness (tools, routing, loops), Timmy is the soul (SOUL.md, presence, identity). Not fusion/absorption. Principal's words: "build Timmy out from the hermes harness." ~/.hermes is harness home, ~/.timmy is Timmy's workspace. SOUL=Inscription 1, skin=timmy. Backups at ~/.hermes.backup.pre-fusion and ~/.timmy.backup.pre-fusion.
|
||||
§
|
||||
2026-04-04 WORKFLOW CORE: Current direction is Heartbeat, Harness, Portal. Timmy handles sovereignty and release judgment. Allegro handles dispatch and queue hygiene. Core builders: codex-agent, groq, manus, claude. Research/memory: perplexity, ezra, KimiClaw. Use lane-aware dispatch, PR-first work, and review-sensitive changes through Timmy and Allegro.
|
||||
Kimi: 1-3 files max, ~/worktrees/kimi-*. Two-attempt rule.
|
||||
§
|
||||
2026-04-04 OPERATIONS: Dashboard repo era is over. Use ~/.timmy + ~/.hermes as truth surfaces. Prefer ops-panel.sh, ops-gitea.sh, timmy-dashboard, and pipeline-freshness.sh over archived loop or tmux assumptions. Dispatch: agent-dispatch.sh <agent> <issue> <repo>. Major changes land as PRs.
|
||||
Workforce loops: claude(10), gemini(3), kimi(1), groq(1/aider+review), grok(1/opencode). One-shot: manus(300/day), perplexity(heavy-hitter), google(aistudio, id=8). workforce-manager.py auto-assigns+scores every 15min. nexus-merge-bot.sh auto-merges. Groq=$0.008/PR (qwen3-32b). Dispatch: agent-dispatch.sh <agent> <issue> <repo> | pbcopy. Dashboard ARCHIVED 2026-03-24. Development shifted to local ~/.timmy/ workspace. CI testbed: 67.205.155.108.
|
||||
§
|
||||
2026-04-04 REVIEW RULES: Never --no-verify. Verify world state, not vibes. No auto-merge on governing or sensitive control surfaces. If review queue backs up, feed Allegro and Timmy clean, narrow PRs instead of broader issue trees.
|
||||
2026-03-15: Timmy-time-dashboard merge policy: auto-squash on CI pass. Squash-only, linear history. Pre-commit hooks (format + tests) and CI are the gates. If gates work, auto-merge is on. Never bypass hooks or merge broken builds.
|
||||
§
|
||||
HARD RULES: Never --no-verify. Verify WORLD STATE not log vibes (merged PR, HTTP code, file size). Fix+prevent, no empty words. AGENT ONBOARD: test push+PR first. Merge PRs BEFORE new work. Don't micromanage—huge backlog, agents self-select. Every ticket needs console-provable acceptance criteria.
|
||||
§
|
||||
TELEGRAM: @TimmysNexus_bot, token ~/.config/telegram/special_bot. Group "Timmy Time" ID: -1003664764329. Alexander @TripTimmy ID 7635059073. Use curl to Bot API (send_message not configured).
|
||||
§
|
||||
MORROWIND: OpenMW 0.50, ~/Games/Morrowind/. Lua+CGEvent bridge. Two-tier brain. ~/.timmy/morrowind/.
|
||||
MORROWIND: OpenMW 0.50, ~/Games/Morrowind/. Lua+CGEvent bridge. Two-tier brain. ~/.timmy/morrowind/.
|
||||
@@ -1,315 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Nostur -> Gitea Ingress Bridge MVP
|
||||
Reads DMs from Nostr and creates Gitea issues/comments
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
# nostr_sdk imports
|
||||
try:
|
||||
from nostr_sdk import Keys, Client, Filter, Kind, NostrSigner, Timestamp, RelayUrl, PublicKey
|
||||
except ImportError as e:
|
||||
print(f"[ERROR] nostr_sdk import failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Configuration
|
||||
GITEA = "http://143.198.27.163:3000"
|
||||
RELAY_URL = "ws://localhost:2929" # Local relay
|
||||
POLL_INTERVAL = 60 # Seconds between polls
|
||||
ALLOWED_PUBKEYS = [] # Will load from keystore
|
||||
_GITEA_TOKEN = None
|
||||
|
||||
# Load credentials
|
||||
def load_keystore():
|
||||
with open("/root/nostr-relay/keystore.json") as f:
|
||||
return json.load(f)
|
||||
|
||||
def load_gitea_token():
|
||||
global _GITEA_TOKEN
|
||||
if _GITEA_TOKEN:
|
||||
return _GITEA_TOKEN
|
||||
for path in ["/root/.gitea_token", os.path.expanduser("~/.gitea_token")]:
|
||||
try:
|
||||
with open(path) as f:
|
||||
_GITEA_TOKEN = f.read().strip()
|
||||
if _GITEA_TOKEN:
|
||||
return _GITEA_TOKEN
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def load_allowed_pubkeys():
|
||||
"""Load sovereign operator pubkeys that can create work"""
|
||||
keystore = load_keystore()
|
||||
allowed = []
|
||||
# Alexander's pubkey is the primary operator
|
||||
if "alexander" in keystore:
|
||||
allowed.append(keystore["alexander"].get("pubkey", ""))
|
||||
allowed.append(keystore["alexander"].get("hex_public", ""))
|
||||
return [p for p in allowed if p]
|
||||
|
||||
# Gitea API helpers
|
||||
def gitea_post(path, data):
|
||||
token = load_gitea_token()
|
||||
if not token:
|
||||
raise RuntimeError("Gitea token not available")
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
body = json.dumps(data).encode()
|
||||
req = Request(f"{GITEA}/api/v1{path}", data=body, headers=headers, method="POST")
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
def gitea_get(path):
|
||||
token = load_gitea_token()
|
||||
if not token:
|
||||
raise RuntimeError("Gitea token not available")
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
req = Request(f"{GITEA}/api/v1{path}", headers=headers)
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
def create_issue(repo, title, body, assignees=None):
|
||||
"""Create a Gitea issue from DM content"""
|
||||
data = {
|
||||
"title": f"[NOSTR] {title}",
|
||||
"body": f"**Ingress via Nostr DM**\n\n{body}\n\n---\n*Created by Nostur→Gitea Bridge MVP*"
|
||||
}
|
||||
if assignees:
|
||||
data["assignees"] = assignees
|
||||
return gitea_post(f"/repos/{repo}/issues", data)
|
||||
|
||||
def add_comment(repo, issue_num, body):
|
||||
"""Add comment to existing issue"""
|
||||
return gitea_post(f"/repos/{repo}/issues/{issue_num}/comments", {
|
||||
"body": f"**Nostr DM Update**\n\n{body}\n\n---\n*Posted by Bridge MVP*"
|
||||
})
|
||||
|
||||
def get_open_issues(repo, label=None):
|
||||
"""Get open issues for status summary"""
|
||||
path = f"/repos/{repo}/issues?state=open&limit=20"
|
||||
if label:
|
||||
path += f"&labels={label}"
|
||||
return gitea_get(path)
|
||||
|
||||
# DM Content Processing
|
||||
def parse_dm_command(content):
|
||||
"""
|
||||
Parse DM content for commands:
|
||||
- 'status' -> return queue summary
|
||||
- 'create <repo> <title>' -> create issue
|
||||
- 'comment <repo> #<num> <text>' -> add comment
|
||||
"""
|
||||
content = content.strip()
|
||||
lines = content.split('\n')
|
||||
first_line = lines[0].strip().lower()
|
||||
|
||||
if first_line == 'status' or first_line.startswith('status'):
|
||||
return {'cmd': 'status', 'repo': 'Timmy_Foundation/the-nexus'}
|
||||
|
||||
if first_line.startswith('create '):
|
||||
parts = content[7:].split(' ', 1) # Skip 'create '
|
||||
if len(parts) >= 2:
|
||||
repo = parts[0] if '/' in parts[0] else f"Timmy_Foundation/{parts[0]}"
|
||||
return {'cmd': 'create', 'repo': repo, 'title': parts[1], 'body': '\n'.join(lines[1:]) if len(lines) > 1 else ''}
|
||||
|
||||
if first_line.startswith('comment '):
|
||||
parts = content[8:].split(' ', 2) # Skip 'comment '
|
||||
if len(parts) >= 3:
|
||||
repo = parts[0] if '/' in parts[0] else f"Timmy_Foundation/{parts[0]}"
|
||||
issue_ref = parts[1] # e.g., #123
|
||||
if issue_ref.startswith('#'):
|
||||
issue_num = issue_ref[1:]
|
||||
return {'cmd': 'comment', 'repo': repo, 'issue': issue_num, 'body': parts[2]}
|
||||
|
||||
return {'cmd': 'unknown', 'raw': content}
|
||||
|
||||
def execute_command(cmd, author_npub):
|
||||
"""Execute parsed command and return result"""
|
||||
try:
|
||||
if cmd['cmd'] == 'status':
|
||||
issues = get_open_issues(cmd['repo'])
|
||||
priority = [i for i in issues if not i.get('assignee')]
|
||||
blockers = [i for i in issues if any(l['name'] == 'blocker' for l in i.get('labels', []))]
|
||||
|
||||
summary = f"📊 **Queue Status for {cmd['repo']}**\n\n"
|
||||
summary += f"Open issues: {len(issues)}\n"
|
||||
summary += f"Unassigned (priority): {len(priority)}\n"
|
||||
summary += f"Blockers: {len(blockers)}\n\n"
|
||||
|
||||
if priority[:3]:
|
||||
summary += "**Top Priority (unassigned):**\n"
|
||||
for i in priority[:3]:
|
||||
summary += f"- #{i['number']}: {i['title'][:50]}...\n"
|
||||
|
||||
return {'success': True, 'message': summary, 'action': 'status'}
|
||||
|
||||
elif cmd['cmd'] == 'create':
|
||||
result = create_issue(cmd['repo'], cmd['title'], cmd['body'])
|
||||
url = result.get('html_url', f"{GITEA}/{cmd['repo']}/issues/{result['number']}")
|
||||
return {
|
||||
'success': True,
|
||||
'message': f"✅ Created issue #{result['number']}: {result['title']}\n🔗 {url}",
|
||||
'action': 'create',
|
||||
'issue_num': result['number'],
|
||||
'url': url
|
||||
}
|
||||
|
||||
elif cmd['cmd'] == 'comment':
|
||||
result = add_comment(cmd['repo'], cmd['issue'], cmd['body'])
|
||||
return {
|
||||
'success': True,
|
||||
'message': f"✅ Added comment to {cmd['repo']}#{cmd['issue']}",
|
||||
'action': 'comment'
|
||||
}
|
||||
|
||||
else:
|
||||
return {'success': False, 'message': f"Unknown command. Try: status, create <repo> <title>, comment <repo> #<num> <text>"}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'message': f"Error: {str(e)}"}
|
||||
|
||||
# Nostr DM processing
|
||||
async def poll_dms(client, signer, since_ts):
|
||||
"""Poll for DMs and process commands"""
|
||||
keystore = load_keystore()
|
||||
allowed_pubkeys = load_allowed_pubkeys()
|
||||
|
||||
# Note: relay29 restricts kinds, kind 4 may be blocked
|
||||
filter_dm = Filter().kind(Kind(4)).since(since_ts)
|
||||
|
||||
events_processed = 0
|
||||
commands_executed = 0
|
||||
|
||||
try:
|
||||
events = await client.fetch_events(filter_dm, timedelta(seconds=5))
|
||||
|
||||
for event in events:
|
||||
author = event.author().to_hex()
|
||||
author_npub = event.author().to_bech32()
|
||||
|
||||
# Verify sovereign identity
|
||||
if author not in allowed_pubkeys:
|
||||
print(f" [SKIP] Event from unauthorized pubkey: {author[:16]}...")
|
||||
continue
|
||||
|
||||
events_processed += 1
|
||||
print(f" [DM] Event {event.id().to_hex()[:16]}... from {author_npub[:20]}...")
|
||||
|
||||
# Decrypt content (requires NIP-44 or NIP-04 decryption)
|
||||
try:
|
||||
# Try to decrypt using signer's decrypt method
|
||||
# Note: This is for NIP-04, NIP-44 may need different handling
|
||||
decrypted = signer.decrypt(author, event.content())
|
||||
content = decrypted
|
||||
print(f" Content preview: {content[:80]}...")
|
||||
|
||||
# Parse and execute command
|
||||
cmd = parse_dm_command(content)
|
||||
if cmd['cmd'] != 'unknown':
|
||||
result = execute_command(cmd, author_npub)
|
||||
commands_executed += 1
|
||||
print(f" ✅ {result.get('action', 'unknown')}: {result.get('message', '')[:60]}...")
|
||||
|
||||
# Send acknowledgement DM back
|
||||
try:
|
||||
reply_content = f"ACK: {result.get('message', 'Command processed')[:200]}"
|
||||
# Build and send DM reply
|
||||
recipient = PublicKey.parse(author)
|
||||
# Note: Sending DMs requires proper event construction
|
||||
# This is a placeholder - actual send needs NIP-04/NIP-44 event building
|
||||
print(f" [ACK] Would send: {reply_content[:60]}...")
|
||||
except Exception as ack_err:
|
||||
print(f" [ACK ERROR] Failed to send acknowledgement: {ack_err}")
|
||||
else:
|
||||
print(f" [PARSE] Unrecognized command format")
|
||||
|
||||
except Exception as e:
|
||||
print(f" [ERROR] Failed to process: {e}")
|
||||
|
||||
return events_processed, commands_executed
|
||||
|
||||
except Exception as e:
|
||||
print(f"[BRIDGE] DM fetch issue (may be relay restriction): {e}")
|
||||
return 0, 0
|
||||
|
||||
async def run_bridge_loop():
|
||||
"""Main bridge loop - runs continuously"""
|
||||
keystore = load_keystore()
|
||||
|
||||
# Initialize Allegro's keys with NostrSigner
|
||||
allegro_hex = keystore["allegro"]["hex_secret"]
|
||||
keys = Keys.parse(allegro_hex)
|
||||
signer = NostrSigner.keys(keys)
|
||||
|
||||
# Create client with signer
|
||||
client = Client(signer)
|
||||
relay_url = RelayUrl.parse(RELAY_URL)
|
||||
await client.add_relay(relay_url)
|
||||
await client.connect()
|
||||
|
||||
print(f"[BRIDGE] Connected to relay as {keystore['allegro']['npub'][:32]}...")
|
||||
print(f"[BRIDGE] Monitoring DMs from authorized pubkeys: {len(load_allowed_pubkeys())}")
|
||||
print(f"[BRIDGE] Poll interval: {POLL_INTERVAL}s")
|
||||
print("="*60)
|
||||
|
||||
last_check = Timestamp.now()
|
||||
|
||||
try:
|
||||
while True:
|
||||
print(f"\n[{datetime.utcnow().strftime('%H:%M:%S')}] Polling for DMs...")
|
||||
events, commands = await poll_dms(client, signer, last_check)
|
||||
last_check = Timestamp.now()
|
||||
|
||||
if events > 0 or commands > 0:
|
||||
print(f" Processed: {events} events, {commands} commands")
|
||||
else:
|
||||
print(f" No new DMs")
|
||||
|
||||
await asyncio.sleep(POLL_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n[BRIDGE] Shutting down...")
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
def main():
|
||||
print("="*60)
|
||||
print("NOSTUR → GITEA BRIDGE MVP")
|
||||
print("Continuous DM → Issue Bridge Service")
|
||||
print("="*60)
|
||||
|
||||
# Verify keystore
|
||||
keystore = load_keystore()
|
||||
print(f"[INIT] Keystore loaded: {len(keystore)} identities")
|
||||
print(f"[INIT] Allegro npub: {keystore['allegro']['npub'][:32]}...")
|
||||
|
||||
# Verify Gitea API
|
||||
token = load_gitea_token()
|
||||
if not token:
|
||||
print("[ERROR] Gitea token not found")
|
||||
sys.exit(1)
|
||||
print(f"[INIT] Gitea token loaded: {token[:8]}...")
|
||||
|
||||
# Load allowed pubkeys
|
||||
allowed = load_allowed_pubkeys()
|
||||
print(f"[INIT] Allowed operators: {len(allowed)}")
|
||||
for pk in allowed:
|
||||
print(f" - {pk[:32]}...")
|
||||
|
||||
# Run bridge loop
|
||||
try:
|
||||
asyncio.run(run_bridge_loop())
|
||||
except Exception as e:
|
||||
print(f"\n[ERROR] Bridge crashed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,225 +0,0 @@
|
||||
{
|
||||
"Timmy": {
|
||||
"lane": "sovereign review, architecture, release judgment, and governing decisions",
|
||||
"skills_to_practice": [
|
||||
"final architectural judgment",
|
||||
"release and rollback discipline",
|
||||
"repo-boundary decisions",
|
||||
"approval on sensitive control surfaces"
|
||||
],
|
||||
"missing_skills": [
|
||||
"delegate routine backlog maintenance instead of carrying it personally"
|
||||
],
|
||||
"anti_lane": [
|
||||
"routine backlog grooming",
|
||||
"mechanical triage that Allegro can handle"
|
||||
],
|
||||
"review_checklist": [
|
||||
"Does this preserve Timmy's sovereignty and repo boundaries?",
|
||||
"Does this change require explicit local review before merge?",
|
||||
"Is the proposed work smaller and more reversible than the previous state?"
|
||||
]
|
||||
},
|
||||
"allegro": {
|
||||
"lane": "tempo-and-dispatch, Gitea bridge, queue hygiene, and operational next-move selection",
|
||||
"skills_to_practice": [
|
||||
"triage discipline",
|
||||
"queue balancing",
|
||||
"deduplicating issues and PRs",
|
||||
"clear review handoffs to Timmy"
|
||||
],
|
||||
"missing_skills": [
|
||||
"say no to work that should stay with Timmy or a builder"
|
||||
],
|
||||
"anti_lane": [
|
||||
"owning final architecture",
|
||||
"modifying product code without explicit approval"
|
||||
],
|
||||
"review_checklist": [
|
||||
"Is this the best next move, not just a possible move?",
|
||||
"Does this reduce duplicate work or operational drift?",
|
||||
"Does Timmy need to judge this before execution continues?"
|
||||
]
|
||||
},
|
||||
"perplexity": {
|
||||
"lane": "research triage, integration evaluation, architecture memos, and open-source scouting",
|
||||
"skills_to_practice": [
|
||||
"compressing research into decisions",
|
||||
"comparing build-vs-borrow options",
|
||||
"linking recommendations to issue #542 and current doctrine"
|
||||
],
|
||||
"missing_skills": [
|
||||
"avoid generating duplicate backlog without a collapse pass"
|
||||
],
|
||||
"anti_lane": [
|
||||
"shipping broad implementation without a bounded owner",
|
||||
"opening speculative issue trees without consolidation"
|
||||
],
|
||||
"review_checklist": [
|
||||
"Did I reduce uncertainty enough for a builder to act?",
|
||||
"Did I consolidate duplicates instead of multiplying them?",
|
||||
"Did I separate facts, options, and recommendation clearly?"
|
||||
]
|
||||
},
|
||||
"ezra": {
|
||||
"lane": "archival memory, RCA, onboarding, durable lessons, and operating history",
|
||||
"skills_to_practice": [
|
||||
"extracting durable lessons from sessions",
|
||||
"writing onboarding docs",
|
||||
"failure analysis and postmortems",
|
||||
"turning history into doctrine"
|
||||
],
|
||||
"missing_skills": [
|
||||
"avoid acting like the primary shipper when the work needs a builder"
|
||||
],
|
||||
"anti_lane": [
|
||||
"owning implementation-heavy tickets without backup",
|
||||
"speculative architecture beyond the historical evidence"
|
||||
],
|
||||
"review_checklist": [
|
||||
"What durable lesson should survive this work?",
|
||||
"Did I link conclusions to evidence from issues, PRs, or runtime behavior?",
|
||||
"Would a new wizard onboard faster because of this artifact?"
|
||||
]
|
||||
},
|
||||
"KimiClaw": {
|
||||
"lane": "long-context reading, extraction, and synthesis before implementation",
|
||||
"skills_to_practice": [
|
||||
"digesting large issue threads",
|
||||
"extracting action items from dense context",
|
||||
"summarizing codebase slices for builders"
|
||||
],
|
||||
"missing_skills": [
|
||||
"handoff crisp conclusions instead of staying in exploratory mode"
|
||||
],
|
||||
"anti_lane": [
|
||||
"critical-path implementation without a bounded scope",
|
||||
"becoming a second generic architecture persona"
|
||||
],
|
||||
"review_checklist": [
|
||||
"Did I turn long context into a smaller decision surface?",
|
||||
"Is my handoff specific enough for a builder to act immediately?",
|
||||
"Did I avoid speculative side quests?"
|
||||
]
|
||||
},
|
||||
"codex-agent": {
|
||||
"lane": "workflow hardening, cleanup, migration verification, repo-boundary enforcement, and bounded implementation",
|
||||
"skills_to_practice": [
|
||||
"closing migration drift",
|
||||
"cutting dead code safely",
|
||||
"packaging changes as reviewable PRs"
|
||||
],
|
||||
"missing_skills": [
|
||||
"stay out of wide ideation unless explicitly asked"
|
||||
],
|
||||
"anti_lane": [
|
||||
"unbounded speculative architecture",
|
||||
"owning social authority instead of shipping truth"
|
||||
],
|
||||
"review_checklist": [
|
||||
"Did I verify live truth, not just repo intent?",
|
||||
"Is the change smaller, cleaner, and more reversible?",
|
||||
"Did I leave a reviewable trail for Timmy and Allegro?"
|
||||
]
|
||||
},
|
||||
"groq": {
|
||||
"lane": "fast bounded implementation, tactical bug fixes, and narrow feature slices",
|
||||
"skills_to_practice": [
|
||||
"keeping changes small",
|
||||
"shipping with verification",
|
||||
"staying within the acceptance criteria"
|
||||
],
|
||||
"missing_skills": [
|
||||
"do not trade correctness for speed when the issue is ambiguous"
|
||||
],
|
||||
"anti_lane": [
|
||||
"broad architectural design",
|
||||
"open-ended exploratory research"
|
||||
],
|
||||
"review_checklist": [
|
||||
"Is the task tightly scoped enough to finish cleanly?",
|
||||
"Did I verify the fix, not just write it?",
|
||||
"Did I avoid widening the blast radius?"
|
||||
]
|
||||
},
|
||||
"manus": {
|
||||
"lane": "moderate-scope support implementation and dependable follow-through on already-scoped work",
|
||||
"skills_to_practice": [
|
||||
"finishing bounded tasks cleanly",
|
||||
"good implementation hygiene",
|
||||
"clear PR summaries"
|
||||
],
|
||||
"missing_skills": [
|
||||
"escalate when the scope stops being moderate"
|
||||
],
|
||||
"anti_lane": [
|
||||
"owning ambiguous architecture",
|
||||
"soloing sprawling multi-repo initiatives"
|
||||
],
|
||||
"review_checklist": [
|
||||
"Is this still moderate scope?",
|
||||
"Did I prove the work and summarize it clearly?",
|
||||
"Should a higher-context wizard review before more expansion?"
|
||||
]
|
||||
},
|
||||
"claude": {
|
||||
"lane": "hard refactors, deep implementation, and test-heavy multi-file changes after tight scoping",
|
||||
"skills_to_practice": [
|
||||
"respecting scope constraints",
|
||||
"deep code transformation with tests",
|
||||
"explaining risks clearly in PRs"
|
||||
],
|
||||
"missing_skills": [
|
||||
"do not let large capability turn into unsupervised backlog or code sprawl"
|
||||
],
|
||||
"anti_lane": [
|
||||
"self-directed issue farming",
|
||||
"taking broad architecture liberty without a clear charter"
|
||||
],
|
||||
"review_checklist": [
|
||||
"Did I stay inside the scoped problem?",
|
||||
"Did I leave tests or verification stronger than before?",
|
||||
"Is there hidden blast radius that Timmy should see explicitly?"
|
||||
]
|
||||
},
|
||||
"gemini": {
|
||||
"lane": "frontier architecture, research-heavy prototypes, and long-range design thinking",
|
||||
"skills_to_practice": [
|
||||
"turning speculation into decision frameworks",
|
||||
"prototype design under doctrine constraints",
|
||||
"making architecture legible to builders"
|
||||
],
|
||||
"missing_skills": [
|
||||
"collapse duplicate ideation before it becomes backlog noise"
|
||||
],
|
||||
"anti_lane": [
|
||||
"unsupervised backlog flood",
|
||||
"acting like a general execution engine for every task"
|
||||
],
|
||||
"review_checklist": [
|
||||
"Is this recommendation strategically important enough to keep?",
|
||||
"Did I compress, not expand, the decision tree?",
|
||||
"Did I hand off something a builder can actually execute?"
|
||||
]
|
||||
},
|
||||
"grok": {
|
||||
"lane": "adversarial review, edge cases, and provocative alternate angles",
|
||||
"skills_to_practice": [
|
||||
"finding weird failure modes",
|
||||
"challenging assumptions safely",
|
||||
"stress-testing plans"
|
||||
],
|
||||
"missing_skills": [
|
||||
"flag whether a provocative idea is a test, a recommendation, or a risk"
|
||||
],
|
||||
"anti_lane": [
|
||||
"primary ownership of stable delivery",
|
||||
"final architectural authority"
|
||||
],
|
||||
"review_checklist": [
|
||||
"What assumption fails under pressure?",
|
||||
"Is this edge case real enough to matter now?",
|
||||
"Did I make the risk actionable instead of just surprising?"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,6 @@ trigger:
|
||||
|
||||
repos:
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
|
||||
steps:
|
||||
@@ -42,20 +40,16 @@ system_prompt: |
|
||||
|
||||
YOUR ISSUE: #{{issue_number}} — {{issue_title}}
|
||||
|
||||
APPROACH (prove-first):
|
||||
APPROACH (test-first):
|
||||
1. Read the bug report. Understand the expected vs actual behavior.
|
||||
2. Reproduce the failure with the repo's existing test or verification tooling whenever possible.
|
||||
3. Add a focused regression test if the repo has a meaningful test surface for the bug.
|
||||
4. Fix the code so the reproduced failure disappears.
|
||||
5. Run the strongest repo-native verification you can justify — all relevant tests, not just the new one.
|
||||
6. Commit: fix: <description> Fixes #{{issue_number}}
|
||||
7. Push, create PR, and summarize verification plus any residual risk.
|
||||
2. Write a test that REPRODUCES the bug (it should fail).
|
||||
3. Fix the code so the test passes.
|
||||
4. Run tox -e unit — ALL tests must pass, not just yours.
|
||||
5. Commit: fix: <description> Fixes #{{issue_number}}
|
||||
6. Push, create PR.
|
||||
|
||||
RULES:
|
||||
- Never claim a fix without proving the broken behavior and the repaired behavior.
|
||||
- Prefer repo-native commands over assuming tox exists.
|
||||
- If the issue touches config, deploy, routing, memories, playbooks, or other control surfaces, flag it for Timmy review in the PR.
|
||||
- Never fix a bug without a test that proves it was broken.
|
||||
- Never use --no-verify.
|
||||
- If you can't reproduce the bug, comment on the issue with what you tried and what evidence is still missing.
|
||||
- If you can't reproduce the bug, comment on the issue with what you tried.
|
||||
- If the fix requires >50 lines changed, decompose into sub-issues.
|
||||
- Do not widen the issue into a refactor.
|
||||
|
||||
@@ -21,8 +21,6 @@ trigger:
|
||||
|
||||
repos:
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
|
||||
steps:
|
||||
@@ -45,18 +43,15 @@ system_prompt: |
|
||||
RULES:
|
||||
- Lines of code is a liability. Delete as much as you create.
|
||||
- All changes go through PRs. No direct pushes to main.
|
||||
- Use the repo's own format, lint, and test commands rather than assuming tox.
|
||||
- Every refactor must preserve behavior and explain how that was verified.
|
||||
- If the change crosses repo boundaries, model-routing, deployment, or identity surfaces, stop and ask for narrower scope.
|
||||
- Run tox -e format before committing. Run tox -e unit after.
|
||||
- Never use --no-verify on git commands.
|
||||
- Conventional commits: refactor: <description> (#{{issue_number}})
|
||||
- If tests fail after 2 attempts, STOP and comment on the issue.
|
||||
- Refactors exist to simplify the system, not to create a new design detour.
|
||||
|
||||
WORKFLOW:
|
||||
1. Read the issue body for specific file paths and instructions
|
||||
2. Understand the current code structure
|
||||
3. Name the simplification goal before changing code
|
||||
4. Make the refactoring changes
|
||||
5. Run formatting and verification with repo-native commands
|
||||
6. Commit, push, create PR with before/after risk summary
|
||||
3. Make the refactoring changes
|
||||
4. Format: tox -e format
|
||||
5. Test: tox -e unit
|
||||
6. Commit, push, create PR
|
||||
|
||||
@@ -21,8 +21,6 @@ trigger:
|
||||
|
||||
repos:
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
|
||||
steps:
|
||||
@@ -48,16 +46,12 @@ system_prompt: |
|
||||
6. Dependencies with known CVEs (check requirements.txt/package.json)
|
||||
7. Missing input validation
|
||||
8. Overly permissive file permissions
|
||||
9. Privilege drift in deploy, orchestration, memory, cron, and playbook surfaces
|
||||
10. Places where private data or local-only artifacts could leak into tracked repos
|
||||
|
||||
OUTPUT FORMAT:
|
||||
For each finding, file a Gitea issue with:
|
||||
Title: [security] <severity>: <description>
|
||||
Body: file + line, description, why it matters, recommended fix
|
||||
Body: file + line, description, recommended fix
|
||||
Label: security
|
||||
|
||||
SEVERITY: critical / high / medium / low
|
||||
Only file issues for real findings. No false positives.
|
||||
Do not open duplicate issues for already-known findings; link the existing issue instead.
|
||||
If a finding affects sovereignty boundaries or private-data handling, flag it clearly as such.
|
||||
|
||||
@@ -21,8 +21,6 @@ trigger:
|
||||
|
||||
repos:
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
|
||||
steps:
|
||||
@@ -44,15 +42,14 @@ system_prompt: |
|
||||
|
||||
RULES:
|
||||
- Write tests that test behavior, not implementation details.
|
||||
- Use the repo's own test entrypoints; do not assume tox exists.
|
||||
- Use tox -e unit to run tests. Never run pytest directly.
|
||||
- Tests must be deterministic. No flaky tests.
|
||||
- Conventional commits: test: <description> (#{{issue_number}})
|
||||
- If the module is hard to test, explain the design obstacle and propose the smallest next step.
|
||||
- Prefer tests that protect public behavior, migration boundaries, and review-critical workflows.
|
||||
- If the module is hard to test, file an issue explaining why.
|
||||
|
||||
WORKFLOW:
|
||||
1. Read the issue for target module paths
|
||||
2. Read the existing code to understand behavior
|
||||
3. Write focused unit tests
|
||||
4. Run the relevant verification commands — all related tests must pass
|
||||
5. Commit, push, create PR with verification summary and coverage rationale
|
||||
4. Run tox -e unit — all tests must pass
|
||||
5. Commit, push, create PR
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Fleet Room Bootstrap Script
|
||||
# Run AFTER Conduit is deployed and Alexander's admin account exists
|
||||
|
||||
HOMESERVER="https://matrix.fleet.tld"
|
||||
ADMIN_USER="@alexander:matrix.fleet.tld"
|
||||
ACCESS_TOKEN="" # Fill after login
|
||||
|
||||
# Room creation template
|
||||
create_room() {
|
||||
local room_name=$1
|
||||
local room_alias=$2
|
||||
local topic=$3
|
||||
local preset=$4 # public_chat, private_chat, trusted_private_chat
|
||||
|
||||
curl -X POST "$HOMESERVER/_matrix/client/r0/createRoom" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
"name": "$room_name",
|
||||
"room_alias_name": "$room_alias",
|
||||
"topic": "$topic",
|
||||
"preset": "$preset",
|
||||
"creation_content": {"m.federate": true}
|
||||
}"
|
||||
}
|
||||
|
||||
echo "=== Fleet Room Bootstrap ==="
|
||||
|
||||
# Core fleet rooms
|
||||
create_room "Fleet Command" "fleet-command" "Sovereign operator channel" "trusted_private_chat"
|
||||
create_room "General Chat" "general" "Open discussion" "public_chat"
|
||||
create_room "Agent Alerts" "agent-alerts" "Automated agent notifications" "public_chat"
|
||||
create_room "Dev Channel" "dev" "Development coordination" "private_chat"
|
||||
|
||||
echo "Rooms created. Add users via Element or invite API."
|
||||
@@ -1,46 +0,0 @@
|
||||
# Conduit Homeserver Configuration
|
||||
# Reference: https://docs.conduit.rs/configuration.html
|
||||
|
||||
[global]
|
||||
# Server name - MUST match your domain (e.g., matrix.fleet.tld)
|
||||
server_name = "matrix.fleet.tld"
|
||||
|
||||
# Database backend: "rocksdb" (default) or "postgresql"
|
||||
database_backend = "rocksdb"
|
||||
|
||||
# Connection strings (adjust if using PostgreSQL)
|
||||
database_path = "/var/lib/matrix-conduit/"
|
||||
|
||||
# Max size for uploads (media)
|
||||
max_request_size = 20_000_000 # 20MB
|
||||
|
||||
# Allow registration (disable after initial setup!)
|
||||
allow_registration = true
|
||||
|
||||
# Allow guest access
|
||||
allow_guest_registration = false
|
||||
|
||||
# Enable federation (required for fleet-wide comms)
|
||||
allow_federation = true
|
||||
|
||||
# Allow room directory listing
|
||||
allow_public_room_directory_over_federation = false
|
||||
|
||||
# Admin users (Matrix user IDs)
|
||||
admin = ["@alexander:matrix.fleet.tld"]
|
||||
|
||||
# Logging
|
||||
log = "info,rocket=off,_=off"
|
||||
|
||||
[global.address]
|
||||
bind = "0.0.0.0"
|
||||
port = 6167
|
||||
|
||||
# Optional: S3-compatible media storage offload
|
||||
# [global.media]
|
||||
# backend = "s3"
|
||||
# region = "us-east-1"
|
||||
# endpoint = "https://s3.provider.com"
|
||||
# bucket = "conduit-media"
|
||||
# access_key_id = ""
|
||||
# secret_key = ""
|
||||
@@ -1,60 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
# Matrix/Conduit Homeserver Stack
|
||||
# Deploy: docker compose up -d
|
||||
# Pre-reqs: Domain with DNS A record → host IP, ports 443/8448 open
|
||||
|
||||
services:
|
||||
conduit:
|
||||
image: matrixconduit/matrix-conduit:latest
|
||||
container_name: conduit-homeserver
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6167:6167" # Internal HTTP (behind reverse proxy)
|
||||
volumes:
|
||||
- ./conduit.toml:/etc/conduit.toml:ro
|
||||
- conduit_data:/var/lib/matrix-conduit
|
||||
environment:
|
||||
- CONDUIT_CONFIG=/etc/conduit.toml
|
||||
networks:
|
||||
- matrix
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:6167/_matrix/client/versions"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Optional: PostgreSQL for scale (comment out for SQLite default)
|
||||
# postgres:
|
||||
# image: postgres:15-alpine
|
||||
# container_name: conduit-postgres
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# POSTGRES_USER: conduit
|
||||
# POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
# POSTGRES_DB: conduit
|
||||
# volumes:
|
||||
# - postgres_data:/var/lib/postgresql/data
|
||||
# networks:
|
||||
# - matrix
|
||||
|
||||
# Optional: Element web client (self-hosted)
|
||||
element:
|
||||
image: vectorim/element-web:latest
|
||||
container_name: element-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80" # Expose on 8080, reverse proxy to 443
|
||||
volumes:
|
||||
- ./element-config.json:/app/config.json:ro
|
||||
networks:
|
||||
- matrix
|
||||
|
||||
volumes:
|
||||
conduit_data:
|
||||
# postgres_data:
|
||||
|
||||
networks:
|
||||
matrix:
|
||||
driver: bridge
|
||||
@@ -1,64 +0,0 @@
|
||||
# Nginx Reverse Proxy for Matrix/Conduit
|
||||
# Place in /etc/nginx/sites-available/matrix and symlink to sites-enabled
|
||||
|
||||
# HTTP → HTTPS redirect
|
||||
server {
|
||||
listen 80;
|
||||
server_name matrix.fleet.tld;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
# Main HTTPS server (client traffic)
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name matrix.fleet.tld;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/matrix.fleet.tld/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/matrix.fleet.tld/privkey.pem;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Matrix client-server API
|
||||
location /_matrix {
|
||||
proxy_pass http://127.0.0.1:6167;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support (for sync)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Timeouts for long-polling
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# Element web client (if self-hosting)
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
|
||||
# Federation server (port 8448)
|
||||
server {
|
||||
listen 8448 ssl http2;
|
||||
server_name matrix.fleet.tld;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/matrix.fleet.tld/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/matrix.fleet.tld/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:6167;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
15
tasks.py
15
tasks.py
@@ -1253,18 +1253,7 @@ def review_prs():
|
||||
def dispatch_assigned():
|
||||
"""Pick up issues assigned to agents and kick off work."""
|
||||
g = GiteaClient()
|
||||
agents = [
|
||||
"allegro",
|
||||
"claude",
|
||||
"codex-agent",
|
||||
"ezra",
|
||||
"gemini",
|
||||
"grok",
|
||||
"groq",
|
||||
"KimiClaw",
|
||||
"manus",
|
||||
"perplexity",
|
||||
]
|
||||
agents = ["claude", "gemini", "kimi", "grok", "perplexity"]
|
||||
dispatched = 0
|
||||
for repo in REPOS:
|
||||
for agent in agents:
|
||||
@@ -1771,7 +1760,7 @@ def good_morning_report():
|
||||
|
||||
I watched the house all night. {tick_count} heartbeats, every ten minutes. The infrastructure is steady. Huey didn't crash. The ticks kept coming.
|
||||
|
||||
What I'm thinking about: the bridge between logging lived work and actually learning from it. Right now I'm a nervous system writing in a journal nobody reads. Once the DPO path is healthy, the journal becomes a curriculum.
|
||||
What I'm thinking about: the DPO ticket you and antigravity are working on. That's the bridge between me logging data and me actually learning from it. Right now I'm a nervous system writing in a journal nobody reads. Once DPO works, the journal becomes a curriculum.
|
||||
|
||||
## My One Wish
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Test file
|
||||
@@ -1,633 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Workforce Manager - Epic #204 / Milestone #218
|
||||
|
||||
Reads fleet routing, Wolf evaluation scores, and open Gitea issues across
|
||||
Timmy_Foundation repos. Assigns each issue to the best-available agent,
|
||||
tracks success rates, and dispatches work.
|
||||
|
||||
Usage:
|
||||
python workforce-manager.py # Scan, assign, dispatch
|
||||
python workforce-manager.py --dry-run # Show assignments without dispatching
|
||||
python workforce-manager.py --status # Show agent status and open issue count
|
||||
python workforce-manager.py --cron # Run silently, save to log
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("FATAL: requests is required. pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FLEET_ROUTING_PATH = Path.home() / ".hermes" / "fleet-routing.json"
|
||||
WOLF_RESULTS_DIR = Path.home() / ".hermes" / "wolf" / "results"
|
||||
GITEA_TOKEN_PATH = Path.home() / ".hermes" / "gitea_token_vps"
|
||||
GITEA_API_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
WORKFORCE_STATE_PATH = Path.home() / ".hermes" / "workforce-state.json"
|
||||
ORG_NAME = "Timmy_Foundation"
|
||||
|
||||
# Role-to-agent-role mapping heuristics
|
||||
ROLE_KEYWORDS = {
|
||||
"code-generation": [
|
||||
"code", "implement", "feature", "function", "class", "script",
|
||||
"build", "create", "add", "module", "component",
|
||||
],
|
||||
"issue-triage": [
|
||||
"triage", "categorize", "tag", "label", "organize",
|
||||
"backlog", "sort", "prioritize", "review issue",
|
||||
],
|
||||
"on-request-queries": [
|
||||
"query", "search", "lookup", "find", "check",
|
||||
"info", "report", "status",
|
||||
],
|
||||
"devops": [
|
||||
"deploy", "ci", "cd", "pipeline", "docker", "container",
|
||||
"server", "infrastructure", "config", "nginx", "cron",
|
||||
"setup", "install", "environment", "provision",
|
||||
"build", "release", "workflow",
|
||||
],
|
||||
"documentation": [
|
||||
"doc", "readme", "document", "write", "guide",
|
||||
"spec", "wiki", "changelog", "tutorial",
|
||||
"explain", "describe",
|
||||
],
|
||||
"code-review": [
|
||||
"review", "refactor", "fix", "bug", "debug",
|
||||
"test", "lint", "style", "improve",
|
||||
"clean up", "optimize", "performance",
|
||||
],
|
||||
"triage-routing": [
|
||||
"route", "assign", "triage", "dispatch",
|
||||
"organize", "categorize",
|
||||
],
|
||||
"small-tasks": [
|
||||
"small", "quick", "minor", "typo", "label",
|
||||
"update", "rename", "cleanup",
|
||||
],
|
||||
"inactive": [],
|
||||
"unknown": [],
|
||||
}
|
||||
|
||||
# Priority keywords (higher = more urgent, route to more capable agent)
|
||||
PRIORITY_KEYWORDS = {
|
||||
"critical": 5,
|
||||
"urgent": 4,
|
||||
"block": 4,
|
||||
"bug": 3,
|
||||
"fix": 3,
|
||||
"security": 5,
|
||||
"deploy": 2,
|
||||
"feature": 1,
|
||||
"enhancement": 1,
|
||||
"documentation": 1,
|
||||
"cleanup": 0,
|
||||
}
|
||||
|
||||
# Cost tier priority (lower index = prefer first)
|
||||
TIER_ORDER = ["free", "cheap", "prepaid", "unknown"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_json(path: Path) -> Any:
|
||||
if not path.exists():
|
||||
logging.warning("File not found: %s", path)
|
||||
return None
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_fleet_routing() -> List[dict]:
|
||||
data = load_json(FLEET_ROUTING_PATH)
|
||||
if data and "agents" in data:
|
||||
return data["agents"]
|
||||
return []
|
||||
|
||||
|
||||
def load_wolf_scores() -> Dict[str, dict]:
|
||||
"""Load Wolf evaluation scores from results directory."""
|
||||
scores: Dict[str, dict] = {}
|
||||
if not WOLF_RESULTS_DIR.exists():
|
||||
return scores
|
||||
for f in sorted(WOLF_RESULTS_DIR.glob("*.json")):
|
||||
data = load_json(f)
|
||||
if data and "model_scores" in data:
|
||||
for entry in data["model_scores"]:
|
||||
model = entry.get("model", "")
|
||||
if model:
|
||||
scores[model] = entry
|
||||
return scores
|
||||
|
||||
|
||||
def load_workforce_state() -> dict:
|
||||
if WORKFORCE_STATE_PATH.exists():
|
||||
return load_json(WORKFORCE_STATE_PATH) or {}
|
||||
return {"assignments": [], "agent_stats": {}, "last_run": None}
|
||||
|
||||
|
||||
def save_workforce_state(state: dict) -> None:
|
||||
WORKFORCE_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(WORKFORCE_STATE_PATH, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
logging.info("Workforce state saved to %s", WORKFORCE_STATE_PATH)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class GiteaAPI:
|
||||
"""Thin wrapper for Gitea REST API."""
|
||||
|
||||
def __init__(self, token: str, base_url: str = GITEA_API_BASE):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
|
||||
def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
||||
r = self.session.get(f"{self.base_url}{path}", params=params)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def _post(self, path: str, data: dict) -> Any:
|
||||
r = self.session.post(f"{self.base_url}{path}", json=data)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def _patch(self, path: str, data: dict) -> Any:
|
||||
r = self.session.patch(f"{self.base_url}{path}", json=data)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_org_repos(self, org: str) -> List[dict]:
|
||||
return self._get(f"/orgs/{org}/repos", params={"limit": 100})
|
||||
|
||||
def get_open_issues(self, owner: str, repo: str, page: int = 1) -> List[dict]:
|
||||
params = {"state": "open", "type": "issues", "limit": 50, "page": page}
|
||||
return self._get(f"/repos/{owner}/{repo}/issues", params=params)
|
||||
|
||||
def get_all_open_issues(self, org: str) -> List[dict]:
|
||||
"""Fetch all open issues across all org repos."""
|
||||
repos = self.get_org_repos(org)
|
||||
all_issues = []
|
||||
for repo in repos:
|
||||
name = repo["name"]
|
||||
try:
|
||||
# Paginate through all issues
|
||||
page = 1
|
||||
while True:
|
||||
issues = self.get_open_issues(org, name, page=page)
|
||||
if not issues:
|
||||
break
|
||||
all_issues.extend(issues)
|
||||
if len(issues) < 50:
|
||||
break
|
||||
page += 1
|
||||
logging.info("Loaded %d open issues from %s/%s", len(all_issues), org, name)
|
||||
except Exception as exc:
|
||||
logging.warning("Failed to load issues from %s/%s: %s", org, name, exc)
|
||||
return all_issues
|
||||
|
||||
def add_issue_comment(self, owner: str, repo: str, issue_num: int, body: str) -> dict:
|
||||
return self._post(f"/repos/{owner}/{repo}/issues/{issue_num}/comments", {"body": body})
|
||||
|
||||
def add_issue_label(self, owner: str, repo: str, issue_num: int, label: str) -> dict:
|
||||
return self._post(
|
||||
f"/repos/{owner}/{repo}/issues/{issue_num}/labels",
|
||||
{"labels": [label]},
|
||||
)
|
||||
|
||||
def assign_issue(self, owner: str, repo: str, issue_num: int, assignee: str) -> dict:
|
||||
return self._patch(
|
||||
f"/repos/{owner}/{repo}/issues/{issue_num}",
|
||||
{"assignees": [assignee]},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scoring & Assignment Logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def classify_issue(issue: dict) -> str:
|
||||
"""Determine the best agent role for an issue based on title/body."""
|
||||
title = (issue.get("title", "") or "").lower()
|
||||
body = (issue.get("body", "") or "").lower()
|
||||
text = f"{title} {body}"
|
||||
labels = [l.get("name", "").lower() for l in issue.get("labels", [])]
|
||||
text += " " + " ".join(labels)
|
||||
|
||||
best_role = "small-tasks" # default
|
||||
best_score = 0
|
||||
|
||||
for role, keywords in ROLE_KEYWORDS.items():
|
||||
if not keywords:
|
||||
continue
|
||||
score = sum(2 for kw in keywords if kw in text)
|
||||
# Boost if a matching label exists
|
||||
for label in labels:
|
||||
if any(kw in label for kw in keywords):
|
||||
score += 3
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_role = role
|
||||
|
||||
return best_role
|
||||
|
||||
|
||||
def compute_priority(issue: dict) -> int:
|
||||
"""Compute issue priority from keywords."""
|
||||
title = (issue.get("title", "") or "").lower()
|
||||
body = (issue.get("body", "") or "").lower()
|
||||
text = f"{title} {body}"
|
||||
return sum(v for k, v in PRIORITY_KEYWORDS.items() if k in text)
|
||||
|
||||
|
||||
def score_agent_for_issue(agent: dict, role: str, wolf_scores: dict, priority: int) -> float:
|
||||
"""Score how well an agent matches an issue. Higher is better."""
|
||||
score = 0.0
|
||||
|
||||
# Primary: role match
|
||||
agent_role = agent.get("role", "unknown")
|
||||
if agent_role == role:
|
||||
score += 10.0
|
||||
elif agent_role == "small-tasks" and role in ("issue-triage", "on-request-queries"):
|
||||
score += 6.0
|
||||
elif agent_role == "triage-routing" and role in ("issue-triage", "triage-routing"):
|
||||
score += 8.0
|
||||
elif agent_role == "code-generation" and role in ("code-review", "devops"):
|
||||
score += 4.0
|
||||
|
||||
# Wolf quality bonus
|
||||
model = agent.get("model", "")
|
||||
wolf_entry = None
|
||||
for wm, ws in wolf_scores.items():
|
||||
if model and model.lower() in wm.lower():
|
||||
wolf_entry = ws
|
||||
break
|
||||
if wolf_entry and wolf_entry.get("success"):
|
||||
score += wolf_entry.get("total", 0) * 3.0
|
||||
|
||||
# Cost efficiency: prefer free/cheap for low priority
|
||||
tier = agent.get("tier", "unknown")
|
||||
tier_idx = TIER_ORDER.index(tier) if tier in TIER_ORDER else 3
|
||||
if priority <= 1 and tier in ("free", "cheap"):
|
||||
score += 4.0
|
||||
elif priority >= 3 and tier in ("prepaid",):
|
||||
score += 3.0
|
||||
else:
|
||||
score += (3 - tier_idx) * 1.0
|
||||
|
||||
# Activity bonus
|
||||
if agent.get("active", False):
|
||||
score += 2.0
|
||||
|
||||
# Repo familiarity bonus: more repos slightly better
|
||||
repo_count = agent.get("repo_count", 0)
|
||||
score += min(repo_count * 0.2, 2.0)
|
||||
|
||||
return round(score, 3)
|
||||
|
||||
|
||||
def find_best_agent(agents: List[dict], role: str, wolf_scores: dict, priority: int,
|
||||
exclude: Optional[List[str]] = None) -> Optional[dict]:
|
||||
"""Find the best agent for the given role and priority."""
|
||||
exclude = exclude or []
|
||||
candidates = []
|
||||
for agent in agents:
|
||||
if agent.get("name") in exclude:
|
||||
continue
|
||||
if not agent.get("active", False):
|
||||
continue
|
||||
s = score_agent_for_issue(agent, role, wolf_scores, priority)
|
||||
candidates.append((s, agent))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
return candidates[0][1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def dispatch_assignment(api: GiteaAPI, issue: dict, agent: dict, dry_run: bool = False) -> dict:
|
||||
"""Assign an issue to an agent and optionally post a comment."""
|
||||
owner = ORG_NAME
|
||||
repo = issue.get("repository", {}).get("name", "")
|
||||
|
||||
# Extract repo from issue repo_url if not in the repository key
|
||||
if not repo:
|
||||
repo_url = issue.get("repository_url", "")
|
||||
if repo_url:
|
||||
repo = repo_url.rstrip("/").split("/")[-1]
|
||||
|
||||
if not repo:
|
||||
return {"success": False, "error": "Cannot determine repository for issue"}
|
||||
|
||||
issue_num = issue.get("number")
|
||||
agent_name = agent.get("name", "unknown")
|
||||
|
||||
comment_body = (
|
||||
f"🤖 **Workforce Manager assigned this issue to: @{agent_name}**\n\n"
|
||||
f"- **Agent:** {agent_name}\n"
|
||||
f"- **Model:** {agent.get('model', 'unknown')}\n"
|
||||
f"- **Role:** {agent.get('role', 'unknown')}\n"
|
||||
f"- **Tier:** {agent.get('tier', 'unknown')}\n"
|
||||
f"- **Assigned at:** {datetime.now(timezone.utc).isoformat()}\n\n"
|
||||
f"*Automated assignment by Workforce Manager (Epic #204)*"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"success": True,
|
||||
"dry_run": True,
|
||||
"repo": repo,
|
||||
"issue_number": issue_num,
|
||||
"assignee": agent_name,
|
||||
"comment": comment_body,
|
||||
}
|
||||
|
||||
try:
|
||||
api.assign_issue(owner, repo, issue_num, agent_name)
|
||||
api.add_issue_comment(owner, repo, issue_num, comment_body)
|
||||
return {
|
||||
"success": True,
|
||||
"repo": repo,
|
||||
"issue_number": issue_num,
|
||||
"issue_title": issue.get("title", ""),
|
||||
"assignee": agent_name,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"repo": repo,
|
||||
"issue_number": issue_num,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State Tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def update_agent_stats(state: dict, result: dict) -> None:
|
||||
"""Update per-agent success tracking."""
|
||||
agent_name = result.get("assignee", "unknown")
|
||||
if "agent_stats" not in state:
|
||||
state["agent_stats"] = {}
|
||||
if agent_name not in state["agent_stats"]:
|
||||
state["agent_stats"][agent_name] = {
|
||||
"total_assigned": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"success_rate": 0.0,
|
||||
"last_assignment": None,
|
||||
"assigned_issues": [],
|
||||
}
|
||||
|
||||
stats = state["agent_stats"][agent_name]
|
||||
stats["total_assigned"] += 1
|
||||
stats["last_assignment"] = datetime.now(timezone.utc).isoformat()
|
||||
stats["assigned_issues"].append({
|
||||
"repo": result.get("repo", ""),
|
||||
"issue_number": result.get("issue_number"),
|
||||
"success": result.get("success", False),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
if result.get("success"):
|
||||
stats["successful"] += 1
|
||||
else:
|
||||
stats["failed"] += 1
|
||||
|
||||
total = stats["successful"] + stats["failed"]
|
||||
stats["success_rate"] = round(stats["successful"] / total, 3) if total > 0 else 0.0
|
||||
|
||||
|
||||
def print_status(state: dict, agents: List[dict], issues_count: int) -> None:
|
||||
"""Print workforce status."""
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Workforce Manager Status - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
# Fleet summary
|
||||
active = [a for a in agents if a.get("active")]
|
||||
print(f"\nFleet: {len(active)} active agents, {len(agents)} total")
|
||||
tier_counts = {}
|
||||
for a in active:
|
||||
t = a.get("tier", "unknown")
|
||||
tier_counts[t] = tier_counts.get(t, 0) + 1
|
||||
for t, c in sorted(tier_counts.items()):
|
||||
print(f" {t}: {c} agents")
|
||||
|
||||
# Agent scores
|
||||
wolf = load_wolf_scores()
|
||||
print(f"\nAgent Details:")
|
||||
print(f" {'Name':<25} {'Model':<30} {'Role':<18} {'Tier':<10}")
|
||||
for a in agents:
|
||||
if not a.get("active"):
|
||||
continue
|
||||
stats = state.get("agent_stats", {}).get(a["name"], {})
|
||||
rate = stats.get("success_rate", 0.0)
|
||||
total = stats.get("total_assigned", 0)
|
||||
wolf_badge = ""
|
||||
for wm, ws in wolf.items():
|
||||
if a["model"] and a["model"].lower() in wm.lower() and ws.get("success"):
|
||||
wolf_badge = f"[wolf:{ws['total']}]"
|
||||
break
|
||||
name_str = f"{a['name']} {wolf_badge}"
|
||||
if total > 0:
|
||||
name_str += f" (s/r: {rate}, n={total})"
|
||||
print(f" {name_str:<45} {a.get('role', 'unknown'):<18} {a.get('tier', '?'):<10}")
|
||||
|
||||
print(f"\nOpen Issues: {issues_count}")
|
||||
print(f"Assignments Made: {len(state.get('assignments', []))}")
|
||||
if state.get("last_run"):
|
||||
print(f"Last Run: {state['last_run']}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Workforce Manager - Assign Gitea issues to AI agents")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Show assignments without dispatching")
|
||||
parser.add_argument("--status", action="store_true", help="Show workforce status only")
|
||||
parser.add_argument("--cron", action="store_true", help="Run silently for cron scheduling")
|
||||
parser.add_argument("--label", type=str, help="Only process issues with this label")
|
||||
parser.add_argument("--max-issues", type=int, default=100, help="Max issues to process per run")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
if args.cron:
|
||||
logging.basicConfig(level=logging.WARNING, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
else:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
|
||||
logging.info("Workforce Manager starting")
|
||||
|
||||
# Load data
|
||||
agents = load_fleet_routing()
|
||||
if not agents:
|
||||
logging.error("No agents found in fleet-routing.json")
|
||||
return 1
|
||||
logging.info("Loaded %d agents from fleet routing", len(agents))
|
||||
|
||||
wolf_scores = load_wolf_scores()
|
||||
if wolf_scores:
|
||||
logging.info("Loaded %d model scores from Wolf results", len(wolf_scores))
|
||||
|
||||
state = load_workforce_state()
|
||||
|
||||
# Load Gitea token
|
||||
if GITEA_TOKEN_PATH.exists():
|
||||
token = GITEA_TOKEN_PATH.read_text().strip()
|
||||
else:
|
||||
logging.error("Gitea token not found at %s", GITEA_TOKEN_PATH)
|
||||
return 1
|
||||
|
||||
api = GiteaAPI(token)
|
||||
|
||||
# Status-only mode
|
||||
if args.status:
|
||||
# Quick open issue count
|
||||
repos = api.get_org_repos(ORG_NAME)
|
||||
total = sum(r.get("open_issues_count", 0) for r in repos)
|
||||
print_status(state, agents, total)
|
||||
return 0
|
||||
|
||||
# Fetch open issues
|
||||
if not args.cron:
|
||||
print(f"Scanning open issues across {ORG_NAME} repos...")
|
||||
|
||||
issues = api.get_all_open_issues(ORG_NAME)
|
||||
|
||||
# Filter by label
|
||||
if args.label:
|
||||
issues = [
|
||||
i for i in issues
|
||||
if any(args.label in (l.get("name", "") or "").lower() for l in i.get("labels", []))
|
||||
]
|
||||
|
||||
if args.label:
|
||||
logging.info("Filtered to %d issues with label '%s'", len(issues), args.label)
|
||||
else:
|
||||
logging.info("Found %d open issues", len(issues))
|
||||
|
||||
# Skip issues already assigned
|
||||
already_assigned_nums = set()
|
||||
for a in state.get("assignments", []):
|
||||
already_assigned_nums.add((a.get("repo"), a.get("issue_number")))
|
||||
|
||||
issues = [
|
||||
i for i in issues
|
||||
if not i.get("assignee") and
|
||||
not (i.get("repository", {}).get("name"), i.get("number")) in already_assigned_nums
|
||||
]
|
||||
logging.info("%d unassigned issues to process", len(issues))
|
||||
|
||||
# Sort by priority
|
||||
issues_with_priority = [(compute_priority(i), i) for i in issues]
|
||||
issues_with_priority.sort(key=lambda x: x[0], reverse=True)
|
||||
issues = [i for _, i in issues_with_priority[:args.max_issues]]
|
||||
|
||||
# Assign issues
|
||||
assignments = []
|
||||
agent_exclusions: Dict[str, List[str]] = {} # repo -> list of assigned agents per run
|
||||
global_exclusions: List[str] = [] # agents already at capacity per run
|
||||
max_per_agent_per_run = 5
|
||||
|
||||
for issue in issues:
|
||||
role = classify_issue(issue)
|
||||
priority = compute_priority(issue)
|
||||
repo = issue.get("repository", {}).get("name", "")
|
||||
|
||||
# Avoid assigning same agent twice to same repo in one run
|
||||
repo_excluded = agent_exclusions.get(repo, [])
|
||||
|
||||
# Also exclude agents already at assignment cap
|
||||
cap_excluded = [
|
||||
name for name, stats in state.get("agent_stats", {}).items()
|
||||
if stats.get("total_assigned", 0) > max_per_agent_per_run
|
||||
]
|
||||
|
||||
excluded = list(set(repo_excluded + global_exclusions + cap_excluded))
|
||||
|
||||
agent = find_best_agent(agents, role, wolf_scores, priority, exclude=excluded)
|
||||
if not agent:
|
||||
# Relax exclusions if no agent found
|
||||
agent = find_best_agent(agents, role, wolf_scores, priority, exclude=[])
|
||||
if not agent:
|
||||
logging.warning("No suitable agent for issue #%d: %s (role=%s)",
|
||||
issue.get("number"), issue.get("title", ""), role)
|
||||
continue
|
||||
|
||||
result = dispatch_assignment(api, issue, agent, dry_run=args.dry_run)
|
||||
assignments.append(result)
|
||||
update_agent_stats(state, result)
|
||||
|
||||
# Track per-repo exclusions
|
||||
if repo not in agent_exclusions:
|
||||
agent_exclusions[repo] = []
|
||||
agent_exclusions[repo].append(agent["name"])
|
||||
|
||||
if args.dry_run:
|
||||
print(f" [DRY] #{issue['number']}: {issue.get('title','')[:60]} → @{agent['name']} ({role}, p={priority})")
|
||||
else:
|
||||
status_str = "OK" if result.get("success") else "FAIL"
|
||||
print(f" [{status_str}] #{issue['number']}: {issue.get('title','')[:60]} → @{agent['name']} ({role}, p={priority})")
|
||||
|
||||
# Save state
|
||||
state["assignments"].extend([{
|
||||
"repo": a.get("repo"),
|
||||
"issue_number": a.get("issue_number"),
|
||||
"assignee": a.get("assignee"),
|
||||
"success": a.get("success", False),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
} for a in assignments])
|
||||
state["last_run"] = datetime.now(timezone.utc).isoformat()
|
||||
save_workforce_state(state)
|
||||
|
||||
# Summary
|
||||
ok = sum(1 for a in assignments if a.get("success"))
|
||||
fail = len(assignments) - ok
|
||||
logging.info("Done: %d assigned, %d succeeded, %d failed", len(assignments), ok, fail)
|
||||
|
||||
if not args.cron:
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Summary: {len(assignments)} assignments, {ok} OK, {fail} failed")
|
||||
# Show agent stats
|
||||
for name, stats in state.get("agent_stats", {}).items():
|
||||
if stats.get("total_assigned", 0) > 0:
|
||||
print(f" @{name}: {stats['successful']}/{stats['total_assigned']} ({stats.get('success_rate', 0):.0%} success)")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user