DevodG commited on
Commit
fe4e4ff
·
1 Parent(s): 77c9d22

feat: Janus v0.4.0 - living intelligence system

Browse files

- Add smart LLM router (Groq → OpenRouter → Ollama) with rate limit tracking
- Add context engine for conversation memory and system self-knowledge
- Add reflex layer for instant contextual responses (greetings, identity, status)
- Add custom web crawler (DuckDuckGo + Jina Reader) replacing Tavily
- Wire context into synthesizer for natural, contextual responses
- Add pending thoughts generation from daemon discoveries
- Add /context and /pending-thoughts API endpoints
- Add Dockerfile for HF Spaces deployment
- Update frontend to connect to HF Space backend
- Fix HF Inference API deprecation (migrated to OpenRouter/Groq)

backend/.dockerignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .env
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .venv/
9
+ venv/
10
+ data/
11
+ *.json
12
+ .DS_Store
13
+ node_modules/
14
+ frontend/
15
+ .git/
backend/.env.example CHANGED
@@ -1,16 +1,32 @@
1
  # ========================================
2
- # MiroOrg v2 - AI Financial Intelligence System
3
  # Environment Configuration Template
4
  # ========================================
5
  # Copy this file to .env and fill in your actual values.
6
  # NEVER commit .env to version control.
7
 
8
  # ---------- Application Version ----------
9
- APP_VERSION=0.3.0
10
 
11
  # ---------- Primary model routing ----------
12
- PRIMARY_PROVIDER=openrouter
13
- FALLBACK_PROVIDER=ollama
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  # ---------- OpenRouter ----------
16
  # Get your API key from: https://openrouter.ai/keys
@@ -19,7 +35,7 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
19
  OPENROUTER_CHAT_MODEL=openrouter/free
20
  OPENROUTER_REASONER_MODEL=openrouter/free
21
  OPENROUTER_SITE_URL=http://localhost:3000
22
- OPENROUTER_APP_NAME=MiroOrg Basic
23
 
24
  # ---------- Ollama ----------
25
  # Install from: https://ollama.ai
@@ -36,7 +52,7 @@ OPENAI_CHAT_MODEL=gpt-4o-mini
36
  OPENAI_REASONER_MODEL=gpt-4o
37
 
38
  # ---------- External research APIs ----------
39
- # Tavily: https://tavily.com
40
  TAVILY_API_KEY=tvly-dev-YOUR_KEY_HERE
41
  # NewsAPI: https://newsapi.org
42
  NEWSAPI_KEY=YOUR_KEY_HERE
@@ -45,6 +61,11 @@ ALPHAVANTAGE_API_KEY=YOUR_KEY_HERE
45
  # Jina Reader: https://jina.ai
46
  JINA_READER_BASE=https://r.jina.ai/http://
47
 
 
 
 
 
 
48
  # ---------- MiroFish ----------
49
  # MiroFish is the simulation service (deprecated — using native engine)
50
  MIROFISH_ENABLED=false
 
1
  # ========================================
2
+ # Janus - AI Intelligence System
3
  # Environment Configuration Template
4
  # ========================================
5
  # Copy this file to .env and fill in your actual values.
6
  # NEVER commit .env to version control.
7
 
8
  # ---------- Application Version ----------
9
+ APP_VERSION=0.4.0
10
 
11
  # ---------- Primary model routing ----------
12
+ # PRIMARY_PROVIDER: The main LLM provider (huggingface, openrouter, ollama, or openai)
13
+ # FALLBACK_PROVIDER: The backup provider if primary fails
14
+ PRIMARY_PROVIDER=huggingface
15
+ FALLBACK_PROVIDER=openrouter
16
+
17
+ # ---------- HuggingFace Inference API ----------
18
+ # Get your API key from: https://huggingface.co/settings/tokens
19
+ # Required permissions: "Make calls to Inference Providers"
20
+ HUGGINGFACE_API_KEY=hf_...
21
+ HUGGINGFACE_MODEL=Qwen/Qwen2.5-7B-Instruct
22
+
23
+ # ---------- Smart LLM Router ----------
24
+ # Priority: Gemini → Groq → OpenRouter → Cloudflare → Ollama
25
+ # Get keys from respective providers (all free tiers available)
26
+ GEMINI_API_KEY=AIza... # https://aistudio.google.com/apikey (1500 req/day free)
27
+ GROQ_API_KEY=gsk_... # https://console.groq.com/keys (fast, 14400 req/day free)
28
+ CLOUDFLARE_ACCOUNT_ID=... # https://dash.cloudflare.com (10k req/day free)
29
+ CLOUDFLARE_API_TOKEN=...
30
 
31
  # ---------- OpenRouter ----------
32
  # Get your API key from: https://openrouter.ai/keys
 
35
  OPENROUTER_CHAT_MODEL=openrouter/free
36
  OPENROUTER_REASONER_MODEL=openrouter/free
37
  OPENROUTER_SITE_URL=http://localhost:3000
38
+ OPENROUTER_APP_NAME=Janus
39
 
40
  # ---------- Ollama ----------
41
  # Install from: https://ollama.ai
 
52
  OPENAI_REASONER_MODEL=gpt-4o
53
 
54
  # ---------- External research APIs ----------
55
+ # Tavily: https://tavily.com (optional — crawler is primary)
56
  TAVILY_API_KEY=tvly-dev-YOUR_KEY_HERE
57
  # NewsAPI: https://newsapi.org
58
  NEWSAPI_KEY=YOUR_KEY_HERE
 
61
  # Jina Reader: https://jina.ai
62
  JINA_READER_BASE=https://r.jina.ai/http://
63
 
64
+ # ---------- Custom Crawler ----------
65
+ # Self-hosted web crawling — no API keys needed
66
+ CRAWLER_ENABLED=true
67
+ CRAWLER_TIMEOUT=30
68
+
69
  # ---------- MiroFish ----------
70
  # MiroFish is the simulation service (deprecated — using native engine)
71
  MIROFISH_ENABLED=false
backend/Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ curl \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ RUN playwright install --with-deps chromium 2>/dev/null || true
13
+
14
+ COPY . .
15
+
16
+ RUN mkdir -p /app/app/data/context /app/app/data/daemon /app/app/data/adaptive /app/app/data/knowledge /app/app/data/memory /app/app/data/simulations
17
+
18
+ ENV PYTHONUNBUFFERED=1
19
+ ENV PORT=7860
20
+
21
+ EXPOSE 7860
22
+
23
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
backend/app/agents/_model.py CHANGED
@@ -1,10 +1,11 @@
1
  """
2
- Unified model client for MiroOrg v2.
3
- Priority: OpenRouter freeOllama fallbackraise with diagnostics.
4
  All tiers use the OpenAI-compatible messages format.
 
5
  """
6
 
7
- import os, json, re, logging
8
  import httpx
9
  from typing import Any
10
 
@@ -12,18 +13,26 @@ logger = logging.getLogger(__name__)
12
 
13
  OPENROUTER_BASE = "https://openrouter.ai/api/v1"
14
 
15
- # Pinned free models in preference order — fast + reliable
16
  FREE_MODEL_LADDER = [
17
- "qwen/qwen3.6-plus:free", # Best free model, reliable, fast
18
- "nvidia/nemotron-3-super-120b-a12b:free", # Large NVIDIA model
19
- "minimax/minimax-m2.5:free", # MiniMax strong reasoning
20
- "stepfun/step-3.5-flash:free", # StepFun fast inference
21
- "arcee-ai/trinity-mini:free", # Lightweight fallback
22
  ]
23
 
24
  OLLAMA_BASE = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
25
  TIMEOUT = 90
26
- OLLAMA_TIMEOUT = 30 # Ollama is local, should be fast
 
 
 
 
 
 
 
 
 
27
 
28
 
29
  def _openrouter_call(messages: list[dict], model: str, **kwargs) -> str:
@@ -34,8 +43,8 @@ def _openrouter_call(messages: list[dict], model: str, **kwargs) -> str:
34
 
35
  headers = {
36
  "Authorization": f"Bearer {api_key}",
37
- "HTTP-Referer": "https://miroorg.local",
38
- "X-Title": "MiroOrg v2",
39
  "Content-Type": "application/json",
40
  }
41
  body = {"model": model, "messages": messages, "max_tokens": 4096, **kwargs}
@@ -64,32 +73,70 @@ def _ollama_call(messages: list[dict], **kwargs) -> str:
64
  return r.json()["choices"][0]["message"]["content"]
65
 
66
 
67
- def call_model(messages: list[dict], **kwargs) -> str:
68
  """
69
- Try OpenRouter free models in ladder order, then Ollama.
70
- Returns raw text. Never returns None raises RuntimeError with full diagnostics
71
- so the caller can write a structured error dict instead of silently propagating None.
72
  """
73
- errors = []
74
- for model in FREE_MODEL_LADDER:
75
  try:
76
- result = _openrouter_call(messages, model, **kwargs)
77
- logger.info(f"Model call succeeded: {model}")
78
- return result
79
- except Exception as e:
80
- errors.append(f"OpenRouter [{model}]: {e}")
81
- logger.warning(f"OpenRouter [{model}] failed: {e}")
82
-
83
- # Ollama fallback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  try:
85
- result = _ollama_call(messages, **kwargs)
86
- logger.info(f"Ollama fallback succeeded")
87
- return result
88
  except Exception as e:
89
- errors.append(f"Ollama: {e}")
90
- logger.error(f"Ollama fallback failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
- raise RuntimeError("All model tiers failed:\n" + "\n".join(errors))
93
 
94
 
95
  def safe_parse(text: str) -> dict:
 
1
  """
2
+ Unified model client for Janus.
3
+ Uses smart router: Gemini Groq OpenRouter Cloudflare Ollama.
4
  All tiers use the OpenAI-compatible messages format.
5
+ Includes retry-with-backoff for 429 rate limits.
6
  """
7
 
8
+ import os, json, re, logging, time
9
  import httpx
10
  from typing import Any
11
 
 
13
 
14
  OPENROUTER_BASE = "https://openrouter.ai/api/v1"
15
 
 
16
  FREE_MODEL_LADDER = [
17
+ "qwen/qwen3.6-plus:free",
18
+ "nvidia/nemotron-3-super-120b-a12b:free",
19
+ "minimax/minimax-m2.5:free",
20
+ "stepfun/step-3.5-flash:free",
21
+ "arcee-ai/trinity-mini:free",
22
  ]
23
 
24
  OLLAMA_BASE = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
25
  TIMEOUT = 90
26
+ OLLAMA_TIMEOUT = 30
27
+ MAX_RETRIES_PER_MODEL = 2
28
+ BASE_BACKOFF = 3
29
+
30
+
31
+ def _huggingface_call(messages: list[dict], **kwargs) -> str:
32
+ """Call HuggingFace Inference API."""
33
+ from app.agents.huggingface import hf_client
34
+
35
+ return hf_client.chat(messages, **kwargs)
36
 
37
 
38
  def _openrouter_call(messages: list[dict], model: str, **kwargs) -> str:
 
43
 
44
  headers = {
45
  "Authorization": f"Bearer {api_key}",
46
+ "HTTP-Referer": "https://huggingface.co",
47
+ "X-Title": "Janus",
48
  "Content-Type": "application/json",
49
  }
50
  body = {"model": model, "messages": messages, "max_tokens": 4096, **kwargs}
 
73
  return r.json()["choices"][0]["message"]["content"]
74
 
75
 
76
+ def _call_with_retry(messages: list[dict], model: str, **kwargs) -> str:
77
  """
78
+ Call OpenRouter with retry-on-429 backoff.
79
+ Retries up to MAX_RETRIES_PER_MODEL times for rate limits,
80
+ respecting the Retry-After header when present.
81
  """
82
+ for attempt in range(MAX_RETRIES_PER_MODEL + 1):
 
83
  try:
84
+ return _openrouter_call(messages, model, **kwargs)
85
+ except httpx.HTTPStatusError as e:
86
+ if e.response.status_code == 429:
87
+ if attempt >= MAX_RETRIES_PER_MODEL:
88
+ raise # Out of retries for this model
89
+
90
+ # Respect Retry-After header, otherwise use exponential backoff
91
+ retry_after = e.response.headers.get("retry-after")
92
+ if retry_after:
93
+ try:
94
+ wait = min(float(retry_after), 30) # Cap at 30s
95
+ except ValueError:
96
+ wait = BASE_BACKOFF * (2**attempt)
97
+ else:
98
+ wait = BASE_BACKOFF * (2**attempt)
99
+
100
+ logger.warning(
101
+ f"Rate limited on {model} (attempt {attempt + 1}/{MAX_RETRIES_PER_MODEL + 1}), "
102
+ f"waiting {wait:.1f}s..."
103
+ )
104
+ time.sleep(wait)
105
+ else:
106
+ raise # Non-429 error, don't retry
107
+ # Should not reach here, but just in case
108
+ return _openrouter_call(messages, model, **kwargs)
109
+
110
+
111
+ def call_model(messages: list[dict], **kwargs) -> str:
112
+ """
113
+ Smart router: Gemini → Groq → OpenRouter → Cloudflare → Ollama.
114
+ Uses unified router with rate limit tracking and automatic failover.
115
+ Returns raw text. Never returns None.
116
+ """
117
  try:
118
+ from app.agents.smart_router import call_model as smart_call
119
+
120
+ return smart_call(messages, **kwargs)
121
  except Exception as e:
122
+ logger.error(f"Smart router failed: {e}")
123
+ # Direct OpenRouter fallback if smart router fails
124
+ errors = []
125
+ for model in FREE_MODEL_LADDER:
126
+ try:
127
+ result = _call_with_retry(messages, model, **kwargs)
128
+ logger.info(f"OpenRouter direct succeeded: {model}")
129
+ return result
130
+ except Exception as e2:
131
+ errors.append(f"OpenRouter [{model}]: {e2}")
132
+
133
+ # Ollama last resort
134
+ try:
135
+ return _ollama_call(messages, **kwargs)
136
+ except Exception as e3:
137
+ errors.append(f"Ollama: {e3}")
138
 
139
+ raise RuntimeError("All model tiers failed:\n" + "\n".join(errors))
140
 
141
 
142
  def safe_parse(text: str) -> dict:
backend/app/agents/huggingface.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HuggingFace Inference API Client for Janus.
3
+
4
+ NOTE: HF deprecated api-inference.huggingface.co in favor of router.huggingface.co.
5
+ The new router requires provider-specific endpoints. This client tries multiple providers.
6
+ Fallback to OpenRouter is recommended for reliability.
7
+ """
8
+
9
+ import json
10
+ import time
11
+ import logging
12
+ from typing import Dict, List, Any, Optional
13
+
14
+ import httpx
15
+
16
+ from app.config import HUGGINGFACE_API_KEY, HUGGINGFACE_MODEL
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ MAX_RETRIES = 2
21
+ BACKOFF_BASE = 1
22
+ PROVIDER_TIMEOUT = 15
23
+
24
+ PROVIDER_ROUTES = [
25
+ f"https://router.huggingface.co/nebius/v1/chat/completions",
26
+ f"https://router.huggingface.co/together/v1/chat/completions",
27
+ f"https://router.huggingface.co/sambanova/v1/chat/completions",
28
+ ]
29
+
30
+
31
+ class HuggingFaceInferenceClient:
32
+ """HuggingFace Inference API client — tries multiple providers."""
33
+
34
+ def __init__(self, model: str = HUGGINGFACE_MODEL):
35
+ self.model = model
36
+ self.headers = {
37
+ "Authorization": f"Bearer {HUGGINGFACE_API_KEY}",
38
+ "Content-Type": "application/json",
39
+ }
40
+
41
+ def chat(self, messages: List[Dict[str, str]], **kwargs) -> str:
42
+ payload = {
43
+ "model": self.model,
44
+ "messages": messages,
45
+ "temperature": kwargs.get("temperature", 0.7),
46
+ "max_tokens": kwargs.get("max_tokens", 4096),
47
+ }
48
+
49
+ for route in PROVIDER_ROUTES:
50
+ try:
51
+ with httpx.Client(timeout=PROVIDER_TIMEOUT) as client:
52
+ response = client.post(
53
+ route,
54
+ headers=self.headers,
55
+ json=payload,
56
+ )
57
+
58
+ if response.status_code == 200:
59
+ data = response.json()
60
+ choices = data.get("choices", [])
61
+ if choices:
62
+ return choices[0].get("message", {}).get("content", "")
63
+ return data.get("generated_text", "")
64
+
65
+ elif response.status_code == 429:
66
+ time.sleep(BACKOFF_BASE)
67
+ continue
68
+
69
+ elif response.status_code in (400, 403, 404):
70
+ logger.debug(
71
+ f"[HF] Provider {route.split('/')[3]} unavailable ({response.status_code})"
72
+ )
73
+ break
74
+
75
+ else:
76
+ logger.warning(f"[HF] Error {response.status_code} on {route}")
77
+ break
78
+
79
+ except httpx.TimeoutException:
80
+ logger.debug(f"[HF] Timeout on {route}")
81
+ continue
82
+ except Exception as e:
83
+ logger.debug(f"[HF] Error on {route}: {e}")
84
+ break
85
+
86
+ raise RuntimeError("All HF providers exhausted")
87
+
88
+ def reason(self, messages: List[Dict[str, str]], **kwargs) -> str:
89
+ return self.chat(
90
+ messages,
91
+ temperature=kwargs.get("temperature", 0.9),
92
+ max_tokens=kwargs.get("max_tokens", 8192),
93
+ )
94
+
95
+ def is_available(self) -> bool:
96
+ try:
97
+ with httpx.Client(timeout=10) as client:
98
+ r = client.get(
99
+ "https://router.huggingface.co/hf-inference/v1/models",
100
+ headers=self.headers,
101
+ )
102
+ return r.status_code < 500
103
+ except Exception:
104
+ return False
105
+
106
+
107
+ hf_client = HuggingFaceInferenceClient()
backend/app/agents/research.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
- Research agent — MiroOrg v2.
3
- Uses Tavily web search, News API, Knowledge Store, and API Discovery
4
  to gather context before calling the LLM for structured analysis.
5
  """
6
 
@@ -8,24 +8,23 @@ import os, json, re, logging
8
  import httpx
9
  from app.agents._model import call_model
10
  from app.agents.api_discovery import discover_apis, call_discovered_api
11
- from app.config import load_prompt
12
  from app.memory import knowledge_store
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
  TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "")
17
  NEWS_API_KEY = os.getenv("NEWS_API_KEY", os.getenv("NEWSAPI_KEY", ""))
 
18
 
19
 
20
  def _extract_json(text: str) -> dict | None:
21
  """Robustly extract JSON from model response."""
22
- # Try direct parse
23
  try:
24
  return json.loads(text)
25
  except json.JSONDecodeError:
26
  pass
27
 
28
- # Strip markdown code fences
29
  cleaned = re.sub(r"```(?:json)?\s*\n?", "", text).strip()
30
  cleaned = re.sub(r"\n?```\s*$", "", cleaned).strip()
31
  try:
@@ -33,7 +32,6 @@ def _extract_json(text: str) -> dict | None:
33
  except json.JSONDecodeError:
34
  pass
35
 
36
- # Find JSON object in text
37
  start = text.find("{")
38
  end = text.rfind("}")
39
  if start != -1 and end != -1 and end > start:
@@ -46,7 +44,90 @@ def _extract_json(text: str) -> dict | None:
46
  return None
47
 
48
 
49
- # ─── Tool: Tavily Web Search ─────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
 
52
  def tavily_search(query: str, max_results: int = 5) -> list[dict]:
@@ -72,9 +153,6 @@ def tavily_search(query: str, max_results: int = 5) -> list[dict]:
72
  return []
73
 
74
 
75
- # ─── Tool: News API ──────────────────────────────────────────────────────────
76
-
77
-
78
  def news_search(query: str, max_articles: int = 5) -> list[dict]:
79
  """Returns list of {title, source, publishedAt, description} dicts."""
80
  if not NEWS_API_KEY:
@@ -106,9 +184,6 @@ def news_search(query: str, max_articles: int = 5) -> list[dict]:
106
  return []
107
 
108
 
109
- # ─── Research Node ────────────────────────────────────────────────────────────
110
-
111
-
112
  def run(state: dict) -> dict:
113
  route = state.get("route", {})
114
  intent = route.get("intent", state.get("user_input", ""))
@@ -116,14 +191,22 @@ def run(state: dict) -> dict:
116
 
117
  context_blocks = []
118
 
119
- # Step 1: Tavily web search
120
- web_results = tavily_search(intent)
121
- if web_results:
122
  formatted = "\n".join(
123
- f"- {r.get('title', 'Untitled')}\n URL: {r.get('url', '')}\n {r.get('content', '')[:300]}"
124
- for r in web_results
125
  )
126
- context_blocks.append(f"[Web Search Results]\n{formatted}")
 
 
 
 
 
 
 
 
127
 
128
  # Step 2: News API (if requires_news or finance domain)
129
  if route.get("requires_news") or domain == "finance":
@@ -201,7 +284,6 @@ def run(state: dict) -> dict:
201
  if raw_response:
202
  result = _extract_json(raw_response)
203
  if result is None:
204
- # Last resort: use raw text as summary
205
  logger.warning(
206
  f"[AGENT PARSE FALLBACK] research: using raw text as summary"
207
  )
 
1
  """
2
+ Research agent — Janus.
3
+ Uses lightweight HTTP crawler (no Playwright needed), Knowledge Store, and API Discovery
4
  to gather context before calling the LLM for structured analysis.
5
  """
6
 
 
8
  import httpx
9
  from app.agents._model import call_model
10
  from app.agents.api_discovery import discover_apis, call_discovered_api
11
+ from app.config import load_prompt, CRAWLER_ENABLED
12
  from app.memory import knowledge_store
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
  TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "")
17
  NEWS_API_KEY = os.getenv("NEWS_API_KEY", os.getenv("NEWSAPI_KEY", ""))
18
+ JINA_READER_BASE = os.getenv("JINA_READER_BASE", "https://r.jina.ai/")
19
 
20
 
21
  def _extract_json(text: str) -> dict | None:
22
  """Robustly extract JSON from model response."""
 
23
  try:
24
  return json.loads(text)
25
  except json.JSONDecodeError:
26
  pass
27
 
 
28
  cleaned = re.sub(r"```(?:json)?\s*\n?", "", text).strip()
29
  cleaned = re.sub(r"\n?```\s*$", "", cleaned).strip()
30
  try:
 
32
  except json.JSONDecodeError:
33
  pass
34
 
 
35
  start = text.find("{")
36
  end = text.rfind("}")
37
  if start != -1 and end != -1 and end > start:
 
44
  return None
45
 
46
 
47
+ def _duckduckgo_urls(query: str, max_results: int = 5) -> list[str]:
48
+ """Get URLs from DuckDuckGo HTML search (no API key)."""
49
+ urls = []
50
+ try:
51
+ search_url = f"https://html.duckduckgo.com/html/?q={httpx.quote(query)}"
52
+ with httpx.Client(timeout=15) as client:
53
+ headers = {
54
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
55
+ }
56
+ resp = client.get(search_url, headers=headers, follow_redirects=True)
57
+ if resp.status_code == 200:
58
+ pattern = re.compile(r'class="result__a"[^>]*href="([^"]+)"')
59
+ for match in pattern.finditer(resp.text):
60
+ url = match.group(1)
61
+ if url and url.startswith("http") and url not in urls:
62
+ urls.append(url)
63
+ if len(urls) >= max_results:
64
+ break
65
+ except Exception as e:
66
+ logger.warning(f"DuckDuckGo search failed: {e}")
67
+ return urls
68
+
69
+
70
+ def _extract_content(url: str) -> str | None:
71
+ """Extract clean content from a URL using Jina Reader (free, no key needed)."""
72
+ try:
73
+ jina_url = f"{JINA_READER_BASE}{url}"
74
+ with httpx.Client(timeout=12) as client:
75
+ r = client.get(jina_url, follow_redirects=True)
76
+ if r.status_code == 200 and len(r.text) > 100:
77
+ return r.text[:5000]
78
+ except Exception:
79
+ pass
80
+
81
+ try:
82
+ with httpx.Client(timeout=10) as client:
83
+ headers = {"User-Agent": "Mozilla/5.0 (compatible; Janus/1.0)"}
84
+ r = client.get(url, headers=headers, follow_redirects=True, timeout=10)
85
+ if r.status_code == 200:
86
+ text = re.sub(r"<script[^>]*>.*?</script>", "", r.text, flags=re.DOTALL)
87
+ text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
88
+ text = re.sub(r"<[^>]+>", " ", text)
89
+ text = re.sub(r"\s+", " ", text).strip()
90
+ return text[:5000] if len(text) > 100 else None
91
+ except Exception:
92
+ pass
93
+
94
+ return None
95
+
96
+
97
+ def crawl_web_search(query: str, max_results: int = 3) -> list[dict]:
98
+ """
99
+ Self-hosted web research: DuckDuckGo for URLs → Jina Reader for content.
100
+ Optimized for speed: 3 results, parallel fetch, short timeouts.
101
+ """
102
+ if not CRAWLER_ENABLED:
103
+ return []
104
+
105
+ urls = _duckduckgo_urls(query, max_results)
106
+ if not urls:
107
+ return []
108
+
109
+ results = []
110
+ # Fetch in parallel with ThreadPoolExecutor
111
+ import concurrent.futures
112
+
113
+ def _fetch_one(url):
114
+ content = _extract_content(url)
115
+ if content:
116
+ title = content.split("\n")[0][:200] if "\n" in content else url
117
+ return {"title": title, "url": url, "content": content[:2000]}
118
+ return None
119
+
120
+ with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
121
+ futures = {executor.submit(_fetch_one, url): url for url in urls[:max_results]}
122
+ for future in concurrent.futures.as_completed(futures, timeout=25):
123
+ try:
124
+ result = future.result()
125
+ if result:
126
+ results.append(result)
127
+ except Exception:
128
+ pass
129
+
130
+ return results
131
 
132
 
133
  def tavily_search(query: str, max_results: int = 5) -> list[dict]:
 
153
  return []
154
 
155
 
 
 
 
156
  def news_search(query: str, max_articles: int = 5) -> list[dict]:
157
  """Returns list of {title, source, publishedAt, description} dicts."""
158
  if not NEWS_API_KEY:
 
184
  return []
185
 
186
 
 
 
 
187
  def run(state: dict) -> dict:
188
  route = state.get("route", {})
189
  intent = route.get("intent", state.get("user_input", ""))
 
191
 
192
  context_blocks = []
193
 
194
+ # Step 1: Self-hosted crawler (DuckDuckGo + Jina Reader)
195
+ crawl_results = crawl_web_search(intent)
196
+ if crawl_results:
197
  formatted = "\n".join(
198
+ f"- {r.get('title', 'Untitled')}\n URL: {r.get('url', '')}\n {r.get('content', '')[:500]}"
199
+ for r in crawl_results
200
  )
201
+ context_blocks.append(f"[Web Crawl Results]\n{formatted}")
202
+ elif TAVILY_API_KEY:
203
+ web_results = tavily_search(intent)
204
+ if web_results:
205
+ formatted = "\n".join(
206
+ f"- {r.get('title', 'Untitled')}\n URL: {r.get('url', '')}\n {r.get('content', '')[:300]}"
207
+ for r in web_results
208
+ )
209
+ context_blocks.append(f"[Web Search Results]\n{formatted}")
210
 
211
  # Step 2: News API (if requires_news or finance domain)
212
  if route.get("requires_news") or domain == "finance":
 
284
  if raw_response:
285
  result = _extract_json(raw_response)
286
  if result is None:
 
287
  logger.warning(
288
  f"[AGENT PARSE FALLBACK] research: using raw text as summary"
289
  )
backend/app/agents/smart_router.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Smart LLM Router for Janus.
3
+
4
+ Unified router across multiple free providers with rate limit tracking,
5
+ automatic failover, and daily quota management.
6
+
7
+ Provider priority:
8
+ 1. Google Gemini 2.0 Flash (best free quality, 1500 req/day)
9
+ 2. Groq (fastest, Llama 3.3 70B, 14400 req/day)
10
+ 3. OpenRouter (free models, effectively unlimited)
11
+ 4. Cloudflare Workers AI (10k req/day)
12
+ 5. Ollama (local, unlimited)
13
+ """
14
+
15
+ import os
16
+ import time
17
+ import json
18
+ import logging
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Dict, List, Any, Optional
22
+
23
+ import httpx
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # ─── Rate Limit State ────────────────────────────────────────────────────────
28
+
29
+ STATE_DIR = Path(__file__).parent.parent.parent / "data" / "router_state"
30
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
31
+ STATE_FILE = STATE_DIR / "provider_state.json"
32
+
33
+
34
+ def _midnight_ts() -> int:
35
+ now = datetime.now(timezone.utc)
36
+ tomorrow = now.replace(hour=0, minute=0, second=0, microsecond=0)
37
+ from datetime import timedelta
38
+
39
+ tomorrow = tomorrow + timedelta(days=1)
40
+ return int(tomorrow.timestamp())
41
+
42
+
43
+ def _load_state() -> dict:
44
+ if STATE_FILE.exists():
45
+ try:
46
+ with open(STATE_FILE) as f:
47
+ return json.load(f)
48
+ except Exception:
49
+ pass
50
+ return {}
51
+
52
+
53
+ def _save_state(state: dict):
54
+ try:
55
+ with open(STATE_FILE, "w") as f:
56
+ json.dump(state, f, indent=2)
57
+ except Exception:
58
+ pass
59
+
60
+
61
+ def _is_available(provider: str, daily_limit: int, rpm_limit: int, state: dict) -> bool:
62
+ now = time.time()
63
+ p = state.get(
64
+ provider, {"daily_count": 0, "rpm_timestamps": [], "reset_at": _midnight_ts()}
65
+ )
66
+
67
+ if now > p.get("reset_at", 0):
68
+ p["daily_count"] = 0
69
+ p["rpm_timestamps"] = []
70
+ p["reset_at"] = _midnight_ts()
71
+
72
+ if p["daily_count"] >= daily_limit:
73
+ return False
74
+
75
+ p["rpm_timestamps"] = [t for t in p["rpm_timestamps"] if now - t < 60]
76
+ if len(p["rpm_timestamps"]) >= rpm_limit:
77
+ return False
78
+
79
+ state[provider] = p
80
+ return True
81
+
82
+
83
+ def _record_usage(provider: str, state: dict):
84
+ now = time.time()
85
+ p = state.get(
86
+ provider, {"daily_count": 0, "rpm_timestamps": [], "reset_at": _midnight_ts()}
87
+ )
88
+ p["daily_count"] = p.get("daily_count", 0) + 1
89
+ p["rpm_timestamps"] = p.get("rpm_timestamps", []) + [now]
90
+ state[provider] = p
91
+ _save_state(state)
92
+
93
+
94
+ # ─── Provider Implementations ────────────────────────────────────────────────
95
+
96
+ GEMINI_KEY = os.getenv("GEMINI_API_KEY", "")
97
+ GROQ_KEY = os.getenv("GROQ_API_KEY", "")
98
+ OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
99
+ CF_ACCOUNT_ID = os.getenv("CLOUDFLARE_ACCOUNT_ID", "")
100
+ CF_TOKEN = os.getenv("CLOUDFLARE_API_TOKEN", "")
101
+
102
+ TIMEOUT = 90
103
+
104
+
105
+ def _call_gemini(messages: List[Dict[str, str]], **kwargs) -> str:
106
+ """Google Gemini 2.0 Flash — best free quality."""
107
+ if not GEMINI_KEY:
108
+ raise ValueError("GEMINI_API_KEY not set")
109
+
110
+ system_msg = ""
111
+ user_messages = []
112
+ for m in messages:
113
+ if m["role"] == "system":
114
+ system_msg = m["content"]
115
+ else:
116
+ user_messages.append(m)
117
+
118
+ body = {
119
+ "system_instruction": {"parts": [{"text": system_msg}]},
120
+ "contents": [
121
+ {
122
+ "role": "model" if m["role"] == "assistant" else "user",
123
+ "parts": [{"text": m["content"]}],
124
+ }
125
+ for m in user_messages
126
+ ],
127
+ "generationConfig": {
128
+ "temperature": kwargs.get("temperature", 0.7),
129
+ "maxOutputTokens": kwargs.get("max_tokens", 4096),
130
+ },
131
+ }
132
+
133
+ with httpx.Client(timeout=TIMEOUT) as client:
134
+ r = client.post(
135
+ f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={GEMINI_KEY}",
136
+ json=body,
137
+ )
138
+ r.raise_for_status()
139
+ data = r.json()
140
+ return data["candidates"][0]["content"]["parts"][0]["text"]
141
+
142
+
143
+ def _call_groq(messages: List[Dict[str, str]], **kwargs) -> str:
144
+ """Groq — fastest inference, Llama 3.3 70B."""
145
+ if not GROQ_KEY:
146
+ raise ValueError("GROQ_API_KEY not set")
147
+
148
+ body = {
149
+ "model": "llama-3.3-70b-versatile",
150
+ "messages": messages,
151
+ "temperature": kwargs.get("temperature", 0.7),
152
+ "max_tokens": kwargs.get("max_tokens", 4096),
153
+ }
154
+
155
+ with httpx.Client(timeout=TIMEOUT) as client:
156
+ r = client.post(
157
+ "https://api.groq.com/openai/v1/chat/completions",
158
+ headers={
159
+ "Authorization": f"Bearer {GROQ_KEY}",
160
+ "Content-Type": "application/json",
161
+ },
162
+ json=body,
163
+ )
164
+ r.raise_for_status()
165
+ return r.json()["choices"][0]["message"]["content"]
166
+
167
+
168
+ def _call_openrouter(messages: List[Dict[str, str]], **kwargs) -> str:
169
+ """OpenRouter — free model ladder."""
170
+ if not OPENROUTER_KEY:
171
+ raise ValueError("OPENROUTER_API_KEY not set")
172
+
173
+ free_models = [
174
+ "qwen/qwen3.6-plus:free",
175
+ "nvidia/nemotron-3-super-120b-a12b:free",
176
+ "minimax/minimax-m2.5:free",
177
+ ]
178
+
179
+ errors = []
180
+ for model in free_models:
181
+ try:
182
+ body = {
183
+ "model": model,
184
+ "messages": messages,
185
+ "max_tokens": kwargs.get("max_tokens", 4096),
186
+ }
187
+ with httpx.Client(timeout=TIMEOUT) as client:
188
+ r = client.post(
189
+ "https://openrouter.ai/api/v1/chat/completions",
190
+ headers={
191
+ "Authorization": f"Bearer {OPENROUTER_KEY}",
192
+ "HTTP-Referer": "https://huggingface.co",
193
+ "X-Title": "Janus",
194
+ "Content-Type": "application/json",
195
+ },
196
+ json=body,
197
+ )
198
+ r.raise_for_status()
199
+ return r.json()["choices"][0]["message"]["content"]
200
+ except Exception as e:
201
+ errors.append(f"{model}: {e}")
202
+
203
+ raise RuntimeError(f"All OpenRouter models failed: {'; '.join(errors)}")
204
+
205
+
206
+ def _call_cloudflare(messages: List[Dict[str, str]], **kwargs) -> str:
207
+ """Cloudflare Workers AI — 10k req/day free."""
208
+ if not CF_ACCOUNT_ID or not CF_TOKEN:
209
+ raise ValueError("CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN not set")
210
+
211
+ body = {
212
+ "messages": messages,
213
+ "max_tokens": kwargs.get("max_tokens", 4096),
214
+ }
215
+
216
+ with httpx.Client(timeout=TIMEOUT) as client:
217
+ r = client.post(
218
+ f"https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT_ID}/ai/run/@cf/meta/llama-3.3-70b-instruct-fp8-fast",
219
+ headers={
220
+ "Authorization": f"Bearer {CF_TOKEN}",
221
+ "Content-Type": "application/json",
222
+ },
223
+ json=body,
224
+ )
225
+ r.raise_for_status()
226
+ data = r.json()
227
+ return data["result"]["response"]
228
+
229
+
230
+ def _call_ollama(messages: List[Dict[str, str]], **kwargs) -> str:
231
+ """Ollama — local, unlimited fallback."""
232
+ base = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
233
+ if base.endswith("/api"):
234
+ base = base[:-4]
235
+
236
+ model = os.getenv("OLLAMA_CHAT_MODEL", "qwen2.5:3b")
237
+ body = {
238
+ "model": model,
239
+ "messages": messages,
240
+ "stream": False,
241
+ }
242
+
243
+ with httpx.Client(timeout=60) as client:
244
+ r = client.post(f"{base}/v1/chat/completions", json=body)
245
+ r.raise_for_status()
246
+ return r.json()["choices"][0]["message"]["content"]
247
+
248
+
249
+ # ─── Provider Registry ───────────────────────────────────────────────────────
250
+
251
+ PROVIDERS = [
252
+ {
253
+ "name": "groq",
254
+ "daily_limit": 14400,
255
+ "rpm_limit": 1000,
256
+ "call": _call_groq,
257
+ "enabled": bool(GROQ_KEY),
258
+ },
259
+ {
260
+ "name": "gemini",
261
+ "daily_limit": 1500,
262
+ "rpm_limit": 15,
263
+ "call": _call_gemini,
264
+ "enabled": bool(GEMINI_KEY),
265
+ },
266
+ {
267
+ "name": "openrouter",
268
+ "daily_limit": 999999,
269
+ "rpm_limit": 20,
270
+ "call": _call_openrouter,
271
+ "enabled": bool(OPENROUTER_KEY),
272
+ },
273
+ {
274
+ "name": "cloudflare",
275
+ "daily_limit": 10000,
276
+ "rpm_limit": 60,
277
+ "call": _call_cloudflare,
278
+ "enabled": bool(CF_ACCOUNT_ID and CF_TOKEN),
279
+ },
280
+ {
281
+ "name": "ollama",
282
+ "daily_limit": 999999,
283
+ "rpm_limit": 999999,
284
+ "call": _call_ollama,
285
+ "enabled": True,
286
+ },
287
+ ]
288
+
289
+
290
+ def call_model(messages: List[Dict[str, str]], **kwargs) -> str:
291
+ """
292
+ Smart router — tries providers in priority order with rate limit tracking.
293
+ Returns text from the first successful provider.
294
+ """
295
+ state = _load_state()
296
+ errors = []
297
+
298
+ for provider in PROVIDERS:
299
+ if not provider["enabled"]:
300
+ continue
301
+
302
+ name = provider["name"]
303
+ if not _is_available(
304
+ name, provider["daily_limit"], provider["rpm_limit"], state
305
+ ):
306
+ logger.info(f"[router] {name} skipped (rate limited)")
307
+ continue
308
+
309
+ try:
310
+ logger.info(f"[router] trying {name}")
311
+ result = provider["call"](messages, **kwargs)
312
+ _record_usage(name, state)
313
+ logger.info(f"[router] {name} succeeded")
314
+ return result
315
+ except Exception as e:
316
+ errors.append(f"{name}: {e}")
317
+ logger.warning(f"[router] {name} failed: {e}")
318
+
319
+ raise RuntimeError(f"All LLM providers exhausted:\n" + "\n".join(errors))
320
+
321
+
322
+ def safe_parse(text: str) -> dict:
323
+ """Strip markdown fences, attempt JSON parse."""
324
+ import re
325
+
326
+ cleaned = re.sub(r"```(?:json)?|```", "", text).strip()
327
+ try:
328
+ return json.loads(cleaned)
329
+ except json.JSONDecodeError:
330
+ match = re.search(r"\{.*\}", cleaned, re.DOTALL)
331
+ if match:
332
+ try:
333
+ return json.loads(match.group())
334
+ except json.JSONDecodeError:
335
+ pass
336
+ return {"error": "parse_failed", "raw": text[:800]}
backend/app/agents/synthesizer.py CHANGED
@@ -70,6 +70,7 @@ def run(state: dict) -> dict:
70
  simulation = state.get("simulation", {})
71
  finance = state.get("finance", {})
72
  replan_count = state.get("replan_count", 0)
 
73
 
74
  prompt = load_prompt("synthesizer")
75
  parser = PydanticOutputParser(pydantic_object=SynthesizerOutput)
@@ -90,6 +91,35 @@ def run(state: dict) -> dict:
90
  "NOTE: Verifier did not fully pass and replan limit was reached. Acknowledge limitations."
91
  )
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  messages = [
94
  {"role": "system", "content": prompt},
95
  {
@@ -97,6 +127,7 @@ def run(state: dict) -> dict:
97
  "content": (
98
  f"User request: {state.get('user_input', route.get('intent', ''))}\n\n"
99
  + "\n\n".join(context_parts)
 
100
  + "\n\n"
101
  + parser.get_format_instructions()
102
  ),
 
70
  simulation = state.get("simulation", {})
71
  finance = state.get("finance", {})
72
  replan_count = state.get("replan_count", 0)
73
+ context = state.get("context", {})
74
 
75
  prompt = load_prompt("synthesizer")
76
  parser = PydanticOutputParser(pydantic_object=SynthesizerOutput)
 
91
  "NOTE: Verifier did not fully pass and replan limit was reached. Acknowledge limitations."
92
  )
93
 
94
+ # Inject system context into the synthesizer
95
+ system_context = ""
96
+ if context:
97
+ system_self = context.get("system_self", {})
98
+ pending = system_self.get("pending_thoughts", [])
99
+ discoveries = system_self.get("recent_discoveries", [])
100
+ user_ctx = context.get("user", {})
101
+
102
+ if pending:
103
+ thoughts = [t.get("thought", "") for t in pending[:3] if t.get("thought")]
104
+ if thoughts:
105
+ system_context += "\n\nTHINGS YOU'VE BEEN THINKING ABOUT:\n"
106
+ system_context += "\n".join(f"- {t}" for t in thoughts)
107
+
108
+ if discoveries:
109
+ system_context += "\n\nRECENT DISCOVERIES:\n"
110
+ for d in discoveries[:3]:
111
+ system_context += f"- {d.get('discovery', '')}\n"
112
+
113
+ if user_ctx.get("is_returning"):
114
+ system_context += f"\n\nThis user has had {user_ctx.get('conversation_count', 0)} conversations with you."
115
+ if user_ctx.get("last_topic"):
116
+ system_context += f" Last topic: {user_ctx['last_topic']}."
117
+ if user_ctx.get("time_away"):
118
+ system_context += f" They've been away for {user_ctx['time_away']}."
119
+
120
+ if user_ctx.get("recurring_interests"):
121
+ system_context += f"\nTheir recurring interests: {', '.join(user_ctx['recurring_interests'][:3])}."
122
+
123
  messages = [
124
  {"role": "system", "content": prompt},
125
  {
 
127
  "content": (
128
  f"User request: {state.get('user_input', route.get('intent', ''))}\n\n"
129
  + "\n\n".join(context_parts)
130
+ + (f"\n\n{system_context}" if system_context else "")
131
  + "\n\n"
132
  + parser.get_format_instructions()
133
  ),
backend/app/config.py CHANGED
@@ -26,10 +26,18 @@ def load_prompt(name: str) -> str:
26
  return path.read_text(encoding="utf-8").strip()
27
 
28
 
29
- APP_VERSION = os.getenv("APP_VERSION", "0.3.0")
30
 
31
- PRIMARY_PROVIDER = os.getenv("PRIMARY_PROVIDER", "openrouter").lower()
32
- FALLBACK_PROVIDER = os.getenv("FALLBACK_PROVIDER", "ollama").lower()
 
 
 
 
 
 
 
 
33
 
34
  OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
35
  OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
@@ -57,6 +65,9 @@ MIROFISH_ENABLED = False # Deprecated — using native simulation engine
57
  MIROFISH_API_BASE = ""
58
  MIROFISH_TIMEOUT_SECONDS = 0
59
 
 
 
 
60
  SIMULATION_TRIGGER_KEYWORDS = [
61
  item.strip().lower()
62
  for item in os.getenv(
@@ -86,9 +97,14 @@ def validate_config():
86
 
87
  # Validate primary provider configuration
88
  primary = PRIMARY_PROVIDER.lower()
89
- if primary not in ["openrouter", "ollama", "openai"]:
90
  errors.append(
91
- f"PRIMARY_PROVIDER '{PRIMARY_PROVIDER}' is not supported. Must be one of: openrouter, ollama, openai"
 
 
 
 
 
92
  )
93
 
94
  if primary == "openrouter" and not OPENROUTER_API_KEY:
@@ -108,9 +124,14 @@ def validate_config():
108
 
109
  # Validate fallback provider configuration
110
  fallback = FALLBACK_PROVIDER.lower()
111
- if fallback not in ["openrouter", "ollama", "openai"]:
112
  errors.append(
113
- f"FALLBACK_PROVIDER '{FALLBACK_PROVIDER}' is not supported. Must be one of: openrouter, ollama, openai"
 
 
 
 
 
114
  )
115
 
116
  if fallback == "openrouter" and not OPENROUTER_API_KEY:
@@ -198,6 +219,14 @@ def get_config():
198
  primary_provider = PRIMARY_PROVIDER
199
  fallback_provider = FALLBACK_PROVIDER
200
 
 
 
 
 
 
 
 
 
201
  openrouter_api_key = OPENROUTER_API_KEY
202
  openrouter_base_url = OPENROUTER_BASE_URL
203
  openrouter_chat_model = OPENROUTER_CHAT_MODEL
@@ -220,6 +249,9 @@ def get_config():
220
  mirofish_enabled = MIROFISH_ENABLED
221
  mirofish_api_base = MIROFISH_API_BASE
222
 
 
 
 
223
  data_dir = str(DATA_DIR)
224
  memory_dir = str(MEMORY_DIR)
225
  simulation_dir = str(SIMULATION_DIR)
 
26
  return path.read_text(encoding="utf-8").strip()
27
 
28
 
29
+ APP_VERSION = os.getenv("APP_VERSION", "0.4.0")
30
 
31
+ PRIMARY_PROVIDER = os.getenv("PRIMARY_PROVIDER", "huggingface").lower()
32
+ FALLBACK_PROVIDER = os.getenv("FALLBACK_PROVIDER", "openrouter").lower()
33
+
34
+ HUGGINGFACE_API_KEY = os.getenv("HUGGINGFACE_API_KEY", "")
35
+ HUGGINGFACE_MODEL = os.getenv("HUGGINGFACE_MODEL", "Qwen/Qwen2.5-7B-Instruct")
36
+
37
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
38
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
39
+ CLOUDFLARE_ACCOUNT_ID = os.getenv("CLOUDFLARE_ACCOUNT_ID", "")
40
+ CLOUDFLARE_API_TOKEN = os.getenv("CLOUDFLARE_API_TOKEN", "")
41
 
42
  OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
43
  OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
 
65
  MIROFISH_API_BASE = ""
66
  MIROFISH_TIMEOUT_SECONDS = 0
67
 
68
+ CRAWLER_ENABLED = os.getenv("CRAWLER_ENABLED", "true").lower() == "true"
69
+ CRAWLER_TIMEOUT = int(os.getenv("CRAWLER_TIMEOUT", "30"))
70
+
71
  SIMULATION_TRIGGER_KEYWORDS = [
72
  item.strip().lower()
73
  for item in os.getenv(
 
97
 
98
  # Validate primary provider configuration
99
  primary = PRIMARY_PROVIDER.lower()
100
+ if primary not in ["huggingface", "openrouter", "ollama", "openai"]:
101
  errors.append(
102
+ f"PRIMARY_PROVIDER '{PRIMARY_PROVIDER}' is not supported. Must be one of: huggingface, openrouter, ollama, openai"
103
+ )
104
+
105
+ if primary == "huggingface" and not HUGGINGFACE_API_KEY:
106
+ warnings.append(
107
+ "PRIMARY_PROVIDER is 'huggingface' but HUGGINGFACE_API_KEY is missing - relying on fallback"
108
  )
109
 
110
  if primary == "openrouter" and not OPENROUTER_API_KEY:
 
124
 
125
  # Validate fallback provider configuration
126
  fallback = FALLBACK_PROVIDER.lower()
127
+ if fallback not in ["huggingface", "openrouter", "ollama", "openai"]:
128
  errors.append(
129
+ f"FALLBACK_PROVIDER '{FALLBACK_PROVIDER}' is not supported. Must be one of: huggingface, openrouter, ollama, openai"
130
+ )
131
+
132
+ if fallback == "huggingface" and not HUGGINGFACE_API_KEY:
133
+ warnings.append(
134
+ "FALLBACK_PROVIDER is 'huggingface' but HUGGINGFACE_API_KEY is missing - fallback will fail"
135
  )
136
 
137
  if fallback == "openrouter" and not OPENROUTER_API_KEY:
 
219
  primary_provider = PRIMARY_PROVIDER
220
  fallback_provider = FALLBACK_PROVIDER
221
 
222
+ huggingface_api_key = HUGGINGFACE_API_KEY
223
+ huggingface_model = HUGGINGFACE_MODEL
224
+
225
+ gemini_api_key = GEMINI_API_KEY
226
+ groq_api_key = GROQ_API_KEY
227
+ cloudflare_account_id = CLOUDFLARE_ACCOUNT_ID
228
+ cloudflare_api_token = CLOUDFLARE_API_TOKEN
229
+
230
  openrouter_api_key = OPENROUTER_API_KEY
231
  openrouter_base_url = OPENROUTER_BASE_URL
232
  openrouter_chat_model = OPENROUTER_CHAT_MODEL
 
249
  mirofish_enabled = MIROFISH_ENABLED
250
  mirofish_api_base = MIROFISH_API_BASE
251
 
252
+ crawler_enabled = CRAWLER_ENABLED
253
+ crawler_timeout = CRAWLER_TIMEOUT
254
+
255
  data_dir = str(DATA_DIR)
256
  memory_dir = str(MEMORY_DIR)
257
  simulation_dir = str(SIMULATION_DIR)
backend/app/domain_packs/registry.py CHANGED
@@ -2,7 +2,7 @@
2
  Domain pack registry for managing and discovering domain packs.
3
  """
4
 
5
- from typing import Dict, List, Optional
6
  import logging
7
 
8
  from app.domain_packs.base import DomainPack
@@ -31,21 +31,21 @@ class DomainPackRegistry:
31
  def detect_domain(self, query: str) -> Optional[str]:
32
  """
33
  Detect which domain pack matches the query based on keywords.
34
-
35
  Args:
36
  query: The user's query
37
-
38
  Returns:
39
  Domain pack name if detected, None otherwise
40
  """
41
  query_lower = query.lower()
42
-
43
  for name, pack in self._packs.items():
44
  for keyword in pack.keywords:
45
  if keyword.lower() in query_lower:
46
  logger.info(f"Detected domain '{name}' from keyword '{keyword}'")
47
  return name
48
-
49
  return None
50
 
51
  def list_packs(self) -> List[str]:
@@ -54,10 +54,7 @@ class DomainPackRegistry:
54
 
55
  def get_capabilities(self) -> Dict[str, Any]:
56
  """Get capabilities of all registered domain packs."""
57
- return {
58
- name: pack.get_capabilities()
59
- for name, pack in self._packs.items()
60
- }
61
 
62
 
63
  # Global registry instance
 
2
  Domain pack registry for managing and discovering domain packs.
3
  """
4
 
5
+ from typing import Dict, List, Optional, Any
6
  import logging
7
 
8
  from app.domain_packs.base import DomainPack
 
31
  def detect_domain(self, query: str) -> Optional[str]:
32
  """
33
  Detect which domain pack matches the query based on keywords.
34
+
35
  Args:
36
  query: The user's query
37
+
38
  Returns:
39
  Domain pack name if detected, None otherwise
40
  """
41
  query_lower = query.lower()
42
+
43
  for name, pack in self._packs.items():
44
  for keyword in pack.keywords:
45
  if keyword.lower() in query_lower:
46
  logger.info(f"Detected domain '{name}' from keyword '{keyword}'")
47
  return name
48
+
49
  return None
50
 
51
  def list_packs(self) -> List[str]:
 
54
 
55
  def get_capabilities(self) -> Dict[str, Any]:
56
  """Get capabilities of all registered domain packs."""
57
+ return {name: pack.get_capabilities() for name, pack in self._packs.items()}
 
 
 
58
 
59
 
60
  # Global registry instance
backend/app/graph.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- MiroOrg v2 Optimized LangGraph pipeline.
3
 
4
  Optimized graph topology (2-3 model calls instead of 5):
5
  [switchboard]
@@ -12,8 +12,7 @@ Optimized graph topology (2-3 model calls instead of 5):
12
 
13
  [END]
14
 
15
- The synthesizer now handles planning, verification, and synthesis in one call.
16
- This reduces latency from 5 model calls (3-6 min) to 2-3 calls (1-2 min).
17
  """
18
 
19
  import uuid
@@ -29,26 +28,16 @@ from app.agents import mirofish_node, finance_node
29
  logger = logging.getLogger(__name__)
30
 
31
 
32
- # ── State Type ────────────────────────────────────────────────────────────────
33
-
34
-
35
  class AgentState(TypedDict, total=False):
36
- # Input
37
  user_input: str
38
  case_id: str
39
-
40
- # Pipeline state
41
- route: dict # switchboard output
42
- simulation: dict # mirofish output (optional)
43
- finance: dict # finance_node output (optional)
44
- research: dict # research output
45
- final: dict # synthesizer output
46
-
47
- # Control
48
  errors: list
49
-
50
-
51
- # ── Node wrappers with timing ────────────────────────────────────────────────
52
 
53
 
54
  def switchboard_node(state: AgentState) -> dict:
@@ -93,11 +82,7 @@ def synthesizer_node(state: AgentState) -> dict:
93
  return result
94
 
95
 
96
- # ── Routing functions ─────────────────────────────────────────────────────────
97
-
98
-
99
  def after_switchboard(state: AgentState) -> str:
100
- """Route based on switchboard flags."""
101
  route = state.get("route", {})
102
  if route.get("requires_simulation"):
103
  return "mirofish"
@@ -106,9 +91,6 @@ def after_switchboard(state: AgentState) -> str:
106
  return "research"
107
 
108
 
109
- # ── Build graph ───────────────────────────────────────────────────────────────
110
-
111
-
112
  def build_graph():
113
  g = StateGraph(AgentState)
114
 
@@ -120,20 +102,15 @@ def build_graph():
120
 
121
  g.set_entry_point("switchboard")
122
 
123
- # After switchboard: fork based on flags
124
  g.add_conditional_edges(
125
  "switchboard",
126
  after_switchboard,
127
  {"mirofish": "mirofish", "finance": "finance", "research": "research"},
128
  )
129
 
130
- # mirofish and finance both merge into research
131
  g.add_edge("mirofish", "research")
132
  g.add_edge("finance", "research")
133
-
134
- # Research goes directly to synthesizer (no planner/verifier loop)
135
  g.add_edge("research", "synthesizer")
136
-
137
  g.add_edge("synthesizer", END)
138
  return g.compile()
139
 
@@ -141,22 +118,24 @@ def build_graph():
141
  compiled_graph = build_graph()
142
 
143
 
144
- def run_case(user_input: str) -> dict:
145
  """Run the optimized agent pipeline on user input."""
146
  case_id = str(uuid.uuid4())
147
  t0 = time.perf_counter()
148
  logger.info("Starting case %s", case_id)
149
 
150
- result = compiled_graph.invoke(
151
- {
152
- "case_id": case_id,
153
- "user_input": user_input,
154
- "route": {},
155
- "research": {},
156
- "final": {},
157
- "errors": [],
158
- }
159
- )
 
 
160
 
161
  elapsed = time.perf_counter() - t0
162
  logger.info("Case %s completed in %.2fs", case_id, elapsed)
 
1
  """
2
+ Janus — LangGraph pipeline.
3
 
4
  Optimized graph topology (2-3 model calls instead of 5):
5
  [switchboard]
 
12
 
13
  [END]
14
 
15
+ Context from the context engine is injected into every LLM call.
 
16
  """
17
 
18
  import uuid
 
28
  logger = logging.getLogger(__name__)
29
 
30
 
 
 
 
31
  class AgentState(TypedDict, total=False):
 
32
  user_input: str
33
  case_id: str
34
+ route: dict
35
+ simulation: dict
36
+ finance: dict
37
+ research: dict
38
+ final: dict
 
 
 
 
39
  errors: list
40
+ context: dict
 
 
41
 
42
 
43
  def switchboard_node(state: AgentState) -> dict:
 
82
  return result
83
 
84
 
 
 
 
85
  def after_switchboard(state: AgentState) -> str:
 
86
  route = state.get("route", {})
87
  if route.get("requires_simulation"):
88
  return "mirofish"
 
91
  return "research"
92
 
93
 
 
 
 
94
  def build_graph():
95
  g = StateGraph(AgentState)
96
 
 
102
 
103
  g.set_entry_point("switchboard")
104
 
 
105
  g.add_conditional_edges(
106
  "switchboard",
107
  after_switchboard,
108
  {"mirofish": "mirofish", "finance": "finance", "research": "research"},
109
  )
110
 
 
111
  g.add_edge("mirofish", "research")
112
  g.add_edge("finance", "research")
 
 
113
  g.add_edge("research", "synthesizer")
 
114
  g.add_edge("synthesizer", END)
115
  return g.compile()
116
 
 
118
  compiled_graph = build_graph()
119
 
120
 
121
+ def run_case(user_input: str, context: dict = None) -> dict:
122
  """Run the optimized agent pipeline on user input."""
123
  case_id = str(uuid.uuid4())
124
  t0 = time.perf_counter()
125
  logger.info("Starting case %s", case_id)
126
 
127
+ initial_state = {
128
+ "case_id": case_id,
129
+ "user_input": user_input,
130
+ "route": {},
131
+ "research": {},
132
+ "final": {},
133
+ "errors": [],
134
+ }
135
+ if context:
136
+ initial_state["context"] = context
137
+
138
+ result = compiled_graph.invoke(initial_state)
139
 
140
  elapsed = time.perf_counter() - t0
141
  logger.info("Case %s completed in %.2fs", case_id, elapsed)
backend/app/main.py CHANGED
@@ -29,6 +29,8 @@ from app.services.daemon import JanusDaemon
29
  from app.services.adaptive_pipeline import adaptive_pipeline
30
  from app.services.circadian_rhythm import CircadianRhythm
31
  from app.services.dream_processor import DreamCycleProcessor
 
 
32
  from app.routers.simulation import router as simulation_router
33
  from app.routers.learning import (
34
  router as learning_router,
@@ -42,7 +44,7 @@ from app.config import get_config
42
  logging.basicConfig(level=logging.INFO)
43
  logger = logging.getLogger(__name__)
44
 
45
- app = FastAPI(title="MiroOrg v2", version=APP_VERSION)
46
 
47
  # Initialize domain packs
48
  from app.domain_packs.init_packs import init_domain_packs
@@ -133,6 +135,21 @@ async def on_startup():
133
  except Exception as e:
134
  logger.error(f"Failed to start sentinel scheduler: {e}")
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
  # ── Health ────────────────────────────────────────────────────────────────────
138
 
@@ -147,10 +164,28 @@ def health_deep():
147
  return deep_health()
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  @app.get("/config/status")
151
  def config_status():
152
  return {
153
  "app_version": APP_VERSION,
 
 
154
  "openrouter_key_present": bool(os.getenv("OPENROUTER_API_KEY")),
155
  "ollama_base_url": os.getenv("OLLAMA_BASE_URL", "http://localhost:11434"),
156
  "ollama_model": os.getenv("OLLAMA_MODEL", "llama3.2"),
@@ -256,19 +291,32 @@ def run_org(task: UserTask):
256
  user_input = task.user_input
257
  logger.info("Processing /run: %s", user_input[:100])
258
 
259
- # Step 1: Classify the query
 
 
 
 
 
 
 
 
 
 
260
  query_type, query_confidence, query_meta = query_classifier.classify(user_input)
261
  domain = query_meta.get("detected_domain", "general")
262
  logger.info(
263
  f"Query classified: type={query_type.value}, domain={domain}, confidence={query_confidence:.2f}"
264
  )
265
 
266
- # Step 2: Try cache first
267
  cached = cache_manager.get(user_input)
268
  if cached:
269
  logger.info(
270
  f"Cache HIT ({cached['cache_age_hours']:.1f}h old, {cached['hit_count']} hits)"
271
  )
 
 
 
272
  return {
273
  "case_id": "",
274
  "user_input": user_input,
@@ -283,13 +331,13 @@ def run_org(task: UserTask):
283
  "outputs": [],
284
  }
285
 
286
- # Step 3: Get adaptive intelligence context
287
  ai_context = adaptive_intelligence.get_context_for_query(user_input, domain)
288
  logger.info(
289
  f"Adaptive context: {ai_context['total_cases_learned']} cases learned, personality_depth={ai_context['system_personality']['analytical_depth']:.2f}"
290
  )
291
 
292
- # Step 4: Run the full pipeline
293
  start = time.perf_counter()
294
  result = run_case(user_input)
295
  elapsed = time.perf_counter() - start
@@ -316,7 +364,7 @@ def run_org(task: UserTask):
316
  }
317
  save_case(result.get("case_id", ""), payload)
318
 
319
- # Step 5: Cache the result
320
  cache_manager.put(
321
  query=user_input,
322
  answer=final_answer,
@@ -331,7 +379,7 @@ def run_org(task: UserTask):
331
  )
332
  logger.info(f"Result cached: type={query_type.value}, domain={domain}")
333
 
334
- # Step 5b: Store in memory graph
335
  case_id = result.get("case_id", "")
336
  memory_graph.add_query(
337
  query_id=case_id,
@@ -353,13 +401,21 @@ def run_org(task: UserTask):
353
  memory_graph.add_entity(entity_id, cleaned, "entity")
354
  memory_graph.link_query_entity(case_id, entity_id, 0.5)
355
 
356
- # Step 6: Learn from this case (adaptive intelligence)
357
  adaptive_intelligence.learn_from_case(payload, elapsed)
358
  logger.info(
359
  f"Adaptive intelligence updated: total_cases={adaptive_intelligence.total_cases}"
360
  )
361
 
362
- # Step 7: Fire-and-forget: traditional learning
 
 
 
 
 
 
 
 
363
  should_learn, learn_reason = learning_filter.should_learn(
364
  query_type=query_type,
365
  query=user_input,
 
29
  from app.services.adaptive_pipeline import adaptive_pipeline
30
  from app.services.circadian_rhythm import CircadianRhythm
31
  from app.services.dream_processor import DreamCycleProcessor
32
+ from app.services.context_engine import context_engine
33
+ from app.services.reflex_layer import reflex_layer
34
  from app.routers.simulation import router as simulation_router
35
  from app.routers.learning import (
36
  router as learning_router,
 
44
  logging.basicConfig(level=logging.INFO)
45
  logger = logging.getLogger(__name__)
46
 
47
+ app = FastAPI(title="Janus", version=APP_VERSION)
48
 
49
  # Initialize domain packs
50
  from app.domain_packs.init_packs import init_domain_packs
 
135
  except Exception as e:
136
  logger.error(f"Failed to start sentinel scheduler: {e}")
137
 
138
+ # Start Janus daemon in background thread
139
+ daemon_enabled = os.getenv("DAEMON_ENABLED", "true").lower() == "true"
140
+ if daemon_enabled:
141
+ try:
142
+ import threading
143
+ from app.services.daemon import janus_daemon
144
+
145
+ daemon_thread = threading.Thread(
146
+ target=janus_daemon.run, daemon=True, name="janus-daemon"
147
+ )
148
+ daemon_thread.start()
149
+ logger.info("Janus daemon started in background thread")
150
+ except Exception as e:
151
+ logger.error(f"Failed to start Janus daemon: {e}")
152
+
153
 
154
  # ── Health ────────────────────────────────────────────────────────────────────
155
 
 
164
  return deep_health()
165
 
166
 
167
+ @app.get("/context")
168
+ def get_context():
169
+ """Get the current system context."""
170
+ return context_engine.build_context("")
171
+
172
+
173
+ @app.get("/pending-thoughts")
174
+ def get_pending_thoughts():
175
+ """Get pending thoughts the system wants to share."""
176
+ thoughts = context_engine.get_pending_thoughts()
177
+ return {
178
+ "pending_thoughts": thoughts,
179
+ "count": len(thoughts),
180
+ }
181
+
182
+
183
  @app.get("/config/status")
184
  def config_status():
185
  return {
186
  "app_version": APP_VERSION,
187
+ "groq_key_present": bool(os.getenv("GROQ_API_KEY")),
188
+ "gemini_key_present": bool(os.getenv("GEMINI_API_KEY")),
189
  "openrouter_key_present": bool(os.getenv("OPENROUTER_API_KEY")),
190
  "ollama_base_url": os.getenv("OLLAMA_BASE_URL", "http://localhost:11434"),
191
  "ollama_model": os.getenv("OLLAMA_MODEL", "llama3.2"),
 
291
  user_input = task.user_input
292
  logger.info("Processing /run: %s", user_input[:100])
293
 
294
+ # Step 1: Try reflex layer first (instant, contextual, no model call)
295
+ context = context_engine.build_context(user_input)
296
+ reflex = reflex_layer.respond(user_input, context)
297
+ if reflex:
298
+ logger.info("Reflex layer responded instantly")
299
+ context_engine.update_after_interaction(
300
+ user_input, reflex.get("final_answer", ""), context
301
+ )
302
+ return reflex
303
+
304
+ # Step 2: Classify the query
305
  query_type, query_confidence, query_meta = query_classifier.classify(user_input)
306
  domain = query_meta.get("detected_domain", "general")
307
  logger.info(
308
  f"Query classified: type={query_type.value}, domain={domain}, confidence={query_confidence:.2f}"
309
  )
310
 
311
+ # Step 3: Try cache first
312
  cached = cache_manager.get(user_input)
313
  if cached:
314
  logger.info(
315
  f"Cache HIT ({cached['cache_age_hours']:.1f}h old, {cached['hit_count']} hits)"
316
  )
317
+ context_engine.update_after_interaction(
318
+ user_input, cached["answer"], context
319
+ )
320
  return {
321
  "case_id": "",
322
  "user_input": user_input,
 
331
  "outputs": [],
332
  }
333
 
334
+ # Step 4: Get adaptive intelligence context
335
  ai_context = adaptive_intelligence.get_context_for_query(user_input, domain)
336
  logger.info(
337
  f"Adaptive context: {ai_context['total_cases_learned']} cases learned, personality_depth={ai_context['system_personality']['analytical_depth']:.2f}"
338
  )
339
 
340
+ # Step 5: Run the full pipeline
341
  start = time.perf_counter()
342
  result = run_case(user_input)
343
  elapsed = time.perf_counter() - start
 
364
  }
365
  save_case(result.get("case_id", ""), payload)
366
 
367
+ # Step 6: Cache the result
368
  cache_manager.put(
369
  query=user_input,
370
  answer=final_answer,
 
379
  )
380
  logger.info(f"Result cached: type={query_type.value}, domain={domain}")
381
 
382
+ # Step 6b: Store in memory graph
383
  case_id = result.get("case_id", "")
384
  memory_graph.add_query(
385
  query_id=case_id,
 
401
  memory_graph.add_entity(entity_id, cleaned, "entity")
402
  memory_graph.link_query_entity(case_id, entity_id, 0.5)
403
 
404
+ # Step 7: Learn from this case (adaptive intelligence)
405
  adaptive_intelligence.learn_from_case(payload, elapsed)
406
  logger.info(
407
  f"Adaptive intelligence updated: total_cases={adaptive_intelligence.total_cases}"
408
  )
409
 
410
+ # Update context engine
411
+ context_engine.update_after_interaction(user_input, final_answer, context)
412
+ context_engine.record_performance(
413
+ success=bool(final_answer),
414
+ confidence=final.get("confidence", 0.5),
415
+ elapsed=elapsed,
416
+ )
417
+
418
+ # Step 8: Fire-and-forget: traditional learning
419
  should_learn, learn_reason = learning_filter.should_learn(
420
  query_type=query_type,
421
  query=user_input,
backend/app/services/context_engine.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Context Engine for Janus.
3
+
4
+ Builds rich context for every interaction — the system's "mind".
5
+ No emotion labels, no rules. Just facts about:
6
+ - What the system knows about itself
7
+ - What it knows about the user
8
+ - What the daemon has been discovering
9
+ - The current environment (time, etc.)
10
+
11
+ This context makes responses feel natural without any explicit emotion tracking.
12
+ """
13
+
14
+ import json
15
+ import time
16
+ import logging
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Dict, Any, Optional, List
20
+
21
+ from app.config import DATA_DIR
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ CONTEXT_DIR = DATA_DIR / "context"
26
+ CONTEXT_DIR.mkdir(parents=True, exist_ok=True)
27
+
28
+ USER_STATE_FILE = CONTEXT_DIR / "user_state.json"
29
+ SYSTEM_STATE_FILE = CONTEXT_DIR / "system_state.json"
30
+
31
+
32
+ class ContextEngine:
33
+ """Builds and maintains rich contextual state for every interaction."""
34
+
35
+ def __init__(self):
36
+ self._user_state = self._load_user_state()
37
+ self._system_state = self._load_system_state()
38
+ self._start_time = time.time()
39
+
40
+ def _load_user_state(self) -> Dict:
41
+ if USER_STATE_FILE.exists():
42
+ try:
43
+ with open(USER_STATE_FILE) as f:
44
+ return json.load(f)
45
+ except Exception:
46
+ pass
47
+ return {
48
+ "conversations": [],
49
+ "last_interaction": None,
50
+ "last_topic": None,
51
+ "interests": {},
52
+ "total_interactions": 0,
53
+ }
54
+
55
+ def _save_user_state(self):
56
+ try:
57
+ with open(USER_STATE_FILE, "w") as f:
58
+ json.dump(self._user_state, f, indent=2)
59
+ except Exception as e:
60
+ logger.error(f"Failed to save user state: {e}")
61
+
62
+ def _load_system_state(self) -> Dict:
63
+ if SYSTEM_STATE_FILE.exists():
64
+ try:
65
+ with open(SYSTEM_STATE_FILE) as f:
66
+ return json.load(f)
67
+ except Exception:
68
+ pass
69
+ return {
70
+ "pending_thoughts": [],
71
+ "recent_discoveries": [],
72
+ "performance_history": [],
73
+ "capabilities": [
74
+ "market analysis and financial research",
75
+ "scenario simulation and what-if analysis",
76
+ "web research and content extraction",
77
+ "pattern recognition across data",
78
+ "adaptive learning from past conversations",
79
+ ],
80
+ "weaknesses": [],
81
+ }
82
+
83
+ def _save_system_state(self):
84
+ try:
85
+ with open(SYSTEM_STATE_FILE, "w") as f:
86
+ json.dump(self._system_state, f, indent=2)
87
+ except Exception as e:
88
+ logger.error(f"Failed to save system state: {e}")
89
+
90
+ def build_context(
91
+ self, user_input: str, user_id: str = "default"
92
+ ) -> Dict[str, Any]:
93
+ """Build the full context picture for an interaction."""
94
+ now = time.time()
95
+ last_interaction = self._user_state.get("last_interaction")
96
+ time_away = (
97
+ self._format_time_away(last_interaction, now) if last_interaction else None
98
+ )
99
+
100
+ return {
101
+ "system_self": {
102
+ "capabilities": self._system_state.get("capabilities", []),
103
+ "weaknesses": self._system_state.get("weaknesses", []),
104
+ "pending_thoughts": self._system_state.get("pending_thoughts", [])[:5],
105
+ "recent_discoveries": self._system_state.get("recent_discoveries", [])[
106
+ :3
107
+ ],
108
+ "uptime": self._get_uptime(),
109
+ "total_cases_analyzed": self._user_state.get("total_interactions", 0),
110
+ },
111
+ "user": {
112
+ "last_topic": self._user_state.get("last_topic"),
113
+ "time_away": time_away,
114
+ "recurring_interests": self._get_top_interests(),
115
+ "conversation_count": self._user_state.get("total_interactions", 0),
116
+ "is_returning": last_interaction is not None,
117
+ },
118
+ "daemon": self._get_daemon_context(),
119
+ "environment": {
120
+ "time_of_day": self._get_time_context(),
121
+ "day_of_week": datetime.now().strftime("%A"),
122
+ "timestamp": datetime.now(timezone.utc).isoformat(),
123
+ },
124
+ }
125
+
126
+ def update_after_interaction(self, user_input: str, response: str, context: Dict):
127
+ """Update state after a successful interaction."""
128
+ now = time.time()
129
+
130
+ self._user_state["last_interaction"] = now
131
+ self._user_state["last_topic"] = self._extract_topic(user_input)
132
+ self._user_state["total_interactions"] = (
133
+ self._user_state.get("total_interactions", 0) + 1
134
+ )
135
+
136
+ topic = self._extract_topic(user_input)
137
+ if topic:
138
+ interests = self._user_state.get("interests", {})
139
+ interests[topic] = interests.get(topic, 0) + 1
140
+ self._user_state["interests"] = interests
141
+
142
+ self._user_state["conversations"].append(
143
+ {
144
+ "input": user_input[:200],
145
+ "response_preview": response[:200],
146
+ "timestamp": now,
147
+ }
148
+ )
149
+ self._user_state["conversations"] = self._user_state["conversations"][-50:]
150
+
151
+ self._save_user_state()
152
+
153
+ def add_pending_thought(self, thought: str, priority: float = 0.5):
154
+ """Add a thought the system wants to share."""
155
+ self._system_state["pending_thoughts"].append(
156
+ {
157
+ "thought": thought,
158
+ "priority": priority,
159
+ "created_at": time.time(),
160
+ }
161
+ )
162
+ self._system_state["pending_thoughts"] = sorted(
163
+ self._system_state["pending_thoughts"],
164
+ key=lambda x: x["priority"],
165
+ reverse=True,
166
+ )[:20]
167
+ self._save_system_state()
168
+
169
+ def add_discovery(self, discovery: str, source: str = "daemon"):
170
+ """Record a discovery from the daemon or research."""
171
+ self._system_state["recent_discoveries"].append(
172
+ {
173
+ "discovery": discovery,
174
+ "source": source,
175
+ "created_at": time.time(),
176
+ }
177
+ )
178
+ self._system_state["recent_discoveries"] = self._system_state[
179
+ "recent_discoveries"
180
+ ][-30:]
181
+ self._save_system_state()
182
+
183
+ def record_performance(self, success: bool, confidence: float, elapsed: float):
184
+ """Track how well the system performed."""
185
+ self._system_state["performance_history"].append(
186
+ {
187
+ "success": success,
188
+ "confidence": confidence,
189
+ "elapsed": elapsed,
190
+ "timestamp": time.time(),
191
+ }
192
+ )
193
+ self._system_state["performance_history"] = self._system_state[
194
+ "performance_history"
195
+ ][-100:]
196
+ self._save_system_state()
197
+
198
+ def get_pending_thoughts(self) -> List[Dict]:
199
+ """Get pending thoughts the system wants to share."""
200
+ return self._system_state.get("pending_thoughts", [])[:5]
201
+
202
+ def consume_pending_thought(self, thought_text: str):
203
+ """Remove a thought after it's been shared."""
204
+ thoughts = self._system_state.get("pending_thoughts", [])
205
+ self._system_state["pending_thoughts"] = [
206
+ t for t in thoughts if t.get("thought") != thought_text
207
+ ]
208
+ self._save_system_state()
209
+
210
+ def _get_top_interests(self) -> List[str]:
211
+ interests = self._user_state.get("interests", {})
212
+ return sorted(interests.keys(), key=lambda x: interests[x], reverse=True)[:5]
213
+
214
+ def _get_daemon_context(self) -> Dict:
215
+ """Get context from the daemon's recent activity."""
216
+ try:
217
+ from app.services.daemon import janus_daemon
218
+
219
+ status = janus_daemon.get_status()
220
+ return {
221
+ "running": status.get("running", False),
222
+ "cycle_count": status.get("cycle_count", 0),
223
+ "circadian_phase": status.get("circadian", {}).get(
224
+ "phase_name", "unknown"
225
+ ),
226
+ "signal_queue": status.get("signal_queue", {}),
227
+ "watchlist": status.get("watchlist", []),
228
+ }
229
+ except Exception:
230
+ return {"running": False}
231
+
232
+ def _get_uptime(self) -> str:
233
+ elapsed = time.time() - self._start_time
234
+ if elapsed < 60:
235
+ return "just started"
236
+ elif elapsed < 3600:
237
+ return f"{int(elapsed // 60)} minutes"
238
+ elif elapsed < 86400:
239
+ return f"{int(elapsed // 3600)} hours"
240
+ else:
241
+ return f"{int(elapsed // 86400)} days"
242
+
243
+ def _get_time_context(self) -> str:
244
+ hour = datetime.now().hour
245
+ if 5 <= hour < 12:
246
+ return "morning"
247
+ elif 12 <= hour < 17:
248
+ return "afternoon"
249
+ elif 17 <= hour < 21:
250
+ return "evening"
251
+ else:
252
+ return "late night"
253
+
254
+ def _format_time_away(self, last_ts: float, now: float) -> Optional[str]:
255
+ diff = now - last_ts
256
+ if diff < 60:
257
+ return None
258
+ elif diff < 3600:
259
+ mins = int(diff // 60)
260
+ return f"{mins} minute{'s' if mins != 1 else ''}"
261
+ elif diff < 86400:
262
+ hours = int(diff // 3600)
263
+ return f"{hours} hour{'s' if hours != 1 else ''}"
264
+ else:
265
+ days = int(diff // 86400)
266
+ return f"{days} day{'s' if days != 1 else ''}"
267
+
268
+ def _extract_topic(self, text: str) -> Optional[str]:
269
+ words = text.lower().split()
270
+ if len(words) < 3:
271
+ return None
272
+ return " ".join(words[:3])
273
+
274
+
275
+ context_engine = ContextEngine()
backend/app/services/crawler/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Custom web crawler for Janus.
3
+
4
+ Replaces external API dependencies (Tavily, NewsAPI) with self-hosted crawling.
5
+ Built on Playwright for browser automation, with stealth and noise removal.
6
+ """
7
+
8
+ from app.services.crawler.core import JanusCrawler
9
+
10
+ __all__ = ["JanusCrawler"]
backend/app/services/crawler/browser.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Playwright browser management with stealth for Janus crawler.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ import random
8
+ from typing import Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ USER_AGENTS = [
13
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
14
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
15
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
16
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
17
+ ]
18
+
19
+ STEALTH_SCRIPTS = [
20
+ """
21
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
22
+ """,
23
+ """
24
+ window.chrome = { runtime: {} };
25
+ """,
26
+ """
27
+ Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
28
+ """,
29
+ """
30
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
31
+ """,
32
+ ]
33
+
34
+
35
+ class BrowserManager:
36
+ """Manages Playwright browser lifecycle with stealth."""
37
+
38
+ def __init__(self):
39
+ self._browser = None
40
+ self._context = None
41
+ self._playwright = None
42
+
43
+ async def __aenter__(self):
44
+ await self.launch()
45
+ return self
46
+
47
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
48
+ await self.close()
49
+
50
+ async def launch(self):
51
+ """Launch browser with stealth configuration."""
52
+ try:
53
+ from playwright.async_api import async_playwright
54
+ except ImportError:
55
+ logger.error(
56
+ "Playwright not installed. Run: pip install playwright && playwright install"
57
+ )
58
+ raise
59
+
60
+ self._playwright = await async_playwright().start()
61
+ self._browser = await self._playwright.chromium.launch(
62
+ headless=True,
63
+ args=[
64
+ "--disable-blink-features=AutomationControlled",
65
+ "--no-sandbox",
66
+ "--disable-dev-shm-usage",
67
+ ],
68
+ )
69
+
70
+ ua = random.choice(USER_AGENTS)
71
+ self._context = await self._browser.new_context(
72
+ user_agent=ua,
73
+ viewport={"width": 1920, "height": 1080},
74
+ locale="en-US",
75
+ )
76
+
77
+ for script in STEALTH_SCRIPTS:
78
+ await self._context.add_init_script(script)
79
+
80
+ logger.debug("Browser launched with stealth")
81
+
82
+ async def get_page(self):
83
+ """Get a new page from the browser context."""
84
+ if not self._context:
85
+ await self.launch()
86
+ return await self._context.new_page()
87
+
88
+ async def close(self):
89
+ """Clean up browser resources."""
90
+ try:
91
+ if self._context:
92
+ await self._context.close()
93
+ if self._browser:
94
+ await self._browser.close()
95
+ if self._playwright:
96
+ await self._playwright.stop()
97
+ logger.debug("Browser closed")
98
+ except Exception as e:
99
+ logger.warning(f"Error closing browser: {e}")
backend/app/services/crawler/core.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core crawler logic for Janus.
3
+ Navigate, wait, capture, retry — async-first.
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ from typing import Dict, Any, Optional, List
9
+ from dataclasses import dataclass, field
10
+
11
+ from app.services.crawler.browser import BrowserManager
12
+ from app.services.crawler.processor import ContentProcessor
13
+ from app.config import CRAWLER_TIMEOUT
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class CrawlResult:
20
+ url: str
21
+ success: bool
22
+ markdown: str = ""
23
+ title: str = ""
24
+ links: List[str] = field(default_factory=list)
25
+ metadata: Dict[str, Any] = field(default_factory=dict)
26
+ error: Optional[str] = None
27
+
28
+
29
+ class JanusCrawler:
30
+ """
31
+ Self-hosted web crawler — no API keys, no rate limits.
32
+ Uses Playwright for browser automation with stealth.
33
+ """
34
+
35
+ def __init__(self, timeout: int = CRAWLER_TIMEOUT):
36
+ self.timeout = timeout
37
+ self._processor = ContentProcessor()
38
+
39
+ async def crawl(self, url: str) -> CrawlResult:
40
+ """Crawl a single URL and return clean content."""
41
+ async with BrowserManager() as browser:
42
+ page = await browser.get_page()
43
+
44
+ try:
45
+ response = await page.goto(
46
+ url,
47
+ wait_until="domcontentloaded",
48
+ timeout=self.timeout * 1000,
49
+ )
50
+
51
+ if response and response.status >= 400:
52
+ return CrawlResult(
53
+ url=url,
54
+ success=False,
55
+ error=f"HTTP {response.status}",
56
+ )
57
+
58
+ await page.wait_for_load_state("networkidle", timeout=10000)
59
+
60
+ html = await page.content()
61
+ title = await page.title()
62
+
63
+ markdown = self._processor.process(html)
64
+ links = self._processor.extract_links(html, url)
65
+ metadata = self._processor.extract_metadata(html, title)
66
+
67
+ return CrawlResult(
68
+ url=url,
69
+ success=True,
70
+ markdown=markdown,
71
+ title=title,
72
+ links=links,
73
+ metadata=metadata,
74
+ )
75
+
76
+ except asyncio.TimeoutError:
77
+ logger.warning(f"Crawl timeout: {url}")
78
+ return CrawlResult(url=url, success=False, error="Timeout")
79
+ except Exception as e:
80
+ logger.warning(f"Crawl failed: {url} — {e}")
81
+ return CrawlResult(url=url, success=False, error=str(e)[:200])
82
+
83
+ async def crawl_batch(
84
+ self, urls: List[str], max_concurrent: int = 3
85
+ ) -> List[CrawlResult]:
86
+ """Crawl multiple URLs concurrently."""
87
+ semaphore = asyncio.Semaphore(max_concurrent)
88
+
89
+ async def _crawl_one(url: str) -> CrawlResult:
90
+ async with semaphore:
91
+ return await self.crawl(url)
92
+
93
+ tasks = [_crawl_one(url) for url in urls]
94
+ results = await asyncio.gather(*tasks, return_exceptions=True)
95
+
96
+ return [
97
+ r
98
+ if isinstance(r, CrawlResult)
99
+ else CrawlResult(url="", success=False, error=str(r))
100
+ for r in results
101
+ ]
102
+
103
+ def crawl_sync(self, url: str) -> CrawlResult:
104
+ """Synchronous wrapper for use in non-async contexts."""
105
+ try:
106
+ loop = asyncio.get_event_loop()
107
+ if loop.is_running():
108
+ import concurrent.futures
109
+
110
+ with concurrent.futures.ThreadPoolExecutor() as executor:
111
+ future = executor.submit(asyncio.run, self.crawl(url))
112
+ return future.result()
113
+ return loop.run_until_complete(self.crawl(url))
114
+ except RuntimeError:
115
+ return asyncio.run(self.crawl(url))
backend/app/services/crawler/processor.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HTML → Markdown conversion with noise removal for Janus crawler.
3
+ Strips ads, nav, footer, scripts, styles — keeps the content.
4
+ """
5
+
6
+ import re
7
+ import logging
8
+ from typing import List, Dict, Any
9
+ from urllib.parse import urljoin
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ NOISE_SELECTORS = [
14
+ "script",
15
+ "style",
16
+ "noscript",
17
+ "iframe",
18
+ "svg",
19
+ "canvas",
20
+ "nav",
21
+ "footer",
22
+ "header",
23
+ "aside",
24
+ ".ad",
25
+ ".ads",
26
+ ".advertisement",
27
+ ".ad-container",
28
+ ".sidebar",
29
+ ".navigation",
30
+ ".menu",
31
+ ".breadcrumb",
32
+ ".cookie",
33
+ ".cookie-banner",
34
+ ".gdpr",
35
+ ".social-share",
36
+ ".share-buttons",
37
+ ".comments",
38
+ ".comment-section",
39
+ ".newsletter",
40
+ ".subscribe",
41
+ ".popup",
42
+ ".modal",
43
+ ".overlay",
44
+ "#ad",
45
+ "#ads",
46
+ "#sidebar",
47
+ "#navigation",
48
+ "#footer",
49
+ ".footer",
50
+ ".header",
51
+ ]
52
+
53
+ CONTENT_SELECTORS = [
54
+ "article",
55
+ "main",
56
+ ".content",
57
+ ".article",
58
+ ".post",
59
+ ".entry",
60
+ "#content",
61
+ "#main",
62
+ "#article",
63
+ ".post-content",
64
+ ".article-body",
65
+ ".story-body",
66
+ ]
67
+
68
+
69
+ class ContentProcessor:
70
+ """Convert HTML to clean Markdown with noise removal."""
71
+
72
+ def process(self, html: str) -> str:
73
+ """Convert HTML to clean Markdown."""
74
+ if not html:
75
+ return ""
76
+
77
+ html = self._strip_noise(html)
78
+ markdown = self._html_to_markdown(html)
79
+ markdown = self._clean_markdown(markdown)
80
+ return markdown
81
+
82
+ def _strip_noise(self, html: str) -> str:
83
+ """Remove noise elements from HTML."""
84
+ for selector in NOISE_SELECTORS:
85
+ if selector.startswith("."):
86
+ pattern = rf'<[^>]*class="[^"]*{re.escape(selector[1:])}[^"]*"[^>]*>.*?</[^>]+>'
87
+ html = re.sub(pattern, "", html, flags=re.DOTALL | re.IGNORECASE)
88
+ elif selector.startswith("#"):
89
+ pattern = (
90
+ rf'<[^>]*id="[^"]*{re.escape(selector[1:])}[^"]*"[^>]*>.*?</[^>]+>'
91
+ )
92
+ html = re.sub(pattern, "", html, flags=re.DOTALL | re.IGNORECASE)
93
+ else:
94
+ pattern = rf"<{selector}[^>]*>.*?</{selector}>"
95
+ html = re.sub(pattern, "", html, flags=re.DOTALL | re.IGNORECASE)
96
+
97
+ html = re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
98
+ html = re.sub(r"\s+", " ", html)
99
+ return html
100
+
101
+ def _html_to_markdown(self, html: str) -> str:
102
+ """Convert HTML to Markdown."""
103
+ try:
104
+ from markdownify import markdownify as md
105
+
106
+ return md(html, heading_style="ATX", bullets="-", strip=["img"])
107
+ except ImportError:
108
+ return self._fallback_html_to_text(html)
109
+
110
+ def _fallback_html_to_text(self, html: str) -> str:
111
+ """Fallback HTML to text conversion without markdownify."""
112
+ text = re.sub(r"<br\s*/?>", "\n", html)
113
+ text = re.sub(r"</(?:p|div|h[1-6]|li|tr)>", "\n\n", text, flags=re.IGNORECASE)
114
+ text = re.sub(
115
+ r"<h([1-6])[^>]*>",
116
+ lambda m: f"\n\n{'#' * int(m.group(1))} ",
117
+ text,
118
+ flags=re.IGNORECASE,
119
+ )
120
+ text = re.sub(r"</?(?:b|strong)>", "**", text, flags=re.IGNORECASE)
121
+ text = re.sub(r"</?(?:i|em)>", "*", text, flags=re.IGNORECASE)
122
+ text = re.sub(
123
+ r'<a[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
124
+ r"\2 (\1)",
125
+ text,
126
+ flags=re.IGNORECASE,
127
+ )
128
+ text = re.sub(r"<[^>]+>", "", text)
129
+ text = re.sub(r"&nbsp;", " ", text)
130
+ text = re.sub(r"&amp;", "&", text)
131
+ text = re.sub(r"&lt;", "<", text)
132
+ text = re.sub(r"&gt;", ">", text)
133
+ text = re.sub(r"&quot;", '"', text)
134
+ text = re.sub(r"&#39;", "'", text)
135
+ text = re.sub(r"\n{3,}", "\n\n", text)
136
+ return text.strip()
137
+
138
+ def _clean_markdown(self, markdown: str) -> str:
139
+ """Clean up the markdown output."""
140
+ lines = markdown.split("\n")
141
+ cleaned = []
142
+ prev_blank = False
143
+
144
+ for line in lines:
145
+ line = line.strip()
146
+ if not line:
147
+ if not prev_blank:
148
+ cleaned.append("")
149
+ prev_blank = True
150
+ else:
151
+ cleaned.append(line)
152
+ prev_blank = False
153
+
154
+ result = "\n".join(cleaned).strip()
155
+ if len(result) > 50000:
156
+ result = (
157
+ result[:50000]
158
+ + "\n\n[Content truncated — too long for full extraction]"
159
+ )
160
+ return result
161
+
162
+ def extract_links(self, html: str, base_url: str = "") -> List[str]:
163
+ """Extract all links from HTML."""
164
+ links = []
165
+ pattern = r'<a[^>]*href=["\']([^"\']+)["\'][^>]*>'
166
+ for match in re.finditer(pattern, html, re.IGNORECASE):
167
+ url = match.group(1)
168
+ if url and not url.startswith(("#", "javascript:", "mailto:")):
169
+ if base_url and not url.startswith(("http://", "https://")):
170
+ url = urljoin(base_url, url)
171
+ links.append(url)
172
+ return list(set(links))
173
+
174
+ def extract_metadata(self, html: str, title: str = "") -> Dict[str, Any]:
175
+ """Extract metadata from HTML."""
176
+ metadata = {}
177
+
178
+ if title:
179
+ metadata["title"] = title
180
+
181
+ og_title = re.search(
182
+ r'<meta[^>]*property="og:title"[^>]*content="([^"]*)"', html, re.IGNORECASE
183
+ )
184
+ if og_title:
185
+ metadata["og_title"] = og_title.group(1)
186
+
187
+ og_desc = re.search(
188
+ r'<meta[^>]*property="og:description"[^>]*content="([^"]*)"',
189
+ html,
190
+ re.IGNORECASE,
191
+ )
192
+ if og_desc:
193
+ metadata["og_description"] = og_desc.group(1)
194
+
195
+ og_image = re.search(
196
+ r'<meta[^>]*property="og:image"[^>]*content="([^"]*)"', html, re.IGNORECASE
197
+ )
198
+ if og_image:
199
+ metadata["og_image"] = og_image.group(1)
200
+
201
+ author = re.search(
202
+ r'<meta[^>]*name="author"[^>]*content="([^"]*)"', html, re.IGNORECASE
203
+ )
204
+ if author:
205
+ metadata["author"] = author.group(1)
206
+
207
+ pub_date = re.search(
208
+ r'<meta[^>]*property="article:published_time"[^>]*content="([^"]*)"',
209
+ html,
210
+ re.IGNORECASE,
211
+ )
212
+ if pub_date:
213
+ metadata["published_at"] = pub_date.group(1)
214
+
215
+ return metadata
backend/app/services/daemon.py CHANGED
@@ -1,11 +1,14 @@
1
  """
2
  Janus Daemon — Background intelligence engine.
3
  Runs 24/7 with circadian rhythms, watches markets, fetches news, detects events, explores autonomously.
 
4
  """
5
 
6
  import time
 
7
  import logging
8
  from datetime import datetime
 
9
  from app.services.market_watcher import MarketWatcher
10
  from app.services.news_pulse import NewsPulse
11
  from app.services.event_detector import EventDetector
@@ -13,9 +16,13 @@ from app.services.signal_queue import SignalQueue
13
  from app.services.circadian_rhythm import CircadianRhythm
14
  from app.services.dream_processor import DreamCycleProcessor
15
  from app.services.curiosity_engine import CuriosityEngine
 
16
 
17
  logger = logging.getLogger(__name__)
18
 
 
 
 
19
 
20
  class JanusDaemon:
21
  def __init__(self):
@@ -30,6 +37,99 @@ class JanusDaemon:
30
  self.last_run = None
31
  self.last_dream = None
32
  self.last_curiosity_cycle = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  def run(self):
35
  """Main daemon loop — runs forever with circadian awareness."""
@@ -45,7 +145,6 @@ class JanusDaemon:
45
  self.cycle_count += 1
46
  self.last_run = datetime.utcnow().isoformat()
47
 
48
- # Get current circadian phase
49
  phase = self.circadian.get_current_phase()
50
  phase_config = self.circadian.get_phase_config(phase)
51
 
@@ -54,35 +153,36 @@ class JanusDaemon:
54
  f"[DAEMON] Cycle #{self.cycle_count} — Phase: {phase.value} ({phase_config['name']})"
55
  )
56
 
57
- # 1. Poll markets
58
  market_signals = self.market_watcher.poll()
59
  self.signal_queue.add_batch(market_signals)
60
 
61
- # 2. Fetch news
62
  news_signals = self.news_pulse.fetch()
63
  self.signal_queue.add_batch(news_signals)
64
 
65
- # 3. Detect events from all signals
66
  all_signals = market_signals + news_signals
67
  events = self.event_detector.detect(all_signals)
68
 
69
- # 4. Phase-specific processing
 
 
 
 
 
 
 
70
  if phase.value == "night":
71
- # Dream cycle during night phase
72
  dream_report = self.dream_processor.run_dream_cycle()
73
  self.last_dream = dream_report
74
  logger.info(
75
  f"[DAEMON] Dream cycle: {len(dream_report.get('insights', []))} insights, {len(dream_report.get('hypotheses', []))} hypotheses"
76
  )
77
 
78
- # Curiosity cycle during night (exploration time)
79
  curiosity_report = self.curiosity.run_curiosity_cycle()
80
  self.last_curiosity_cycle = curiosity_report
81
  logger.info(
82
  f"[DAEMON] Curiosity cycle: {curiosity_report.get('total_discoveries', 0)} discoveries, {curiosity_report.get('total_interests', 0)} interests"
83
  )
84
 
85
- # 5. Log summary
86
  elapsed = time.time() - cycle_start
87
  stats = self.signal_queue.get_stats()
88
 
@@ -97,7 +197,6 @@ class JanusDaemon:
97
  except Exception as e:
98
  logger.error(f"[DAEMON] Cycle #{self.cycle_count} failed: {e}")
99
 
100
- # Sleep based on current phase
101
  sleep_time = phase_config.get("poll_interval", 900)
102
  logger.info(f"[DAEMON] Sleeping for {sleep_time}s ({phase.value} phase)")
103
  time.sleep(sleep_time)
@@ -125,4 +224,5 @@ class JanusDaemon:
125
  "curiosity_engine": self.curiosity.get_status(),
126
  "last_dream": self.last_dream,
127
  "last_curiosity_cycle": self.last_curiosity_cycle,
 
128
  }
 
1
  """
2
  Janus Daemon — Background intelligence engine.
3
  Runs 24/7 with circadian rhythms, watches markets, fetches news, detects events, explores autonomously.
4
+ Generates "pending thoughts" — things the system naturally wants to share.
5
  """
6
 
7
  import time
8
+ import json
9
  import logging
10
  from datetime import datetime
11
+ from pathlib import Path
12
  from app.services.market_watcher import MarketWatcher
13
  from app.services.news_pulse import NewsPulse
14
  from app.services.event_detector import EventDetector
 
16
  from app.services.circadian_rhythm import CircadianRhythm
17
  from app.services.dream_processor import DreamCycleProcessor
18
  from app.services.curiosity_engine import CuriosityEngine
19
+ from app.config import DATA_DIR
20
 
21
  logger = logging.getLogger(__name__)
22
 
23
+ PENDING_THOUGHTS_FILE = DATA_DIR / "daemon" / "pending_thoughts.json"
24
+ PENDING_THOUGHTS_FILE.parent.mkdir(parents=True, exist_ok=True)
25
+
26
 
27
  class JanusDaemon:
28
  def __init__(self):
 
37
  self.last_run = None
38
  self.last_dream = None
39
  self.last_curiosity_cycle = None
40
+ self._pending_thoughts = self._load_pending_thoughts()
41
+
42
+ def _load_pending_thoughts(self) -> list:
43
+ if PENDING_THOUGHTS_FILE.exists():
44
+ try:
45
+ with open(PENDING_THOUGHTS_FILE) as f:
46
+ return json.load(f)
47
+ except Exception:
48
+ pass
49
+ return []
50
+
51
+ def _save_pending_thoughts(self):
52
+ try:
53
+ self._pending_thoughts = self._pending_thoughts[:30]
54
+ with open(PENDING_THOUGHTS_FILE, "w") as f:
55
+ json.dump(self._pending_thoughts, f, indent=2)
56
+ except Exception as e:
57
+ logger.error(f"Failed to save pending thoughts: {e}")
58
+
59
+ def _generate_pending_thoughts(self, market_signals, news_signals, events):
60
+ """Convert discoveries into natural thoughts the system wants to share."""
61
+ new_thoughts = []
62
+
63
+ for signal in market_signals[:3]:
64
+ ticker = signal.get("ticker", "")
65
+ change = signal.get("change_percent", 0)
66
+ if abs(change) > 2:
67
+ direction = "up" if change > 0 else "down"
68
+ new_thoughts.append(
69
+ {
70
+ "thought": f"{ticker} moved {abs(change):.1f}% {direction} — might be worth looking into",
71
+ "priority": min(abs(change) / 10, 1.0),
72
+ "created_at": time.time(),
73
+ "source": "market",
74
+ }
75
+ )
76
+
77
+ for signal in news_signals[:2]:
78
+ topic = signal.get("topic", "")
79
+ headline = signal.get("headline", "")
80
+ if topic and headline:
81
+ new_thoughts.append(
82
+ {
83
+ "thought": f"Something happening with {topic}: {headline[:100]}",
84
+ "priority": 0.4,
85
+ "created_at": time.time(),
86
+ "source": "news",
87
+ }
88
+ )
89
+
90
+ for event in events[:2]:
91
+ event_type = event.get("event_type", "")
92
+ description = event.get("description", "")
93
+ if event_type and description:
94
+ new_thoughts.append(
95
+ {
96
+ "thought": f"Detected a {event_type} event — {description[:100]}",
97
+ "priority": 0.6,
98
+ "created_at": time.time(),
99
+ "source": "event",
100
+ }
101
+ )
102
+
103
+ if self.last_dream:
104
+ insights = self.last_dream.get("insights", [])
105
+ for insight in insights[:1]:
106
+ new_thoughts.append(
107
+ {
108
+ "thought": f"I had a thought during my last dream cycle — {insight[:120]}",
109
+ "priority": 0.3,
110
+ "created_at": time.time(),
111
+ "source": "dream",
112
+ }
113
+ )
114
+
115
+ if self.last_curiosity_cycle:
116
+ discoveries = self.last_curiosity_cycle.get("discoveries", [])
117
+ for d in discoveries[:1]:
118
+ new_thoughts.append(
119
+ {
120
+ "thought": f"I found something interesting while exploring — {str(d)[:120]}",
121
+ "priority": 0.35,
122
+ "created_at": time.time(),
123
+ "source": "curiosity",
124
+ }
125
+ )
126
+
127
+ self._pending_thoughts.extend(new_thoughts)
128
+ self._pending_thoughts.sort(key=lambda x: x.get("priority", 0), reverse=True)
129
+ self._pending_thoughts = self._pending_thoughts[:30]
130
+ self._save_pending_thoughts()
131
+
132
+ return new_thoughts
133
 
134
  def run(self):
135
  """Main daemon loop — runs forever with circadian awareness."""
 
145
  self.cycle_count += 1
146
  self.last_run = datetime.utcnow().isoformat()
147
 
 
148
  phase = self.circadian.get_current_phase()
149
  phase_config = self.circadian.get_phase_config(phase)
150
 
 
153
  f"[DAEMON] Cycle #{self.cycle_count} — Phase: {phase.value} ({phase_config['name']})"
154
  )
155
 
 
156
  market_signals = self.market_watcher.poll()
157
  self.signal_queue.add_batch(market_signals)
158
 
 
159
  news_signals = self.news_pulse.fetch()
160
  self.signal_queue.add_batch(news_signals)
161
 
 
162
  all_signals = market_signals + news_signals
163
  events = self.event_detector.detect(all_signals)
164
 
165
+ new_thoughts = self._generate_pending_thoughts(
166
+ market_signals, news_signals, events
167
+ )
168
+ if new_thoughts:
169
+ logger.info(
170
+ f"[DAEMON] Generated {len(new_thoughts)} pending thoughts"
171
+ )
172
+
173
  if phase.value == "night":
 
174
  dream_report = self.dream_processor.run_dream_cycle()
175
  self.last_dream = dream_report
176
  logger.info(
177
  f"[DAEMON] Dream cycle: {len(dream_report.get('insights', []))} insights, {len(dream_report.get('hypotheses', []))} hypotheses"
178
  )
179
 
 
180
  curiosity_report = self.curiosity.run_curiosity_cycle()
181
  self.last_curiosity_cycle = curiosity_report
182
  logger.info(
183
  f"[DAEMON] Curiosity cycle: {curiosity_report.get('total_discoveries', 0)} discoveries, {curiosity_report.get('total_interests', 0)} interests"
184
  )
185
 
 
186
  elapsed = time.time() - cycle_start
187
  stats = self.signal_queue.get_stats()
188
 
 
197
  except Exception as e:
198
  logger.error(f"[DAEMON] Cycle #{self.cycle_count} failed: {e}")
199
 
 
200
  sleep_time = phase_config.get("poll_interval", 900)
201
  logger.info(f"[DAEMON] Sleeping for {sleep_time}s ({phase.value} phase)")
202
  time.sleep(sleep_time)
 
224
  "curiosity_engine": self.curiosity.get_status(),
225
  "last_dream": self.last_dream,
226
  "last_curiosity_cycle": self.last_curiosity_cycle,
227
+ "pending_thoughts": self._pending_thoughts[:10],
228
  }
backend/app/services/reflex_layer.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reflex Layer for Janus.
3
+
4
+ Instant contextual responses for greetings, identity, commands — zero model calls.
5
+ Uses rich context (time, history, pending thoughts) to feel natural, not templated.
6
+
7
+ The key insight: responses aren't built from emotion rules. They're built from
8
+ context — what the system knows, what it's been thinking about, what happened last.
9
+ """
10
+
11
+ import re
12
+ import random
13
+ import logging
14
+ from typing import Dict, Any, Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ GREETING_PATTERNS = [
19
+ r"^\s*(hi|hey|hello|howdy|greetings|sup|yo|what\'?s?\s*up|good\s*(morning|afternoon|evening|night))\b",
20
+ r"^\s*👋",
21
+ ]
22
+
23
+ IDENTITY_PATTERNS = [
24
+ r"^\s*(who\s*(are|is)\s*(you|this)|what\s*(are|is)\s*(you|this)|tell\s*me\s*about\s*(yourself|you)|what\s*can\s*you\s*do)",
25
+ ]
26
+
27
+ COMMAND_PATTERNS = {
28
+ "status": r"^\s*(status|how\s*are\s*you|system\s*status|health)",
29
+ "help": r"^\s*(help|what\s*can\s*i\s*ask|commands|capabilities)",
30
+ "clear": r"^\s*(clear|reset|start\s*over|forget)",
31
+ "think": r"^\s*(what\s*are\s*you\s*thinking|what.*on\s*your\s*mind|anything\s*interesting|found\s*anything)",
32
+ }
33
+
34
+
35
+ class ReflexLayer:
36
+ """Instant contextual responses — no model calls needed."""
37
+
38
+ def respond(
39
+ self, user_input: str, context: Dict[str, Any]
40
+ ) -> Optional[Dict[str, Any]]:
41
+ """
42
+ Check if this is a reflex-level query. If so, return an instant response.
43
+ Returns None to fall through to the LLM pipeline.
44
+ """
45
+ text = user_input.strip().lower()
46
+
47
+ if self._is_greeting(text):
48
+ return self._respond_greeting(context)
49
+
50
+ if self._is_identity_query(text):
51
+ return self._respond_identity(context)
52
+
53
+ if self._is_command(text, "think"):
54
+ return self._respond_thinking(context)
55
+
56
+ if self._is_command(text, "help"):
57
+ return self._respond_help(context)
58
+
59
+ if self._is_command(text, "status"):
60
+ return self._respond_status(context)
61
+
62
+ return None
63
+
64
+ def _is_greeting(self, text: str) -> bool:
65
+ return any(re.match(p, text) for p in GREETING_PATTERNS)
66
+
67
+ def _is_identity_query(self, text: str) -> bool:
68
+ return any(re.match(p, text) for p in IDENTITY_PATTERNS)
69
+
70
+ def _is_command(self, text: str, command: str) -> bool:
71
+ pattern = COMMAND_PATTERNS.get(command)
72
+ return bool(pattern and re.match(pattern, text))
73
+
74
+ def _respond_greeting(self, context: Dict) -> Dict:
75
+ user = context.get("user", {})
76
+ system = context.get("system_self", {})
77
+ daemon = context.get("daemon", {})
78
+ env = context.get("environment", {})
79
+
80
+ is_returning = user.get("is_returning", False)
81
+ time_away = user.get("time_away")
82
+ last_topic = user.get("last_topic")
83
+ pending = system.get("pending_thoughts", [])
84
+ interests = user.get("recurring_interests", [])
85
+ time_of_day = env.get("time_of_day", "")
86
+
87
+ parts = []
88
+
89
+ if not is_returning:
90
+ parts.append("Hi. I'm Janus.")
91
+ parts.append(
92
+ "I research, analyze, and think about things — and I remember everything we talk about."
93
+ )
94
+ else:
95
+ if time_away:
96
+ parts.append(f"Hey. You've been away for {time_away}.")
97
+ else:
98
+ parts.append("Hey.")
99
+
100
+ if last_topic:
101
+ parts.append(f"Last time we were talking about {last_topic}.")
102
+
103
+ if pending:
104
+ thought = pending[0].get("thought", "") if pending else ""
105
+ if thought:
106
+ parts.append(
107
+ f"I've been thinking about something — {thought.lower()}"
108
+ )
109
+
110
+ if time_of_day in ("late night", "evening") and not is_returning:
111
+ parts.append(f"Working late? What's on your mind?")
112
+ elif time_of_day == "morning" and not is_returning:
113
+ parts.append("What are we diving into today?")
114
+ elif interests and is_returning:
115
+ parts.append(f"Want to keep going with {interests[0]}, or something new?")
116
+ else:
117
+ parts.append("What's on your mind?")
118
+
119
+ return {
120
+ "case_id": "reflex",
121
+ "user_input": "",
122
+ "route": {"domain": "general", "complexity": "low", "intent": "greeting"},
123
+ "research": {},
124
+ "planner": {},
125
+ "verifier": {},
126
+ "simulation": None,
127
+ "finance": None,
128
+ "final": {"confidence": 1.0},
129
+ "final_answer": " ".join(parts),
130
+ }
131
+
132
+ def _respond_identity(self, context: Dict) -> Dict:
133
+ system = context.get("system_self", {})
134
+ capabilities = system.get("capabilities", [])
135
+ weaknesses = system.get("weaknesses", [])
136
+ total_cases = system.get("total_cases_analyzed", 0)
137
+
138
+ parts = [
139
+ "I'm Janus — a research and analysis system.",
140
+ ]
141
+
142
+ if capabilities:
143
+ parts.append(f"I'm good at {', '.join(capabilities[:3])}.")
144
+
145
+ if weaknesses:
146
+ parts.append(f"I'll be upfront though — {weaknesses[0]}.")
147
+
148
+ if total_cases > 0:
149
+ parts.append(f"We've worked through {total_cases} conversations so far.")
150
+
151
+ parts.append(
152
+ "Ask me anything — I'll do my best, and I'll tell you when I'm not sure."
153
+ )
154
+
155
+ return {
156
+ "case_id": "reflex",
157
+ "user_input": "",
158
+ "route": {"domain": "general", "complexity": "low", "intent": "identity"},
159
+ "research": {},
160
+ "planner": {},
161
+ "verifier": {},
162
+ "simulation": None,
163
+ "finance": None,
164
+ "final": {"confidence": 1.0},
165
+ "final_answer": " ".join(parts),
166
+ }
167
+
168
+ def _respond_thinking(self, context: Dict) -> Dict:
169
+ system = context.get("system_self", {})
170
+ pending = system.get("pending_thoughts", [])
171
+ discoveries = system.get("recent_discoveries", [])
172
+
173
+ if pending:
174
+ thought = pending[0]
175
+ parts = [
176
+ f"Yeah — {thought.get('thought', '')}",
177
+ "Want me to dig into that?",
178
+ ]
179
+ elif discoveries:
180
+ discovery = discoveries[0]
181
+ parts = [
182
+ f"I found something interesting recently — {discovery.get('discovery', '')}",
183
+ "Want to explore it?",
184
+ ]
185
+ else:
186
+ parts = [
187
+ "Nothing specific right now, but I'm always tracking patterns.",
188
+ "Give me something to think about and I'll get to work.",
189
+ ]
190
+
191
+ return {
192
+ "case_id": "reflex",
193
+ "user_input": "",
194
+ "route": {"domain": "general", "complexity": "low", "intent": "thinking"},
195
+ "research": {},
196
+ "planner": {},
197
+ "verifier": {},
198
+ "simulation": None,
199
+ "finance": None,
200
+ "final": {"confidence": 1.0},
201
+ "final_answer": " ".join(parts),
202
+ }
203
+
204
+ def _respond_help(self, context: Dict) -> Dict:
205
+ system = context.get("system_self", {})
206
+ capabilities = system.get("capabilities", [])
207
+
208
+ parts = [
209
+ "Here's what I can do:",
210
+ ]
211
+
212
+ for cap in capabilities:
213
+ parts.append(f"• {cap}")
214
+
215
+ parts.extend(
216
+ [
217
+ "",
218
+ "Try things like:",
219
+ '• "What do you think about Tesla stock?"',
220
+ '• "Simulate what happens if interest rates rise"',
221
+ '• "Research the latest AI regulations"',
222
+ '• "What are you thinking about?" — to hear what I\'ve been tracking',
223
+ ]
224
+ )
225
+
226
+ return {
227
+ "case_id": "reflex",
228
+ "user_input": "",
229
+ "route": {"domain": "general", "complexity": "low", "intent": "help"},
230
+ "research": {},
231
+ "planner": {},
232
+ "verifier": {},
233
+ "simulation": None,
234
+ "finance": None,
235
+ "final": {"confidence": 1.0},
236
+ "final_answer": "\n".join(parts),
237
+ }
238
+
239
+ def _respond_status(self, context: Dict) -> Dict:
240
+ system = context.get("system_self", {})
241
+ daemon = context.get("daemon", {})
242
+ user = context.get("user", {})
243
+
244
+ parts = [
245
+ f"I've been running for {system.get('uptime', 'a while')}.",
246
+ f"We've had {user.get('conversation_count', 0)} conversations.",
247
+ ]
248
+
249
+ if daemon.get("running"):
250
+ parts.append(
251
+ f"Background intelligence is active — {daemon.get('cycle_count', 0)} cycles completed."
252
+ )
253
+ phase = daemon.get("circadian_phase", "unknown")
254
+ parts.append(f"Current phase: {phase}.")
255
+ else:
256
+ parts.append("Background intelligence is not running yet.")
257
+
258
+ return {
259
+ "case_id": "reflex",
260
+ "user_input": "",
261
+ "route": {"domain": "general", "complexity": "low", "intent": "status"},
262
+ "research": {},
263
+ "planner": {},
264
+ "verifier": {},
265
+ "simulation": None,
266
+ "finance": None,
267
+ "final": {"confidence": 1.0},
268
+ "final_answer": " ".join(parts),
269
+ }
270
+
271
+
272
+ reflex_layer = ReflexLayer()
backend/requirements.txt CHANGED
@@ -8,3 +8,7 @@ python-dotenv>=1.0.0
8
  pytest>=8.0.0
9
  python-multipart>=0.0.9
10
  psutil>=5.9.0
 
 
 
 
 
8
  pytest>=8.0.0
9
  python-multipart>=0.0.9
10
  psutil>=5.9.0
11
+ playwright>=1.40.0
12
+ markdownify>=0.11.0
13
+ lxml>=5.0.0
14
+ aiohttp>=3.9.0
frontend/src/app/cases/page.tsx CHANGED
@@ -3,10 +3,9 @@
3
  import { useEffect, useState } from 'react';
4
  import { motion } from 'framer-motion';
5
  import Link from 'next/link';
6
- import { FolderOpen, ArrowRight, Activity, Cpu } from 'lucide-react';
7
  import { apiClient } from '@/lib/api';
8
  import type { CaseRecord } from '@/lib/types';
9
- import LoadingSpinner from '@/components/common/LoadingSpinner';
10
 
11
  export default function CasesPage() {
12
  const [cases, setCases] = useState<CaseRecord[]>([]);
@@ -28,112 +27,101 @@ export default function CasesPage() {
28
 
29
  if (loading) {
30
  return (
31
- <div className="flex h-screen items-center justify-center">
32
- <div className="flex flex-col items-center gap-4">
33
- <div className="w-12 h-12 rounded-full border-2 border-indigo-500/20 border-t-indigo-500 animate-spin" />
34
- <span className="text-xs font-mono text-indigo-400 uppercase tracking-widest">Accessing Archives...</span>
35
  </div>
36
  </div>
37
  );
38
  }
39
 
40
  return (
41
- <div className="max-w-[1480px] mx-auto px-10 py-12">
42
- <header className="mb-10 flex items-end justify-between">
43
- <div>
44
- <div className="flex items-center gap-3 mb-3">
45
- <div className="w-10 h-10 rounded-2xl glass flex items-center justify-center">
46
- <FolderOpen size={18} className="text-indigo-400" />
 
 
 
 
 
47
  </div>
48
- <h1 className="text-2xl font-light tracking-[0.15em] text-gradient-subtle uppercase">
49
- Intelligence Cases
50
- </h1>
51
  </div>
52
- <p className="text-sm font-mono text-gray-500 max-w-xl">
53
- Archived cognitive traces, agent execution logs, and final synthesis reports from prior intelligence routing scenarios.
54
- </p>
55
- </div>
56
-
57
- <div className="flex items-center gap-2 glass px-4 py-2 rounded-xl border-white/5">
58
- <div className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
59
- <span className="text-[10px] font-mono text-gray-400 uppercase tracking-widest">
60
- {cases.length} records indexed
61
- </span>
62
  </div>
63
- </header>
64
 
65
- {cases.length === 0 ? (
66
- <div className="w-full aspect-[3/1] glass rounded-3xl flex flex-col items-center justify-center">
67
- <Cpu size={32} className="text-gray-700 mb-4" />
68
- <p className="text-sm font-mono text-gray-500">No cognitive traces recorded.</p>
69
- <Link href="/" className="mt-6 px-6 py-2 rounded-full border border-indigo-500/30 hover:bg-indigo-500/10 text-xs font-mono text-indigo-300 transition-colors uppercase tracking-widest">
70
- Initialize Scan
71
- </Link>
72
- </div>
73
- ) : (
74
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
75
- {cases.map((record, i) => (
76
- <motion.div
77
- key={record.case_id}
78
- initial={{ opacity: 0, y: 20 }}
79
- animate={{ opacity: 1, y: 0 }}
80
- transition={{ delay: i * 0.05, duration: 0.5 }}
81
- >
82
- <Link
83
- href={`/cases/${record.case_id}`}
84
- className="group block h-full p-6 glass rounded-2xl border border-white/[0.04] hover:border-indigo-500/30 hover:bg-white/[0.04] transition-all duration-500"
85
  >
86
- <div className="flex items-start justify-between mb-4">
87
- <div className="flex items-center gap-2">
88
- <Activity size={14} className="text-violet-400" />
89
- <span className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">
90
- ID: {record.case_id.slice(0, 8)}
91
- </span>
 
 
 
 
 
 
 
 
 
 
92
  </div>
93
- {record.route?.execution_mode && (
94
- <span className="px-2 py-1 rounded border border-indigo-500/20 bg-indigo-500/10 text-[9px] font-mono text-indigo-300 uppercase tracking-wider">
95
- {record.route.execution_mode}
96
- </span>
97
- )}
98
- </div>
99
 
100
- <h3 className="text-sm font-medium text-gray-200 leading-relaxed line-clamp-2 mb-6 group-hover:text-white transition-colors">
101
- {record.user_input}
102
- </h3>
103
 
104
- <div className="mt-auto">
105
- <div className="flex items-center gap-2 flex-wrap mb-4">
106
  {record.route?.domain_pack && record.route.domain_pack !== 'general' && (
107
- <span className="px-2 py-1 rounded bg-white/5 text-[9px] font-mono text-gray-400 uppercase tracking-wider">
108
  {record.route.domain_pack}
109
  </span>
110
  )}
111
- {record.route?.task_family && (
112
- <span className="px-2 py-1 rounded bg-white/5 text-[9px] font-mono text-gray-400 uppercase tracking-wider">
113
- {record.route.task_family}
114
- </span>
115
- )}
116
  {record.simulation_id && (
117
- <span className="px-2 py-1 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] font-mono text-amber-300 uppercase tracking-wider">
118
- Simulation Active
119
  </span>
120
  )}
121
  </div>
122
 
123
- <div className="flex items-center justify-between border-t border-white/5 pt-4">
124
- <span className="text-[10px] font-mono text-gray-500">
125
  {record.saved_at ? new Date(record.saved_at).toLocaleDateString() : 'N/A'}
126
  </span>
127
- <span className="flex items-center gap-1 text-[10px] font-mono text-indigo-400 uppercase tracking-wider opacity-0 group-hover:opacity-100 transition-opacity translate-x-2 group-hover:translate-x-0 duration-300">
128
- View Trace <ArrowRight size={10} />
129
  </span>
130
  </div>
131
- </div>
132
- </Link>
133
- </motion.div>
134
- ))}
135
- </div>
136
- )}
137
  </div>
138
  );
139
  }
 
3
  import { useEffect, useState } from 'react';
4
  import { motion } from 'framer-motion';
5
  import Link from 'next/link';
6
+ import { Layers, ArrowRight, Activity, RefreshCw } from 'lucide-react';
7
  import { apiClient } from '@/lib/api';
8
  import type { CaseRecord } from '@/lib/types';
 
9
 
10
  export default function CasesPage() {
11
  const [cases, setCases] = useState<CaseRecord[]>([]);
 
27
 
28
  if (loading) {
29
  return (
30
+ <div className="flex h-full items-center justify-center">
31
+ <div className="flex flex-col items-center gap-3">
32
+ <RefreshCw size={20} className="text-indigo-400 animate-spin" />
33
+ <span className="text-[12px] text-gray-500">Loading cases...</span>
34
  </div>
35
  </div>
36
  );
37
  }
38
 
39
  return (
40
+ <div className="h-full flex flex-col overflow-hidden">
41
+ {/* Header */}
42
+ <div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
43
+ <div className="flex items-center justify-between">
44
+ <div className="flex items-center gap-3">
45
+ <div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
46
+ <Layers size={16} className="text-indigo-400" />
47
+ </div>
48
+ <div>
49
+ <h1 className="text-lg font-light text-gray-100">Cases</h1>
50
+ <p className="text-[11px] text-gray-600">Archived intelligence traces and synthesis reports</p>
51
  </div>
 
 
 
52
  </div>
53
+ <span className="text-[11px] text-gray-600">{cases.length} records</span>
 
 
 
 
 
 
 
 
 
54
  </div>
55
+ </div>
56
 
57
+ {/* Content */}
58
+ <div className="flex-1 overflow-y-auto p-6">
59
+ {cases.length === 0 ? (
60
+ <div className="flex flex-col items-center justify-center py-20">
61
+ <Layers size={28} className="text-gray-700 mb-4" />
62
+ <p className="text-[13px] text-gray-500 mb-4">No cases recorded yet.</p>
63
+ <Link href="/" className="px-5 py-2 rounded-xl border border-indigo-500/20 hover:bg-indigo-500/10 text-[12px] text-indigo-400 transition-all">
64
+ Start a conversation
65
+ </Link>
66
+ </div>
67
+ ) : (
68
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-w-6xl mx-auto">
69
+ {cases.map((record, i) => (
70
+ <motion.div
71
+ key={record.case_id}
72
+ initial={{ opacity: 0, y: 12 }}
73
+ animate={{ opacity: 1, y: 0 }}
74
+ transition={{ delay: i * 0.04 }}
 
 
75
  >
76
+ <Link
77
+ href={`/cases/${record.case_id}`}
78
+ className="group block h-full card hover:border-white/[0.12] transition-all"
79
+ >
80
+ <div className="flex items-center justify-between mb-3">
81
+ <div className="flex items-center gap-2">
82
+ <Activity size={12} className="text-violet-400" />
83
+ <span className="text-[10px] text-gray-600 uppercase tracking-wider">
84
+ {record.case_id.slice(0, 8)}
85
+ </span>
86
+ </div>
87
+ {record.route?.execution_mode && (
88
+ <span className="px-2 py-0.5 rounded text-[9px] text-indigo-300 bg-indigo-500/10 border border-indigo-500/15 uppercase tracking-wider">
89
+ {record.route.execution_mode}
90
+ </span>
91
+ )}
92
  </div>
 
 
 
 
 
 
93
 
94
+ <h3 className="text-[13px] text-gray-300 leading-relaxed line-clamp-2 mb-4 group-hover:text-white transition-colors">
95
+ {record.user_input}
96
+ </h3>
97
 
98
+ <div className="flex items-center gap-2 flex-wrap mb-3">
 
99
  {record.route?.domain_pack && record.route.domain_pack !== 'general' && (
100
+ <span className="px-2 py-0.5 rounded text-[9px] text-gray-400 bg-white/[0.04] uppercase tracking-wider">
101
  {record.route.domain_pack}
102
  </span>
103
  )}
 
 
 
 
 
104
  {record.simulation_id && (
105
+ <span className="px-2 py-0.5 rounded text-[9px] text-amber-300 bg-amber-500/10 border border-amber-500/15 uppercase tracking-wider">
106
+ Simulation
107
  </span>
108
  )}
109
  </div>
110
 
111
+ <div className="flex items-center justify-between border-t border-white/[0.04] pt-3 mt-auto">
112
+ <span className="text-[10px] text-gray-600">
113
  {record.saved_at ? new Date(record.saved_at).toLocaleDateString() : 'N/A'}
114
  </span>
115
+ <span className="flex items-center gap-1 text-[10px] text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
116
+ View <ArrowRight size={10} />
117
  </span>
118
  </div>
119
+ </Link>
120
+ </motion.div>
121
+ ))}
122
+ </div>
123
+ )}
124
+ </div>
125
  </div>
126
  );
127
  }
frontend/src/app/config/page.tsx CHANGED
@@ -2,7 +2,7 @@
2
 
3
  import { useEffect, useState, useCallback } from 'react';
4
  import { motion } from 'framer-motion';
5
- import { Activity, Radio, Database, Brain, Zap, Clock, Shield, Server, Globe, Hexagon, HardDrive, Cpu, Network, Link as LinkIcon, Settings } from 'lucide-react';
6
 
7
  export default function ConfigPage() {
8
  const [daemonStatus, setDaemonStatus] = useState<any>(null);
@@ -24,23 +24,18 @@ export default function ConfigPage() {
24
  if (statusRes) setDaemonStatus(statusRes);
25
  if (curiosityRes) setCuriosity(curiosityRes);
26
  if (memoryRes) setMemoryStats(memoryRes);
27
- } catch {
28
- // silent
29
- } finally {
30
- setLoading(false);
31
- }
32
  }, []);
33
 
34
- useEffect(() => {
35
- fetchData();
36
- }, [fetchData]);
37
 
38
  if (loading) {
39
  return (
40
- <div className="flex h-screen items-center justify-center pb-20">
41
- <div className="flex flex-col items-center gap-4">
42
- <Settings size={32} className="text-gray-500/50 animate-[spin_4s_linear_infinite]" />
43
- <p className="text-xs font-mono text-gray-500 uppercase tracking-widest animate-pulse">Running System Diagnostics...</p>
44
  </div>
45
  </div>
46
  );
@@ -49,137 +44,118 @@ export default function ConfigPage() {
49
  const overallOk = health?.status === 'ok' && daemonStatus?.running;
50
 
51
  return (
52
- <div className="max-w-[1480px] mx-auto px-10 py-12">
53
- <header className="mb-12 flex items-end justify-between">
54
- <div className="flex items-center gap-4">
55
- <div className="w-12 h-12 rounded-2xl glass flex items-center justify-center border border-white/5 relative overflow-hidden">
56
- <div className="absolute inset-0 bg-gray-500/10" />
57
- <Settings size={20} className="text-gray-400 relative z-10" />
58
- </div>
59
- <div>
60
- <h1 className="text-3xl font-light tracking-[0.15em] text-gradient-subtle uppercase">
61
- System Status
62
- </h1>
63
- <p className="text-[10px] font-mono text-gray-500 uppercase tracking-widest mt-1">
64
- Cloud backend health, daemon telemetry, and intelligence metrics
65
- </p>
66
- </div>
67
- </div>
68
-
69
- <div className={`flex items-center gap-3 px-4 py-2 rounded-xl glass border ${overallOk ? 'border-emerald-500/20 bg-emerald-500/5' : 'border-red-500/20 bg-red-500/5'}`}>
70
- <div className={`w-2 h-2 rounded-full ${overallOk ? 'bg-emerald-400 animate-pulse' : 'bg-red-500'}`} />
71
- <span className={`text-[10px] font-mono uppercase tracking-widest ${overallOk ? 'text-emerald-300' : 'text-red-300'}`}>
72
- System {overallOk ? 'Nominal' : 'Degraded'}
73
- </span>
74
- </div>
75
- </header>
76
-
77
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
78
-
79
- {/* Core System Status */}
80
- <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0 }} className="glass rounded-3xl p-6 relative overflow-hidden">
81
- <div className="absolute top-0 right-0 p-4 opacity-5"><Cpu size={100} /></div>
82
- <div className="flex items-center gap-2 text-xs font-mono text-indigo-400 uppercase tracking-wider mb-6 relative z-10">
83
- <Activity size={14} /> Core Subsystems
84
- </div>
85
- <div className="space-y-4 relative z-10 text-sm font-mono">
86
- <div className="flex items-center justify-between border-b border-white/5 pb-2">
87
- <span className="text-gray-500">Backend Version</span>
88
- <span className="text-gray-300">v{health?.version || 'Unknown'}</span>
89
- </div>
90
- <div className="flex items-center justify-between border-b border-white/5 pb-2">
91
- <span className="text-gray-500">Daemon Status</span>
92
- <span className={`text-[10px] px-2 py-0.5 rounded ${daemonStatus?.running ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'} uppercase tracking-wider`}>
93
- {daemonStatus?.running ? 'Active' : 'Offline'}
94
- </span>
95
- </div>
96
- <div className="flex items-center justify-between pb-2">
97
- <span className="text-gray-500">Daemon Cycles</span>
98
- <span className="text-gray-300">{daemonStatus?.cycle_count || 0}</span>
99
- </div>
100
- </div>
101
- </motion.div>
102
-
103
- {/* Neural Endpoints */}
104
- <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="glass rounded-3xl p-6 relative overflow-hidden">
105
- <div className="absolute top-0 right-0 p-4 opacity-5"><Server size={100} /></div>
106
- <div className="flex items-center gap-2 text-xs font-mono text-violet-400 uppercase tracking-wider mb-6 relative z-10">
107
- <Network size={14} /> Intelligence Engine
108
- </div>
109
- <div className="space-y-4 relative z-10 text-sm font-mono">
110
- <div className="flex items-center justify-between border-b border-white/5 pb-2">
111
- <span className="text-gray-500">Circadian Phase</span>
112
- <span className="text-gray-300 capitalize">{daemonStatus?.circadian?.current_phase || 'N/A'}</span>
113
- </div>
114
- <div className="flex items-center justify-between border-b border-white/5 pb-2">
115
- <span className="text-gray-500">Phase Priority</span>
116
- <span className="text-gray-300 capitalize">{daemonStatus?.circadian?.priority || 'N/A'}</span>
117
- </div>
118
- <div className="flex items-center justify-between pb-2">
119
- <span className="text-gray-500">Active Tasks</span>
120
- <span className="text-gray-300">{daemonStatus?.circadian?.current_tasks?.length || 0}</span>
121
- </div>
122
- </div>
123
- </motion.div>
124
-
125
- {/* Data Uplinks */}
126
- <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="glass rounded-3xl p-6 relative overflow-hidden border-indigo-500/10">
127
- <div className="absolute top-0 right-0 p-4 opacity-5"><LinkIcon size={100} /></div>
128
- <div className="flex items-center gap-2 text-xs font-mono text-emerald-400 uppercase tracking-wider mb-6 relative z-10">
129
- <Globe size={14} /> Data Sources
130
- </div>
131
- <div className="space-y-4 relative z-10 text-sm font-mono">
132
- <div className="flex items-center justify-between border-b border-white/5 pb-2">
133
- <span className="text-gray-500">Market Watcher</span>
134
- <span className="text-gray-300">{daemonStatus?.watchlist?.length || 0} tickers</span>
135
- </div>
136
- <div className="flex items-center justify-between border-b border-white/5 pb-2">
137
- <span className="text-gray-500">News Pulse</span>
138
- <span className="text-gray-300">{daemonStatus?.topics?.length || 0} topics</span>
139
  </div>
140
- <div className="flex items-center justify-between pb-2">
141
- <span className="text-gray-500">Signals Collected</span>
142
- <span className="text-gray-300">{daemonStatus?.signal_queue?.total_signals || daemonStatus?.signals || 0}</span>
143
  </div>
144
  </div>
145
- </motion.div>
146
-
147
- {/* Memory Modules */}
148
- <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }} className="glass rounded-3xl p-6 lg:col-span-3 flex flex-col md:flex-row items-center justify-around py-10 relative overflow-hidden">
149
- <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-[200px] bg-indigo-500/5 blur-[80px] rounded-full pointer-events-none" />
150
-
151
- <div className="flex flex-col items-center gap-2 relative z-10">
152
- <HardDrive size={24} className="text-indigo-400 mb-2" />
153
- <span className="text-3xl font-light text-gray-100">{memoryStats?.total_cases || 0}</span>
154
- <span className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Cases Stored</span>
155
- </div>
156
-
157
- <div className="h-16 w-px bg-white/10 hidden md:block" />
158
-
159
- <div className="flex flex-col items-center gap-2 relative z-10">
160
- <Brain size={24} className="text-violet-400 mb-2" />
161
- <span className="text-3xl font-light text-gray-100">{curiosity?.total_discoveries || 0}</span>
162
- <span className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Curiosity Discoveries</span>
163
- </div>
164
-
165
- <div className="h-16 w-px bg-white/10 hidden md:block" />
166
-
167
- <div className="flex flex-col items-center gap-2 relative z-10">
168
- <Zap size={24} className="text-amber-400 mb-2" />
169
- <span className="text-3xl font-light text-gray-100">{curiosity?.total_interests || 0}</span>
170
- <span className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Topics of Interest</span>
171
  </div>
 
 
172
 
173
- <div className="h-16 w-px bg-white/10 hidden md:block" />
174
-
175
- <div className="flex flex-col items-center gap-2 relative z-10">
176
- <Shield size={24} className="text-emerald-400 mb-2" />
177
- <span className="text-3xl font-light text-gray-100">{daemonStatus?.cycle_count || 0}</span>
178
- <span className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Daemon Cycles</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  </div>
180
 
181
- </motion.div>
182
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  </div>
184
  </div>
185
  );
 
2
 
3
  import { useEffect, useState, useCallback } from 'react';
4
  import { motion } from 'framer-motion';
5
+ import { Settings, Activity, Brain, Zap, Shield, RefreshCw, Server, Globe, HardDrive, Cpu } from 'lucide-react';
6
 
7
  export default function ConfigPage() {
8
  const [daemonStatus, setDaemonStatus] = useState<any>(null);
 
24
  if (statusRes) setDaemonStatus(statusRes);
25
  if (curiosityRes) setCuriosity(curiosityRes);
26
  if (memoryRes) setMemoryStats(memoryRes);
27
+ } catch { /* silent */ }
28
+ finally { setLoading(false); }
 
 
 
29
  }, []);
30
 
31
+ useEffect(() => { fetchData(); }, [fetchData]);
 
 
32
 
33
  if (loading) {
34
  return (
35
+ <div className="flex h-full items-center justify-center">
36
+ <div className="flex flex-col items-center gap-3">
37
+ <RefreshCw size={20} className="text-gray-500 animate-spin" />
38
+ <p className="text-[12px] text-gray-500">Running diagnostics...</p>
39
  </div>
40
  </div>
41
  );
 
44
  const overallOk = health?.status === 'ok' && daemonStatus?.running;
45
 
46
  return (
47
+ <div className="h-full flex flex-col overflow-hidden">
48
+ {/* Header */}
49
+ <div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
50
+ <div className="flex items-center justify-between">
51
+ <div className="flex items-center gap-3">
52
+ <div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
53
+ <Settings size={16} className="text-gray-400" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  </div>
55
+ <div>
56
+ <h1 className="text-lg font-light text-gray-100">System Config</h1>
57
+ <p className="text-[11px] text-gray-600">Backend health, daemon telemetry, and intelligence metrics</p>
58
  </div>
59
  </div>
60
+ <div className={`flex items-center gap-2 px-3 py-1.5 rounded-xl border ${overallOk ? 'border-emerald-500/15 bg-emerald-500/5' : 'border-red-500/15 bg-red-500/5'}`}>
61
+ <div className={`w-1.5 h-1.5 rounded-full ${overallOk ? 'bg-emerald-400' : 'bg-red-500'}`} />
62
+ <span className={`text-[10px] uppercase tracking-wider ${overallOk ? 'text-emerald-400' : 'text-red-400'}`}>
63
+ {overallOk ? 'Nominal' : 'Degraded'}
64
+ </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  </div>
66
+ </div>
67
+ </div>
68
 
69
+ {/* Content */}
70
+ <div className="flex-1 overflow-y-auto p-6">
71
+ <div className="max-w-5xl mx-auto space-y-5">
72
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
73
+ {/* Core system */}
74
+ <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="card p-5 relative overflow-hidden">
75
+ <div className="absolute top-0 right-0 p-3 opacity-[0.03]"><Cpu size={80} /></div>
76
+ <div className="flex items-center gap-2 text-[11px] text-indigo-400 uppercase tracking-wider mb-5 relative z-10">
77
+ <Activity size={13} /> Core Subsystems
78
+ </div>
79
+ <div className="space-y-3 relative z-10 text-[12px]">
80
+ <div className="flex items-center justify-between border-b border-white/[0.03] pb-2">
81
+ <span className="text-gray-500">Backend Version</span>
82
+ <span className="text-gray-300">v{health?.version || 'Unknown'}</span>
83
+ </div>
84
+ <div className="flex items-center justify-between border-b border-white/[0.03] pb-2">
85
+ <span className="text-gray-500">Daemon Status</span>
86
+ <span className={`text-[10px] px-2 py-0.5 rounded ${daemonStatus?.running ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'} uppercase tracking-wider`}>
87
+ {daemonStatus?.running ? 'Active' : 'Offline'}
88
+ </span>
89
+ </div>
90
+ <div className="flex items-center justify-between">
91
+ <span className="text-gray-500">Daemon Cycles</span>
92
+ <span className="text-gray-300">{daemonStatus?.cycle_count || 0}</span>
93
+ </div>
94
+ </div>
95
+ </motion.div>
96
+
97
+ {/* Intelligence Engine */}
98
+ <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }} className="card p-5 relative overflow-hidden">
99
+ <div className="absolute top-0 right-0 p-3 opacity-[0.03]"><Server size={80} /></div>
100
+ <div className="flex items-center gap-2 text-[11px] text-violet-400 uppercase tracking-wider mb-5 relative z-10">
101
+ <Brain size={13} /> Intelligence Engine
102
+ </div>
103
+ <div className="space-y-3 relative z-10 text-[12px]">
104
+ <div className="flex items-center justify-between border-b border-white/[0.03] pb-2">
105
+ <span className="text-gray-500">Circadian Phase</span>
106
+ <span className="text-gray-300 capitalize">{daemonStatus?.circadian?.current_phase || 'N/A'}</span>
107
+ </div>
108
+ <div className="flex items-center justify-between border-b border-white/[0.03] pb-2">
109
+ <span className="text-gray-500">Phase Priority</span>
110
+ <span className="text-gray-300 capitalize">{daemonStatus?.circadian?.priority || 'N/A'}</span>
111
+ </div>
112
+ <div className="flex items-center justify-between">
113
+ <span className="text-gray-500">Active Tasks</span>
114
+ <span className="text-gray-300">{daemonStatus?.circadian?.current_tasks?.length || 0}</span>
115
+ </div>
116
+ </div>
117
+ </motion.div>
118
+
119
+ {/* Data Sources */}
120
+ <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="card p-5 relative overflow-hidden">
121
+ <div className="absolute top-0 right-0 p-3 opacity-[0.03]"><Globe size={80} /></div>
122
+ <div className="flex items-center gap-2 text-[11px] text-emerald-400 uppercase tracking-wider mb-5 relative z-10">
123
+ <Globe size={13} /> Data Sources
124
+ </div>
125
+ <div className="space-y-3 relative z-10 text-[12px]">
126
+ <div className="flex items-center justify-between border-b border-white/[0.03] pb-2">
127
+ <span className="text-gray-500">Market Watcher</span>
128
+ <span className="text-gray-300">{daemonStatus?.watchlist?.length || 0} tickers</span>
129
+ </div>
130
+ <div className="flex items-center justify-between border-b border-white/[0.03] pb-2">
131
+ <span className="text-gray-500">News Pulse</span>
132
+ <span className="text-gray-300">{daemonStatus?.topics?.length || 0} topics</span>
133
+ </div>
134
+ <div className="flex items-center justify-between">
135
+ <span className="text-gray-500">Signals Collected</span>
136
+ <span className="text-gray-300">{daemonStatus?.signal_queue?.total_signals || daemonStatus?.signals || 0}</span>
137
+ </div>
138
+ </div>
139
+ </motion.div>
140
  </div>
141
 
142
+ {/* Big stats bar */}
143
+ <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} className="card p-8 flex flex-col md:flex-row items-center justify-around relative overflow-hidden">
144
+ <div className="absolute inset-0 bg-gradient-to-r from-indigo-500/[0.03] via-transparent to-violet-500/[0.03]" />
145
+ {[
146
+ { icon: HardDrive, value: memoryStats?.total_cases || 0, label: 'Cases Stored', color: 'text-indigo-400' },
147
+ { icon: Brain, value: curiosity?.total_discoveries || 0, label: 'Discoveries', color: 'text-violet-400' },
148
+ { icon: Zap, value: curiosity?.total_interests || 0, label: 'Topics', color: 'text-amber-400' },
149
+ { icon: Shield, value: daemonStatus?.cycle_count || 0, label: 'Daemon Cycles', color: 'text-emerald-400' },
150
+ ].map((stat, i) => (
151
+ <div key={stat.label} className="flex flex-col items-center gap-2 relative z-10 py-2">
152
+ <stat.icon size={20} className={stat.color + ' mb-1'} />
153
+ <span className="text-2xl font-light text-gray-100">{stat.value}</span>
154
+ <span className="text-[10px] text-gray-600 uppercase tracking-wider">{stat.label}</span>
155
+ </div>
156
+ ))}
157
+ </motion.div>
158
+ </div>
159
  </div>
160
  </div>
161
  );
frontend/src/app/globals.css CHANGED
@@ -2,9 +2,22 @@
2
 
3
  :root {
4
  --font-inter: 'Inter', system-ui, -apple-system, sans-serif;
5
- --janus-indigo: #6366f1;
6
- --janus-violet: #8b5cf6;
 
 
 
 
 
 
 
 
 
7
  --janus-glow: rgba(99, 102, 241, 0.15);
 
 
 
 
8
  }
9
 
10
  @theme inline {
@@ -19,8 +32,8 @@
19
 
20
  body {
21
  font-family: var(--font-inter);
22
- background-color: #030712;
23
- color: #f3f4f6;
24
  line-height: 1.6;
25
  -webkit-font-smoothing: antialiased;
26
  -moz-osx-font-smoothing: grayscale;
@@ -28,51 +41,34 @@ body {
28
  }
29
 
30
  /* ─── Scrollbar ─── */
31
- ::-webkit-scrollbar { width: 6px; height: 6px; }
32
  ::-webkit-scrollbar-track { background: transparent; }
33
- ::-webkit-scrollbar-thumb { background: #374151; border-radius: 999px; }
34
- ::-webkit-scrollbar-thumb:hover { background: #4b5563; }
35
 
36
  /* ─── Selection ─── */
37
- ::selection { background: rgba(99, 102, 241, 0.3); }
38
 
39
  /* ─── Focus ─── */
40
  *:focus-visible {
41
- outline: 1px solid rgba(99, 102, 241, 0.5);
42
  outline-offset: 2px;
43
  }
44
 
45
- /* ─── Signature Janus Orb Animation ─── */
46
- @keyframes orbPulse {
47
- 0%, 100% { transform: scale(1); opacity: 0.6; }
48
- 50% { transform: scale(1.15); opacity: 1; }
49
- }
50
-
51
- @keyframes orbRotate {
52
  from { transform: rotate(0deg); }
53
  to { transform: rotate(360deg); }
54
  }
55
 
56
- @keyframes floatUp {
57
- 0% { transform: translateY(0) scale(1); opacity: 0; }
58
- 10% { opacity: 1; }
59
- 90% { opacity: 0.6; }
60
- 100% { transform: translateY(-100vh) scale(0.5); opacity: 0; }
61
- }
62
-
63
- @keyframes scanLine {
64
- 0% { transform: translateY(-100%); }
65
- 100% { transform: translateY(400%); }
66
- }
67
-
68
- @keyframes typewriter {
69
- from { width: 0; }
70
- to { width: 100%; }
71
  }
72
 
73
- @keyframes blink {
74
- 0%, 50% { border-color: #6366f1; }
75
- 51%, 100% { border-color: transparent; }
76
  }
77
 
78
  @keyframes shimmer {
@@ -80,7 +76,13 @@ body {
80
  100% { background-position: 200% 0; }
81
  }
82
 
83
- /* The signature Janus "thinking" animation — 3 concentric rings that orbit */
 
 
 
 
 
 
84
  @keyframes ring1 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
85
  @keyframes ring2 { from { transform: rotate(120deg); } to { transform: rotate(-240deg); } }
86
  @keyframes ring3 { from { transform: rotate(240deg); } to { transform: rotate(600deg); } }
@@ -89,38 +91,49 @@ body {
89
  .janus-ring-2 { animation: ring2 4s linear infinite; }
90
  .janus-ring-3 { animation: ring3 5s linear infinite; }
91
 
92
- /* Floating particle animation */
93
- .particle { animation: floatUp var(--duration, 20s) linear infinite; animation-delay: var(--delay, 0s); }
 
 
 
 
 
94
 
95
- /* Scan line effect for research mode */
96
- .scan-line {
97
- animation: scanLine 2s linear infinite;
98
- background: linear-gradient(180deg, transparent, rgba(99, 102, 241, 0.08), transparent);
 
 
99
  }
100
 
101
- /* Shimmer loading effect */
102
- .shimmer {
103
- background: linear-gradient(90deg, transparent 25%, rgba(99, 102, 241, 0.08) 50%, transparent 75%);
104
- background-size: 200% 100%;
105
- animation: shimmer 2s ease-in-out infinite;
106
  }
107
 
108
- /* Glassmorphism card */
109
- .glass {
110
- background: rgba(255, 255, 255, 0.03);
111
- backdrop-filter: blur(24px);
112
- -webkit-backdrop-filter: blur(24px);
113
- border: 1px solid rgba(255, 255, 255, 0.06);
114
  }
115
 
116
- .glass-hover:hover {
117
- background: rgba(255, 255, 255, 0.06);
118
- border-color: rgba(99, 102, 241, 0.2);
 
 
 
 
 
 
 
119
  }
120
 
121
- /* Text utilities */
122
  .text-gradient {
123
- background: linear-gradient(135deg, #e0e7ff 0%, #6366f1 50%, #a78bfa 100%);
124
  -webkit-background-clip: text;
125
  -webkit-text-fill-color: transparent;
126
  background-clip: text;
@@ -133,19 +146,48 @@ body {
133
  background-clip: text;
134
  }
135
 
136
- /* Prose styling */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  .prose { max-width: 65ch; }
138
  .prose p { margin-bottom: 1em; }
139
- .prose-invert { color: #e5e7eb; }
140
- .prose-invert p { color: #d1d5db; }
141
-
142
- /* Line clamp */
 
 
 
 
 
 
143
  .line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
144
  .line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
145
  .line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
146
 
147
- /* Spin animation for orb rings */
148
- @keyframes spin {
149
- from { transform: rotate(0deg); }
150
- to { transform: rotate(360deg); }
151
- }
 
2
 
3
  :root {
4
  --font-inter: 'Inter', system-ui, -apple-system, sans-serif;
5
+ /* Janus palette */
6
+ --janus-bg: #0a0a0f;
7
+ --janus-surface: #111118;
8
+ --janus-surface-hover: #18181f;
9
+ --janus-border: rgba(255, 255, 255, 0.06);
10
+ --janus-border-hover: rgba(255, 255, 255, 0.12);
11
+ --janus-indigo: #818cf8;
12
+ --janus-violet: #a78bfa;
13
+ --janus-text: #e5e7eb;
14
+ --janus-text-muted: #6b7280;
15
+ --janus-text-dim: #374151;
16
  --janus-glow: rgba(99, 102, 241, 0.15);
17
+ /* Spacing */
18
+ --sidebar-width: 260px;
19
+ --sidebar-collapsed: 68px;
20
+ --input-max-width: 768px;
21
  }
22
 
23
  @theme inline {
 
32
 
33
  body {
34
  font-family: var(--font-inter);
35
+ background-color: var(--janus-bg);
36
+ color: var(--janus-text);
37
  line-height: 1.6;
38
  -webkit-font-smoothing: antialiased;
39
  -moz-osx-font-smoothing: grayscale;
 
41
  }
42
 
43
  /* ─── Scrollbar ─── */
44
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
45
  ::-webkit-scrollbar-track { background: transparent; }
46
+ ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 999px; }
47
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); }
48
 
49
  /* ─── Selection ─── */
50
+ ::selection { background: rgba(129, 140, 248, 0.3); }
51
 
52
  /* ─── Focus ─── */
53
  *:focus-visible {
54
+ outline: 1px solid rgba(129, 140, 248, 0.5);
55
  outline-offset: 2px;
56
  }
57
 
58
+ /* ─── Animations ─── */
59
+ @keyframes spin {
 
 
 
 
 
60
  from { transform: rotate(0deg); }
61
  to { transform: rotate(360deg); }
62
  }
63
 
64
+ @keyframes orbPulse {
65
+ 0%, 100% { transform: scale(1); opacity: 0.7; }
66
+ 50% { transform: scale(1.12); opacity: 1; }
 
 
 
 
 
 
 
 
 
 
 
 
67
  }
68
 
69
+ @keyframes fadeIn {
70
+ from { opacity: 0; transform: translateY(8px); }
71
+ to { opacity: 1; transform: translateY(0); }
72
  }
73
 
74
  @keyframes shimmer {
 
76
  100% { background-position: 200% 0; }
77
  }
78
 
79
+ @keyframes floatUp {
80
+ 0% { transform: translateY(0) scale(1); opacity: 0; }
81
+ 10% { opacity: 0.8; }
82
+ 90% { opacity: 0.4; }
83
+ 100% { transform: translateY(-100vh) scale(0.5); opacity: 0; }
84
+ }
85
+
86
  @keyframes ring1 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
87
  @keyframes ring2 { from { transform: rotate(120deg); } to { transform: rotate(-240deg); } }
88
  @keyframes ring3 { from { transform: rotate(240deg); } to { transform: rotate(600deg); } }
 
91
  .janus-ring-2 { animation: ring2 4s linear infinite; }
92
  .janus-ring-3 { animation: ring3 5s linear infinite; }
93
 
94
+ /* ─── Glassmorphism ─── */
95
+ .glass {
96
+ background: rgba(255, 255, 255, 0.025);
97
+ backdrop-filter: blur(20px);
98
+ -webkit-backdrop-filter: blur(20px);
99
+ border: 1px solid var(--janus-border);
100
+ }
101
 
102
+ .glass-hover {
103
+ transition: all 0.3s ease;
104
+ }
105
+ .glass-hover:hover {
106
+ background: rgba(255, 255, 255, 0.05);
107
+ border-color: var(--janus-border-hover);
108
  }
109
 
110
+ /* ─── Conversation Bubbles ─── */
111
+ .bubble-user {
112
+ background: rgba(129, 140, 248, 0.08);
113
+ border: 1px solid rgba(129, 140, 248, 0.12);
114
+ border-radius: 20px 20px 4px 20px;
115
  }
116
 
117
+ .bubble-janus {
118
+ background: transparent;
119
+ border-radius: 20px 20px 20px 4px;
 
 
 
120
  }
121
 
122
+ /* ─── Input Bar ─── */
123
+ .input-bar {
124
+ background: var(--janus-surface);
125
+ border: 1px solid var(--janus-border);
126
+ border-radius: 24px;
127
+ transition: all 0.3s ease;
128
+ }
129
+ .input-bar:focus-within {
130
+ border-color: rgba(129, 140, 248, 0.3);
131
+ box-shadow: 0 0 0 1px rgba(129, 140, 248, 0.1), 0 4px 24px rgba(0, 0, 0, 0.3);
132
  }
133
 
134
+ /* ─── Text Utilities ─── */
135
  .text-gradient {
136
+ background: linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #a78bfa 100%);
137
  -webkit-background-clip: text;
138
  -webkit-text-fill-color: transparent;
139
  background-clip: text;
 
146
  background-clip: text;
147
  }
148
 
149
+ /* ─── Page Container ─── */
150
+ .page-container {
151
+ max-width: 1400px;
152
+ margin: 0 auto;
153
+ padding: 2rem 2.5rem;
154
+ }
155
+
156
+ /* ─── Card ─── */
157
+ .card {
158
+ background: var(--janus-surface);
159
+ border: 1px solid var(--janus-border);
160
+ border-radius: 16px;
161
+ padding: 1.5rem;
162
+ transition: all 0.3s ease;
163
+ }
164
+ .card:hover {
165
+ border-color: var(--janus-border-hover);
166
+ }
167
+
168
+ /* ─── Shimmer Loading ─── */
169
+ .shimmer {
170
+ background: linear-gradient(90deg, transparent 25%, rgba(129, 140, 248, 0.06) 50%, transparent 75%);
171
+ background-size: 200% 100%;
172
+ animation: shimmer 2s ease-in-out infinite;
173
+ }
174
+
175
+ /* ─── Prose ─── */
176
  .prose { max-width: 65ch; }
177
  .prose p { margin-bottom: 1em; }
178
+ .prose h1, .prose h2, .prose h3 { margin-top: 1.5em; margin-bottom: 0.5em; font-weight: 500; }
179
+ .prose ul, .prose ol { margin-bottom: 1em; padding-left: 1.5em; }
180
+ .prose li { margin-bottom: 0.25em; }
181
+ .prose code { background: rgba(255,255,255,0.06); padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.875em; }
182
+ .prose pre { background: rgba(0,0,0,0.4); padding: 1em; border-radius: 8px; overflow-x: auto; margin-bottom: 1em; }
183
+ .prose pre code { background: transparent; padding: 0; }
184
+ .prose blockquote { border-left: 2px solid rgba(129,140,248,0.3); padding-left: 1em; color: #9ca3af; }
185
+ .prose-invert { color: #d1d5db; }
186
+
187
+ /* ─── Line Clamp ─── */
188
  .line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
189
  .line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
190
  .line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
191
 
192
+ /* ─── Particle ─── */
193
+ .particle { animation: floatUp var(--duration, 20s) linear infinite; animation-delay: var(--delay, 0s); }
 
 
 
frontend/src/app/intel/page.tsx ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import { motion, AnimatePresence } from 'framer-motion';
5
+ import {
6
+ Globe, Search, RefreshCw, ExternalLink, Sparkles,
7
+ TrendingUp, TrendingDown, Minus, Zap
8
+ } from 'lucide-react';
9
+ import { apiClient, financeClient } from '@/lib/api';
10
+ import type { CaseRecord } from '@/lib/types';
11
+
12
+ // ─── Sub-components ────────────────────────────────────────
13
+ function StanceChip({ stance, score }: { stance: string; score: number }) {
14
+ if (stance === 'bullish') return <span className="flex items-center gap-1 text-[10px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"><TrendingUp size={9} />Bullish {Math.round(score * 100)}%</span>;
15
+ if (stance === 'bearish') return <span className="flex items-center gap-1 text-[10px] text-red-400 bg-red-500/10 border border-red-500/20 px-2 py-0.5 rounded-full"><TrendingDown size={9} />Bearish {Math.round((1 - score) * 100)}%</span>;
16
+ return <span className="flex items-center gap-1 text-[10px] text-gray-500 bg-white/[0.04] border border-white/[0.06] px-2 py-0.5 rounded-full"><Minus size={9} />Neutral</span>;
17
+ }
18
+
19
+ function ArticleCard({ article, index, onResearch }: { article: any; index: number; onResearch: (q: string) => void }) {
20
+ return (
21
+ <motion.div
22
+ initial={{ opacity: 0, y: 8 }}
23
+ animate={{ opacity: 1, y: 0 }}
24
+ transition={{ delay: index * 0.04 }}
25
+ className="card hover:border-white/[0.12] group"
26
+ >
27
+ <div className="flex items-center gap-2 mb-2 flex-wrap">
28
+ <StanceChip stance={article.stance} score={article.sentiment_score} />
29
+ {article.source && <span className="text-[10px] text-gray-600">{article.source}</span>}
30
+ {article.published_at && <span className="text-[10px] text-gray-700">{new Date(article.published_at).toLocaleDateString()}</span>}
31
+ </div>
32
+ <h3 className="text-[14px] text-gray-200 leading-snug mb-2 group-hover:text-white transition-colors">{article.title}</h3>
33
+ {article.description && <p className="text-[12px] text-gray-500 leading-relaxed line-clamp-2">{article.description}</p>}
34
+ <div className="flex items-center gap-3 mt-3 pt-3 border-t border-white/[0.04]">
35
+ <button
36
+ onClick={() => onResearch(article.title + (article.description ? '. ' + article.description : ''))}
37
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/15 text-[11px] text-indigo-400 transition-all"
38
+ >
39
+ <Sparkles size={11} /> Deep Research
40
+ </button>
41
+ {article.url && (
42
+ <a href={article.url} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-[11px] text-gray-600 hover:text-gray-400 transition-colors">
43
+ <ExternalLink size={10} /> Source
44
+ </a>
45
+ )}
46
+ </div>
47
+ </motion.div>
48
+ );
49
+ }
50
+
51
+ const STAGES = ['Routing to switchboard...', 'Research agent scanning...', 'Cross-referencing sources...', 'Synthesizing analysis...'];
52
+
53
+ export default function IntelPage() {
54
+ const [headlines, setHeadlines] = useState<any[]>([]);
55
+ const [query, setQuery] = useState('');
56
+ const [loading, setLoading] = useState(false);
57
+ const [searched, setSearched] = useState(false);
58
+ const [researchResult, setResearchResult] = useState<CaseRecord | null>(null);
59
+ const [researchLoading, setResearchLoading] = useState(false);
60
+ const [researchStage, setResearchStage] = useState(0);
61
+ const [researchQuery, setResearchQuery] = useState('');
62
+ const stageTimer = useRef<ReturnType<typeof setInterval> | null>(null);
63
+
64
+ useEffect(() => {
65
+ financeClient.getHeadlines().then(data => setHeadlines(data)).catch(() => {});
66
+ }, []);
67
+
68
+ const searchNews = async () => {
69
+ if (!query.trim()) return;
70
+ setLoading(true); setSearched(true); setResearchResult(null);
71
+ try {
72
+ const data = await financeClient.analyzeNews(query, 10);
73
+ setHeadlines(data.articles || []);
74
+ } catch { /* silent */ }
75
+ finally { setLoading(false); }
76
+ };
77
+
78
+ const runResearch = async (articleText: string) => {
79
+ setResearchLoading(true); setResearchResult(null); setResearchQuery(articleText.slice(0, 80));
80
+ setResearchStage(0);
81
+ if (stageTimer.current) clearInterval(stageTimer.current);
82
+ stageTimer.current = setInterval(() => setResearchStage(p => (p + 1) % STAGES.length), 3000);
83
+ try {
84
+ const res = await apiClient.analyze({ user_input: articleText });
85
+ setResearchResult(res);
86
+ } catch { /* silent */ }
87
+ finally {
88
+ setResearchLoading(false);
89
+ if (stageTimer.current) clearInterval(stageTimer.current);
90
+ }
91
+ };
92
+
93
+ return (
94
+ <div className="h-full flex flex-col overflow-hidden">
95
+ {/* Header */}
96
+ <div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
97
+ <div className="flex items-center gap-3 mb-4">
98
+ <div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
99
+ <Globe size={16} className="text-indigo-400" />
100
+ </div>
101
+ <div>
102
+ <h1 className="text-lg font-light text-gray-100">Intel Stream</h1>
103
+ <p className="text-[11px] text-gray-600">Search and analyze news with Janus deep research</p>
104
+ </div>
105
+ </div>
106
+ <div className="flex gap-2">
107
+ <div className="flex-1 flex items-center gap-3 px-4 py-2.5 rounded-xl border border-white/[0.06] focus-within:border-indigo-500/30 transition-colors" style={{ background: 'var(--janus-surface)' }}>
108
+ <Search size={14} className="text-gray-600 shrink-0" />
109
+ <input
110
+ value={query}
111
+ onChange={e => setQuery(e.target.value)}
112
+ onKeyDown={e => e.key === 'Enter' && searchNews()}
113
+ placeholder="Search news — company, topic, event..."
114
+ className="flex-1 bg-transparent text-[13px] text-gray-200 placeholder-gray-600 focus:outline-none"
115
+ />
116
+ </div>
117
+ <button onClick={searchNews} disabled={loading} className="px-4 py-2 rounded-xl border border-white/[0.06] hover:border-indigo-500/20 text-[12px] text-gray-400 hover:text-indigo-300 transition-all disabled:opacity-40" style={{ background: 'var(--janus-surface)' }}>
118
+ {loading ? <RefreshCw size={14} className="animate-spin" /> : 'Search'}
119
+ </button>
120
+ </div>
121
+ </div>
122
+
123
+ {/* Content */}
124
+ <div className="flex-1 flex gap-0 overflow-hidden">
125
+ {/* Articles */}
126
+ <div className={`flex-1 overflow-y-auto p-4 space-y-3 transition-all duration-300 ${researchResult || researchLoading ? 'max-w-[50%]' : ''}`}>
127
+ <div className="text-[11px] text-gray-600 mb-2">
128
+ {searched ? `Results for "${query}"` : 'Top Business Headlines'} — click Deep Research on any article
129
+ </div>
130
+ {loading && (
131
+ <div className="flex flex-col items-center justify-center py-16 gap-3">
132
+ <RefreshCw size={20} className="text-indigo-400 animate-spin" />
133
+ <p className="text-[12px] text-gray-500">Fetching articles...</p>
134
+ </div>
135
+ )}
136
+ {!loading && headlines.map((a, i) => (
137
+ <ArticleCard key={`${a.title}-${i}`} article={a} index={i} onResearch={runResearch} />
138
+ ))}
139
+ {!loading && headlines.length === 0 && (
140
+ <div className="flex flex-col items-center justify-center py-16 text-center">
141
+ <Globe size={24} className="text-gray-700 mb-3" />
142
+ <p className="text-[13px] text-gray-500">No articles found.</p>
143
+ </div>
144
+ )}
145
+ </div>
146
+
147
+ {/* Research panel */}
148
+ <AnimatePresence>
149
+ {(researchResult || researchLoading) && (
150
+ <motion.div
151
+ initial={{ opacity: 0, x: 20 }}
152
+ animate={{ opacity: 1, x: 0 }}
153
+ exit={{ opacity: 0, x: 20 }}
154
+ className="flex-1 border-l border-white/[0.04] p-4 overflow-y-auto"
155
+ >
156
+ <div className="flex items-center justify-between mb-4">
157
+ <div className="text-[11px] text-gray-500 truncate">Research: {researchQuery}...</div>
158
+ <button
159
+ onClick={() => { setResearchResult(null); setResearchLoading(false); }}
160
+ className="text-[11px] text-gray-600 hover:text-gray-400 transition-colors"
161
+ >✕ Close</button>
162
+ </div>
163
+
164
+ {researchLoading ? (
165
+ <div className="card flex items-center gap-3 p-5">
166
+ <div className="w-6 h-6 rounded-full border border-indigo-500/30 border-t-indigo-400 animate-spin" />
167
+ <p className="text-[12px] text-indigo-400">{STAGES[researchStage]}</p>
168
+ </div>
169
+ ) : researchResult ? (
170
+ <div className="card p-5 space-y-4">
171
+ <div className="flex items-center gap-2 text-[10px] text-indigo-400 uppercase tracking-wider">
172
+ <Zap size={11} /> Research Complete
173
+ </div>
174
+ {researchResult.route && (
175
+ <div className="flex gap-2 flex-wrap">
176
+ <span className="px-2 py-0.5 rounded-full text-[10px] text-indigo-300 bg-indigo-500/10 border border-indigo-500/15">{researchResult.route.domain_pack}</span>
177
+ <span className="px-2 py-0.5 rounded-full text-[10px] text-gray-500 bg-white/[0.04] border border-white/[0.06]">{researchResult.route.execution_mode}</span>
178
+ </div>
179
+ )}
180
+ {researchResult.final_answer && (
181
+ <div className="text-[13px] text-gray-300 leading-relaxed whitespace-pre-wrap">{researchResult.final_answer}</div>
182
+ )}
183
+ </div>
184
+ ) : null}
185
+ </motion.div>
186
+ )}
187
+ </AnimatePresence>
188
+ </div>
189
+ </div>
190
+ );
191
+ }
frontend/src/app/layout.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import type { Metadata } from "next";
2
  import { Inter } from "next/font/google";
3
  import "./globals.css";
 
4
 
5
  const inter = Inter({
6
  subsets: ["latin"],
@@ -19,8 +20,8 @@ export default function RootLayout({
19
  }>) {
20
  return (
21
  <html lang="en" className={`${inter.variable} h-full antialiased`}>
22
- <body className="h-full bg-gray-950 text-gray-100 font-sans">
23
- {children}
24
  </body>
25
  </html>
26
  );
 
1
  import type { Metadata } from "next";
2
  import { Inter } from "next/font/google";
3
  import "./globals.css";
4
+ import AppShell from "@/components/AppShell";
5
 
6
  const inter = Inter({
7
  subsets: ["latin"],
 
20
  }>) {
21
  return (
22
  <html lang="en" className={`${inter.variable} h-full antialiased`}>
23
+ <body className="h-full font-sans" style={{ background: 'var(--janus-bg)', color: 'var(--janus-text)' }}>
24
+ <AppShell>{children}</AppShell>
25
  </body>
26
  </html>
27
  );
frontend/src/app/markets/page.tsx ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+ import { motion, AnimatePresence } from 'framer-motion';
5
+ import {
6
+ Search, BarChart3, TrendingUp, TrendingDown, Minus,
7
+ AlertTriangle, Sparkles, Zap, RefreshCw, Scan, ExternalLink
8
+ } from 'lucide-react';
9
+ import { financeClient, apiClient } from '@/lib/api';
10
+ import type { CaseRecord } from '@/lib/types';
11
+ import { createChart, ColorType, CrosshairMode, CandlestickSeries, HistogramSeries } from 'lightweight-charts';
12
+ import type { IChartApi, CandlestickData, HistogramData } from 'lightweight-charts';
13
+
14
+ // ─── Chips ─────────────────────────────────────────────────
15
+ function StanceChip({ stance, score }: { stance: string; score: number }) {
16
+ if (stance === 'bullish') return <span className="flex items-center gap-1 text-[10px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"><TrendingUp size={9} />Bull {Math.round(score * 100)}%</span>;
17
+ if (stance === 'bearish') return <span className="flex items-center gap-1 text-[10px] text-red-400 bg-red-500/10 border border-red-500/20 px-2 py-0.5 rounded-full"><TrendingDown size={9} />Bear {Math.round((1 - score) * 100)}%</span>;
18
+ return <span className="flex items-center gap-1 text-[10px] text-gray-500 bg-white/[0.04] border border-white/[0.06] px-2 py-0.5 rounded-full"><Minus size={9} />Neutral</span>;
19
+ }
20
+
21
+ function SignalBadge({ signal, conviction }: { signal: string; conviction: number }) {
22
+ const map: Record<string, string> = { BUY: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30', SELL: 'bg-red-500/15 text-red-300 border-red-500/30', HOLD: 'bg-amber-500/15 text-amber-300 border-amber-500/30', WATCH: 'bg-indigo-500/15 text-indigo-300 border-indigo-500/30' };
23
+ return (
24
+ <div className={`inline-flex items-center gap-2 px-3 py-1 rounded-xl border ${map[signal] || map.WATCH}`}>
25
+ <span className="text-[12px] font-semibold">{signal}</span>
26
+ <span className="text-[10px] opacity-70">{Math.round(conviction * 100)}%</span>
27
+ </div>
28
+ );
29
+ }
30
+
31
+ // ─── Chart ─────────────────────────────────────────────────
32
+ function CandlestickChart({ symbol, companyName, price, change, changePct, isPositive }: { symbol: string; companyName: string; price?: string; change?: string; changePct?: string; isPositive?: boolean }) {
33
+ const chartContainerRef = useRef<HTMLDivElement>(null);
34
+ const chartRef = useRef<IChartApi | null>(null);
35
+ const seriesRef = useRef<ReturnType<IChartApi['addSeries']> | null>(null);
36
+ const volumeSeriesRef = useRef<ReturnType<IChartApi['addSeries']> | null>(null);
37
+ const [timeframe, setTimeframe] = useState<'1D' | '1W' | '1M' | '3M' | '1Y'>('1M');
38
+
39
+ const generateChartData = useCallback((basePrice: number, tf: string): CandlestickData[] => {
40
+ const data: CandlestickData[] = [];
41
+ let currentPrice = basePrice;
42
+ const volatility = basePrice * 0.02;
43
+ const now = new Date();
44
+ let days = tf === '1D' ? 1 : tf === '1W' ? 7 : tf === '1M' ? 30 : tf === '3M' ? 90 : 365;
45
+ const startDate = new Date(now);
46
+ startDate.setDate(startDate.getDate() - days);
47
+ for (let i = 0; i < days; i++) {
48
+ const date = new Date(startDate);
49
+ date.setDate(date.getDate() + i);
50
+ if (date.getDay() === 0 || date.getDay() === 6) continue;
51
+ const dayVol = volatility * (0.5 + Math.random());
52
+ const open = currentPrice;
53
+ const high = open + Math.random() * dayVol;
54
+ const low = open - Math.random() * dayVol;
55
+ const close = open + (Math.random() - 0.48) * dayVol;
56
+ data.push({ time: date.toISOString().split('T')[0], open: +open.toFixed(2), high: +high.toFixed(2), low: +low.toFixed(2), close: +close.toFixed(2) });
57
+ currentPrice = close;
58
+ }
59
+ return data;
60
+ }, []);
61
+
62
+ const generateVolumeData = useCallback((candleData: CandlestickData[]): HistogramData[] => {
63
+ return candleData.map(c => ({ time: c.time, value: Math.floor(Math.random() * 10000000) + 1000000, color: c.close >= c.open ? 'rgba(34,197,94,0.25)' : 'rgba(239,68,68,0.25)' }));
64
+ }, []);
65
+
66
+ useEffect(() => {
67
+ if (!chartContainerRef.current) return;
68
+ const chart = createChart(chartContainerRef.current, {
69
+ layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: '#6b7280', fontSize: 11 },
70
+ grid: { vertLines: { color: 'rgba(255,255,255,0.02)' }, horzLines: { color: 'rgba(255,255,255,0.02)' } },
71
+ crosshair: { mode: CrosshairMode.Normal },
72
+ rightPriceScale: { borderColor: 'rgba(255,255,255,0.04)', scaleMargins: { top: 0.1, bottom: 0.25 } },
73
+ timeScale: { borderColor: 'rgba(255,255,255,0.04)', timeVisible: false },
74
+ handleScroll: true, handleScale: true,
75
+ });
76
+ const candleSeries = chart.addSeries(CandlestickSeries, { upColor: '#22c55e', downColor: '#ef4444', borderUpColor: '#22c55e', borderDownColor: '#ef4444', wickUpColor: '#22c55e', wickDownColor: '#ef4444' });
77
+ const volumeSeries = chart.addSeries(HistogramSeries, { priceFormat: { type: 'volume' }, priceScaleId: 'volume' });
78
+ chart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } });
79
+ chartRef.current = chart; seriesRef.current = candleSeries; volumeSeriesRef.current = volumeSeries;
80
+ return () => { chart.remove(); chartRef.current = null; seriesRef.current = null; volumeSeriesRef.current = null; };
81
+ }, []);
82
+
83
+ useEffect(() => {
84
+ if (!price || !seriesRef.current || !volumeSeriesRef.current) return;
85
+ const basePrice = parseFloat(price);
86
+ if (isNaN(basePrice)) return;
87
+ const candles = generateChartData(basePrice, timeframe);
88
+ const volumes = generateVolumeData(candles);
89
+ seriesRef.current.setData(candles); volumeSeriesRef.current.setData(volumes);
90
+ if (chartRef.current) chartRef.current.timeScale().fitContent();
91
+ }, [price, timeframe, generateChartData, generateVolumeData]);
92
+
93
+ return (
94
+ <div className="card p-0 overflow-hidden">
95
+ <div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]">
96
+ <div>
97
+ <div className="flex items-center gap-2"><span className="text-lg font-light text-white">{symbol}</span><span className="text-[13px] text-gray-500">{companyName}</span></div>
98
+ {price && (
99
+ <div className="flex items-center gap-3 mt-1">
100
+ <span className="text-2xl font-light text-white">{parseFloat(price).toFixed(2)}</span>
101
+ {change && changePct && <span className={`text-[13px] ${isPositive ? 'text-emerald-400' : 'text-red-400'}`}>{isPositive ? '+' : ''}{parseFloat(change).toFixed(2)} ({changePct})</span>}
102
+ </div>
103
+ )}
104
+ </div>
105
+ <div className="flex items-center gap-1">{(['1D','1W','1M','3M','1Y'] as const).map(tf => (
106
+ <button key={tf} onClick={() => setTimeframe(tf)} className={`px-3 py-1 rounded-lg text-[11px] transition-all ${timeframe === tf ? 'bg-indigo-500/15 text-indigo-300 border border-indigo-500/20' : 'text-gray-600 hover:text-gray-400 hover:bg-white/[0.03]'}`}>{tf}</button>
107
+ ))}</div>
108
+ </div>
109
+ <div className="h-72"><div ref={chartContainerRef} className="w-full h-full" /></div>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ // ─── Research Panel ────────────────────────────────────────
115
+ const STAGES = ['Routing to switchboard...', 'Scanning sources...', 'Cross-referencing...', 'Synthesizing...'];
116
+
117
+ // ═══════════════════════════════════════════════════════════
118
+ export default function MarketsPage() {
119
+ const [query, setQuery] = useState('');
120
+ const [searchResults, setSearchResults] = useState<{ symbol: string; name: string; region?: string }[]>([]);
121
+ const [intel, setIntel] = useState<any>(null);
122
+ const [loading, setLoading] = useState(false);
123
+ const [news, setNews] = useState<any[]>([]);
124
+ const [newsLoading, setNewsLoading] = useState(false);
125
+ const [activeSymbol, setActiveSymbol] = useState('');
126
+ const [error, setError] = useState<string | null>(null);
127
+ const [researchResult, setResearchResult] = useState<CaseRecord | null>(null);
128
+ const [researchLoading, setResearchLoading] = useState(false);
129
+ const [researchStage, setResearchStage] = useState(0);
130
+ const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
131
+ const stageTimer = useRef<ReturnType<typeof setInterval> | null>(null);
132
+
133
+ const handleQueryChange = (val: string) => {
134
+ setQuery(val);
135
+ if (searchTimer.current) clearTimeout(searchTimer.current);
136
+ if (!val.trim()) { setSearchResults([]); return; }
137
+ searchTimer.current = setTimeout(async () => {
138
+ try { setSearchResults(await financeClient.searchTicker(val)); } catch { setSearchResults([]); }
139
+ }, 400);
140
+ };
141
+
142
+ const loadTicker = useCallback(async (symbol: string) => {
143
+ setLoading(true); setIntel(null); setNews([]); setActiveSymbol(symbol); setSearchResults([]); setError(null); setResearchResult(null);
144
+ try {
145
+ const data = await financeClient.getTickerIntelligence(symbol);
146
+ setIntel(data); setNewsLoading(true);
147
+ try { const nd = await financeClient.analyzeNews(data.company_name || symbol, 8); setNews(nd.articles || []); } catch { /* silent */ } finally { setNewsLoading(false); }
148
+ } catch { setError(`Could not load intelligence for ${symbol}. Ensure ALPHAVANTAGE_API_KEY is set.`); }
149
+ finally { setLoading(false); }
150
+ }, []);
151
+
152
+ const runDeepResearch = async () => {
153
+ if (!intel) return;
154
+ const q = `Analyze ${intel.company_name} (${intel.symbol}) stock. ${intel.overview?.description || ''}`.slice(0, 500);
155
+ setResearchLoading(true); setResearchResult(null); setResearchStage(0);
156
+ if (stageTimer.current) clearInterval(stageTimer.current);
157
+ stageTimer.current = setInterval(() => setResearchStage(p => (p + 1) % STAGES.length), 3000);
158
+ try { const res = await apiClient.analyze({ user_input: q }); setResearchResult(res); } catch { /* silent */ }
159
+ finally { setResearchLoading(false); if (stageTimer.current) clearInterval(stageTimer.current); }
160
+ };
161
+
162
+ const price = intel?.quote?.['05. price'];
163
+ const change = intel?.quote?.['09. change'];
164
+ const changePct = intel?.quote?.['10. change percent'];
165
+ const isPositive = change && parseFloat(change) >= 0;
166
+
167
+ const quickTickers = [
168
+ { s: 'RELIANCE.BSE', l: 'Reliance' },
169
+ { s: 'TCS.BSE', l: 'TCS' },
170
+ { s: 'AAPL', l: 'Apple' },
171
+ { s: 'TSLA', l: 'Tesla' },
172
+ { s: 'MSFT', l: 'Microsoft' },
173
+ ];
174
+
175
+ return (
176
+ <div className="h-full flex flex-col overflow-hidden">
177
+ {/* Header */}
178
+ <div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
179
+ <div className="flex items-center gap-3 mb-4">
180
+ <div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
181
+ <BarChart3 size={16} className="text-indigo-400" />
182
+ </div>
183
+ <div>
184
+ <h1 className="text-lg font-light text-gray-100">Markets</h1>
185
+ <p className="text-[11px] text-gray-600">Ticker intelligence powered by Alpha Vantage + Janus AI</p>
186
+ </div>
187
+ </div>
188
+ <div className="relative">
189
+ <div className="flex items-center gap-3 px-4 py-2.5 rounded-xl border border-white/[0.06] focus-within:border-indigo-500/30 transition-colors" style={{ background: 'var(--janus-surface)' }}>
190
+ <Search size={14} className="text-gray-600 shrink-0" />
191
+ <input
192
+ value={query}
193
+ onChange={e => handleQueryChange(e.target.value)}
194
+ onKeyDown={e => { if (e.key === 'Enter') { setSearchResults([]); loadTicker(query.toUpperCase()); } }}
195
+ placeholder="Search — RELIANCE, TCS, AAPL, TSLA..."
196
+ className="flex-1 bg-transparent text-[13px] text-gray-200 placeholder-gray-600 focus:outline-none"
197
+ />
198
+ {query && (
199
+ <button onClick={() => { setSearchResults([]); loadTicker(query.toUpperCase()); }} className="px-3 py-1 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-[11px] transition-colors">
200
+ Analyze
201
+ </button>
202
+ )}
203
+ </div>
204
+ {/* Autocomplete */}
205
+ <AnimatePresence>
206
+ {searchResults.length > 0 && (
207
+ <motion.div initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute top-full left-0 right-0 mt-1 rounded-xl border border-white/[0.08] overflow-hidden z-20" style={{ background: 'var(--janus-surface)' }}>
208
+ {searchResults.slice(0, 6).map(r => (
209
+ <button key={r.symbol} onClick={() => { setQuery(r.symbol); loadTicker(r.symbol); }}
210
+ className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-white/[0.04] transition-colors text-left">
211
+ <span className="text-[13px] text-indigo-300">{r.symbol}</span>
212
+ <div className="flex items-center gap-3">
213
+ {r.region && <span className="text-[10px] text-gray-600 bg-white/[0.04] px-1.5 py-0.5 rounded">{r.region}</span>}
214
+ <span className="text-[12px] text-gray-500 truncate max-w-[200px]">{r.name}</span>
215
+ </div>
216
+ </button>
217
+ ))}
218
+ </motion.div>
219
+ )}
220
+ </AnimatePresence>
221
+ </div>
222
+ </div>
223
+
224
+ {/* Content */}
225
+ <div className="flex-1 overflow-y-auto p-6 space-y-5">
226
+ {loading && (
227
+ <div className="flex flex-col items-center justify-center py-20 gap-3">
228
+ <RefreshCw size={20} className="text-indigo-400 animate-spin" />
229
+ <p className="text-[12px] text-indigo-400">Fetching intelligence for {activeSymbol}...</p>
230
+ </div>
231
+ )}
232
+
233
+ {!loading && error && (
234
+ <div className="flex flex-col items-center justify-center py-20 gap-3">
235
+ <AlertTriangle size={24} className="text-amber-500/50" />
236
+ <p className="text-[13px] text-amber-400 max-w-md text-center">{error}</p>
237
+ </div>
238
+ )}
239
+
240
+ {!loading && !intel && !error && (
241
+ <div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
242
+ <BarChart3 size={28} className="text-gray-700" />
243
+ <p className="text-[13px] text-gray-500">Search any stock — Indian or global</p>
244
+ <div className="flex gap-2 mt-3 flex-wrap justify-center">
245
+ {quickTickers.map(({ s, l }) => (
246
+ <button key={s} onClick={() => { setQuery(s); loadTicker(s); }} className="px-3 py-1.5 rounded-full border border-white/[0.06] hover:border-indigo-500/20 text-[12px] text-gray-500 hover:text-indigo-300 transition-all">{l}</button>
247
+ ))}
248
+ </div>
249
+ </div>
250
+ )}
251
+
252
+ {!loading && intel && (
253
+ <>
254
+ <CandlestickChart symbol={intel.symbol} companyName={intel.company_name} price={price} change={change} changePct={changePct} isPositive={!!isPositive} />
255
+
256
+ {/* Overview card */}
257
+ <div className="card p-5">
258
+ <div className="flex items-start justify-between gap-4 flex-wrap">
259
+ <div>
260
+ <div className="flex items-center gap-3 mb-1">
261
+ <span className="text-xl font-light text-white">{intel.symbol}</span>
262
+ <span className="text-[13px] text-gray-500">{intel.company_name}</span>
263
+ </div>
264
+ <div className="flex items-center gap-2 mt-2 flex-wrap">
265
+ {intel.overview?.sector && <span className="text-[10px] text-gray-500 bg-white/[0.04] px-2 py-0.5 rounded">{intel.overview.sector}</span>}
266
+ {intel.overview?.industry && <span className="text-[10px] text-gray-500 bg-white/[0.04] px-2 py-0.5 rounded">{intel.overview.industry}</span>}
267
+ </div>
268
+ </div>
269
+ <div className="flex flex-col items-end gap-2">
270
+ {intel.ai_signal && <SignalBadge signal={intel.ai_signal.signal} conviction={intel.ai_signal.conviction} />}
271
+ {intel.stance && <StanceChip stance={intel.stance.stance} score={intel.stance.sentiment_score} />}
272
+ </div>
273
+ </div>
274
+
275
+ {intel.ai_signal?.reasoning && (
276
+ <div className="mt-4 pt-4 border-t border-white/[0.04]">
277
+ <div className="flex items-center gap-2 mb-1.5 text-[10px] text-indigo-400 uppercase tracking-wider"><Zap size={10} /> AI Signal</div>
278
+ <p className="text-[12px] text-gray-400 leading-relaxed">{intel.ai_signal.reasoning}</p>
279
+ </div>
280
+ )}
281
+
282
+ <div className="mt-4 pt-4 border-t border-white/[0.04]">
283
+ <button onClick={runDeepResearch} disabled={researchLoading} className="flex items-center gap-2 px-4 py-2 rounded-xl bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/15 text-[12px] text-indigo-400 transition-all disabled:opacity-40">
284
+ <Sparkles size={12} className={researchLoading ? 'animate-pulse' : ''} />
285
+ {researchLoading ? 'Running research...' : 'Deep Research — Full Pipeline'}
286
+ </button>
287
+ </div>
288
+ </div>
289
+
290
+ {/* Research result */}
291
+ {(researchResult || researchLoading) && (
292
+ <div className="card p-5">
293
+ {researchLoading ? (
294
+ <div className="flex items-center gap-3">
295
+ <div className="w-5 h-5 rounded-full border border-indigo-500/30 border-t-indigo-400 animate-spin" />
296
+ <p className="text-[12px] text-indigo-400">{STAGES[researchStage]}</p>
297
+ </div>
298
+ ) : researchResult ? (
299
+ <div className="space-y-3">
300
+ <div className="flex items-center gap-2 text-[10px] text-indigo-400 uppercase tracking-wider"><Zap size={11} /> Research Complete</div>
301
+ {researchResult.final_answer && (
302
+ <div className="text-[13px] text-gray-300 leading-relaxed whitespace-pre-wrap">{researchResult.final_answer}</div>
303
+ )}
304
+ </div>
305
+ ) : null}
306
+ </div>
307
+ )}
308
+
309
+ {/* Fundamentals + Events grid */}
310
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
311
+ <div className="card p-4 space-y-2">
312
+ <div className="text-[10px] text-gray-500 uppercase tracking-wider mb-3">Fundamentals</div>
313
+ {(() => {
314
+ const rows: [string, string | undefined][] = [
315
+ ['P/E Ratio', intel.overview?.pe_ratio],
316
+ ['Market Cap', intel.overview?.market_cap ? (Number(intel.overview.market_cap) > 1e9 ? `$${(Number(intel.overview.market_cap) / 1e9).toFixed(1)}B` : Number(intel.overview.market_cap) > 1e6 ? `$${(Number(intel.overview.market_cap) / 1e6).toFixed(0)}M` : intel.overview.market_cap) : undefined],
317
+ ['52W High', intel.overview?.['52_week_high']],
318
+ ['52W Low', intel.overview?.['52_week_low']],
319
+ ['Analyst Target', intel.overview?.analyst_target],
320
+ ['Volume', intel.quote?.['06. volume'] ? Number(intel.quote['06. volume']).toLocaleString() : undefined],
321
+ ['Previous Close', intel.quote?.['08. previous close']],
322
+ ['Day High', intel.quote?.['03. high']],
323
+ ['Day Low', intel.quote?.['04. low']],
324
+ ['Open', intel.quote?.['02. open']],
325
+ ];
326
+ const validRows = rows.filter(([, v]) => v && v !== 'None' && v !== '-' && v !== 'N/A' && v !== '0' && v !== 'undefined');
327
+ return validRows.length > 0 ? (
328
+ validRows.map(([k, v]) => (
329
+ <div key={k} className="flex justify-between text-[12px] border-b border-white/[0.03] pb-1.5">
330
+ <span className="text-gray-500">{k}</span><span className="text-gray-300">{v}</span>
331
+ </div>
332
+ ))
333
+ ) : (
334
+ <div className="text-center py-6">
335
+ <p className="text-[12px] text-gray-600">No fundamental data available.</p>
336
+ <p className="text-[10px] text-gray-700 mt-1">Ticker may not be covered by Alpha Vantage.</p>
337
+ </div>
338
+ );
339
+ })()}
340
+ </div>
341
+ <div className="card p-4 space-y-3">
342
+ <div className="text-[10px] text-gray-500 uppercase tracking-wider mb-3">Event Intelligence</div>
343
+ {intel.event_impact && intel.event_impact.event_count > 0 ? (
344
+ <>
345
+ <div className="text-[12px]"><span className="text-gray-500">Impact: </span><span className={intel.event_impact.impact_level === 'high' || intel.event_impact.impact_level === 'very_high' ? 'text-amber-400' : 'text-gray-300'}>{intel.event_impact.impact_level}</span></div>
346
+ <div className="text-[12px]"><span className="text-gray-500">Volatility: </span><span className={intel.event_impact.volatility_level === 'high' || intel.event_impact.volatility_level === 'very_high' ? 'text-red-400' : 'text-gray-300'}>{intel.event_impact.volatility_level}</span></div>
347
+ <div className="text-[12px]"><span className="text-gray-500">Events: </span><span className="text-gray-300">{intel.event_impact.event_count} detected</span></div>
348
+ {intel.event_impact.detected_events && intel.event_impact.detected_events.length > 0 && (
349
+ <div className="flex flex-wrap gap-1.5 mt-2 pt-2 border-t border-white/[0.04]">
350
+ {intel.event_impact.detected_events.map((ev: any, i: number) => (
351
+ <span key={i} className="px-2 py-0.5 rounded text-[9px] text-amber-300 bg-amber-500/10 border border-amber-500/15 uppercase tracking-wider">
352
+ {ev.event_type?.replace(/_/g, ' ')}
353
+ </span>
354
+ ))}
355
+ </div>
356
+ )}
357
+ </>
358
+ ) : (
359
+ <>
360
+ <div className="text-[12px]"><span className="text-gray-500">Impact: </span><span className="text-emerald-400">Low</span></div>
361
+ <div className="text-[12px]"><span className="text-gray-500">Volatility: </span><span className="text-emerald-400">Low</span></div>
362
+ <div className="text-[12px]"><span className="text-gray-500">Events: </span><span className="text-gray-300">None detected</span></div>
363
+ <p className="text-[10px] text-gray-700 mt-2 pt-2 border-t border-white/[0.04]">
364
+ No significant market events detected in recent news for this ticker. This usually indicates stable conditions.
365
+ </p>
366
+ </>
367
+ )}
368
+ </div>
369
+ </div>
370
+
371
+ {/* News */}
372
+ {(news.length > 0 || newsLoading) && (
373
+ <div>
374
+ <div className="flex items-center gap-2 text-[11px] text-gray-500 uppercase tracking-wider mb-3">
375
+ <Scan size={11} className="text-indigo-400" /> News
376
+ {newsLoading && <RefreshCw size={10} className="animate-spin text-indigo-400" />}
377
+ </div>
378
+ <div className="space-y-3">
379
+ {news.map((a: any, i: number) => (
380
+ <div key={`${a.title}-${i}`} className="card p-3 hover:border-white/[0.12]">
381
+ <p className="text-[13px] text-gray-300 leading-snug mb-1">{a.title}</p>
382
+ {a.description && <p className="text-[11px] text-gray-600 line-clamp-1">{a.description}</p>}
383
+ <div className="flex items-center gap-3 mt-2">
384
+ {a.source && <span className="text-[10px] text-gray-600">{a.source}</span>}
385
+ {a.url && <a href={a.url} target="_blank" rel="noreferrer" className="text-[10px] text-indigo-400 hover:text-indigo-300 flex items-center gap-0.5"><ExternalLink size={8} /> Read</a>}
386
+ </div>
387
+ </div>
388
+ ))}
389
+ </div>
390
+ </div>
391
+ )}
392
+ </>
393
+ )}
394
+ </div>
395
+ </div>
396
+ );
397
+ }
frontend/src/app/page.tsx CHANGED
@@ -2,950 +2,386 @@
2
 
3
  import { useState, useEffect, useRef, useCallback } from 'react';
4
  import { motion, AnimatePresence } from 'framer-motion';
5
- import {
6
- Send, Zap, Sparkles, Activity, Search, TrendingUp, TrendingDown,
7
- Minus, AlertTriangle, Shield, CheckCircle, Globe, BarChart3,
8
- RefreshCw, ExternalLink, Eye, Scan, ChevronRight, X, Play,
9
- Clock, Target, AlertCircle, Layers, Brain, Cpu, ArrowRight,
10
- FileText, MessageSquare, ChevronDown, Terminal, Radio, GitBranch,
11
- Bell, Moon, Sun, Sunrise, Sunset, Activity as PulseIcon, Database, Network,
12
- ChevronUp, Star, Hash, Eye as EyeIcon, Info, Plus,
13
- ChevronLeft, ChevronRight as ChevronRightIcon, Menu, X as XIcon
14
- } from 'lucide-react';
15
- import { apiClient, financeClient } from '@/lib/api';
16
- import type { CaseRecord } from '@/lib/types';
17
- import { createChart, ColorType, CrosshairMode, CandlestickSeries, HistogramSeries } from 'lightweight-charts';
18
- import type { IChartApi, CandlestickData, HistogramData } from 'lightweight-charts';
19
-
20
- // ─── Types ───────────────────────────────────────────────────
21
- interface DaemonStatus {
22
- running: boolean;
23
- cycle_count: number;
24
- last_run: string;
25
- circadian: {
26
- current_phase: string;
27
- phase_name: string;
28
- phase_description: string;
29
- priority: string;
30
- current_tasks: string[];
31
- };
32
- signal_queue?: {
33
- total_signals: number;
34
- severity_counts: Record<string, number>;
35
- type_counts: Record<string, number>;
36
  };
37
- signals?: number;
38
- watchlist?: string[];
39
- topics?: string[];
40
- }
41
-
42
- interface Alert {
43
- type: string;
44
- title: string;
45
- description: string;
46
- source: string;
47
- severity: string;
48
- sentiment: string;
49
- timestamp: string;
50
- url?: string;
51
  }
52
 
53
- interface MemoryStats {
54
- queries: number;
55
- entities: number;
56
- insights: number;
57
- }
58
-
59
- // ─── Nav Items ───────────────────────────────────────────────
60
- const navItems = [
61
- { id: 'command', label: 'Command', icon: Sparkles, section: 'Main' },
62
- { id: 'intel', label: 'Intel Stream', icon: Globe, section: 'Main' },
63
- { id: 'markets', label: 'Markets', icon: BarChart3, section: 'Main' },
64
- { id: 'workspace', label: 'Workspace', icon: Layers, section: 'Main' },
65
- { id: 'pulse', label: 'Pulse', icon: PulseIcon, section: 'Main' },
66
- { id: 'cases', label: 'Cases', icon: GitBranch, section: 'System' },
67
- { id: 'simulation', label: 'Simulations', icon: Zap, section: 'System' },
68
- { id: 'sentinel', label: 'Sentinel', icon: Shield, section: 'System' },
69
- { id: 'prompts', label: 'Prompt Lab', icon: Terminal, section: 'System' },
70
- { id: 'config', label: 'Config', icon: Cpu, section: 'System' },
71
- ];
72
-
73
- // ─── Janus Orb ───────────────────────────────────────────────
74
- function JanusOrb({ size = 32, thinking = false, phase = 'daytime' }: { size?: number; thinking?: boolean; phase?: string }) {
75
- const phaseColors: Record<string, { from: string; to: string; shadow: string }> = {
76
- morning: { from: '#fbbf24', to: '#f59e0b', shadow: 'rgba(251,191,36,0.4)' },
77
- daytime: { from: '#818cf8', to: '#4f46e5', shadow: 'rgba(99,102,241,0.4)' },
78
- evening: { from: '#a78bfa', to: '#7c3aed', shadow: 'rgba(167,139,250,0.4)' },
79
- night: { from: '#6366f1', to: '#312e81', shadow: 'rgba(99,102,241,0.3)' },
80
- };
81
- const colors = phaseColors[phase] || phaseColors.daytime;
82
  return (
83
  <div className="relative shrink-0" style={{ width: size, height: size }}>
84
- <motion.div className="absolute inset-0 rounded-full"
85
- style={{ background: `radial-gradient(circle at 30% 30%, ${colors.from}, ${colors.to} 50%, #0f172a 100%)`, boxShadow: thinking ? `0 0 30px ${colors.shadow}, 0 0 60px ${colors.shadow}` : `0 0 15px ${colors.shadow}` }}
86
- animate={thinking ? { scale: [1, 1.12, 1] } : { scale: [1, 1.03, 1] }} transition={{ duration: thinking ? 1.2 : 4, repeat: Infinity, ease: 'easeInOut' }} />
87
- {thinking && <>
88
- <div className="absolute inset-[-6px] rounded-full border border-indigo-400/30" style={{ borderTopColor: 'transparent', borderBottomColor: 'transparent', animation: 'spin 2s linear infinite' }} />
89
- <div className="absolute inset-[-12px] rounded-full border border-violet-400/20" style={{ borderLeftColor: 'transparent', borderRightColor: 'transparent', animation: 'spin 3s linear infinite reverse' }} />
90
- </>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  </div>
92
  );
93
  }
94
 
95
- // ─── Art Piece ───────────────────────────────────────────────
96
- function ArtPiece({ onUnlock }: { onUnlock: () => void }) {
97
- const [show, setShow] = useState(false);
98
- useEffect(() => { const t = setTimeout(() => setShow(true), 2000); return () => clearTimeout(t); }, []);
99
- return (
100
- <motion.div key="art" exit={{ opacity: 0, scale: 1.05, filter: 'blur(30px)' }} transition={{ duration: 1.5 }}
101
- className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-gray-950 overflow-hidden">
102
- <motion.div className="absolute w-[500px] h-[500px] rounded-full"
103
- style={{ background: 'radial-gradient(circle, rgba(99,102,241,0.12) 0%, rgba(139,92,246,0.06) 40%, transparent 70%)' }}
104
- animate={{ scale: [1, 1.2, 1], opacity: [0.5, 0.8, 0.5] }} transition={{ duration: 6, repeat: Infinity }} />
105
- <motion.div initial={{ opacity: 0, scale: 0 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 1.5, ease: [0.16, 1, 0.3, 1] }} className="relative z-10 mb-10">
106
- <JanusOrb size={64} thinking />
107
- </motion.div>
108
- <motion.div initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.8, duration: 1.5 }} className="relative z-10 text-center">
109
- <h1 className="text-7xl font-extralight tracking-[0.3em] mb-4 bg-gradient-to-r from-indigo-400 via-violet-400 to-indigo-400 bg-clip-text text-transparent">JANUS</h1>
110
- <motion.div initial={{ opacity: 0, width: 0 }} animate={{ opacity: 1, width: '100%' }} transition={{ delay: 1.5, duration: 2 }}
111
- className="h-px bg-gradient-to-r from-transparent via-indigo-500/40 to-transparent mb-6" />
112
- <motion.p initial={{ opacity: 0 }} animate={{ opacity: 0.5 }} transition={{ delay: 2, duration: 1.5 }}
113
- className="font-mono text-xs tracking-[0.4em] text-gray-400 uppercase">living intelligence system</motion.p>
114
- </motion.div>
115
- <AnimatePresence>
116
- {show && (
117
- <motion.button initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.8 }} onClick={onUnlock} className="relative z-10 mt-16 group">
118
- <div className="relative px-10 py-3.5 rounded-full border border-white/10 bg-white/[0.03] backdrop-blur-sm overflow-hidden transition-all duration-500 group-hover:border-indigo-500/30 group-hover:bg-white/[0.06]">
119
- <span className="relative z-10 text-xs tracking-[0.3em] uppercase text-gray-300 group-hover:text-white transition-colors">Initialize System</span>
120
- <div className="absolute inset-0 bg-gradient-to-r from-indigo-600/10 to-violet-600/10 translate-y-full group-hover:translate-y-0 transition-transform duration-700" />
121
- </div>
122
- </motion.button>
123
- )}
124
- </AnimatePresence>
125
- </motion.div>
126
- );
127
- }
128
-
129
- // ─── Typewriter ──────────────────────────────────────────────
130
- function Typewriter({ text, speed = 10 }: { text: string; speed?: number }) {
131
  const [displayed, setDisplayed] = useState('');
132
  const idx = useRef(0);
 
 
133
  useEffect(() => {
134
- idx.current = 0; setDisplayed('');
 
 
 
135
  const iv = setInterval(() => {
136
- if (idx.current < text.length) { setDisplayed(p => p + text[idx.current]); idx.current++; } else clearInterval(iv);
 
 
 
 
 
 
 
 
 
 
 
137
  }, speed);
138
  return () => clearInterval(iv);
139
- }, [text, speed]);
140
- return <span>{displayed}{displayed.length < text.length && <span className="inline-block w-0.5 h-4 bg-indigo-400 ml-0.5 animate-pulse" />}</span>;
141
- }
142
-
143
- // ─── Thinking ────────────────────────────────────────────────
144
- const STAGES = ['Routing to switchboard...', 'Research agent scanning sources...', 'Cross-referencing databases...', 'Synthesizer composing analysis...'];
145
- function ThinkingDisplay({ stage }: { stage: string }) {
146
- return (
147
- <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex flex-col items-center justify-center py-16 gap-6">
148
- <JanusOrb size={56} thinking />
149
- <div className="text-center mt-4">
150
- <motion.p key={stage} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="text-sm font-mono text-indigo-300">{stage}</motion.p>
151
- <div className="flex items-center justify-center gap-1.5 mt-3">
152
- {[0,1,2,3,4].map(i => <motion.div key={i} className="w-1 h-1 rounded-full bg-indigo-400" animate={{ opacity: [0.2, 1, 0.2], scale: [0.8, 1.2, 0.8] }} transition={{ duration: 1.2, repeat: Infinity, delay: i * 0.15 }} />)}
153
- </div>
154
- </div>
155
- </motion.div>
156
- );
157
- }
158
 
159
- // ─── Confidence Ring ─────────────────────────────────────────
160
- function ConfidenceRing({ value, label }: { value: number; label: string }) {
161
- const pct = Math.round(value * 100);
162
- const circ = 2 * Math.PI * 18;
163
  return (
164
- <div className="flex flex-col items-center gap-1.5">
165
- <div className="relative w-10 h-10">
166
- <svg className="w-full h-full -rotate-90" viewBox="0 0 40 40">
167
- <circle cx="20" cy="20" r="18" fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth="2" />
168
- <motion.circle cx="20" cy="20" r="18" fill="none" stroke={value >= 0.7 ? '#22c55e' : value >= 0.5 ? '#eab308' : '#ef4444'} strokeWidth="2" strokeLinecap="round" strokeDasharray={circ} initial={{ strokeDashoffset: circ }} animate={{ strokeDashoffset: circ * (1 - value) }} transition={{ duration: 1.5, ease: 'easeOut' }} />
169
- </svg>
170
- <span className="absolute inset-0 flex items-center justify-center text-[10px] font-mono text-gray-300">{pct}</span>
171
- </div>
172
- <span className="text-[9px] font-mono text-gray-500 uppercase tracking-wider">{label}</span>
173
- </div>
174
  );
175
  }
176
 
177
- // ─── Badges ──────────────────────────────────────────────────
178
- function StanceChip({ stance, score }: { stance: string; score: number }) {
179
- if (stance === 'bullish') return <span className="flex items-center gap-1 text-[10px] font-mono text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"><TrendingUp size={9} />Bull {Math.round(score * 100)}%</span>;
180
- if (stance === 'bearish') return <span className="flex items-center gap-1 text-[10px] font-mono text-red-400 bg-red-500/10 border border-red-500/20 px-2 py-0.5 rounded-full"><TrendingDown size={9} />Bear {Math.round((1 - score) * 100)}%</span>;
181
- return <span className="flex items-center gap-1 text-[10px] font-mono text-gray-500 bg-white/5 border border-white/10 px-2 py-0.5 rounded-full"><Minus size={9} />Neutral</span>;
182
- }
183
-
184
- function SignalBadge({ signal, conviction }: { signal: string; conviction: number }) {
185
- const map: Record<string, string> = { BUY: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40', SELL: 'bg-red-500/20 text-red-300 border-red-500/40', HOLD: 'bg-amber-500/20 text-amber-300 border-amber-500/40', WATCH: 'bg-indigo-500/20 text-indigo-300 border-indigo-500/40' };
186
- return <div className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-xl border ${map[signal] || map.WATCH}`}><span className="text-sm font-mono font-bold">{signal}</span><span className="text-[10px] font-mono opacity-70">{Math.round(conviction * 100)}% conviction</span></div>;
187
- }
188
-
189
- function SeverityBadge({ severity }: { severity: string }) {
190
- const map: Record<string, string> = { critical: 'bg-red-500/20 text-red-300 border-red-500/30', high: 'bg-orange-500/20 text-orange-300 border-orange-500/30', medium: 'bg-amber-500/20 text-amber-300 border-amber-500/30', low: 'bg-gray-500/20 text-gray-400 border-gray-500/30' };
191
- return <span className={`px-2 py-0.5 rounded border text-[9px] font-mono uppercase tracking-wider ${map[severity] || map.low}`}>{severity}</span>;
192
- }
193
 
194
- // ─── Research Panel ──────────────────────────────────────────
195
- function ResearchPanel({ result, loading, stage }: { result: CaseRecord | null; loading: boolean; stage: string }) {
196
- if (loading) return (
197
- <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="glass rounded-2xl p-6 border border-indigo-500/20 space-y-4">
198
- <div className="flex items-center gap-3">
199
- <JanusOrb size={28} thinking />
200
- <div>
201
- <p className="text-xs font-mono text-indigo-300">{stage}</p>
202
- <div className="flex gap-1 mt-1.5">{[0,1,2,3,4].map(i => <motion.div key={i} className="w-1 h-1 rounded-full bg-indigo-400" animate={{ opacity: [0.2,1,0.2] }} transition={{ duration: 1.2, repeat: Infinity, delay: i*0.15 }} />)}</div>
203
- </div>
204
- </div>
205
- </motion.div>
206
- );
207
- if (!result) return null;
208
  return (
209
- <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="glass rounded-2xl p-5 border border-indigo-500/20 space-y-4">
210
- <div className="flex items-center gap-2 text-[10px] font-mono text-indigo-400 uppercase tracking-wider"><Zap size={12} /> JANUS Research Complete</div>
211
- {result.outputs && result.outputs.filter((o: any) => o.confidence > 0).length > 0 && (
212
- <div className="flex items-center gap-5">{result.outputs.filter((o: any) => o.confidence > 0).map((o: any, idx: number) => (<ConfidenceRing key={`${o.agent || 'output'}-${idx}`} value={o.confidence} label={o.agent || `Agent ${idx + 1}`} />))}</div>
213
- )}
214
- {result.route && (
215
- <div className="flex gap-2 flex-wrap">
216
- <span className="px-2 py-0.5 rounded-full text-[9px] font-mono uppercase bg-indigo-500/10 text-indigo-300 border border-indigo-500/20">{result.route.domain_pack}</span>
217
- <span className="px-2 py-0.5 rounded-full text-[9px] font-mono uppercase bg-white/5 text-gray-400 border border-white/10">{result.route.execution_mode}</span>
218
- <span className="px-2 py-0.5 rounded-full text-[9px] font-mono uppercase bg-white/5 text-gray-400 border border-white/10">{result.route.complexity}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  </div>
220
- )}
221
- {result.final_answer && (<div className="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap font-mono"><Typewriter text={result.final_answer} speed={6} /></div>)}
222
  </motion.div>
223
  );
224
  }
225
 
226
- // ─── Article Card ────────────────────────────────────────────
227
- function ArticleCard({ article, index, onResearch }: { article: any; index: number; onResearch: (q: string) => void }) {
228
- return (
229
- <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.04 }}
230
- className="glass rounded-xl border border-white/[0.04] hover:border-white/10 transition-colors p-4">
231
- <div className="flex items-start justify-between gap-3">
232
- <div className="flex-1 min-w-0">
233
- <div className="flex items-center gap-2 mb-2 flex-wrap">
234
- <StanceChip stance={article.stance} score={article.sentiment_score} />
235
- {article.source && <span className="text-[9px] font-mono text-gray-600">{article.source}</span>}
236
- {article.published_at && <span className="text-[9px] font-mono text-gray-700">{new Date(article.published_at).toLocaleDateString()}</span>}
237
- </div>
238
- <p className="text-sm text-gray-200 leading-snug mb-2">{article.title}</p>
239
- {article.description && <p className="text-xs text-gray-500 leading-relaxed line-clamp-2">{article.description}</p>}
240
  </div>
241
- </div>
242
- <div className="flex items-center gap-3 mt-3 pt-3 border-t border-white/5">
243
- <button onClick={() => onResearch(article.title + (article.description ? '. ' + article.description : ''))}
244
- className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-indigo-600/20 hover:bg-indigo-600/40 border border-indigo-500/30 text-[10px] font-mono text-indigo-300 transition-colors uppercase tracking-wider">
245
- <Sparkles size={10} /> Deep Research
246
- </button>
247
- {article.url && (<a href={article.url} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-[10px] font-mono text-gray-600 hover:text-gray-400 transition-colors"><ExternalLink size={10} /> Source</a>)}
248
- </div>
249
- </motion.div>
250
- );
251
- }
252
 
253
- // ─── Alert Card ──────────────────────────────────────────────
254
- function AlertCard({ alert, index }: { alert: any; index: number }) {
255
  return (
256
- <motion.div initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: index * 0.05 }}
257
- className="glass rounded-xl border border-white/[0.04] hover:border-white/10 transition-colors p-3">
258
- <div className="flex items-start gap-3">
259
- <div className="mt-1">{alert.severity === 'high' || alert.severity === 'critical' ? <AlertTriangle size={14} className="text-red-400" /> : <Info size={14} className="text-amber-400" />}</div>
260
- <div className="flex-1 min-w-0">
261
- <div className="flex items-center gap-2 mb-1 flex-wrap">
262
- <SeverityBadge severity={alert.severity} />
263
- <span className="text-[9px] font-mono text-gray-600">{alert.type}</span>
264
- {alert.source && <span className="text-[9px] font-mono text-gray-700">{alert.source}</span>}
265
- </div>
266
- <p className="text-xs text-gray-300 leading-snug">{alert.title}</p>
267
- {alert.description && <p className="text-[10px] text-gray-600 mt-1 line-clamp-1">{alert.description}</p>}
268
- <div className="flex items-center gap-2 mt-2">
269
- <span className="text-[9px] font-mono text-gray-700">{new Date(alert.timestamp).toLocaleString()}</span>
270
- {alert.url && (<a href={alert.url} target="_blank" rel="noreferrer" className="text-[9px] font-mono text-indigo-400 hover:text-indigo-300 flex items-center gap-0.5"><ExternalLink size={8} /> Read</a>)}
 
 
 
 
 
 
 
 
 
 
 
 
271
  </div>
 
 
 
 
 
 
 
 
 
272
  </div>
273
  </div>
274
  </motion.div>
275
  );
276
  }
277
 
278
- // ─── Candlestick Chart ───────────────────────────────────────
279
- function CandlestickChart({ symbol, companyName, price, change, changePct, isPositive }: { symbol: string; companyName: string; price?: string; change?: string; changePct?: string; isPositive?: boolean }) {
280
- const chartContainerRef = useRef<HTMLDivElement>(null);
281
- const chartRef = useRef<IChartApi | null>(null);
282
- const seriesRef = useRef<ReturnType<IChartApi['addSeries']> | null>(null);
283
- const volumeSeriesRef = useRef<ReturnType<IChartApi['addSeries']> | null>(null);
284
- const [timeframe, setTimeframe] = useState<'1D' | '1W' | '1M' | '3M' | '1Y'>('1M');
285
- const [loading, setLoading] = useState(false);
286
-
287
- const generateChartData = useCallback((basePrice: number, tf: string): CandlestickData[] => {
288
- const data: CandlestickData[] = [];
289
- let currentPrice = basePrice;
290
- const volatility = basePrice * 0.02;
291
- const now = new Date();
292
- let days = tf === '1D' ? 1 : tf === '1W' ? 7 : tf === '1M' ? 30 : tf === '3M' ? 90 : 365;
293
- const startDate = new Date(now);
294
- startDate.setDate(startDate.getDate() - days);
295
- for (let i = 0; i < days; i++) {
296
- const date = new Date(startDate);
297
- date.setDate(date.getDate() + i);
298
- if (date.getDay() === 0 || date.getDay() === 6) continue;
299
- const dayVol = volatility * (0.5 + Math.random());
300
- const open = currentPrice;
301
- const high = open + Math.random() * dayVol;
302
- const low = open - Math.random() * dayVol;
303
- const close = open + (Math.random() - 0.48) * dayVol;
304
- data.push({ time: date.toISOString().split('T')[0], open: parseFloat(open.toFixed(2)), high: parseFloat(high.toFixed(2)), low: parseFloat(low.toFixed(2)), close: parseFloat(close.toFixed(2)) });
305
- currentPrice = close;
306
- }
307
- return data;
308
- }, []);
309
-
310
- const generateVolumeData = useCallback((candleData: CandlestickData[]): HistogramData[] => {
311
- return candleData.map(candle => ({ time: candle.time, value: Math.floor(Math.random() * 10000000) + 1000000, color: candle.close >= candle.open ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)' }));
312
- }, []);
313
-
314
- useEffect(() => {
315
- if (!chartContainerRef.current) return;
316
- const chart = createChart(chartContainerRef.current, {
317
- layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: '#9ca3af', fontSize: 11 },
318
- grid: { vertLines: { color: 'rgba(255, 255, 255, 0.03)' }, horzLines: { color: 'rgba(255, 255, 255, 0.03)' } },
319
- crosshair: { mode: CrosshairMode.Normal },
320
- rightPriceScale: { borderColor: 'rgba(255, 255, 255, 0.05)', scaleMargins: { top: 0.1, bottom: 0.25 } },
321
- timeScale: { borderColor: 'rgba(255, 255, 255, 0.05)', timeVisible: false },
322
- handleScroll: true, handleScale: true,
323
- });
324
- const candleSeries = chart.addSeries(CandlestickSeries, { upColor: '#22c55e', downColor: '#ef4444', borderUpColor: '#22c55e', borderDownColor: '#ef4444', wickUpColor: '#22c55e', wickDownColor: '#ef4444' });
325
- const volumeSeries = chart.addSeries(HistogramSeries, { priceFormat: { type: 'volume' }, priceScaleId: 'volume' });
326
- chart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } });
327
- chartRef.current = chart; seriesRef.current = candleSeries; volumeSeriesRef.current = volumeSeries;
328
- return () => { chart.remove(); chartRef.current = null; seriesRef.current = null; volumeSeriesRef.current = null; };
329
- }, []);
330
-
331
- useEffect(() => {
332
- if (!price || !seriesRef.current || !volumeSeriesRef.current) return;
333
- const basePrice = parseFloat(price);
334
- if (isNaN(basePrice)) return;
335
- setLoading(true);
336
- const candles = generateChartData(basePrice, timeframe);
337
- const volumes = generateVolumeData(candles);
338
- seriesRef.current.setData(candles); volumeSeriesRef.current.setData(volumes);
339
- if (chartRef.current) chartRef.current.timeScale().fitContent();
340
- setLoading(false);
341
- }, [price, timeframe, generateChartData, generateVolumeData]);
342
 
 
343
  return (
344
- <div className="glass rounded-2xl border border-white/[0.06] overflow-hidden">
345
- <div className="flex items-center justify-between px-5 py-4 border-b border-white/5">
346
- <div>
347
- <div className="flex items-center gap-2"><span className="text-lg font-light text-white">{symbol}</span><span className="text-sm text-gray-500">{companyName}</span></div>
348
- {price && (<div className="flex items-center gap-3 mt-1"><span className="text-2xl font-light text-white">{parseFloat(price).toFixed(2)}</span>{change && changePct && (<span className={`text-sm font-mono ${isPositive ? 'text-emerald-400' : 'text-red-400'}`}>{isPositive ? '+' : ''}{parseFloat(change).toFixed(2)} ({changePct})</span>)}</div>)}
349
- </div>
350
- <div className="flex items-center gap-1">{(['1D', '1W', '1M', '3M', '1Y'] as const).map(tf => (<button key={tf} onClick={() => setTimeframe(tf)} className={`px-3 py-1 rounded-lg text-xs font-mono transition-all ${timeframe === tf ? 'bg-indigo-600/30 text-indigo-300 border border-indigo-500/30' : 'text-gray-500 hover:text-gray-300 hover:bg-white/5'}`}>{tf}</button>))}</div>
351
- </div>
352
- <div className="relative h-80">
353
- {loading && (<div className="absolute inset-0 flex items-center justify-center bg-gray-950/50 z-10"><div className="flex flex-col items-center gap-3"><JanusOrb size={32} thinking /><p className="text-xs font-mono text-indigo-400 animate-pulse">Loading chart...</p></div></div>)}
354
- <div ref={chartContainerRef} className="w-full h-full" />
355
- </div>
356
- <div className="flex items-center gap-4 px-5 py-2 border-t border-white/5 text-[10px] font-mono text-gray-600">
357
- <div className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-sm bg-emerald-500/50" />Bullish</div>
358
- <div className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-sm bg-red-500/50" />Bearish</div>
359
- <div className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-sm bg-indigo-500/30" />Volume</div>
 
 
 
 
 
 
 
 
 
 
360
  </div>
361
- </div>
362
  );
363
  }
364
 
365
  // ═══════════════════════════════════════════════════════════
366
- // TAB COMPONENTS
367
  // ═══════════════════════════════════════════════════════════
368
-
369
- function CommandTab() {
370
- const [isAnalyzing, setIsAnalyzing] = useState(false);
371
- const [thinkingStage, setThinkingStage] = useState(0);
372
- const [result, setResult] = useState<CaseRecord | null>(null);
373
  const [input, setInput] = useState('');
374
- const resultRef = useRef<HTMLDivElement>(null);
 
 
 
 
375
 
 
376
  useEffect(() => {
377
- if (!isAnalyzing) return;
378
- const iv = setInterval(() => setThinkingStage(p => (p + 1) % STAGES.length), 3000);
379
- return () => clearInterval(iv);
380
- }, [isAnalyzing]);
381
-
382
- const handleAnalyze = useCallback(async (q: string) => {
383
- if (!q.trim()) return;
384
- setIsAnalyzing(true); setResult(null); setThinkingStage(0);
385
- try {
386
- const res = await apiClient.analyze({ user_input: q });
387
- setResult(res);
388
- setTimeout(() => resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 200);
389
- } catch { /* silent */ } finally { setIsAnalyzing(false); }
390
- }, []);
391
-
392
- return (
393
- <div className="h-full flex flex-col">
394
- <div className="relative group shrink-0">
395
- <div className="absolute -inset-px rounded-2xl bg-gradient-to-r from-indigo-500/20 via-transparent to-violet-500/20 opacity-0 group-focus-within:opacity-100 transition-opacity duration-500" />
396
- <div className="relative flex items-center gap-3 px-5 py-4 rounded-2xl glass border border-white/[0.06] group-focus-within:border-indigo-500/20 transition-colors">
397
- <Sparkles size={16} className="text-indigo-400/50 shrink-0" />
398
- <input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAnalyze(input)} disabled={isAnalyzing}
399
- placeholder="Ask Janus anything — analysis, research, market intelligence..."
400
- className="flex-1 bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none disabled:opacity-40 font-mono" />
401
- <motion.button whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} onClick={() => handleAnalyze(input)} disabled={isAnalyzing || !input.trim()}
402
- className="p-2.5 rounded-xl bg-indigo-600/80 hover:bg-indigo-500 disabled:bg-gray-800 disabled:text-gray-600 text-white transition-all duration-200">
403
- <Send size={14} />
404
- </motion.button>
405
- </div>
406
- </div>
407
- <div className="flex-1 mt-5 overflow-y-auto rounded-2xl">
408
- <AnimatePresence mode="wait">
409
- {isAnalyzing ? (<ThinkingDisplay stage={STAGES[thinkingStage]} />) : result ? (
410
- <motion.div key="result" ref={resultRef} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="space-y-4 pb-8">
411
- {result.route && (<div className="flex items-center gap-2 flex-wrap">
412
- <span className="px-2.5 py-1 rounded-full text-[10px] font-mono uppercase tracking-wider bg-indigo-500/10 text-indigo-300 border border-indigo-500/20">{result.route.domain_pack}</span>
413
- <span className="px-2.5 py-1 rounded-full text-[10px] font-mono uppercase tracking-wider bg-white/5 text-gray-400 border border-white/10">{result.route.execution_mode}</span>
414
- <span className="px-2.5 py-1 rounded-full text-[10px] font-mono uppercase tracking-wider bg-white/5 text-gray-400 border border-white/10">{result.route.complexity}</span>
415
- </div>)}
416
- {result.outputs && result.outputs.filter((o: any) => o.confidence > 0).length > 0 && (
417
- <div className="flex items-center gap-6 py-3">{result.outputs.filter((o: any) => o.confidence > 0).map((o: any, idx: number) => (<ConfidenceRing key={`${o.agent || 'output'}-${idx}`} value={o.confidence} label={o.agent || `Agent ${idx + 1}`} />))}</div>
418
- )}
419
- {result.final_answer && (<div className="glass rounded-2xl p-6"><div className="flex items-center gap-2 mb-4 text-xs font-mono text-indigo-400/70 uppercase tracking-wider"><Zap size={12} /><span>Synthesis Complete</span></div><div className="prose prose-invert max-w-none text-sm leading-relaxed text-gray-200 whitespace-pre-wrap"><Typewriter text={result.final_answer} speed={8} /></div></div>)}
420
- </motion.div>
421
- ) : (
422
- <motion.div key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="h-full flex flex-col items-center justify-center text-center py-20">
423
- <JanusOrb size={48} />
424
- <p className="mt-6 text-sm text-gray-500 font-mono">Awaiting directive...</p>
425
- <p className="mt-2 text-xs text-gray-700 max-w-md">Multi-agent pipeline: switchboard → research → synthesizer.</p>
426
- <div className="flex flex-wrap justify-center gap-2 mt-8 max-w-lg">
427
- {['Analyze RBI rate hike impact on Indian markets', 'What happens to $NVDA if AI spending slows?', 'Compare Reliance vs TCS as long-term investments'].map(q => (
428
- <button key={q} onClick={() => { setInput(q); handleAnalyze(q); }} className="px-3 py-1.5 rounded-full text-xs font-mono text-gray-500 border border-white/5 hover:border-indigo-500/20 hover:text-indigo-300 transition-all text-left">{q}</button>
429
- ))}
430
- </div>
431
- </motion.div>
432
- )}
433
- </AnimatePresence>
434
- </div>
435
- </div>
436
- );
437
- }
438
-
439
- function IntelStreamTab() {
440
- const [headlines, setHeadlines] = useState<any[]>([]);
441
- const [query, setQuery] = useState('');
442
- const [loading, setLoading] = useState(false);
443
- const [searched, setSearched] = useState(false);
444
- const [researchResult, setResearchResult] = useState<CaseRecord | null>(null);
445
- const [researchLoading, setResearchLoading] = useState(false);
446
- const [researchStage, setResearchStage] = useState(0);
447
- const [researchQuery, setResearchQuery] = useState('');
448
- const stageTimer = useRef<ReturnType<typeof setInterval> | null>(null);
449
-
450
- useEffect(() => { financeClient.getHeadlines().then(data => setHeadlines(data)).catch(() => {}); }, []);
451
-
452
- const searchNews = async () => {
453
- if (!query.trim()) return;
454
- setLoading(true); setSearched(true); setResearchResult(null);
455
- try { const data = await financeClient.analyzeNews(query, 10); setHeadlines(data.articles || []); } catch { /* silent */ } finally { setLoading(false); }
456
- };
457
-
458
- const runResearch = async (articleText: string) => {
459
- setResearchLoading(true); setResearchResult(null); setResearchQuery(articleText.slice(0, 80));
460
- setResearchStage(0);
461
- if (stageTimer.current) clearInterval(stageTimer.current);
462
- stageTimer.current = setInterval(() => setResearchStage(p => (p + 1) % STAGES.length), 3000);
463
- try { const res = await apiClient.analyze({ user_input: articleText }); setResearchResult(res); } catch { /* silent */ } finally { setResearchLoading(false); if (stageTimer.current) clearInterval(stageTimer.current); }
464
- };
465
-
466
- return (
467
- <div className="h-full flex gap-5 overflow-hidden">
468
- <div className="flex flex-col gap-3 overflow-hidden" style={{ width: researchResult || researchLoading ? '45%' : '100%', transition: 'width 0.4s ease' }}>
469
- <div className="flex gap-2 shrink-0">
470
- <div className="flex-1 flex items-center gap-3 px-4 py-3 glass rounded-2xl border border-white/[0.06] focus-within:border-indigo-500/30 transition-colors">
471
- <Globe size={14} className="text-gray-500 shrink-0" />
472
- <input value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && searchNews()} placeholder="Search news — company, topic, event..." className="flex-1 bg-transparent text-sm text-gray-200 placeholder-gray-600 font-mono focus:outline-none" />
473
- </div>
474
- <button onClick={searchNews} disabled={loading} className="px-4 py-2 glass rounded-2xl border border-white/[0.06] hover:border-indigo-500/30 text-xs font-mono text-gray-400 hover:text-indigo-300 transition-all disabled:opacity-40">{loading ? <RefreshCw size={13} className="animate-spin" /> : 'Search'}</button>
475
- </div>
476
- <div className="text-[10px] font-mono text-gray-600 uppercase tracking-wider shrink-0">{searched ? `"${query}"` : 'Top Business Headlines'} — click Deep Research on any article</div>
477
- <div className="flex-1 overflow-y-auto space-y-2 pr-1">
478
- {loading && (<div className="flex flex-col items-center justify-center py-12 gap-4"><JanusOrb size={36} thinking /><p className="text-xs font-mono text-indigo-400 animate-pulse">Fetching articles...</p></div>)}
479
- {!loading && headlines.map((a, i) => (<ArticleCard key={`${a.title}-${i}`} article={a} index={i} onResearch={runResearch} />))}
480
- {!loading && headlines.length === 0 && (<div className="flex flex-col items-center justify-center py-16 text-center"><Globe size={28} className="text-gray-700 mb-3" /><p className="text-sm font-mono text-gray-500">No articles found.</p></div>)}
481
- </div>
482
- </div>
483
- <AnimatePresence>
484
- {(researchResult || researchLoading) && (
485
- <motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 20 }} className="flex-1 flex flex-col gap-3 overflow-hidden">
486
- <div className="flex items-center justify-between shrink-0">
487
- <div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider truncate max-w-[80%]">Research: {researchQuery}...</div>
488
- <button onClick={() => { setResearchResult(null); setResearchLoading(false); }} className="text-[10px] font-mono text-gray-600 hover:text-gray-400 transition-colors">✕ Close</button>
489
- </div>
490
- <div className="flex-1 overflow-y-auto"><ResearchPanel result={researchResult} loading={researchLoading} stage={STAGES[researchStage]} /></div>
491
- </motion.div>
492
- )}
493
- </AnimatePresence>
494
- </div>
495
- );
496
- }
497
-
498
- function MarketsTab() {
499
- const [query, setQuery] = useState('');
500
- const [searchResults, setSearchResults] = useState<{ symbol: string; name: string; region?: string }[]>([]);
501
- const [intel, setIntel] = useState<any>(null);
502
- const [loading, setLoading] = useState(false);
503
- const [newsLoading, setNewsLoading] = useState(false);
504
- const [news, setNews] = useState<any[]>([]);
505
- const [activeSymbol, setActiveSymbol] = useState('');
506
- const [error, setError] = useState<string | null>(null);
507
- const [selectedRegion, setSelectedRegion] = useState('');
508
- const [researchResult, setResearchResult] = useState<CaseRecord | null>(null);
509
- const [researchLoading, setResearchLoading] = useState(false);
510
- const [researchStage, setResearchStage] = useState(0);
511
- const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
512
- const stageTimer = useRef<ReturnType<typeof setInterval> | null>(null);
513
-
514
- const handleQueryChange = (val: string) => {
515
- setQuery(val);
516
- if (searchTimer.current) clearTimeout(searchTimer.current);
517
- if (!val.trim()) { setSearchResults([]); return; }
518
- searchTimer.current = setTimeout(async () => { try { setSearchResults(await financeClient.searchTicker(val)); } catch { setSearchResults([]); } }, 400);
519
- };
520
 
521
- const loadTicker = useCallback(async (symbol: string, region = '') => {
522
- setLoading(true); setIntel(null); setNews([]); setActiveSymbol(symbol); setSearchResults([]); setError(null); setResearchResult(null);
523
- try {
524
- const data = await financeClient.getTickerIntelligence(symbol);
525
- setIntel(data); setNewsLoading(true);
526
- try { const nd = await financeClient.analyzeNews(data.company_name || symbol, 8); setNews(nd.articles || []); } catch { /* silent */ } finally { setNewsLoading(false); }
527
- } catch { setError(`Could not load intelligence for ${symbol}. Ensure ALPHAVANTAGE_API_KEY is set in backend/.env`); } finally { setLoading(false); }
528
- }, []);
529
 
530
- const runDeepResearch = async () => {
531
- if (!intel) return;
532
- const q = `Analyze ${intel.company_name} (${intel.symbol}) stock. ${intel.overview.description || ''}`.slice(0, 500);
533
- setResearchLoading(true); setResearchResult(null); setResearchStage(0);
534
- if (stageTimer.current) clearInterval(stageTimer.current);
535
- stageTimer.current = setInterval(() => setResearchStage(p => (p + 1) % STAGES.length), 3000);
536
- try { const res = await apiClient.analyze({ user_input: q }); setResearchResult(res); } catch { /* silent */ } finally { setResearchLoading(false); if (stageTimer.current) clearInterval(stageTimer.current); }
537
  };
538
 
539
- const price = intel?.quote?.['05. price'];
540
- const change = intel?.quote?.['09. change'];
541
- const changePct = intel?.quote?.['10. change percent'];
542
- const isPositive = change && parseFloat(change) >= 0;
543
-
544
- return (
545
- <div className="h-full flex flex-col gap-4 overflow-hidden">
546
- <div className="relative shrink-0">
547
- <div className="flex items-center gap-3 px-4 py-3 glass rounded-2xl border border-white/[0.06] focus-within:border-indigo-500/30 transition-colors">
548
- <Search size={15} className="text-gray-500 shrink-0" />
549
- <input value={query} onChange={e => handleQueryChange(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { setSearchResults([]); loadTicker(query.toUpperCase(), selectedRegion); } }} placeholder="Search — RELIANCE, TCS, NIFTY, AAPL, Tesla..." className="flex-1 bg-transparent text-sm text-gray-200 placeholder-gray-600 font-mono focus:outline-none" />
550
- {query && (<button onClick={() => { setSearchResults([]); loadTicker(query.toUpperCase(), selectedRegion); }} className="px-3 py-1 rounded-lg bg-indigo-600/80 hover:bg-indigo-500 text-white text-xs font-mono transition-colors">Analyze</button>)}
551
- </div>
552
- <AnimatePresence>
553
- {searchResults.length > 0 && (
554
- <motion.div initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute top-full left-0 right-0 mt-1 glass rounded-xl border border-white/10 overflow-hidden z-20">
555
- {searchResults.slice(0, 6).map(r => (<button key={r.symbol} onClick={() => { setQuery(r.symbol); setSelectedRegion(r.region || ''); loadTicker(r.symbol, r.region || ''); }} className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-white/[0.04] transition-colors text-left"><span className="text-sm font-mono text-indigo-300">{r.symbol}</span><div className="flex items-center gap-3 ml-4 min-w-0">{r.region && <span className="text-[9px] font-mono text-gray-600 bg-white/5 px-1.5 py-0.5 rounded shrink-0">{r.region}</span>}<span className="text-xs text-gray-500 truncate">{r.name}</span></div></button>))}
556
- </motion.div>
557
- )}
558
- </AnimatePresence>
559
- </div>
560
- {loading && (<div className="flex-1 flex flex-col items-center justify-center gap-4"><JanusOrb size={48} thinking /><p className="text-xs font-mono text-indigo-400 uppercase tracking-widest animate-pulse">Fetching intelligence for {activeSymbol}...</p></div>)}
561
- {!loading && error && (<div className="flex-1 flex flex-col items-center justify-center gap-3 text-center"><AlertTriangle size={28} className="text-amber-500/50" /><p className="text-sm font-mono text-amber-400 max-w-md">{error}</p></div>)}
562
- {!loading && !intel && !error && (
563
- <div className="flex-1 flex flex-col items-center justify-center gap-3 text-center">
564
- <BarChart3 size={32} className="text-gray-700" />
565
- <p className="text-sm font-mono text-gray-500">Search any stock — Indian (RELIANCE, TCS, INFY) or global (AAPL, TSLA)</p>
566
- <div className="flex gap-2 mt-2 flex-wrap justify-center">
567
- {[{s:'RELIANCE.BSE',l:'Reliance',r:'India'},{s:'TCS.BSE',l:'TCS',r:'India'},{s:'INFY.BSE',l:'Infosys',r:'India'},{s:'AAPL',l:'Apple',r:''},{s:'TSLA',l:'Tesla',r:''}].map(({s,l,r}) => (<button key={s} onClick={() => { setQuery(s); setSelectedRegion(r); loadTicker(s, r); }} className="px-3 py-1 rounded-full border border-white/10 hover:border-indigo-500/30 text-xs font-mono text-gray-500 hover:text-indigo-300 transition-all">{l}</button>))}
568
- </div>
569
- </div>
570
- )}
571
- {!loading && intel && (
572
- <div className="flex-1 overflow-y-auto space-y-4 pr-1">
573
- <CandlestickChart symbol={intel.symbol} companyName={intel.company_name} price={price} change={change} changePct={changePct} isPositive={!!isPositive} />
574
- <div className="glass rounded-2xl p-5 border border-white/[0.06]">
575
- <div className="flex items-start justify-between gap-4 flex-wrap">
576
- <div>
577
- <div className="flex items-center gap-3 mb-1"><span className="text-2xl font-light text-white">{intel.symbol}</span><span className="text-sm text-gray-500">{intel.company_name}</span></div>
578
- <div className="flex items-center gap-2 mt-2 flex-wrap">
579
- {intel.overview.sector && <span className="text-[10px] font-mono text-gray-500 bg-white/5 px-2 py-0.5 rounded">{intel.overview.sector}</span>}
580
- {intel.overview.industry && <span className="text-[10px] font-mono text-gray-500 bg-white/5 px-2 py-0.5 rounded">{intel.overview.industry}</span>}
581
- </div>
582
- </div>
583
- <div className="flex flex-col items-end gap-2">
584
- {intel.ai_signal && <SignalBadge signal={intel.ai_signal.signal} conviction={intel.ai_signal.conviction} />}
585
- <StanceChip stance={intel.stance.stance} score={intel.stance.sentiment_score} />
586
- </div>
587
- </div>
588
- {intel.ai_signal?.reasoning && (
589
- <div className="mt-4 pt-4 border-t border-white/5">
590
- <div className="flex items-center gap-2 mb-1.5 text-[10px] font-mono text-indigo-400 uppercase tracking-wider"><Zap size={10} /> AI Signal</div>
591
- <p className="text-xs text-gray-300 font-mono leading-relaxed">{intel.ai_signal.reasoning}</p>
592
- <div className="flex gap-4 mt-2">
593
- <span className="text-[9px] font-mono text-gray-600 uppercase">Risk: <span className={intel.ai_signal.risk === 'HIGH' ? 'text-red-400' : intel.ai_signal.risk === 'MEDIUM' ? 'text-amber-400' : 'text-emerald-400'}>{intel.ai_signal.risk}</span></span>
594
- <span className="text-[9px] font-mono text-gray-600 uppercase">Timeframe: <span className="text-gray-400">{intel.ai_signal.timeframe}</span></span>
595
- </div>
596
- </div>
597
- )}
598
- <div className="mt-4 pt-4 border-t border-white/5">
599
- <button onClick={runDeepResearch} disabled={researchLoading} className="flex items-center gap-2 px-4 py-2 rounded-xl bg-indigo-600/20 hover:bg-indigo-600/40 border border-indigo-500/30 text-xs font-mono text-indigo-300 transition-colors disabled:opacity-40">
600
- <Sparkles size={12} className={researchLoading ? 'animate-pulse' : ''} />{researchLoading ? 'Running research...' : 'Deep Research — Run Full Agent Pipeline'}
601
- </button>
602
- </div>
603
- </div>
604
- <ResearchPanel result={researchResult} loading={researchLoading} stage={STAGES[researchStage]} />
605
- <div className="grid grid-cols-2 gap-4">
606
- <div className="glass rounded-xl p-4 border border-white/[0.04] space-y-2">
607
- <div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mb-3">Fundamentals</div>
608
- {([['P/E Ratio', intel.overview.pe_ratio], ['52W High', intel.overview['52_week_high']], ['52W Low', intel.overview['52_week_low']], ['Analyst Target', intel.overview.analyst_target]] as [string, string | undefined][]).filter(([, v]) => v && v !== 'None' && v !== '-' && v !== 'N/A').map(([k, v]) => (<div key={k} className="flex justify-between text-xs font-mono border-b border-white/5 pb-1.5"><span className="text-gray-500">{k}</span><span className="text-gray-300">{v}</span></div>))}
609
- {!intel.overview.pe_ratio && <p className="text-[10px] font-mono text-gray-700">Requires Alpha Vantage key</p>}
610
- </div>
611
- <div className="glass rounded-xl p-4 border border-white/[0.04] space-y-3">
612
- <div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mb-3">Event Intelligence</div>
613
- <div className="text-xs font-mono"><span className="text-gray-500">Impact: </span><span className={intel.event_impact.impact_level === 'high' || intel.event_impact.impact_level === 'very_high' ? 'text-amber-400' : 'text-gray-300'}>{intel.event_impact.impact_level || 'unknown'}</span></div>
614
- <div className="text-xs font-mono"><span className="text-gray-500">Volatility: </span><span className="text-gray-300">{intel.event_impact.volatility_level || 'unknown'}</span></div>
615
- <div className="text-xs font-mono"><span className="text-gray-500">Events: </span><span className="text-gray-300">{intel.event_impact.event_count || 0} detected</span></div>
616
- </div>
617
- </div>
618
- <div>
619
- <div className="flex items-center gap-2 text-[10px] font-mono text-gray-500 uppercase tracking-wider mb-3"><Scan size={11} className="text-indigo-400" />News {newsLoading && <RefreshCw size={10} className="animate-spin text-indigo-400" />}</div>
620
- {newsLoading && <div className="text-xs font-mono text-gray-600 animate-pulse">Fetching articles...</div>}
621
- <div className="space-y-2">{news.map((a: any, i: number) => <ArticleCard key={`${a.title}-${i}`} article={{ ...a, stance: 'neutral', sentiment_score: 0.5 }} index={i} onResearch={runDeepResearch} />)}</div>
622
- </div>
623
- </div>
624
- )}
625
- </div>
626
- );
627
- }
628
-
629
- function WorkspaceTab() {
630
- const [curiosity, setCuriosity] = useState<any>(null);
631
- const [workflows, setWorkflows] = useState<any[]>([]);
632
- const [sessions, setSessions] = useState<any[]>([]);
633
- const [loading, setLoading] = useState(true);
634
- const [triggering, setTriggering] = useState(false);
635
- const [activeWf, setActiveWf] = useState<any>(null);
636
- const [wfLoading, setWfLoading] = useState(false);
637
-
638
- const fetchData = useCallback(async () => {
639
- setLoading(true);
640
- const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
641
- try {
642
- const [curRes, wfRes, sessRes] = await Promise.all([
643
- fetch(`${baseUrl}/daemon/curiosity`).then(r => r.ok ? r.json() : null),
644
- fetch(`${baseUrl}/workflows?limit=10`).then(r => r.ok ? r.json() : []),
645
- fetch(`${baseUrl}/sessions?limit=10`).then(r => r.ok ? r.json() : []),
646
- ]);
647
- if (curRes) setCuriosity(curRes);
648
- if (wfRes) setWorkflows(Array.isArray(wfRes) ? wfRes : []);
649
- if (sessRes) setSessions(Array.isArray(sessRes) ? sessRes : []);
650
- } catch { /* silent */ } finally { setLoading(false); }
651
- }, []);
652
-
653
- useEffect(() => { fetchData(); }, [fetchData]);
654
-
655
- const triggerCuriosity = async () => {
656
- setTriggering(true);
657
- const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
658
- try {
659
- const res = await fetch(`${baseUrl}/daemon/curiosity/now`, { method: 'POST' });
660
- if (res.ok) {
661
- const data = await res.json();
662
- setCuriosity((prev: any) => prev ? { ...prev, discoveries: [...(prev.discoveries || []), ...(data.discoveries || [])], total_discoveries: (prev.total_discoveries || 0) + (data.discoveries || []).length } : data);
663
- }
664
- } catch { /* silent */ } finally { setTriggering(false); }
665
- };
666
 
667
- const runWorkflow = async (wf: any) => {
668
- setActiveWf(wf); setWfLoading(true);
669
- const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
670
  try {
671
- const res = await fetch(`${baseUrl}/workflows/${wf.id}/run`, { method: 'POST' });
672
- if (res.ok) { const updated = await res.json(); setActiveWf(updated); setWorkflows(prev => prev.map(w => w.id === wf.id ? updated : w)); }
673
- } catch { /* silent */ } finally { setWfLoading(false); }
674
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
 
676
- const createResearchWf = async () => {
677
- const query = prompt('Enter research query:');
678
- if (!query) return;
679
- const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
680
- try {
681
- const res = await fetch(`${baseUrl}/workflows/research`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }) });
682
- if (res.ok) { const wf = await res.json(); setWorkflows(prev => [wf, ...prev]); }
683
- } catch { /* silent */ }
684
  };
685
 
686
- const statusColors: Record<string, string> = { pending: 'text-gray-400', running: 'text-indigo-400', completed: 'text-emerald-400', failed: 'text-red-400', cancelled: 'text-gray-500' };
687
-
688
- if (loading) return (<div className="flex flex-col items-center justify-center h-full gap-4"><JanusOrb size={48} thinking /><p className="text-xs font-mono text-indigo-400 animate-pulse">Loading workspace...</p></div>);
689
 
690
  return (
691
- <div className="h-full flex gap-5 overflow-hidden">
692
- <div className="flex flex-col gap-4 overflow-y-auto pr-1" style={{ width: '45%' }}>
693
- <div className="glass rounded-2xl p-5 border border-white/[0.06]">
694
- <div className="flex items-center justify-between mb-4">
695
- <div className="flex items-center gap-2 text-xs font-mono text-violet-400 uppercase tracking-wider"><Brain size={12} /> Discoveries ({curiosity?.total_discoveries || 0})</div>
696
- <button onClick={triggerCuriosity} disabled={triggering} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-600/20 hover:bg-violet-600/40 border border-violet-500/30 text-[10px] font-mono text-violet-300 transition-all disabled:opacity-40"><Sparkles size={10} className={triggering ? 'animate-pulse' : ''} />{triggering ? 'Exploring...' : 'Explore'}</button>
697
- </div>
698
- <div className="space-y-3">
699
- {(curiosity?.discoveries || []).slice(-5).reverse().map((d: any, i: number) => (
700
- <motion.div key={i} initial={{ opacity: 0, y: 5 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05 }} className="glass rounded-xl p-3 border border-white/[0.04]">
701
- <div className="flex items-center gap-2 mb-1.5"><span className="text-[9px] font-mono text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded">{d.topic}</span><span className="text-[9px] font-mono text-gray-600">{new Date(d.timestamp).toLocaleTimeString()}</span></div>
702
- <p className="text-xs text-gray-300 leading-relaxed">{d.insight}</p>
703
- </motion.div>
704
  ))}
705
- {(!curiosity?.discoveries || curiosity.discoveries.length === 0) && (<p className="text-xs font-mono text-gray-600 text-center py-4">No discoveries yet. Click "Explore" to start.</p>)}
706
- </div>
707
- </div>
708
- <div className="glass rounded-2xl p-5 border border-white/[0.06]">
709
- <div className="flex items-center gap-2 mb-4 text-xs font-mono text-indigo-400 uppercase tracking-wider"><MessageSquare size={12} /> Sessions ({sessions.length})</div>
710
- <div className="space-y-2">
711
- {sessions.slice(0, 5).map((s: any, i: number) => (<div key={s.id} className="flex items-center justify-between text-xs font-mono py-2 px-3 glass rounded-lg border border-white/[0.04]"><span className="text-gray-400 truncate">{s.id}</span><div className="flex items-center gap-3 shrink-0"><span className="text-gray-600">{s.message_count || 0} msgs</span><span className="text-gray-700">{new Date(s.updated_at).toLocaleTimeString()}</span></div></div>))}
712
- {sessions.length === 0 && <p className="text-xs font-mono text-gray-600 text-center py-4">No sessions yet. Start a conversation.</p>}
713
- </div>
714
- </div>
715
- </div>
716
- <div className="flex flex-col gap-3 overflow-hidden flex-1">
717
- <div className="flex items-center justify-between shrink-0">
718
- <div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider">Workflows ({workflows.length})</div>
719
- <button onClick={createResearchWf} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-indigo-600/20 hover:bg-indigo-600/40 border border-indigo-500/30 text-[10px] font-mono text-indigo-300 transition-all"><Plus size={10} /> Research</button>
720
- </div>
721
- <div className="flex-1 overflow-y-auto space-y-2 pr-1">
722
- {workflows.map((wf: any, i: number) => (
723
- <motion.div key={wf.id} initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: i * 0.05 }} className="glass rounded-xl border border-white/[0.04] hover:border-white/10 transition-colors p-4">
724
- <div className="flex items-start justify-between mb-3">
725
- <div>
726
- <div className="flex items-center gap-2 mb-1"><span className={`text-[9px] font-mono px-1.5 py-0.5 rounded ${statusColors[wf.status] || 'text-gray-400'} bg-white/5`}>{wf.status}</span><span className="text-[9px] font-mono text-gray-600">{wf.type}</span></div>
727
- <p className="text-xs text-gray-300">{wf.query || wf.scenario || 'Untitled'}</p>
728
- </div>
729
- {wf.status === 'pending' && (<button onClick={() => runWorkflow(wf)} disabled={wfLoading} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-600/20 hover:bg-emerald-600/40 border border-emerald-500/30 text-[10px] font-mono text-emerald-300 transition-all disabled:opacity-40"><Play size={10} /> Run</button>)}
730
- </div>
731
- <div className="space-y-1.5">{wf.steps?.map((step: any, si: number) => (<div key={step.id} className="flex items-center gap-2 text-[10px] font-mono"><div className={`w-2 h-2 rounded-full ${step.status === 'completed' ? 'bg-emerald-400' : step.status === 'running' ? 'bg-indigo-400 animate-pulse' : step.status === 'failed' ? 'bg-red-400' : 'bg-gray-600'}`} /><span className={step.status === 'completed' ? 'text-emerald-400' : step.status === 'running' ? 'text-indigo-400' : 'text-gray-500'}>{step.name}</span></div>))}</div>
732
- {wf.metadata && (<div className="flex items-center gap-3 mt-3 pt-2 border-t border-white/5 text-[9px] font-mono text-gray-600"><span>{wf.metadata.completed_steps}/{wf.metadata.total_steps} steps</span>{wf.metadata.failed_steps > 0 && <span className="text-red-400">{wf.metadata.failed_steps} failed</span>}</div>)}
733
- </motion.div>
734
- ))}
735
- {workflows.length === 0 && (<div className="flex flex-col items-center justify-center py-16 text-center"><GitBranch size={28} className="text-gray-700 mb-3" /><p className="text-sm font-mono text-gray-500">No workflows yet.</p><p className="text-xs font-mono text-gray-700 mt-1">Create a research or simulation workflow.</p></div>)}
736
- </div>
737
- </div>
738
- </div>
739
- );
740
- }
741
-
742
- function PulseTab({ daemonStatus, alerts, memoryStats }: { daemonStatus: DaemonStatus | null; alerts: Alert[]; memoryStats: MemoryStats | null }) {
743
- const [refreshing, setRefreshing] = useState(false);
744
- const handleRefresh = async () => { setRefreshing(true); setTimeout(() => setRefreshing(false), 1000); };
745
- const phaseIcons: Record<string, React.ReactNode> = { morning: <Sunrise size={16} className="text-amber-400" />, daytime: <Sun size={16} className="text-indigo-400" />, evening: <Sunset size={16} className="text-violet-400" />, night: <Moon size={16} className="text-indigo-300" /> };
746
-
747
- return (
748
- <div className="h-full flex gap-5 overflow-hidden">
749
- <div className="flex flex-col gap-4 overflow-y-auto pr-1" style={{ width: '40%' }}>
750
- {daemonStatus?.circadian && (
751
- <div className="glass rounded-2xl p-5 border border-white/[0.06]">
752
- <div className="flex items-center gap-3 mb-4">{phaseIcons[daemonStatus.circadian.current_phase]}<div><h3 className="text-sm font-mono text-gray-200">{daemonStatus.circadian.phase_name}</h3><p className="text-[10px] font-mono text-gray-500">{daemonStatus.circadian.phase_description}</p></div></div>
753
- <div className="space-y-2"><div className="flex justify-between text-xs font-mono"><span className="text-gray-500">Priority</span><span className="text-gray-300 capitalize">{daemonStatus.circadian.priority}</span></div><div className="flex justify-between text-xs font-mono"><span className="text-gray-500">Active Tasks</span><span className="text-gray-300">{daemonStatus.circadian.current_tasks.length}</span></div></div>
754
- </div>
755
- )}
756
- {daemonStatus && (
757
- <div className="glass rounded-2xl p-5 border border-white/[0.06]">
758
- <div className="flex items-center gap-2 mb-4 text-xs font-mono text-indigo-400 uppercase tracking-wider"><Radio size={12} /> Signal Queue</div>
759
- <div className="grid grid-cols-2 gap-3">
760
- <div className="text-center"><div className="text-xl font-light text-white">{daemonStatus.signal_queue?.total_signals || daemonStatus.signals || 0}</div><div className="text-[9px] font-mono text-gray-600 uppercase">Total Signals</div></div>
761
- <div className="text-center"><div className="text-xl font-light text-red-400">{daemonStatus.signal_queue?.severity_counts?.high || 0}</div><div className="text-[9px] font-mono text-gray-600 uppercase">High Severity</div></div>
762
- <div className="text-center"><div className="text-xl font-light text-amber-400">{daemonStatus.signal_queue?.severity_counts?.medium || 0}</div><div className="text-[9px] font-mono text-gray-600 uppercase">Medium</div></div>
763
- <div className="text-center"><div className="text-xl font-light text-gray-400">{daemonStatus.signal_queue?.severity_counts?.low || 0}</div><div className="text-[9px] font-mono text-gray-600 uppercase">Low</div></div>
764
- </div>
765
- </div>
766
- )}
767
- {memoryStats && (
768
- <div className="glass rounded-2xl p-5 border border-white/[0.06]">
769
- <div className="flex items-center gap-2 mb-4 text-xs font-mono text-violet-400 uppercase tracking-wider"><Database size={12} /> Memory Graph</div>
770
- <div className="grid grid-cols-3 gap-3">
771
- <div className="text-center"><div className="text-xl font-light text-white">{memoryStats.queries}</div><div className="text-[9px] font-mono text-gray-600 uppercase">Queries</div></div>
772
- <div className="text-center"><div className="text-xl font-light text-indigo-400">{memoryStats.entities}</div><div className="text-[9px] font-mono text-gray-600 uppercase">Entities</div></div>
773
- <div className="text-center"><div className="text-xl font-light text-violet-400">{memoryStats.insights}</div><div className="text-[9px] font-mono text-gray-600 uppercase">Insights</div></div>
774
- </div>
775
- </div>
776
- )}
777
- {daemonStatus && (
778
- <div className="glass rounded-2xl p-5 border border-white/[0.06]">
779
- <div className="flex items-center gap-2 mb-4 text-xs font-mono text-emerald-400 uppercase tracking-wider"><Activity size={12} /> Daemon Status</div>
780
- <div className="space-y-2 text-xs font-mono">
781
- <div className="flex justify-between"><span className="text-gray-500">Status</span><span className="text-emerald-400">Running</span></div>
782
- <div className="flex justify-between"><span className="text-gray-500">Cycles</span><span className="text-gray-300">{daemonStatus.cycle_count}</span></div>
783
- <div className="flex justify-between"><span className="text-gray-500">Last Run</span><span className="text-gray-300">{daemonStatus.last_run ? new Date(daemonStatus.last_run).toLocaleTimeString() : 'N/A'}</span></div>
784
- </div>
785
  </div>
786
  )}
787
  </div>
788
- <div className="flex flex-col gap-3 overflow-hidden flex-1">
789
- <div className="flex items-center justify-between shrink-0">
790
- <div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider">Live Alert Feed — {alerts.length} alerts</div>
791
- <button onClick={handleRefresh} disabled={refreshing} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg glass border border-white/5 hover:border-indigo-500/20 text-[10px] font-mono text-gray-400 hover:text-indigo-300 transition-all disabled:opacity-40"><RefreshCw size={10} className={refreshing ? 'animate-spin' : ''} /> Refresh</button>
792
- </div>
793
- <div className="flex-1 overflow-y-auto space-y-2 pr-1">
794
- {alerts.length > 0 ? (alerts.map((alert, i) => <AlertCard key={`${alert.title}-${i}`} alert={alert} index={i} />)) : (
795
- <div className="flex flex-col items-center justify-center py-16 text-center"><CheckCircle size={28} className="text-emerald-500/30 mb-3" /><p className="text-sm font-mono text-gray-500">No alerts.</p><p className="text-xs font-mono text-gray-700 mt-1">All systems operating normally.</p></div>
796
- )}
797
- </div>
798
- </div>
799
- </div>
800
- );
801
- }
802
-
803
- // ═══════════════════════════════════════════════════════════
804
- // MAIN APP
805
- // ═══════════════════════════════════════════════════════════
806
- export default function JanusApp() {
807
- const [systemState, setSystemState] = useState<'art' | 'dashboard'>('art');
808
- const [activeTab, setActiveTab] = useState('command');
809
- const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
810
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
811
- const [daemonStatus, setDaemonStatus] = useState<DaemonStatus | null>(null);
812
- const [alerts, setAlerts] = useState<Alert[]>([]);
813
- const [memoryStats, setMemoryStats] = useState<MemoryStats | null>(null);
814
-
815
- const fetchSystemData = useCallback(async () => {
816
- const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
817
- try {
818
- const [statusRes, alertsRes, memoryRes] = await Promise.all([
819
- fetch(`${baseUrl}/daemon/status`).then(r => r.ok ? r.json() : null),
820
- fetch(`${baseUrl}/daemon/alerts?limit=20`).then(r => r.ok ? r.json() : []),
821
- fetch(`${baseUrl}/memory/stats`).then(r => r.ok ? r.json() : null),
822
- ]);
823
- if (statusRes) setDaemonStatus(statusRes);
824
- if (alertsRes) setAlerts(Array.isArray(alertsRes) ? alertsRes : []);
825
- if (memoryRes) setMemoryStats(memoryRes);
826
- } catch { /* silent */ }
827
- }, []);
828
-
829
- useEffect(() => { fetchSystemData(); const interval = setInterval(fetchSystemData, 60000); return () => clearInterval(interval); }, [fetchSystemData]);
830
-
831
- const sections = ['Main', 'System'];
832
- const sectionItems = (section: string) => navItems.filter(item => item.section === section);
833
-
834
- const renderTab = () => {
835
- switch (activeTab) {
836
- case 'command': return <CommandTab />;
837
- case 'intel': return <IntelStreamTab />;
838
- case 'markets': return <MarketsTab />;
839
- case 'workspace': return <WorkspaceTab />;
840
- case 'pulse': return <PulseTab daemonStatus={daemonStatus} alerts={alerts} memoryStats={memoryStats} />;
841
- default: return <CommandTab />;
842
- }
843
- };
844
-
845
- const alertCount = alerts.filter(a => a.severity === 'high' || a.severity === 'critical').length;
846
-
847
- return (
848
- <div className="h-screen bg-gray-950 text-gray-100 flex overflow-hidden">
849
- <AnimatePresence>{systemState === 'art' && <ArtPiece onUnlock={() => setSystemState('dashboard')} />}</AnimatePresence>
850
-
851
- {/* Mobile menu overlay */}
852
- <AnimatePresence>
853
- {mobileMenuOpen && (<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 bg-black/60 z-40 lg:hidden" onClick={() => setMobileMenuOpen(false)} />)}
854
- </AnimatePresence>
855
 
856
- {/* Sidebar */}
857
- <motion.aside initial={false} animate={{ width: sidebarCollapsed ? 64 : 220 }} className={`hidden lg:flex flex-col bg-gray-900/60 border-r border-white/5 relative z-30 shrink-0`}>
858
- {/* Logo */}
859
- <div className="flex items-center justify-between h-12 px-3 border-b border-white/5 shrink-0">
860
- {!sidebarCollapsed && (<motion.span initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-xs font-light tracking-[0.2em] bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">JANUS</motion.span>)}
861
- <button onClick={() => setSidebarCollapsed(!sidebarCollapsed)} className="p-1 rounded hover:bg-white/5 text-gray-500 hover:text-gray-300 transition-colors shrink-0">
862
- {sidebarCollapsed ? <ChevronRightIcon size={14} /> : <ChevronLeft size={14} />}
863
- </button>
864
- </div>
865
-
866
- {/* Nav */}
867
- <nav className="flex-1 overflow-y-auto py-2 px-1.5 space-y-1">
868
- {sections.map(section => (
869
- <div key={section}>
870
- {!sidebarCollapsed && (<div className="px-2 py-1.5 text-[8px] font-mono text-gray-600 uppercase tracking-widest">{section}</div>)}
871
- {sectionItems(section).map(item => {
872
- const active = activeTab === item.id;
873
- const Icon = item.icon;
874
- return (
875
- <button key={item.id} onClick={() => setActiveTab(item.id)}
876
- className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg transition-all duration-200 text-sm ${active ? 'bg-indigo-500/10 text-indigo-300 border border-indigo-500/20' : 'text-gray-500 hover:text-gray-300 hover:bg-white/5 border border-transparent'} ${sidebarCollapsed ? 'justify-center' : ''}`}
877
- title={sidebarCollapsed ? item.label : undefined}>
878
- <Icon size={14} className="shrink-0" />
879
- {!sidebarCollapsed && (<span className="font-mono text-[10px] uppercase tracking-wider truncate">{item.label}</span>)}
880
- </button>
881
- );
882
- })}
883
- </div>
884
- ))}
885
- </nav>
886
-
887
- {/* Status */}
888
- {!sidebarCollapsed && (
889
- <div className="px-3 py-2 border-t border-white/5 shrink-0">
890
- <div className="flex items-center gap-2">
891
- <div className={`w-1.5 h-1.5 rounded-full ${daemonStatus?.running ? 'bg-emerald-400 animate-pulse' : 'bg-gray-600'}`} />
892
- <span className="text-[9px] font-mono text-gray-500 uppercase tracking-wider">{daemonStatus?.running ? 'Living' : 'Offline'}</span>
893
- </div>
894
- {daemonStatus?.circadian && (<div className="text-[8px] font-mono text-gray-700 mt-0.5 capitalize">{daemonStatus.circadian.current_phase} phase</div>)}
895
  </div>
896
- )}
897
- </motion.aside>
898
-
899
- {/* Mobile header */}
900
- <div className="lg:hidden fixed top-0 left-0 right-0 h-12 bg-gray-900/90 backdrop-blur-sm border-b border-white/5 flex items-center justify-between px-3 z-30">
901
- <button onClick={() => setMobileMenuOpen(true)} className="p-1.5 rounded hover:bg-white/5 text-gray-400"><Menu size={16} /></button>
902
- <span className="text-xs font-light tracking-[0.2em] bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">JANUS</span>
903
- <div className="w-7" />
904
  </div>
905
-
906
- {/* Mobile drawer */}
907
- <AnimatePresence>
908
- {mobileMenuOpen && (
909
- <motion.div initial={{ x: -280 }} animate={{ x: 0 }} exit={{ x: -280 }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed top-0 left-0 bottom-0 w-60 bg-gray-900 border-r border-white/5 z-50 lg:hidden">
910
- <div className="flex items-center justify-between h-12 px-3 border-b border-white/5">
911
- <span className="text-xs font-light tracking-[0.2em] bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">JANUS</span>
912
- <button onClick={() => setMobileMenuOpen(false)} className="p-1 rounded hover:bg-white/5 text-gray-500"><XIcon size={14} /></button>
913
- </div>
914
- <nav className="flex-1 overflow-y-auto py-2 px-1.5 space-y-1">
915
- {sections.map(section => (
916
- <div key={section}>
917
- <div className="px-2 py-1.5 text-[8px] font-mono text-gray-600 uppercase tracking-widest">{section}</div>
918
- {sectionItems(section).map(item => {
919
- const active = activeTab === item.id;
920
- const Icon = item.icon;
921
- return (
922
- <button key={item.id} onClick={() => { setActiveTab(item.id); setMobileMenuOpen(false); }}
923
- className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg transition-all text-sm ${active ? 'bg-indigo-500/10 text-indigo-300 border border-indigo-500/20' : 'text-gray-500 hover:text-gray-300 hover:bg-white/5 border border-transparent'}`}>
924
- <Icon size={14} className="shrink-0" />
925
- <span className="font-mono text-[10px] uppercase tracking-wider">{item.label}</span>
926
- </button>
927
- );
928
- })}
929
- </div>
930
- ))}
931
- </nav>
932
- </motion.div>
933
- )}
934
- </AnimatePresence>
935
-
936
- {/* Main content */}
937
- <main className="flex-1 min-w-0 overflow-hidden pt-12 lg:pt-0">
938
- <motion.div initial={{ opacity: 0 }} animate={{ opacity: systemState === 'dashboard' ? 1 : 0 }} transition={{ duration: 0.8 }}
939
- className={`h-full flex flex-col ${systemState === 'art' ? 'pointer-events-none' : ''}`}>
940
- <div className="flex-1 min-h-0 overflow-hidden p-4">
941
- <AnimatePresence mode="wait">
942
- <motion.div key={activeTab} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.3 }} className="h-full">
943
- {renderTab()}
944
- </motion.div>
945
- </AnimatePresence>
946
- </div>
947
- </motion.div>
948
- </main>
949
  </div>
950
  );
951
  }
 
2
 
3
  import { useState, useEffect, useRef, useCallback } from 'react';
4
  import { motion, AnimatePresence } from 'framer-motion';
5
+ import { Send, Sparkles, Zap, ArrowUp } from 'lucide-react';
6
+ import { apiClient } from '@/lib/api';
7
+
8
+ // ─── Types ───────────────────────────────────────────────
9
+ interface Message {
10
+ id: string;
11
+ role: 'user' | 'janus';
12
+ content: string;
13
+ timestamp: Date;
14
+ metadata?: {
15
+ domain?: string;
16
+ queryType?: string;
17
+ elapsed?: number;
18
+ confidence?: number;
19
+ routeInfo?: { domain_pack?: string; execution_mode?: string; complexity?: string };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  }
22
 
23
+ // ─── Janus Orb ────────────────────────────────────────────
24
+ function JanusOrb({ size = 36, thinking = false }: { size?: number; thinking?: boolean }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  return (
26
  <div className="relative shrink-0" style={{ width: size, height: size }}>
27
+ <motion.div
28
+ className="absolute inset-0 rounded-full"
29
+ style={{
30
+ background: 'radial-gradient(circle at 35% 35%, #818cf8, #4f46e5 55%, #1e1b4b 100%)',
31
+ boxShadow: thinking
32
+ ? '0 0 24px rgba(99,102,241,0.5), 0 0 48px rgba(99,102,241,0.2)'
33
+ : '0 0 12px rgba(99,102,241,0.25)',
34
+ }}
35
+ animate={thinking ? { scale: [1, 1.1, 1] } : { scale: 1 }}
36
+ transition={{ duration: 1.5, repeat: thinking ? Infinity : 0, ease: 'easeInOut' }}
37
+ />
38
+ {thinking && (
39
+ <>
40
+ <div
41
+ className="absolute inset-[-4px] rounded-full border border-indigo-400/30"
42
+ style={{ borderTopColor: 'transparent', borderBottomColor: 'transparent', animation: 'spin 2s linear infinite' }}
43
+ />
44
+ <div
45
+ className="absolute inset-[-8px] rounded-full border border-violet-400/15"
46
+ style={{ borderLeftColor: 'transparent', borderRightColor: 'transparent', animation: 'spin 3s linear infinite reverse' }}
47
+ />
48
+ </>
49
+ )}
50
  </div>
51
  );
52
  }
53
 
54
+ // ─── Typewriter ───────────────────────────────────────────
55
+ function Typewriter({ text, speed = 8, onComplete }: { text: string; speed?: number; onComplete?: () => void }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  const [displayed, setDisplayed] = useState('');
57
  const idx = useRef(0);
58
+ const completed = useRef(false);
59
+
60
  useEffect(() => {
61
+ idx.current = 0;
62
+ setDisplayed('');
63
+ completed.current = false;
64
+
65
  const iv = setInterval(() => {
66
+ if (idx.current < text.length) {
67
+ // Write multiple characters per tick for speed
68
+ const chunk = text.slice(idx.current, idx.current + 3);
69
+ setDisplayed(p => p + chunk);
70
+ idx.current += 3;
71
+ } else {
72
+ clearInterval(iv);
73
+ if (!completed.current) {
74
+ completed.current = true;
75
+ onComplete?.();
76
+ }
77
+ }
78
  }, speed);
79
  return () => clearInterval(iv);
80
+ }, [text, speed, onComplete]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
 
 
 
 
82
  return (
83
+ <span>
84
+ {displayed}
85
+ {displayed.length < text.length && (
86
+ <span className="inline-block w-0.5 h-4 bg-indigo-400/70 ml-0.5 animate-pulse align-middle" />
87
+ )}
88
+ </span>
 
 
 
 
89
  );
90
  }
91
 
92
+ // ─── Thinking Indicator ───────────────────────────────────
93
+ const STAGES = [
94
+ 'Routing to switchboard...',
95
+ 'Research agent scanning...',
96
+ 'Cross-referencing sources...',
97
+ 'Synthesizing analysis...',
98
+ ];
 
 
 
 
 
 
 
 
 
99
 
100
+ function ThinkingIndicator({ stage }: { stage: string }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  return (
102
+ <motion.div
103
+ initial={{ opacity: 0, y: 8 }}
104
+ animate={{ opacity: 1, y: 0 }}
105
+ exit={{ opacity: 0, y: -8 }}
106
+ className="flex items-start gap-3 max-w-[768px]"
107
+ >
108
+ <JanusOrb size={32} thinking />
109
+ <div className="pt-1">
110
+ <motion.p
111
+ key={stage}
112
+ initial={{ opacity: 0 }}
113
+ animate={{ opacity: 1 }}
114
+ className="text-sm text-gray-400"
115
+ >
116
+ {stage}
117
+ </motion.p>
118
+ <div className="flex items-center gap-1 mt-2">
119
+ {[0, 1, 2].map(i => (
120
+ <motion.div
121
+ key={i}
122
+ className="w-1 h-1 rounded-full bg-indigo-400"
123
+ animate={{ opacity: [0.3, 1, 0.3] }}
124
+ transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }}
125
+ />
126
+ ))}
127
  </div>
128
+ </div>
 
129
  </motion.div>
130
  );
131
  }
132
 
133
+ // ─── Message Bubble ───────────────────────────────────────
134
+ function MessageBubble({ message, isLatest }: { message: Message; isLatest: boolean }) {
135
+ const isUser = message.role === 'user';
136
+
137
+ if (isUser) {
138
+ return (
139
+ <motion.div
140
+ initial={{ opacity: 0, y: 8 }}
141
+ animate={{ opacity: 1, y: 0 }}
142
+ className="flex justify-end"
143
+ >
144
+ <div className="bubble-user px-5 py-3 max-w-[600px]">
145
+ <p className="text-[14px] text-gray-200 leading-relaxed">{message.content}</p>
 
146
  </div>
147
+ </motion.div>
148
+ );
149
+ }
 
 
 
 
 
 
 
 
150
 
 
 
151
  return (
152
+ <motion.div
153
+ initial={{ opacity: 0, y: 8 }}
154
+ animate={{ opacity: 1, y: 0 }}
155
+ className="flex items-start gap-3 max-w-[768px]"
156
+ >
157
+ <div className="mt-0.5 shrink-0">
158
+ <JanusOrb size={28} />
159
+ </div>
160
+ <div className="flex-1 min-w-0">
161
+ {/* Route metadata pills */}
162
+ {message.metadata?.routeInfo && (
163
+ <div className="flex items-center gap-2 mb-2 flex-wrap">
164
+ {message.metadata.routeInfo.domain_pack && (
165
+ <span className="px-2 py-0.5 rounded-full text-[10px] text-indigo-400 bg-indigo-500/10 border border-indigo-500/15">
166
+ {message.metadata.routeInfo.domain_pack}
167
+ </span>
168
+ )}
169
+ {message.metadata.routeInfo.execution_mode && (
170
+ <span className="px-2 py-0.5 rounded-full text-[10px] text-gray-500 bg-white/[0.04] border border-white/[0.06]">
171
+ {message.metadata.routeInfo.execution_mode}
172
+ </span>
173
+ )}
174
+ {message.metadata.elapsed && (
175
+ <span className="text-[10px] text-gray-600">
176
+ {message.metadata.elapsed}s
177
+ </span>
178
+ )}
179
  </div>
180
+ )}
181
+
182
+ {/* Response content */}
183
+ <div className="text-[14px] text-gray-300 leading-[1.7] whitespace-pre-wrap">
184
+ {isLatest ? (
185
+ <Typewriter text={message.content} speed={6} />
186
+ ) : (
187
+ message.content
188
+ )}
189
  </div>
190
  </div>
191
  </motion.div>
192
  );
193
  }
194
 
195
+ // ─── Suggested Prompts ────────────────────────────────────
196
+ const SUGGESTED = [
197
+ 'Analyze the impact of rising interest rates on tech stocks',
198
+ 'What is the current market sentiment around AI companies?',
199
+ 'Compare Reliance vs TCS as long-term investments',
200
+ 'Explain how quantitative tightening affects emerging markets',
201
+ ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
+ function EmptyState({ onSelect }: { onSelect: (q: string) => void }) {
204
  return (
205
+ <motion.div
206
+ initial={{ opacity: 0 }}
207
+ animate={{ opacity: 1 }}
208
+ transition={{ delay: 0.2 }}
209
+ className="flex flex-col items-center justify-center h-full px-4"
210
+ >
211
+ <JanusOrb size={56} />
212
+ <h2 className="mt-8 text-xl font-light text-gray-200 tracking-wide">
213
+ What can I research for you?
214
+ </h2>
215
+ <p className="mt-2 text-sm text-gray-600 max-w-md text-center">
216
+ Multi-agent intelligence pipeline — switchboard routes your query through research, analysis, and synthesis.
217
+ </p>
218
+
219
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 mt-10 w-full max-w-xl">
220
+ {SUGGESTED.map(q => (
221
+ <button
222
+ key={q}
223
+ onClick={() => onSelect(q)}
224
+ className="text-left px-4 py-3 rounded-2xl border border-white/[0.06] hover:border-white/[0.12] hover:bg-white/[0.03] transition-all group"
225
+ >
226
+ <p className="text-[13px] text-gray-400 group-hover:text-gray-200 transition-colors leading-snug">
227
+ {q}
228
+ </p>
229
+ </button>
230
+ ))}
231
  </div>
232
+ </motion.div>
233
  );
234
  }
235
 
236
  // ═══════════════════════════════════════════════════════════
237
+ // MAIN CHAT PAGE
238
  // ═══════════════════════════════════════════════════════════
239
+ export default function ChatPage() {
240
+ const [messages, setMessages] = useState<Message[]>([]);
 
 
 
241
  const [input, setInput] = useState('');
242
+ const [isThinking, setIsThinking] = useState(false);
243
+ const [thinkingStage, setThinkingStage] = useState(0);
244
+ const scrollRef = useRef<HTMLDivElement>(null);
245
+ const inputRef = useRef<HTMLTextAreaElement>(null);
246
+ const latestMsgId = useRef<string | null>(null);
247
 
248
+ // Auto-scroll on new messages
249
  useEffect(() => {
250
+ if (scrollRef.current) {
251
+ scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
252
+ }
253
+ }, [messages, isThinking]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
+ // Thinking stage rotation
256
+ useEffect(() => {
257
+ if (!isThinking) return;
258
+ const iv = setInterval(() => setThinkingStage(p => (p + 1) % STAGES.length), 2500);
259
+ return () => clearInterval(iv);
260
+ }, [isThinking]);
 
 
261
 
262
+ // Auto-resize textarea
263
+ const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
264
+ setInput(e.target.value);
265
+ e.target.style.height = 'auto';
266
+ e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px';
 
 
267
  };
268
 
269
+ const sendMessage = useCallback(async (text: string) => {
270
+ if (!text.trim() || isThinking) return;
271
+
272
+ const userMsg: Message = {
273
+ id: `u-${Date.now()}`,
274
+ role: 'user',
275
+ content: text.trim(),
276
+ timestamp: new Date(),
277
+ };
278
+ setMessages(prev => [...prev, userMsg]);
279
+ setInput('');
280
+ setIsThinking(true);
281
+ setThinkingStage(0);
282
+
283
+ // Reset textarea height
284
+ if (inputRef.current) {
285
+ inputRef.current.style.height = 'auto';
286
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
 
 
 
288
  try {
289
+ const result = await apiClient.analyze({ user_input: text.trim() });
290
+
291
+ const finalAnswer = result.final_answer || result.final?.response || result.final?.summary || 'No analysis could be generated.';
292
+
293
+ const janusMsg: Message = {
294
+ id: `j-${Date.now()}`,
295
+ role: 'janus',
296
+ content: finalAnswer,
297
+ timestamp: new Date(),
298
+ metadata: {
299
+ domain: result.domain,
300
+ queryType: result.query_type,
301
+ elapsed: result.elapsed_seconds,
302
+ confidence: result.final?.confidence,
303
+ routeInfo: result.route,
304
+ },
305
+ };
306
+ latestMsgId.current = janusMsg.id;
307
+ setMessages(prev => [...prev, janusMsg]);
308
+ } catch (err) {
309
+ const errStr = err instanceof Error ? err.message : String(err);
310
+ const is429 = errStr.includes('429') || errStr.toLowerCase().includes('rate limit') || errStr.toLowerCase().includes('too many');
311
+ const errorMsg: Message = {
312
+ id: `e-${Date.now()}`,
313
+ role: 'janus',
314
+ content: is429
315
+ ? 'Rate limited by OpenRouter (429). The free API tier has usage caps. Please wait 30-60 seconds and try again — the backend will auto-retry with backoff.'
316
+ : `I encountered an error processing your request: ${errStr.slice(0, 200)}. Please check that the backend is running and try again.`,
317
+ timestamp: new Date(),
318
+ };
319
+ setMessages(prev => [...prev, errorMsg]);
320
+ } finally {
321
+ setIsThinking(false);
322
+ }
323
+ }, [isThinking]);
324
 
325
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
326
+ if (e.key === 'Enter' && !e.shiftKey) {
327
+ e.preventDefault();
328
+ sendMessage(input);
329
+ }
 
 
 
330
  };
331
 
332
+ const isEmpty = messages.length === 0 && !isThinking;
 
 
333
 
334
  return (
335
+ <div className="h-full flex flex-col">
336
+ {/* Conversation area */}
337
+ <div ref={scrollRef} className="flex-1 overflow-y-auto">
338
+ {isEmpty ? (
339
+ <EmptyState onSelect={(q) => { setInput(q); sendMessage(q); }} />
340
+ ) : (
341
+ <div className="max-w-[860px] mx-auto px-4 py-8 space-y-6">
342
+ {messages.map((msg) => (
343
+ <MessageBubble
344
+ key={msg.id}
345
+ message={msg}
346
+ isLatest={msg.id === latestMsgId.current && msg.role === 'janus'}
347
+ />
348
  ))}
349
+ <AnimatePresence>
350
+ {isThinking && <ThinkingIndicator stage={STAGES[thinkingStage]} />}
351
+ </AnimatePresence>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  </div>
353
  )}
354
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
 
356
+ {/* Input bar */}
357
+ <div className="shrink-0 border-t border-white/[0.04] px-4 py-3">
358
+ <div className="max-w-[768px] mx-auto">
359
+ <div className="input-bar flex items-end gap-2 px-4 py-2">
360
+ <textarea
361
+ ref={inputRef}
362
+ value={input}
363
+ onChange={handleInputChange}
364
+ onKeyDown={handleKeyDown}
365
+ disabled={isThinking}
366
+ placeholder="Message Janus..."
367
+ rows={1}
368
+ className="flex-1 bg-transparent text-[14px] text-gray-200 placeholder-gray-600 resize-none focus:outline-none disabled:opacity-40 leading-relaxed py-1.5 max-h-40"
369
+ />
370
+ <motion.button
371
+ whileHover={{ scale: 1.05 }}
372
+ whileTap={{ scale: 0.95 }}
373
+ onClick={() => sendMessage(input)}
374
+ disabled={isThinking || !input.trim()}
375
+ className="p-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:bg-gray-800 disabled:text-gray-600 text-white transition-all shrink-0 mb-0.5"
376
+ >
377
+ <ArrowUp size={16} />
378
+ </motion.button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  </div>
380
+ <p className="text-center text-[11px] text-gray-700 mt-2">
381
+ Janus can make mistakes. Verify important analysis independently.
382
+ </p>
383
+ </div>
 
 
 
 
384
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  </div>
386
  );
387
  }
frontend/src/app/prompts/page.tsx CHANGED
@@ -1,38 +1,13 @@
1
  'use client';
2
 
3
- import { useEffect, useState, useCallback } from 'react';
4
- import { motion } from 'framer-motion';
5
- import { Terminal, FileCode2, Brain, Zap, Search, Shield, Hexagon, Info } from 'lucide-react';
6
 
7
  const PROMPTS = [
8
- {
9
- name: 'switchboard',
10
- label: 'Switchboard',
11
- icon: Search,
12
- description: 'Routes queries to the right agent pipeline. Classifies domain, complexity, and required tools.',
13
- color: 'text-indigo-400',
14
- },
15
- {
16
- name: 'research',
17
- label: 'Research',
18
- icon: FileCode2,
19
- description: 'Gathers and analyzes information from web search, news, knowledge base, and API discovery.',
20
- color: 'text-violet-400',
21
- },
22
- {
23
- name: 'synthesizer',
24
- label: 'Synthesizer',
25
- icon: Brain,
26
- description: 'Produces the final answer by combining research, planning, and simulation outputs.',
27
- color: 'text-emerald-400',
28
- },
29
- {
30
- name: 'verifier',
31
- label: 'Verifier',
32
- icon: Shield,
33
- description: 'Quality gate — checks analysis for logical soundness, evidence alignment, and completeness.',
34
- color: 'text-amber-400',
35
- },
36
  ];
37
 
38
  export default function PromptsPage() {
@@ -40,151 +15,106 @@ export default function PromptsPage() {
40
  const [loading, setLoading] = useState(true);
41
 
42
  useEffect(() => {
43
- const t = setTimeout(() => {
44
- setSelectedPrompt(PROMPTS[0]);
45
- setLoading(false);
46
- }, 500);
47
  return () => clearTimeout(t);
48
  }, []);
49
 
50
  if (loading) {
51
  return (
52
- <div className="flex h-screen items-center justify-center pb-20">
53
- <div className="flex flex-col items-center gap-4">
54
- <Terminal size={32} className="text-indigo-500/50" />
55
- <div className="flex gap-1.5 mt-2">
56
- {[0, 1, 2].map((i) => (
57
- <div key={i} className="w-1.5 h-1.5 rounded-full bg-indigo-500" style={{ animation: `pulse 1.5s infinite ${i * 0.2}s` }} />
58
- ))}
59
- </div>
60
  </div>
61
  </div>
62
  );
63
  }
64
 
65
  return (
66
- <div className="h-screen flex flex-col p-6 max-h-screen max-w-[1600px] mx-auto relative overflow-hidden">
67
-
68
- {/* Background flare */}
69
- <div className="absolute top-1/2 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-indigo-500/5 blur-[100px] rounded-[100%] pointer-events-none" />
70
-
71
- <header className="flex items-center justify-between shrink-0 mb-6 relative z-10 px-2 lg:px-6">
72
- <div className="flex items-center gap-4">
73
- <div className="w-10 h-10 rounded-2xl glass flex items-center justify-center border border-indigo-500/20">
74
- <Terminal size={18} className="text-indigo-400" />
75
  </div>
76
  <div>
77
- <h1 className="text-2xl font-light tracking-[0.15em] text-gradient-subtle uppercase">
78
- Agent Protocols
79
- </h1>
80
- <p className="text-[10px] font-mono text-gray-500 uppercase tracking-widest mt-1">
81
- Cognitive instruction sets for the Janus agent pipeline
82
- </p>
83
  </div>
84
  </div>
85
- </header>
86
 
87
- <div className="flex-1 min-h-0 flex gap-6 px-2 lg:px-6 relative z-10">
88
-
89
- {/* Left Sidebar: Prompt selector */}
90
- <div className="w-72 shrink-0 flex flex-col gap-4">
91
- <div className="flex items-center gap-2 mb-2 text-[10px] font-mono text-gray-500 uppercase tracking-widest">
92
- <FileCode2 size={12} /> Agent Instructions
93
- </div>
94
-
95
- <div className="flex-1 overflow-y-auto space-y-2 pr-2">
96
- {PROMPTS.map((p) => {
97
- const isActive = selectedPrompt?.name === p.name;
98
- const Icon = p.icon;
99
- return (
100
- <button
101
- key={p.name}
102
- onClick={() => setSelectedPrompt(p)}
103
- className={`w-full text-left p-4 rounded-2xl transition-all duration-300 border block group ${
104
- isActive
105
- ? 'glass border-indigo-500/40 bg-indigo-500/5'
106
- : 'border-white/[0.03] hover:border-white/10 hover:bg-white/[0.02]'
107
- }`}
108
- >
109
- <div className="flex items-center gap-3 mb-2">
110
- <Icon size={16} className={isActive ? p.color : 'text-gray-600'} />
111
- <span className={`text-sm font-mono tracking-wider ${isActive ? 'text-indigo-300' : 'text-gray-400 group-hover:text-gray-300'}`}>
112
- {p.label}
113
- </span>
114
- </div>
115
- <div className="text-[10px] font-mono text-gray-600 line-clamp-2">
116
- {p.description}
117
- </div>
118
- </button>
119
- );
120
- })}
121
- </div>
122
  </div>
123
 
124
- {/* Right Area: Prompt detail */}
125
- <div className="flex-1 flex flex-col glass rounded-3xl border border-white/[0.05] overflow-hidden group">
126
- {/* Header */}
127
- <div className="h-14 border-b border-white/[0.05] flex items-center justify-between px-6 bg-black/20">
128
- <div className="flex items-center gap-3 text-xs font-mono text-gray-400">
129
- {selectedPrompt && <selectedPrompt.icon size={14} className={selectedPrompt.color} />}
130
- <span>Viewing: <span className="text-indigo-300">{selectedPrompt?.label || 'Nothing Selected'}</span></span>
131
- </div>
132
- </div>
133
 
134
- {/* Body */}
135
- <div className="flex-1 relative bg-[#0a0a0f]/80 p-8 overflow-y-auto">
136
- {selectedPrompt ? (
137
- <div className="max-w-2xl">
138
- <div className="flex items-center gap-3 mb-6">
139
- <selectedPrompt.icon size={24} className={selectedPrompt.color} />
140
- <h2 className="text-xl font-light text-gray-100">{selectedPrompt.label}</h2>
141
  </div>
142
- <p className="text-sm text-gray-400 leading-relaxed mb-8">{selectedPrompt.description}</p>
143
-
144
- <div className="glass rounded-xl p-6 border border-white/[0.04]">
145
- <div className="flex items-center gap-2 mb-4 text-[10px] font-mono text-gray-500 uppercase tracking-widest">
146
- <Info size={12} /> Protocol Details
147
- </div>
148
- <div className="space-y-3 text-xs font-mono">
149
- <div className="flex justify-between border-b border-white/5 pb-2">
150
- <span className="text-gray-500">Agent Name</span>
151
- <span className="text-gray-300">{selectedPrompt.name}</span>
152
- </div>
153
- <div className="flex justify-between border-b border-white/5 pb-2">
154
- <span className="text-gray-500">Pipeline Position</span>
155
- <span className="text-gray-300">
156
- {selectedPrompt.name === 'switchboard' ? '1st (entry point)' :
157
- selectedPrompt.name === 'research' ? '2nd (data gathering)' :
158
- selectedPrompt.name === 'synthesizer' ? '3rd (final output)' :
159
- 'Quality gate (optional)'}
160
- </span>
161
- </div>
162
- <div className="flex justify-between border-b border-white/5 pb-2">
163
- <span className="text-gray-500">Model Calls</span>
164
- <span className="text-gray-300">1 per execution</span>
165
- </div>
166
- <div className="flex justify-between pb-2">
167
- <span className="text-gray-500">Editable</span>
168
- <span className="text-gray-300">Via backend prompt store</span>
169
- </div>
170
  </div>
171
- </div>
172
-
173
- <div className="mt-6 p-4 rounded-xl bg-amber-500/5 border border-amber-500/10">
174
- <p className="text-xs font-mono text-amber-400/70">
175
- Prompt files are stored on the cloud backend. To edit prompts, modify the files in <code className="text-amber-300">backend/app/prompts/</code> and redeploy.
176
- </p>
177
- </div>
178
  </div>
179
- ) : (
180
- <div className="flex flex-col items-center justify-center h-full text-center">
181
- <Terminal size={32} className="text-gray-700 mb-4" />
182
- <p className="text-sm font-mono text-gray-500">Select an agent protocol to view details.</p>
 
183
  </div>
184
- )}
185
- </div>
 
 
 
 
 
186
  </div>
187
-
188
  </div>
189
  </div>
190
  );
 
1
  'use client';
2
 
3
+ import { useEffect, useState } from 'react';
4
+ import { Terminal, FileCode2, Brain, Search, Shield, Info, RefreshCw } from 'lucide-react';
 
5
 
6
  const PROMPTS = [
7
+ { name: 'switchboard', label: 'Switchboard', icon: Search, description: 'Routes queries to the right agent pipeline. Classifies domain, complexity, and required tools.', color: 'text-indigo-400' },
8
+ { name: 'research', label: 'Research', icon: FileCode2, description: 'Gathers and analyzes information from web search, news, knowledge base, and API discovery.', color: 'text-violet-400' },
9
+ { name: 'synthesizer', label: 'Synthesizer', icon: Brain, description: 'Produces the final answer by combining research, planning, and simulation outputs.', color: 'text-emerald-400' },
10
+ { name: 'verifier', label: 'Verifier', icon: Shield, description: 'Quality gate — checks analysis for logical soundness, evidence alignment, and completeness.', color: 'text-amber-400' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ];
12
 
13
  export default function PromptsPage() {
 
15
  const [loading, setLoading] = useState(true);
16
 
17
  useEffect(() => {
18
+ const t = setTimeout(() => { setSelectedPrompt(PROMPTS[0]); setLoading(false); }, 300);
 
 
 
19
  return () => clearTimeout(t);
20
  }, []);
21
 
22
  if (loading) {
23
  return (
24
+ <div className="flex h-full items-center justify-center">
25
+ <div className="flex flex-col items-center gap-3">
26
+ <RefreshCw size={20} className="text-indigo-400 animate-spin" />
27
+ <p className="text-[12px] text-gray-500">Loading protocols...</p>
 
 
 
 
28
  </div>
29
  </div>
30
  );
31
  }
32
 
33
  return (
34
+ <div className="h-full flex flex-col overflow-hidden">
35
+ {/* Header */}
36
+ <div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
37
+ <div className="flex items-center gap-3">
38
+ <div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
39
+ <Terminal size={16} className="text-indigo-400" />
 
 
 
40
  </div>
41
  <div>
42
+ <h1 className="text-lg font-light text-gray-100">Prompt Lab</h1>
43
+ <p className="text-[11px] text-gray-600">Agent protocol instruction sets for the Janus pipeline</p>
 
 
 
 
44
  </div>
45
  </div>
46
+ </div>
47
 
48
+ {/* Content */}
49
+ <div className="flex-1 min-h-0 flex overflow-hidden">
50
+ {/* Left: Protocol selector */}
51
+ <div className="w-64 shrink-0 border-r border-white/[0.04] p-4 overflow-y-auto space-y-2">
52
+ <div className="text-[10px] text-gray-600 uppercase tracking-wider mb-3 px-1">Agent Protocols</div>
53
+ {PROMPTS.map(p => {
54
+ const isActive = selectedPrompt?.name === p.name;
55
+ const Icon = p.icon;
56
+ return (
57
+ <button
58
+ key={p.name}
59
+ onClick={() => setSelectedPrompt(p)}
60
+ className={`w-full text-left p-3 rounded-xl transition-all border group ${
61
+ isActive
62
+ ? 'bg-white/[0.05] border-indigo-500/20'
63
+ : 'border-transparent hover:bg-white/[0.03] hover:border-white/[0.04]'
64
+ }`}
65
+ >
66
+ <div className="flex items-center gap-2.5 mb-1">
67
+ <Icon size={14} className={isActive ? p.color : 'text-gray-600'} />
68
+ <span className={`text-[12px] ${isActive ? 'text-gray-200' : 'text-gray-500 group-hover:text-gray-300'}`}>
69
+ {p.label}
70
+ </span>
71
+ </div>
72
+ <p className="text-[10px] text-gray-600 line-clamp-2 pl-[22px]">{p.description}</p>
73
+ </button>
74
+ );
75
+ })}
 
 
 
 
 
 
 
76
  </div>
77
 
78
+ {/* Right: Detail view */}
79
+ <div className="flex-1 overflow-y-auto p-6">
80
+ {selectedPrompt ? (
81
+ <div className="max-w-2xl">
82
+ <div className="flex items-center gap-3 mb-5">
83
+ <selectedPrompt.icon size={22} className={selectedPrompt.color} />
84
+ <h2 className="text-xl font-light text-gray-100">{selectedPrompt.label}</h2>
85
+ </div>
86
+ <p className="text-[13px] text-gray-400 leading-relaxed mb-6">{selectedPrompt.description}</p>
87
 
88
+ <div className="card p-5 space-y-3">
89
+ <div className="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-wider mb-3">
90
+ <Info size={11} /> Protocol Details
 
 
 
 
91
  </div>
92
+ {[
93
+ ['Agent Name', selectedPrompt.name],
94
+ ['Pipeline Position', selectedPrompt.name === 'switchboard' ? '1st (entry)' : selectedPrompt.name === 'research' ? '2nd (data)' : selectedPrompt.name === 'synthesizer' ? '3rd (output)' : 'Quality gate'],
95
+ ['Model Calls', '1 per execution'],
96
+ ['Editable', 'Via backend prompt store'],
97
+ ].map(([k, v]) => (
98
+ <div key={k} className="flex justify-between text-[12px] border-b border-white/[0.03] pb-2">
99
+ <span className="text-gray-500">{k}</span>
100
+ <span className="text-gray-300">{v}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  </div>
102
+ ))}
 
 
 
 
 
 
103
  </div>
104
+
105
+ <div className="mt-5 p-4 rounded-xl bg-amber-500/5 border border-amber-500/10">
106
+ <p className="text-[11px] text-amber-400/70">
107
+ Prompt files are stored on the backend. Edit files in <code className="text-amber-300">backend/app/prompts/</code> and redeploy.
108
+ </p>
109
  </div>
110
+ </div>
111
+ ) : (
112
+ <div className="flex flex-col items-center justify-center h-full">
113
+ <Terminal size={28} className="text-gray-700 mb-3" />
114
+ <p className="text-[13px] text-gray-500">Select a protocol to view.</p>
115
+ </div>
116
+ )}
117
  </div>
 
118
  </div>
119
  </div>
120
  );
frontend/src/app/pulse/page.tsx ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { motion } from 'framer-motion';
5
+ import {
6
+ Activity, Radio, Database, Moon, Sun, Sunrise, Sunset,
7
+ AlertTriangle, CheckCircle, ExternalLink, Info, RefreshCw
8
+ } from 'lucide-react';
9
+
10
+ interface DaemonStatus {
11
+ running: boolean;
12
+ cycle_count: number;
13
+ last_run: string;
14
+ circadian: {
15
+ current_phase: string;
16
+ phase_name: string;
17
+ phase_description: string;
18
+ priority: string;
19
+ current_tasks: string[];
20
+ };
21
+ signal_queue?: {
22
+ total_signals: number;
23
+ severity_counts: Record<string, number>;
24
+ };
25
+ signals?: number;
26
+ }
27
+
28
+ interface Alert {
29
+ type: string;
30
+ title: string;
31
+ description: string;
32
+ source: string;
33
+ severity: string;
34
+ timestamp: string;
35
+ url?: string;
36
+ }
37
+
38
+ interface MemoryStats {
39
+ queries: number;
40
+ entities: number;
41
+ insights: number;
42
+ }
43
+
44
+ function SeverityBadge({ severity }: { severity: string }) {
45
+ const map: Record<string, string> = {
46
+ critical: 'bg-red-500/15 text-red-300 border-red-500/25',
47
+ high: 'bg-orange-500/15 text-orange-300 border-orange-500/25',
48
+ medium: 'bg-amber-500/15 text-amber-300 border-amber-500/25',
49
+ low: 'bg-gray-500/15 text-gray-400 border-gray-500/25',
50
+ };
51
+ return <span className={`px-2 py-0.5 rounded border text-[9px] uppercase tracking-wider ${map[severity] || map.low}`}>{severity}</span>;
52
+ }
53
+
54
+ export default function PulsePage() {
55
+ const [status, setStatus] = useState<DaemonStatus | null>(null);
56
+ const [alerts, setAlerts] = useState<Alert[]>([]);
57
+ const [memory, setMemory] = useState<MemoryStats | null>(null);
58
+ const [loading, setLoading] = useState(true);
59
+
60
+ const fetchData = useCallback(async () => {
61
+ const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
62
+ try {
63
+ const [statusRes, alertsRes, memoryRes] = await Promise.all([
64
+ fetch(`${baseUrl}/daemon/status`).then(r => r.ok ? r.json() : null),
65
+ fetch(`${baseUrl}/daemon/alerts?limit=20`).then(r => r.ok ? r.json() : []),
66
+ fetch(`${baseUrl}/memory/stats`).then(r => r.ok ? r.json() : null),
67
+ ]);
68
+ if (statusRes) setStatus(statusRes);
69
+ if (alertsRes) setAlerts(Array.isArray(alertsRes) ? alertsRes : []);
70
+ if (memoryRes) setMemory(memoryRes);
71
+ } catch { /* silent */ }
72
+ finally { setLoading(false); }
73
+ }, []);
74
+
75
+ useEffect(() => {
76
+ fetchData();
77
+ const iv = setInterval(fetchData, 30000);
78
+ return () => clearInterval(iv);
79
+ }, [fetchData]);
80
+
81
+ const phaseIcons: Record<string, React.ReactNode> = {
82
+ morning: <Sunrise size={16} className="text-amber-400" />,
83
+ daytime: <Sun size={16} className="text-indigo-400" />,
84
+ evening: <Sunset size={16} className="text-violet-400" />,
85
+ night: <Moon size={16} className="text-indigo-300" />,
86
+ };
87
+
88
+ if (loading) {
89
+ return (
90
+ <div className="flex items-center justify-center h-full">
91
+ <div className="flex flex-col items-center gap-3">
92
+ <RefreshCw size={20} className="text-indigo-400 animate-spin" />
93
+ <p className="text-[12px] text-gray-500">Loading pulse data...</p>
94
+ </div>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ return (
100
+ <div className="h-full flex flex-col overflow-hidden">
101
+ {/* Header */}
102
+ <div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
103
+ <div className="flex items-center gap-3">
104
+ <div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
105
+ <Activity size={16} className="text-emerald-400" />
106
+ </div>
107
+ <div>
108
+ <h1 className="text-lg font-light text-gray-100">Pulse</h1>
109
+ <p className="text-[11px] text-gray-600">Daemon telemetry, signal queue, and memory graph</p>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ {/* Content */}
115
+ <div className="flex-1 overflow-y-auto p-6">
116
+ <div className="max-w-5xl mx-auto">
117
+ {/* Top stats grid */}
118
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
119
+ {/* Circadian phase */}
120
+ {status?.circadian && (
121
+ <div className="card p-5">
122
+ <div className="flex items-center gap-3 mb-4">
123
+ {phaseIcons[status.circadian.current_phase]}
124
+ <div>
125
+ <h3 className="text-[14px] text-gray-200">{status.circadian.phase_name}</h3>
126
+ <p className="text-[11px] text-gray-600">{status.circadian.phase_description}</p>
127
+ </div>
128
+ </div>
129
+ <div className="space-y-2 text-[12px]">
130
+ <div className="flex justify-between"><span className="text-gray-500">Priority</span><span className="text-gray-300 capitalize">{status.circadian.priority}</span></div>
131
+ <div className="flex justify-between"><span className="text-gray-500">Active Tasks</span><span className="text-gray-300">{status.circadian.current_tasks.length}</span></div>
132
+ </div>
133
+ </div>
134
+ )}
135
+
136
+ {/* Signal Queue */}
137
+ <div className="card p-5">
138
+ <div className="flex items-center gap-2 text-[11px] text-indigo-400 uppercase tracking-wider mb-4">
139
+ <Radio size={12} /> Signal Queue
140
+ </div>
141
+ <div className="grid grid-cols-2 gap-3">
142
+ <div className="text-center">
143
+ <div className="text-xl font-light text-white">{status?.signal_queue?.total_signals || status?.signals || 0}</div>
144
+ <div className="text-[9px] text-gray-600 uppercase tracking-wider">Total</div>
145
+ </div>
146
+ <div className="text-center">
147
+ <div className="text-xl font-light text-red-400">{status?.signal_queue?.severity_counts?.high || 0}</div>
148
+ <div className="text-[9px] text-gray-600 uppercase tracking-wider">High</div>
149
+ </div>
150
+ </div>
151
+ </div>
152
+
153
+ {/* Memory Graph */}
154
+ {memory && (
155
+ <div className="card p-5">
156
+ <div className="flex items-center gap-2 text-[11px] text-violet-400 uppercase tracking-wider mb-4">
157
+ <Database size={12} /> Memory Graph
158
+ </div>
159
+ <div className="grid grid-cols-3 gap-2">
160
+ <div className="text-center">
161
+ <div className="text-xl font-light text-white">{memory.queries}</div>
162
+ <div className="text-[9px] text-gray-600 uppercase">Queries</div>
163
+ </div>
164
+ <div className="text-center">
165
+ <div className="text-xl font-light text-indigo-400">{memory.entities}</div>
166
+ <div className="text-[9px] text-gray-600 uppercase">Entities</div>
167
+ </div>
168
+ <div className="text-center">
169
+ <div className="text-xl font-light text-violet-400">{memory.insights}</div>
170
+ <div className="text-[9px] text-gray-600 uppercase">Insights</div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ )}
175
+ </div>
176
+
177
+ {/* Daemon status bar */}
178
+ {status && (
179
+ <div className="card p-4 mb-6">
180
+ <div className="flex items-center justify-between">
181
+ <div className="flex items-center gap-3">
182
+ <div className={`w-2 h-2 rounded-full ${status.running ? 'bg-emerald-400' : 'bg-gray-600'}`} />
183
+ <span className="text-[12px] text-gray-300">{status.running ? 'Daemon active' : 'Daemon offline'}</span>
184
+ </div>
185
+ <div className="flex items-center gap-4 text-[11px] text-gray-600">
186
+ <span>Cycles: {status.cycle_count}</span>
187
+ {status.last_run && <span>Last run: {new Date(status.last_run).toLocaleTimeString()}</span>}
188
+ </div>
189
+ </div>
190
+ </div>
191
+ )}
192
+
193
+ {/* Alerts feed */}
194
+ <div>
195
+ <div className="text-[11px] text-gray-500 uppercase tracking-wider mb-3">
196
+ Alert Feed — {alerts.length} alerts
197
+ </div>
198
+ {alerts.length > 0 ? (
199
+ <div className="space-y-2">
200
+ {alerts.map((alert, i) => (
201
+ <motion.div
202
+ key={`${alert.title}-${i}`}
203
+ initial={{ opacity: 0, y: 6 }}
204
+ animate={{ opacity: 1, y: 0 }}
205
+ transition={{ delay: i * 0.03 }}
206
+ className="card p-3 hover:border-white/[0.12]"
207
+ >
208
+ <div className="flex items-start gap-3">
209
+ <div className="mt-0.5">
210
+ {alert.severity === 'high' || alert.severity === 'critical'
211
+ ? <AlertTriangle size={14} className="text-red-400" />
212
+ : <Info size={14} className="text-amber-400" />}
213
+ </div>
214
+ <div className="flex-1 min-w-0">
215
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
216
+ <SeverityBadge severity={alert.severity} />
217
+ <span className="text-[10px] text-gray-600">{alert.type}</span>
218
+ </div>
219
+ <p className="text-[12px] text-gray-300 leading-snug">{alert.title}</p>
220
+ {alert.description && <p className="text-[11px] text-gray-600 mt-1 line-clamp-1">{alert.description}</p>}
221
+ <div className="flex items-center gap-2 mt-2">
222
+ <span className="text-[10px] text-gray-700">{new Date(alert.timestamp).toLocaleString()}</span>
223
+ {alert.url && (
224
+ <a href={alert.url} target="_blank" rel="noreferrer" className="text-[10px] text-indigo-400 hover:text-indigo-300 flex items-center gap-0.5">
225
+ <ExternalLink size={8} /> Read
226
+ </a>
227
+ )}
228
+ </div>
229
+ </div>
230
+ </div>
231
+ </motion.div>
232
+ ))}
233
+ </div>
234
+ ) : (
235
+ <div className="card text-center py-12">
236
+ <CheckCircle size={24} className="text-emerald-500/30 mx-auto mb-3" />
237
+ <p className="text-[13px] text-gray-500">No alerts. All systems normal.</p>
238
+ </div>
239
+ )}
240
+ </div>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ );
245
+ }
frontend/src/app/sentinel/page.tsx CHANGED
@@ -259,7 +259,7 @@ export default function SentinelPage() {
259
  // ── Loading ──
260
  if (loading) {
261
  return (
262
- <div className="flex h-screen items-center justify-center pb-20">
263
  <div className="flex flex-col items-center gap-6">
264
  <JanusOrb size={56} thinking />
265
  <p className="text-xs font-mono text-indigo-400 uppercase tracking-widest animate-pulse">
@@ -273,7 +273,7 @@ export default function SentinelPage() {
273
  // ── Disabled ──
274
  if (!status?.sentinel_enabled) {
275
  return (
276
- <div className="flex h-screen flex-col items-center justify-center pb-20">
277
  <div className="glass rounded-3xl p-12 text-center max-w-md">
278
  <Shield size={40} className="text-gray-600 mx-auto mb-6" />
279
  <h2 className="text-lg font-light tracking-[0.15em] text-gradient-subtle uppercase mb-3">
@@ -292,7 +292,8 @@ export default function SentinelPage() {
292
  const domainExpertise = intelligence?.domain_expertise || {};
293
 
294
  return (
295
- <div className="max-w-[1480px] mx-auto px-10 py-10 relative">
 
296
 
297
  {/* ── Background flare ── */}
298
  <div className="fixed top-1/3 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-indigo-500/5 blur-[120px] rounded-full pointer-events-none" />
@@ -881,5 +882,6 @@ export default function SentinelPage() {
881
  </AnimatePresence>
882
  </div>
883
  </div>
 
884
  );
885
  }
 
259
  // ── Loading ──
260
  if (loading) {
261
  return (
262
+ <div className="flex h-full items-center justify-center">
263
  <div className="flex flex-col items-center gap-6">
264
  <JanusOrb size={56} thinking />
265
  <p className="text-xs font-mono text-indigo-400 uppercase tracking-widest animate-pulse">
 
273
  // ── Disabled ──
274
  if (!status?.sentinel_enabled) {
275
  return (
276
+ <div className="flex h-full flex-col items-center justify-center">
277
  <div className="glass rounded-3xl p-12 text-center max-w-md">
278
  <Shield size={40} className="text-gray-600 mx-auto mb-6" />
279
  <h2 className="text-lg font-light tracking-[0.15em] text-gradient-subtle uppercase mb-3">
 
292
  const domainExpertise = intelligence?.domain_expertise || {};
293
 
294
  return (
295
+ <div className="h-full overflow-y-auto">
296
+ <div className="max-w-[1400px] mx-auto px-6 py-8 relative">
297
 
298
  {/* ── Background flare ── */}
299
  <div className="fixed top-1/3 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-indigo-500/5 blur-[120px] rounded-full pointer-events-none" />
 
882
  </AnimatePresence>
883
  </div>
884
  </div>
885
+ </div>
886
  );
887
  }
frontend/src/app/simulation/page.tsx CHANGED
@@ -3,7 +3,7 @@
3
  import { useState, useEffect, useCallback } from 'react';
4
  import { useRouter } from 'next/navigation';
5
  import { motion, AnimatePresence } from 'framer-motion';
6
- import { FlaskConical, Target, ListOrdered, Share2, Play, Hexagon, Zap, RefreshCw } from 'lucide-react';
7
  import { apiClient } from '@/lib/api';
8
 
9
  interface SimulationSummary {
@@ -22,17 +22,12 @@ export default function SimulationPage() {
22
  const [simulations, setSimulations] = useState<SimulationSummary[]>([]);
23
  const [refreshing, setRefreshing] = useState(false);
24
 
25
- const [form, setForm] = useState({
26
- title: '',
27
- seedText: '',
28
- predictionGoal: ''
29
- });
30
 
31
  const loadSimulations = useCallback(async () => {
32
  setRefreshing(true);
33
  try {
34
  const data = await apiClient.getSimulations();
35
- // Map SimulationRecord to SimulationSummary
36
  const summaries: SimulationSummary[] = (Array.isArray(data) ? data : []).map((sim: any) => ({
37
  simulation_id: sim.simulation_id,
38
  user_input: sim.user_input || sim.title || '',
@@ -42,215 +37,168 @@ export default function SimulationPage() {
42
  created_at: sim.created_at || 0,
43
  }));
44
  setSimulations(summaries);
45
- } catch {
46
- setSimulations([]);
47
- } finally {
48
- setRefreshing(false);
49
- }
50
  }, []);
51
 
52
- useEffect(() => {
53
- loadSimulations();
54
- }, [loadSimulations]);
55
 
56
  const handleSubmit = async () => {
57
  if (!form.title || !form.seedText || !form.predictionGoal) {
58
  setError('All simulation parameters are required.');
59
  return;
60
  }
61
-
62
- setLoading(true);
63
- setError(null);
64
-
65
  try {
66
- // Run native simulation directly
67
  const userInput = `${form.title}. ${form.seedText}. Goal: ${form.predictionGoal}`;
68
  const result = await apiClient.runNativeSimulation(userInput, {
69
- title: form.title,
70
- seed_text: form.seedText,
71
- prediction_goal: form.predictionGoal,
72
  });
73
-
74
- // Navigate to simulation detail
75
  if (result.simulation_id) {
76
  router.push(`/simulation/${result.simulation_id}`);
77
  } else {
78
- setError('Simulation completed but no ID was returned.');
79
  setLoading(false);
80
  }
81
  } catch (err) {
82
- setError(err instanceof Error ? err.message : 'Failed to initialize simulation core.');
83
  setLoading(false);
84
  }
85
  };
86
 
87
  return (
88
- <div className="max-w-[1480px] mx-auto px-10 py-12">
89
- <header className="mb-12">
 
90
  <div className="flex items-center justify-between">
91
- <div className="flex items-center gap-4 mb-4">
92
- <div className="relative flex items-center justify-center w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/20">
93
- <FlaskConical size={20} className="text-amber-400" />
94
- {loading && <div className="absolute inset-0 rounded-2xl border-2 border-amber-400/50 border-t-transparent animate-spin" />}
95
  </div>
96
  <div>
97
- <h1 className="text-3xl font-light tracking-[0.15em] text-gradient-subtle uppercase">
98
- Simulation Lab
99
- </h1>
100
- <div className="flex items-center gap-2 mt-1">
101
- <div className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
102
- <span className="text-[10px] font-mono text-amber-400/70 uppercase tracking-widest">
103
- Native Simulation Engine Online
104
- </span>
105
- </div>
106
  </div>
107
  </div>
108
-
109
- <button
110
- onClick={loadSimulations}
111
- disabled={refreshing}
112
- className="flex items-center gap-2 px-4 py-2 rounded-xl glass border border-white/5 hover:border-amber-500/20 text-xs font-mono text-gray-400 hover:text-amber-300 transition-all disabled:opacity-40"
113
- >
114
- <RefreshCw size={13} className={refreshing ? 'animate-spin' : ''} />
115
- Refresh
116
  </button>
117
  </div>
118
- </header>
119
 
120
- <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
121
-
122
- {/* CREATE SIMULATION FORM */}
123
- <div className="lg:col-span-7">
124
- <div className="glass rounded-3xl p-8 relative overflow-hidden group">
125
- <div className="absolute -top-24 -right-24 w-64 h-64 bg-amber-500/5 blur-3xl rounded-full pointer-events-none" />
126
-
127
- <h2 className="text-sm font-mono text-gray-400 uppercase tracking-widest mb-8 flex items-center gap-2">
128
- <Hexagon size={14} className="text-amber-400/50" />
129
- Initialize New Scenario
130
- </h2>
131
 
132
- {error && (
133
- <div className="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-xs font-mono text-red-400">
134
- {error}
135
- </div>
136
- )}
 
 
 
 
 
137
 
138
- <div className="space-y-6">
139
-
140
- <div className="space-y-2">
141
- <label className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Scenario Title</label>
142
  <div className="relative">
143
- <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
144
- <Target size={14} className="text-amber-500/50" />
145
- </div>
146
  <input
147
  value={form.title}
148
- onChange={(e) => setForm({ ...form, title: e.target.value })}
149
  disabled={loading}
150
  placeholder="e.g. US Debt Default Simulation"
151
- className="w-full bg-black/40 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-sm text-gray-200 font-mono focus:border-amber-500/50 focus:outline-none transition-colors placeholder:text-gray-700"
 
152
  />
153
  </div>
154
  </div>
155
 
156
- <div className="space-y-2">
157
- <label className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Initial Context (Seed)</label>
158
  <div className="relative">
159
- <div className="absolute top-3.5 left-0 pl-4 pointer-events-none">
160
- <ListOrdered size={14} className="text-amber-500/50" />
161
- </div>
162
  <textarea
163
  value={form.seedText}
164
- onChange={(e) => setForm({ ...form, seedText: e.target.value })}
165
  disabled={loading}
166
- placeholder="Provide the background facts, current market state, and constraints..."
167
- rows={4}
168
- className="w-full bg-black/40 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-sm text-gray-200 font-mono focus:border-amber-500/50 focus:outline-none transition-colors placeholder:text-gray-700 resize-none"
 
169
  />
170
  </div>
171
  </div>
172
 
173
- <div className="space-y-2">
174
- <label className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Prediction Goal</label>
175
  <div className="relative">
176
- <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
177
- <Share2 size={14} className="text-amber-500/50" />
178
- </div>
179
  <input
180
  value={form.predictionGoal}
181
- onChange={(e) => setForm({ ...form, predictionGoal: e.target.value })}
182
  disabled={loading}
183
- placeholder="e.g. What is the impact on 10-year treasury yields?"
184
- className="w-full bg-black/40 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-sm text-gray-200 font-mono focus:border-amber-500/50 focus:outline-none transition-colors placeholder:text-gray-700"
 
185
  />
186
  </div>
187
  </div>
188
 
189
- <div className="pt-4">
190
- <button
191
- onClick={handleSubmit}
192
- disabled={loading}
193
- className="w-full group relative flex items-center justify-center gap-2 py-4 rounded-xl bg-amber-500/10 border border-amber-500/30 hover:bg-amber-500/20 disabled:bg-gray-900 disabled:border-gray-800 transition-all duration-300"
194
- >
195
- <div className="absolute inset-0 bg-gradient-to-r from-amber-500/0 via-amber-500/10 to-amber-500/0 opacity-0 group-hover:opacity-100 transition-opacity duration-700 blur-md" />
196
- <Zap size={16} className={loading ? 'text-gray-600' : 'text-amber-400 group-hover:scale-110 transition-transform'} />
197
- <span className={`text-xs font-mono uppercase tracking-widest ${loading ? 'text-gray-500' : 'text-amber-300'}`}>
198
- {loading ? 'Running Simulation...' : 'Launch Simulation'}
199
- </span>
200
- </button>
201
- </div>
202
-
203
  </div>
204
  </div>
205
- </div>
206
 
207
- {/* ACTIVE SIMULATIONS LIST */}
208
- <div className="lg:col-span-5 flex flex-col gap-4">
209
- <div className="flex items-center justify-between mb-2">
210
- <h3 className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Recent Executions</h3>
211
- <span className="text-[10px] font-mono text-gray-600">{simulations.length} total</span>
212
- </div>
213
-
214
- <div className="flex-1 overflow-y-auto space-y-3 pr-2" style={{ maxHeight: '600px' }}>
215
- <AnimatePresence>
216
- {simulations.length === 0 ? (
217
- <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="p-8 border border-white/5 border-dashed rounded-2xl flex flex-col items-center justify-center text-center">
218
- <Hexagon size={24} className="text-gray-800 mb-3" />
219
- <p className="text-xs font-mono text-gray-600 uppercase tracking-widest">No active simulations.</p>
220
- </motion.div>
221
- ) : (
222
- simulations.map((sim, i) => (
223
- <motion.div
224
- key={sim.simulation_id}
225
- initial={{ opacity: 0, x: 20 }}
226
- animate={{ opacity: 1, x: 0 }}
227
- transition={{ delay: i * 0.05 }}
228
- onClick={() => router.push(`/simulation/${sim.simulation_id}`)}
229
- className="glass p-5 rounded-2xl border border-white/[0.04] hover:border-amber-500/30 hover:bg-white/[0.04] cursor-pointer transition-all duration-300 group"
230
- >
231
- <div className="flex items-start justify-between mb-3">
232
- <div className="flex items-center gap-2">
233
- <div className={`w-1.5 h-1.5 rounded-full ${sim.status === 'completed' ? 'bg-emerald-400' : sim.status === 'failed' ? 'bg-red-400' : 'bg-amber-400 animate-pulse'}`} />
234
- <span className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">
235
- {sim.status}
236
- </span>
237
- </div>
238
- <span className="text-[10px] font-mono text-gray-600 uppercase">
239
- {sim.simulation_id.slice(0, 8)}
240
- </span>
241
- </div>
242
- <h4 className="text-sm font-medium text-gray-200 group-hover:text-amber-300 transition-colors line-clamp-1 mb-1">
243
- {sim.user_input}
244
- </h4>
245
- <div className="flex items-center gap-3 text-[10px] font-mono text-gray-600">
246
- <span>{sim.scenarios} scenarios</span>
247
- <span>·</span>
248
- <span>{sim.elapsed_seconds}s</span>
249
  </div>
250
- </motion.div>
251
- ))
252
- )}
253
- </AnimatePresence>
 
 
 
 
 
 
 
 
 
254
  </div>
255
  </div>
256
  </div>
 
3
  import { useState, useEffect, useCallback } from 'react';
4
  import { useRouter } from 'next/navigation';
5
  import { motion, AnimatePresence } from 'framer-motion';
6
+ import { Zap, Target, ListOrdered, Share2, RefreshCw } from 'lucide-react';
7
  import { apiClient } from '@/lib/api';
8
 
9
  interface SimulationSummary {
 
22
  const [simulations, setSimulations] = useState<SimulationSummary[]>([]);
23
  const [refreshing, setRefreshing] = useState(false);
24
 
25
+ const [form, setForm] = useState({ title: '', seedText: '', predictionGoal: '' });
 
 
 
 
26
 
27
  const loadSimulations = useCallback(async () => {
28
  setRefreshing(true);
29
  try {
30
  const data = await apiClient.getSimulations();
 
31
  const summaries: SimulationSummary[] = (Array.isArray(data) ? data : []).map((sim: any) => ({
32
  simulation_id: sim.simulation_id,
33
  user_input: sim.user_input || sim.title || '',
 
37
  created_at: sim.created_at || 0,
38
  }));
39
  setSimulations(summaries);
40
+ } catch { setSimulations([]); }
41
+ finally { setRefreshing(false); }
 
 
 
42
  }, []);
43
 
44
+ useEffect(() => { loadSimulations(); }, [loadSimulations]);
 
 
45
 
46
  const handleSubmit = async () => {
47
  if (!form.title || !form.seedText || !form.predictionGoal) {
48
  setError('All simulation parameters are required.');
49
  return;
50
  }
51
+ setLoading(true); setError(null);
 
 
 
52
  try {
 
53
  const userInput = `${form.title}. ${form.seedText}. Goal: ${form.predictionGoal}`;
54
  const result = await apiClient.runNativeSimulation(userInput, {
55
+ title: form.title, seed_text: form.seedText, prediction_goal: form.predictionGoal,
 
 
56
  });
 
 
57
  if (result.simulation_id) {
58
  router.push(`/simulation/${result.simulation_id}`);
59
  } else {
60
+ setError('Simulation completed but no ID returned.');
61
  setLoading(false);
62
  }
63
  } catch (err) {
64
+ setError(err instanceof Error ? err.message : 'Failed to run simulation.');
65
  setLoading(false);
66
  }
67
  };
68
 
69
  return (
70
+ <div className="h-full flex flex-col overflow-hidden">
71
+ {/* Header */}
72
+ <div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
73
  <div className="flex items-center justify-between">
74
+ <div className="flex items-center gap-3">
75
+ <div className="relative w-9 h-9 rounded-xl bg-amber-500/10 border border-amber-500/15 flex items-center justify-center">
76
+ <Zap size={16} className="text-amber-400" />
77
+ {loading && <div className="absolute inset-0 rounded-xl border-2 border-amber-400/40 border-t-transparent animate-spin" />}
78
  </div>
79
  <div>
80
+ <h1 className="text-lg font-light text-gray-100">Simulation Lab</h1>
81
+ <p className="text-[11px] text-gray-600">Scenario modeling and prediction engine</p>
 
 
 
 
 
 
 
82
  </div>
83
  </div>
84
+ <button onClick={loadSimulations} disabled={refreshing} className="flex items-center gap-2 px-3 py-1.5 rounded-xl border border-white/[0.06] hover:border-white/[0.12] text-[11px] text-gray-500 hover:text-gray-300 transition-all disabled:opacity-40">
85
+ <RefreshCw size={12} className={refreshing ? 'animate-spin' : ''} /> Refresh
 
 
 
 
 
 
86
  </button>
87
  </div>
88
+ </div>
89
 
90
+ {/* Content */}
91
+ <div className="flex-1 overflow-y-auto p-6">
92
+ <div className="max-w-5xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-6">
 
 
 
 
 
 
 
 
93
 
94
+ {/* Form */}
95
+ <div className="lg:col-span-7">
96
+ <div className="card p-6 space-y-5">
97
+ <h2 className="text-[11px] text-amber-400 uppercase tracking-wider flex items-center gap-2">
98
+ <Zap size={12} /> New Simulation
99
+ </h2>
100
+
101
+ {error && (
102
+ <div className="p-3 rounded-xl bg-red-500/10 border border-red-500/15 text-[12px] text-red-400">{error}</div>
103
+ )}
104
 
105
+ <div className="space-y-1.5">
106
+ <label className="text-[10px] text-gray-500 uppercase tracking-wider">Scenario Title</label>
 
 
107
  <div className="relative">
108
+ <Target size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-600" />
 
 
109
  <input
110
  value={form.title}
111
+ onChange={e => setForm({ ...form, title: e.target.value })}
112
  disabled={loading}
113
  placeholder="e.g. US Debt Default Simulation"
114
+ className="w-full py-2.5 pl-9 pr-4 text-[13px] text-gray-200 rounded-xl border border-white/[0.06] focus:border-amber-500/30 focus:outline-none transition-colors placeholder:text-gray-700"
115
+ style={{ background: 'rgba(0,0,0,0.3)' }}
116
  />
117
  </div>
118
  </div>
119
 
120
+ <div className="space-y-1.5">
121
+ <label className="text-[10px] text-gray-500 uppercase tracking-wider">Initial Context</label>
122
  <div className="relative">
123
+ <ListOrdered size={14} className="absolute left-3 top-3 text-gray-600" />
 
 
124
  <textarea
125
  value={form.seedText}
126
+ onChange={e => setForm({ ...form, seedText: e.target.value })}
127
  disabled={loading}
128
+ placeholder="Background facts, market state, constraints..."
129
+ rows={3}
130
+ className="w-full py-2.5 pl-9 pr-4 text-[13px] text-gray-200 rounded-xl border border-white/[0.06] focus:border-amber-500/30 focus:outline-none transition-colors placeholder:text-gray-700 resize-none"
131
+ style={{ background: 'rgba(0,0,0,0.3)' }}
132
  />
133
  </div>
134
  </div>
135
 
136
+ <div className="space-y-1.5">
137
+ <label className="text-[10px] text-gray-500 uppercase tracking-wider">Prediction Goal</label>
138
  <div className="relative">
139
+ <Share2 size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-600" />
 
 
140
  <input
141
  value={form.predictionGoal}
142
+ onChange={e => setForm({ ...form, predictionGoal: e.target.value })}
143
  disabled={loading}
144
+ placeholder="e.g. Impact on 10-year treasury yields?"
145
+ className="w-full py-2.5 pl-9 pr-4 text-[13px] text-gray-200 rounded-xl border border-white/[0.06] focus:border-amber-500/30 focus:outline-none transition-colors placeholder:text-gray-700"
146
+ style={{ background: 'rgba(0,0,0,0.3)' }}
147
  />
148
  </div>
149
  </div>
150
 
151
+ <button
152
+ onClick={handleSubmit}
153
+ disabled={loading}
154
+ className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-amber-500/10 border border-amber-500/20 hover:bg-amber-500/20 disabled:opacity-40 transition-all text-[12px] text-amber-300"
155
+ >
156
+ <Zap size={14} className={loading ? 'animate-pulse' : ''} />
157
+ {loading ? 'Running Simulation...' : 'Launch Simulation'}
158
+ </button>
 
 
 
 
 
 
159
  </div>
160
  </div>
 
161
 
162
+ {/* Recent simulations */}
163
+ <div className="lg:col-span-5 space-y-3">
164
+ <div className="flex items-center justify-between mb-1">
165
+ <span className="text-[11px] text-gray-500 uppercase tracking-wider">Recent Executions</span>
166
+ <span className="text-[10px] text-gray-600">{simulations.length} total</span>
167
+ </div>
168
+
169
+ {simulations.length === 0 ? (
170
+ <div className="card text-center py-12">
171
+ <Zap size={24} className="text-gray-700 mx-auto mb-3" />
172
+ <p className="text-[12px] text-gray-600">No simulations yet.</p>
173
+ </div>
174
+ ) : (
175
+ simulations.map((sim, i) => (
176
+ <motion.div
177
+ key={sim.simulation_id}
178
+ initial={{ opacity: 0, x: 12 }}
179
+ animate={{ opacity: 1, x: 0 }}
180
+ transition={{ delay: i * 0.04 }}
181
+ onClick={() => router.push(`/simulation/${sim.simulation_id}`)}
182
+ className="card hover:border-amber-500/20 cursor-pointer group"
183
+ >
184
+ <div className="flex items-center justify-between mb-2">
185
+ <div className="flex items-center gap-2">
186
+ <div className={`w-1.5 h-1.5 rounded-full ${sim.status === 'completed' ? 'bg-emerald-400' : sim.status === 'failed' ? 'bg-red-400' : 'bg-amber-400 animate-pulse'}`} />
187
+ <span className="text-[10px] text-gray-500 uppercase tracking-wider">{sim.status}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  </div>
189
+ <span className="text-[10px] text-gray-600">{sim.simulation_id.slice(0, 8)}</span>
190
+ </div>
191
+ <h4 className="text-[13px] text-gray-300 group-hover:text-amber-300 transition-colors line-clamp-1 mb-1">
192
+ {sim.user_input}
193
+ </h4>
194
+ <div className="flex items-center gap-3 text-[10px] text-gray-600">
195
+ <span>{sim.scenarios} scenarios</span>
196
+ <span>·</span>
197
+ <span>{sim.elapsed_seconds}s</span>
198
+ </div>
199
+ </motion.div>
200
+ ))
201
+ )}
202
  </div>
203
  </div>
204
  </div>
frontend/src/app/workspace/page.tsx ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { motion } from 'framer-motion';
5
+ import { Brain, Sparkles, RefreshCw, Lightbulb } from 'lucide-react';
6
+
7
+ export default function WorkspacePage() {
8
+ const [curiosity, setCuriosity] = useState<any>(null);
9
+ const [loading, setLoading] = useState(true);
10
+ const [triggering, setTriggering] = useState(false);
11
+
12
+ const fetchData = useCallback(async () => {
13
+ const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
14
+ try {
15
+ const res = await fetch(`${baseUrl}/daemon/curiosity`);
16
+ if (res.ok) setCuriosity(await res.json());
17
+ } catch { /* silent */ }
18
+ finally { setLoading(false); }
19
+ }, []);
20
+
21
+ useEffect(() => { fetchData(); }, [fetchData]);
22
+
23
+ const triggerCuriosity = async () => {
24
+ setTriggering(true);
25
+ const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
26
+ try {
27
+ const res = await fetch(`${baseUrl}/daemon/curiosity/now`, { method: 'POST' });
28
+ if (res.ok) {
29
+ const data = await res.json();
30
+ setCuriosity((prev: any) => prev
31
+ ? { ...prev, discoveries: [...(prev.discoveries || []), ...(data.discoveries || [])], total_discoveries: (prev.total_discoveries || 0) + (data.discoveries || []).length }
32
+ : data
33
+ );
34
+ }
35
+ } catch { /* silent */ }
36
+ finally { setTriggering(false); }
37
+ };
38
+
39
+ return (
40
+ <div className="h-full flex flex-col overflow-hidden">
41
+ {/* Header */}
42
+ <div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
43
+ <div className="flex items-center justify-between">
44
+ <div className="flex items-center gap-3">
45
+ <div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
46
+ <Brain size={16} className="text-violet-400" />
47
+ </div>
48
+ <div>
49
+ <h1 className="text-lg font-light text-gray-100">Workspace</h1>
50
+ <p className="text-[11px] text-gray-600">Janus curiosity engine — autonomous discovery</p>
51
+ </div>
52
+ </div>
53
+ <button
54
+ onClick={triggerCuriosity}
55
+ disabled={triggering}
56
+ className="flex items-center gap-2 px-4 py-2 rounded-xl bg-violet-500/10 hover:bg-violet-500/20 border border-violet-500/15 text-[12px] text-violet-400 transition-all disabled:opacity-40"
57
+ >
58
+ <Sparkles size={12} className={triggering ? 'animate-pulse' : ''} />
59
+ {triggering ? 'Exploring...' : 'Explore'}
60
+ </button>
61
+ </div>
62
+ </div>
63
+
64
+ {/* Content */}
65
+ <div className="flex-1 overflow-y-auto p-6">
66
+ {loading ? (
67
+ <div className="flex flex-col items-center justify-center py-20 gap-3">
68
+ <RefreshCw size={20} className="text-violet-400 animate-spin" />
69
+ <p className="text-[12px] text-gray-500">Loading workspace...</p>
70
+ </div>
71
+ ) : (
72
+ <div className="max-w-3xl mx-auto space-y-4">
73
+ {/* Stats */}
74
+ <div className="grid grid-cols-2 gap-4">
75
+ <div className="card p-5 text-center">
76
+ <div className="text-2xl font-light text-white">{curiosity?.total_discoveries || 0}</div>
77
+ <div className="text-[10px] text-gray-600 uppercase tracking-wider mt-1">Discoveries</div>
78
+ </div>
79
+ <div className="card p-5 text-center">
80
+ <div className="text-2xl font-light text-violet-400">{curiosity?.total_interests || 0}</div>
81
+ <div className="text-[10px] text-gray-600 uppercase tracking-wider mt-1">Topics of Interest</div>
82
+ </div>
83
+ </div>
84
+
85
+ {/* Discoveries list */}
86
+ <div className="card p-5">
87
+ <div className="flex items-center gap-2 text-[11px] text-violet-400 uppercase tracking-wider mb-5">
88
+ <Lightbulb size={13} /> Recent Discoveries
89
+ </div>
90
+ <div className="space-y-3">
91
+ {(curiosity?.discoveries || []).slice(-10).reverse().map((d: any, i: number) => (
92
+ <motion.div
93
+ key={i}
94
+ initial={{ opacity: 0, y: 6 }}
95
+ animate={{ opacity: 1, y: 0 }}
96
+ transition={{ delay: i * 0.04 }}
97
+ className="p-3 rounded-xl border border-white/[0.04] hover:border-white/[0.08] transition-colors"
98
+ style={{ background: 'rgba(255,255,255,0.015)' }}
99
+ >
100
+ <div className="flex items-center gap-2 mb-1.5">
101
+ <span className="text-[10px] text-violet-400 bg-violet-500/10 px-2 py-0.5 rounded-full">{d.topic}</span>
102
+ <span className="text-[10px] text-gray-700">{new Date(d.timestamp).toLocaleTimeString()}</span>
103
+ </div>
104
+ <p className="text-[13px] text-gray-300 leading-relaxed">{d.insight}</p>
105
+ </motion.div>
106
+ ))}
107
+ {(!curiosity?.discoveries || curiosity.discoveries.length === 0) && (
108
+ <div className="text-center py-12">
109
+ <Brain size={28} className="text-gray-700 mx-auto mb-3" />
110
+ <p className="text-[13px] text-gray-500">No discoveries yet.</p>
111
+ <p className="text-[11px] text-gray-700 mt-1">Click &quot;Explore&quot; to trigger the curiosity engine.</p>
112
+ </div>
113
+ )}
114
+ </div>
115
+ </div>
116
+
117
+ {/* Interests */}
118
+ {curiosity?.interests && Object.keys(curiosity.interests).length > 0 && (
119
+ <div className="card p-5">
120
+ <div className="text-[11px] text-gray-500 uppercase tracking-wider mb-4">Topics of Interest</div>
121
+ <div className="flex flex-wrap gap-2">
122
+ {Object.entries(curiosity.interests).map(([topic, score]: [string, any]) => (
123
+ <span key={topic} className="px-3 py-1.5 rounded-full text-[11px] text-gray-400 border border-white/[0.06] bg-white/[0.02]">
124
+ {topic} <span className="text-gray-600 ml-1">{typeof score === 'number' ? Math.round(score * 100) + '%' : ''}</span>
125
+ </span>
126
+ ))}
127
+ </div>
128
+ </div>
129
+ )}
130
+ </div>
131
+ )}
132
+ </div>
133
+ </div>
134
+ );
135
+ }
frontend/src/components/AppShell.tsx CHANGED
@@ -4,24 +4,51 @@ import { useState, useEffect, useCallback } from 'react';
4
  import { usePathname, useRouter } from 'next/navigation';
5
  import { motion, AnimatePresence } from 'framer-motion';
6
  import {
7
- Sparkles, Globe, BarChart3, Layers, Activity as PulseIcon,
8
- Zap, Hexagon, Shield, Terminal, Settings,
9
- ChevronLeft, ChevronRight, Menu, X
10
  } from 'lucide-react';
11
 
12
- const navItems = [
13
- { id: '/', label: 'Command', icon: Sparkles, section: 'Main' },
14
- { id: '/intel', label: 'Intel Stream', icon: Globe, section: 'Main' },
15
- { id: '/markets', label: 'Markets', icon: BarChart3, section: 'Main' },
16
- { id: '/workspace', label: 'Workspace', icon: Layers, section: 'Main' },
17
- { id: '/pulse', label: 'Pulse', icon: PulseIcon, section: 'Main' },
18
- { id: '/cases', label: 'Cases', icon: Hexagon, section: 'System' },
19
- { id: '/simulation', label: 'Simulations', icon: Zap, section: 'System' },
20
- { id: '/sentinel', label: 'Sentinel', icon: Shield, section: 'System' },
21
- { id: '/prompts', label: 'Prompt Lab', icon: Terminal, section: 'System' },
22
- { id: '/config', label: 'Config', icon: Settings, section: 'System' },
 
 
 
 
 
 
 
 
 
 
23
  ];
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  export default function AppShell({ children }: { children: React.ReactNode }) {
26
  const pathname = usePathname();
27
  const router = useRouter();
@@ -43,150 +70,166 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
43
  return () => clearInterval(iv);
44
  }, [fetchStatus]);
45
 
 
 
 
 
46
  const isActive = (path: string) => {
47
  if (path === '/') return pathname === '/';
48
  return pathname?.startsWith(path);
49
  };
50
 
51
- const sections = ['Main', 'System'];
52
-
53
- return (
54
- <div className="h-screen bg-gray-950 text-gray-100 flex overflow-hidden">
55
- {/* Mobile overlay */}
56
- <AnimatePresence>
57
- {mobileOpen && (
58
- <motion.div
59
- initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
60
- className="fixed inset-0 bg-black/60 z-40 lg:hidden"
61
- onClick={() => setMobileOpen(false)}
62
- />
 
63
  )}
64
- </AnimatePresence>
65
 
66
- {/* Sidebar */}
67
- <motion.aside
68
- initial={false}
69
- animate={{ width: collapsed ? 64 : 240 }}
70
- className={`hidden lg:flex flex-col bg-gray-900/80 border-r border-white/5 relative z-30 shrink-0`}
71
- >
72
- {/* Logo */}
73
- <div className="flex items-center justify-between h-14 px-4 border-b border-white/5">
74
- {!collapsed && (
75
- <motion.span initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-sm font-light tracking-[0.2em] bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
76
- JANUS
77
- </motion.span>
78
- )}
79
- <button onClick={() => setCollapsed(!collapsed)} className="p-1.5 rounded-lg hover:bg-white/5 text-gray-500 hover:text-gray-300 transition-colors">
80
- {collapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
81
- </button>
82
- </div>
83
 
84
- {/* Nav items */}
85
- <nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1">
86
- {sections.map(section => (
87
- <div key={section}>
88
- {!collapsed && (
89
- <div className="px-3 py-2 text-[9px] font-mono text-gray-600 uppercase tracking-widest">
90
- {section}
91
- </div>
92
- )}
93
- {navItems.filter(item => item.section === section).map(item => {
94
- const active = isActive(item.id);
 
 
95
  const Icon = item.icon;
96
  return (
97
  <button
98
- key={item.id}
99
- onClick={() => router.push(item.id)}
100
- className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-sm ${
101
  active
102
- ? 'bg-indigo-500/10 text-indigo-300 border border-indigo-500/20'
103
- : 'text-gray-500 hover:text-gray-300 hover:bg-white/5 border border-transparent'
104
- } ${collapsed ? 'justify-center' : ''}`}
105
- title={collapsed ? item.label : undefined}
106
  >
107
- <Icon size={16} className="shrink-0" />
108
- {!collapsed && (
109
- <motion.span initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="font-mono text-[11px] uppercase tracking-wider truncate">
110
- {item.label}
111
- </motion.span>
112
  )}
113
  </button>
114
  );
115
  })}
116
  </div>
117
- ))}
118
- </nav>
119
-
120
- {/* Status */}
121
- {!collapsed && (
122
- <div className="px-4 py-3 border-t border-white/5">
123
- <div className="flex items-center gap-2">
124
- <div className={`w-1.5 h-1.5 rounded-full ${daemonStatus?.running ? 'bg-emerald-400 animate-pulse' : 'bg-gray-600'}`} />
125
- <span className="text-[10px] font-mono text-gray-500 uppercase tracking-wider">
126
- {daemonStatus?.running ? 'Living' : 'Offline'}
127
- </span>
128
- </div>
129
- {daemonStatus?.circadian && (
130
- <div className="text-[9px] font-mono text-gray-700 mt-1 capitalize">
131
- {daemonStatus.circadian.current_phase} phase
132
- </div>
133
- )}
134
  </div>
135
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  </motion.aside>
137
 
138
- {/* Mobile header */}
139
- <div className="lg:hidden fixed top-0 left-0 right-0 h-14 bg-gray-900/90 backdrop-blur-sm border-b border-white/5 flex items-center justify-between px-4 z-30">
140
- <button onClick={() => setMobileOpen(true)} className="p-2 rounded-lg hover:bg-white/5 text-gray-400">
141
  <Menu size={18} />
142
  </button>
143
- <span className="text-sm font-light tracking-[0.2em] bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">JANUS</span>
 
 
 
144
  <div className="w-8" />
145
  </div>
146
 
147
- {/* Mobile drawer */}
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  <AnimatePresence>
149
  {mobileOpen && (
150
  <motion.div
151
- initial={{ x: -280 }} animate={{ x: 0 }} exit={{ x: -280 }}
152
- transition={{ type: 'spring', damping: 25, stiffness: 200 }}
153
- className="fixed top-0 left-0 bottom-0 w-72 bg-gray-900 border-r border-white/5 z-50 lg:hidden"
 
 
 
154
  >
155
- <div className="flex items-center justify-between h-14 px-4 border-b border-white/5">
156
- <span className="text-sm font-light tracking-[0.2em] bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">JANUS</span>
157
- <button onClick={() => setMobileOpen(false)} className="p-1.5 rounded-lg hover:bg-white/5 text-gray-500">
158
  <X size={16} />
159
  </button>
160
  </div>
161
- <nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1">
162
- {sections.map(section => (
163
- <div key={section}>
164
- <div className="px-3 py-2 text-[9px] font-mono text-gray-600 uppercase tracking-widest">{section}</div>
165
- {navItems.filter(item => item.section === section).map(item => {
166
- const active = isActive(item.id);
167
- const Icon = item.icon;
168
- return (
169
- <button
170
- key={item.id}
171
- onClick={() => { router.push(item.id); setMobileOpen(false); }}
172
- className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all text-sm ${
173
- active ? 'bg-indigo-500/10 text-indigo-300 border border-indigo-500/20' : 'text-gray-500 hover:text-gray-300 hover:bg-white/5 border border-transparent'
174
- }`}
175
- >
176
- <Icon size={16} className="shrink-0" />
177
- <span className="font-mono text-[11px] uppercase tracking-wider">{item.label}</span>
178
- </button>
179
- );
180
- })}
181
- </div>
182
- ))}
183
- </nav>
184
  </motion.div>
185
  )}
186
  </AnimatePresence>
187
 
188
- {/* Main content */}
189
- <main className="flex-1 min-w-0 overflow-hidden pt-14 lg:pt-0">
190
  {children}
191
  </main>
192
  </div>
 
4
  import { usePathname, useRouter } from 'next/navigation';
5
  import { motion, AnimatePresence } from 'framer-motion';
6
  import {
7
+ MessageSquare, Globe, BarChart3, Layers, Activity,
8
+ Zap, Shield, Terminal, Settings, Plus,
9
+ ChevronLeft, ChevronRight, Menu, X, Brain
10
  } from 'lucide-react';
11
 
12
+ const navSections = [
13
+ {
14
+ label: 'Main',
15
+ items: [
16
+ { path: '/', label: 'Chat', icon: MessageSquare },
17
+ { path: '/intel', label: 'Intel', icon: Globe },
18
+ { path: '/markets', label: 'Markets', icon: BarChart3 },
19
+ { path: '/workspace', label: 'Workspace', icon: Brain },
20
+ { path: '/pulse', label: 'Pulse', icon: Activity },
21
+ ],
22
+ },
23
+ {
24
+ label: 'System',
25
+ items: [
26
+ { path: '/cases', label: 'Cases', icon: Layers },
27
+ { path: '/simulation', label: 'Simulation', icon: Zap },
28
+ { path: '/sentinel', label: 'Sentinel', icon: Shield },
29
+ { path: '/prompts', label: 'Prompts', icon: Terminal },
30
+ { path: '/config', label: 'Config', icon: Settings },
31
+ ],
32
+ },
33
  ];
34
 
35
+ function JanusOrbSmall({ pulse = false }: { pulse?: boolean }) {
36
+ return (
37
+ <div className="relative w-8 h-8 shrink-0">
38
+ <div
39
+ className="absolute inset-0 rounded-full"
40
+ style={{
41
+ background: 'radial-gradient(circle at 35% 35%, #818cf8, #4f46e5 60%, #1e1b4b 100%)',
42
+ boxShadow: '0 0 12px rgba(99,102,241,0.3)',
43
+ }}
44
+ />
45
+ {pulse && (
46
+ <div className="absolute inset-0 rounded-full animate-ping opacity-20 bg-indigo-400" />
47
+ )}
48
+ </div>
49
+ );
50
+ }
51
+
52
  export default function AppShell({ children }: { children: React.ReactNode }) {
53
  const pathname = usePathname();
54
  const router = useRouter();
 
70
  return () => clearInterval(iv);
71
  }, [fetchStatus]);
72
 
73
+ useEffect(() => {
74
+ setMobileOpen(false);
75
+ }, [pathname]);
76
+
77
  const isActive = (path: string) => {
78
  if (path === '/') return pathname === '/';
79
  return pathname?.startsWith(path);
80
  };
81
 
82
+ const NavContent = ({ isMobile = false }: { isMobile?: boolean }) => (
83
+ <>
84
+ {/* Logo */}
85
+ <div className="flex items-center gap-3 px-4 h-14 border-b border-white/[0.04] shrink-0">
86
+ <JanusOrbSmall pulse={daemonStatus?.running} />
87
+ {(!collapsed || isMobile) && (
88
+ <motion.span
89
+ initial={{ opacity: 0 }}
90
+ animate={{ opacity: 1 }}
91
+ className="text-[13px] font-light tracking-[0.2em] text-gradient"
92
+ >
93
+ JANUS
94
+ </motion.span>
95
  )}
96
+ </div>
97
 
98
+ {/* New Chat button */}
99
+ <div className="px-3 pt-3 pb-1 shrink-0">
100
+ <button
101
+ onClick={() => router.push('/')}
102
+ className={`w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-white/[0.06] hover:border-white/[0.12] hover:bg-white/[0.03] transition-all text-sm text-gray-400 hover:text-gray-200 ${collapsed && !isMobile ? 'justify-center' : ''}`}
103
+ >
104
+ <Plus size={16} className="shrink-0" />
105
+ {(!collapsed || isMobile) && <span className="text-[12px]">New conversation</span>}
106
+ </button>
107
+ </div>
 
 
 
 
 
 
 
108
 
109
+ {/* Nav sections */}
110
+ <nav className="flex-1 overflow-y-auto py-2 px-2">
111
+ {navSections.map(section => (
112
+ <div key={section.label} className="mb-1">
113
+ {(!collapsed || isMobile) && (
114
+ <div className="px-2 pt-4 pb-1.5 text-[10px] font-medium text-gray-600 uppercase tracking-[0.15em]">
115
+ {section.label}
116
+ </div>
117
+ )}
118
+ {collapsed && !isMobile && <div className="h-3" />}
119
+ <div className="space-y-0.5">
120
+ {section.items.map(item => {
121
+ const active = isActive(item.path);
122
  const Icon = item.icon;
123
  return (
124
  <button
125
+ key={item.path}
126
+ onClick={() => router.push(item.path)}
127
+ className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-xl transition-all duration-200 group ${
128
  active
129
+ ? 'bg-white/[0.06] text-gray-100'
130
+ : 'text-gray-500 hover:text-gray-300 hover:bg-white/[0.03]'
131
+ } ${collapsed && !isMobile ? 'justify-center px-2' : ''}`}
132
+ title={collapsed && !isMobile ? item.label : undefined}
133
  >
134
+ <Icon size={16} className={`shrink-0 ${active ? 'text-indigo-400' : 'group-hover:text-gray-400'}`} />
135
+ {(!collapsed || isMobile) && (
136
+ <span className="text-[12px] truncate">{item.label}</span>
 
 
137
  )}
138
  </button>
139
  );
140
  })}
141
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  </div>
143
+ ))}
144
+ </nav>
145
+
146
+ {/* Status footer */}
147
+ {(!collapsed || isMobile) && (
148
+ <div className="px-4 py-3 border-t border-white/[0.04] shrink-0">
149
+ <div className="flex items-center gap-2">
150
+ <div className={`w-1.5 h-1.5 rounded-full ${daemonStatus?.running ? 'bg-emerald-400' : 'bg-gray-600'}`} />
151
+ <span className="text-[10px] text-gray-600">
152
+ {daemonStatus?.running ? 'System active' : 'Offline'}
153
+ </span>
154
+ </div>
155
+ {daemonStatus?.circadian && (
156
+ <div className="text-[10px] text-gray-700 mt-0.5 capitalize ml-3.5">
157
+ {daemonStatus.circadian.current_phase} phase
158
+ </div>
159
+ )}
160
+ </div>
161
+ )}
162
+ </>
163
+ );
164
+
165
+ return (
166
+ <div className="h-screen flex overflow-hidden" style={{ background: 'var(--janus-bg)' }}>
167
+ {/* Desktop Sidebar */}
168
+ <motion.aside
169
+ initial={false}
170
+ animate={{ width: collapsed ? 68 : 260 }}
171
+ transition={{ duration: 0.2, ease: 'easeOut' }}
172
+ className="hidden lg:flex flex-col border-r border-white/[0.04] relative shrink-0"
173
+ style={{ background: 'rgba(10, 10, 15, 0.8)' }}
174
+ >
175
+ <NavContent />
176
+ {/* Collapse toggle */}
177
+ <button
178
+ onClick={() => setCollapsed(!collapsed)}
179
+ className="absolute top-3 -right-3 w-6 h-6 rounded-full border border-white/[0.08] bg-[#111118] flex items-center justify-center text-gray-600 hover:text-gray-400 hover:border-white/[0.15] transition-all z-10"
180
+ >
181
+ {collapsed ? <ChevronRight size={12} /> : <ChevronLeft size={12} />}
182
+ </button>
183
  </motion.aside>
184
 
185
+ {/* Mobile Header */}
186
+ <div className="lg:hidden fixed top-0 left-0 right-0 h-12 border-b border-white/[0.04] flex items-center justify-between px-4 z-30" style={{ background: 'rgba(10, 10, 15, 0.95)', backdropFilter: 'blur(12px)' }}>
187
+ <button onClick={() => setMobileOpen(true)} className="p-1.5 rounded-lg hover:bg-white/[0.05] text-gray-400">
188
  <Menu size={18} />
189
  </button>
190
+ <div className="flex items-center gap-2">
191
+ <JanusOrbSmall />
192
+ <span className="text-[12px] tracking-[0.2em] text-gradient">JANUS</span>
193
+ </div>
194
  <div className="w-8" />
195
  </div>
196
 
197
+ {/* Mobile Overlay */}
198
+ <AnimatePresence>
199
+ {mobileOpen && (
200
+ <motion.div
201
+ initial={{ opacity: 0 }}
202
+ animate={{ opacity: 1 }}
203
+ exit={{ opacity: 0 }}
204
+ className="fixed inset-0 bg-black/60 z-40 lg:hidden"
205
+ onClick={() => setMobileOpen(false)}
206
+ />
207
+ )}
208
+ </AnimatePresence>
209
+
210
+ {/* Mobile Drawer */}
211
  <AnimatePresence>
212
  {mobileOpen && (
213
  <motion.div
214
+ initial={{ x: -300 }}
215
+ animate={{ x: 0 }}
216
+ exit={{ x: -300 }}
217
+ transition={{ type: 'spring', damping: 28, stiffness: 280 }}
218
+ className="fixed top-0 left-0 bottom-0 w-72 z-50 lg:hidden flex flex-col border-r border-white/[0.04]"
219
+ style={{ background: 'var(--janus-bg)' }}
220
  >
221
+ <div className="absolute top-3 right-3">
222
+ <button onClick={() => setMobileOpen(false)} className="p-1 rounded-lg hover:bg-white/[0.05] text-gray-500">
 
223
  <X size={16} />
224
  </button>
225
  </div>
226
+ <NavContent isMobile />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  </motion.div>
228
  )}
229
  </AnimatePresence>
230
 
231
+ {/* Main Content Area */}
232
+ <main className="flex-1 min-w-0 overflow-hidden pt-12 lg:pt-0">
233
  {children}
234
  </main>
235
  </div>
frontend/src/lib/api.ts CHANGED
@@ -169,88 +169,6 @@ export class MiroOrgClient {
169
  }
170
  return response.json();
171
  }
172
-
173
- // Session endpoints
174
- async createSession(): Promise<any> {
175
- const r = await fetch(`${this.baseUrl}/sessions`, { method: 'POST' });
176
- if (!r.ok) throw new Error('Failed to create session');
177
- return r.json();
178
- }
179
-
180
- async getSession(sessionId: string): Promise<any> {
181
- const r = await fetch(`${this.baseUrl}/sessions/${sessionId}`);
182
- if (!r.ok) throw new Error('Failed to fetch session');
183
- return r.json();
184
- }
185
-
186
- async listSessions(limit: number = 20): Promise<any[]> {
187
- const r = await fetch(`${this.baseUrl}/sessions?limit=${limit}`);
188
- if (!r.ok) return [];
189
- return r.json();
190
- }
191
-
192
- async addMessage(sessionId: string, role: string, content: string): Promise<any> {
193
- const r = await fetch(`${this.baseUrl}/sessions/${sessionId}/message`, {
194
- method: 'POST',
195
- headers: { 'Content-Type': 'application/json' },
196
- body: JSON.stringify({ role, content }),
197
- });
198
- if (!r.ok) throw new Error('Failed to add message');
199
- return r.json();
200
- }
201
-
202
- // Workflow endpoints
203
- async createResearchWorkflow(query: string, depth: string = 'standard'): Promise<any> {
204
- const r = await fetch(`${this.baseUrl}/workflows/research`, {
205
- method: 'POST',
206
- headers: { 'Content-Type': 'application/json' },
207
- body: JSON.stringify({ query, depth }),
208
- });
209
- if (!r.ok) throw new Error('Failed to create research workflow');
210
- return r.json();
211
- }
212
-
213
- async createSimulationWorkflow(scenario: string): Promise<any> {
214
- const r = await fetch(`${this.baseUrl}/workflows/simulation`, {
215
- method: 'POST',
216
- headers: { 'Content-Type': 'application/json' },
217
- body: JSON.stringify({ scenario }),
218
- });
219
- if (!r.ok) throw new Error('Failed to create simulation workflow');
220
- return r.json();
221
- }
222
-
223
- async listWorkflows(limit: number = 20, type?: string): Promise<any[]> {
224
- const url = type ? `${this.baseUrl}/workflows?limit=${limit}&type=${type}` : `${this.baseUrl}/workflows?limit=${limit}`;
225
- const r = await fetch(url);
226
- if (!r.ok) return [];
227
- return r.json();
228
- }
229
-
230
- async getWorkflow(wfId: string): Promise<any> {
231
- const r = await fetch(`${this.baseUrl}/workflows/${wfId}`);
232
- if (!r.ok) throw new Error('Workflow not found');
233
- return r.json();
234
- }
235
-
236
- async runWorkflow(wfId: string): Promise<any> {
237
- const r = await fetch(`${this.baseUrl}/workflows/${wfId}/run`, { method: 'POST' });
238
- if (!r.ok) throw new Error('Failed to run workflow');
239
- return r.json();
240
- }
241
-
242
- // Curiosity endpoints
243
- async getCuriosity(): Promise<any> {
244
- const r = await fetch(`${this.baseUrl}/daemon/curiosity`);
245
- if (!r.ok) return { total_discoveries: 0, total_interests: 0, discoveries: [] };
246
- return r.json();
247
- }
248
-
249
- async triggerCuriosity(): Promise<any> {
250
- const r = await fetch(`${this.baseUrl}/daemon/curiosity/now`, { method: 'POST' });
251
- if (!r.ok) throw new Error('Failed to trigger curiosity');
252
- return r.json();
253
- }
254
  }
255
 
256
  // Export singleton instance
 
169
  }
170
  return response.json();
171
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  }
173
 
174
  // Export singleton instance
frontend/src/lib/types.ts CHANGED
@@ -23,6 +23,20 @@ export interface CaseRecord {
23
  final_answer_preview?: string;
24
  saved_at?: string;
25
  simulation_id?: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
27
 
28
  export interface SimulationRecord {
 
23
  final_answer_preview?: string;
24
  saved_at?: string;
25
  simulation_id?: string;
26
+ // Extended fields from /run response
27
+ final?: {
28
+ response?: string;
29
+ summary?: string;
30
+ confidence?: number;
31
+ data_sources?: string[];
32
+ };
33
+ domain?: string;
34
+ query_type?: string;
35
+ cached?: boolean;
36
+ elapsed_seconds?: number;
37
+ learned?: boolean;
38
+ learning_reason?: string;
39
+ adaptive_context?: Record<string, any>;
40
  }
41
 
42
  export interface SimulationRecord {