Compare commits

..

7 Commits

Author SHA1 Message Date
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
cd19c857f3 feat: make DAVE Telegram chat conversational (#83)
All checks were successful
Codex Template Compliance / template-compliance (push) Successful in 6s
Release Dry Run / release-dry-run (push) Successful in 15s
Build / test-and-image (push) Successful in 30s
Closes #82.
2026-07-07 16:49:49 +00:00
8576a71b4f Merge pull request 'docs: record DAVE profile language fix' (#81) from codex/handoff-profile-language-enforcement into codex/production-intelligence-terminal
Some checks failed
Codex Template Compliance / template-compliance (push) Successful in 7s
Release Dry Run / release-dry-run (push) Successful in 17s
Build / test-and-image (push) Successful in 29s
Scheduled Dependency Check / dependency-check (push) Failing after 13s
2026-07-06 07:20:17 +00:00
16 changed files with 378 additions and 73 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.
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)
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 degradedStatuses = ['no_credentials', 'no_key', 'disabled', 'degraded', 'failed', 'error'];
const isDegraded = hasError || degradedStatuses.includes(data?.status);
const error = data?.error || data?.configWarning || data?.message || null;
return {
name,
status: isDegraded ? 'degraded' : 'ok',
durationMs: Date.now() - start,
data,
error: hasError ? data.error : null,
error: isDegraded ? error : null,
};
} catch (e) {
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
function latestFromSeries(seriesData) {
if (!seriesData?.data?.length) return null;
@@ -95,17 +100,25 @@ function momChange(seriesData) {
// Briefing — pull latest CPI, unemployment, payrolls
export async function briefing(apiKey) {
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) {
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) {
return {
source: 'BLS',
status: 'degraded',
error: resp.message?.[0] || 'BLS API returned no data',
rawStatus: resp.status,
configWarning,
timestamp: new Date().toISOString(),
};
}
@@ -155,6 +168,7 @@ export async function briefing(apiKey) {
return {
source: 'BLS',
timestamp: new Date().toISOString(),
...(configWarning ? { status: 'degraded', configWarning } : { status: 'ok' }),
indicators,
signals,
};

View File

@@ -50,29 +50,29 @@ const THRESHOLDS = {
// Get recent RadNet analytical results (JSON)
export async function getAnalyticalResults(opts = {}) {
const { rows = 50, startRow = 0 } = opts;
const { rows = 50, startRow = 0, timeout = 10000 } = opts;
return safeFetch(
`${RADNET_ANALYTICAL}/ROWS/${startRow}:${startRow + rows}/JSON`,
{ timeout: 25000 }
{ timeout, retries: 0, source: 'EPA RadNet' }
);
}
// Get results filtered by state
export async function getResultsByState(state, opts = {}) {
const { rows = 25, startRow = 0 } = opts;
const { rows = 25, startRow = 0, timeout = 8000 } = opts;
return safeFetch(
`${RADNET_ANALYTICAL}/ANA_STATE/${state}/ROWS/${startRow}:${startRow + rows}/JSON`,
{ timeout: 25000 }
{ timeout, retries: 0, source: 'EPA RadNet' }
);
}
// Get results filtered by analyte type
export async function getResultsByAnalyte(analyte, opts = {}) {
const { rows = 25, startRow = 0 } = opts;
const { rows = 25, startRow = 0, timeout = 8000 } = opts;
const encoded = encodeURIComponent(analyte);
return safeFetch(
`${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 = [];
// 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 : [];
// Compact all readings
@@ -137,15 +150,21 @@ export async function briefing() {
readings.push(...allReadings);
// 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 => {
const data = await getResultsByAnalyte(analyte, { rows: 20 });
const data = await getResultsByAnalyte(analyte, { rows: 10, timeout: 6000 });
const records = Array.isArray(data) ? data : [];
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
for (const r of records) {
if (!readings.some(existing =>
@@ -196,6 +215,8 @@ export async function briefing() {
return {
source: 'EPA RadNet',
timestamp: new Date().toISOString(),
status: analyteErrors.length ? 'degraded' : 'ok',
...(analyteErrors.length ? { error: 'epa_analyte_fetch_degraded', analyteErrors: analyteErrors.slice(0, 3) } : {}),
totalReadings: readings.length,
readings: readings.slice(0, 50), // cap for briefing size
stateSummary,

View File

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

View File

@@ -2,8 +2,6 @@
// No auth required. Monitors the Specially Designated Nationals (SDN) list
// and consolidated sanctions list for changes.
import { safeFetch } from '../utils/fetch.mjs';
const EXPORTS_BASE = 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports';
// SDN list endpoints
@@ -42,21 +40,19 @@ function parseSDNMetadata(xml) {
// Fetch SDN list metadata (smaller initial chunk via timeout)
export async function getSDNMetadata() {
// The full SDN XML is large; safeFetch will get the first 500 chars
// which should include the header/publish date
const data = await safeFetch(SDN_XML_URL, { timeout: 20000 });
const data = await fetchXmlSnippet(SDN_XML_URL);
return parseSDNMetadata(data);
}
// Fetch advanced SDN data (includes more structured info)
export async function getSDNAdvanced() {
const data = await safeFetch(SDN_ADVANCED_URL, { timeout: 20000 });
const data = await fetchXmlSnippet(SDN_ADVANCED_URL);
return parseSDNMetadata(data);
}
// Fetch consolidated list metadata
export async function getConsolidatedMetadata() {
const data = await safeFetch(CONS_ADVANCED_URL, { timeout: 20000 });
const data = await fetchXmlSnippet(CONS_ADVANCED_URL);
return parseSDNMetadata(data);
}
@@ -101,15 +97,13 @@ function parseRecentEntries(xml) {
// Briefing — report on sanctions list status and metadata
export async function briefing() {
const [sdnMeta, advancedMeta] = await Promise.all([
const [sdnMeta, advancedMeta, advancedSnippet] = await Promise.all([
getSDNMetadata(),
getSDNAdvanced(),
fetchXmlSnippet(SDN_ADVANCED_URL, { maxBytes: 256_000 }),
]);
// Try to extract any entries visible in the advanced data
const sampleEntries = parseRecentEntries(
await safeFetch(SDN_ADVANCED_URL, { timeout: 25000 })
);
const sampleEntries = parseRecentEntries(advancedSnippet);
return {
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
if (process.argv[1]?.endsWith('ofac.mjs')) {
const data = await briefing();

View File

@@ -1,6 +1,26 @@
# 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`.
- Active branch: `codex/fix-dave-telegram-human-style`.
- 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.
- Tests added in `test/telegram-chat.test.mjs` to verify short default chunks and conversational fallback alerts without `CRUCIX ROUTINE`, `Confidence:`, `Direction:`, or visible tool traces.
## 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

View File

@@ -108,7 +108,7 @@ export class DavePresence {
this._save();
try {
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, {
chatId: 'dave-presence',
context: String(await this.getContext()).slice(0, 12000),
@@ -123,11 +123,11 @@ export class DavePresence {
const german = profile.language === 'de';
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 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 });
if (!sent?.ok && sent !== true) {
this._schedule(now, this.minIntervalMs);

View File

@@ -257,7 +257,7 @@ ${JSON.stringify(this.registry.describe())}
PROTOCOL: Output exactly one JSON object, without markdown.
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
? '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
@@ -277,7 +277,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".
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"}`;
}
}

View File

@@ -150,10 +150,10 @@ export class TelegramAlerter {
return chunks;
}
_conversationChunks(text, maxLen = 900) {
_conversationChunks(text, maxLen = 420) {
const cleaned = stripVisibleMarkdown(String(text || '').trim());
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 chunks = [];
let current = '';
@@ -163,21 +163,19 @@ export class TelegramAlerter {
};
for (const paragraph of paragraphs) {
if (paragraph.length > limit) {
flush();
for (const sentence of splitSentences(paragraph)) {
if ((current.length + sentence.length + 1) > limit) flush();
if (sentence.length > limit) {
chunks.push(...this._chunkText(sentence, limit).map(part => part.trim()).filter(Boolean));
} else {
current = current ? `${current} ${sentence}` : sentence;
}
const sentences = splitSentences(paragraph);
const shouldSentenceSplit = paragraph.length > Math.floor(limit * 0.65) || sentences.length > 2;
const units = shouldSentenceSplit ? sentences : [paragraph];
for (const unit of units) {
if (unit.length > limit) {
flush();
chunks.push(...this._chunkText(unit, limit).map(part => part.trim()).filter(Boolean));
continue;
}
flush();
continue;
if ((current.length + unit.length + 2) > limit) flush();
current = current ? `${current}\n\n${unit}` : unit;
}
if ((current.length + paragraph.length + 2) > limit) flush();
current = current ? `${current}\n\n${paragraph}` : paragraph;
}
flush();
return chunks;
@@ -195,7 +193,7 @@ export class TelegramAlerter {
* Evaluate delta signals with LLM and send tiered alert if warranted.
* 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 (!delta?.summary?.totalChanges) return false;
if (this._isMuted()) {
@@ -262,9 +260,13 @@ export class TelegramAlerter {
return false;
}
// 4. Format and send tiered alert
const message = this._formatTieredAlert(evaluation, delta, tier);
const sent = await this.sendAlert(message);
// 4. Format and send as short conversational messages.
const messages = this._formatTieredAlertConversation(evaluation, delta, tier, opts);
const sendResult = await this.sendConversation(messages, {
maxChunkChars: opts.maxChunkChars ?? 360,
delayMs: opts.delayMs ?? 450,
});
const sent = Boolean(sendResult?.ok || sendResult === true);
if (sent) {
// Mark signals as alerted with content hashing
@@ -963,6 +965,50 @@ Respond with ONLY valid JSON:
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 = [
`[DAVE // ${tierLabel}]\n${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 ──────────────────────────────────────────────────────────────
@@ -1008,6 +1054,20 @@ function splitSentences(text) {
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) {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@@ -14,9 +14,11 @@ ADAPTIVE WRITING STYLE:
TELEGRAM CHAT DELIVERY:
- 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.
- 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.
- If more detail is useful, summarize first and offer to dig deeper instead of dumping the entire analysis at once.`;
- 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.
- 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) {
const normalized = String(value || '').trim().toLowerCase();

View File

@@ -12,7 +12,7 @@
"brief:save": "node apis/save-briefing.mjs",
"diag": "node diag.mjs",
"test": "npm run test:unit",
"test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/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",
"clean": "node scripts/clean.mjs",
"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 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) {
const action = result.pendingAction;
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) => {
@@ -872,15 +869,16 @@ async function runSweepCycle() {
// 6. Alert evaluation — Telegram + Discord (LLM with rule-based fallback, multi-tier, semantic dedup)
if (delta?.summary?.totalChanges > 0) {
if (telegramAlerter.isConfigured) {
const telegramAlertOpts = { language: securityProfileStore.getAgentProfile()?.language || null };
if (config.telegram.agentEnabled && config.telegram.agentProactiveEnabled && shouldRunProactiveAgent(delta)) {
runProactiveAgent(synthesized, delta).catch(err => {
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);
});
});
} else {
telegramAlerter.evaluateAndAlert(llmProvider, delta, memory).catch(err => {
telegramAlerter.evaluateAndAlert(llmProvider, delta, memory, telegramAlertOpts).catch(err => {
console.error('[Crucix] Telegram alert error:', err.message);
});
}
@@ -927,7 +925,7 @@ function shouldRunProactiveAgent(delta) {
async function runProactiveAgent(data, delta) {
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 result = await terminalAgent.analyzeProactively(prompt, {
context: buildTelegramChatContext(data, buildHealth()),
@@ -937,25 +935,22 @@ async function runProactiveAgent(data, delta) {
if (result.pendingAction) return false;
const policy = evaluateSecurityAlertPolicy(result, profile);
if (!profile && !result.notify) {
return telegramAlerter.evaluateAndAlert(null, delta, memory);
return telegramAlerter.evaluateAndAlert(null, delta, memory, { language: profile?.language || null });
}
if (!policy.send) {
console.log(`[Security Manager] Proactive notification suppressed: ${policy.reason}`);
return false;
}
const german = profile?.language === 'de';
const evidence = result.evidence?.length ? `${german ? 'Belege' : 'Evidence'}:\n${result.evidence.map(item => `- ${item}`).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 evidence = result.evidence?.length ? `${german ? 'Belege' : 'Evidence'}:\n${result.evidence.slice(0, 2).map(item => `- ${String(item).slice(0, 120)}`).join('\n')}` : '';
const priority = german
? ({ routine: 'HINWEIS', priority: 'PRIORITÄT', flash: 'SOFORT' }[result.priority] || 'HINWEIS')
: String(result.priority || 'routine').toUpperCase();
const messages = [
`[DAVE // ${priority}]\n${result.answer}`,
evidence,
trace,
].filter(Boolean);
const sent = await telegramAlerter.sendConversation(messages, { maxChunkChars: 800 });
const sent = await telegramAlerter.sendConversation(messages, { maxChunkChars: 380 });
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' }));
assert.equal(result.status, 'degraded');
assert.equal(result.error, null);
assert.equal(result.error, 'missing key');
});

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

@@ -146,6 +146,74 @@ test('Telegram conversation delivery strips visible markdown and splits large AI
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], /^\[DAVE \/\/ HINWEIS\]/);
assert.doesNotMatch(texts.join('\n'), /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', () => {
const messages = extractBotChannelMessages([
{ update_id: 1, message: { text: 'private question', chat: { id: 42, type: 'private' } } },