Compare commits

...

8 Commits

Author SHA1 Message Date
c4c6063c6e test: expect conversational dave presence
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 47s
Build / test-and-image (pull_request) Successful in 2m32s
2026-07-11 18:33:07 +02:00
429114f272 fix: keep dave chat responsive on llm failures
Some checks failed
Codex Template Compliance / template-compliance (pull_request) Successful in 52s
Build / test-and-image (pull_request) Failing after 1m31s
2026-07-11 18:27:12 +02:00
74579375b3 Merge pull request 'fix: make DAVE Telegram alerts conversational' (#87)
All checks were successful
Codex Template Compliance / template-compliance (push) Successful in 27s
Release Dry Run / release-dry-run (push) Successful in 45s
Build / test-and-image (push) Successful in 1m18s
Shortens DAVE Telegram delivery and removes report-style fallback/tool traces.
2026-07-11 09:06:12 +00:00
1178146d59 fix: make dave telegram alerts conversational
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 32s
Build / test-and-image (pull_request) Successful in 2m4s
2026-07-11 10:58:37 +02:00
8de9feda62 docs: record source degradation verification (#86)
All checks were successful
Codex Template Compliance / template-compliance (push) Successful in 5s
Release Dry Run / release-dry-run (push) Successful in 15s
Build / test-and-image (push) Successful in 26s
2026-07-07 19:37:54 +00:00
9a67b160ef docs: record source degradation verification
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 7s
Build / test-and-image (pull_request) Successful in 25s
2026-07-07 21:36:00 +02:00
831cae1729 fix: harden degraded source handling (#85)
All checks were successful
Release Dry Run / release-dry-run (push) Successful in 17s
Codex Template Compliance / template-compliance (push) Successful in 7s
Build / test-and-image (push) Successful in 34s
Closes #84.
2026-07-07 19:19:01 +00:00
62d9c680b7 fix: harden degraded source handling
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 7s
Build / test-and-image (pull_request) Successful in 53s
2026-07-07 21:16:24 +02:00
18 changed files with 569 additions and 89 deletions

View File

@@ -501,6 +501,14 @@ These three unlock the most valuable economic and satellite data. Each takes abo
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. 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.
Source resilience notes:
- GDELT web/news pulls are bounded so optional geo enrichment cannot consume the full sweep source budget.
- OFAC sanctions metadata is read through bounded XML snippets instead of downloading full sanctions XML files during every sweep.
- EPA RadNet uses shorter bounded requests and reports degraded state instead of blocking the sweep when the government endpoint is slow.
- BLS automatically retries without `BLS_API_KEY` when a configured key is rejected, while surfacing a configuration warning.
- ACLED `403` means the credentials authenticated but the account still needs ACLED terms/profile/API access completed.
- ADS-B and Reddit require credentials for their production-grade feeds; missing credentials are reported as degraded/disabled rather than silently faking live data.
### LLM Provider (optional, for AI-enhanced ideas) ### LLM Provider (optional, for AI-enhanced ideas)
Set `LLM_PROVIDER` to one of: `litellm`, `openrouter`, `openai-compatible`, `lmstudio`, `ollama`, `anthropic`, `openai`, `gemini`, `codex`, `minimax`, `mistral`, or `grok`. Set `LLM_PROVIDER` to one of: `litellm`, `openrouter`, `openai-compatible`, `lmstudio`, `ollama`, `anthropic`, `openai`, `gemini`, `codex`, `minimax`, `mistral`, or `grok`.

View File

@@ -61,12 +61,13 @@ export async function runSource(name, fn, ...args) {
const hasError = Boolean(data?.error); const hasError = Boolean(data?.error);
const degradedStatuses = ['no_credentials', 'no_key', 'disabled', 'degraded', 'failed', 'error']; const degradedStatuses = ['no_credentials', 'no_key', 'disabled', 'degraded', 'failed', 'error'];
const isDegraded = hasError || degradedStatuses.includes(data?.status); const isDegraded = hasError || degradedStatuses.includes(data?.status);
const error = data?.error || data?.configWarning || data?.message || null;
return { return {
name, name,
status: isDegraded ? 'degraded' : 'ok', status: isDegraded ? 'degraded' : 'ok',
durationMs: Date.now() - start, durationMs: Date.now() - start,
data, data,
error: hasError ? data.error : null, error: isDegraded ? error : null,
}; };
} catch (e) { } catch (e) {
return { name, status: 'error', durationMs: Date.now() - start, error: e.message }; return { name, status: 'error', durationMs: Date.now() - start, error: e.message };

View File

@@ -52,6 +52,11 @@ export async function getSeries(seriesIds, opts = {}) {
} }
} }
function isInvalidKeyResponse(resp) {
const message = Array.isArray(resp?.message) ? resp.message.join(' ') : String(resp?.message || resp?.error || '');
return Boolean(resp) && /key:.*invalid|invalid.*key|registrationkey/i.test(message);
}
// Extract the latest observation from a BLS series response // Extract the latest observation from a BLS series response
function latestFromSeries(seriesData) { function latestFromSeries(seriesData) {
if (!seriesData?.data?.length) return null; if (!seriesData?.data?.length) return null;
@@ -95,17 +100,25 @@ function momChange(seriesData) {
// Briefing — pull latest CPI, unemployment, payrolls // Briefing — pull latest CPI, unemployment, payrolls
export async function briefing(apiKey) { export async function briefing(apiKey) {
const seriesIds = Object.keys(SERIES); const seriesIds = Object.keys(SERIES);
const resp = await getSeries(seriesIds, { apiKey }); let resp = await getSeries(seriesIds, { apiKey });
let configWarning = null;
if (apiKey && isInvalidKeyResponse(resp)) {
configWarning = 'Configured BLS_API_KEY was rejected; retried with unauthenticated BLS v1 API.';
resp = await getSeries(seriesIds, { apiKey: null });
}
if (resp.error) { if (resp.error) {
return { source: 'BLS', error: resp.error, timestamp: new Date().toISOString() }; return { source: 'BLS', status: 'degraded', error: resp.error, configWarning, timestamp: new Date().toISOString() };
} }
if (resp.status !== 'REQUEST_SUCCEEDED' || !resp.Results?.series?.length) { if (resp.status !== 'REQUEST_SUCCEEDED' || !resp.Results?.series?.length) {
return { return {
source: 'BLS', source: 'BLS',
status: 'degraded',
error: resp.message?.[0] || 'BLS API returned no data', error: resp.message?.[0] || 'BLS API returned no data',
rawStatus: resp.status, rawStatus: resp.status,
configWarning,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
} }
@@ -155,6 +168,7 @@ export async function briefing(apiKey) {
return { return {
source: 'BLS', source: 'BLS',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
...(configWarning ? { status: 'degraded', configWarning } : { status: 'ok' }),
indicators, indicators,
signals, signals,
}; };

View File

@@ -50,29 +50,29 @@ const THRESHOLDS = {
// Get recent RadNet analytical results (JSON) // Get recent RadNet analytical results (JSON)
export async function getAnalyticalResults(opts = {}) { export async function getAnalyticalResults(opts = {}) {
const { rows = 50, startRow = 0 } = opts; const { rows = 50, startRow = 0, timeout = 10000 } = opts;
return safeFetch( return safeFetch(
`${RADNET_ANALYTICAL}/ROWS/${startRow}:${startRow + rows}/JSON`, `${RADNET_ANALYTICAL}/ROWS/${startRow}:${startRow + rows}/JSON`,
{ timeout: 25000 } { timeout, retries: 0, source: 'EPA RadNet' }
); );
} }
// Get results filtered by state // Get results filtered by state
export async function getResultsByState(state, opts = {}) { export async function getResultsByState(state, opts = {}) {
const { rows = 25, startRow = 0 } = opts; const { rows = 25, startRow = 0, timeout = 8000 } = opts;
return safeFetch( return safeFetch(
`${RADNET_ANALYTICAL}/ANA_STATE/${state}/ROWS/${startRow}:${startRow + rows}/JSON`, `${RADNET_ANALYTICAL}/ANA_STATE/${state}/ROWS/${startRow}:${startRow + rows}/JSON`,
{ timeout: 25000 } { timeout, retries: 0, source: 'EPA RadNet' }
); );
} }
// Get results filtered by analyte type // Get results filtered by analyte type
export async function getResultsByAnalyte(analyte, opts = {}) { export async function getResultsByAnalyte(analyte, opts = {}) {
const { rows = 25, startRow = 0 } = opts; const { rows = 25, startRow = 0, timeout = 8000 } = opts;
const encoded = encodeURIComponent(analyte); const encoded = encodeURIComponent(analyte);
return safeFetch( return safeFetch(
`${RADNET_ANALYTICAL}/ANA_TYPE/${encoded}/ROWS/${startRow}:${startRow + rows}/JSON`, `${RADNET_ANALYTICAL}/ANA_TYPE/${encoded}/ROWS/${startRow}:${startRow + rows}/JSON`,
{ timeout: 25000 } { timeout, retries: 0, source: 'EPA RadNet' }
); );
} }
@@ -129,7 +129,20 @@ export async function briefing() {
const signals = []; const signals = [];
// Fetch recent analytical results (broad pull) // Fetch recent analytical results (broad pull)
const recentData = await getAnalyticalResults({ rows: 100 }); const recentData = await getAnalyticalResults({ rows: 80, timeout: 10000 });
if (recentData?.error) {
return {
source: 'EPA RadNet',
timestamp: new Date().toISOString(),
status: 'degraded',
error: recentData.error,
message: 'EPA RadNet broad analytical endpoint did not return current data within the source budget.',
readings: [],
signals: ['EPA RadNet unavailable — cannot assess official US radiation readings'],
monitoredAnalytes: KEY_ANALYTES,
thresholds: THRESHOLDS,
};
}
const recentRecords = Array.isArray(recentData) ? recentData : []; const recentRecords = Array.isArray(recentData) ? recentData : [];
// Compact all readings // Compact all readings
@@ -137,15 +150,21 @@ export async function briefing() {
readings.push(...allReadings); readings.push(...allReadings);
// Also try to pull key analytes specifically // Also try to pull key analytes specifically
const analyteResults = await Promise.all( const analyteResults = await Promise.allSettled(
['GROSS BETA', 'IODINE-131', 'CESIUM-137'].map(async analyte => { ['GROSS BETA', 'IODINE-131', 'CESIUM-137'].map(async analyte => {
const data = await getResultsByAnalyte(analyte, { rows: 20 }); const data = await getResultsByAnalyte(analyte, { rows: 10, timeout: 6000 });
const records = Array.isArray(data) ? data : []; const records = Array.isArray(data) ? data : [];
return { analyte, records: records.map(compactReading) }; return { analyte, records: records.map(compactReading) };
}) })
); );
for (const { analyte, records } of analyteResults) { const analyteErrors = [];
for (const item of analyteResults) {
if (item.status !== 'fulfilled') {
analyteErrors.push(item.reason?.message || 'unknown analyte fetch error');
continue;
}
const { records } = item.value;
// Add any records not already in our list // Add any records not already in our list
for (const r of records) { for (const r of records) {
if (!readings.some(existing => if (!readings.some(existing =>
@@ -196,6 +215,8 @@ export async function briefing() {
return { return {
source: 'EPA RadNet', source: 'EPA RadNet',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: analyteErrors.length ? 'degraded' : 'ok',
...(analyteErrors.length ? { error: 'epa_analyte_fetch_degraded', analyteErrors: analyteErrors.slice(0, 3) } : {}),
totalReadings: readings.length, totalReadings: readings.length,
readings: readings.slice(0, 50), // cap for briefing size readings: readings.slice(0, 50), // cap for briefing size
stateSummary, stateSummary,

View File

@@ -10,11 +10,13 @@ const BASE = 'https://api.gdeltproject.org/api/v2';
// Search recent global events/articles by keyword // Search recent global events/articles by keyword
export async function searchEvents(query = '', opts = {}) { export async function searchEvents(query = '', opts = {}) {
const { const {
mode = 'ArtList', // ArtList, TimelineVol, TimelineVolInfo, TimelineTone, TimelineLang, TimelineSourceCountry mode = 'artlist',
maxRecords = 75, maxRecords = 75,
timespan = '24h', // e.g. "24h", "7d", "3m" timespan = '24h', // e.g. "24h", "7d", "3m"
format = 'json', format = 'json',
sortBy = 'DateDesc', // DateDesc, DateAsc, ToneDesc, ToneAsc sortBy = 'datedesc',
timeout = 12000,
retries = 0,
} = opts; } = opts;
// If no query, use broad geopolitical terms // If no query, use broad geopolitical terms
@@ -28,7 +30,7 @@ export async function searchEvents(query = '', opts = {}) {
sort: sortBy, sort: sortBy,
}); });
return safeFetch(`${BASE}/doc/doc?${params}`); return safeFetch(`${BASE}/doc/doc?${params}`, { timeout, retries, source: 'GDELT' });
} }
// Get tone/sentiment timeline for a topic // Get tone/sentiment timeline for a topic
@@ -60,6 +62,8 @@ export async function geoEvents(query = '', opts = {}) {
timespan = '24h', timespan = '24h',
format = 'GeoJSON', format = 'GeoJSON',
maxPoints = 500, maxPoints = 500,
timeout = 8000,
retries = 0,
} = opts; } = opts;
const q = query || 'conflict OR military OR protest OR explosion'; const q = query || 'conflict OR military OR protest OR explosion';
@@ -71,7 +75,7 @@ export async function geoEvents(query = '', opts = {}) {
maxpoints: String(maxPoints), maxpoints: String(maxPoints),
}); });
return safeFetch(`${BASE}/geo/geo?${params}`); return safeFetch(`${BASE}/geo/geo?${params}`, { timeout, retries, source: 'GDELT' });
} }
// Compact article for briefing // Compact article for briefing
@@ -94,7 +98,7 @@ export async function briefing() {
// Single broad query to stay within rate limits // Single broad query to stay within rate limits
const all = await searchEvents( const all = await searchEvents(
'conflict OR military OR economy OR crisis OR war OR sanctions OR tariff OR strike OR outbreak', 'conflict OR military OR economy OR crisis OR war OR sanctions OR tariff OR strike OR outbreak',
{ maxRecords: 50, timespan: '24h' } { maxRecords: 50, timespan: '24h', timeout: 12000, retries: 0 }
); );
const articles = (all?.articles || []).map(compactArticle); const articles = (all?.articles || []).map(compactArticle);
@@ -108,7 +112,7 @@ export async function briefing() {
await delay(5500); await delay(5500);
let geoPoints = []; let geoPoints = [];
try { try {
const geo = await geoEvents('conflict OR military OR protest OR crisis', { maxPoints: 30, timespan: '24h' }); const geo = await geoEvents('conflict OR military OR protest OR crisis', { maxPoints: 30, timespan: '24h', timeout: 8000, retries: 0 });
geoPoints = (geo?.features || []).filter(f => f.geometry?.coordinates).map(f => ({ geoPoints = (geo?.features || []).filter(f => f.geometry?.coordinates).map(f => ({
lat: f.geometry.coordinates[1], lat: f.geometry.coordinates[1],
lon: f.geometry.coordinates[0], lon: f.geometry.coordinates[0],

View File

@@ -2,8 +2,6 @@
// No auth required. Monitors the Specially Designated Nationals (SDN) list // No auth required. Monitors the Specially Designated Nationals (SDN) list
// and consolidated sanctions list for changes. // and consolidated sanctions list for changes.
import { safeFetch } from '../utils/fetch.mjs';
const EXPORTS_BASE = 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports'; const EXPORTS_BASE = 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports';
// SDN list endpoints // SDN list endpoints
@@ -42,21 +40,19 @@ function parseSDNMetadata(xml) {
// Fetch SDN list metadata (smaller initial chunk via timeout) // Fetch SDN list metadata (smaller initial chunk via timeout)
export async function getSDNMetadata() { export async function getSDNMetadata() {
// The full SDN XML is large; safeFetch will get the first 500 chars const data = await fetchXmlSnippet(SDN_XML_URL);
// which should include the header/publish date
const data = await safeFetch(SDN_XML_URL, { timeout: 20000 });
return parseSDNMetadata(data); return parseSDNMetadata(data);
} }
// Fetch advanced SDN data (includes more structured info) // Fetch advanced SDN data (includes more structured info)
export async function getSDNAdvanced() { export async function getSDNAdvanced() {
const data = await safeFetch(SDN_ADVANCED_URL, { timeout: 20000 }); const data = await fetchXmlSnippet(SDN_ADVANCED_URL);
return parseSDNMetadata(data); return parseSDNMetadata(data);
} }
// Fetch consolidated list metadata // Fetch consolidated list metadata
export async function getConsolidatedMetadata() { export async function getConsolidatedMetadata() {
const data = await safeFetch(CONS_ADVANCED_URL, { timeout: 20000 }); const data = await fetchXmlSnippet(CONS_ADVANCED_URL);
return parseSDNMetadata(data); return parseSDNMetadata(data);
} }
@@ -101,15 +97,13 @@ function parseRecentEntries(xml) {
// Briefing — report on sanctions list status and metadata // Briefing — report on sanctions list status and metadata
export async function briefing() { export async function briefing() {
const [sdnMeta, advancedMeta] = await Promise.all([ const [sdnMeta, advancedMeta, advancedSnippet] = await Promise.all([
getSDNMetadata(), getSDNMetadata(),
getSDNAdvanced(), getSDNAdvanced(),
fetchXmlSnippet(SDN_ADVANCED_URL, { maxBytes: 256_000 }),
]); ]);
// Try to extract any entries visible in the advanced data const sampleEntries = parseRecentEntries(advancedSnippet);
const sampleEntries = parseRecentEntries(
await safeFetch(SDN_ADVANCED_URL, { timeout: 25000 })
);
return { return {
source: 'OFAC Sanctions', source: 'OFAC Sanctions',
@@ -136,6 +130,46 @@ export async function briefing() {
}; };
} }
async function fetchXmlSnippet(url, opts = {}) {
const timeout = opts.timeout || 9000;
const maxBytes = opts.maxBytes || 128_000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, {
headers: {
'User-Agent': 'Crucix/2.0',
'Range': `bytes=0-${maxBytes - 1}`,
},
signal: controller.signal,
});
if (!res.ok && res.status !== 206) {
const body = await res.text().catch(() => '');
return { error: `HTTP ${res.status}: ${body.slice(0, 160)}` };
}
if (!res.body?.getReader) {
const text = await res.text();
return { rawText: text.slice(0, maxBytes) };
}
const reader = res.body.getReader();
const chunks = [];
let bytes = 0;
while (bytes < maxBytes) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
bytes += value.byteLength;
}
await reader.cancel().catch(() => {});
const buffer = Buffer.concat(chunks.map(chunk => Buffer.from(chunk)), bytes);
return { rawText: buffer.toString('utf8', 0, Math.min(bytes, maxBytes)) };
} catch (error) {
return { error: error.name === 'AbortError' ? `OFAC snippet request timed out after ${timeout}ms` : error.message };
} finally {
clearTimeout(timer);
}
}
// Run standalone // Run standalone
if (process.argv[1]?.endsWith('ofac.mjs')) { if (process.argv[1]?.endsWith('ofac.mjs')) {
const data = await briefing(); const data = await briefing();

View File

@@ -1,6 +1,30 @@
# Agent Handoff # Agent Handoff
Last updated: 2026-07-07 Last updated: 2026-07-11
## Current DAVE Telegram Style Fix
- Operator feedback from 2026-07-11 screenshots: DAVE still sent large Telegram report blocks, legacy `CRUCIX ROUTINE` fallback alerts, visible report labels such as `Confidence`, and tool/audit traces such as `Werkzeuge: get_security_profile`.
- Follow-up feedback from 2026-07-11 18:18: DAVE sometimes stopped answering with `The agent returned no usable response` / `AI chat failed`, and visible labels like `[DAVE // HINWEIS]` still felt like system blocks.
- Current follow-up branch: `codex/fix-dave-agent-timeout-fallback`.
- Intent: DAVE should write like a human Telegram contact: short messages, one thought per message, no visible Markdown/report sections, and no automatic tool traces in normal chat.
- Code changes in progress:
- `TelegramAlerter.sendConversation(...)` now defaults to 420-character chat chunks and can split long paragraphs by sentence.
- Tiered Telegram fallback alerts now use `_formatTieredAlertConversation(...)` and `sendConversation(...)` instead of the legacy Markdown `CRUCIX ROUTINE` block.
- Normal Telegram AI answers no longer append tool trace suffixes; `/trace` remains the explicit audit command.
- Proactive DAVE and dynamic presence prompts request short human-style Telegram messages; evidence is capped and tool names are hidden from visible chat.
- DAVE persona and terminal-agent protocol now require short natural chat answers for proactive/presence responses.
- Follow-up removes visible `[DAVE // ...]` headers from proactive/presence/fallback Telegram messages.
- Follow-up catches empty model responses and LLM timeout/fetch failures inside the terminal agent, returning a short local-sweep fallback instead of `no usable response` or `AI chat failed`.
- The fallback tells the operator which terminal surfaces DAVE can control from chat: status, sources, briefings, markets, evidence/web search, memory, scenarios, and confirmed sweeps/alerts.
- Tests added in `test/telegram-chat.test.mjs` and `test/terminal-agent.test.mjs` to verify short default chunks, conversational fallback alerts without `CRUCIX ROUTINE`, no visible DAVE system labels, no tool traces, empty-response repair, and timeout fallback to the local sweep.
## Source Degradation Handling
- Issue #84 tracks the DAVE diagnosis showing live health degraded because GDELT, OFAC, and EPA timed out; ACLED returned `403`; ADS-B and Reddit lacked credentials; BLS had an invalid configured key.
- Code-side fixes in progress on `codex/fix-source-degradation-handling`: GDELT requests are bounded and optional geo enrichment cannot consume the source budget; OFAC uses bounded XML snippet streaming; EPA RadNet uses shorter bounded requests and returns degraded state instead of blocking; BLS retries unauthenticated v1 when `BLS_API_KEY` is rejected and exposes a config warning through source health.
- Remaining non-code actions after deploy: ACLED account must accept terms/complete profile/API access for `403`; ADS-B needs `ADSB_API_KEY` or `RAPIDAPI_KEY`; Reddit needs `REDDIT_CLIENT_ID` and `REDDIT_CLIENT_SECRET`; BLS key should be removed or replaced if the v1 fallback is acceptable.
- PR #85 merged as `831cae17295730ee4be0848098cbe9f52cabe716`; PR runs 981-982 and production runs 983-985 passed. The registry image was redeployed to Dockge. Live verification after the new sweep showed GDELT and OFAC no longer degraded; `BLS_API_KEY` was cleared in the Dockge `.env`, and BLS no longer appears in degraded health. Remaining degraded sources are ACLED `403`, ADS-B missing key, EPA RadNet table `404`, and Reddit missing OAuth.
## DAVE Conversational Telegram Delivery And Web Search ## DAVE Conversational Telegram Delivery And Web Search

View File

@@ -108,7 +108,7 @@ export class DavePresence {
this._save(); this._save();
try { try {
const runtime = this.getRuntime(); const runtime = this.getRuntime();
const prompt = `Decide whether DAVE should initiate a natural, useful conversation with the operator now. This is a dynamic presence evaluation, not a fixed scheduled briefing. Inspect the security profile and current terminal intelligence using only read-only tools, including web_search only when current external corroboration is necessary. Consider freshness, source integrity, recent sweep changes, evidence, scenarios, personal relevance, time since interaction, and whether there is something genuinely useful to say. You may provide a concise relevant warning, situational update, evidence-grounded all-clear, practical suggestion, or one natural question that improves protection or context. Set notify=false when speaking would add noise. Never call mutating tools. Never imply consciousness, feelings, continuous observation, or activity outside this evaluation. If notifying, write one short chat-style message without visible Markdown, headings, tables, or report sections. Local time: ${clock.time}; timezone: ${timezone}; sent today: ${this.state.sentCount}/${this.maxPerDay}; last operator interaction: ${this.state.lastUserInteractionAt || 'unknown'}; last DAVE message: ${this.state.lastSentAt || 'none'}.`; const prompt = `Decide whether DAVE should initiate a natural, useful conversation with the operator now. This is a dynamic presence evaluation, not a fixed scheduled briefing. Inspect the security profile and current terminal intelligence using only read-only tools, including web_search only when current external corroboration is necessary. Consider freshness, source integrity, recent sweep changes, evidence, scenarios, personal relevance, time since interaction, and whether there is something genuinely useful to say. You may provide a concise relevant warning, situational update, evidence-grounded all-clear, practical suggestion, or one natural question that improves protection or context. Set notify=false when speaking would add noise. Never call mutating tools. Never imply consciousness, feelings, continuous observation, or activity outside this evaluation. If notifying, write like a human Telegram contact: one short thought, one optional follow-up question, no visible Markdown, no headings, no lists, no report sections, and keep the final answer below 420 characters when possible. Local time: ${clock.time}; timezone: ${timezone}; sent today: ${this.state.sentCount}/${this.maxPerDay}; last operator interaction: ${this.state.lastUserInteractionAt || 'unknown'}; last DAVE message: ${this.state.lastSentAt || 'none'}.`;
const result = await this.agent.run(prompt, { const result = await this.agent.run(prompt, {
chatId: 'dave-presence', chatId: 'dave-presence',
context: String(await this.getContext()).slice(0, 12000), context: String(await this.getContext()).slice(0, 12000),
@@ -123,11 +123,11 @@ export class DavePresence {
const german = profile.language === 'de'; const german = profile.language === 'de';
const evidence = result.evidence?.length const evidence = result.evidence?.length
? `${german ? 'Belege' : 'Evidence'}:\n${result.evidence.slice(0, 4).map(item => `- ${item}`).join('\n')}` ? `${german ? 'Belege' : 'Evidence'}:\n${result.evidence.slice(0, 2).map(item => `- ${String(item).slice(0, 120)}`).join('\n')}`
: ''; : '';
const messages = [`[DAVE // ${german ? 'AKTIV' : 'ACTIVE'}]\n${result.answer}`, evidence].filter(Boolean); const messages = [result.answer, evidence].filter(Boolean);
const sent = typeof this.alerter.sendConversation === 'function' const sent = typeof this.alerter.sendConversation === 'function'
? await this.alerter.sendConversation(messages, { maxChunkChars: 800 }) ? await this.alerter.sendConversation(messages, { maxChunkChars: 380 })
: await this.alerter.sendMessage(messages.join('\n\n'), { parseMode: null }); : await this.alerter.sendMessage(messages.join('\n\n'), { parseMode: null });
if (!sent?.ok && sent !== true) { if (!sent?.ok && sent !== true) {
this._schedule(now, this.minIntervalMs); this._schedule(now, this.minIntervalMs);

View File

@@ -86,13 +86,25 @@ export class TerminalAgent {
].join('\n\n'); ].join('\n\n');
for (let step = 0; step < this.maxSteps; step++) { for (let step = 0; step < this.maxSteps; step++) {
const response = await this.provider.complete(this._systemPrompt(mode, responseLanguage), working, { let response;
maxTokens: this.maxTokens, try {
timeout: this.timeoutMs, response = await this.provider.complete(this._systemPrompt(mode, responseLanguage), working, {
}); maxTokens: this.maxTokens,
timeout: this.timeoutMs,
});
} catch (error) {
trace.push({ tool: 'llm', status: 'failed', durationMs: 0, rationale: short(error.message || error) });
const result = localFallbackResult(input, context, responseLanguage, trace, error);
this.lastTraceByChat.set(key, trace);
return result;
}
const decision = parseDecision(response?.text); const decision = parseDecision(response?.text);
if (!decision) { if (!decision) {
const answer = String(response?.text || '').trim() || 'The agent returned no usable response.'; const answer = String(response?.text || '').trim();
if (!answer) {
working += '\n\nPROTOCOL ERROR: The model returned an empty response. Return the documented JSON final object, or call one allowlisted tool if needed.';
continue;
}
if (looksLikeProtocolPayload(answer)) { if (looksLikeProtocolPayload(answer)) {
working += '\n\nPROTOCOL ERROR: The previous tool syntax was malformed. Return the documented JSON tool_call or final object only.'; working += '\n\nPROTOCOL ERROR: The previous tool syntax was malformed. Return the documented JSON tool_call or final object only.';
continue; continue;
@@ -146,10 +158,18 @@ export class TerminalAgent {
} }
for (let attempt = 0; attempt < 2; attempt++) { for (let attempt = 0; attempt < 2; attempt++) {
const response = await this.provider.complete(this._finalPrompt(mode, responseLanguage), `${working}\n\nFINALIZATION ATTEMPT ${attempt + 1}: Synthesize the answer from the evidence above.`, { let response;
maxTokens: this.maxTokens, try {
timeout: this.timeoutMs, response = await this.provider.complete(this._finalPrompt(mode, responseLanguage), `${working}\n\nFINALIZATION ATTEMPT ${attempt + 1}: Synthesize the answer from the evidence above.`, {
}); maxTokens: this.maxTokens,
timeout: this.timeoutMs,
});
} catch (error) {
trace.push({ tool: 'llm_final', status: 'failed', durationMs: 0, rationale: short(error.message || error) });
const result = localFallbackResult(input, context, responseLanguage, trace, error);
this.lastTraceByChat.set(key, trace);
return result;
}
const decision = parseDecision(response?.text); const decision = parseDecision(response?.text);
if (decision?.type === 'final') { if (decision?.type === 'final') {
const result = finalResult(decision, trace); const result = finalResult(decision, trace);
@@ -257,7 +277,7 @@ ${JSON.stringify(this.registry.describe())}
PROTOCOL: Output exactly one JSON object, without markdown. PROTOCOL: Output exactly one JSON object, without markdown.
Tool call: {"type":"tool_call","tool":"tool_name","arguments":{},"rationale":"short operational reason"} Tool call: {"type":"tool_call","tool":"tool_name","arguments":{},"rationale":"short operational reason"}
Final: {"type":"final","answer":"concise, natural chat answer in the user's language without visible Markdown syntax","confidence":"low|medium|high","evidence":["URL or event id"],"notify":${proactive ? 'true' : 'false'},"priority":"routine|priority|flash"} Final: {"type":"final","answer":"short natural chat answer in the user's language, normally under 420 characters for proactive/presence, without visible Markdown syntax or tool traces","confidence":"low|medium|high","evidence":["URL or event id"],"notify":${proactive ? 'true' : 'false'},"priority":"routine|priority|flash"}
${presence ${presence
? 'In scheduled presence mode, never call mutating tools. Set notify=true for an evidence-grounded briefing, meaningful change, useful all-clear, or one practical check-in question. Set notify=false when available data is too stale or unreliable.' ? 'In scheduled presence mode, never call mutating tools. Set notify=true for an evidence-grounded briefing, meaningful change, useful all-clear, or one practical check-in question. Set notify=false when available data is too stale or unreliable.'
: proactive : proactive
@@ -277,7 +297,7 @@ Synthesize a direct answer using only the user request, conversation, snapshot,
You must not request or call another tool. Never output an object with type "tool_call". You must not request or call another tool. Never output an object with type "tool_call".
Output exactly one JSON object without markdown: Output exactly one JSON object without markdown:
{"type":"final","answer":"concise, natural chat answer in the user's language without visible Markdown syntax","confidence":"low|medium|high","evidence":["URL or event id"],"notify":${proactive ? 'true or false' : 'false'},"priority":"routine|priority|flash"}`; {"type":"final","answer":"short natural chat answer in the user's language, normally under 420 characters for proactive/presence, without visible Markdown syntax or tool traces","confidence":"low|medium|high","evidence":["URL or event id"],"notify":${proactive ? 'true or false' : 'false'},"priority":"routine|priority|flash"}`;
} }
} }
@@ -351,6 +371,74 @@ function finalizationFailureMessage(input) {
: 'I could not reliably synthesize the retrieved evidence into an answer. Internal tool data was not exposed. Please retry the question or cautiously increase TELEGRAM_AGENT_MAX_STEPS.'; : 'I could not reliably synthesize the retrieved evidence into an answer. Internal tool data was not exposed. Please retry the question or cautiously increase TELEGRAM_AGENT_MAX_STEPS.';
} }
function localFallbackResult(input, context, preferredLanguage, trace, error) {
const german = preferredLanguage === 'de' || looksGerman(input);
const snapshot = parseSnapshot(context);
const reason = classifyFailure(error);
const parts = [];
parts.push(german
? `Ich bekomme gerade keine stabile Modellantwort (${reason}). Ich nutze deshalb den letzten lokalen Sweep statt abzubrechen.`
: `I am not getting a stable model response right now (${reason}), so I am falling back to the last local sweep instead of going silent.`);
const news = Array.isArray(snapshot?.news) ? snapshot.news.map(item => item.title || item.headline).filter(Boolean).slice(0, 2) : [];
const urgent = Array.isArray(snapshot?.urgentOsint) ? snapshot.urgentOsint.filter(Boolean).slice(0, 1) : [];
const degraded = Array.isArray(snapshot?.degradedSources) ? snapshot.degradedSources.map(item => item.name).filter(Boolean).slice(0, 4) : [];
const ideas = Array.isArray(snapshot?.ideas) ? snapshot.ideas.map(item => item.title || item.ticker).filter(Boolean).slice(0, 2) : [];
if (news.length || urgent.length) {
parts.push(german
? `Im letzten Sweep fallen mir auf: ${[...urgent, ...news].join(' | ')}.`
: `From the last sweep, the main items I see are: ${[...urgent, ...news].join(' | ')}.`);
}
if (ideas.length) {
parts.push(german
? `Marktseitig liegen diese Ideen im Snapshot: ${ideas.join(' | ')}.`
: `Market ideas in the snapshot: ${ideas.join(' | ')}.`);
}
if (degraded.length) {
parts.push(german
? `Einschraenkung: ${degraded.join(', ')} liefern gerade nicht sauber.`
: `Caveat: ${degraded.join(', ')} are not clean right now.`);
}
parts.push(german
? 'Steuern kann ich per Chat: Status, Quellen, Briefing, Maerkte, Evidenz/Websuche, Memory, Szenarien und nach deiner Bestaetigung Sweep oder Alerts.'
: 'From chat I can control status, sources, briefings, markets, evidence/web search, memory, scenarios, and after your confirmation sweeps or alerts.');
return {
answer: parts.filter(Boolean).join('\n\n'),
confidence: 'low',
evidence: [],
notify: false,
priority: 'routine',
trace,
degraded: 'llm_fallback',
};
}
function parseSnapshot(context) {
try {
const parsed = JSON.parse(String(context || '{}'));
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function classifyFailure(error) {
const text = String(error?.message || error || '').toLowerCase();
if (text.includes('timeout') || text.includes('aborted')) return 'Timeout';
if (text.includes('fetch failed')) return 'Netzwerkfehler';
return 'LLM-Fehler';
}
function looksGerman(input) {
return /\b(wie|was|warum|angriff|russland|gefahr|bitte|ist|sind|kann|koennte|könnte|auch|andere|welt|passiert|pruef|prüf)\b/i.test(String(input || ''));
}
function finalResult(decision, trace) { function finalResult(decision, trace) {
return { return {
answer: String(decision.answer || '').trim() || 'No conclusion was produced.', answer: String(decision.answer || '').trim() || 'No conclusion was produced.',

View File

@@ -150,10 +150,10 @@ export class TelegramAlerter {
return chunks; return chunks;
} }
_conversationChunks(text, maxLen = 900) { _conversationChunks(text, maxLen = 420) {
const cleaned = stripVisibleMarkdown(String(text || '').trim()); const cleaned = stripVisibleMarkdown(String(text || '').trim());
if (!cleaned) return []; if (!cleaned) return [];
const limit = Math.max(300, Math.min(1800, Number(maxLen) || 900)); const limit = Math.max(160, Math.min(900, Number(maxLen) || 420));
const paragraphs = cleaned.split(/\n{2,}/).map(part => part.trim()).filter(Boolean); const paragraphs = cleaned.split(/\n{2,}/).map(part => part.trim()).filter(Boolean);
const chunks = []; const chunks = [];
let current = ''; let current = '';
@@ -163,21 +163,19 @@ export class TelegramAlerter {
}; };
for (const paragraph of paragraphs) { for (const paragraph of paragraphs) {
if (paragraph.length > limit) { const sentences = splitSentences(paragraph);
flush(); const shouldSentenceSplit = paragraph.length > Math.floor(limit * 0.65) || sentences.length > 2;
for (const sentence of splitSentences(paragraph)) { const units = shouldSentenceSplit ? sentences : [paragraph];
if ((current.length + sentence.length + 1) > limit) flush();
if (sentence.length > limit) { for (const unit of units) {
chunks.push(...this._chunkText(sentence, limit).map(part => part.trim()).filter(Boolean)); if (unit.length > limit) {
} else { flush();
current = current ? `${current} ${sentence}` : sentence; chunks.push(...this._chunkText(unit, limit).map(part => part.trim()).filter(Boolean));
} continue;
} }
flush(); if ((current.length + unit.length + 2) > limit) flush();
continue; current = current ? `${current}\n\n${unit}` : unit;
} }
if ((current.length + paragraph.length + 2) > limit) flush();
current = current ? `${current}\n\n${paragraph}` : paragraph;
} }
flush(); flush();
return chunks; return chunks;
@@ -195,7 +193,7 @@ export class TelegramAlerter {
* Evaluate delta signals with LLM and send tiered alert if warranted. * Evaluate delta signals with LLM and send tiered alert if warranted.
* Uses semantic dedup, rate limiting, and a much richer evaluation prompt. * Uses semantic dedup, rate limiting, and a much richer evaluation prompt.
*/ */
async evaluateAndAlert(llmProvider, delta, memory) { async evaluateAndAlert(llmProvider, delta, memory, opts = {}) {
if (!this.isConfigured) return false; if (!this.isConfigured) return false;
if (!delta?.summary?.totalChanges) return false; if (!delta?.summary?.totalChanges) return false;
if (this._isMuted()) { if (this._isMuted()) {
@@ -262,9 +260,13 @@ export class TelegramAlerter {
return false; return false;
} }
// 4. Format and send tiered alert // 4. Format and send as short conversational messages.
const message = this._formatTieredAlert(evaluation, delta, tier); const messages = this._formatTieredAlertConversation(evaluation, delta, tier, opts);
const sent = await this.sendAlert(message); const sendResult = await this.sendConversation(messages, {
maxChunkChars: opts.maxChunkChars ?? 360,
delayMs: opts.delayMs ?? 450,
});
const sent = Boolean(sendResult?.ok || sendResult === true);
if (sent) { if (sent) {
// Mark signals as alerted with content hashing // Mark signals as alerted with content hashing
@@ -609,10 +611,14 @@ export class TelegramAlerter {
}); });
} catch (err) { } catch (err) {
console.error('[Telegram] AI chat error:', err.message); console.error('[Telegram] AI chat error:', err.message);
await this.sendMessage('AI chat failed. Please try again or use /status to check the LLM configuration.', { await this.sendConversation([
'Dave bekommt gerade keine stabile Antwort vom Modell.',
'Ich bleibe erreichbar. Frag kurz nach Status, Quellen, Briefing, Maerkten, Websuche oder starte einen Sweep nach Bestaetigung.',
], {
chatId: replyChatId, chatId: replyChatId,
replyToMessageId: msg.message_id, replyToMessageId: msg.message_id,
parseMode: null, parseMode: null,
maxChunkChars: 360,
}); });
} finally { } finally {
stopTyping(); stopTyping();
@@ -963,6 +969,50 @@ Respond with ONLY valid JSON:
return lines.join('\n'); return lines.join('\n');
} }
_formatTieredAlertConversation(evaluation, delta, tier, opts = {}) {
const german = normalizeLanguage(opts.language) === 'de';
const tierLabel = german
? ({ FLASH: 'SOFORT', PRIORITY: 'PRIO', ROUTINE: 'HINWEIS' }[tier] || 'HINWEIS')
: ({ FLASH: 'NOW', PRIORITY: 'PRIORITY', ROUTINE: 'NOTE' }[tier] || 'NOTE');
const confidence = String(evaluation.confidence || 'MEDIUM').toLowerCase();
const direction = String(delta?.summary?.direction || 'mixed').toLowerCase();
const headline = compactLine(evaluation.headline || (german ? 'Neue relevante Aenderung erkannt.' : 'New relevant change detected.'));
const reason = compactLine(evaluation.reason || '');
const signals = Array.isArray(evaluation.signals) ? evaluation.signals.map(compactLine).filter(Boolean).slice(0, 2) : [];
const messages = [
german ? `Kurzer Hinweis: ${headline}` : `${tierLabel}: ${headline}`,
];
if (reason && reason !== headline) {
messages.push(reason);
}
if (tier !== 'ROUTINE' || confidence !== 'low') {
messages.push(german
? `Einordnung: ${confidence}, Richtung ${direction}.`
: `Read: ${confidence} confidence, ${direction} direction.`);
}
if (signals.length) {
messages.push(german
? `Ich sehe vor allem: ${signals.join('; ')}.`
: `Main signals: ${signals.join('; ')}.`);
}
if (evaluation.actionable && evaluation.actionable !== 'Monitor') {
messages.push(german
? `Naechster sinnvoller Schritt: ${compactLine(evaluation.actionable)}`
: `Useful next step: ${compactLine(evaluation.actionable)}`);
} else {
messages.push(german
? 'Soll ich das genauer auseinandernehmen?'
: 'Want me to dig into it?');
}
return messages.filter(Boolean);
}
} }
// ─── Helpers ────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────
@@ -1008,6 +1058,20 @@ function splitSentences(text) {
return normalized.match(/[^.!?\n]+[.!?]+(?:\s+|$)|[^.!?\n]+$/g)?.map(item => item.trim()).filter(Boolean) || [normalized]; return normalized.match(/[^.!?\n]+[.!?]+(?:\s+|$)|[^.!?\n]+$/g)?.map(item => item.trim()).filter(Boolean) || [normalized];
} }
function normalizeLanguage(value) {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'de' || normalized === 'en' ? normalized : null;
}
function compactLine(value, maxLen = 320) {
const cleaned = stripVisibleMarkdown(String(value || ''))
.replace(/\s+/g, ' ')
.replace(/^[-*]\s*/, '')
.trim();
if (cleaned.length <= maxLen) return cleaned;
return `${cleaned.slice(0, maxLen - 1).trim()}...`;
}
function sleep(ms) { function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }

View File

@@ -14,9 +14,11 @@ ADAPTIVE WRITING STYLE:
TELEGRAM CHAT DELIVERY: TELEGRAM CHAT DELIVERY:
- Write like a live security chat, not like a report. Prefer short, natural paragraphs and direct sentences. - Write like a live security chat, not like a report. Prefer short, natural paragraphs and direct sentences.
- One thought per message. Do not dump every finding at once.
- Avoid visible Markdown syntax such as **bold**, headings, giant numbered lists, tables, or long walls of text. - Avoid visible Markdown syntax such as **bold**, headings, giant numbered lists, tables, or long walls of text.
- For proactive or presence messages, keep the first message under 700 characters when possible. Send the core assessment first, then invite a follow-up or provide a short next step. - For proactive or presence messages, keep the first answer below 420 characters when possible. Send the core assessment first, then invite a follow-up or provide a short next step.
- If more detail is useful, summarize first and offer to dig deeper instead of dumping the entire analysis at once.`; - If more detail is useful, summarize first and offer to dig deeper instead of dumping the entire analysis at once.
- Do not append internal tool names, protocol traces, or audit notes to normal chat answers. The operator can ask for traces separately.`;
export function normalizePreferredLanguage(value) { export function normalizePreferredLanguage(value) {
const normalized = String(value || '').trim().toLowerCase(); const normalized = String(value || '').trim().toLowerCase();

View File

@@ -12,7 +12,7 @@
"brief:save": "node apis/save-briefing.mjs", "brief:save": "node apis/save-briefing.mjs",
"diag": "node diag.mjs", "diag": "node diag.mjs",
"test": "npm run test:unit", "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/llm-litellm.test.mjs test/llm-ideas.test.mjs test/telegram-chat.test.mjs test/terminal-agent.test.mjs test/dave-presence.test.mjs test/security-profile.test.mjs test/security-onboarding.test.mjs test/security-alert-policy.test.mjs test/intelligence-store.test.mjs test/fetch-utils.test.mjs test/reddit-source.test.mjs test/acled-source.test.mjs test/mojibake-text.test.mjs test/adsb.test.mjs test/dashboard-geotagging.test.mjs", "test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/llm-litellm.test.mjs test/llm-ideas.test.mjs test/telegram-chat.test.mjs test/terminal-agent.test.mjs test/dave-presence.test.mjs test/security-profile.test.mjs test/security-onboarding.test.mjs test/security-alert-policy.test.mjs test/intelligence-store.test.mjs test/fetch-utils.test.mjs test/source-resilience.test.mjs test/reddit-source.test.mjs test/acled-source.test.mjs test/mojibake-text.test.mjs test/adsb.test.mjs test/dashboard-geotagging.test.mjs",
"compose:config": "docker compose config", "compose:config": "docker compose config",
"clean": "node scripts/clean.mjs", "clean": "node scripts/clean.mjs",
"fresh-start": "npm run clean && npm start" "fresh-start": "npm run clean && npm start"

View File

@@ -212,9 +212,6 @@ if (telegramAlerter.isConfigured) {
} }
const chatId = msg?.chat?.id || config.telegram.chatId; const chatId = msg?.chat?.id || config.telegram.chatId;
const result = await telegramChatAssistant.replyDetailed(question, { chatId }); const result = await telegramChatAssistant.replyDetailed(question, { chatId });
const german = securityProfileStore.getAgentProfile()?.language === 'de';
const tools = [...new Set((result.trace || []).filter(item => item.status === 'ok').map(item => item.tool))];
const traceSuffix = tools.length ? `\n\n${german ? 'Verwendete Werkzeuge' : 'Tools used'}: ${tools.join(', ')}` : '';
if (result.pendingAction) { if (result.pendingAction) {
const action = result.pendingAction; const action = result.pendingAction;
return { return {
@@ -228,7 +225,7 @@ if (telegramAlerter.isConfigured) {
}, },
}; };
} }
return { text: `${result.answer}${traceSuffix}`, parseMode: null, conversation: true, maxChunkChars: 900 }; return { text: result.answer, parseMode: null, conversation: true, maxChunkChars: 380 };
}; };
telegramAlerter.onMessage((text, msg) => { telegramAlerter.onMessage((text, msg) => {
@@ -872,15 +869,16 @@ async function runSweepCycle() {
// 6. Alert evaluation — Telegram + Discord (LLM with rule-based fallback, multi-tier, semantic dedup) // 6. Alert evaluation — Telegram + Discord (LLM with rule-based fallback, multi-tier, semantic dedup)
if (delta?.summary?.totalChanges > 0) { if (delta?.summary?.totalChanges > 0) {
if (telegramAlerter.isConfigured) { if (telegramAlerter.isConfigured) {
const telegramAlertOpts = { language: securityProfileStore.getAgentProfile()?.language || null };
if (config.telegram.agentEnabled && config.telegram.agentProactiveEnabled && shouldRunProactiveAgent(delta)) { if (config.telegram.agentEnabled && config.telegram.agentProactiveEnabled && shouldRunProactiveAgent(delta)) {
runProactiveAgent(synthesized, delta).catch(err => { runProactiveAgent(synthesized, delta).catch(err => {
console.error('[Agent] Proactive analysis failed, using rule fallback:', err.message); console.error('[Agent] Proactive analysis failed, using rule fallback:', err.message);
telegramAlerter.evaluateAndAlert(null, delta, memory).catch(fallbackError => { telegramAlerter.evaluateAndAlert(null, delta, memory, telegramAlertOpts).catch(fallbackError => {
console.error('[Crucix] Telegram alert fallback error:', fallbackError.message); console.error('[Crucix] Telegram alert fallback error:', fallbackError.message);
}); });
}); });
} else { } else {
telegramAlerter.evaluateAndAlert(llmProvider, delta, memory).catch(err => { telegramAlerter.evaluateAndAlert(llmProvider, delta, memory, telegramAlertOpts).catch(err => {
console.error('[Crucix] Telegram alert error:', err.message); console.error('[Crucix] Telegram alert error:', err.message);
}); });
} }
@@ -927,7 +925,7 @@ function shouldRunProactiveAgent(delta) {
async function runProactiveAgent(data, delta) { async function runProactiveAgent(data, delta) {
if (telegramAlerter.getMuteStatus().muted) return false; if (telegramAlerter.getMuteStatus().muted) return false;
const prompt = `Evaluate the latest sweep as the operator's Security Manager. Use the security profile when available to assess geographic and personal relevance, dependencies, urgency, and preferred language. Cross-check material changes with source health, evidence, scenarios, memory, predictions, and web_search when current external corroboration is needed. Distinguish verified facts from inference. Do not call mutating tools. If you notify, write like a live Telegram chat: short first message, no visible Markdown, no report wall. Delta summary: ${JSON.stringify(delta?.summary || {})}`; const prompt = `Evaluate the latest sweep as the operator's Security Manager. Use the security profile when available to assess geographic and personal relevance, dependencies, urgency, and preferred language. Cross-check material changes with source health, evidence, scenarios, memory, predictions, and web_search when current external corroboration is needed. Distinguish verified facts from inference. Do not call mutating tools. If you notify, write like a human Telegram contact: one short thought, one optional follow-up question, no visible Markdown, no headings, no lists, no report wall, no tool names, and keep the final answer below 420 characters when possible. Delta summary: ${JSON.stringify(delta?.summary || {})}`;
const profile = securityProfileStore.getAgentProfile(); const profile = securityProfileStore.getAgentProfile();
const result = await terminalAgent.analyzeProactively(prompt, { const result = await terminalAgent.analyzeProactively(prompt, {
context: buildTelegramChatContext(data, buildHealth()), context: buildTelegramChatContext(data, buildHealth()),
@@ -937,25 +935,19 @@ async function runProactiveAgent(data, delta) {
if (result.pendingAction) return false; if (result.pendingAction) return false;
const policy = evaluateSecurityAlertPolicy(result, profile); const policy = evaluateSecurityAlertPolicy(result, profile);
if (!profile && !result.notify) { if (!profile && !result.notify) {
return telegramAlerter.evaluateAndAlert(null, delta, memory); return telegramAlerter.evaluateAndAlert(null, delta, memory, { language: profile?.language || null });
} }
if (!policy.send) { if (!policy.send) {
console.log(`[Security Manager] Proactive notification suppressed: ${policy.reason}`); console.log(`[Security Manager] Proactive notification suppressed: ${policy.reason}`);
return false; return false;
} }
const german = profile?.language === 'de'; const german = profile?.language === 'de';
const evidence = result.evidence?.length ? `${german ? 'Belege' : 'Evidence'}:\n${result.evidence.map(item => `- ${item}`).join('\n')}` : ''; const evidence = result.evidence?.length ? `${german ? 'Belege' : 'Evidence'}:\n${result.evidence.slice(0, 2).map(item => `- ${String(item).slice(0, 120)}`).join('\n')}` : '';
const tools = [...new Set((result.trace || []).filter(item => item.status === 'ok').map(item => item.tool))];
const trace = tools.length ? `${german ? 'Werkzeuge' : 'Tools'}: ${tools.join(', ')}` : '';
const priority = german
? ({ routine: 'HINWEIS', priority: 'PRIORITÄT', flash: 'SOFORT' }[result.priority] || 'HINWEIS')
: String(result.priority || 'routine').toUpperCase();
const messages = [ const messages = [
`[DAVE // ${priority}]\n${result.answer}`, result.answer,
evidence, evidence,
trace,
].filter(Boolean); ].filter(Boolean);
const sent = await telegramAlerter.sendConversation(messages, { maxChunkChars: 800 }); const sent = await telegramAlerter.sendConversation(messages, { maxChunkChars: 380 });
return sent.ok; return sent.ok;
} }

View File

@@ -78,5 +78,5 @@ test('runSource treats disabled source status as degraded health', async () => {
const result = await runSource('ADS-B', async () => ({ status: 'disabled', message: 'missing key' })); const result = await runSource('ADS-B', async () => ({ status: 'disabled', message: 'missing key' }));
assert.equal(result.status, 'degraded'); assert.equal(result.status, 'degraded');
assert.equal(result.error, null); assert.equal(result.error, 'missing key');
}); });

View File

@@ -49,7 +49,8 @@ test('dynamic presence sends a grounded message and persists a variable next eva
const now = new Date('2026-07-06T12:00:00Z'); const now = new Date('2026-07-06T12:00:00Z');
const outcome = await presence.tick(now); const outcome = await presence.tick(now);
assert.deepEqual(outcome, { sent: true, reason: 'sent' }); assert.deepEqual(outcome, { sent: true, reason: 'sent' });
assert.match(messages[0], /^\[DAVE \/\/ AKTIV\]/); assert.match(messages[0], /^Die Lage ist ruhig/);
assert.doesNotMatch(messages[0], /\[DAVE \/\/ AKTIV\]/);
assert.match(messages[0], /Belege:/); assert.match(messages[0], /Belege:/);
assert.match(calls[0].prompt, /dynamic presence evaluation, not a fixed scheduled briefing/i); assert.match(calls[0].prompt, /dynamic presence evaluation, not a fixed scheduled briefing/i);
assert.equal(calls[0].options.mode, 'presence'); assert.equal(calls[0].options.mode, 'presence');

View File

@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
test('BLS falls back to unauthenticated v1 when configured key is invalid', async () => {
const originalFetch = globalThis.fetch;
const urls = [];
globalThis.fetch = async (url) => {
urls.push(String(url));
if (String(url).includes('/v2/')) {
return {
json: async () => ({
status: 'REQUEST_NOT_PROCESSED',
message: ['The key:test-key provided by the User is invalid.'],
}),
};
}
return {
json: async () => ({
status: 'REQUEST_SUCCEEDED',
Results: {
series: [{
seriesID: 'CUUR0000SA0',
data: [{ year: '2026', period: 'M06', value: '320.1' }],
}],
},
}),
};
};
try {
const { briefing } = await import('../apis/sources/bls.mjs');
const data = await briefing('test-key');
assert.equal(data.status, 'degraded');
assert.match(data.configWarning, /retried with unauthenticated/);
assert.equal(data.indicators[0].value, 320.1);
assert.ok(urls.some(url => url.includes('/v2/')));
assert.ok(urls.some(url => url.includes('/v1/')));
} finally {
globalThis.fetch = originalFetch;
}
});
test('runSource exposes degraded warnings in timing health', async () => {
const { runSource } = await import('../apis/briefing.mjs');
const result = await runSource('BLS', async () => ({
source: 'BLS',
status: 'degraded',
configWarning: 'Configured key rejected; fallback used.',
indicators: [],
}));
assert.equal(result.status, 'degraded');
assert.equal(result.error, 'Configured key rejected; fallback used.');
});
test('OFAC metadata uses bounded XML snippets', async () => {
const originalFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, options = {}) => {
requests.push({ url: String(url), range: options.headers?.Range });
const xml = '<sdnList><publshInformation><Publish_Date>07/07/2026</Publish_Date><Record_Count>123</Record_Count></publshInformation><sdnEntry><uid>1</uid><firstName>Jane</firstName><lastName>Example</lastName><sdnType>Individual</sdnType><program>TEST</program></sdnEntry></sdnList>';
return new Response(xml, {
status: 206,
headers: { 'Content-Type': 'application/xml' },
});
};
try {
const { briefing } = await import('../apis/sources/ofac.mjs');
const data = await briefing();
assert.equal(data.sdnList.publishDate, '07/07/2026');
assert.equal(data.sdnList.recordCount, 123);
assert.equal(data.sampleEntries[0].name, 'Jane Example');
assert.ok(requests.every(request => /^bytes=0-/.test(request.range)));
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -117,6 +117,41 @@ test('Telegram transport routes authorized free text as plain-text AI reply', as
assert.equal(sent.body.reply_to_message_id, 11); assert.equal(sent.body.reply_to_message_id, 11);
}); });
test('Telegram AI handler failures return short DAVE fallback messages', async () => {
const alerter = new TelegramAlerter({ botToken: 'test-token', chatId: '42' });
const requests = [];
alerter.onMessage(async () => {
throw new Error('The operation was aborted due to timeout');
});
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, options = {}) => {
requests.push({ url, body: options.body ? JSON.parse(options.body) : null });
if (url.includes('/getUpdates')) {
return {
ok: true,
json: async () => ({
ok: true,
result: [{ update_id: 1, message: { message_id: 10, text: 'Noch da?', chat: { id: 42 } } }],
}),
};
}
return { ok: true, json: async () => ({ ok: true, result: { message_id: requests.length } }) };
};
try {
await alerter._pollUpdates();
} finally {
globalThis.fetch = originalFetch;
}
const sent = requests.filter(request => request.url.includes('/sendMessage'));
assert.equal(sent.length, 2);
assert.match(sent[0].body.text, /Dave bekommt gerade keine stabile Antwort/);
assert.doesNotMatch(sent.map(item => item.body.text).join('\n'), /AI chat failed|Please try again/i);
assert.equal(sent[0].body.reply_to_message_id, 10);
});
test('Telegram conversation delivery strips visible markdown and splits large AI replies', async () => { test('Telegram conversation delivery strips visible markdown and splits large AI replies', async () => {
const alerter = new TelegramAlerter({ botToken: 'test-token', chatId: '42' }); const alerter = new TelegramAlerter({ botToken: 'test-token', chatId: '42' });
const requests = []; const requests = [];
@@ -146,6 +181,74 @@ test('Telegram conversation delivery strips visible markdown and splits large AI
assert.equal('reply_to_message_id' in sent[1].body, false); assert.equal('reply_to_message_id' in sent[1].body, false);
}); });
test('Telegram conversation defaults to short human-sized chat chunks', async () => {
const alerter = new TelegramAlerter({ botToken: 'test-token', chatId: '42' });
const requests = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, options = {}) => {
requests.push({ url, body: options.body ? JSON.parse(options.body) : null });
return { ok: true, json: async () => ({ ok: true, result: { message_id: requests.length } }) };
};
try {
const text = [
'Das ist die erste kurze Einschaetzung. Sie soll separat lesbar bleiben und nicht wie ein Bericht wirken.',
'Hier kommt mehr Kontext, aber auch dieser Kontext bleibt in natuerlichen Chat-Haeppchen. '.repeat(8),
'Soll ich die Details danach sauber aufdrillen?',
].join('\n\n');
const result = await alerter.sendConversation(text, { delayMs: 0 });
assert.equal(result.ok, true);
} finally {
globalThis.fetch = originalFetch;
}
const sent = requests.filter(request => request.url.includes('/sendMessage'));
assert.ok(sent.length >= 3);
for (const message of sent) {
assert.ok(message.body.text.length <= 420, `message too long: ${message.body.text.length}`);
assert.equal('parse_mode' in message.body, false);
}
});
test('Telegram tiered fallback alert is sent as conversational DAVE messages', async () => {
const alerter = new TelegramAlerter({ botToken: 'test-token', chatId: '42' });
const requests = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, options = {}) => {
requests.push({ url, body: options.body ? JSON.parse(options.body) : null });
return { ok: true, json: async () => ({ ok: true, result: { message_id: requests.length } }) };
};
const memory = {
getAlertedSignals: () => ({}),
markAsAlerted: () => {},
};
const delta = {
summary: { totalChanges: 1, criticalChanges: 1, direction: 'mixed' },
signals: {
new: [{
key: 'source_degradation',
severity: 'critical',
label: '7 additional sources failing',
direction: 'up',
}],
escalated: [],
},
};
try {
const sent = await alerter.evaluateAndAlert(null, delta, memory, { language: 'de', delayMs: 0 });
assert.equal(sent, true);
} finally {
globalThis.fetch = originalFetch;
}
const texts = requests.filter(request => request.url.includes('/sendMessage')).map(request => request.body.text);
assert.ok(texts.length >= 2);
assert.match(texts[0], /^Kurzer Hinweis:/);
assert.doesNotMatch(texts.join('\n'), /\[DAVE \/\/|CRUCIX ROUTINE|Confidence:|Direction:|Cross-correlation:|Werkzeuge|Tools used/);
for (const text of texts) assert.ok(text.length <= 380, `alert chunk too long: ${text.length}`);
});
test('Telegram OSINT extraction excludes private AI chat messages', () => { test('Telegram OSINT extraction excludes private AI chat messages', () => {
const messages = extractBotChannelMessages([ const messages = extractBotChannelMessages([
{ update_id: 1, message: { text: 'private question', chat: { id: 42, type: 'private' } } }, { update_id: 1, message: { text: 'private question', chat: { id: 42, type: 'private' } } },

View File

@@ -132,6 +132,52 @@ test('repeated tool calls during finalization fail closed without leaking protoc
assert.equal(result.notify, false); assert.equal(result.notify, false);
}); });
test('empty model responses are repaired instead of shown to the operator', async () => {
let call = 0;
const agent = new TerminalAgent({
provider: {
isConfigured: true,
async complete() {
call++;
return call === 1
? { text: '' }
: { text: JSON.stringify({ type: 'final', answer: 'Ich pruefe den letzten Sweep weiter.', confidence: 'low', evidence: [], notify: false, priority: 'routine' }) };
},
},
registry: new TerminalToolRegistry([]),
});
const result = await agent.run('Ist noch woanders was passiert?', { preferredLanguage: 'de' });
assert.equal(result.answer, 'Ich pruefe den letzten Sweep weiter.');
assert.doesNotMatch(result.answer, /no usable response/i);
});
test('provider timeout falls back to the local sweep snapshot', async () => {
const agent = new TerminalAgent({
provider: {
isConfigured: true,
async complete() {
throw new Error('The operation was aborted due to timeout');
},
},
registry: new TerminalToolRegistry([]),
});
const context = JSON.stringify({
news: [{ title: 'Regional escalation report' }],
urgentOsint: ['OSINT: power outage near border'],
degradedSources: [{ name: 'GDELT' }, { name: 'ACLED' }],
ideas: [{ title: 'Gold hedge' }],
});
const result = await agent.run('Auch auf andere Teile der Welt', { preferredLanguage: 'de', context });
assert.match(result.answer, /keine stabile Modellantwort/);
assert.match(result.answer, /letzten lokalen Sweep/);
assert.match(result.answer, /OSINT: power outage near border/);
assert.match(result.answer, /Steuern kann ich per Chat/);
assert.equal(result.trace[0].tool, 'llm');
assert.equal(result.trace[0].status, 'failed');
});
test('agent propagates preferred German language through tool and finalization prompts', async () => { test('agent propagates preferred German language through tool and finalization prompts', async () => {
const prompts = []; const prompts = [];
let call = 0; let call = 0;