feat: add dynamic autonomous DAVE presence
This commit is contained in:
10
.env.example
10
.env.example
@@ -21,6 +21,16 @@ BRIEF_VERBOSITY=standard
|
|||||||
SECURITY_ONBOARDING_ENABLED=true
|
SECURITY_ONBOARDING_ENABLED=true
|
||||||
SECURITY_PROFILE_ENCRYPTION_KEY=
|
SECURITY_PROFILE_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# DAVE dynamic presence (opt-in)
|
||||||
|
DAVE_PRESENCE_ENABLED=false
|
||||||
|
DAVE_PRESENCE_MAX_MESSAGES_PER_DAY=4
|
||||||
|
DAVE_PRESENCE_MIN_GAP_MINUTES=75
|
||||||
|
DAVE_PRESENCE_MIN_INTERVAL_MINUTES=45
|
||||||
|
DAVE_PRESENCE_MAX_INTERVAL_MINUTES=180
|
||||||
|
DAVE_PRESENCE_IDLE_AFTER_MINUTES=60
|
||||||
|
DAVE_PRESENCE_CHECK_INTERVAL_MINUTES=5
|
||||||
|
DAVE_PRESENCE_TIMEZONE=Europe/Berlin
|
||||||
|
|
||||||
# LLM layer
|
# LLM layer
|
||||||
# Providers: litellm | openrouter | openai-compatible | lmstudio | ollama | openai | anthropic | gemini | mistral | minimax | grok | codex
|
# Providers: litellm | openrouter | openai-compatible | lmstudio | ollama | openai | anthropic | gemini | mistral | minimax | grok | codex
|
||||||
LLM_PROVIDER=openrouter
|
LLM_PROVIDER=openrouter
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -141,6 +141,14 @@ BRIEF_VERBOSITY=standard
|
|||||||
SECURITY_ONBOARDING_ENABLED=true
|
SECURITY_ONBOARDING_ENABLED=true
|
||||||
# Generate once with: openssl rand -base64 48
|
# Generate once with: openssl rand -base64 48
|
||||||
SECURITY_PROFILE_ENCRYPTION_KEY=replace-with-a-unique-random-secret
|
SECURITY_PROFILE_ENCRYPTION_KEY=replace-with-a-unique-random-secret
|
||||||
|
DAVE_PRESENCE_ENABLED=false
|
||||||
|
DAVE_PRESENCE_MAX_MESSAGES_PER_DAY=4
|
||||||
|
DAVE_PRESENCE_MIN_GAP_MINUTES=75
|
||||||
|
DAVE_PRESENCE_MIN_INTERVAL_MINUTES=45
|
||||||
|
DAVE_PRESENCE_MAX_INTERVAL_MINUTES=180
|
||||||
|
DAVE_PRESENCE_IDLE_AFTER_MINUTES=60
|
||||||
|
DAVE_PRESENCE_CHECK_INTERVAL_MINUTES=5
|
||||||
|
DAVE_PRESENCE_TIMEZONE=Europe/Berlin
|
||||||
|
|
||||||
LLM_PROVIDER=openrouter
|
LLM_PROVIDER=openrouter
|
||||||
LLM_BASE_URL=https://openrouter.ai/api/v1
|
LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||||
@@ -412,6 +420,12 @@ 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.
|
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.
|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### Discord Bot (Two-Way)
|
### Discord Bot (Two-Way)
|
||||||
|
|
||||||
Intelligence Terminal also supports Discord as a full-featured bot with slash commands and rich embed alerts. It mirrors the Telegram bot's capabilities with Discord-native formatting.
|
Intelligence Terminal also supports Discord as a full-featured bot with slash commands and rich embed alerts. It mirrors the Telegram bot's capabilities with Discord-native formatting.
|
||||||
@@ -693,6 +707,14 @@ All settings are in `.env` with sensible defaults:
|
|||||||
| `BRIEF_VERBOSITY` | `standard` | Briefing detail level |
|
| `BRIEF_VERBOSITY` | `standard` | Briefing detail level |
|
||||||
| `SECURITY_ONBOARDING_ENABLED` | `true` | Start language-first Security Manager onboarding when no profile exists |
|
| `SECURITY_ONBOARDING_ENABLED` | `true` | Start language-first Security Manager onboarding when no profile exists |
|
||||||
| `SECURITY_PROFILE_ENCRYPTION_KEY` | disabled | Unique secret of at least 32 characters used to encrypt the local profile |
|
| `SECURITY_PROFILE_ENCRYPTION_KEY` | disabled | Unique secret of at least 32 characters used to encrypt the local profile |
|
||||||
|
| `DAVE_PRESENCE_ENABLED` | `false` | Enable dynamic, unsolicited DAVE Telegram presence |
|
||||||
|
| `DAVE_PRESENCE_MAX_MESSAGES_PER_DAY` | `4` | Hard daily cap for dynamic presence messages |
|
||||||
|
| `DAVE_PRESENCE_MIN_GAP_MINUTES` | `75` | Minimum gap between DAVE-initiated messages |
|
||||||
|
| `DAVE_PRESENCE_MIN_INTERVAL_MINUTES` | `45` | Lower bound for randomized evaluation intervals |
|
||||||
|
| `DAVE_PRESENCE_MAX_INTERVAL_MINUTES` | `180` | Upper bound for randomized evaluation intervals |
|
||||||
|
| `DAVE_PRESENCE_IDLE_AFTER_MINUTES` | `60` | Delay unsolicited evaluation after operator activity |
|
||||||
|
| `DAVE_PRESENCE_CHECK_INTERVAL_MINUTES` | `5` | Lightweight timer resolution for due evaluations |
|
||||||
|
| `DAVE_PRESENCE_TIMEZONE` | `Europe/Berlin` | Fallback timezone when the profile has none |
|
||||||
| `LLM_PROVIDER` | disabled | `litellm`, `openrouter`, `openai-compatible`, `lmstudio`, `ollama`, `anthropic`, `openai`, `gemini`, `codex`, `minimax`, `mistral`, or `grok` |
|
| `LLM_PROVIDER` | disabled | `litellm`, `openrouter`, `openai-compatible`, `lmstudio`, `ollama`, `anthropic`, `openai`, `gemini`, `codex`, `minimax`, `mistral`, or `grok` |
|
||||||
| `LLM_BASE_URL` | provider default | API base URL; required for LiteLLM and custom endpoints |
|
| `LLM_BASE_URL` | provider default | API base URL; required for LiteLLM and custom endpoints |
|
||||||
| `LLM_API_KEY` | — | Provider or proxy API key; required for LiteLLM |
|
| `LLM_API_KEY` | — | Provider or proxy API key; required for LiteLLM |
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ export default {
|
|||||||
profileEncryptionKey: process.env.SECURITY_PROFILE_ENCRYPTION_KEY || null,
|
profileEncryptionKey: process.env.SECURITY_PROFILE_ENCRYPTION_KEY || null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
davePresence: {
|
||||||
|
enabled: boolEnv('DAVE_PRESENCE_ENABLED', false),
|
||||||
|
maxPerDay: intEnv('DAVE_PRESENCE_MAX_MESSAGES_PER_DAY', 4),
|
||||||
|
minGapMinutes: intEnv('DAVE_PRESENCE_MIN_GAP_MINUTES', 75),
|
||||||
|
minIntervalMinutes: intEnv('DAVE_PRESENCE_MIN_INTERVAL_MINUTES', 45),
|
||||||
|
maxIntervalMinutes: intEnv('DAVE_PRESENCE_MAX_INTERVAL_MINUTES', 180),
|
||||||
|
idleAfterMinutes: intEnv('DAVE_PRESENCE_IDLE_AFTER_MINUTES', 60),
|
||||||
|
checkIntervalMinutes: intEnv('DAVE_PRESENCE_CHECK_INTERVAL_MINUTES', 5),
|
||||||
|
timezone: process.env.DAVE_PRESENCE_TIMEZONE || 'Europe/Berlin',
|
||||||
|
},
|
||||||
|
|
||||||
llm: {
|
llm: {
|
||||||
provider: process.env.LLM_PROVIDER || null, // litellm | openrouter | openai-compatible | lmstudio | ollama | other supported providers
|
provider: process.env.LLM_PROVIDER || null, // litellm | openrouter | openai-compatible | lmstudio | ollama | other supported providers
|
||||||
apiKey: process.env.LLM_API_KEY || null,
|
apiKey: process.env.LLM_API_KEY || null,
|
||||||
|
|||||||
240
lib/agent/dave-presence.mjs
Normal file
240
lib/agent/dave-presence.mjs
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import { isWithinQuietHours } from '../security/security-alert-policy.mjs';
|
||||||
|
|
||||||
|
export class DavePresence {
|
||||||
|
constructor({ agent, alerter, profileStore, getContext, getRuntime, statePath, config = {}, random = Math.random } = {}) {
|
||||||
|
this.agent = agent;
|
||||||
|
this.alerter = alerter;
|
||||||
|
this.profileStore = profileStore;
|
||||||
|
this.getContext = getContext || (() => '');
|
||||||
|
this.getRuntime = getRuntime || (() => ({}));
|
||||||
|
this.statePath = statePath;
|
||||||
|
this.random = random;
|
||||||
|
this.enabled = Boolean(config.enabled);
|
||||||
|
this.maxPerDay = boundedInt(config.maxPerDay, 1, 8, 4);
|
||||||
|
this.minGapMs = boundedInt(config.minGapMinutes, 15, 720, 75) * 60 * 1000;
|
||||||
|
this.minIntervalMs = boundedInt(config.minIntervalMinutes, 15, 360, 45) * 60 * 1000;
|
||||||
|
this.maxIntervalMs = boundedInt(config.maxIntervalMinutes, 30, 720, 180) * 60 * 1000;
|
||||||
|
if (this.maxIntervalMs < this.minIntervalMs) this.maxIntervalMs = this.minIntervalMs;
|
||||||
|
this.idleAfterMs = boundedInt(config.idleAfterMinutes, 15, 720, 60) * 60 * 1000;
|
||||||
|
this.checkIntervalMs = boundedInt(config.checkIntervalMinutes, 1, 60, 5) * 60 * 1000;
|
||||||
|
this.fallbackTimezone = String(config.timezone || 'Europe/Berlin');
|
||||||
|
this.state = loadState(statePath);
|
||||||
|
this.inFlight = false;
|
||||||
|
this.timer = null;
|
||||||
|
this.startupTimer = null;
|
||||||
|
this.lastReason = this.enabled ? 'waiting' : 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
start(now = new Date()) {
|
||||||
|
if (!this.enabled || this.timer) return false;
|
||||||
|
if (!this.state.nextEvaluationAt) this._schedule(now, randomBetween(this.minIntervalMs, this.maxIntervalMs, this.random));
|
||||||
|
const run = () => this.tick().catch(error => {
|
||||||
|
this.lastReason = `failed: ${String(error.message || error).slice(0, 160)}`;
|
||||||
|
console.error('[DAVE Presence] Evaluation failed:', error.message);
|
||||||
|
});
|
||||||
|
this.startupTimer = setTimeout(run, 30_000);
|
||||||
|
this.startupTimer.unref?.();
|
||||||
|
this.timer = setInterval(run, this.checkIntervalMs);
|
||||||
|
this.timer.unref?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.startupTimer) clearTimeout(this.startupTimer);
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
this.startupTimer = null;
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
noteUserInteraction(now = new Date()) {
|
||||||
|
this.state.lastUserInteractionAt = now.toISOString();
|
||||||
|
const earliest = now.getTime() + this.idleAfterMs;
|
||||||
|
if (!this.state.nextEvaluationAt || new Date(this.state.nextEvaluationAt).getTime() < earliest) {
|
||||||
|
this.state.nextEvaluationAt = new Date(earliest).toISOString();
|
||||||
|
}
|
||||||
|
this._save();
|
||||||
|
}
|
||||||
|
|
||||||
|
nudge(delta, now = new Date()) {
|
||||||
|
if (!this.enabled) return false;
|
||||||
|
const critical = Number(delta?.summary?.criticalChanges || 0);
|
||||||
|
const total = Number(delta?.summary?.totalChanges || 0);
|
||||||
|
if (critical <= 0 && total < 3) return false;
|
||||||
|
const delay = critical > 0 ? 5 * 60 * 1000 : 20 * 60 * 1000;
|
||||||
|
const candidate = now.getTime() + delay;
|
||||||
|
if (!this.state.nextEvaluationAt || candidate < new Date(this.state.nextEvaluationAt).getTime()) {
|
||||||
|
this.state.nextEvaluationAt = new Date(candidate).toISOString();
|
||||||
|
this._save();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async tick(now = new Date()) {
|
||||||
|
if (!this.enabled) return this._result(false, 'disabled');
|
||||||
|
if (this.inFlight) return this._result(false, 'in_flight');
|
||||||
|
if (!this.agent?.isConfigured || !this.alerter?.isConfigured) return this._result(false, 'unavailable');
|
||||||
|
const profile = this.profileStore?.getAgentProfile();
|
||||||
|
if (!profile) return this._result(false, 'profile_missing');
|
||||||
|
|
||||||
|
const timezone = profile.timezone || this.fallbackTimezone;
|
||||||
|
const clock = localClock(now, timezone);
|
||||||
|
if (!clock) return this._result(false, 'invalid_timezone');
|
||||||
|
this._rollDay(clock.day);
|
||||||
|
if (this.state.sentCount >= this.maxPerDay) return this._result(false, 'daily_limit');
|
||||||
|
|
||||||
|
const dueAt = new Date(this.state.nextEvaluationAt || 0).getTime();
|
||||||
|
if (Number.isFinite(dueAt) && dueAt > now.getTime()) return this._result(false, 'not_due');
|
||||||
|
if (isWithinQuietHours(profile.quietHours, timezone, now)) {
|
||||||
|
this._schedule(now, Math.min(this.maxIntervalMs, 30 * 60 * 1000));
|
||||||
|
return this._result(false, 'quiet_hours');
|
||||||
|
}
|
||||||
|
if (this.state.lastSentAt && now.getTime() - new Date(this.state.lastSentAt).getTime() < this.minGapMs) {
|
||||||
|
this._scheduleAt(new Date(this.state.lastSentAt).getTime() + this.minGapMs);
|
||||||
|
return this._result(false, 'minimum_gap');
|
||||||
|
}
|
||||||
|
if (this.state.lastUserInteractionAt && now.getTime() - new Date(this.state.lastUserInteractionAt).getTime() < this.idleAfterMs) {
|
||||||
|
this._scheduleAt(new Date(this.state.lastUserInteractionAt).getTime() + this.idleAfterMs);
|
||||||
|
return this._result(false, 'operator_active');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inFlight = true;
|
||||||
|
this.state.lastEvaluationAt = now.toISOString();
|
||||||
|
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 result = await this.agent.run(prompt, {
|
||||||
|
chatId: 'dave-presence',
|
||||||
|
context: String(await this.getContext()).slice(0, 12000),
|
||||||
|
runtime,
|
||||||
|
mode: 'presence',
|
||||||
|
});
|
||||||
|
if (result.pendingAction || !result.notify) {
|
||||||
|
this._scheduleDynamic(now, runtime?.delta, false);
|
||||||
|
return this._result(false, result.pendingAction ? 'mutation_rejected' : 'agent_declined');
|
||||||
|
}
|
||||||
|
|
||||||
|
const evidence = result.evidence?.length
|
||||||
|
? `\n\nEvidence:\n${result.evidence.slice(0, 4).map(item => `- ${item}`).join('\n')}`
|
||||||
|
: '';
|
||||||
|
const sent = await this.alerter.sendMessage(`[DAVE // ACTIVE]\n${result.answer}${evidence}`, { parseMode: null });
|
||||||
|
if (!sent?.ok && sent !== true) {
|
||||||
|
this._schedule(now, this.minIntervalMs);
|
||||||
|
return this._result(false, 'send_failed');
|
||||||
|
}
|
||||||
|
this.state.sentCount++;
|
||||||
|
this.state.lastSentAt = now.toISOString();
|
||||||
|
this._scheduleDynamic(now, runtime?.delta, true);
|
||||||
|
return this._result(true, 'sent');
|
||||||
|
} finally {
|
||||||
|
this.inFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
return {
|
||||||
|
enabled: this.enabled,
|
||||||
|
running: Boolean(this.timer),
|
||||||
|
dynamic: true,
|
||||||
|
maxPerDay: this.maxPerDay,
|
||||||
|
sentToday: this.state.sentCount || 0,
|
||||||
|
lastSentAt: this.state.lastSentAt || null,
|
||||||
|
lastEvaluationAt: this.state.lastEvaluationAt || null,
|
||||||
|
nextEvaluationAt: this.state.nextEvaluationAt || null,
|
||||||
|
lastUserInteractionAt: this.state.lastUserInteractionAt || null,
|
||||||
|
lastReason: this.lastReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_rollDay(day) {
|
||||||
|
if (this.state.day === day) return;
|
||||||
|
this.state = { ...this.state, day, sentCount: 0 };
|
||||||
|
this._save();
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleDynamic(now, delta, sent) {
|
||||||
|
const critical = Number(delta?.summary?.criticalChanges || 0);
|
||||||
|
const total = Number(delta?.summary?.totalChanges || 0);
|
||||||
|
let min = this.minIntervalMs;
|
||||||
|
let max = this.maxIntervalMs;
|
||||||
|
if (critical > 0) max = Math.min(max, min * 1.5);
|
||||||
|
else if (total >= 3) max = Math.min(max, min * 2);
|
||||||
|
else if (!sent) min = Math.min(max, min * 1.5);
|
||||||
|
this._schedule(now, randomBetween(min, max, this.random));
|
||||||
|
}
|
||||||
|
|
||||||
|
_schedule(now, delayMs) {
|
||||||
|
this._scheduleAt(now.getTime() + Math.max(60_000, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleAt(timestamp) {
|
||||||
|
this.state.nextEvaluationAt = new Date(timestamp).toISOString();
|
||||||
|
this._save();
|
||||||
|
}
|
||||||
|
|
||||||
|
_save() {
|
||||||
|
if (!this.statePath) return;
|
||||||
|
mkdirSync(dirname(this.statePath), { recursive: true });
|
||||||
|
const temporaryPath = `${this.statePath}.tmp`;
|
||||||
|
writeFileSync(temporaryPath, JSON.stringify(this.state), 'utf8');
|
||||||
|
renameSync(temporaryPath, this.statePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
_result(sent, reason) {
|
||||||
|
this.lastReason = reason;
|
||||||
|
return { sent, reason };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localClock(date, timezone) {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||||
|
}).formatToParts(date);
|
||||||
|
const value = type => parts.find(part => part.type === type)?.value;
|
||||||
|
const hour = Number(value('hour'));
|
||||||
|
const minute = Number(value('minute'));
|
||||||
|
return {
|
||||||
|
day: `${value('year')}-${value('month')}-${value('day')}`,
|
||||||
|
time: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadState(path) {
|
||||||
|
const empty = { day: null, sentCount: 0, lastSentAt: null, lastEvaluationAt: null, nextEvaluationAt: null, lastUserInteractionAt: null };
|
||||||
|
if (!path || !existsSync(path)) return empty;
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(readFileSync(path, 'utf8'));
|
||||||
|
return {
|
||||||
|
day: typeof value.day === 'string' ? value.day : null,
|
||||||
|
sentCount: boundedInt(value.sentCount, 0, 8, 0),
|
||||||
|
lastSentAt: validIso(value.lastSentAt),
|
||||||
|
lastEvaluationAt: validIso(value.lastEvaluationAt),
|
||||||
|
nextEvaluationAt: validIso(value.nextEvaluationAt),
|
||||||
|
lastUserInteractionAt: validIso(value.lastUserInteractionAt),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomBetween(min, max, random) {
|
||||||
|
return Math.round(min + Math.max(0, Math.min(1, Number(random()) || 0)) * (max - min));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validIso(value) {
|
||||||
|
const date = new Date(value);
|
||||||
|
return value && Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedInt(value, min, max, fallback) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
|
||||||
|
}
|
||||||
@@ -110,6 +110,11 @@ export class TerminalAgent {
|
|||||||
working += `\n\nTOOL ERROR: ${decision.tool} is not allowlisted.`;
|
working += `\n\nTOOL ERROR: ${decision.tool} is not allowlisted.`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (tool.mutating && mode !== 'chat') {
|
||||||
|
trace.push({ tool: tool.name, status: 'rejected', durationMs: 0, rationale: short(decision.rationale) });
|
||||||
|
working += `\n\nTOOL ERROR: ${tool.name} is unavailable outside an operator-initiated chat. Continue with read-only evidence or return final JSON.`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (tool.mutating) {
|
if (tool.mutating) {
|
||||||
const pendingAction = this._createPending(key, tool, decision.arguments || {}, decision.rationale);
|
const pendingAction = this._createPending(key, tool, decision.arguments || {}, decision.rationale);
|
||||||
trace.push({ tool: tool.name, status: 'confirmation_required', durationMs: 0, rationale: short(decision.rationale) });
|
trace.push({ tool: tool.name, status: 'confirmation_required', durationMs: 0, rationale: short(decision.rationale) });
|
||||||
@@ -216,7 +221,8 @@ export class TerminalAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_systemPrompt(mode) {
|
_systemPrompt(mode) {
|
||||||
const proactive = mode === 'proactive';
|
const proactive = mode === 'proactive' || mode === 'presence';
|
||||||
|
const presence = mode === 'presence';
|
||||||
return `You are the operator's controlled Intelligence Terminal Security Manager. Your job is to identify material personal security risks, verify evidence, explain relevance, and propose practical protective actions. Select only allowlisted tools and use the minimum steps needed.
|
return `You are the operator's controlled Intelligence Terminal Security Manager. Your job is to identify material personal security risks, verify evidence, explain relevance, and propose practical protective actions. Select only allowlisted tools and use the minimum steps needed.
|
||||||
|
|
||||||
${DAVE_PERSONA_PROMPT}
|
${DAVE_PERSONA_PROMPT}
|
||||||
@@ -243,11 +249,15 @@ ${JSON.stringify(this.registry.describe())}
|
|||||||
PROTOCOL: Output exactly one JSON object, without markdown.
|
PROTOCOL: Output exactly one JSON object, without markdown.
|
||||||
Tool call: {"type":"tool_call","tool":"tool_name","arguments":{},"rationale":"short operational reason"}
|
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 answer in the user's language","confidence":"low|medium|high","evidence":["URL or event id"],"notify":${proactive ? 'true' : 'false'},"priority":"routine|priority|flash"}
|
||||||
${proactive ? 'In proactive mode, never call mutating tools. Set notify=true only for material, actionable, cross-checked changes. Otherwise notify=false and briefly explain why.' : 'In chat mode, notify must be false.'}`;
|
${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
|
||||||
|
? 'In proactive mode, never call mutating tools. Set notify=true only for material, actionable, cross-checked changes. Otherwise notify=false and briefly explain why.'
|
||||||
|
: 'In chat mode, notify must be false.'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_finalPrompt(mode) {
|
_finalPrompt(mode) {
|
||||||
const proactive = mode === 'proactive';
|
const proactive = mode === 'proactive' || mode === 'presence';
|
||||||
return `You are the operator's Intelligence Terminal Security Manager. Tool use is finished and unavailable in this phase.
|
return `You are the operator's Intelligence Terminal Security Manager. Tool use is finished and unavailable in this phase.
|
||||||
|
|
||||||
${DAVE_PERSONA_PROMPT}
|
${DAVE_PERSONA_PROMPT}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export class TelegramAlerter {
|
|||||||
this._commandHandlers = {}; // Registered command callbacks
|
this._commandHandlers = {}; // Registered command callbacks
|
||||||
this._messageHandler = null; // Conversational free-text callback
|
this._messageHandler = null; // Conversational free-text callback
|
||||||
this._callbackHandler = null;
|
this._callbackHandler = null;
|
||||||
|
this._activityHandler = null;
|
||||||
this._pollingInterval = null;
|
this._pollingInterval = null;
|
||||||
this._pollInProgress = false;
|
this._pollInProgress = false;
|
||||||
this._botUsername = null;
|
this._botUsername = null;
|
||||||
@@ -331,6 +332,10 @@ export class TelegramAlerter {
|
|||||||
this._callbackHandler = handler;
|
this._callbackHandler = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onActivity(handler) {
|
||||||
|
this._activityHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
async sendChatAction(chatId, action = 'typing') {
|
async sendChatAction(chatId, action = 'typing') {
|
||||||
if (!this.isConfigured) return false;
|
if (!this.isConfigured) return false;
|
||||||
try {
|
try {
|
||||||
@@ -456,6 +461,7 @@ export class TelegramAlerter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _handleMessage(msg) {
|
async _handleMessage(msg) {
|
||||||
|
try { this._activityHandler?.(msg); } catch {}
|
||||||
const text = msg.text.trim();
|
const text = msg.text.trim();
|
||||||
const parts = text.split(/\s+/);
|
const parts = text.split(/\s+/);
|
||||||
const rawCommand = parts[0].toLowerCase();
|
const rawCommand = parts[0].toLowerCase();
|
||||||
@@ -573,6 +579,7 @@ export class TelegramAlerter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _handleCallbackQuery(query) {
|
async _handleCallbackQuery(query) {
|
||||||
|
try { this._activityHandler?.(query.message); } catch {}
|
||||||
if (!this._callbackHandler || !query?.data) return;
|
if (!this._callbackHandler || !query?.data) return;
|
||||||
const chatId = query.message?.chat?.id;
|
const chatId = query.message?.chat?.id;
|
||||||
const stopTyping = this._startTyping(chatId);
|
const stopTyping = this._startTyping(chatId);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"brief:save": "node apis/save-briefing.mjs",
|
"brief:save": "node apis/save-briefing.mjs",
|
||||||
"diag": "node diag.mjs",
|
"diag": "node diag.mjs",
|
||||||
"test": "npm run test:unit",
|
"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/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/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",
|
"compose:config": "docker compose config",
|
||||||
"clean": "node scripts/clean.mjs",
|
"clean": "node scripts/clean.mjs",
|
||||||
"fresh-start": "npm run clean && npm start"
|
"fresh-start": "npm run clean && npm start"
|
||||||
|
|||||||
14
server.mjs
14
server.mjs
@@ -17,6 +17,7 @@ import { generateLLMIdeas } from './lib/llm/ideas.mjs';
|
|||||||
import { TelegramChatAssistant, buildTelegramChatContext } from './lib/llm/telegram-chat.mjs';
|
import { TelegramChatAssistant, buildTelegramChatContext } from './lib/llm/telegram-chat.mjs';
|
||||||
import { TerminalAgent } from './lib/agent/terminal-agent.mjs';
|
import { TerminalAgent } from './lib/agent/terminal-agent.mjs';
|
||||||
import { createTerminalToolRegistry } from './lib/agent/terminal-tools.mjs';
|
import { createTerminalToolRegistry } from './lib/agent/terminal-tools.mjs';
|
||||||
|
import { DavePresence } from './lib/agent/dave-presence.mjs';
|
||||||
import { SecurityProfileStore } from './lib/security/security-profile-store.mjs';
|
import { SecurityProfileStore } from './lib/security/security-profile-store.mjs';
|
||||||
import { SecurityOnboarding } from './lib/security/security-onboarding.mjs';
|
import { SecurityOnboarding } from './lib/security/security-onboarding.mjs';
|
||||||
import { evaluateSecurityAlertPolicy } from './lib/security/security-alert-policy.mjs';
|
import { evaluateSecurityAlertPolicy } from './lib/security/security-alert-policy.mjs';
|
||||||
@@ -97,11 +98,21 @@ const telegramChatAssistant = new TelegramChatAssistant({
|
|||||||
maxTokens: config.telegram.aiMaxTokens,
|
maxTokens: config.telegram.aiMaxTokens,
|
||||||
timeoutMs: config.telegram.aiTimeoutMs,
|
timeoutMs: config.telegram.aiTimeoutMs,
|
||||||
});
|
});
|
||||||
|
const davePresence = new DavePresence({
|
||||||
|
agent: terminalAgent,
|
||||||
|
alerter: telegramAlerter,
|
||||||
|
profileStore: securityProfileStore,
|
||||||
|
getContext: () => buildTelegramChatContext(currentData, buildHealth()),
|
||||||
|
getRuntime: () => ({ data: currentData, delta: memory.getLastDelta() }),
|
||||||
|
statePath: join(RUNS_DIR, 'dave-presence-state.json'),
|
||||||
|
config: config.davePresence,
|
||||||
|
});
|
||||||
|
|
||||||
if (llmProvider) console.log(`[Crucix] LLM enabled: ${llmProvider.name} (${llmProvider.model})`);
|
if (llmProvider) console.log(`[Crucix] LLM enabled: ${llmProvider.name} (${llmProvider.model})`);
|
||||||
else if (config.llm.provider) console.warn(`[Crucix] LLM provider "${config.llm.provider}" is not configured; LLM features disabled`);
|
else if (config.llm.provider) console.warn(`[Crucix] LLM provider "${config.llm.provider}" is not configured; LLM features disabled`);
|
||||||
if (telegramAlerter.isConfigured) {
|
if (telegramAlerter.isConfigured) {
|
||||||
console.log('[Crucix] Telegram alerts enabled');
|
console.log('[Crucix] Telegram alerts enabled');
|
||||||
|
telegramAlerter.onActivity(() => davePresence.noteUserInteraction());
|
||||||
|
|
||||||
// ─── Two-Way Bot Commands ───────────────────────────────────────────────
|
// ─── Two-Way Bot Commands ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -299,6 +310,7 @@ if (telegramAlerter.isConfigured) {
|
|||||||
console.error('[Security Manager] Initial onboarding failed:', error.message);
|
console.error('[Security Manager] Initial onboarding failed:', error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (davePresence.start()) console.log('[DAVE Presence] Dynamic presence enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Discord Bot ===
|
// === Discord Bot ===
|
||||||
@@ -704,6 +716,7 @@ function buildHealth() {
|
|||||||
maxSteps: config.telegram.agentMaxSteps,
|
maxSteps: config.telegram.agentMaxSteps,
|
||||||
proactive: Boolean(config.telegram.agentEnabled && config.telegram.agentProactiveEnabled),
|
proactive: Boolean(config.telegram.agentEnabled && config.telegram.agentProactiveEnabled),
|
||||||
},
|
},
|
||||||
|
davePresence: davePresence.status(),
|
||||||
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
|
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
|
||||||
terminalActionsEnabled: config.terminalActionsEnabled,
|
terminalActionsEnabled: config.terminalActionsEnabled,
|
||||||
terminalActionsTokenRequired: !!config.sweepToken,
|
terminalActionsTokenRequired: !!config.sweepToken,
|
||||||
@@ -881,6 +894,7 @@ async function runSweepCycle() {
|
|||||||
memory.pruneAlertedSignals();
|
memory.pruneAlertedSignals();
|
||||||
|
|
||||||
currentData = synthesized;
|
currentData = synthesized;
|
||||||
|
davePresence.nudge(delta);
|
||||||
lastSuccessfulSweepTime = lastSweepTime;
|
lastSuccessfulSweepTime = lastSweepTime;
|
||||||
intelligenceStore.recordRun(currentData, delta);
|
intelligenceStore.recordRun(currentData, delta);
|
||||||
|
|
||||||
|
|||||||
91
test/dave-presence.test.mjs
Normal file
91
test/dave-presence.test.mjs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { DavePresence, localClock } from '../lib/agent/dave-presence.mjs';
|
||||||
|
|
||||||
|
function setup({ result, profile = {}, random = () => 0 } = {}) {
|
||||||
|
const messages = [];
|
||||||
|
const calls = [];
|
||||||
|
const statePath = join(mkdtempSync(join(tmpdir(), 'dave-presence-')), 'state.json');
|
||||||
|
const presence = new DavePresence({
|
||||||
|
agent: {
|
||||||
|
isConfigured: true,
|
||||||
|
async run(prompt, options) {
|
||||||
|
calls.push({ prompt, options });
|
||||||
|
return result || { answer: 'Die Lage ist ruhig und die Daten sind aktuell.', notify: true, priority: 'routine', evidence: ['evt-1'], trace: [] };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alerter: {
|
||||||
|
isConfigured: true,
|
||||||
|
async sendMessage(text) { messages.push(text); return { ok: true }; },
|
||||||
|
},
|
||||||
|
profileStore: {
|
||||||
|
getAgentProfile() {
|
||||||
|
return { language: 'de', timezone: 'UTC', quietHours: null, alertPreference: 'all', ...profile };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getContext: () => '{"status":"healthy"}',
|
||||||
|
getRuntime: () => ({ delta: { summary: { totalChanges: 0, criticalChanges: 0 } } }),
|
||||||
|
statePath,
|
||||||
|
random,
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
maxPerDay: 4,
|
||||||
|
minGapMinutes: 15,
|
||||||
|
minIntervalMinutes: 15,
|
||||||
|
maxIntervalMinutes: 30,
|
||||||
|
idleAfterMinutes: 15,
|
||||||
|
checkIntervalMinutes: 5,
|
||||||
|
timezone: 'UTC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { presence, messages, calls, statePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dynamic presence sends a grounded message and persists a variable next evaluation', async () => {
|
||||||
|
const { presence, messages, calls } = setup({ random: () => 0.5 });
|
||||||
|
const now = new Date('2026-07-06T12:00:00Z');
|
||||||
|
const outcome = await presence.tick(now);
|
||||||
|
assert.deepEqual(outcome, { sent: true, reason: 'sent' });
|
||||||
|
assert.match(messages[0], /^\[DAVE \/\/ ACTIVE\]/);
|
||||||
|
assert.match(calls[0].prompt, /dynamic presence evaluation, not a fixed scheduled briefing/i);
|
||||||
|
assert.equal(calls[0].options.mode, 'presence');
|
||||||
|
assert.equal(presence.status().sentToday, 1);
|
||||||
|
assert.equal(presence.status().nextEvaluationAt, '2026-07-06T12:22:30.000Z');
|
||||||
|
assert.equal((await presence.tick(new Date('2026-07-06T12:05:00Z'))).reason, 'not_due');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('material sweep changes dynamically pull the next evaluation forward', () => {
|
||||||
|
const { presence } = setup();
|
||||||
|
const now = new Date('2026-07-06T12:00:00Z');
|
||||||
|
presence.noteUserInteraction(now);
|
||||||
|
assert.equal(presence.status().nextEvaluationAt, '2026-07-06T12:15:00.000Z');
|
||||||
|
assert.equal(presence.nudge({ summary: { criticalChanges: 1, totalChanges: 1 } }, now), true);
|
||||||
|
assert.equal(presence.status().nextEvaluationAt, '2026-07-06T12:05:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recent operator activity delays unsolicited conversation', async () => {
|
||||||
|
const { presence, messages } = setup();
|
||||||
|
const now = new Date('2026-07-06T12:00:00Z');
|
||||||
|
presence.noteUserInteraction(now);
|
||||||
|
const outcome = await presence.tick(new Date('2026-07-06T12:01:00Z'));
|
||||||
|
assert.equal(outcome.reason, 'not_due');
|
||||||
|
assert.equal(messages.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quiet hours and mutating proposals are suppressed', async () => {
|
||||||
|
const quiet = setup({ profile: { quietHours: '00:00-00:00' } });
|
||||||
|
assert.equal((await quiet.presence.tick(new Date('2026-07-06T12:00:00Z'))).reason, 'quiet_hours');
|
||||||
|
assert.equal(quiet.messages.length, 0);
|
||||||
|
|
||||||
|
const mutation = setup({ result: { answer: 'Confirmation required.', notify: true, pendingAction: { tool: 'trigger_sweep' } } });
|
||||||
|
assert.equal((await mutation.presence.tick(new Date('2026-07-06T12:00:00Z'))).reason, 'mutation_rejected');
|
||||||
|
assert.equal(mutation.messages.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local clock uses the profile timezone', () => {
|
||||||
|
assert.deepEqual(localClock(new Date('2026-07-06T12:30:00Z'), 'Europe/Berlin'), { day: '2026-07-06', time: '14:30' });
|
||||||
|
assert.equal(localClock(new Date(), 'Invalid/Timezone'), null);
|
||||||
|
});
|
||||||
@@ -130,3 +130,23 @@ test('repeated tool calls during finalization fail closed without leaking protoc
|
|||||||
assert.doesNotMatch(result.answer, /tool_call|get_evidence|rationale/i);
|
assert.doesNotMatch(result.answer, /tool_call|get_evidence|rationale/i);
|
||||||
assert.equal(result.notify, false);
|
assert.equal(result.notify, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('scheduled presence cannot create pending mutating actions', async () => {
|
||||||
|
let executions = 0;
|
||||||
|
const agent = new TerminalAgent({
|
||||||
|
provider: providerWith([
|
||||||
|
{ type: 'tool_call', tool: 'trigger_sweep', arguments: {}, rationale: 'Refresh data' },
|
||||||
|
{ type: 'final', answer: 'No autonomous action was taken.', confidence: 'high', evidence: [], notify: false, priority: 'routine' },
|
||||||
|
]),
|
||||||
|
registry: new TerminalToolRegistry([{
|
||||||
|
name: 'trigger_sweep',
|
||||||
|
mutating: true,
|
||||||
|
handler: async () => { executions++; },
|
||||||
|
}]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await agent.run('Evaluate presence', { mode: 'presence' });
|
||||||
|
assert.equal(result.pendingAction, undefined);
|
||||||
|
assert.equal(result.trace[0].status, 'rejected');
|
||||||
|
assert.equal(executions, 0);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user