fix: make dave telegram alerts conversational
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 32s
Build / test-and-image (pull_request) Successful in 2m4s

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

View File

@@ -257,7 +257,7 @@ ${JSON.stringify(this.registry.describe())}
PROTOCOL: Output exactly one JSON object, without markdown.
Tool call: {"type":"tool_call","tool":"tool_name","arguments":{},"rationale":"short operational reason"}
Final: {"type":"final","answer":"concise, natural chat answer in the user's language without visible Markdown syntax","confidence":"low|medium|high","evidence":["URL or event id"],"notify":${proactive ? 'true' : 'false'},"priority":"routine|priority|flash"}
Final: {"type":"final","answer":"short natural chat answer in the user's language, normally under 420 characters for proactive/presence, without visible Markdown syntax or tool traces","confidence":"low|medium|high","evidence":["URL or event id"],"notify":${proactive ? 'true' : 'false'},"priority":"routine|priority|flash"}
${presence
? 'In scheduled presence mode, never call mutating tools. Set notify=true for an evidence-grounded briefing, meaningful change, useful all-clear, or one practical check-in question. Set notify=false when available data is too stale or unreliable.'
: proactive
@@ -277,7 +277,7 @@ Synthesize a direct answer using only the user request, conversation, snapshot,
You must not request or call another tool. Never output an object with type "tool_call".
Output exactly one JSON object without markdown:
{"type":"final","answer":"concise, natural chat answer in the user's language without visible Markdown syntax","confidence":"low|medium|high","evidence":["URL or event id"],"notify":${proactive ? 'true or false' : 'false'},"priority":"routine|priority|flash"}`;
{"type":"final","answer":"short natural chat answer in the user's language, normally under 420 characters for proactive/presence, without visible Markdown syntax or tool traces","confidence":"low|medium|high","evidence":["URL or event id"],"notify":${proactive ? 'true or false' : 'false'},"priority":"routine|priority|flash"}`;
}
}

View File

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

View File

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