Compare commits
7 Commits
codex/issu
...
267af03b22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
267af03b22 | ||
| 8605d0baab | |||
| 53470cc701 | |||
| 4262c7e939 | |||
| e933586b22 | |||
| 8e096b2697 | |||
| b309bd690e |
@@ -6,6 +6,7 @@ PORT=3117
|
|||||||
REFRESH_INTERVAL_MINUTES=15
|
REFRESH_INTERVAL_MINUTES=15
|
||||||
AUTO_OPEN_BROWSER=false
|
AUTO_OPEN_BROWSER=false
|
||||||
STALE_DATA_MAX_AGE_MINUTES=60
|
STALE_DATA_MAX_AGE_MINUTES=60
|
||||||
|
TERMINAL_ACTIONS_ENABLED=true
|
||||||
SWEEP_TOKEN=
|
SWEEP_TOKEN=
|
||||||
BRIEF_VERBOSITY=standard
|
BRIEF_VERBOSITY=standard
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ jobs:
|
|||||||
run: docker compose config
|
run: docker compose config
|
||||||
|
|
||||||
- name: Build Docker image
|
- 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
|
- name: Publish Docker image
|
||||||
if: ${{ env.REGISTRY_TOKEN != '' }}
|
if: ${{ env.REGISTRY_TOKEN != '' }}
|
||||||
@@ -47,8 +52,9 @@ jobs:
|
|||||||
image="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${IMAGE_NAME}"
|
image="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${IMAGE_NAME}"
|
||||||
date_tag="$(date -u +%Y%m%d)"
|
date_tag="$(date -u +%Y%m%d)"
|
||||||
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin
|
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USERNAME}" --password-stdin
|
||||||
docker tag "${image}:${GITHUB_SHA}" "${image}:latest"
|
docker tag "${BUILD_IMAGE}" "${image}:${GITHUB_SHA}"
|
||||||
docker tag "${image}:${GITHUB_SHA}" "${image}:${date_tag}"
|
docker tag "${BUILD_IMAGE}" "${image}:latest"
|
||||||
|
docker tag "${BUILD_IMAGE}" "${image}:${date_tag}"
|
||||||
docker push "${image}:${GITHUB_SHA}"
|
docker push "${image}:${GITHUB_SHA}"
|
||||||
docker push "${image}:latest"
|
docker push "${image}:latest"
|
||||||
docker push "${image}:${date_tag}"
|
docker push "${image}:${date_tag}"
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -135,6 +135,7 @@ PORT=3117
|
|||||||
REFRESH_INTERVAL_MINUTES=15
|
REFRESH_INTERVAL_MINUTES=15
|
||||||
AUTO_OPEN_BROWSER=false
|
AUTO_OPEN_BROWSER=false
|
||||||
STALE_DATA_MAX_AGE_MINUTES=60
|
STALE_DATA_MAX_AGE_MINUTES=60
|
||||||
|
TERMINAL_ACTIONS_ENABLED=true
|
||||||
SWEEP_TOKEN=
|
SWEEP_TOKEN=
|
||||||
BRIEF_VERBOSITY=standard
|
BRIEF_VERBOSITY=standard
|
||||||
|
|
||||||
@@ -187,6 +188,38 @@ 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`.
|
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`.
|
||||||
|
|
||||||
|
#### Memory And Prediction Loop
|
||||||
|
|
||||||
|
Crucix stores longitudinal memory in `runs/intelligence.db` when the current Node.js build exposes `node:sqlite`. If SQLite is unavailable, the file is created as a harmless placeholder and `/api/health` reports the memory store as unavailable instead of failing the sweep.
|
||||||
|
|
||||||
|
The memory layer persists:
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `runs` | Sweep timestamps, source health counts, and delta direction summaries. |
|
||||||
|
| `entities` | Stable entity IDs for recurring countries, regions, and locations. |
|
||||||
|
| `events` | Stable event IDs for conflict, OSINT, urgent news, and new delta signals across sweeps. |
|
||||||
|
| `predictions` | Trade/intelligence hypotheses with evidence, confidence, horizon, outcome state, and latest grading. |
|
||||||
|
|
||||||
|
Query endpoints:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/memory/search?q=iran&limit=25
|
||||||
|
GET /api/memory/predictions?state=open&limit=25
|
||||||
|
```
|
||||||
|
|
||||||
|
Memory endpoints use the same operator authorization gate as Terminal Actions. The dashboard Terminal Actions panel includes a `Memory` action for a quick operator-facing view of recent events and prediction states.
|
||||||
|
|
||||||
|
Retention, backup, and privacy expectations:
|
||||||
|
|
||||||
|
- Treat `runs/intelligence.db` as operator data. It can contain source excerpts, headlines, generated hypotheses, and URLs from your configured feeds.
|
||||||
|
- Back up `runs/` with the rest of your Dockge volume if you want longitudinal learning to survive container replacement.
|
||||||
|
- Delete `runs/intelligence.db` to reset SQLite memory; the next sweep recreates the schema.
|
||||||
|
- Do not commit `runs/` or `.env`. API credentials stay in `.env`; memory stores derived observations, not secrets.
|
||||||
|
- If you expose the dashboard through a reverse proxy, protect Terminal Actions and memory queries behind your normal authentication boundary.
|
||||||
|
|
||||||
#### Build And Publish Your Gitea Image
|
#### Build And Publish Your Gitea Image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default {
|
|||||||
autoOpenBrowser: boolEnv('AUTO_OPEN_BROWSER', false),
|
autoOpenBrowser: boolEnv('AUTO_OPEN_BROWSER', false),
|
||||||
staleDataMaxAgeMinutes: intEnv('STALE_DATA_MAX_AGE_MINUTES', 60),
|
staleDataMaxAgeMinutes: intEnv('STALE_DATA_MAX_AGE_MINUTES', 60),
|
||||||
sweepToken: process.env.SWEEP_TOKEN || null,
|
sweepToken: process.env.SWEEP_TOKEN || null,
|
||||||
|
terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', true),
|
||||||
|
|
||||||
llm: {
|
llm: {
|
||||||
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok
|
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok
|
||||||
|
|||||||
@@ -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}
|
.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{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)}
|
.mini-btn:hover{color:var(--accent);border-color:rgba(100,240,200,0.4)}
|
||||||
|
.action-grid{display:grid;grid-template-columns:repeat(4,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}
|
.layer-left{display:flex;align-items:center;gap:8px}
|
||||||
.ldot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
.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)}
|
.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 isFlat = shouldStartFlat();
|
||||||
let layerModes = JSON.parse(localStorage.getItem('crucix_layer_modes') || '{}');
|
let layerModes = JSON.parse(localStorage.getItem('crucix_layer_modes') || '{}');
|
||||||
let spaceDisplayMode = localStorage.getItem('crucix_space_display') || 'icons';
|
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 currentRegion = 'world';
|
||||||
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
|
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
|
||||||
|
|
||||||
@@ -1564,6 +1573,57 @@ function renderLower(){
|
|||||||
document.getElementById('lowerGrid').innerHTML=`${tickerPanel}${osintPanel}${macroPanel}${ideasPanel}`;
|
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 === 'memory'){
|
||||||
|
const events = payload.recentEvents || [];
|
||||||
|
const predictions = payload.predictions || [];
|
||||||
|
terminalOutput = [
|
||||||
|
'> memory',
|
||||||
|
`Store: ${payload.memory?.available ? 'available' : 'unavailable'}`,
|
||||||
|
`Recent events: ${events.length}`,
|
||||||
|
...events.slice(0,4).map(e => `- ${e.kind}: ${e.name}${e.region ? ' [' + e.region + ']' : ''}`),
|
||||||
|
`Predictions: ${predictions.length}`,
|
||||||
|
...predictions.slice(0,4).map(p => `- ${p.outcome_state || 'open'}: ${p.title}`)
|
||||||
|
].join('\n');
|
||||||
|
}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 ===
|
// === RIGHT RAIL ===
|
||||||
function renderRight(){
|
function renderRight(){
|
||||||
const mobile = isMobileLayout();
|
const mobile = isMobileLayout();
|
||||||
@@ -1605,6 +1665,16 @@ function renderRight(){
|
|||||||
const deltaHtml = hasDelta ? deltaRows.join('') : `<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">${t('delta.noChanges','No changes since last sweep')}</div>`;
|
const deltaHtml = hasDelta ? deltaRows.join('') : `<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">${t('delta.noChanges','No changes since last sweep')}</div>`;
|
||||||
|
|
||||||
document.getElementById('rightRail').innerHTML=`
|
document.getElementById('rightRail').innerHTML=`
|
||||||
|
<div class="g-panel right-actions">
|
||||||
|
<div class="sec-head"><h3>Terminal Actions</h3><span class="badge">${terminalBusy?'RUNNING':'READY'}</span></div>
|
||||||
|
<div class="action-grid">
|
||||||
|
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('status')">Status</button>
|
||||||
|
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('sweep')">Sweep</button>
|
||||||
|
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('brief')">Brief</button>
|
||||||
|
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('memory')">Memory</button>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-output">${terminalOutput.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])).replace(/\n/g,'<br>')}</div>
|
||||||
|
</div>
|
||||||
<div class="g-panel right-signals">
|
<div class="g-panel right-signals">
|
||||||
<div class="sec-head"><h3>${t('panels.crossSourceSignals','Cross-Source Signals')}</h3><span class="badge">${t('badges.worldview','WORLDVIEW')}</span></div>
|
<div class="sec-head"><h3>${t('panels.crossSourceSignals','Cross-Source Signals')}</h3><span class="badge">${t('badges.worldview','WORLDVIEW')}</span></div>
|
||||||
${signals}
|
${signals}
|
||||||
@@ -1839,10 +1909,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const hasInlineData = !!(D && D.meta);
|
const hasInlineData = !!(D && D.meta);
|
||||||
const canProbeApi = location.protocol !== 'file:';
|
const canProbeApi = location.protocol !== 'file:';
|
||||||
|
|
||||||
if (canProbeApi && !hasInlineData) {
|
if (canProbeApi) {
|
||||||
// Server mode: always fetch live data from API (ignore any stale inline D)
|
// Server mode: always fetch live data from API (ignore any stale inline D)
|
||||||
fetch('/api/data')
|
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(); })
|
.then(data => { D = data; init(); connectSSE(); })
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Should not reach here — server routes to loading.html when no data
|
// Should not reach here — server routes to loading.html when no data
|
||||||
|
|||||||
@@ -1,18 +1,489 @@
|
|||||||
# Agent Handoff
|
# 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
|
```text
|
||||||
git.wilkensxl.de/mrsphay/intelligence-terminal:latest
|
git.wilkensxl.de/mrsphay/intelligence-terminal:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
### API And Health
|
||||||
|
|
||||||
- The repository is Docker-first and should stay suitable for Dockge/Pangolin.
|
Added or hardened:
|
||||||
- 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`.
|
- `GET /api/health`
|
||||||
- If Gitea Registry authentication is unavailable locally, build and push with the commands documented in `README.md`.
|
- `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
|
||||||
|
```
|
||||||
|
|||||||
@@ -5,12 +5,21 @@
|
|||||||
- Shell execution: browser auto-open is gated by `AUTO_OPEN_BROWSER` and defaults to false.
|
- 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.
|
- Secrets: `.env` remains ignored; `.env.example` contains no real keys.
|
||||||
- External network calls: source fetches use timeout/retry diagnostics and expose degraded state.
|
- 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/`.
|
- 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.
|
- 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
|
## Residual Risk
|
||||||
|
|
||||||
- External feeds can return malformed, stale, or adversarial content. UI rendering should continue to sanitize titles and URLs.
|
- 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.
|
- 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.
|
- `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.
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
const PREDICTION_STATES = new Set(['open', 'monitoring', 'observed', 'expired_unverified', 'invalidated']);
|
||||||
|
|
||||||
export class IntelligenceStore {
|
export class IntelligenceStore {
|
||||||
constructor(dbPath) {
|
constructor(dbPath) {
|
||||||
@@ -30,15 +33,24 @@ export class IntelligenceStore {
|
|||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS predictions (
|
CREATE TABLE IF NOT EXISTS predictions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
stable_id TEXT UNIQUE,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
type TEXT,
|
type TEXT,
|
||||||
|
hypothesis TEXT,
|
||||||
|
evidence_json TEXT,
|
||||||
confidence TEXT,
|
confidence TEXT,
|
||||||
|
horizon TEXT,
|
||||||
|
outcome_state TEXT DEFAULT 'open',
|
||||||
|
outcome_json TEXT,
|
||||||
|
last_evaluated_at TEXT,
|
||||||
source TEXT,
|
source TEXT,
|
||||||
payload_json TEXT NOT NULL
|
payload_json TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS entities (
|
CREATE TABLE IF NOT EXISTS entities (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
stable_id TEXT UNIQUE,
|
||||||
first_seen TEXT NOT NULL,
|
first_seen TEXT NOT NULL,
|
||||||
last_seen TEXT NOT NULL,
|
last_seen TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -46,7 +58,21 @@ export class IntelligenceStore {
|
|||||||
count INTEGER DEFAULT 1,
|
count INTEGER DEFAULT 1,
|
||||||
UNIQUE(name, kind)
|
UNIQUE(name, kind)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
stable_id TEXT NOT NULL UNIQUE,
|
||||||
|
first_seen TEXT NOT NULL,
|
||||||
|
last_seen TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
region TEXT,
|
||||||
|
severity TEXT,
|
||||||
|
source TEXT,
|
||||||
|
evidence_json TEXT NOT NULL,
|
||||||
|
count INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
this._migrate();
|
||||||
this.available = true;
|
this.available = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.available = false;
|
this.available = false;
|
||||||
@@ -71,24 +97,141 @@ export class IntelligenceStore {
|
|||||||
delta?.summary?.direction || null,
|
delta?.summary?.direction || null,
|
||||||
JSON.stringify({ meta, delta: delta?.summary || null }),
|
JSON.stringify({ meta, delta: delta?.summary || null }),
|
||||||
);
|
);
|
||||||
for (const idea of data.ideas || []) {
|
|
||||||
this.db.prepare(`INSERT INTO predictions (created_at, title, type, confidence, source, payload_json)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`).run(
|
|
||||||
timestamp,
|
|
||||||
idea.title || 'Untitled idea',
|
|
||||||
idea.type || null,
|
|
||||||
idea.confidence || null,
|
|
||||||
idea.source || data.ideasSource || null,
|
|
||||||
JSON.stringify(idea),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this._recordEntities(data, timestamp);
|
this._recordEntities(data, timestamp);
|
||||||
|
this._recordEvents(data, delta, timestamp);
|
||||||
|
this.evaluatePredictions(data, timestamp);
|
||||||
|
this._recordPredictions(data, timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
status() {
|
status() {
|
||||||
return { available: this.available, path: this.dbPath, reason: this.reason };
|
return { available: this.available, path: this.dbPath, reason: this.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queryMemory({ q = '', limit = 25 } = {}) {
|
||||||
|
if (!this.available || !this.db) return { available: false, reason: this.reason, results: [] };
|
||||||
|
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 25));
|
||||||
|
const term = String(q || '').trim();
|
||||||
|
const like = `%${term}%`;
|
||||||
|
const where = term
|
||||||
|
? 'WHERE name LIKE ? OR region LIKE ? OR source LIKE ? OR kind LIKE ?'
|
||||||
|
: '';
|
||||||
|
const params = term ? [like, like, like, like, safeLimit] : [safeLimit];
|
||||||
|
const events = this.db.prepare(`
|
||||||
|
SELECT stable_id, first_seen, last_seen, kind, name, region, severity, source, count, evidence_json
|
||||||
|
FROM events
|
||||||
|
${where}
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(...params).map(row => ({ ...row, evidence: parseJson(row.evidence_json, {}) }));
|
||||||
|
return { available: true, q: term, results: events };
|
||||||
|
}
|
||||||
|
|
||||||
|
listPredictions({ state = null, limit = 25 } = {}) {
|
||||||
|
if (!this.available || !this.db) return { available: false, reason: this.reason, predictions: [] };
|
||||||
|
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 25));
|
||||||
|
const normalizedState = state && PREDICTION_STATES.has(String(state)) ? String(state) : null;
|
||||||
|
const rows = normalizedState
|
||||||
|
? this.db.prepare(`SELECT * FROM predictions WHERE outcome_state = ? ORDER BY created_at DESC LIMIT ?`).all(normalizedState, safeLimit)
|
||||||
|
: this.db.prepare(`SELECT * FROM predictions ORDER BY created_at DESC LIMIT ?`).all(safeLimit);
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
predictions: rows.map(row => ({
|
||||||
|
stable_id: row.stable_id,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
title: row.title,
|
||||||
|
type: row.type,
|
||||||
|
hypothesis: row.hypothesis,
|
||||||
|
confidence: row.confidence,
|
||||||
|
horizon: row.horizon,
|
||||||
|
outcome_state: row.outcome_state,
|
||||||
|
last_evaluated_at: row.last_evaluated_at,
|
||||||
|
source: row.source,
|
||||||
|
evidence: parseJson(row.evidence_json, []),
|
||||||
|
outcome: parseJson(row.outcome_json, null),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluatePredictions(data, timestamp = new Date().toISOString()) {
|
||||||
|
if (!this.available || !this.db) return;
|
||||||
|
const rows = this.db.prepare(`
|
||||||
|
SELECT id, created_at, title, type, horizon, outcome_state, payload_json
|
||||||
|
FROM predictions
|
||||||
|
WHERE outcome_state IN ('open', 'monitoring')
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 200
|
||||||
|
`).all();
|
||||||
|
for (const row of rows) {
|
||||||
|
const payload = parseJson(row.payload_json, {});
|
||||||
|
const evaluation = evaluatePredictionAgainstSweep(row, payload, data, timestamp);
|
||||||
|
this.db.prepare(`UPDATE predictions
|
||||||
|
SET outcome_state = ?, outcome_json = ?, last_evaluated_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?`).run(
|
||||||
|
evaluation.state,
|
||||||
|
JSON.stringify(evaluation),
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
row.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_migrate() {
|
||||||
|
const columns = {
|
||||||
|
predictions: [
|
||||||
|
['stable_id', 'TEXT'],
|
||||||
|
['updated_at', 'TEXT'],
|
||||||
|
['hypothesis', 'TEXT'],
|
||||||
|
['evidence_json', 'TEXT'],
|
||||||
|
['horizon', 'TEXT'],
|
||||||
|
['outcome_state', "TEXT DEFAULT 'open'"],
|
||||||
|
['outcome_json', 'TEXT'],
|
||||||
|
['last_evaluated_at', 'TEXT'],
|
||||||
|
],
|
||||||
|
entities: [
|
||||||
|
['stable_id', 'TEXT'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
for (const [table, defs] of Object.entries(columns)) {
|
||||||
|
for (const [name, type] of defs) {
|
||||||
|
try { this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${type}`); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_predictions_stable_id ON predictions(stable_id)`); } catch { }
|
||||||
|
try { this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_entities_stable_id ON entities(stable_id)`); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
_recordPredictions(data, timestamp) {
|
||||||
|
for (const idea of data.ideas || []) {
|
||||||
|
const title = idea.title || 'Untitled idea';
|
||||||
|
const stableId = stableId('prediction', title, idea.type || '', idea.ticker || '', idea.horizon || '');
|
||||||
|
const evidence = Array.isArray(idea.signals) ? idea.signals : [];
|
||||||
|
this.db.prepare(`INSERT INTO predictions (
|
||||||
|
stable_id, created_at, updated_at, title, type, hypothesis, evidence_json, confidence,
|
||||||
|
horizon, outcome_state, source, payload_json
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
|
||||||
|
ON CONFLICT(stable_id) DO UPDATE SET
|
||||||
|
updated_at=excluded.updated_at,
|
||||||
|
confidence=excluded.confidence,
|
||||||
|
evidence_json=excluded.evidence_json,
|
||||||
|
payload_json=excluded.payload_json`).run(
|
||||||
|
stableId,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
title,
|
||||||
|
idea.type || null,
|
||||||
|
idea.rationale || idea.text || title,
|
||||||
|
JSON.stringify(evidence),
|
||||||
|
idea.confidence || null,
|
||||||
|
idea.horizon || null,
|
||||||
|
idea.source || data.ideasSource || null,
|
||||||
|
JSON.stringify(idea),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_recordEntities(data, timestamp) {
|
_recordEntities(data, timestamp) {
|
||||||
const names = [];
|
const names = [];
|
||||||
for (const item of data.acled?.deadliestEvents || []) {
|
for (const item of data.acled?.deadliestEvents || []) {
|
||||||
@@ -99,14 +242,154 @@ export class IntelligenceStore {
|
|||||||
if (item.region) names.push([item.region, 'region']);
|
if (item.region) names.push([item.region, 'region']);
|
||||||
}
|
}
|
||||||
for (const [name, kind] of names.slice(0, 200)) {
|
for (const [name, kind] of names.slice(0, 200)) {
|
||||||
this.db.prepare(`INSERT INTO entities (first_seen, last_seen, name, kind, count)
|
const cleanName = String(name).slice(0, 160);
|
||||||
VALUES (?, ?, ?, ?, 1)
|
this.db.prepare(`INSERT INTO entities (stable_id, first_seen, last_seen, name, kind, count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1)
|
||||||
ON CONFLICT(name, kind) DO UPDATE SET last_seen=excluded.last_seen, count=count+1`).run(
|
ON CONFLICT(name, kind) DO UPDATE SET last_seen=excluded.last_seen, count=count+1`).run(
|
||||||
|
stableId('entity', kind, cleanName),
|
||||||
timestamp,
|
timestamp,
|
||||||
timestamp,
|
timestamp,
|
||||||
String(name).slice(0, 160),
|
cleanName,
|
||||||
kind,
|
kind,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_recordEvents(data, delta, timestamp) {
|
||||||
|
const events = extractEvents(data, delta);
|
||||||
|
for (const event of events.slice(0, 300)) {
|
||||||
|
this.db.prepare(`INSERT INTO events (
|
||||||
|
stable_id, first_seen, last_seen, kind, name, region, severity, source, evidence_json, count
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
|
ON CONFLICT(stable_id) DO UPDATE SET
|
||||||
|
last_seen=excluded.last_seen,
|
||||||
|
severity=COALESCE(excluded.severity, severity),
|
||||||
|
evidence_json=excluded.evidence_json,
|
||||||
|
count=count+1`).run(
|
||||||
|
event.stable_id,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
event.kind,
|
||||||
|
event.name,
|
||||||
|
event.region || null,
|
||||||
|
event.severity || null,
|
||||||
|
event.source || null,
|
||||||
|
JSON.stringify(event.evidence || {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableId(...parts) {
|
||||||
|
const input = parts.map(part => String(part || '').trim().toLowerCase()).join('|');
|
||||||
|
return createHash('sha256').update(input).digest('hex').slice(0, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value, fallback) {
|
||||||
|
try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEvents(data, delta) {
|
||||||
|
const events = [];
|
||||||
|
const push = ({ kind, name, region, severity, source, evidence }) => {
|
||||||
|
if (!kind || !name) return;
|
||||||
|
events.push({
|
||||||
|
stable_id: stableId('event', kind, name, region || source || ''),
|
||||||
|
kind,
|
||||||
|
name: String(name).slice(0, 240),
|
||||||
|
region: region ? String(region).slice(0, 120) : null,
|
||||||
|
severity: severity || null,
|
||||||
|
source: source || null,
|
||||||
|
evidence: evidence || {},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of data.acled?.deadliestEvents || []) {
|
||||||
|
push({
|
||||||
|
kind: 'conflict',
|
||||||
|
name: item.event_type || item.sub_event_type || item.location || item.country,
|
||||||
|
region: item.country || item.location,
|
||||||
|
severity: Number(item.fatalities || 0) > 0 ? 'high' : 'medium',
|
||||||
|
source: 'ACLED',
|
||||||
|
evidence: item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const item of data.tg?.urgent || []) {
|
||||||
|
push({
|
||||||
|
kind: 'osint',
|
||||||
|
name: (item.text || '').slice(0, 120),
|
||||||
|
region: item.region || 'OSINT',
|
||||||
|
severity: 'high',
|
||||||
|
source: item.channel || item.chat || 'telegram',
|
||||||
|
evidence: item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const item of data.newsFeed || data.news || []) {
|
||||||
|
if (!item.urgent) continue;
|
||||||
|
push({
|
||||||
|
kind: 'news',
|
||||||
|
name: item.headline || item.title,
|
||||||
|
region: item.region,
|
||||||
|
severity: 'medium',
|
||||||
|
source: item.source,
|
||||||
|
evidence: item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const signal of delta?.signals?.new || []) {
|
||||||
|
push({
|
||||||
|
kind: 'delta',
|
||||||
|
name: signal.label || signal.reason || signal.key,
|
||||||
|
region: signal.region,
|
||||||
|
severity: signal.severity || 'medium',
|
||||||
|
source: 'delta',
|
||||||
|
evidence: signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluatePredictionAgainstSweep(row, payload, data, timestamp) {
|
||||||
|
const terms = [
|
||||||
|
row.title,
|
||||||
|
payload.ticker,
|
||||||
|
...(Array.isArray(payload.signals) ? payload.signals : []),
|
||||||
|
].filter(Boolean).map(v => String(v).toLowerCase());
|
||||||
|
const evidenceText = [
|
||||||
|
...(data.tSignals || []),
|
||||||
|
...(data.newsFeed || []).slice(0, 40).map(n => `${n.source || ''} ${n.headline || n.title || ''}`),
|
||||||
|
...(data.tg?.urgent || []).slice(0, 20).map(p => p.text || ''),
|
||||||
|
].join('\n').toLowerCase();
|
||||||
|
const matched = terms.filter(term => term.length >= 4 && evidenceText.includes(term.slice(0, 60)));
|
||||||
|
const expired = predictionExpired(row.created_at, row.horizon, timestamp);
|
||||||
|
const state = matched.length
|
||||||
|
? 'observed'
|
||||||
|
: expired
|
||||||
|
? 'expired_unverified'
|
||||||
|
: 'monitoring';
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
evaluated_at: timestamp,
|
||||||
|
matched_terms: matched.slice(0, 10),
|
||||||
|
expired,
|
||||||
|
reason: matched.length
|
||||||
|
? 'Current sweep contains matching evidence terms.'
|
||||||
|
: expired
|
||||||
|
? 'Prediction horizon elapsed without matching evidence.'
|
||||||
|
: 'Prediction remains open for future sweeps.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function predictionExpired(createdAt, horizon, nowIso) {
|
||||||
|
const created = new Date(createdAt).getTime();
|
||||||
|
const now = new Date(nowIso).getTime();
|
||||||
|
if (!Number.isFinite(created) || !Number.isFinite(now)) return false;
|
||||||
|
const text = String(horizon || '').toLowerCase();
|
||||||
|
const days = text.includes('intraday') ? 1
|
||||||
|
: text.includes('day') ? 7
|
||||||
|
: text.includes('week') ? 45
|
||||||
|
: text.includes('month') ? 180
|
||||||
|
: text.includes('strategic') ? 365
|
||||||
|
: 30;
|
||||||
|
return now - created > days * 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|||||||
71
server.mjs
71
server.mjs
@@ -288,15 +288,55 @@ app.get('/api/metrics', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/memory/search', (req, res) => {
|
||||||
|
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Memory queries disabled or unauthorized' });
|
||||||
|
res.json(intelligenceStore.queryMemory({
|
||||||
|
q: req.query.q || '',
|
||||||
|
limit: req.query.limit || 25,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/memory/predictions', (req, res) => {
|
||||||
|
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Memory queries disabled or unauthorized' });
|
||||||
|
res.json(intelligenceStore.listPredictions({
|
||||||
|
state: req.query.state || null,
|
||||||
|
limit: req.query.limit || 25,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/sweep', express.json(), (req, res) => {
|
app.post('/api/sweep', express.json(), (req, res) => {
|
||||||
const remote = req.ip || '';
|
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' });
|
||||||
const local = remote.includes('127.0.0.1') || remote === '::1' || remote === '::ffff:127.0.0.1';
|
triggerSweep(res);
|
||||||
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' });
|
app.post('/api/action', express.json(), async (req, res) => {
|
||||||
if (sweepInProgress) return res.status(409).json({ status: 'already_running', sweepStartedAt });
|
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' });
|
||||||
runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
|
const action = String(req.body?.action || req.query.action || '').toLowerCase();
|
||||||
res.status(202).json({ status: 'accepted' });
|
|
||||||
|
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 === 'memory') {
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
memory: intelligenceStore.status(),
|
||||||
|
recentEvents: intelligenceStore.queryMemory({ q: req.body?.q || '', limit: 8 }).results,
|
||||||
|
predictions: intelligenceStore.listPredictions({ limit: 8 }).predictions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'sweep') {
|
||||||
|
return triggerSweep(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).json({ ok: false, error: 'Unknown action', actions: ['status', 'brief', 'memory', 'sweep'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// API: available locales
|
// API: available locales
|
||||||
@@ -333,6 +373,20 @@ function dataAgeMs() {
|
|||||||
return Number.isFinite(ms) ? ms : null;
|
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() {
|
function getLLMStatus() {
|
||||||
if (!config.llm.provider) return { state: 'disabled' };
|
if (!config.llm.provider) return { state: 'disabled' };
|
||||||
if (!llmProvider) return { state: 'misconfigured', provider: config.llm.provider };
|
if (!llmProvider) return { state: 'misconfigured', provider: config.llm.provider };
|
||||||
@@ -376,6 +430,7 @@ function buildHealth() {
|
|||||||
llm: getLLMStatus(),
|
llm: getLLMStatus(),
|
||||||
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
|
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
|
||||||
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
|
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
|
||||||
|
terminalActionsEnabled: Boolean(config.terminalActionsEnabled || config.sweepToken),
|
||||||
refreshIntervalMinutes: config.refreshIntervalMinutes,
|
refreshIntervalMinutes: config.refreshIntervalMinutes,
|
||||||
language: currentLanguage,
|
language: currentLanguage,
|
||||||
memory: intelligenceStore.status(),
|
memory: intelligenceStore.status(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
|
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
|
||||||
|
|
||||||
test('safeFetch reports HTML as degraded JSON response', async () => {
|
test('safeFetch reports HTML as degraded JSON response', async () => {
|
||||||
@@ -34,3 +35,24 @@ test('safeFetchText returns text and byte count', async () => {
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('intelligence store defines durable memory and prediction lifecycle tables', () => {
|
||||||
|
const store = readFileSync(new URL('../lib/intelligence-store.mjs', import.meta.url), 'utf8');
|
||||||
|
assert.match(store, /CREATE TABLE IF NOT EXISTS events/);
|
||||||
|
assert.match(store, /stable_id TEXT NOT NULL UNIQUE/);
|
||||||
|
assert.match(store, /hypothesis TEXT/);
|
||||||
|
assert.match(store, /evidence_json TEXT/);
|
||||||
|
assert.match(store, /outcome_state TEXT DEFAULT 'open'/);
|
||||||
|
assert.match(store, /evaluatePredictions/);
|
||||||
|
assert.match(store, /queryMemory/);
|
||||||
|
assert.match(store, /listPredictions/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('server exposes memory-backed query APIs and dashboard memory action', () => {
|
||||||
|
const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
|
||||||
|
const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
|
||||||
|
assert.match(server, /\/api\/memory\/search/);
|
||||||
|
assert.match(server, /\/api\/memory\/predictions/);
|
||||||
|
assert.match(server, /action === 'memory'/);
|
||||||
|
assert.match(html, /runTerminalAction\('memory'\)/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user