feat: make DAVE Telegram chat conversational
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 8s
Build / test-and-image (pull_request) Successful in 52s

This commit is contained in:
2026-07-07 18:46:44 +02:00
parent c0fb9d1e66
commit a7f3f36d2b
10 changed files with 244 additions and 35 deletions

View File

@@ -108,7 +108,7 @@ export class DavePresence {
this._save();
try {
const runtime = this.getRuntime();
const prompt = `Decide whether DAVE should initiate a natural, useful conversation with the operator now. This is a dynamic presence evaluation, not a fixed scheduled briefing. Inspect the security profile and current terminal intelligence using only read-only tools. 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');

View File

@@ -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"}`;
}
}

View File

@@ -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) => {

View File

@@ -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));
}

View File

@@ -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();