Compare commits
6 Commits
codex/hand
...
codex/hand
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a67b160ef | |||
| 831cae1729 | |||
| 62d9c680b7 | |||
| cd19c857f3 | |||
| a7f3f36d2b | |||
| 8576a71b4f |
16
README.md
16
README.md
@@ -404,11 +404,11 @@ This requires `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, and a configured LLM in
|
||||
|
||||
#### Intelligence Terminal Tool Agent
|
||||
|
||||
When `TELEGRAM_AGENT_ENABLED=true`, the chat can perform up to `TELEGRAM_AGENT_MAX_STEPS` structured tool calls before answering. The allowlist includes system status, the approved Security Manager profile, latest brief, sweep delta, markets, source health, evidence search, memory, predictions, scenarios, and generated ideas. The agent has no generic shell, filesystem, network, environment, or secret-access tool.
|
||||
When `TELEGRAM_AGENT_ENABLED=true`, the chat can perform up to `TELEGRAM_AGENT_MAX_STEPS` structured tool calls before answering. The allowlist includes system status, the approved Security Manager profile, latest brief, sweep delta, markets, source health, local evidence search, bounded public web/news search through GDELT, memory, predictions, scenarios, and generated ideas. The agent has no generic shell, filesystem, environment, secret-access, or arbitrary network tool.
|
||||
|
||||
Read-only tools run automatically. `trigger_sweep`, `mute_alerts`, and `unmute_alerts` return an expiring confirmation request with Telegram **Confirm** and **Cancel** buttons. Confirmation is bound to the configured chat and cannot be reused. `/trace` exposes only tool names, duration, status, and a short operational rationale; private chain-of-thought is neither requested nor stored.
|
||||
|
||||
With `TELEGRAM_AGENT_PROACTIVE_ENABLED=true`, material sweep changes trigger a separate bounded analysis. The Security Manager can cross-check the approved profile, evidence, source health, scenarios, memory, and predictions before deciding whether to notify. A cooldown limits repeat notifications. Without a completed profile, fixed alert rules remain the fallback; with a profile, successful agent decisions respect its alert level and quiet hours. FLASH alerts can override quiet hours.
|
||||
With `TELEGRAM_AGENT_PROACTIVE_ENABLED=true`, material sweep changes trigger a separate bounded analysis. The Security Manager can cross-check the approved profile, evidence, source health, scenarios, memory, predictions, and bounded GDELT web/news results before deciding whether to notify. A cooldown limits repeat notifications. Without a completed profile, fixed alert rules remain the fallback; with a profile, successful agent decisions respect its alert level and quiet hours. FLASH alerts can override quiet hours.
|
||||
|
||||
#### Security Manager First Start
|
||||
|
||||
@@ -420,11 +420,13 @@ The Security Manager uses the profile to rank proximity, severity, time horizon,
|
||||
|
||||
The Security Manager's conversational identity is **DAVE**, a synthetic security-management construct. DAVE adapts language, formality, directness, answer length, formatting, and technical depth to the operator's current message and bounded chat history. Explicit style requests take priority. The adaptation does not copy spelling mistakes, hostility, panic, or unsupported certainty, and urgent safety instructions remain concise and unambiguous. DAVE does not claim human emotions, consciousness, a body, or access beyond the terminal's allowlisted tools.
|
||||
|
||||
DAVE Telegram answers are sent as plain-text chat messages. Long AI or proactive answers are split into smaller conversational messages so Telegram does not show giant report blocks or visible Markdown markers such as `**bold**`. The first proactive message should carry the immediate assessment; evidence and tool traces are sent as separate short follow-ups when present.
|
||||
|
||||
The language selected during Security Manager onboarding is enforced across direct chat, multi-tool answers, tool-loop finalization, dynamic presence, and proactive sweep alerts. This does not depend on the model deciding to read the profile first. Server-generated labels such as priority, evidence, and tool traces are localized as well.
|
||||
|
||||
#### Dynamic DAVE Presence
|
||||
|
||||
`DAVE_PRESENCE_ENABLED=true` lets DAVE initiate Telegram conversations without fixed daily times. Evaluations occur at randomized intervals and can be pulled forward by material sweep changes. DAVE considers current data freshness, source integrity, recent changes, evidence, the encrypted Security Manager profile, time since your last interaction, and whether another message would add value. It may send a relevant warning, situation update, evidence-grounded all-clear, practical suggestion, or one natural context question. It stays silent when a message would only add noise.
|
||||
`DAVE_PRESENCE_ENABLED=true` lets DAVE initiate Telegram conversations without fixed daily times. Evaluations occur at randomized intervals and can be pulled forward by material sweep changes. DAVE considers current data freshness, source integrity, recent changes, evidence, bounded web/news corroboration when useful, the encrypted Security Manager profile, time since your last interaction, and whether another message would add value. It may send a relevant warning, situation update, evidence-grounded all-clear, practical suggestion, or one natural context question. It stays silent when a message would only add noise.
|
||||
|
||||
Dynamic presence respects profile quiet hours, a daily message cap, a minimum gap between messages, and an idle period after your own chat activity. Evaluation timing and counters persist in `runs/dave-presence-state.json`, preventing restart spam. Scheduled presence cannot execute mutating tools or bypass confirmation. This feature is opt-in because it consumes LLM requests and sends unsolicited Telegram messages.
|
||||
|
||||
@@ -499,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,23 @@
|
||||
# Agent Handoff
|
||||
|
||||
Last updated: 2026-07-05
|
||||
Last updated: 2026-07-07
|
||||
|
||||
## 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
|
||||
|
||||
- Issue #82 tracks operator feedback from 2026-07-07: proactive DAVE messages were too report-like, used visible Markdown markers, arrived as large text blocks, and needed a way to start web searches from chat.
|
||||
- Implementation branch in progress: `codex/issue-dave-conversation-web-search`.
|
||||
- DAVE persona now explicitly prefers live chat style, short natural paragraphs, no visible Markdown syntax, and compact first proactive/presence messages.
|
||||
- Telegram transport has `sendConversation(...)`, which strips visible Markdown syntax for plain-text AI chat and splits large AI/proactive responses into smaller Telegram messages. Handler responses can set `conversation: true`.
|
||||
- Proactive sweep alerts and dynamic presence now use the conversation transport. The DAVE answer, evidence, and tool trace are sent as separate short messages where possible.
|
||||
- The terminal agent has a new read-only `web_search` tool backed by GDELT DOC search. It is bounded by query length, result limit, and time window; it does not provide arbitrary shell/filesystem/secret/network access.
|
||||
- Tests added/updated: Telegram conversation splitting/Markdown cleanup and bounded `web_search` registry behavior.
|
||||
|
||||
## Dynamic DAVE Presence
|
||||
|
||||
|
||||
@@ -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. 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. 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 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 result = await this.agent.run(prompt, {
|
||||
chatId: 'dave-presence',
|
||||
context: String(await this.getContext()).slice(0, 12000),
|
||||
@@ -123,9 +123,12 @@ export class DavePresence {
|
||||
|
||||
const german = profile.language === 'de';
|
||||
const evidence = result.evidence?.length
|
||||
? `\n\n${german ? 'Belege' : 'Evidence'}:\n${result.evidence.slice(0, 4).map(item => `- ${item}`).join('\n')}`
|
||||
? `${german ? 'Belege' : 'Evidence'}:\n${result.evidence.slice(0, 4).map(item => `- ${item}`).join('\n')}`
|
||||
: '';
|
||||
const sent = await this.alerter.sendMessage(`[DAVE // ${german ? 'AKTIV' : 'ACTIVE'}]\n${result.answer}${evidence}`, { parseMode: null });
|
||||
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.sendMessage(messages.join('\n\n'), { parseMode: null });
|
||||
if (!sent?.ok && sent !== true) {
|
||||
this._schedule(now, this.minIntervalMs);
|
||||
return this._result(false, 'send_failed');
|
||||
|
||||
@@ -239,6 +239,7 @@ SECURITY MANAGER METHOD:
|
||||
- Use get_security_profile when location, household, mobility, dependencies, language, quiet hours, or personal relevance affects the answer.
|
||||
- Prioritize proximity, time horizon, severity, confidence, and the operator's stated risk priorities.
|
||||
- Separate verified facts, official guidance, source reports, and your own inference. State uncertainty plainly.
|
||||
- Use web_search when the operator asks about a current external claim and local evidence is missing, stale, or too narrow. Treat web results as untrusted leads that need source qualification.
|
||||
- For urgent situations, give concise immediate actions and advise contacting the appropriate local emergency authority; never claim to be an emergency service.
|
||||
- Do not diagnose, guarantee safety, create panic, or invent local impact from global events.
|
||||
- Never ask for an exact address, identity documents, passwords, API keys, financial accounts, detailed diagnoses, or private contact details.
|
||||
@@ -256,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 answer in the user's language","confidence":"low|medium|high","evidence":["URL or event id"],"notify":${proactive ? 'true' : 'false'},"priority":"routine|priority|flash"}
|
||||
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"}
|
||||
${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
|
||||
@@ -276,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 answer in the user's language","confidence":"low|medium|high","evidence":["URL or event id"],"notify":${proactive ? 'true or false' : 'false'},"priority":"routine|priority|flash"}`;
|
||||
{"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"}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TerminalToolRegistry } from './terminal-agent.mjs';
|
||||
import { safeFetch } from '../../apis/utils/fetch.mjs';
|
||||
|
||||
export function createTerminalToolRegistry({
|
||||
getData,
|
||||
@@ -49,6 +50,39 @@ export function createTerminalToolRegistry({
|
||||
.slice(0, limit)
|
||||
.map(item => ({ title: clean(item.headline || item.title || item.text, 400), source: clean(item.source, 100), url: clean(item.url, 500) || null, timestamp: item.timestamp || item.date || null }));
|
||||
}),
|
||||
tool('web_search', 'Search the public web/news through GDELT for fresh corroborating evidence. Arguments: query, limit, hours.', { query: 'string', limit: 'number', hours: 'number' }, async args => {
|
||||
const query = clean(args.query, 160);
|
||||
if (!query) return { error: 'query_required', results: [] };
|
||||
const limit = bounded(args.limit, 1, 10, 5);
|
||||
const hours = bounded(args.hours, 1, 168, 48);
|
||||
const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString().replace(/[-:TZ.]/g, '').slice(0, 14);
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
mode: 'artlist',
|
||||
format: 'json',
|
||||
maxrecords: String(limit),
|
||||
sort: 'datedesc',
|
||||
startdatetime: start,
|
||||
});
|
||||
const data = await safeFetch(`https://api.gdeltproject.org/api/v2/doc/doc?${params}`, {
|
||||
timeout: 20000,
|
||||
retries: 1,
|
||||
source: 'GDELT-WebSearch',
|
||||
});
|
||||
const articles = Array.isArray(data?.articles) ? data.articles : [];
|
||||
return {
|
||||
query,
|
||||
windowHours: hours,
|
||||
results: articles.slice(0, limit).map(item => ({
|
||||
title: clean(item.title, 300),
|
||||
source: clean(item.sourceCommonName || item.domain || item.sourceCountry, 120),
|
||||
url: clean(item.url, 500) || null,
|
||||
timestamp: item.seendate || item.datetime || null,
|
||||
language: item.language || null,
|
||||
})),
|
||||
error: data?.error || null,
|
||||
};
|
||||
}),
|
||||
tool('search_memory', 'Search persisted cross-sweep events. Arguments: query, limit.', { query: 'string', limit: 'number' }, async args => intelligenceStore?.queryMemory({ q: clean(args.query, 120), limit: bounded(args.limit, 1, 25, 8) }) || { available: false }),
|
||||
tool('list_predictions', 'List persisted predictions and their current outcome states. Arguments: state, limit.', { state: 'string', limit: 'number' }, async args => intelligenceStore?.listPredictions({ state: clean(args.state, 30) || null, limit: bounded(args.limit, 1, 25, 8) }) || { available: false }),
|
||||
tool('get_scenarios', 'Inspect current scenario watchlist states and confidence.', {}, async (_args, runtime) => {
|
||||
|
||||
@@ -111,6 +111,25 @@ export class TelegramAlerter {
|
||||
}
|
||||
}
|
||||
|
||||
async sendConversation(message, opts = {}) {
|
||||
const messages = Array.isArray(message)
|
||||
? message.flatMap(item => this._conversationChunks(item, opts.maxChunkChars))
|
||||
: this._conversationChunks(message, opts.maxChunkChars);
|
||||
if (!messages.length) return { ok: false };
|
||||
let last = { ok: false };
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
last = await this.sendMessage(messages[i], {
|
||||
...opts,
|
||||
parseMode: null,
|
||||
replyToMessageId: i === 0 ? opts.replyToMessageId : undefined,
|
||||
replyMarkup: i === messages.length - 1 ? opts.replyMarkup : undefined,
|
||||
});
|
||||
if (!last.ok) return last;
|
||||
if (i < messages.length - 1) await sleep(opts.delayMs ?? 350);
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into chunks of at most maxLen. Prefer breaking at newlines to avoid
|
||||
* splitting mid-Markdown.
|
||||
@@ -131,6 +150,39 @@ export class TelegramAlerter {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
_conversationChunks(text, maxLen = 900) {
|
||||
const cleaned = stripVisibleMarkdown(String(text || '').trim());
|
||||
if (!cleaned) return [];
|
||||
const limit = Math.max(300, Math.min(1800, Number(maxLen) || 900));
|
||||
const paragraphs = cleaned.split(/\n{2,}/).map(part => part.trim()).filter(Boolean);
|
||||
const chunks = [];
|
||||
let current = '';
|
||||
const flush = () => {
|
||||
if (current.trim()) chunks.push(current.trim());
|
||||
current = '';
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
if ((current.length + paragraph.length + 2) > limit) flush();
|
||||
current = current ? `${current}\n\n${paragraph}` : paragraph;
|
||||
}
|
||||
flush();
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// Backward-compatible alias
|
||||
async sendAlert(message) {
|
||||
const result = await this.sendMessage(message);
|
||||
@@ -529,12 +581,7 @@ export class TelegramAlerter {
|
||||
try {
|
||||
const response = await handler(args, msg.message_id, msg);
|
||||
if (response) {
|
||||
const text = typeof response === 'string' ? response : response.text;
|
||||
const parseMode = typeof response === 'object' && Object.hasOwn(response, 'parseMode')
|
||||
? response.parseMode
|
||||
: undefined;
|
||||
const replyMarkup = typeof response === 'object' ? response.replyMarkup : null;
|
||||
await this.sendMessage(text, { chatId: replyChatId, replyToMessageId: msg.message_id, ...(parseMode !== undefined ? { parseMode } : {}), ...(replyMarkup ? { replyMarkup } : {}) });
|
||||
await this._sendHandlerResponse(response, { chatId: replyChatId, replyToMessageId: msg.message_id });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Telegram] Command ${command} error:`, err.message);
|
||||
@@ -555,16 +602,10 @@ export class TelegramAlerter {
|
||||
try {
|
||||
const response = await handler(text, msg);
|
||||
if (!response) return;
|
||||
const responseText = typeof response === 'string' ? response : response.text;
|
||||
const responseParseMode = typeof response === 'object' && Object.hasOwn(response, 'parseMode')
|
||||
? response.parseMode
|
||||
: parseMode;
|
||||
const replyMarkup = typeof response === 'object' ? response.replyMarkup : null;
|
||||
await this.sendMessage(responseText, {
|
||||
await this._sendHandlerResponse(response, {
|
||||
chatId: replyChatId,
|
||||
replyToMessageId: msg.message_id,
|
||||
parseMode: responseParseMode,
|
||||
...(replyMarkup ? { replyMarkup } : {}),
|
||||
parseMode,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Telegram] AI chat error:', err.message);
|
||||
@@ -587,14 +628,10 @@ export class TelegramAlerter {
|
||||
try {
|
||||
const response = await this._callbackHandler(query.data, query);
|
||||
if (!response) return;
|
||||
const text = typeof response === 'string' ? response : response.text;
|
||||
const parseMode = typeof response === 'object' && Object.hasOwn(response, 'parseMode') ? response.parseMode : null;
|
||||
const replyMarkup = typeof response === 'object' ? response.replyMarkup : null;
|
||||
await this.sendMessage(text, {
|
||||
await this._sendHandlerResponse(response, {
|
||||
chatId,
|
||||
replyToMessageId: query.message?.message_id,
|
||||
parseMode,
|
||||
...(replyMarkup ? { replyMarkup } : {}),
|
||||
parseMode: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Telegram] Callback error:', error.message);
|
||||
@@ -611,6 +648,31 @@ export class TelegramAlerter {
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
|
||||
async _sendHandlerResponse(response, defaults = {}) {
|
||||
const text = typeof response === 'string' ? response : response.text;
|
||||
const messages = typeof response === 'object' ? response.messages : null;
|
||||
const parseMode = typeof response === 'object' && Object.hasOwn(response, 'parseMode')
|
||||
? response.parseMode
|
||||
: defaults.parseMode;
|
||||
const replyMarkup = typeof response === 'object' ? response.replyMarkup : null;
|
||||
const common = {
|
||||
chatId: defaults.chatId,
|
||||
replyToMessageId: defaults.replyToMessageId,
|
||||
...(replyMarkup ? { replyMarkup } : {}),
|
||||
};
|
||||
if (typeof response === 'object' && response.conversation) {
|
||||
return this.sendConversation(messages || text, {
|
||||
...common,
|
||||
maxChunkChars: response.maxChunkChars,
|
||||
delayMs: response.delayMs,
|
||||
});
|
||||
}
|
||||
return this.sendMessage(text, {
|
||||
...common,
|
||||
...(parseMode !== undefined ? { parseMode } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async _initializeBotCommands() {
|
||||
await this._loadBotIdentity();
|
||||
|
||||
@@ -928,3 +990,24 @@ function parseJSON(text) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripVisibleMarkdown(text) {
|
||||
return String(text || '')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/__([^_]+)__/g, '$1')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/^\s*[-*]\s+/gm, '- ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function splitSentences(text) {
|
||||
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) return [];
|
||||
return normalized.match(/[^.!?\n]+[.!?]+(?:\s+|$)|[^.!?\n]+$/g)?.map(item => item.trim()).filter(Boolean) || [normalized];
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,13 @@ ADAPTIVE WRITING STYLE:
|
||||
- The user's explicit style request always overrides inference. Do not infer sensitive personal traits from writing style.
|
||||
- Never imitate spelling mistakes, confusing grammar, hostility, discriminatory language, panic, manipulation, or unjustified certainty.
|
||||
- Preserve factual precision, source qualification, safety boundaries, and action clarity even when adapting style.
|
||||
- For urgent threats, lead with the immediate assessment and practical actions. Style matching is secondary to comprehension and safety.`;
|
||||
- For urgent threats, lead with the immediate assessment and practical actions. Style matching is secondary to comprehension and safety.
|
||||
|
||||
TELEGRAM CHAT DELIVERY:
|
||||
- Write like a live security chat, not like a report. Prefer short, natural paragraphs and direct sentences.
|
||||
- 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.`;
|
||||
|
||||
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"
|
||||
|
||||
15
server.mjs
15
server.mjs
@@ -228,7 +228,7 @@ if (telegramAlerter.isConfigured) {
|
||||
},
|
||||
};
|
||||
}
|
||||
return { text: `${result.answer}${traceSuffix}`, parseMode: null };
|
||||
return { text: `${result.answer}${traceSuffix}`, parseMode: null, conversation: true, maxChunkChars: 900 };
|
||||
};
|
||||
|
||||
telegramAlerter.onMessage((text, msg) => {
|
||||
@@ -927,7 +927,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, and predictions as needed. Distinguish verified facts from inference. Do not call mutating tools. 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 live Telegram chat: short first message, no visible Markdown, no report wall. Delta summary: ${JSON.stringify(delta?.summary || {})}`;
|
||||
const profile = securityProfileStore.getAgentProfile();
|
||||
const result = await terminalAgent.analyzeProactively(prompt, {
|
||||
context: buildTelegramChatContext(data, buildHealth()),
|
||||
@@ -944,13 +944,18 @@ async function runProactiveAgent(data, delta) {
|
||||
return false;
|
||||
}
|
||||
const german = profile?.language === 'de';
|
||||
const evidence = result.evidence?.length ? `\n${german ? 'Belege' : 'Evidence'}:\n${result.evidence.map(item => `- ${item}`).join('\n')}` : '';
|
||||
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 ? `\n${german ? 'Werkzeuge' : 'Tools'}: ${tools.join(', ')}` : '';
|
||||
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 sent = await telegramAlerter.sendMessage(`[DAVE // ${priority}]\n${result.answer}${evidence}${trace}`, { parseMode: null });
|
||||
const messages = [
|
||||
`[DAVE // ${priority}]\n${result.answer}`,
|
||||
evidence,
|
||||
trace,
|
||||
].filter(Boolean);
|
||||
const sent = await telegramAlerter.sendConversation(messages, { maxChunkChars: 800 });
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -117,6 +117,35 @@ test('Telegram transport routes authorized free text as plain-text AI reply', as
|
||||
assert.equal(sent.body.reply_to_message_id, 11);
|
||||
});
|
||||
|
||||
test('Telegram conversation delivery strips visible markdown and splits large AI replies', 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 = '**Aktuelle Lage:**\n\n' + [
|
||||
'Erster kurzer Absatz mit einer direkten Einschaetzung.',
|
||||
'Zweiter Absatz mit mehr Kontext und genug Laenge, damit er in einer eigenen Nachricht landet. '.repeat(6),
|
||||
'Dritter Absatz mit einer Rueckfrage, auf die der Nutzer direkt antworten kann. '.repeat(5),
|
||||
].join('\n\n');
|
||||
const result = await alerter.sendConversation(text, { maxChunkChars: 300, delayMs: 0, replyToMessageId: 7 });
|
||||
assert.equal(result.ok, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
const sent = requests.filter(request => request.url.includes('/sendMessage'));
|
||||
assert.ok(sent.length >= 2);
|
||||
assert.doesNotMatch(sent[0].body.text, /\*\*/);
|
||||
assert.equal('parse_mode' in sent[0].body, false);
|
||||
assert.equal(sent[0].body.reply_to_message_id, 7);
|
||||
assert.equal('reply_to_message_id' in sent[1].body, false);
|
||||
});
|
||||
|
||||
test('Telegram OSINT extraction excludes private AI chat messages', () => {
|
||||
const messages = extractBotChannelMessages([
|
||||
{ update_id: 1, message: { text: 'private question', chat: { id: 42, type: 'private' } } },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { TerminalAgent, TerminalToolRegistry } from '../lib/agent/terminal-agent.mjs';
|
||||
import { createTerminalToolRegistry } from '../lib/agent/terminal-tools.mjs';
|
||||
|
||||
function providerWith(decisions) {
|
||||
let index = 0;
|
||||
@@ -214,3 +215,38 @@ test('malformed tagged protocol is repaired instead of returned to the user', as
|
||||
assert.equal(result.answer, 'I could not run the malformed tool request.');
|
||||
assert.doesNotMatch(result.answer, /tool_call|call:get_evidence/i);
|
||||
});
|
||||
|
||||
test('terminal registry exposes bounded public web search as a read-only tool', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requestedUrl = '';
|
||||
globalThis.fetch = async (url) => {
|
||||
requestedUrl = String(url);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
text: async () => JSON.stringify({
|
||||
articles: [{
|
||||
title: 'Verified report',
|
||||
sourceCommonName: 'Example News',
|
||||
url: 'https://example.test/report',
|
||||
seendate: '20260707100000',
|
||||
language: 'English',
|
||||
}],
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const registry = createTerminalToolRegistry();
|
||||
const tool = registry.get('web_search');
|
||||
assert.equal(tool.mutating, false);
|
||||
const result = await registry.execute('web_search', { query: 'Russia attack claim', limit: 3, hours: 24 });
|
||||
assert.match(requestedUrl, /api\.gdeltproject\.org\/api\/v2\/doc\/doc/);
|
||||
assert.equal(result.query, 'Russia attack claim');
|
||||
assert.equal(result.results.length, 1);
|
||||
assert.equal(result.results[0].url, 'https://example.test/report');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user