Compare commits
7 Commits
codex/issu
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
| 1178146d59 | |||
| 8de9feda62 | |||
| 9a67b160ef | |||
| 831cae1729 | |||
| 62d9c680b7 | |||
| cd19c857f3 | |||
| 8576a71b4f |
@@ -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`.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
21
server.mjs
21
server.mjs
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
78
test/source-resilience.test.mjs
Normal file
78
test/source-resilience.test.mjs
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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' } } },
|
||||
|
||||
Reference in New Issue
Block a user