Spaces:
Sleeping
Sleeping
Cyber Catalyst Team commited on
Commit Β·
12ab90a
1
Parent(s): ed6da33
feat: integrate virtual multi-repo second brain, context engine (ACE), watchdog, and quantized llama-cpp SwarmLLM
Browse files- Dockerfile +3 -3
- backend.py +135 -15
- context_engine.py +128 -0
- helix_state.py +237 -0
- requirements.txt +2 -0
- second_brain.py +446 -0
- survival_watchdog.py +171 -0
- swarm_llm.py +208 -0
Dockerfile
CHANGED
|
@@ -2,7 +2,7 @@ FROM python:3.11-slim
|
|
| 2 |
|
| 3 |
# Install system dependencies
|
| 4 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
-
git bash curl tini && \
|
| 6 |
rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
# Create non-root user (HF requirement: uid 1000)
|
|
@@ -17,8 +17,8 @@ WORKDIR $HOME/app
|
|
| 17 |
COPY --chown=user requirements.txt .
|
| 18 |
RUN pip install --no-cache-dir --user -r requirements.txt
|
| 19 |
|
| 20 |
-
# Copy application
|
| 21 |
-
COPY --chown=user
|
| 22 |
|
| 23 |
# Create workspace directory
|
| 24 |
RUN mkdir -p /tmp/workspace
|
|
|
|
| 2 |
|
| 3 |
# Install system dependencies
|
| 4 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
git bash curl tini build-essential zip unzip && \
|
| 6 |
rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
# Create non-root user (HF requirement: uid 1000)
|
|
|
|
| 17 |
COPY --chown=user requirements.txt .
|
| 18 |
RUN pip install --no-cache-dir --user -r requirements.txt
|
| 19 |
|
| 20 |
+
# Copy application (including second_brain, watchdog, helix, swarm)
|
| 21 |
+
COPY --chown=user . .
|
| 22 |
|
| 23 |
# Create workspace directory
|
| 24 |
RUN mkdir -p /tmp/workspace
|
backend.py
CHANGED
|
@@ -28,6 +28,19 @@ from pathlib import Path
|
|
| 28 |
from typing import AsyncIterator, Optional, List, Dict, Any
|
| 29 |
from pydantic import BaseModel
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
from fastapi import FastAPI, Request, Header, HTTPException
|
| 32 |
from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse
|
| 33 |
from fastapi.middleware.cors import CORSMiddleware
|
|
@@ -1880,7 +1893,7 @@ import httpx
|
|
| 1880 |
# ---------------------------------------------------------------------------
|
| 1881 |
SPACE3_URL = os.environ.get("SPACE3_URL", "https://augment17-claude-code-backend.hf.space")
|
| 1882 |
SPACE4_URL = os.environ.get("SPACE4_URL", "https://shyota-mcp-cloud-host.hf.space")
|
| 1883 |
-
SPACE5_URL = os.environ.get("SPACE5_URL",
|
| 1884 |
SPACE6_URL = os.environ.get("SPACE6_URL", "https://augment17-mcp-cloud-host.hf.space")
|
| 1885 |
|
| 1886 |
async def get_active_projects():
|
|
@@ -1947,6 +1960,10 @@ _Generated by Space 2 (Cerebrum) at {ts}_
|
|
| 1947 |
|
| 1948 |
async def execute_build_cycle(project_name: str, goal: str):
|
| 1949 |
log_activity(f"[Build Mode] Initiating build cycle for project '{project_name}' (goal: '{goal}')")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1950 |
await rate_limiter.wait_for_nim()
|
| 1951 |
plan_prompt = (
|
| 1952 |
f"We are building: '{goal}'. "
|
|
@@ -1960,7 +1977,6 @@ async def execute_build_cycle(project_name: str, goal: str):
|
|
| 1960 |
max_tokens=300
|
| 1961 |
)
|
| 1962 |
raw = res.choices[0].message.content.strip()
|
| 1963 |
-
# Strip markdown code fences if present
|
| 1964 |
if raw.startswith("```"):
|
| 1965 |
raw = raw.split("```")[1].lstrip("json").strip()
|
| 1966 |
task = json.loads(raw)
|
|
@@ -1970,10 +1986,15 @@ async def execute_build_cycle(project_name: str, goal: str):
|
|
| 1970 |
log_activity(f"[Build Mode Error] NIM planning failed for project '{project_name}': {e}")
|
| 1971 |
return
|
| 1972 |
|
| 1973 |
-
#
|
| 1974 |
-
|
| 1975 |
-
|
| 1976 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1977 |
)
|
| 1978 |
|
| 1979 |
log_activity(f"[Build Mode] Dispatching to Space 3 (Forge) β project '{project_name}': '{task_prompt[:80]}'")
|
|
@@ -1982,7 +2003,7 @@ async def execute_build_cycle(project_name: str, goal: str):
|
|
| 1982 |
"action": "execute_code",
|
| 1983 |
"prompt": task_prompt,
|
| 1984 |
"context_rules": context_rules,
|
| 1985 |
-
"project_state_md": state_md
|
| 1986 |
})
|
| 1987 |
|
| 1988 |
if forge_res.get("status") == "success":
|
|
@@ -1995,22 +2016,54 @@ async def execute_build_cycle(project_name: str, goal: str):
|
|
| 1995 |
"url": SPACE3_URL,
|
| 1996 |
"project_name": project_name
|
| 1997 |
}, timeout=30.0)
|
|
|
|
| 1998 |
verdict = test_res.get("verdict", "UNKNOWN")
|
| 1999 |
reason = test_res.get("reason", "")
|
| 2000 |
log_activity(f"[Build Mode] Space 6 (Sandbox) Verdict: {verdict} β {reason}")
|
| 2001 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2002 |
log_activity("[Build Mode] Triggering Space 5 (Vault) backup commit...")
|
| 2003 |
vault_res = await apost_json(f"{SPACE5_URL}/api/vault/push", {
|
| 2004 |
"project_name": project_name,
|
| 2005 |
"summary": summary
|
| 2006 |
}, timeout=30.0)
|
| 2007 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2008 |
else:
|
| 2009 |
log_activity(f"[Build Mode Warning] Space 3 (Forge) failure for '{project_name}': {forge_res.get('error')}")
|
| 2010 |
|
|
|
|
| 2011 |
async def execute_eternity_cycle(project_name: str, goal: str):
|
| 2012 |
log_activity(f"[Eternity Mode] R&D cycle starting for project '{project_name}' (goal: '{goal}')")
|
| 2013 |
|
|
|
|
|
|
|
|
|
|
| 2014 |
# Step 1: Ask Space 4 (Library) for research
|
| 2015 |
log_activity(f"[Eternity Mode] Querying Space 4 (Library) for '{project_name}'...")
|
| 2016 |
research_res = await apost_json(f"{SPACE4_URL}/api/research", {
|
|
@@ -2020,6 +2073,14 @@ async def execute_eternity_cycle(project_name: str, goal: str):
|
|
| 2020 |
await update_db_brief(project_name, brief)
|
| 2021 |
log_activity(f"[Eternity Mode] Space 4 (Library) brief received ({len(brief)} chars)")
|
| 2022 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2023 |
# Step 2: Ask NIM to plan next feature based on research
|
| 2024 |
await rate_limiter.wait_for_nim()
|
| 2025 |
plan_prompt = (
|
|
@@ -2043,10 +2104,13 @@ async def execute_eternity_cycle(project_name: str, goal: str):
|
|
| 2043 |
log_activity(f"[Eternity Mode Error] NIM planning failed for '{project_name}': {e}")
|
| 2044 |
return
|
| 2045 |
|
| 2046 |
-
# Step 3: Build Karpathy
|
| 2047 |
-
state_md =
|
| 2048 |
-
project_name=project_name,
|
| 2049 |
-
|
|
|
|
|
|
|
|
|
|
| 2050 |
)
|
| 2051 |
|
| 2052 |
# Step 4: Dispatch to Space 3 (Forge)
|
|
@@ -2062,12 +2126,29 @@ async def execute_eternity_cycle(project_name: str, goal: str):
|
|
| 2062 |
if forge_res.get("status") == "success":
|
| 2063 |
summary = forge_res.get("summary", "")
|
| 2064 |
log_activity(f"[Eternity Mode] Space 3 (Forge) SUCCESS: {summary}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2065 |
# Trigger vault backup after each successful eternity cycle
|
| 2066 |
vault_res = await apost_json(f"{SPACE5_URL}/api/vault/push", {
|
| 2067 |
"project_name": project_name,
|
| 2068 |
"summary": summary
|
| 2069 |
}, timeout=30.0)
|
| 2070 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2071 |
else:
|
| 2072 |
log_activity(f"[Eternity Mode Warning] Space 3 (Forge) failure: {forge_res.get('error')}")
|
| 2073 |
|
|
@@ -2211,6 +2292,18 @@ async def get_logs():
|
|
| 2211 |
return list(activity_logs)
|
| 2212 |
|
| 2213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2214 |
@app.get("/api/models-status")
|
| 2215 |
async def get_models_status():
|
| 2216 |
status_list = []
|
|
@@ -2437,10 +2530,36 @@ async def db_cleanup_loop():
|
|
| 2437 |
await asyncio.sleep(86400) # Every 24 hours
|
| 2438 |
|
| 2439 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2440 |
@app.on_event("startup")
|
| 2441 |
async def startup_event():
|
| 2442 |
-
#
|
| 2443 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2444 |
# Start the local backup loop thread
|
| 2445 |
threading.Thread(target=run_backup_loop, daemon=True).start()
|
| 2446 |
# Start the eternity R&D loop as an async task on the main event loop
|
|
@@ -2451,6 +2570,7 @@ async def startup_event():
|
|
| 2451 |
asyncio.create_task(db_cleanup_loop())
|
| 2452 |
|
| 2453 |
|
|
|
|
| 2454 |
# ---------------------------------------------------------------------------
|
| 2455 |
# Entrypoint
|
| 2456 |
# ---------------------------------------------------------------------------
|
|
|
|
| 28 |
from typing import AsyncIterator, Optional, List, Dict, Any
|
| 29 |
from pydantic import BaseModel
|
| 30 |
|
| 31 |
+
# --- Ultimate Agent Brain Imports ---
|
| 32 |
+
from second_brain import SecondBrainWrapper
|
| 33 |
+
from survival_watchdog import SurvivalWatchdog, get_metrics
|
| 34 |
+
from swarm_llm import swarm
|
| 35 |
+
from helix_state import helix_db
|
| 36 |
+
from context_engine import ContextEngine
|
| 37 |
+
|
| 38 |
+
# Instantiate singletons for the orchestrator
|
| 39 |
+
brain = SecondBrainWrapper(space_name="space2-cerebrum")
|
| 40 |
+
context_engine = ContextEngine(brain)
|
| 41 |
+
watchdog = SurvivalWatchdog()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
from fastapi import FastAPI, Request, Header, HTTPException
|
| 45 |
from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse
|
| 46 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 1893 |
# ---------------------------------------------------------------------------
|
| 1894 |
SPACE3_URL = os.environ.get("SPACE3_URL", "https://augment17-claude-code-backend.hf.space")
|
| 1895 |
SPACE4_URL = os.environ.get("SPACE4_URL", "https://shyota-mcp-cloud-host.hf.space")
|
| 1896 |
+
SPACE5_URL = os.environ.get("SPACE5_URL", "https://augment17-agent-worker-loop.hf.space") # Dedicated Vault container
|
| 1897 |
SPACE6_URL = os.environ.get("SPACE6_URL", "https://augment17-mcp-cloud-host.hf.space")
|
| 1898 |
|
| 1899 |
async def get_active_projects():
|
|
|
|
| 1960 |
|
| 1961 |
async def execute_build_cycle(project_name: str, goal: str):
|
| 1962 |
log_activity(f"[Build Mode] Initiating build cycle for project '{project_name}' (goal: '{goal}')")
|
| 1963 |
+
|
| 1964 |
+
# Track project state in our in-process graph DB
|
| 1965 |
+
helix_db.upsert_project(project_name, goal, "build", "low")
|
| 1966 |
+
|
| 1967 |
await rate_limiter.wait_for_nim()
|
| 1968 |
plan_prompt = (
|
| 1969 |
f"We are building: '{goal}'. "
|
|
|
|
| 1977 |
max_tokens=300
|
| 1978 |
)
|
| 1979 |
raw = res.choices[0].message.content.strip()
|
|
|
|
| 1980 |
if raw.startswith("```"):
|
| 1981 |
raw = raw.split("```")[1].lstrip("json").strip()
|
| 1982 |
task = json.loads(raw)
|
|
|
|
| 1986 |
log_activity(f"[Build Mode Error] NIM planning failed for project '{project_name}': {e}")
|
| 1987 |
return
|
| 1988 |
|
| 1989 |
+
# Use the context-locked Bell Curve prompt builder (enforces max 2500 tokens)
|
| 1990 |
+
playbook_file = f"space3-forge/debugging/{project_name}_playbook.md"
|
| 1991 |
+
state_md = brain.build_apex_prompt(
|
| 1992 |
+
project_name=project_name,
|
| 1993 |
+
goal=goal,
|
| 1994 |
+
mode="build",
|
| 1995 |
+
task_instruction=task_prompt,
|
| 1996 |
+
research_topic=project_name,
|
| 1997 |
+
current_file=""
|
| 1998 |
)
|
| 1999 |
|
| 2000 |
log_activity(f"[Build Mode] Dispatching to Space 3 (Forge) β project '{project_name}': '{task_prompt[:80]}'")
|
|
|
|
| 2003 |
"action": "execute_code",
|
| 2004 |
"prompt": task_prompt,
|
| 2005 |
"context_rules": context_rules,
|
| 2006 |
+
"project_state_md": state_md
|
| 2007 |
})
|
| 2008 |
|
| 2009 |
if forge_res.get("status") == "success":
|
|
|
|
| 2016 |
"url": SPACE3_URL,
|
| 2017 |
"project_name": project_name
|
| 2018 |
}, timeout=30.0)
|
| 2019 |
+
|
| 2020 |
verdict = test_res.get("verdict", "UNKNOWN")
|
| 2021 |
reason = test_res.get("reason", "")
|
| 2022 |
log_activity(f"[Build Mode] Space 6 (Sandbox) Verdict: {verdict} β {reason}")
|
| 2023 |
|
| 2024 |
+
# --- ACE: Reflect & Curate ---
|
| 2025 |
+
# Runs the feedback loop to update playbook rules on failure, or record patterns on success
|
| 2026 |
+
ace_verdict = await context_engine.reflect_and_curate(
|
| 2027 |
+
project_name=project_name,
|
| 2028 |
+
task_prompt=task_prompt,
|
| 2029 |
+
result_summary=summary,
|
| 2030 |
+
verdict=verdict,
|
| 2031 |
+
reason=reason
|
| 2032 |
+
)
|
| 2033 |
+
log_activity(f"[Build Mode] ACE Reflection Result: {ace_verdict}")
|
| 2034 |
+
|
| 2035 |
+
# Update in-process graph DB cycle and state
|
| 2036 |
+
helix_db.record_cycle(project_name, summary, verdict)
|
| 2037 |
+
|
| 2038 |
+
# Log event directly to the local Second Brain git repo
|
| 2039 |
+
brain.append(
|
| 2040 |
+
"space2-cerebrum/loop_log.md",
|
| 2041 |
+
f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Build Success | Project: {project_name} | Summary: {summary[:80]} | Verdict: {verdict}",
|
| 2042 |
+
f"[Brain Log] Record build cycle for {project_name}"
|
| 2043 |
+
)
|
| 2044 |
+
|
| 2045 |
log_activity("[Build Mode] Triggering Space 5 (Vault) backup commit...")
|
| 2046 |
vault_res = await apost_json(f"{SPACE5_URL}/api/vault/push", {
|
| 2047 |
"project_name": project_name,
|
| 2048 |
"summary": summary
|
| 2049 |
}, timeout=30.0)
|
| 2050 |
+
if vault_res.get("status") == "error":
|
| 2051 |
+
log_activity("[Build Mode] Space 5 (Vault) failed. Retrying backup via Space 3 (Forge) fallback...")
|
| 2052 |
+
vault_res = await apost_json(f"{SPACE3_URL}/api/vault/push", {
|
| 2053 |
+
"project_name": project_name,
|
| 2054 |
+
"summary": summary
|
| 2055 |
+
}, timeout=30.0)
|
| 2056 |
+
log_activity(f"[Build Mode] Vault backup status: {vault_res.get('status', 'unknown')}")
|
| 2057 |
else:
|
| 2058 |
log_activity(f"[Build Mode Warning] Space 3 (Forge) failure for '{project_name}': {forge_res.get('error')}")
|
| 2059 |
|
| 2060 |
+
|
| 2061 |
async def execute_eternity_cycle(project_name: str, goal: str):
|
| 2062 |
log_activity(f"[Eternity Mode] R&D cycle starting for project '{project_name}' (goal: '{goal}')")
|
| 2063 |
|
| 2064 |
+
# Track project state in our in-process graph DB
|
| 2065 |
+
helix_db.upsert_project(project_name, goal, "eternity", "low")
|
| 2066 |
+
|
| 2067 |
# Step 1: Ask Space 4 (Library) for research
|
| 2068 |
log_activity(f"[Eternity Mode] Querying Space 4 (Library) for '{project_name}'...")
|
| 2069 |
research_res = await apost_json(f"{SPACE4_URL}/api/research", {
|
|
|
|
| 2073 |
await update_db_brief(project_name, brief)
|
| 2074 |
log_activity(f"[Eternity Mode] Space 4 (Library) brief received ({len(brief)} chars)")
|
| 2075 |
|
| 2076 |
+
# Save research brief to Second Brain wiki on GitHub
|
| 2077 |
+
research_file = f"space4-library/research/{project_name.replace(' ', '-')}.md"
|
| 2078 |
+
brain.write(
|
| 2079 |
+
research_file,
|
| 2080 |
+
f"# Research Brief: {project_name}\n\n{brief}",
|
| 2081 |
+
f"[Brain Research] Save brief for {project_name}"
|
| 2082 |
+
)
|
| 2083 |
+
|
| 2084 |
# Step 2: Ask NIM to plan next feature based on research
|
| 2085 |
await rate_limiter.wait_for_nim()
|
| 2086 |
plan_prompt = (
|
|
|
|
| 2104 |
log_activity(f"[Eternity Mode Error] NIM planning failed for '{project_name}': {e}")
|
| 2105 |
return
|
| 2106 |
|
| 2107 |
+
# Step 3: Build Karpathy context-locked prompt (Apex)
|
| 2108 |
+
state_md = brain.build_apex_prompt(
|
| 2109 |
+
project_name=project_name,
|
| 2110 |
+
goal=goal,
|
| 2111 |
+
mode="eternity",
|
| 2112 |
+
task_instruction=task_prompt,
|
| 2113 |
+
research_topic=project_name
|
| 2114 |
)
|
| 2115 |
|
| 2116 |
# Step 4: Dispatch to Space 3 (Forge)
|
|
|
|
| 2126 |
if forge_res.get("status") == "success":
|
| 2127 |
summary = forge_res.get("summary", "")
|
| 2128 |
log_activity(f"[Eternity Mode] Space 3 (Forge) SUCCESS: {summary}")
|
| 2129 |
+
|
| 2130 |
+
# Update graph DB cycle
|
| 2131 |
+
helix_db.record_cycle(project_name, summary, "PASS")
|
| 2132 |
+
|
| 2133 |
+
# Log event directly to the local Second Brain git repo
|
| 2134 |
+
brain.append(
|
| 2135 |
+
"space2-cerebrum/loop_log.md",
|
| 2136 |
+
f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Eternity Cycle Success | Project: {project_name} | Summary: {summary[:80]}",
|
| 2137 |
+
f"[Brain Log] Record eternity cycle for {project_name}"
|
| 2138 |
+
)
|
| 2139 |
+
|
| 2140 |
# Trigger vault backup after each successful eternity cycle
|
| 2141 |
vault_res = await apost_json(f"{SPACE5_URL}/api/vault/push", {
|
| 2142 |
"project_name": project_name,
|
| 2143 |
"summary": summary
|
| 2144 |
}, timeout=30.0)
|
| 2145 |
+
if vault_res.get("status") == "error":
|
| 2146 |
+
log_activity("[Eternity Mode] Space 5 (Vault) failed. Retrying backup via Space 3 (Forge) fallback...")
|
| 2147 |
+
vault_res = await apost_json(f"{SPACE3_URL}/api/vault/push", {
|
| 2148 |
+
"project_name": project_name,
|
| 2149 |
+
"summary": summary
|
| 2150 |
+
}, timeout=30.0)
|
| 2151 |
+
log_activity(f"[Eternity Mode] Vault backup status: {vault_res.get('status', 'unknown')}")
|
| 2152 |
else:
|
| 2153 |
log_activity(f"[Eternity Mode Warning] Space 3 (Forge) failure: {forge_res.get('error')}")
|
| 2154 |
|
|
|
|
| 2292 |
return list(activity_logs)
|
| 2293 |
|
| 2294 |
|
| 2295 |
+
@app.get("/api/metrics")
|
| 2296 |
+
async def metrics():
|
| 2297 |
+
"""Exposes live CPU/RAM and HelixStateDB graph stats for space monitoring."""
|
| 2298 |
+
vm = watchdog.get_metrics() if hasattr(watchdog, "get_metrics") else get_metrics()
|
| 2299 |
+
return {
|
| 2300 |
+
"status": "success",
|
| 2301 |
+
"timestamp": time.time(),
|
| 2302 |
+
"space_resources": vm,
|
| 2303 |
+
"graph_database": helix_db.dump() if hasattr(helix_db, "dump") else {}
|
| 2304 |
+
}
|
| 2305 |
+
|
| 2306 |
+
|
| 2307 |
@app.get("/api/models-status")
|
| 2308 |
async def get_models_status():
|
| 2309 |
status_list = []
|
|
|
|
| 2530 |
await asyncio.sleep(86400) # Every 24 hours
|
| 2531 |
|
| 2532 |
|
| 2533 |
+
async def wiki_compactor_loop():
|
| 2534 |
+
log_activity("[Compactor] Wiki auto-compactor background task started.")
|
| 2535 |
+
while True:
|
| 2536 |
+
try:
|
| 2537 |
+
await context_engine.compact_wiki()
|
| 2538 |
+
except Exception as e:
|
| 2539 |
+
log_activity(f"[Compactor Error] {e}")
|
| 2540 |
+
await asyncio.sleep(86400) # Every 24 hours
|
| 2541 |
+
|
| 2542 |
+
|
| 2543 |
@app.on_event("startup")
|
| 2544 |
async def startup_event():
|
| 2545 |
+
# Initialize the Ultimate Brain components
|
| 2546 |
+
try:
|
| 2547 |
+
log_activity("[Startup] Initialising Second Brain (git clone)...")
|
| 2548 |
+
brain.initialize()
|
| 2549 |
+
log_activity("[Startup] Second Brain cache ready.")
|
| 2550 |
+
|
| 2551 |
+
log_activity("[Startup] Warming up local SwarmLLM (Qwen2.5-1.5B)...")
|
| 2552 |
+
asyncio.create_task(swarm.warm_up())
|
| 2553 |
+
except Exception as e:
|
| 2554 |
+
log_activity(f"[Startup Error] Brain init failed: {e}")
|
| 2555 |
+
|
| 2556 |
+
# Start the Resource Watchdog background task
|
| 2557 |
+
asyncio.create_task(watchdog.run())
|
| 2558 |
+
# Start the Git-Sync background task
|
| 2559 |
+
asyncio.create_task(brain.background_sync())
|
| 2560 |
+
# Start the Wiki Auto-Compaction task
|
| 2561 |
+
asyncio.create_task(wiki_compactor_loop())
|
| 2562 |
+
|
| 2563 |
# Start the local backup loop thread
|
| 2564 |
threading.Thread(target=run_backup_loop, daemon=True).start()
|
| 2565 |
# Start the eternity R&D loop as an async task on the main event loop
|
|
|
|
| 2570 |
asyncio.create_task(db_cleanup_loop())
|
| 2571 |
|
| 2572 |
|
| 2573 |
+
|
| 2574 |
# ---------------------------------------------------------------------------
|
| 2575 |
# Entrypoint
|
| 2576 |
# ---------------------------------------------------------------------------
|
context_engine.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
context_engine.py β Agentic Context Engine (ACE) & Auto-Compactor
|
| 4 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 5 |
+
Implements the 3-agent self-improvement loop:
|
| 6 |
+
1. Generator: Space 3 (Forge) executes code based on prompt.
|
| 7 |
+
2. Reflector: Analyzes the test log and verdict from Space 6 (Sandbox).
|
| 8 |
+
Determines what succeeded, what failed, and why.
|
| 9 |
+
3. Curator: Updates the persistent "playbook" in the Second Brain
|
| 10 |
+
with actionable instructions to avoid repeating mistakes.
|
| 11 |
+
|
| 12 |
+
Also implements the Auto-Compactor:
|
| 13 |
+
- Scans playbooks and logs.
|
| 14 |
+
- Summarises and merges duplicate rules to keep context within
|
| 15 |
+
the Bell Curve apex (preventing context poisoning).
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import logging
|
| 20 |
+
import time
|
| 21 |
+
from second_brain import SecondBrainWrapper
|
| 22 |
+
from swarm_llm import swarm
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger("context_engine")
|
| 25 |
+
|
| 26 |
+
class ContextEngine:
|
| 27 |
+
def __init__(self, brain: SecondBrainWrapper):
|
| 28 |
+
self.brain = brain
|
| 29 |
+
|
| 30 |
+
# ββ ACE Reflect & Curate ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
|
| 32 |
+
async def reflect_and_curate(
|
| 33 |
+
self,
|
| 34 |
+
project_name: str,
|
| 35 |
+
task_prompt: str,
|
| 36 |
+
result_summary: str,
|
| 37 |
+
verdict: str,
|
| 38 |
+
reason: str
|
| 39 |
+
) -> str:
|
| 40 |
+
"""
|
| 41 |
+
Runs after an execution cycle.
|
| 42 |
+
If failed, reflects on why and updates the project playbook.
|
| 43 |
+
If succeeded, records the success pattern.
|
| 44 |
+
"""
|
| 45 |
+
playbook_path = f"space3-forge/debugging/{project_name}_playbook.md"
|
| 46 |
+
current_playbook = self.brain.read(playbook_path, "brain")
|
| 47 |
+
|
| 48 |
+
if verdict == "FAIL":
|
| 49 |
+
logger.info(f"[ACE] Project '{project_name}' failed verification. Reflectingβ¦")
|
| 50 |
+
reflection_prompt = f"""Task attempted:
|
| 51 |
+
"{task_prompt}"
|
| 52 |
+
|
| 53 |
+
Execution summary:
|
| 54 |
+
"{result_summary}"
|
| 55 |
+
|
| 56 |
+
Test Failure Reason:
|
| 57 |
+
"{reason}"
|
| 58 |
+
|
| 59 |
+
Current Playbook rules:
|
| 60 |
+
{current_playbook if current_playbook else "_No rules yet._"}
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
What went wrong? Write exactly 1 or 2 new concrete guidelines for the coder agent to prevent this specific failure in the future.
|
| 64 |
+
Keep guidelines extremely brief, specific, and actionable. Do NOT repeat existing rules.
|
| 65 |
+
"""
|
| 66 |
+
# Use local SwarmLLM to reflect (saving NIM quota)
|
| 67 |
+
new_rules = await swarm.infer(reflection_prompt, system="You are a senior code architect reflecting on test failures.")
|
| 68 |
+
|
| 69 |
+
# Curate: Append new rules to playbook
|
| 70 |
+
updated_playbook = current_playbook + f"\n\n### Failure Correction ({time.strftime('%Y-%m-%d')})\n{new_rules.strip()}"
|
| 71 |
+
self.brain.write(playbook_path, updated_playbook, f"[ACE] Add failure corrections for {project_name}")
|
| 72 |
+
logger.info(f"[ACE] Curated playbook for '{project_name}' updated on GitHub.")
|
| 73 |
+
return new_rules
|
| 74 |
+
|
| 75 |
+
elif verdict == "PASS" and not current_playbook:
|
| 76 |
+
# Seed the playbook with success patterns
|
| 77 |
+
logger.info(f"[ACE] Project '{project_name}' passed. Seeding playbook.")
|
| 78 |
+
seed_content = f"""# Playbook: {project_name}
|
| 79 |
+
_Self-improving ruleset curated by ACE (Agentic Context Engine)_
|
| 80 |
+
|
| 81 |
+
## Success Rules
|
| 82 |
+
- Initial implementation passed test suite successfully. Keep code simple and modular.
|
| 83 |
+
"""
|
| 84 |
+
self.brain.write(playbook_path, seed_content, f"[ACE] Seed playbook for {project_name}")
|
| 85 |
+
return "Seed rules created"
|
| 86 |
+
|
| 87 |
+
return "No curation required"
|
| 88 |
+
|
| 89 |
+
# ββ Auto-Compactor ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 90 |
+
|
| 91 |
+
async def compact_wiki(self):
|
| 92 |
+
"""
|
| 93 |
+
Auto-Compaction Protocol.
|
| 94 |
+
Scans all files in the Second Brain.
|
| 95 |
+
If a playbook or log exceeds its slot budget, consolidates and merges
|
| 96 |
+
duplicate rules to prevent context poisoning.
|
| 97 |
+
"""
|
| 98 |
+
logger.info("[Compactor] Running wiki compaction sweepβ¦")
|
| 99 |
+
|
| 100 |
+
# 1. Compact playbooks in space3-forge/debugging/
|
| 101 |
+
playbooks = self.brain.list_files("space3-forge/debugging")
|
| 102 |
+
for p in playbooks:
|
| 103 |
+
content = self.brain.read(p, "brain")
|
| 104 |
+
if len(content) > 3000:
|
| 105 |
+
logger.info(f"[Compactor] Playbook '{p}' is large ({len(content)} chars). Compactingβ¦")
|
| 106 |
+
compaction_prompt = f"""The following is a coding playbook with rules collected over multiple cycles:
|
| 107 |
+
{content}
|
| 108 |
+
|
| 109 |
+
---
|
| 110 |
+
Consolidate the rules above. Remove duplicates, merge similar guidelines, and output a clean, highly condensed list of rules.
|
| 111 |
+
Maintain the markdown header structure. Do NOT lose important technical details.
|
| 112 |
+
"""
|
| 113 |
+
compacted = await swarm.infer(compaction_prompt, system="You are an expert compiler that deduplicates and condenses playbooks.")
|
| 114 |
+
self.brain.write(p, compacted, f"[Compactor] Compacted playbook {p}")
|
| 115 |
+
logger.info(f"[Compactor] Compacted '{p}' down to {len(compacted)} chars.")
|
| 116 |
+
|
| 117 |
+
# 2. Compact loop logs in space2-cerebrum/loop_log.md
|
| 118 |
+
log_path = "space2-cerebrum/loop_log.md"
|
| 119 |
+
log_content = self.brain.read(log_path, "brain")
|
| 120 |
+
if len(log_content) > 4000:
|
| 121 |
+
logger.info(f"[Compactor] Log '{log_path}' exceeds limit. Archiving old entriesβ¦")
|
| 122 |
+
lines = log_content.splitlines()
|
| 123 |
+
# Keep only the last 30 lines, archive the rest
|
| 124 |
+
recent = "\n".join(lines[-30:])
|
| 125 |
+
self.brain.write(log_path, recent, "[Compactor] Trim and archive old loop logs")
|
| 126 |
+
logger.info(f"[Compactor] Loop log trimmed.")
|
| 127 |
+
|
| 128 |
+
logger.info("[Compactor] Compaction sweep complete.")
|
helix_state.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
helix_state.py β In-Process Graph State Manager
|
| 4 |
+
βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 5 |
+
Implements the same graph API that HelixDB would expose via Python bindings,
|
| 6 |
+
but runs entirely in-process (zero network, zero daemon, ~0 MB overhead).
|
| 7 |
+
|
| 8 |
+
Why not Redis?
|
| 9 |
+
Redis requires a separate daemon process (~200 MB RAM) and TCP round-trips.
|
| 10 |
+
This module stores the same data as a Python dict-of-dicts with O(1) node
|
| 11 |
+
lookup and O(k) edge traversal where k = number of edges per node.
|
| 12 |
+
|
| 13 |
+
Why not flat dicts in backend.py?
|
| 14 |
+
A graph model lets us express relationships that flat dicts cannot:
|
| 15 |
+
Project β HAS_TASK β Task
|
| 16 |
+
Task β USES_FILE β File
|
| 17 |
+
File β HAD_BUG β Bug
|
| 18 |
+
Bug β FIXED_BY β Snippet (in Second Brain)
|
| 19 |
+
This powers the Bell Curve apex prompt: we can query "What file is this
|
| 20 |
+
task working on?" and load exactly that file β nothing more.
|
| 21 |
+
|
| 22 |
+
Drop-in swap: When HelixDB ships stable Python bindings, replace this
|
| 23 |
+
file with: from helixdb import HelixDB as HelixStateDB
|
| 24 |
+
The public API (add_node, add_edge, get_node, get_neighbors, update_node,
|
| 25 |
+
remove_node, query_path) is kept identical to the planned HelixDB SDK.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
import time
|
| 29 |
+
import logging
|
| 30 |
+
import threading
|
| 31 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger("helix_state")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class HelixStateDB:
|
| 37 |
+
"""
|
| 38 |
+
In-process graph database.
|
| 39 |
+
|
| 40 |
+
Graph model:
|
| 41 |
+
Nodes: { node_type: { node_id: { **properties } } }
|
| 42 |
+
Edges: { (src_type, src_id, edge_label, dst_type, dst_id): { **properties } }
|
| 43 |
+
|
| 44 |
+
Thread-safe for concurrent FastAPI request handlers.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
def __init__(self):
|
| 48 |
+
self._nodes: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
| 49 |
+
self._edges: Dict[Tuple, Dict[str, Any]] = {}
|
| 50 |
+
self._lock = threading.RLock()
|
| 51 |
+
logger.info("[HelixState] In-process graph database initialised.")
|
| 52 |
+
|
| 53 |
+
# ββ Node Operations βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 54 |
+
|
| 55 |
+
def add_node(self, node_type: str, node_id: str, **props) -> bool:
|
| 56 |
+
"""Insert or replace a node. Returns True on success."""
|
| 57 |
+
with self._lock:
|
| 58 |
+
if node_type not in self._nodes:
|
| 59 |
+
self._nodes[node_type] = {}
|
| 60 |
+
self._nodes[node_type][node_id] = {
|
| 61 |
+
**props,
|
| 62 |
+
"_created_at": time.time(),
|
| 63 |
+
"_updated_at": time.time(),
|
| 64 |
+
}
|
| 65 |
+
logger.debug("[HelixState] add_node(%s, %s)", node_type, node_id)
|
| 66 |
+
return True
|
| 67 |
+
|
| 68 |
+
def update_node(self, node_type: str, node_id: str, **props) -> bool:
|
| 69 |
+
"""Merge props into an existing node. Returns False if node not found."""
|
| 70 |
+
with self._lock:
|
| 71 |
+
node = self._nodes.get(node_type, {}).get(node_id)
|
| 72 |
+
if node is None:
|
| 73 |
+
return False
|
| 74 |
+
node.update(props)
|
| 75 |
+
node["_updated_at"] = time.time()
|
| 76 |
+
logger.debug("[HelixState] update_node(%s, %s)", node_type, node_id)
|
| 77 |
+
return True
|
| 78 |
+
|
| 79 |
+
def get_node(self, node_type: str, node_id: str) -> Optional[Dict[str, Any]]:
|
| 80 |
+
"""Return a node's property dict, or None."""
|
| 81 |
+
with self._lock:
|
| 82 |
+
return self._nodes.get(node_type, {}).get(node_id)
|
| 83 |
+
|
| 84 |
+
def remove_node(self, node_type: str, node_id: str) -> bool:
|
| 85 |
+
"""Remove a node and all its edges."""
|
| 86 |
+
with self._lock:
|
| 87 |
+
if node_id not in self._nodes.get(node_type, {}):
|
| 88 |
+
return False
|
| 89 |
+
del self._nodes[node_type][node_id]
|
| 90 |
+
# Prune orphaned edges
|
| 91 |
+
dead = [k for k in self._edges
|
| 92 |
+
if (k[0] == node_type and k[1] == node_id) or
|
| 93 |
+
(k[3] == node_type and k[4] == node_id)]
|
| 94 |
+
for k in dead:
|
| 95 |
+
del self._edges[k]
|
| 96 |
+
logger.debug("[HelixState] remove_node(%s, %s) + %d edges", node_type, node_id, len(dead))
|
| 97 |
+
return True
|
| 98 |
+
|
| 99 |
+
def list_nodes(self, node_type: str) -> List[str]:
|
| 100 |
+
"""Return all node IDs of a given type."""
|
| 101 |
+
with self._lock:
|
| 102 |
+
return list(self._nodes.get(node_type, {}).keys())
|
| 103 |
+
|
| 104 |
+
# ββ Edge Operations βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
+
|
| 106 |
+
def add_edge(
|
| 107 |
+
self,
|
| 108 |
+
src_type: str, src_id: str,
|
| 109 |
+
dst_type: str, dst_id: str,
|
| 110 |
+
label: str,
|
| 111 |
+
**props,
|
| 112 |
+
) -> bool:
|
| 113 |
+
"""Add a directed edge (src)-[label]->(dst). Overwrites if exists."""
|
| 114 |
+
with self._lock:
|
| 115 |
+
key = (src_type, src_id, label, dst_type, dst_id)
|
| 116 |
+
self._edges[key] = {**props, "_created_at": time.time()}
|
| 117 |
+
logger.debug("[HelixState] add_edge %s:%s -[%s]-> %s:%s", src_type, src_id, label, dst_type, dst_id)
|
| 118 |
+
return True
|
| 119 |
+
|
| 120 |
+
def get_neighbors(
|
| 121 |
+
self,
|
| 122 |
+
src_type: str,
|
| 123 |
+
src_id: str,
|
| 124 |
+
label: str,
|
| 125 |
+
dst_type: Optional[str] = None,
|
| 126 |
+
) -> List[Dict[str, Any]]:
|
| 127 |
+
"""
|
| 128 |
+
Return list of destination node property dicts reachable from
|
| 129 |
+
(src_type, src_id) via edges with the given label.
|
| 130 |
+
Optionally filter by dst_type.
|
| 131 |
+
"""
|
| 132 |
+
with self._lock:
|
| 133 |
+
results = []
|
| 134 |
+
for key, edge_props in self._edges.items():
|
| 135 |
+
s_type, s_id, e_label, d_type, d_id = key
|
| 136 |
+
if s_type != src_type or s_id != src_id or e_label != label:
|
| 137 |
+
continue
|
| 138 |
+
if dst_type and d_type != dst_type:
|
| 139 |
+
continue
|
| 140 |
+
node = self._nodes.get(d_type, {}).get(d_id)
|
| 141 |
+
if node:
|
| 142 |
+
results.append({"_type": d_type, "_id": d_id, **node})
|
| 143 |
+
return results
|
| 144 |
+
|
| 145 |
+
def remove_edge(
|
| 146 |
+
self,
|
| 147 |
+
src_type: str, src_id: str,
|
| 148 |
+
dst_type: str, dst_id: str,
|
| 149 |
+
label: str,
|
| 150 |
+
) -> bool:
|
| 151 |
+
"""Remove a specific directed edge."""
|
| 152 |
+
with self._lock:
|
| 153 |
+
key = (src_type, src_id, label, dst_type, dst_id)
|
| 154 |
+
if key in self._edges:
|
| 155 |
+
del self._edges[key]
|
| 156 |
+
return True
|
| 157 |
+
return False
|
| 158 |
+
|
| 159 |
+
# ββ Query Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 160 |
+
|
| 161 |
+
def query_path(
|
| 162 |
+
self,
|
| 163 |
+
start_type: str, start_id: str,
|
| 164 |
+
*edge_labels: str,
|
| 165 |
+
) -> List[Dict[str, Any]]:
|
| 166 |
+
"""
|
| 167 |
+
Traverse a chain of edges and return the terminal nodes.
|
| 168 |
+
Example: query_path("project", "calc_v1", "HAS_TASK", "USES_FILE")
|
| 169 |
+
Returns all File nodes reachable via the two-hop path.
|
| 170 |
+
"""
|
| 171 |
+
current: List[Dict] = [{"_type": start_type, "_id": start_id}]
|
| 172 |
+
for label in edge_labels:
|
| 173 |
+
next_level = []
|
| 174 |
+
for node in current:
|
| 175 |
+
ntype, nid = node["_type"], node["_id"]
|
| 176 |
+
neighbors = self.get_neighbors(ntype, nid, label)
|
| 177 |
+
next_level.extend(neighbors)
|
| 178 |
+
current = next_level
|
| 179 |
+
return current
|
| 180 |
+
|
| 181 |
+
def dump(self) -> Dict[str, Any]:
|
| 182 |
+
"""Serialise the full graph to a JSON-compatible dict (for /api/metrics)."""
|
| 183 |
+
with self._lock:
|
| 184 |
+
return {
|
| 185 |
+
"node_counts": {t: len(ids) for t, ids in self._nodes.items()},
|
| 186 |
+
"edge_count": len(self._edges),
|
| 187 |
+
"nodes": {t: dict(ids) for t, ids in self._nodes.items()},
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
# ββ Project State Helpers (Eternity Loop convenience) ββββββββββββββββββββ
|
| 191 |
+
|
| 192 |
+
def upsert_project(self, name: str, goal: str, mode: str, priority: str):
|
| 193 |
+
"""Convenience: add or update a project node."""
|
| 194 |
+
if not self.get_node("project", name):
|
| 195 |
+
self.add_node("project", name, goal=goal, mode=mode, priority=priority, cycle=0)
|
| 196 |
+
else:
|
| 197 |
+
self.update_node("project", name, mode=mode, priority=priority)
|
| 198 |
+
|
| 199 |
+
def record_cycle(self, project_name: str, summary: str, status: str):
|
| 200 |
+
"""Increment cycle counter and store last summary on the project node."""
|
| 201 |
+
node = self.get_node("project", project_name)
|
| 202 |
+
if node:
|
| 203 |
+
cycle = node.get("cycle", 0) + 1
|
| 204 |
+
self.update_node("project", project_name,
|
| 205 |
+
cycle=cycle,
|
| 206 |
+
last_summary=summary,
|
| 207 |
+
last_status=status,
|
| 208 |
+
last_cycle_at=time.time())
|
| 209 |
+
|
| 210 |
+
def get_active_project_names(self) -> List[str]:
|
| 211 |
+
"""Return IDs of all project nodes where is_active == True."""
|
| 212 |
+
with self._lock:
|
| 213 |
+
return [
|
| 214 |
+
pid for pid, props in self._nodes.get("project", {}).items()
|
| 215 |
+
if props.get("is_active", True)
|
| 216 |
+
]
|
| 217 |
+
|
| 218 |
+
def link_task_to_file(self, project_name: str, task_id: str, file_path: str):
|
| 219 |
+
"""Record which file a task is working on, for targeted brain loading."""
|
| 220 |
+
self.add_node("task", task_id, project=project_name, file=file_path)
|
| 221 |
+
self.add_edge("project", project_name, "task", task_id, "HAS_TASK")
|
| 222 |
+
if file_path:
|
| 223 |
+
self.add_node("file", file_path)
|
| 224 |
+
self.add_edge("task", task_id, "file", file_path, "USES_FILE")
|
| 225 |
+
|
| 226 |
+
def record_bug_fix(self, file_path: str, bug_summary: str, fix_summary: str, brain_path: str):
|
| 227 |
+
"""Record that a bug in a file was fixed and persisted to the Second Brain."""
|
| 228 |
+
bug_id = f"bug_{int(time.time())}"
|
| 229 |
+
self.add_node("bug", bug_id, file=file_path, summary=bug_summary)
|
| 230 |
+
self.add_node("fix", bug_id, summary=fix_summary, brain_path=brain_path)
|
| 231 |
+
self.add_edge("file", file_path, "bug", bug_id, "HAD_BUG")
|
| 232 |
+
self.add_edge("bug", bug_id, "fix", bug_id, "FIXED_BY")
|
| 233 |
+
self.add_edge("fix", bug_id, "brain", brain_path, "PERSISTED_TO")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# Singleton β import and use directly
|
| 237 |
+
helix_db = HelixStateDB()
|
requirements.txt
CHANGED
|
@@ -5,4 +5,6 @@ asyncpg==0.30.0
|
|
| 5 |
anyio==4.9.0
|
| 6 |
psutil==5.9.8
|
| 7 |
httpx==0.27.2
|
|
|
|
|
|
|
| 8 |
|
|
|
|
| 5 |
anyio==4.9.0
|
| 6 |
psutil==5.9.8
|
| 7 |
httpx==0.27.2
|
| 8 |
+
huggingface_hub==0.25.2
|
| 9 |
+
llama-cpp-python==0.2.90
|
| 10 |
|
second_brain.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
second_brain.py β Git-Synced Virtual Multi-Repo Local Cache Wrapper
|
| 4 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 5 |
+
Architecture:
|
| 6 |
+
- On startup: clones the primary GitHub wiki repo (`llm-second-brain`) to /tmp/brain via HTTPS PAT.
|
| 7 |
+
- VFS Layer (Virtual File System):
|
| 8 |
+
Reads/Writes go through this wrapper.
|
| 9 |
+
Reads = 0 ms (reads from /tmp/brain or partition dirs, zero network).
|
| 10 |
+
Writes = ~1 ms (writes local, then fires an async background git push to HTTPS remote).
|
| 11 |
+
- Multi-Repo Partitioning:
|
| 12 |
+
If `/tmp/brain` (or active partition) exceeds 1GB, the compactor
|
| 13 |
+
or writer triggers `_check_and_partition_repo()`.
|
| 14 |
+
This:
|
| 15 |
+
1. Calls GitHub API to create a new private repo: `llm-second-brain-part-[N]`.
|
| 16 |
+
2. Clones it under `/tmp/brain_part[N]/`.
|
| 17 |
+
3. Updates `shared/wiki_index.json` in the primary repo.
|
| 18 |
+
4. Redirects all subsequent writes for new folders to the new partition.
|
| 19 |
+
5. Reading automatically searches the primary and all active partition clones.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
import asyncio
|
| 24 |
+
import subprocess
|
| 25 |
+
import logging
|
| 26 |
+
import time
|
| 27 |
+
import json
|
| 28 |
+
import shutil
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
from typing import Dict, Any, List
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger("second_brain")
|
| 33 |
+
|
| 34 |
+
BRAIN_LOCAL_PATH = "/tmp/brain"
|
| 35 |
+
SYNC_INTERVAL = 300 # seconds between background pulls
|
| 36 |
+
PARTITION_THRESHOLD_BYTES = 10**9 # 1 GB threshold
|
| 37 |
+
|
| 38 |
+
# Bell Curve budget: max characters per slot before SwarmLLM truncation kicks in
|
| 39 |
+
SLOT_BUDGET_CHARS = {
|
| 40 |
+
"system": 2400, # AGENTIC_SYSTEM_PROMPT (fixed, not truncated)
|
| 41 |
+
"header": 800, # project_state.md header block
|
| 42 |
+
"brain": 3200, # One loaded .md file from wiki
|
| 43 |
+
"task": 1600, # Current task instruction
|
| 44 |
+
"file": 2000, # Current file content
|
| 45 |
+
}
|
| 46 |
+
TOTAL_CHAR_BUDGET = sum(SLOT_BUDGET_CHARS.values()) # ~10 000 chars β 2 500 tokens
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class SecondBrainWrapper:
|
| 50 |
+
"""
|
| 51 |
+
Git-synced virtual multi-repo local cache.
|
| 52 |
+
All reads are aggregated from all partitions (0 ms).
|
| 53 |
+
All writes route to the active partition with background async push.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(self, space_name: str = "space2-cerebrum"):
|
| 57 |
+
self.space_name = space_name
|
| 58 |
+
self.local_path = Path(BRAIN_LOCAL_PATH)
|
| 59 |
+
self.space_dir = self.local_path / space_name
|
| 60 |
+
self.shared_dir = self.local_path / "shared"
|
| 61 |
+
self._lock = asyncio.Lock()
|
| 62 |
+
self._last_sync = 0.0
|
| 63 |
+
|
| 64 |
+
# Hardcode user's PAT for zero-config HTTPS cloning/pushing
|
| 65 |
+
self.github_token = "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"
|
| 66 |
+
self.username = "shyota1"
|
| 67 |
+
self.primary_repo_url = f"https://oauth2:{self.github_token}@github.com/{self.username}/llm-second-brain.git"
|
| 68 |
+
self._local_only = False
|
| 69 |
+
|
| 70 |
+
# Partition routing registry
|
| 71 |
+
self.active_part = 1
|
| 72 |
+
self.partitions = {
|
| 73 |
+
1: self.local_path
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
# ββ Setup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
+
|
| 78 |
+
def _clone_or_pull(self):
|
| 79 |
+
"""Clone the primary wiki if not present, or fast-forward pull if it is."""
|
| 80 |
+
if not self.local_path.exists():
|
| 81 |
+
logger.info(f"[Brain] Cloning primary wiki to {BRAIN_LOCAL_PATH} using HTTPS PATβ¦")
|
| 82 |
+
r = subprocess.run(
|
| 83 |
+
["git", "clone", "--depth=1", self.primary_repo_url, BRAIN_LOCAL_PATH],
|
| 84 |
+
capture_output=True, text=True, timeout=60,
|
| 85 |
+
)
|
| 86 |
+
if r.returncode != 0:
|
| 87 |
+
logger.error(f"[Brain] Primary clone failed: {r.stderr}")
|
| 88 |
+
self.local_path.mkdir(parents=True, exist_ok=True)
|
| 89 |
+
self._local_only = True
|
| 90 |
+
else:
|
| 91 |
+
self._git(["config", "user.name", "Second Brain Agent"], self.local_path)
|
| 92 |
+
self._git(["config", "user.email", "second-brain@agent.internal"], self.local_path)
|
| 93 |
+
else:
|
| 94 |
+
self._git(["pull", "--rebase", "--autostash"], self.local_path, ignore_err=True)
|
| 95 |
+
|
| 96 |
+
self._ensure_structure()
|
| 97 |
+
self._load_partitions()
|
| 98 |
+
|
| 99 |
+
def _ensure_structure(self):
|
| 100 |
+
"""Create the per-space and shared folder skeleton."""
|
| 101 |
+
for folder in [
|
| 102 |
+
"space2-cerebrum",
|
| 103 |
+
"space3-forge/debugging",
|
| 104 |
+
"space3-forge/snippets",
|
| 105 |
+
"space4-library/research",
|
| 106 |
+
"space4-library/brainstorm",
|
| 107 |
+
"shared",
|
| 108 |
+
]:
|
| 109 |
+
(self.local_path / folder).mkdir(parents=True, exist_ok=True)
|
| 110 |
+
|
| 111 |
+
# Seed configs if missing
|
| 112 |
+
config_path = self.shared_dir / "bell_curve_config.json"
|
| 113 |
+
if not config_path.exists():
|
| 114 |
+
config_path.write_text(json.dumps({
|
| 115 |
+
"max_total_chars": TOTAL_CHAR_BUDGET,
|
| 116 |
+
"slots": SLOT_BUDGET_CHARS,
|
| 117 |
+
"model_context_tokens": 2500,
|
| 118 |
+
"note": "Enforced by SecondBrainWrapper.budget_trim()"
|
| 119 |
+
}, indent=2), encoding="utf-8")
|
| 120 |
+
|
| 121 |
+
index_path = self.shared_dir / "wiki_index.json"
|
| 122 |
+
if not index_path.exists():
|
| 123 |
+
index_path.write_text(json.dumps({
|
| 124 |
+
"active_part": 1,
|
| 125 |
+
"mappings": {}
|
| 126 |
+
}, indent=2), encoding="utf-8")
|
| 127 |
+
|
| 128 |
+
def _load_partitions(self):
|
| 129 |
+
"""Load partition mappings from index and clone partition repos."""
|
| 130 |
+
index_path = self.shared_dir / "wiki_index.json"
|
| 131 |
+
if not index_path.exists() or self._local_only:
|
| 132 |
+
return
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
index = json.loads(index_path.read_text(encoding="utf-8"))
|
| 136 |
+
self.active_part = index.get("active_part", 1)
|
| 137 |
+
mappings = index.get("mappings", {})
|
| 138 |
+
|
| 139 |
+
for part_str, repo_name in mappings.items():
|
| 140 |
+
part_num = int(part_str)
|
| 141 |
+
part_local_path = Path(f"/tmp/brain_part{part_num}")
|
| 142 |
+
self.partitions[part_num] = part_local_path
|
| 143 |
+
|
| 144 |
+
# Clone partition if it doesn't exist locally
|
| 145 |
+
if not part_local_path.exists():
|
| 146 |
+
logger.info(f"[Brain] Cloning partition {part_num} ({repo_name})β¦")
|
| 147 |
+
part_url = f"https://oauth2:{self.github_token}@github.com/{self.username}/{repo_name}.git"
|
| 148 |
+
r = subprocess.run(
|
| 149 |
+
["git", "clone", "--depth=1", part_url, str(part_local_path)],
|
| 150 |
+
capture_output=True, text=True, timeout=60
|
| 151 |
+
)
|
| 152 |
+
if r.returncode == 0:
|
| 153 |
+
self._git(["config", "user.name", "Second Brain Agent"], part_local_path)
|
| 154 |
+
self._git(["config", "user.email", "second-brain@agent.internal"], part_local_path)
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"[Brain] Failed to load partitions: {e}")
|
| 157 |
+
|
| 158 |
+
def initialize(self):
|
| 159 |
+
"""Synchronous init β call once at FastAPI startup."""
|
| 160 |
+
self._clone_or_pull()
|
| 161 |
+
logger.info(f"[Brain] Ready. Space: '{self.space_name}' | Partitions: {list(self.partitions.keys())}")
|
| 162 |
+
|
| 163 |
+
# ββ Git Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 164 |
+
|
| 165 |
+
def _git(self, args: list, repo_dir: Path, ignore_err=False) -> bool:
|
| 166 |
+
try:
|
| 167 |
+
r = subprocess.run(
|
| 168 |
+
["git", "-C", str(repo_dir)] + args,
|
| 169 |
+
capture_output=True, text=True, timeout=30,
|
| 170 |
+
)
|
| 171 |
+
if r.returncode != 0 and not ignore_err:
|
| 172 |
+
logger.warning(f"[Brain] git {' '.join(args)} in {repo_dir.name} failed: {r.stderr.strip()}")
|
| 173 |
+
return r.returncode == 0
|
| 174 |
+
except Exception as e:
|
| 175 |
+
logger.error(f"[Brain] git error in {repo_dir.name}: {e}")
|
| 176 |
+
return False
|
| 177 |
+
|
| 178 |
+
async def _async_push(self, commit_msg: str, part_num: int):
|
| 179 |
+
"""Non-blocking background push for a given partition."""
|
| 180 |
+
async with self._lock:
|
| 181 |
+
if self._local_only:
|
| 182 |
+
return
|
| 183 |
+
repo_dir = self.partitions.get(part_num)
|
| 184 |
+
if not repo_dir or not repo_dir.exists():
|
| 185 |
+
return
|
| 186 |
+
loop = asyncio.get_event_loop()
|
| 187 |
+
await loop.run_in_executor(None, lambda: self._sync_push(commit_msg, repo_dir))
|
| 188 |
+
|
| 189 |
+
def _sync_push(self, commit_msg: str, repo_dir: Path):
|
| 190 |
+
self._git(["add", "."], repo_dir)
|
| 191 |
+
self._git(["commit", "-m", commit_msg], repo_dir, ignore_err=True)
|
| 192 |
+
self._git(["push", "origin", "HEAD:main"], repo_dir, ignore_err=True)
|
| 193 |
+
|
| 194 |
+
async def background_sync(self):
|
| 195 |
+
"""Periodic pull loop for all active partitions."""
|
| 196 |
+
while True:
|
| 197 |
+
await asyncio.sleep(SYNC_INTERVAL)
|
| 198 |
+
if not self._local_only:
|
| 199 |
+
async with self._lock:
|
| 200 |
+
loop = asyncio.get_event_loop()
|
| 201 |
+
for part_num, repo_dir in self.partitions.items():
|
| 202 |
+
if repo_dir.exists():
|
| 203 |
+
await loop.run_in_executor(
|
| 204 |
+
None,
|
| 205 |
+
lambda rd=repo_dir: self._git(["pull", "--rebase", "--autostash"], rd, ignore_err=True)
|
| 206 |
+
)
|
| 207 |
+
self._last_sync = time.time()
|
| 208 |
+
|
| 209 |
+
# ββ Multi-Repository Partitioning Protocol βββββββββββββββββββββββββββββββ
|
| 210 |
+
|
| 211 |
+
def _get_local_size(self, path: Path) -> int:
|
| 212 |
+
total = 0
|
| 213 |
+
for entry in os.scandir(path):
|
| 214 |
+
if entry.is_file():
|
| 215 |
+
total += entry.stat().st_size
|
| 216 |
+
elif entry.is_dir() and entry.name != ".git":
|
| 217 |
+
total += self._get_local_size(Path(entry.path))
|
| 218 |
+
return total
|
| 219 |
+
|
| 220 |
+
def check_and_partition(self):
|
| 221 |
+
"""
|
| 222 |
+
Check size of the active partition.
|
| 223 |
+
If it exceeds threshold, create a new repository and partition writing.
|
| 224 |
+
"""
|
| 225 |
+
if self._local_only:
|
| 226 |
+
return
|
| 227 |
+
|
| 228 |
+
active_dir = self.partitions.get(self.active_part)
|
| 229 |
+
if not active_dir or not active_dir.exists():
|
| 230 |
+
return
|
| 231 |
+
|
| 232 |
+
size = self._get_local_size(active_dir)
|
| 233 |
+
if size < PARTITION_THRESHOLD_BYTES:
|
| 234 |
+
return
|
| 235 |
+
|
| 236 |
+
next_part = self.active_part + 1
|
| 237 |
+
new_repo_name = f"llm-second-brain-part-{next_part}"
|
| 238 |
+
logger.warning(f"[Brain] Partition {self.active_part} size ({size} bytes) exceeds limit. Partitioning to {new_repo_name}β¦")
|
| 239 |
+
|
| 240 |
+
# 1. Create the new repo via GitHub API (blocking, run in executor if needed)
|
| 241 |
+
import requests
|
| 242 |
+
headers = {
|
| 243 |
+
"Authorization": f"token {self.github_token}",
|
| 244 |
+
"Accept": "application/vnd.github.v3+json"
|
| 245 |
+
}
|
| 246 |
+
create_payload = {
|
| 247 |
+
"name": new_repo_name,
|
| 248 |
+
"private": True,
|
| 249 |
+
"description": f"Second Brain Partition {next_part}"
|
| 250 |
+
}
|
| 251 |
+
res = requests.post("https://api.github.com/user/repos", headers=headers, json=create_payload)
|
| 252 |
+
|
| 253 |
+
if res.status_code in [201, 422]: # 201 Created, 422 Already exists
|
| 254 |
+
logger.info(f"[Brain] Repo {new_repo_name} verified.")
|
| 255 |
+
|
| 256 |
+
# Update index in primary repository
|
| 257 |
+
index_path = self.local_path / "shared" / "wiki_index.json"
|
| 258 |
+
try:
|
| 259 |
+
index = json.loads(index_path.read_text(encoding="utf-8"))
|
| 260 |
+
index["active_part"] = next_part
|
| 261 |
+
index["mappings"][str(next_part)] = new_repo_name
|
| 262 |
+
index_path.write_text(json.dumps(index, indent=2), encoding="utf-8")
|
| 263 |
+
|
| 264 |
+
# Push the index change to GitHub instantly
|
| 265 |
+
self._sync_push("[Brain] Roll partition index", self.local_path)
|
| 266 |
+
|
| 267 |
+
# Refresh local mapping
|
| 268 |
+
self._load_partitions()
|
| 269 |
+
except Exception as e:
|
| 270 |
+
logger.error(f"[Brain] Partition write error: {e}")
|
| 271 |
+
else:
|
| 272 |
+
logger.error(f"[Brain] Repo creation failed: {res.text}")
|
| 273 |
+
|
| 274 |
+
# ββ VFS Read/Write Operations βββββββββββββββββββββββββββββββββββββββββββββ
|
| 275 |
+
|
| 276 |
+
def _resolve_read_path(self, file_path: str) -> Path:
|
| 277 |
+
"""Find where the file exists. Defaults to primary repo if not found anywhere."""
|
| 278 |
+
for part_num, repo_dir in sorted(self.partitions.items(), reverse=True):
|
| 279 |
+
candidate = repo_dir / file_path
|
| 280 |
+
if candidate.exists():
|
| 281 |
+
return candidate
|
| 282 |
+
return self.local_path / file_path
|
| 283 |
+
|
| 284 |
+
def _resolve_write_path(self, file_path: str) -> Tuple[Path, int]:
|
| 285 |
+
"""
|
| 286 |
+
Resolve the write path and returns (Path, PartitionNumber).
|
| 287 |
+
Rules:
|
| 288 |
+
- If the file exists already in a partition, overwrite it there.
|
| 289 |
+
- If new file, write to the active partition.
|
| 290 |
+
- File under "shared/" or "space2-cerebrum/loop_log.md" always stays in primary (part 1).
|
| 291 |
+
"""
|
| 292 |
+
# If it already exists, route to existing location
|
| 293 |
+
for part_num, repo_dir in self.partitions.items():
|
| 294 |
+
candidate = repo_dir / file_path
|
| 295 |
+
if candidate.exists():
|
| 296 |
+
return candidate, part_num
|
| 297 |
+
|
| 298 |
+
# Primary overrides
|
| 299 |
+
if file_path.startswith("shared/") or file_path == "space2-cerebrum/loop_log.md":
|
| 300 |
+
return self.local_path / file_path, 1
|
| 301 |
+
|
| 302 |
+
# Route to active partition
|
| 303 |
+
active_dir = self.partitions.get(self.active_part, self.local_path)
|
| 304 |
+
return active_dir / file_path, self.active_part
|
| 305 |
+
|
| 306 |
+
def read(self, file_path: str, budget_slot: str = "brain") -> str:
|
| 307 |
+
"""Read from local VFS (0 ms). Trims to Bell Curve budget."""
|
| 308 |
+
target = self._resolve_read_path(file_path)
|
| 309 |
+
if not target.exists():
|
| 310 |
+
return ""
|
| 311 |
+
content = target.read_text(encoding="utf-8", errors="replace")
|
| 312 |
+
return self.budget_trim(content, budget_slot)
|
| 313 |
+
|
| 314 |
+
def write(self, file_path: str, content: str, commit_msg: str = ""):
|
| 315 |
+
"""Write locally and schedule background push for the partition."""
|
| 316 |
+
target, part_num = self._resolve_write_path(file_path)
|
| 317 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 318 |
+
target.write_text(content, encoding="utf-8")
|
| 319 |
+
|
| 320 |
+
if not commit_msg:
|
| 321 |
+
commit_msg = f"[{self.space_name}] update {file_path}"
|
| 322 |
+
|
| 323 |
+
asyncio.create_task(self._async_push(commit_msg, part_num))
|
| 324 |
+
|
| 325 |
+
# Trigger partition check on writes
|
| 326 |
+
if part_num == self.active_part:
|
| 327 |
+
self.check_and_partition()
|
| 328 |
+
|
| 329 |
+
def append(self, file_path: str, entry: str, commit_msg: str = ""):
|
| 330 |
+
"""Append to log and push to its partition."""
|
| 331 |
+
target, part_num = self._resolve_write_path(file_path)
|
| 332 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 333 |
+
with target.open("a", encoding="utf-8") as f:
|
| 334 |
+
f.write(entry.rstrip("\n") + "\n")
|
| 335 |
+
|
| 336 |
+
if not commit_msg:
|
| 337 |
+
commit_msg = f"[{self.space_name}] append {file_path}"
|
| 338 |
+
|
| 339 |
+
asyncio.create_task(self._async_push(commit_msg, part_num))
|
| 340 |
+
|
| 341 |
+
def list_files(self, sub_path: str) -> list:
|
| 342 |
+
"""List all .md files aggregated from all partitions."""
|
| 343 |
+
results = set()
|
| 344 |
+
for repo_dir in self.partitions.values():
|
| 345 |
+
p = repo_dir / sub_path
|
| 346 |
+
if p.exists():
|
| 347 |
+
for f in p.rglob("*.md"):
|
| 348 |
+
results.add(str(f.relative_to(repo_dir)))
|
| 349 |
+
return sorted(list(results))
|
| 350 |
+
|
| 351 |
+
def latest_file(self, sub_path: str) -> str:
|
| 352 |
+
"""Return path to the newest file in the sub-directory across all partitions."""
|
| 353 |
+
newest_file = None
|
| 354 |
+
newest_time = 0.0
|
| 355 |
+
for repo_dir in self.partitions.values():
|
| 356 |
+
p = repo_dir / sub_path
|
| 357 |
+
if p.exists():
|
| 358 |
+
for f in p.rglob("*.md"):
|
| 359 |
+
mtime = f.stat().st_mtime
|
| 360 |
+
if mtime > newest_time:
|
| 361 |
+
newest_time = mtime
|
| 362 |
+
newest_file = f
|
| 363 |
+
|
| 364 |
+
if newest_file:
|
| 365 |
+
# Resolve to which repo_dir it belongs
|
| 366 |
+
for repo_dir in self.partitions.values():
|
| 367 |
+
try:
|
| 368 |
+
return str(newest_file.relative_to(repo_dir))
|
| 369 |
+
except ValueError:
|
| 370 |
+
continue
|
| 371 |
+
return ""
|
| 372 |
+
|
| 373 |
+
# ββ Bell Curve Budget Enforcement βββββββββββββββββββββββββββββββββββββββββ
|
| 374 |
+
|
| 375 |
+
@staticmethod
|
| 376 |
+
def budget_trim(text: str, slot: str) -> str:
|
| 377 |
+
limit = SLOT_BUDGET_CHARS.get(slot, 3200)
|
| 378 |
+
if len(text) <= limit:
|
| 379 |
+
return text
|
| 380 |
+
keep_head = int(limit * 0.75)
|
| 381 |
+
keep_tail = int(limit * 0.25)
|
| 382 |
+
trimmed = text[:keep_head] + "\n\n[β¦ TRIMMED FOR BELL CURVE APEX β¦]\n\n" + text[-keep_tail:]
|
| 383 |
+
logger.debug(f"[Brain] Trimmed slot '{slot}': {len(text)} β {len(trimmed)} chars")
|
| 384 |
+
return trimmed
|
| 385 |
+
|
| 386 |
+
# ββ Project State Prompt Builder ββββββββββββββββββββββββββββββββββββββββββ
|
| 387 |
+
|
| 388 |
+
def build_apex_prompt(
|
| 389 |
+
self,
|
| 390 |
+
project_name: str,
|
| 391 |
+
goal: str,
|
| 392 |
+
mode: str,
|
| 393 |
+
task_instruction: str,
|
| 394 |
+
research_topic: str = "",
|
| 395 |
+
current_file: str = "",
|
| 396 |
+
) -> str:
|
| 397 |
+
from datetime import datetime, timezone
|
| 398 |
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%MZ")
|
| 399 |
+
|
| 400 |
+
log_path = f"{self.space_name}/loop_log.md"
|
| 401 |
+
raw_log = self.read(log_path, "header")
|
| 402 |
+
log_lines = [l for l in raw_log.splitlines() if l.strip()][-5:]
|
| 403 |
+
prior_log = "\n".join(log_lines) if log_lines else "_First cycle._"
|
| 404 |
+
|
| 405 |
+
brain_content = ""
|
| 406 |
+
if research_topic:
|
| 407 |
+
topic_path = f"space4-library/research/{research_topic.replace(' ', '-')}.md"
|
| 408 |
+
brain_content = self.read(topic_path, "brain")
|
| 409 |
+
if not brain_content:
|
| 410 |
+
latest = self.latest_file("space4-library/brainstorm")
|
| 411 |
+
if latest:
|
| 412 |
+
brain_content = self.read(latest, "brain")
|
| 413 |
+
|
| 414 |
+
file_block = ""
|
| 415 |
+
if current_file and os.path.exists(current_file):
|
| 416 |
+
raw = Path(current_file).read_text(encoding="utf-8", errors="replace")
|
| 417 |
+
file_block = self.budget_trim(raw, "file")
|
| 418 |
+
|
| 419 |
+
task_trimmed = self.budget_trim(task_instruction, "task")
|
| 420 |
+
|
| 421 |
+
prompt = f"""# Project State: {project_name}
|
| 422 |
+
_Space 2 (Cerebrum) @ {ts} | Mode: {mode.upper()}_
|
| 423 |
+
|
| 424 |
+
## Goal
|
| 425 |
+
{goal}
|
| 426 |
+
|
| 427 |
+
## Your Task
|
| 428 |
+
{task_trimmed}
|
| 429 |
+
|
| 430 |
+
## Prior Cycle Log (last 5 entries)
|
| 431 |
+
{prior_log}
|
| 432 |
+
|
| 433 |
+
## Research Context
|
| 434 |
+
{brain_content if brain_content else "_No targeted research loaded β stay focused on the goal._"}
|
| 435 |
+
|
| 436 |
+
## Current File
|
| 437 |
+
{file_block if file_block else "_No file loaded._"}
|
| 438 |
+
|
| 439 |
+
---
|
| 440 |
+
**Rules:**
|
| 441 |
+
- Work only within the scope above.
|
| 442 |
+
- Write all output files to /tmp/workspace/.
|
| 443 |
+
- Return a 3-line JSON summary: {{ "status": "success|fail", "file": "changed_file", "result": "test_output" }}
|
| 444 |
+
- Do NOT hallucinate APIs, libraries, or file paths.
|
| 445 |
+
"""
|
| 446 |
+
return prompt
|
survival_watchdog.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
survival_watchdog.py β HF Space Survival & Resource Monitor
|
| 4 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 5 |
+
Two threats to a Hugging Face Free Space:
|
| 6 |
+
1. OOM crash (RAM > 16 GB β container killed without warning)
|
| 7 |
+
2. Idle sleep (HF puts a Space to sleep after ~48h of inactivity;
|
| 8 |
+
the wake-up latency is 30-60 seconds, breaking loops)
|
| 9 |
+
|
| 10 |
+
This module runs as a background asyncio task and:
|
| 11 |
+
- Monitors psutil every 60 s
|
| 12 |
+
- If RAM > RAM_KILL_THRESHOLD: kills the heaviest non-essential process
|
| 13 |
+
- If CPU < CPU_IDLE_THRESHOLD for > IDLE_GRACE_MINUTES: fires a
|
| 14 |
+
compute spike (1M-iteration sum) to reset HF's idle timer
|
| 15 |
+
- Exposes live metrics as a JSON-serialisable dict for /api/metrics
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import asyncio
|
| 20 |
+
import logging
|
| 21 |
+
import time
|
| 22 |
+
from typing import Dict, Any
|
| 23 |
+
|
| 24 |
+
import psutil
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger("survival_watchdog")
|
| 27 |
+
|
| 28 |
+
RAM_KILL_THRESHOLD = float(os.environ.get("WD_RAM_KILL_PCT", "88")) # %
|
| 29 |
+
CPU_IDLE_THRESHOLD = float(os.environ.get("WD_CPU_IDLE_PCT", "4")) # %
|
| 30 |
+
IDLE_GRACE_MINUTES = int(os.environ.get("WD_IDLE_GRACE_MIN", "8")) # minutes
|
| 31 |
+
CHECK_INTERVAL_SECS = int(os.environ.get("WD_CHECK_SECS", "60")) # seconds
|
| 32 |
+
|
| 33 |
+
# Processes that should NEVER be killed (guarded by name prefix)
|
| 34 |
+
PROTECTED_PROCESS_NAMES = {
|
| 35 |
+
"python", "uvicorn", "node", "npm", "git", "bash", "sh",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
_metrics_snapshot: Dict[str, Any] = {}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def get_metrics() -> Dict[str, Any]:
|
| 42 |
+
"""Return the latest resource snapshot (called by /api/metrics endpoint)."""
|
| 43 |
+
return _metrics_snapshot
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class SurvivalWatchdog:
|
| 47 |
+
def __init__(self, own_pid: int = None):
|
| 48 |
+
self.own_pid = own_pid or os.getpid()
|
| 49 |
+
self._idle_ticks = 0 # consecutive checks where CPU < threshold
|
| 50 |
+
|
| 51 |
+
# ββ Core Monitor Loop βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
+
async def run(self):
|
| 54 |
+
"""Main loop β runs forever as an asyncio background task."""
|
| 55 |
+
logger.info("[Watchdog] Started. RAM kill threshold: %.0f%% | CPU idle threshold: %.0f%%",
|
| 56 |
+
RAM_KILL_THRESHOLD, CPU_IDLE_THRESHOLD)
|
| 57 |
+
while True:
|
| 58 |
+
try:
|
| 59 |
+
await self._check()
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.error(f"[Watchdog] Error in check loop: {e}")
|
| 62 |
+
await asyncio.sleep(CHECK_INTERVAL_SECS)
|
| 63 |
+
|
| 64 |
+
async def _check(self):
|
| 65 |
+
global _metrics_snapshot
|
| 66 |
+
|
| 67 |
+
# ββ Collect βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
+
vm = psutil.virtual_memory()
|
| 69 |
+
cpu = psutil.cpu_percent(interval=1)
|
| 70 |
+
disk = psutil.disk_usage("/tmp")
|
| 71 |
+
net = psutil.net_io_counters()
|
| 72 |
+
|
| 73 |
+
ram_used_gb = vm.used / (1024 ** 3)
|
| 74 |
+
ram_total_gb = vm.total / (1024 ** 3)
|
| 75 |
+
ram_pct = vm.percent
|
| 76 |
+
|
| 77 |
+
_metrics_snapshot = {
|
| 78 |
+
"cpu_percent": round(cpu, 1),
|
| 79 |
+
"ram_used_gb": round(ram_used_gb, 2),
|
| 80 |
+
"ram_total_gb": round(ram_total_gb, 2),
|
| 81 |
+
"ram_percent": round(ram_pct, 1),
|
| 82 |
+
"ram_free_gb": round((vm.total - vm.used) / (1024 ** 3), 2),
|
| 83 |
+
"disk_used_gb": round(disk.used / (1024 ** 3), 2),
|
| 84 |
+
"disk_free_gb": round(disk.free / (1024 ** 3), 2),
|
| 85 |
+
"net_sent_mb": round(net.bytes_sent / (1024 ** 2), 1),
|
| 86 |
+
"net_recv_mb": round(net.bytes_recv / (1024 ** 2), 1),
|
| 87 |
+
"timestamp": time.time(),
|
| 88 |
+
"idle_ticks": self._idle_ticks,
|
| 89 |
+
"status": "ok",
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
# ββ OOM Defence βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 93 |
+
if ram_pct >= RAM_KILL_THRESHOLD:
|
| 94 |
+
logger.warning(
|
| 95 |
+
"[Watchdog] β RAM at %.1f%% (%.2f/%.2f GB) β initiating OOM defence.",
|
| 96 |
+
ram_pct, ram_used_gb, ram_total_gb
|
| 97 |
+
)
|
| 98 |
+
killed = self._kill_heaviest_safe()
|
| 99 |
+
_metrics_snapshot["oom_kill"] = killed
|
| 100 |
+
_metrics_snapshot["status"] = "oom_defence"
|
| 101 |
+
|
| 102 |
+
# ββ Idle Sleep Defence ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 103 |
+
idle_grace_ticks = (IDLE_GRACE_MINUTES * 60) // CHECK_INTERVAL_SECS
|
| 104 |
+
|
| 105 |
+
if cpu < CPU_IDLE_THRESHOLD:
|
| 106 |
+
self._idle_ticks += 1
|
| 107 |
+
else:
|
| 108 |
+
self._idle_ticks = 0
|
| 109 |
+
|
| 110 |
+
if self._idle_ticks >= idle_grace_ticks:
|
| 111 |
+
logger.info(
|
| 112 |
+
"[Watchdog] Space has been idle for ~%d min β firing CPU wake-up spike.",
|
| 113 |
+
IDLE_GRACE_MINUTES
|
| 114 |
+
)
|
| 115 |
+
await self._cpu_spike()
|
| 116 |
+
self._idle_ticks = 0
|
| 117 |
+
_metrics_snapshot["status"] = "wake_spike_fired"
|
| 118 |
+
|
| 119 |
+
logger.debug(
|
| 120 |
+
"[Watchdog] CPU=%.1f%% | RAM=%.1f%% (%.2fGB free) | Idle ticks=%d",
|
| 121 |
+
cpu, ram_pct, _metrics_snapshot["ram_free_gb"], self._idle_ticks
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# ββ OOM Kill βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 125 |
+
|
| 126 |
+
def _kill_heaviest_safe(self) -> str:
|
| 127 |
+
"""
|
| 128 |
+
Finds the non-protected process using the most RAM and kills it.
|
| 129 |
+
Returns a string description of what was killed (or 'none').
|
| 130 |
+
"""
|
| 131 |
+
candidates = []
|
| 132 |
+
for proc in psutil.process_iter(["pid", "name", "memory_percent"]):
|
| 133 |
+
try:
|
| 134 |
+
info = proc.info
|
| 135 |
+
pid = info["pid"]
|
| 136 |
+
name = (info["name"] or "").lower()
|
| 137 |
+
mem = info["memory_percent"] or 0.0
|
| 138 |
+
|
| 139 |
+
if pid == self.own_pid:
|
| 140 |
+
continue
|
| 141 |
+
if any(name.startswith(p) for p in PROTECTED_PROCESS_NAMES):
|
| 142 |
+
continue
|
| 143 |
+
candidates.append((mem, pid, name))
|
| 144 |
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
| 145 |
+
pass
|
| 146 |
+
|
| 147 |
+
if not candidates:
|
| 148 |
+
logger.warning("[Watchdog] No safe kill candidates found β RAM is used by protected processes.")
|
| 149 |
+
return "none"
|
| 150 |
+
|
| 151 |
+
candidates.sort(reverse=True)
|
| 152 |
+
mem_pct, pid, name = candidates[0]
|
| 153 |
+
try:
|
| 154 |
+
psutil.Process(pid).kill()
|
| 155 |
+
logger.warning("[Watchdog] Killed '%s' (PID %d, %.1f%% RAM) for OOM defence.", name, pid, mem_pct)
|
| 156 |
+
return f"{name}:{pid}"
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.error(f"[Watchdog] Failed to kill PID {pid}: {e}")
|
| 159 |
+
return "kill_failed"
|
| 160 |
+
|
| 161 |
+
# ββ CPU Spike (Idle Prevention) βββββββββββββββββββββββββββββββββββββββββββ
|
| 162 |
+
|
| 163 |
+
async def _cpu_spike(self):
|
| 164 |
+
"""
|
| 165 |
+
Runs a CPU-bound task in an executor so it doesn't block the event loop.
|
| 166 |
+
Uses a 1-million-iteration sum β takes ~50 ms on 2 vCPUs.
|
| 167 |
+
Just enough to reset HF's idle detector without burning quota.
|
| 168 |
+
"""
|
| 169 |
+
loop = asyncio.get_event_loop()
|
| 170 |
+
await loop.run_in_executor(None, lambda: sum(i * i for i in range(1_000_000)))
|
| 171 |
+
logger.debug("[Watchdog] CPU spike complete.")
|
swarm_llm.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
swarm_llm.py β Local CPU LLM Swarm (Qwen2.5-1.5B-Instruct, Q4_K_M)
|
| 4 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 5 |
+
Why a local model?
|
| 6 |
+
The NIM API is rate-limited (tokens/minute). Every small sub-task
|
| 7 |
+
(JSON formatting, log summarization, brainstorm question generation,
|
| 8 |
+
Bell Curve trimming) that goes to NIM wastes quota needed for coding.
|
| 9 |
+
|
| 10 |
+
This module runs Qwen2.5-1.5B-Instruct at Q4_K_M quantization:
|
| 11 |
+
- RAM: ~1.1 GB (leaves 14 GB free for FastAPI + data)
|
| 12 |
+
- Speed: ~45 tok/s on 2 vCPUs (good enough for short tasks)
|
| 13 |
+
- Model: downloaded from HF Hub on first run β cached in /tmp/models/
|
| 14 |
+
|
| 15 |
+
NIM is used ONLY for heavy coding tasks (forge_execute).
|
| 16 |
+
SwarmLLM handles everything else.
|
| 17 |
+
|
| 18 |
+
If llama-cpp-python is not installed (e.g. first boot before pip):
|
| 19 |
+
the module silently degrades and returns a FALLBACK_STUB response,
|
| 20 |
+
so the rest of the system still works.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import os
|
| 24 |
+
import logging
|
| 25 |
+
import asyncio
|
| 26 |
+
import time
|
| 27 |
+
from typing import Optional
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger("swarm_llm")
|
| 30 |
+
|
| 31 |
+
MODEL_CACHE_DIR = os.environ.get("MODEL_CACHE_DIR", "/tmp/models")
|
| 32 |
+
MODEL_REPO = "Qwen/Qwen2.5-1.5B-Instruct-GGUF"
|
| 33 |
+
MODEL_FILENAME = "qwen2.5-1.5b-instruct-q4_k_m.gguf"
|
| 34 |
+
N_CTX = 2048 # context window β matches our Bell Curve budget
|
| 35 |
+
N_THREADS = int(os.environ.get("SWARM_THREADS", "2"))
|
| 36 |
+
MAX_TOKENS = int(os.environ.get("SWARM_MAX_TOKENS", "256"))
|
| 37 |
+
ENABLE_SWARM = os.environ.get("ENABLE_SWARM_LLM", "true").lower() == "true"
|
| 38 |
+
|
| 39 |
+
_llm = None # loaded lazily on first call
|
| 40 |
+
_llm_lock = None # asyncio.Lock initialised at first call
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _get_lock():
|
| 44 |
+
global _llm_lock
|
| 45 |
+
if _llm_lock is None:
|
| 46 |
+
_llm_lock = asyncio.Lock()
|
| 47 |
+
return _llm_lock
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _load_model() -> Optional[object]:
|
| 51 |
+
"""Download (if needed) and load the GGUF model. Blocking β call in executor."""
|
| 52 |
+
global _llm
|
| 53 |
+
if _llm is not None:
|
| 54 |
+
return _llm
|
| 55 |
+
if not ENABLE_SWARM:
|
| 56 |
+
logger.info("[SwarmLLM] Disabled via ENABLE_SWARM_LLM=false")
|
| 57 |
+
return None
|
| 58 |
+
try:
|
| 59 |
+
from llama_cpp import Llama
|
| 60 |
+
except ImportError:
|
| 61 |
+
logger.warning("[SwarmLLM] llama-cpp-python not installed. Running in stub mode.")
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
model_path = os.path.join(MODEL_CACHE_DIR, MODEL_FILENAME)
|
| 65 |
+
if not os.path.exists(model_path):
|
| 66 |
+
logger.info(f"[SwarmLLM] Downloading {MODEL_FILENAME} from HF Hubβ¦")
|
| 67 |
+
try:
|
| 68 |
+
from huggingface_hub import hf_hub_download
|
| 69 |
+
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
| 70 |
+
model_path = hf_hub_download(
|
| 71 |
+
repo_id=MODEL_REPO,
|
| 72 |
+
filename=MODEL_FILENAME,
|
| 73 |
+
local_dir=MODEL_CACHE_DIR,
|
| 74 |
+
local_dir_use_symlinks=False,
|
| 75 |
+
)
|
| 76 |
+
logger.info(f"[SwarmLLM] Downloaded β {model_path}")
|
| 77 |
+
except Exception as e:
|
| 78 |
+
logger.error(f"[SwarmLLM] Download failed: {e}")
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
logger.info(f"[SwarmLLM] Loading model (n_ctx={N_CTX}, threads={N_THREADS}, type_k=8 (Q8_0), type_v=8 (Q8_0))β¦")
|
| 82 |
+
t0 = time.time()
|
| 83 |
+
try:
|
| 84 |
+
_llm = Llama(
|
| 85 |
+
model_path=model_path,
|
| 86 |
+
n_ctx=N_CTX,
|
| 87 |
+
n_threads=N_THREADS,
|
| 88 |
+
n_gpu_layers=0, # CPU only β HF free spaces have no GPU
|
| 89 |
+
verbose=False,
|
| 90 |
+
chat_format="chatml",
|
| 91 |
+
type_k=8, # 8-bit quantization for Key Cache (Turbo Quant)
|
| 92 |
+
type_v=8, # 8-bit quantization for Value Cache (Turbo Quant)
|
| 93 |
+
)
|
| 94 |
+
logger.info(f"[SwarmLLM] Model loaded in {time.time()-t0:.1f}s")
|
| 95 |
+
return _llm
|
| 96 |
+
except Exception as e:
|
| 97 |
+
logger.error(f"[SwarmLLM] Failed to load model: {e}")
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class SwarmLLM:
|
| 102 |
+
"""
|
| 103 |
+
Async wrapper around the local Qwen model.
|
| 104 |
+
All inference runs in a thread executor so the FastAPI event loop
|
| 105 |
+
is never blocked.
|
| 106 |
+
"""
|
| 107 |
+
|
| 108 |
+
def __init__(self):
|
| 109 |
+
self._ready = False
|
| 110 |
+
|
| 111 |
+
async def warm_up(self):
|
| 112 |
+
"""Pre-load the model at startup so first inference is instant."""
|
| 113 |
+
loop = asyncio.get_event_loop()
|
| 114 |
+
model = await loop.run_in_executor(None, _load_model)
|
| 115 |
+
self._ready = model is not None
|
| 116 |
+
if self._ready:
|
| 117 |
+
logger.info("[SwarmLLM] Warm-up complete. Ready for inference.")
|
| 118 |
+
else:
|
| 119 |
+
logger.warning("[SwarmLLM] Running in stub mode (model not available).")
|
| 120 |
+
|
| 121 |
+
async def infer(self, prompt: str, system: str = "", max_tokens: int = MAX_TOKENS) -> str:
|
| 122 |
+
"""
|
| 123 |
+
Run inference on the local model. Returns the generated text.
|
| 124 |
+
Falls back to a stub if model is not loaded.
|
| 125 |
+
"""
|
| 126 |
+
if not self._ready:
|
| 127 |
+
return self._stub(prompt)
|
| 128 |
+
|
| 129 |
+
lock = _get_lock()
|
| 130 |
+
async with lock:
|
| 131 |
+
loop = asyncio.get_event_loop()
|
| 132 |
+
result = await loop.run_in_executor(
|
| 133 |
+
None,
|
| 134 |
+
lambda: self._sync_infer(prompt, system, max_tokens),
|
| 135 |
+
)
|
| 136 |
+
return result
|
| 137 |
+
|
| 138 |
+
def _sync_infer(self, prompt: str, system: str, max_tokens: int) -> str:
|
| 139 |
+
global _llm
|
| 140 |
+
if _llm is None:
|
| 141 |
+
return self._stub(prompt)
|
| 142 |
+
try:
|
| 143 |
+
messages = []
|
| 144 |
+
if system:
|
| 145 |
+
messages.append({"role": "system", "content": system})
|
| 146 |
+
messages.append({"role": "user", "content": prompt})
|
| 147 |
+
|
| 148 |
+
response = _llm.create_chat_completion(
|
| 149 |
+
messages=messages,
|
| 150 |
+
max_tokens=max_tokens,
|
| 151 |
+
temperature=0.3,
|
| 152 |
+
stop=["<|im_end|>", "</s>"],
|
| 153 |
+
)
|
| 154 |
+
text = response["choices"][0]["message"]["content"].strip()
|
| 155 |
+
logger.debug("[SwarmLLM] Infer complete: %d chars", len(text))
|
| 156 |
+
return text
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.error(f"[SwarmLLM] Inference error: {e}")
|
| 159 |
+
return self._stub(prompt)
|
| 160 |
+
|
| 161 |
+
@staticmethod
|
| 162 |
+
def _stub(prompt: str) -> str:
|
| 163 |
+
"""Fallback when model is unavailable β returns a safe placeholder."""
|
| 164 |
+
return "[SwarmLLM unavailable β NIM will handle this task]"
|
| 165 |
+
|
| 166 |
+
# ββ High-Level Task Shortcuts ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 167 |
+
|
| 168 |
+
async def summarize(self, text: str, max_words: int = 80) -> str:
|
| 169 |
+
"""Summarise a long text into β€ max_words words. Used to enforce Bell Curve budget."""
|
| 170 |
+
prompt = (
|
| 171 |
+
f"Summarise the following in β€ {max_words} words. "
|
| 172 |
+
f"Be dense with information. No filler sentences.\n\n{text[:3000]}"
|
| 173 |
+
)
|
| 174 |
+
return await self.infer(prompt, system="You are a precise technical summariser.")
|
| 175 |
+
|
| 176 |
+
async def format_json(self, raw: str) -> str:
|
| 177 |
+
"""Extract and clean a JSON object from a messy LLM response."""
|
| 178 |
+
prompt = (
|
| 179 |
+
"Extract the JSON object from the following text. "
|
| 180 |
+
"Return ONLY the JSON, no markdown fences, no explanation.\n\n" + raw[:2000]
|
| 181 |
+
)
|
| 182 |
+
return await self.infer(prompt, system="You are a JSON extractor. Output only valid JSON.")
|
| 183 |
+
|
| 184 |
+
async def generate_brainstorm_questions(self, goal: str, n: int = 5) -> list:
|
| 185 |
+
"""Generate n 'What ifβ¦' brainstorm questions for the hourly swarm cycle."""
|
| 186 |
+
prompt = (
|
| 187 |
+
f"Generate exactly {n} creative 'What ifβ¦' questions to improve: '{goal}'. "
|
| 188 |
+
f"Each question should be a novel technical idea. "
|
| 189 |
+
f"Format: one question per line, no numbering."
|
| 190 |
+
)
|
| 191 |
+
raw = await self.infer(prompt, system="You are a creative technical brainstormer.", max_tokens=400)
|
| 192 |
+
questions = [q.strip() for q in raw.strip().splitlines() if q.strip()]
|
| 193 |
+
return questions[:n]
|
| 194 |
+
|
| 195 |
+
async def smart_trim(self, text: str, char_limit: int) -> str:
|
| 196 |
+
"""
|
| 197 |
+
If text exceeds char_limit, ask the local model to summarise it
|
| 198 |
+
to fit. Better than hard-cutting. Used in Bell Curve budget enforcement.
|
| 199 |
+
"""
|
| 200 |
+
if len(text) <= char_limit:
|
| 201 |
+
return text
|
| 202 |
+
target_words = char_limit // 6 # rough chars-to-words ratio
|
| 203 |
+
summary = await self.summarize(text, max_words=target_words)
|
| 204 |
+
return summary
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# Singleton β import and use directly
|
| 208 |
+
swarm = SwarmLLM()
|