diff --git a/.env.example b/.env.example
index 3ea7f45..6a1e88a 100644
--- a/.env.example
+++ b/.env.example
@@ -6,6 +6,7 @@ PORT=3117
REFRESH_INTERVAL_MINUTES=15
AUTO_OPEN_BROWSER=false
STALE_DATA_MAX_AGE_MINUTES=60
+TERMINAL_ACTIONS_ENABLED=true
SWEEP_TOKEN=
BRIEF_VERBOSITY=standard
@@ -35,6 +36,8 @@ ACLED_EMAIL=
ACLED_PASSWORD=
CLOUDFLARE_API_TOKEN=
BLS_API_KEY=
+REDDIT_CLIENT_ID=
+REDDIT_CLIENT_SECRET=
# Telegram bot and alerts
TELEGRAM_BOT_TOKEN=
diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
index 0264ab8..cde440d 100644
--- a/.gitea/workflows/build.yml
+++ b/.gitea/workflows/build.yml
@@ -38,7 +38,12 @@ jobs:
run: docker compose config
- name: Build Docker image
- run: docker build -t "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${IMAGE_NAME}:${GITHUB_SHA}" .
+ shell: bash
+ run: |
+ image="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${IMAGE_NAME}"
+ build_tag="build-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_NUMBER:-0}"
+ echo "BUILD_IMAGE=${image}:${build_tag}" >> "$GITHUB_ENV"
+ docker build -t "${image}:${build_tag}" .
- name: Publish Docker image
if: ${{ env.REGISTRY_TOKEN != '' }}
@@ -47,8 +52,9 @@ jobs:
image="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${IMAGE_NAME}"
date_tag="$(date -u +%Y%m%d)"
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin
- docker tag "${image}:${GITHUB_SHA}" "${image}:latest"
- docker tag "${image}:${GITHUB_SHA}" "${image}:${date_tag}"
+ docker tag "${BUILD_IMAGE}" "${image}:${GITHUB_SHA}"
+ docker tag "${BUILD_IMAGE}" "${image}:latest"
+ docker tag "${BUILD_IMAGE}" "${image}:${date_tag}"
docker push "${image}:${GITHUB_SHA}"
docker push "${image}:latest"
docker push "${image}:${date_tag}"
diff --git a/README.md b/README.md
index 5937d50..3bddfc3 100644
--- a/README.md
+++ b/README.md
@@ -123,6 +123,7 @@ PORT=3117
REFRESH_INTERVAL_MINUTES=15
AUTO_OPEN_BROWSER=false
STALE_DATA_MAX_AGE_MINUTES=60
+TERMINAL_ACTIONS_ENABLED=true
SWEEP_TOKEN=
BRIEF_VERBOSITY=standard
@@ -175,6 +176,41 @@ LLM_MODEL=your-model
For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-terminal:3117` (or the `PORT` you set). Missing API keys do not crash sweeps; affected sources are reported as degraded in `/api/health`.
+The dashboard Terminal Actions panel can trigger `status`, `sweep`, and `brief` through `/api/action`. Leave `TERMINAL_ACTIONS_ENABLED=true` for a private home-server deployment. For an internet-exposed deployment, set `SWEEP_TOKEN` and pass it through trusted automation, or set `TERMINAL_ACTIONS_ENABLED=false` to disable browser-triggered actions. If you protect actions with `SWEEP_TOKEN`, the browser can send it from `localStorage.crucix_sweep_token`.
+
+#### Scenario Watchlist
+
+Crucix can track operator hypotheses across sweeps with a runtime scenario file at `runs/scenarios.json`. On first run, the server creates three disabled starter examples:
+
+- Middle East energy shock
+- Macro stress spillover
+- Regional escalation risk
+
+Enable or add scenarios by editing `runs/scenarios.json`:
+
+```json
+{
+ "version": 1,
+ "scenarios": [
+ {
+ "id": "middle-east-energy-shock",
+ "enabled": true,
+ "name": "Middle East energy shock",
+ "description": "Energy supply risk building from regional conflict.",
+ "regions": ["Middle East", "Iran", "Strait of Hormuz"],
+ "categories": ["osint", "energy", "maritime"],
+ "keywords": ["missile", "strike", "hormuz", "oil"],
+ "thresholds": { "watching": 2, "building": 4, "confirmed": 7 },
+ "invalidation": "WTI normalizes and urgent regional signals fade."
+ }
+ ]
+}
+```
+
+Malformed scenario config degrades safely: sweeps continue and the dashboard shows the watchlist as a config issue. Scenario state is persisted in `runs/scenario-state.json`; delete that file to reset state transitions without deleting definitions.
+
+Scenario states are `dormant`, `watching`, `building`, and `confirmed`. The dashboard shows active scenario state, confidence, score, and recent trigger time. Briefings include a `Scenario Watchlist` section when one or more scenarios change state.
+
#### Build And Publish Your Gitea Image
```bash
@@ -315,6 +351,9 @@ These three unlock the most valuable economic and satellite data. Each takes abo
| `ACLED_EMAIL` + `ACLED_PASSWORD` | Armed conflict event data | [acleddata.com/register](https://acleddata.com/register/) — free, OAuth2 |
| `AISSTREAM_API_KEY` | Maritime AIS vessel tracking | [aisstream.io](https://aisstream.io/) — free |
| `ADSB_API_KEY` | Unfiltered flight tracking | [RapidAPI](https://rapidapi.com/adsbexchange/api/adsbexchange-com1) — ~$10/mo |
+| `REDDIT_CLIENT_ID` + `REDDIT_CLIENT_SECRET` | Reddit social sentiment | [reddit.com/prefs/apps](https://www.reddit.com/prefs/apps/) — create a script app |
+
+Reddit is OAuth-only in this fork. If the Reddit credentials are missing or rejected, the Reddit source is reported as degraded and no unauthenticated `reddit.com/.../hot.json` fallback is used.
### LLM Provider (optional, for AI-enhanced ideas)
diff --git a/apis/sources/reddit.mjs b/apis/sources/reddit.mjs
index 29606cf..c6d17e0 100644
--- a/apis/sources/reddit.mjs
+++ b/apis/sources/reddit.mjs
@@ -1,14 +1,15 @@
-// Reddit — social sentiment intelligence
-// Reddit now requires OAuth for API access (public JSON API returns 403).
-// Gracefully degrades when not authenticated.
-// To enable: register an app at https://www.reddit.com/prefs/apps/ and set
-// REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in .env
+// Reddit social sentiment intelligence.
+// Reddit API access requires OAuth. Runtime sweeps intentionally do not use
+// unauthenticated reddit.com .json scraping because it is unreliable and not
+// acceptable for production operation.
import { safeFetch } from '../utils/fetch.mjs';
import '../utils/env.mjs';
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
+const USER_AGENT = 'Crucix/2.0 intelligence-engine';
+
const SUBREDDITS = [
'worldnews',
'geopolitics',
@@ -17,48 +18,95 @@ const SUBREDDITS = [
'commodities',
];
-// Get OAuth token using client credentials flow (application-only)
-async function getToken() {
- const clientId = process.env.REDDIT_CLIENT_ID;
- const clientSecret = process.env.REDDIT_CLIENT_SECRET;
- if (!clientId || !clientSecret) return null;
+export function getRedditConfig(env = process.env) {
+ const clientId = env.REDDIT_CLIENT_ID || '';
+ const clientSecret = env.REDDIT_CLIENT_SECRET || '';
+ const missing = [];
+ if (!clientId) missing.push('REDDIT_CLIENT_ID');
+ if (!clientSecret) missing.push('REDDIT_CLIENT_SECRET');
+ return {
+ clientId,
+ clientSecret,
+ configured: missing.length === 0,
+ missing,
+ };
+}
+
+function credentialsMessage(missing) {
+ return `Reddit requires OAuth. Register a script app at https://www.reddit.com/prefs/apps/ and set ${missing.join(' and ')} in .env`;
+}
+
+export async function getToken({ env = process.env, fetchImpl = globalThis.fetch } = {}) {
+ const config = getRedditConfig(env);
+ if (!config.configured) {
+ return {
+ ok: false,
+ status: 'no_credentials',
+ missing: config.missing,
+ error: 'missing_reddit_oauth_credentials',
+ message: credentialsMessage(config.missing),
+ };
+ }
try {
- const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
- const res = await fetch('https://www.reddit.com/api/v1/access_token', {
+ const auth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
+ const res = await fetchImpl('https://www.reddit.com/api/v1/access_token', {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded',
- 'User-Agent': 'Crucix/1.0 intelligence-engine',
+ 'User-Agent': USER_AGENT,
},
body: 'grant_type=client_credentials',
});
- if (!res.ok) return null;
+ if (!res.ok) {
+ const body = await res.text().catch(() => '');
+ return {
+ ok: false,
+ status: 'auth_failed',
+ error: `reddit_oauth_http_${res.status}`,
+ message: `Reddit OAuth token request failed with HTTP ${res.status}`,
+ detail: body.slice(0, 200),
+ };
+ }
+
const data = await res.json();
- return data.access_token || null;
- } catch {
- return null;
+ if (!data.access_token) {
+ return {
+ ok: false,
+ status: 'auth_failed',
+ error: 'reddit_oauth_missing_access_token',
+ message: 'Reddit OAuth token response did not include an access token',
+ };
+ }
+ return { ok: true, status: 'ok', token: data.access_token };
+ } catch (e) {
+ return {
+ ok: false,
+ status: 'auth_failed',
+ error: 'reddit_oauth_request_failed',
+ message: e.message,
+ };
}
}
-// Fetch hot posts — tries OAuth first, then falls back to public endpoint
export async function getHot(subreddit, opts = {}) {
const { limit = 10, token = null } = opts;
- if (token) {
- // Use OAuth endpoint
- return safeFetch(`https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1`, {
- headers: {
- 'Authorization': `Bearer ${token}`,
- 'User-Agent': 'Crucix/1.0 intelligence-engine',
- },
- });
+ if (!token) {
+ return {
+ status: 'no_credentials',
+ error: 'reddit_oauth_required',
+ message: 'Reddit source requires OAuth; unauthenticated reddit.com .json scraping is disabled',
+ };
}
- // Try public endpoint (may 403)
- return safeFetch(`https://www.reddit.com/r/${subreddit}/hot.json?limit=${limit}&raw_json=1`, {
- headers: { 'User-Agent': 'Crucix/1.0 intelligence-engine' },
+ return safeFetch(`https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1`, {
+ source: 'Reddit',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'User-Agent': USER_AGENT,
+ },
});
}
@@ -74,29 +122,46 @@ function compactPost(child) {
};
}
-export async function briefing() {
- const token = await getToken();
+export async function briefing(opts = {}) {
+ const {
+ env = process.env,
+ subreddits = SUBREDDITS,
+ delayMs = 1000,
+ fetchImpl = globalThis.fetch,
+ } = opts;
+ const tokenResult = await getToken({ env, fetchImpl });
- if (!token && !process.env.REDDIT_CLIENT_ID) {
+ if (!tokenResult.ok) {
return {
source: 'Reddit',
timestamp: new Date().toISOString(),
- status: 'no_key',
- message: 'Reddit requires OAuth. Register at https://www.reddit.com/prefs/apps/ (script type), set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in .env',
+ status: tokenResult.status,
+ error: tokenResult.error,
+ message: tokenResult.message,
+ missing: tokenResult.missing || [],
};
}
const subredditResults = {};
- for (const sub of SUBREDDITS) {
- const result = await getHot(sub, { limit: 10, token });
+ const errors = [];
+ for (const sub of subreddits) {
+ const result = await getHot(sub, { limit: 10, token: tokenResult.token });
+ if (result?.error) {
+ errors.push({ subreddit: sub, error: result.error });
+ subredditResults[sub] = [];
+ if (delayMs > 0) await delay(delayMs);
+ continue;
+ }
const children = result?.data?.children || [];
subredditResults[sub] = children.map(compactPost).filter(Boolean);
- await delay(token ? 1000 : 2000);
+ if (delayMs > 0) await delay(delayMs);
}
return {
source: 'Reddit',
timestamp: new Date().toISOString(),
+ status: errors.length > 0 ? 'degraded' : 'ok',
+ ...(errors.length > 0 ? { error: 'reddit_subreddit_fetch_failed', errors } : {}),
subreddits: subredditResults,
};
}
diff --git a/crucix.config.mjs b/crucix.config.mjs
index 8792df3..19bbce2 100644
--- a/crucix.config.mjs
+++ b/crucix.config.mjs
@@ -24,6 +24,7 @@ export default {
autoOpenBrowser: boolEnv('AUTO_OPEN_BROWSER', false),
staleDataMaxAgeMinutes: intEnv('STALE_DATA_MAX_AGE_MINUTES', 60),
sweepToken: process.env.SWEEP_TOKEN || null,
+ terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', true),
llm: {
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok
diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html
index 921b3fd..39f2c7e 100644
--- a/dashboard/public/jarvis.html
+++ b/dashboard/public/jarvis.html
@@ -83,6 +83,13 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
.sensor-actions{display:flex;gap:6px;align-items:center}
.mini-btn{border:1px solid rgba(100,240,200,0.18);background:rgba(100,240,200,0.04);color:var(--dim);font-family:var(--mono);font-size:9px;padding:3px 6px;cursor:pointer}
.mini-btn:hover{color:var(--accent);border-color:rgba(100,240,200,0.4)}
+.action-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-bottom:10px}
+.action-btn{border:1px solid rgba(68,204,255,0.24);background:rgba(68,204,255,0.06);color:var(--text);font-family:var(--mono);font-size:9px;padding:7px 6px;cursor:pointer;text-transform:uppercase;letter-spacing:.08em}
+.action-btn:hover{border-color:rgba(68,204,255,0.55);color:var(--accent2);background:rgba(68,204,255,0.12)}
+.action-btn[disabled]{opacity:.45;cursor:wait}
+.terminal-output{min-height:58px;max-height:180px;overflow:auto;border:1px solid rgba(255,255,255,0.05);background:rgba(0,0,0,0.22);padding:8px;font-family:var(--mono);font-size:10px;line-height:1.45;color:var(--dim);white-space:pre-wrap}
+.terminal-output strong{color:var(--accent)}
+.terminal-output .err{color:var(--danger)}
.layer-left{display:flex;align-items:center;gap:8px}
.ldot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.ldot.air{background:var(--accent);box-shadow:0 0 6px rgba(100,240,200,0.4)}
@@ -404,6 +411,8 @@ let lowPerfMode = localStorage.getItem('crucix_low_perf') === 'true';
let isFlat = shouldStartFlat();
let layerModes = JSON.parse(localStorage.getItem('crucix_layer_modes') || '{}');
let spaceDisplayMode = localStorage.getItem('crucix_space_display') || 'icons';
+let terminalOutput = 'Ready. Live data is loaded from /api/data in server mode.';
+let terminalBusy = false;
let currentRegion = 'world';
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
@@ -1564,6 +1573,46 @@ function renderLower(){
document.getElementById('lowerGrid').innerHTML=`${tickerPanel}${osintPanel}${macroPanel}${ideasPanel}`;
}
+async function runTerminalAction(action){
+ if(terminalBusy) return;
+ terminalBusy = true;
+ terminalOutput = `> ${action}\nRunning...`;
+ renderRight();
+ try{
+ const res = await fetch('/api/action', {
+ method:'POST',
+ headers:{
+ 'Content-Type':'application/json',
+ ...(localStorage.getItem('crucix_sweep_token') ? {'x-crucix-token': localStorage.getItem('crucix_sweep_token')} : {})
+ },
+ body:JSON.stringify({action})
+ });
+ const payload = await res.json().catch(()=>({error:'Invalid server response'}));
+ if(!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
+ if(action === 'status'){
+ const h = payload.health || {};
+ terminalOutput = [
+ '> status',
+ `State: ${h.status || '--'}`,
+ `Last sweep: ${h.lastSuccessfulSweep || h.lastSweep || '--'}`,
+ `Data age: ${h.dataAgeSeconds != null ? h.dataAgeSeconds + 's' : '--'}`,
+ `Sources: ${h.sourcesOk || 0} ok / ${h.sourcesDegraded || 0} degraded / ${h.sourcesFailed || 0} failed`,
+ `LLM: ${h.llm?.state || '--'}`,
+ `Sweep active: ${h.sweepInProgress ? 'yes' : 'no'}`
+ ].join('\n');
+ }else if(action === 'brief'){
+ terminalOutput = `> brief\n${payload.text || 'No briefing text returned.'}`;
+ }else if(action === 'sweep'){
+ terminalOutput = `> sweep\n${payload.status === 'already_running' ? 'Sweep already running.' : 'Sweep accepted. The dashboard will update when the sweep finishes.'}`;
+ }
+ }catch(err){
+ terminalOutput = `> ${action}\nERROR: ${err.message}`;
+ }finally{
+ terminalBusy = false;
+ renderRight();
+ }
+}
+
// === RIGHT RAIL ===
function renderRight(){
const mobile = isMobileLayout();
@@ -1603,12 +1652,33 @@ function renderRight(){
deltaRows.push(`
▼${s.label||s.key}${s.from}→${s.to} (${val})
`);
}
const deltaHtml = hasDelta ? deltaRows.join('') : `${t('delta.noChanges','No changes since last sweep')}
`;
+ const scenarioItems = (D.scenarios?.items || []).filter(s => s.enabled || s.state !== 'dormant').slice(0,4);
+ const scenarioHtml = scenarioItems.length ? scenarioItems.map(s => `
+
+
${s.name} ${(s.state||'dormant').toUpperCase()}
+
${s.description || ''}
+
${s.confidence || 0}% confidence · score ${s.score || 0}${s.lastTriggerTime ? ' · ' + getAge(s.lastTriggerTime) : ''}
+
+ `).join('') : `No active scenario watchlist items
`;
document.getElementById('rightRail').innerHTML=`
+
+
Terminal Actions
${terminalBusy?'RUNNING':'READY'}
+
+
+
+
+
+
${terminalOutput.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])).replace(/\n/g,'
')}
+
${t('panels.crossSourceSignals','Cross-Source Signals')}
${t('badges.worldview','WORLDVIEW')}
${signals}
+
+
Scenario Watchlist
${D.scenarios?.available===false?'CONFIG':'LIVE'}
+ ${scenarioHtml}
+
${mobile ? '' : buildOsintPanel('right-osint', 260)}
${t('panels.signalCore','Signal Core')}
${t('badges.hotMetrics','HOT METRICS')}
@@ -1839,10 +1909,10 @@ document.addEventListener('DOMContentLoaded', () => {
const hasInlineData = !!(D && D.meta);
const canProbeApi = location.protocol !== 'file:';
- if (canProbeApi && !hasInlineData) {
+ if (canProbeApi) {
// Server mode: always fetch live data from API (ignore any stale inline D)
fetch('/api/data')
- .then(r => r.json())
+ .then(r => { if(!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(data => { D = data; init(); connectSSE(); })
.catch(() => {
// Should not reach here — server routes to loading.html when no data
diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md
index c54e815..3eee3ed 100644
--- a/docs/agent-handoff.md
+++ b/docs/agent-handoff.md
@@ -1,18 +1,489 @@
# Agent Handoff
-## Current Release Goal
+Last updated: 2026-05-17
-Source branch: `codex/production-intelligence-terminal`
+## Repository State
-Registry image:
+Project: Crucix fork / Intelligence Terminal
+
+Local workspace:
+
+```text
+C:\Users\MrSphay\Documents\Codex\Crucix\intelligence-terminal
+```
+
+Remotes:
+
+```text
+origin https://git.wilkensxl.de/MrSphay/intelligence-terminal.git
+upstream https://github.com/calesthio/Crucix.git
+```
+
+Current branch tip:
+
+```text
+Run `git rev-parse HEAD` after clone/pull. This handoff was updated by the `docs: sync issue tracker and handoff` commit after the implementation commit below.
+```
+
+Latest implementation commit before issue-sync documentation:
+
+```text
+53470cc701ec322080a89d220aef449b25850590
+```
+
+Both pushed branches currently point to this commit:
+
+```text
+origin/codex/production-intelligence-terminal
+origin/main
+```
+
+Gitea repository:
+
+```text
+https://git.wilkensxl.de/MrSphay/intelligence-terminal
+```
+
+Default branch observed through the Gitea API:
+
+```text
+codex/production-intelligence-terminal
+```
+
+## Agent Kit Requirements Applied
+
+The mandatory kit was cloned and reviewed first:
+
+```text
+C:\Users\MrSphay\Documents\Codex\Crucix\agent-kit
+```
+
+Rules applied from the kit:
+
+- Keep agent context in source control: `AGENTS.md`, `.codex/project.md`, and this handoff file.
+- Use Gitea Ubuntu runners for heavy verification and package publishing.
+- Keep Docker/Dockge operation first-class.
+- Do not commit secrets, `.env`, private logs, tokens, or generated `runs/` data.
+- Add report-only maintenance workflows for security, dependency checks, repo cleanup, release dry runs, and template compliance.
+- Poll pushed Gitea Actions until terminal state when a token is available.
+
+## What Was Implemented
+
+### Docker And Runtime
+
+- Docker image is Docker-first and Dockge/Pangolin suitable.
+- Browser auto-open is disabled by default through `AUTO_OPEN_BROWSER=false`.
+- Runtime health checks now work in the container without `wget` or host browser tools.
+- `runs` is persisted through a volume.
+- A later fix added `docker-entrypoint.sh` to prepare `/app/runs` before dropping privileges, so mounted volumes work with the non-root Node runtime.
+- `docker-compose.yml` uses the Gitea Registry image by default:
```text
git.wilkensxl.de/mrsphay/intelligence-terminal:latest
```
-## Notes
+### API And Health
-- The repository is Docker-first and should stay suitable for Dockge/Pangolin.
-- Use `.env.example` as the operator-facing source of truth for configuration.
-- Source health and network metrics are available through `/api/health` and `/api/metrics`.
-- If Gitea Registry authentication is unavailable locally, build and push with the commands documented in `README.md`.
+Added or hardened:
+
+- `GET /api/health`
+- `GET /api/data`
+- `GET /api/metrics`
+- `POST /api/sweep`
+- `POST /api/action`
+
+Health now reports:
+
+- `starting`
+- `healthy`
+- `degraded`
+- `stale`
+- `error`
+
+It also reports:
+
+- last sweep timestamps
+- stale/bootstrap state
+- data age
+- source health
+- source errors
+- LLM configuration state
+- Telegram/Discord enabled state
+- memory store state
+
+### Live Data And Source Degradation
+
+- Existing `runs/latest.json` is only treated as bootstrap/stale data until a real sweep completes.
+- Sweeps update `sourceHealth`, SSE/API data, and memory state.
+- RSS/news feed failures no longer silently look like fresh valid data.
+- `safeFetch` now tracks request counts, failures, bytes, source labels, hosts, and recent fetch events.
+- `safeFetch` has better timeout/retry/backoff/error behavior and reports HTML-as-API-error cases.
+- Yahoo Finance fetches are more explicit about source errors and HTML/API failures.
+- ACLED missing credentials now degrade transparently.
+- Telegram polling has quieter network-error backoff logs.
+
+### LLM Integration
+
+Added unified OpenAI-compatible provider layer:
+
+```text
+lib/llm/openai-compatible.mjs
+```
+
+Supported provider paths include:
+
+- `openrouter`
+- `openai`
+- `openai-compatible`
+- `local-openai`
+- `lmstudio`
+- `lm-studio`
+- `ollama`
+
+Relevant environment keys:
+
+```text
+LLM_PROVIDER
+LLM_BASE_URL
+LLM_API_KEY
+LLM_MODEL
+LLM_TEMPERATURE
+LLM_MAX_TOKENS
+LLM_TIMEOUT_MS
+OPENROUTER_SITE_URL
+OPENROUTER_APP_NAME
+```
+
+OpenRouter Free and local OpenAI-compatible endpoints are documented in `README.md` and `.env.example`.
+
+### Memory
+
+Added Phase-1 SQLite memory:
+
+```text
+lib/intelligence-store.mjs
+runs/intelligence.db
+```
+
+It uses `node:sqlite` when available and gracefully falls back when unavailable.
+
+### Dashboard
+
+Implemented:
+
+- interactive Sensor Grid layer modes
+- focus/hide/normal states persisted in `localStorage`
+- Space Watch icon/orbit toggle
+- map/globe filtering consistency
+- flat map label redraw handling
+- live server-mode data loading from `/api/data` even when `jarvis.html` still contains an offline inline snapshot
+- Terminal Actions panel with `Status`, `Sweep`, and `Brief` buttons
+
+Important UI markers in the final code:
+
+```text
+layerModes
+spaceDisplayMode
+toggleSpaceDisplay()
+shouldShowType()
+runTerminalAction()
+```
+
+### Briefings
+
+Brief output now includes:
+
+- Source Integrity
+- evidence links
+- event IDs
+- configurable verbosity through `BRIEF_VERBOSITY`
+
+### Documentation
+
+Updated:
+
+- `README.md`
+- `.env.example`
+- `docs/sources/README.md`
+- `docs/sources/opensky.md`
+- `docs/sources/acled.md`
+- `docs/sources/telegram.md`
+- `docs/sources/firms.md`
+- `docs/sources/maritime.md`
+- `docs/security-review.md`
+- `docs/release-checklist.md`
+
+README includes:
+
+- Gitea Registry pull example
+- Dockge-compatible compose example
+- full `.env` examples
+- OpenRouter Free setup
+- LM Studio setup
+- Ollama setup
+- local OpenAI-compatible setup
+- Pangolin/reverse proxy notes
+
+## Registry And Images
+
+Registry image:
+
+```text
+git.wilkensxl.de/mrsphay/intelligence-terminal
+```
+
+Verified package tags through Gitea API:
+
+```text
+latest
+20260517
+e933586b220656a2858d2215b934b22d1f08a908
+53470cc701ec322080a89d220aef449b25850590
+```
+
+Successful pull test:
+
+```bash
+docker pull git.wilkensxl.de/mrsphay/intelligence-terminal:latest
+```
+
+Observed digest:
+
+```text
+sha256:780a41413921bd9a676461eca1cd1372591f523be4b7c9513d9bc085cbe7922d
+```
+
+## Gitea Actions
+
+Workflows present:
+
+```text
+.gitea/workflows/build.yml
+.gitea/workflows/security-scan.yml
+.gitea/workflows/repo-cleanup.yml
+.gitea/workflows/dependency-check.yml
+.gitea/workflows/release-dry-run.yml
+.gitea/workflows/template-compliance.yml
+```
+
+Final runs for commit `53470cc701ec322080a89d220aef449b25850590` were polled through the Gitea API and succeeded:
+
+```text
+build.yml on main: success
+build.yml on codex/production-intelligence-terminal: success
+release-dry-run.yml on main: success
+release-dry-run.yml on codex/production-intelligence-terminal: success
+template-compliance.yml on main: success
+template-compliance.yml on codex/production-intelligence-terminal: success
+```
+
+Relevant run URLs:
+
+```text
+https://git.wilkensxl.de/MrSphay/intelligence-terminal/actions/runs/23
+https://git.wilkensxl.de/MrSphay/intelligence-terminal/actions/runs/24
+https://git.wilkensxl.de/MrSphay/intelligence-terminal/actions/runs/25
+https://git.wilkensxl.de/MrSphay/intelligence-terminal/actions/runs/26
+https://git.wilkensxl.de/MrSphay/intelligence-terminal/actions/runs/27
+https://git.wilkensxl.de/MrSphay/intelligence-terminal/actions/runs/28
+```
+
+Repository secret expected by the registry publish workflow:
+
+```text
+REGISTRY_TOKEN
+```
+
+Local token note:
+
+- `GITEA_TOKEN` was visible in the final Codex process.
+- It was used only for Gitea API checks and not printed.
+
+## Issue Sync
+
+Open upstream GitHub issues were reviewed on 2026-05-17 from:
+
+```text
+https://github.com/calesthio/Crucix/issues
+```
+
+The upstream list contained 24 open issues. Issues already handled by this fork were not copied as open work, including the Docker stale-dashboard incident (#105), map label redraw (#70), Sensor Grid controls (#72), space display toggle (#51), source docs (#52), Dockge/CasaOS docs (#78), LLM timeout (#87), inject/static helper confusion (#100), network metrics (#101), Telegram polling backoff (#104), and briefing/evidence context (#75).
+
+Issues not relevant to this fork were also not copied, including the Wallpaper Engine redesign (#41), the fork-inflation discussion (#107), empty/unclear placeholders (#79/#80), and the general use-case discussion (#93).
+
+The following Gitea issues were created for real remaining work:
+
+```text
+#1 Reddit source must stop unauthenticated .json scraping
+ https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/1
+
+#2 Send operator alerts when dashboard data remains stale
+ https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/2
+
+#3 ACLED credentialed integration needs regression test and diagnostics
+ https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/3
+
+#4 Complete memory and prediction loop beyond Phase-1 SQLite
+ https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/4
+
+#5 Remove old inline dashboard snapshot from production builds
+ https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/5
+
+#6 Harden Terminal Actions for public reverse-proxy deployments
+ https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/6
+
+#7 Replace ADS-B stub with real disabled/degraded source handling
+ https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/7
+
+#8 Clean inherited public-demo and upstream marketing references
+ https://git.wilkensxl.de/MrSphay/intelligence-terminal/issues/8
+```
+
+## Verification Already Performed
+
+Local lightweight checks:
+
+```bash
+npm run test:unit
+npm audit --omit=dev --audit-level=high
+docker compose --env-file .env.example config
+node --check server.mjs
+node --check dashboard/inject.mjs
+node --check lib/llm/openai-compatible.mjs
+git diff --check
+```
+
+Unit test result:
+
+```text
+21 tests passing
+0 failing
+```
+
+Audit result:
+
+```text
+0 high vulnerabilities
+```
+
+Docker build and smoke test were performed locally earlier:
+
+```bash
+docker build -t git.wilkensxl.de/mrsphay/intelligence-terminal:latest .
+docker run --rm -d --name intelligence-terminal-smoke -p 127.0.0.1::3117 -e AUTO_OPEN_BROWSER=false git.wilkensxl.de/mrsphay/intelligence-terminal:latest
+```
+
+Smoke test observations:
+
+- Server booted.
+- No `xdg-open` error.
+- Initial sweep completed.
+- `/api/health` moved from `starting` to `degraded` with transparent source errors.
+- Degraded state was expected without all optional API keys.
+
+Additional checks after fixing the dashboard live-data bug and Terminal Actions:
+
+```bash
+node --check server.mjs
+npm run test:unit
+docker compose --env-file .env.example config
+git diff --check
+```
+
+The dashboard script was also syntax-checked after extracting script blocks from `dashboard/public/jarvis.html`.
+
+## Important Commits
+
+```text
+7e85a54 chore: apply agent kit project structure
+85f97bb feat: harden intelligence runtime and llm providers
+42b7fc2 docs: add registry dockge and dashboard operations
+d072390 ci: align gitea workflows with agent kit
+0559481 ci: fix gitea registry publish login
+f3c9331 ci: fix agent kit compliance checks
+c2d572e fix: prepare runs volume before dropping privileges
+8e096b2 ci: harden gitea workflow reruns
+e933586 merge: reconcile main with production branch
+4262c7e docs: expand agent handoff
+53470cc fix: load live dashboard data and add terminal actions
+```
+
+The large implementation commit `85f97bb` and the dashboard/action fix `53470cc` are contained in both:
+
+```text
+origin/codex/production-intelligence-terminal
+origin/main
+```
+
+## How To Continue In A Fresh Codex Environment
+
+1. Clone the Gitea repository:
+
+```bash
+git clone https://git.wilkensxl.de/MrSphay/intelligence-terminal.git
+cd intelligence-terminal
+git checkout codex/production-intelligence-terminal
+```
+
+2. Confirm the expected commit:
+
+```bash
+git rev-parse HEAD
+```
+
+Expected:
+
+```text
+The branch tip should include commit 53470cc701ec322080a89d220aef449b25850590 and the later `docs: sync issue tracker and handoff` commit.
+```
+
+3. Read these files first:
+
+```text
+AGENTS.md
+.codex/project.md
+docs/agent-handoff.md
+README.md
+.env.example
+```
+
+4. If checking Actions, use `GITEA_TOKEN` from the environment. Do not print it.
+
+PowerShell check:
+
+```powershell
+if ($env:GITEA_TOKEN) { "GITEA_TOKEN=set" } else { "GITEA_TOKEN=missing" }
+```
+
+5. Useful commands:
+
+```bash
+npm run test:unit
+docker compose --env-file .env.example config
+docker pull git.wilkensxl.de/mrsphay/intelligence-terminal:latest
+```
+
+6. Start with Dockge/Pangolin using the README compose example and a `.env` based on `.env.example`.
+
+## Remaining Risks And Follow-Ups
+
+- Some sources will report `degraded` until optional keys are set, especially ACLED, FRED, EIA, and Cloudflare Radar.
+- OpenSky can rate-limit with HTTP 429; this is now visible in health instead of hidden.
+- GDELT/OFAC can time out under runner/network conditions; health reports this explicitly.
+- Browser-level visual verification of the full dashboard should be repeated after any future UI change.
+- The project still inherits the original Crucix broad source surface. Future work should prefer focused source-by-source tests over broad refactors.
+- If a new Codex environment sees non-fast-forward branch pushes, fetch first and preserve remote commits. Do not force-push without explicit approval.
+
+## Operator Pull Command
+
+For deployment:
+
+```bash
+docker pull git.wilkensxl.de/mrsphay/intelligence-terminal:latest
+```
+
+For a pinned deployment:
+
+```bash
+docker pull git.wilkensxl.de/mrsphay/intelligence-terminal:20260517
+```
diff --git a/docs/security-review.md b/docs/security-review.md
index 3b362ea..8e66f9b 100644
--- a/docs/security-review.md
+++ b/docs/security-review.md
@@ -5,12 +5,21 @@
- Shell execution: browser auto-open is gated by `AUTO_OPEN_BROWSER` and defaults to false.
- Secrets: `.env` remains ignored; `.env.example` contains no real keys.
- External network calls: source fetches use timeout/retry diagnostics and expose degraded state.
-- Manual actions: `/api/sweep` is local-only unless `SWEEP_TOKEN` is configured.
+- Manual actions: `/api/sweep` and `/api/action` are gated by `TERMINAL_ACTIONS_ENABLED` and local-only or `SWEEP_TOKEN` authorization.
- File writes: runtime writes are limited to `runs/`.
- HTML injection: dashboard data is JSON-injected only by the CLI path; server mode serves data through API/SSE.
+## Terminal Actions
+
+- `TERMINAL_ACTIONS_ENABLED=true` enables dashboard-triggered `status`, `sweep`, and `brief` actions through `POST /api/action`.
+- If `SWEEP_TOKEN` is set, callers must send the token through `x-sweep-token`, `Authorization: Bearer ...`, or the `token` request body field.
+- If `SWEEP_TOKEN` is empty, actions are accepted only from local loopback addresses.
+- For private Dockge/LAN deployments, this is intended to make the terminal operable from the browser.
+- For Pangolin or other internet-exposed deployments, set `SWEEP_TOKEN` or `TERMINAL_ACTIONS_ENABLED=false` until the public reverse-proxy hardening issue is completed.
+
## Residual Risk
- External feeds can return malformed, stale, or adversarial content. UI rendering should continue to sanitize titles and URLs.
- LLM outputs are advisory only and must not be treated as financial advice.
- `node:sqlite` availability depends on the Node 22 build; when unavailable the memory database degrades to a no-op placeholder.
+- Browser-stored sweep tokens are acceptable for a trusted home-server UI, but should not be treated as a strong auth boundary on a public endpoint.
diff --git a/docs/sources/README.md b/docs/sources/README.md
index 008b1f5..e8549a4 100644
--- a/docs/sources/README.md
+++ b/docs/sources/README.md
@@ -16,3 +16,4 @@ Source docs:
- [Telegram](telegram.md)
- [FIRMS](firms.md)
- [Maritime](maritime.md)
+- [Reddit](reddit.md)
diff --git a/docs/sources/reddit.md b/docs/sources/reddit.md
new file mode 100644
index 0000000..c7ce6e4
--- /dev/null
+++ b/docs/sources/reddit.md
@@ -0,0 +1,33 @@
+# Reddit Source
+
+Reddit is used as a social sentiment input for selected geopolitical and market subreddits.
+
+## Configuration
+
+Create a Reddit script app at:
+
+```text
+https://www.reddit.com/prefs/apps/
+```
+
+Then set:
+
+```env
+REDDIT_CLIENT_ID=
+REDDIT_CLIENT_SECRET=
+```
+
+## Runtime Behavior
+
+- The source uses the OAuth client credentials flow and then reads `https://oauth.reddit.com`.
+- Unauthenticated `reddit.com/.../hot.json` scraping is intentionally disabled.
+- Missing credentials return `status: no_credentials` and are surfaced as source degradation.
+- OAuth failures return `status: auth_failed` without logging or returning the client secret.
+- Subreddit fetch failures return `status: degraded` with per-subreddit errors.
+
+## Test
+
+```bash
+node apis/sources/reddit.mjs
+npm run test:unit
+```
diff --git a/lib/scenarios.mjs b/lib/scenarios.mjs
new file mode 100644
index 0000000..16b86bd
--- /dev/null
+++ b/lib/scenarios.mjs
@@ -0,0 +1,212 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
+import { join } from 'path';
+
+const DEFAULT_SCENARIOS = [
+ {
+ id: 'middle-east-energy-shock',
+ enabled: false,
+ name: 'Middle East energy shock',
+ description: 'Energy supply risk building from Middle East conflict or chokepoint pressure.',
+ regions: ['Middle East', 'Iran', 'Israel', 'Strait of Hormuz'],
+ categories: ['osint', 'energy', 'maritime'],
+ keywords: ['missile', 'strike', 'hormuz', 'oil', 'energy', 'blockade'],
+ thresholds: { watching: 2, building: 4, confirmed: 7 },
+ invalidation: 'WTI normalizes and regional urgent signals fade for several sweeps.',
+ },
+ {
+ id: 'macro-stress-spillover',
+ enabled: false,
+ name: 'Macro stress spillover',
+ description: 'Market stress spreads from volatility into credit, rates, or commodities.',
+ regions: ['US', 'Global'],
+ categories: ['macro', 'markets'],
+ keywords: ['vix', 'spread', 'credit', 'yield', 'inflation', 'gold'],
+ thresholds: { watching: 2, building: 4, confirmed: 6 },
+ invalidation: 'VIX and credit stress both normalize while source health remains stable.',
+ },
+ {
+ id: 'regional-escalation-risk',
+ enabled: false,
+ name: 'Regional escalation risk',
+ description: 'Local conflict signals broaden across adjacent regions or source categories.',
+ regions: ['Ukraine', 'Taiwan', 'Africa', 'Middle East'],
+ categories: ['conflict', 'thermal', 'osint', 'air'],
+ keywords: ['mobilization', 'intercept', 'drone', 'ballistic', 'fatalities', 'border'],
+ thresholds: { watching: 2, building: 5, confirmed: 8 },
+ invalidation: 'No fresh cross-source escalation signals appear inside the configured horizon.',
+ },
+];
+
+export function evaluateScenarios(data, delta, runsDir) {
+ const loaded = loadScenarioDefinitions(runsDir);
+ if (!loaded.ok) {
+ return { available: false, error: loaded.error, items: [], changed: [] };
+ }
+
+ const statePath = join(runsDir, 'scenario-state.json');
+ const previous = readJson(statePath, {});
+ const evaluatedAt = data.meta?.timestamp || new Date().toISOString();
+ const corpus = buildCorpus(data, delta);
+ const items = loaded.scenarios.map(def => evaluateScenario(def, corpus, previous[def.id], evaluatedAt));
+ const changed = items.filter(item => item.changed);
+
+ writeJson(statePath, Object.fromEntries(items.map(item => [item.id, {
+ state: item.state,
+ score: item.score,
+ confidence: item.confidence,
+ lastTriggerTime: item.lastTriggerTime,
+ updatedAt: evaluatedAt,
+ }])));
+
+ return {
+ available: true,
+ path: loaded.path,
+ items,
+ changed,
+ };
+}
+
+export function loadScenarioDefinitions(runsDir) {
+ const path = join(runsDir, 'scenarios.json');
+ try {
+ if (!existsSync(runsDir)) mkdirSync(runsDir, { recursive: true });
+ if (!existsSync(path)) {
+ writeJson(path, {
+ version: 1,
+ scenarios: DEFAULT_SCENARIOS,
+ });
+ }
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
+ if (!raw || !Array.isArray(raw.scenarios)) throw new Error('scenarios must be an array');
+ const scenarios = raw.scenarios
+ .map(normalizeScenario)
+ .filter(Boolean);
+ return { ok: true, path, scenarios };
+ } catch (err) {
+ return { ok: false, path, error: err.message };
+ }
+}
+
+function normalizeScenario(input) {
+ if (!input || typeof input !== 'object') return null;
+ const id = String(input.id || input.name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+ const name = String(input.name || input.id || '').trim();
+ if (!id || !name) return null;
+ const thresholds = input.thresholds || {};
+ return {
+ id,
+ enabled: input.enabled === true,
+ name,
+ description: String(input.description || ''),
+ regions: arrayOfStrings(input.regions),
+ categories: arrayOfStrings(input.categories),
+ keywords: arrayOfStrings(input.keywords).map(s => s.toLowerCase()),
+ thresholds: {
+ watching: Number(thresholds.watching || 2),
+ building: Number(thresholds.building || 4),
+ confirmed: Number(thresholds.confirmed || 7),
+ },
+ invalidation: String(input.invalidation || ''),
+ };
+}
+
+function evaluateScenario(def, corpus, previous, evaluatedAt) {
+ if (!def.enabled) {
+ return {
+ ...publicScenario(def),
+ state: 'dormant',
+ score: 0,
+ confidence: 0,
+ evidence: [],
+ changed: previous?.state && previous.state !== 'dormant',
+ lastTriggerTime: previous?.lastTriggerTime || null,
+ };
+ }
+
+ const evidence = [];
+ let score = 0;
+ for (const keyword of def.keywords) {
+ const hit = corpus.entries.find(entry => entry.text.includes(keyword));
+ if (hit) {
+ score += 1;
+ evidence.push({ type: 'keyword', label: keyword, source: hit.source, text: hit.original.slice(0, 180) });
+ }
+ }
+ for (const region of def.regions) {
+ const needle = region.toLowerCase();
+ const hit = corpus.entries.find(entry => entry.text.includes(needle));
+ if (hit) {
+ score += 1;
+ evidence.push({ type: 'region', label: region, source: hit.source, text: hit.original.slice(0, 180) });
+ }
+ }
+ for (const category of def.categories) {
+ if (corpus.categories.has(category.toLowerCase())) {
+ score += 1;
+ evidence.push({ type: 'category', label: category, source: 'sweep', text: `${category} category active` });
+ }
+ }
+
+ const state = score >= def.thresholds.confirmed ? 'confirmed'
+ : score >= def.thresholds.building ? 'building'
+ : score >= def.thresholds.watching ? 'watching'
+ : 'dormant';
+ const confidence = Math.min(100, Math.round((score / Math.max(1, def.thresholds.confirmed)) * 100));
+ const changed = previous?.state ? previous.state !== state : state !== 'dormant';
+ return {
+ ...publicScenario(def),
+ state,
+ score,
+ confidence,
+ evidence: evidence.slice(0, 6),
+ changed,
+ lastTriggerTime: state === 'dormant' ? (previous?.lastTriggerTime || null) : evaluatedAt,
+ };
+}
+
+function publicScenario(def) {
+ return {
+ id: def.id,
+ name: def.name,
+ description: def.description,
+ enabled: def.enabled,
+ invalidation: def.invalidation,
+ };
+}
+
+function buildCorpus(data, delta) {
+ const entries = [];
+ const categories = new Set();
+ const push = (source, text, category) => {
+ if (!text) return;
+ entries.push({ source, original: String(text), text: String(text).toLowerCase() });
+ if (category) categories.add(category);
+ };
+
+ for (const signal of data.tSignals || []) push('thermal', signal, 'thermal');
+ for (const post of data.tg?.urgent || []) push(post.channel || 'telegram', post.text, 'osint');
+ for (const item of data.newsFeed || []) push(item.source || 'news', item.headline || item.title, 'news');
+ for (const item of data.news || []) push(item.source || 'news', item.headline || item.title, 'news');
+ for (const item of data.acled?.deadliestEvents || []) push('ACLED', `${item.country || ''} ${item.location || ''} ${item.event_type || ''} ${item.fatalities || ''}`, 'conflict');
+ for (const item of data.air || []) push('OpenSky', `${item.region} ${item.total} aircraft`, 'air');
+ for (const item of data.chokepoints || []) push('Maritime', `${item.label} ${item.note}`, 'maritime');
+ if (data.energy?.wti || data.energy?.brent) push('energy', `WTI ${data.energy.wti} Brent ${data.energy.brent}`, 'energy');
+ if (data.markets?.vix || data.fred?.some(f => f.id === 'VIXCLS')) push('markets', 'VIX volatility market stress', 'markets');
+ if (delta?.summary) push('delta', `${delta.summary.direction} ${delta.summary.totalChanges} changes ${delta.summary.criticalChanges} critical`, 'delta');
+ for (const signal of delta?.signals?.new || []) push('delta', signal.label || signal.reason || signal.key, 'delta');
+ for (const signal of delta?.signals?.escalated || []) push('delta', signal.label || signal.reason || signal.key, 'delta');
+
+ return { entries, categories };
+}
+
+function arrayOfStrings(value) {
+ return Array.isArray(value) ? value.map(v => String(v).trim()).filter(Boolean) : [];
+}
+
+function readJson(path, fallback) {
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; }
+}
+
+function writeJson(path, value) {
+ writeFileSync(path, JSON.stringify(value, null, 2));
+}
diff --git a/package.json b/package.json
index 4f3e7cc..b4e582f 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"brief:save": "node apis/save-briefing.mjs",
"diag": "node diag.mjs",
"test": "npm run test:unit",
- "test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs",
+ "test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs test/reddit-source.test.mjs",
"compose:config": "docker compose config",
"clean": "node scripts/clean.mjs",
"fresh-start": "npm run clean && npm start"
diff --git a/server.mjs b/server.mjs
index 8f46df8..18c4afd 100644
--- a/server.mjs
+++ b/server.mjs
@@ -18,6 +18,7 @@ import { TelegramAlerter } from './lib/alerts/telegram.mjs';
import { DiscordAlerter } from './lib/alerts/discord.mjs';
import { getFetchMetrics } from './apis/utils/fetch.mjs';
import { IntelligenceStore } from './lib/intelligence-store.mjs';
+import { evaluateScenarios } from './lib/scenarios.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = __dirname;
@@ -289,14 +290,28 @@ app.get('/api/metrics', (req, res) => {
});
app.post('/api/sweep', express.json(), (req, res) => {
- const remote = req.ip || '';
- const local = remote.includes('127.0.0.1') || remote === '::1' || remote === '::ffff:127.0.0.1';
- const token = req.get('x-crucix-token') || req.query.token || req.body?.token;
- if (config.sweepToken && token !== config.sweepToken) return res.status(401).json({ error: 'Invalid sweep token' });
- if (!config.sweepToken && !local) return res.status(403).json({ error: 'Manual sweep is local-only unless SWEEP_TOKEN is set' });
- if (sweepInProgress) return res.status(409).json({ status: 'already_running', sweepStartedAt });
- runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
- res.status(202).json({ status: 'accepted' });
+ if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' });
+ triggerSweep(res);
+});
+
+app.post('/api/action', express.json(), async (req, res) => {
+ if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' });
+ const action = String(req.body?.action || req.query.action || '').toLowerCase();
+
+ if (action === 'status') {
+ return res.json({ ok: true, action, health: buildHealth() });
+ }
+
+ if (action === 'brief') {
+ if (!currentData) return res.status(503).json({ ok: false, action, error: 'No data yet — first sweep in progress' });
+ return res.json({ ok: true, action, text: buildBrief(currentData) });
+ }
+
+ if (action === 'sweep') {
+ return triggerSweep(res);
+ }
+
+ res.status(400).json({ ok: false, error: 'Unknown action', actions: ['status', 'brief', 'sweep'] });
});
// API: available locales
@@ -333,6 +348,20 @@ function dataAgeMs() {
return Number.isFinite(ms) ? ms : null;
}
+function canRunTerminalAction(req) {
+ const remote = req.ip || '';
+ const local = remote.includes('127.0.0.1') || remote === '::1' || remote === '::ffff:127.0.0.1';
+ const token = req.get('x-crucix-token') || req.query.token || req.body?.token;
+ if (config.sweepToken) return token === config.sweepToken;
+ return Boolean(config.terminalActionsEnabled || local);
+}
+
+function triggerSweep(res) {
+ if (sweepInProgress) return res.status(409).json({ ok: true, status: 'already_running', sweepStartedAt });
+ runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
+ return res.status(202).json({ ok: true, status: 'accepted' });
+}
+
function getLLMStatus() {
if (!config.llm.provider) return { state: 'disabled' };
if (!llmProvider) return { state: 'misconfigured', provider: config.llm.provider };
@@ -376,6 +405,7 @@ function buildHealth() {
llm: getLLMStatus(),
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
+ terminalActionsEnabled: Boolean(config.terminalActionsEnabled || config.sweepToken),
refreshIntervalMinutes: config.refreshIntervalMinutes,
language: currentLanguage,
memory: intelligenceStore.status(),
@@ -418,6 +448,13 @@ function buildBrief(data) {
lines.push('', '*Why This Matters*');
for (const idea of ideas) lines.push(`- ${idea.title}: ${(idea.rationale || idea.text || '').slice(0, 140)}`);
}
+ const scenarioChanges = data.scenarios?.changed || [];
+ if (scenarioChanges.length) {
+ lines.push('', '*Scenario Watchlist*');
+ for (const scenario of scenarioChanges.slice(0, 4)) {
+ lines.push(`- ${scenario.name}: ${scenario.state.toUpperCase()} (${scenario.confidence}% confidence)`);
+ }
+ }
lines.push('', '*What To Do Next*', '- Open the dashboard, verify the evidence links, and compare source health before acting.');
return lines.join('\n');
}
@@ -464,6 +501,7 @@ async function runSweepCycle() {
// 4. Delta computation + memory
const delta = memory.addRun(synthesized);
synthesized.delta = delta;
+ synthesized.scenarios = evaluateScenarios(synthesized, delta, RUNS_DIR);
// 5. LLM-powered trade ideas (LLM-only feature) — isolated so failures don't kill sweep
if (llmProvider?.isConfigured) {
diff --git a/test/fetch-utils.test.mjs b/test/fetch-utils.test.mjs
index 2dcee45..8a69f73 100644
--- a/test/fetch-utils.test.mjs
+++ b/test/fetch-utils.test.mjs
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
test('safeFetch reports HTML as degraded JSON response', async () => {
@@ -34,3 +35,18 @@ test('safeFetchText returns text and byte count', async () => {
globalThis.fetch = originalFetch;
}
});
+
+test('scenario watchlist feature is wired into sweep, briefing, and dashboard', () => {
+ const scenarios = readFileSync(new URL('../lib/scenarios.mjs', import.meta.url), 'utf8');
+ const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
+ const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
+ const readme = readFileSync(new URL('../README.md', import.meta.url), 'utf8');
+ assert.match(scenarios, /DEFAULT_SCENARIOS/);
+ assert.match(scenarios, /runsDir, 'scenarios\.json'/);
+ assert.match(scenarios, /scenario-state\.json/);
+ assert.match(scenarios, /watching.*building.*confirmed/s);
+ assert.match(server, /evaluateScenarios\(synthesized, delta, RUNS_DIR\)/);
+ assert.match(server, /\*Scenario Watchlist\*/);
+ assert.match(html, /Scenario Watchlist/);
+ assert.match(readme, /runs\/scenarios\.json/);
+});
diff --git a/test/reddit-source.test.mjs b/test/reddit-source.test.mjs
new file mode 100644
index 0000000..1e61620
--- /dev/null
+++ b/test/reddit-source.test.mjs
@@ -0,0 +1,109 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { briefing, getHot, getRedditConfig, getToken } from '../apis/sources/reddit.mjs';
+
+test('Reddit reports missing OAuth credentials without network access', async () => {
+ let calls = 0;
+ const data = await briefing({
+ env: {},
+ delayMs: 0,
+ fetchImpl: async () => {
+ calls++;
+ throw new Error('unexpected network access');
+ },
+ });
+
+ assert.equal(calls, 0);
+ assert.equal(data.status, 'no_credentials');
+ assert.equal(data.error, 'missing_reddit_oauth_credentials');
+ assert.deepEqual(data.missing, ['REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET']);
+});
+
+test('Reddit hot posts require OAuth token and never use public JSON fallback', async () => {
+ const originalFetch = globalThis.fetch;
+ let calledUrl = null;
+ globalThis.fetch = async url => {
+ calledUrl = url;
+ throw new Error('unexpected public fallback');
+ };
+
+ try {
+ const data = await getHot('worldnews');
+ assert.equal(calledUrl, null);
+ assert.equal(data.status, 'no_credentials');
+ assert.equal(data.error, 'reddit_oauth_required');
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('Reddit classifies OAuth HTTP failure without exposing secrets', async () => {
+ const result = await getToken({
+ env: { REDDIT_CLIENT_ID: 'client-id', REDDIT_CLIENT_SECRET: 'client-secret' },
+ fetchImpl: async () => ({
+ ok: false,
+ status: 401,
+ text: async () => 'invalid client',
+ }),
+ });
+
+ assert.equal(result.ok, false);
+ assert.equal(result.status, 'auth_failed');
+ assert.equal(result.error, 'reddit_oauth_http_401');
+ assert.doesNotMatch(JSON.stringify(result), /client-secret/);
+});
+
+test('Reddit fetches hot posts through oauth.reddit.com when configured', async () => {
+ const originalFetch = globalThis.fetch;
+ const urls = [];
+ globalThis.fetch = async url => {
+ urls.push(String(url));
+ if (String(url).includes('/api/v1/access_token')) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ access_token: 'test-token' }),
+ };
+ }
+ return {
+ ok: true,
+ status: 200,
+ headers: { get: () => 'application/json' },
+ text: async () => JSON.stringify({
+ data: {
+ children: [
+ {
+ data: {
+ title: 'Market stress headline',
+ score: 42,
+ num_comments: 7,
+ url: 'https://example.test/post',
+ created_utc: 1700000000,
+ },
+ },
+ ],
+ },
+ }),
+ };
+ };
+
+ try {
+ const data = await briefing({
+ env: { REDDIT_CLIENT_ID: 'client-id', REDDIT_CLIENT_SECRET: 'client-secret' },
+ subreddits: ['worldnews'],
+ delayMs: 0,
+ });
+
+ assert.equal(data.status, 'ok');
+ assert.equal(data.subreddits.worldnews[0].title, 'Market stress headline');
+ assert.ok(urls.some(url => url === 'https://www.reddit.com/api/v1/access_token'));
+ assert.ok(urls.some(url => url.startsWith('https://oauth.reddit.com/r/worldnews/hot')));
+ assert.equal(urls.some(url => url.includes('hot.json')), false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('Reddit config reports partial credential state', () => {
+ assert.deepEqual(getRedditConfig({ REDDIT_CLIENT_ID: 'id' }).missing, ['REDDIT_CLIENT_SECRET']);
+});