Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
496 lines
18 KiB
JavaScript
496 lines
18 KiB
JavaScript
// ============================================================
|
||
// PES Agentic Workflows — The Pet as Trigger & Face
|
||
// ============================================================
|
||
// Зверь = лицо и триггер, под ним = настоящие workflow.
|
||
//
|
||
// Built-in workflows:
|
||
// triage — scan CRM for new/urgent items
|
||
// daily_summary — aggregate today's events + interactions
|
||
// task_cleanup — archive stale notes/reminders
|
||
// health_check — monitor PES vitals, flag anomalies
|
||
// crm_digest — summarize CRM space activity
|
||
//
|
||
// Level gates:
|
||
// L8 — triage, health_check, daily_summary (read-only + analytics)
|
||
// L10 — crm_digest (CRM analytics)
|
||
// L15 — task_cleanup, custom workflows (automation)
|
||
// ============================================================
|
||
|
||
const WORKFLOW_DEFS = {
|
||
// ── L8: Basic workflows ──────────────────────────────
|
||
triage: {
|
||
name: 'Triage',
|
||
description: 'Сканирует CRM на новые/срочные записи',
|
||
levelReq: 8,
|
||
cooldownMs: 10 * 60 * 1000, // 10 min
|
||
schedule: null, // manual only
|
||
async run(ctx) {
|
||
const { crm, store } = ctx;
|
||
if (!crm) return { ok: false, text: 'CRM не подключена' };
|
||
|
||
const tables = await crm.getTables();
|
||
if (!tables.length) return { ok: false, text: 'Нет таблиц в CRM' };
|
||
|
||
const results = [];
|
||
let totalNew = 0;
|
||
|
||
for (const table of tables.slice(0, 8)) {
|
||
try {
|
||
const rows = await crm.getRows(table.id, { limit: 5 });
|
||
if (!rows.length) continue;
|
||
|
||
// Check for recently created/updated rows
|
||
const recent = rows.filter(r => {
|
||
const created = r.created_at || r.data?.created_at;
|
||
if (!created) return false;
|
||
const age = Date.now() - new Date(created).getTime();
|
||
return age < 24 * 60 * 60 * 1000; // last 24h
|
||
});
|
||
|
||
if (recent.length > 0) {
|
||
totalNew += recent.length;
|
||
results.push({
|
||
table: table.name || table.display_name,
|
||
count: recent.length,
|
||
sample: recent[0].data || recent[0],
|
||
});
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
if (totalNew === 0) {
|
||
return { ok: true, text: '✅ Всё спокойно — нет новых записей за 24ч', data: { totalNew: 0 } };
|
||
}
|
||
|
||
let text = `📋 Triage: ${totalNew} новых записей за 24ч\n\n`;
|
||
for (const r of results) {
|
||
text += `📁 ${r.table}: ${r.count} новых\n`;
|
||
const sample = r.sample;
|
||
const preview = Object.entries(sample)
|
||
.filter(([k, v]) => v != null && v !== '' && !k.startsWith('_') && k !== 'id')
|
||
.slice(0, 3)
|
||
.map(([k, v]) => ` ${k}: ${String(v).slice(0, 60)}`)
|
||
.join('\n');
|
||
if (preview) text += preview + '\n';
|
||
text += '\n';
|
||
}
|
||
|
||
return { ok: true, text, data: { totalNew, tables: results.length } };
|
||
},
|
||
},
|
||
|
||
health_check: {
|
||
name: 'Health Check',
|
||
description: 'Проверяет виталы PES и флагует аномалии',
|
||
levelReq: 8,
|
||
cooldownMs: 5 * 60 * 1000,
|
||
schedule: '*/30 * * * *', // every 30 min
|
||
async run(ctx) {
|
||
const { store } = ctx;
|
||
const stats = store.getStats();
|
||
if (!stats) return { ok: false, text: 'Нет данных' };
|
||
|
||
const alerts = [];
|
||
const report = [];
|
||
|
||
// Vitals check
|
||
if (stats.mood < 0.2) alerts.push('⚠️ Настроение критически низкое');
|
||
if (stats.energy < 0.15) alerts.push('⚠️ Энергия почти на нуле');
|
||
if (stats.hunger > 0.85) alerts.push('🍖 Очень голоден!');
|
||
if (stats.loneliness > 0.8) alerts.push('💔 Очень одинок');
|
||
|
||
// Interaction check
|
||
const recent = store.getRecentInteractions(10);
|
||
const lastInteraction = recent[0];
|
||
if (lastInteraction) {
|
||
const age = Date.now() - new Date(lastInteraction.timestamp).getTime();
|
||
if (age > 6 * 60 * 60 * 1000) { // 6 hours no interaction
|
||
alerts.push('⏰ Нет взаимодействий > 6 часов');
|
||
}
|
||
}
|
||
|
||
// Pending reminders check
|
||
try {
|
||
const pending = store.getPendingReminders();
|
||
if (pending.length > 0) {
|
||
alerts.push(`📌 ${pending.length} просроченных напоминаний`);
|
||
}
|
||
} catch (_) {}
|
||
|
||
// Build report
|
||
report.push(`🐾 ${stats.level?.toFixed?.(1) || stats.level} lvl, ${stats.xp} XP`);
|
||
report.push(`💚 mood: ${Math.round(stats.mood * 100)}%`);
|
||
report.push(`⚡ energy: ${Math.round(stats.energy * 100)}%`);
|
||
report.push(`🍖 hunger: ${Math.round(stats.hunger * 100)}%`);
|
||
report.push(`🧠 interactions: ${stats.interactions_total}`);
|
||
|
||
const hasAlerts = alerts.length > 0;
|
||
let text = hasAlerts
|
||
? `🚨 Health Check — ${alerts.length} предупреждений:\n\n${alerts.join('\n')}\n\n${report.join('\n')}`
|
||
: `✅ Health Check — всё в норме\n\n${report.join('\n')}`;
|
||
|
||
return { ok: true, text, data: { alerts: alerts.length, vitals: { mood: stats.mood, energy: stats.energy, hunger: stats.hunger } } };
|
||
},
|
||
},
|
||
|
||
// ── L12: Analytics workflows ─────────────────────────
|
||
daily_summary: {
|
||
name: 'Daily Summary',
|
||
description: 'Итоги дня: взаимодействия, XP, эмоции, заметки',
|
||
levelReq: 8,
|
||
cooldownMs: 60 * 60 * 1000, // 1 hour
|
||
schedule: '0 21 * * *', // every day at 21:00
|
||
async run(ctx) {
|
||
const { store } = ctx;
|
||
const stats = store.getStats();
|
||
if (!stats) return { ok: false, text: 'Нет данных' };
|
||
|
||
// Today's interactions
|
||
const allRecent = store.getRecentInteractions(100);
|
||
const todayStart = new Date();
|
||
todayStart.setHours(0, 0, 0, 0);
|
||
const todayInteractions = allRecent.filter(i => new Date(i.timestamp) >= todayStart);
|
||
|
||
// XP earned today
|
||
let todayXP = 0;
|
||
try {
|
||
const xpRows = store.db.prepare(`
|
||
SELECT SUM(amount) as total FROM xp_log
|
||
WHERE timestamp >= ?
|
||
`).get(todayStart.toISOString());
|
||
todayXP = xpRows?.total || 0;
|
||
} catch (_) {}
|
||
|
||
// Notes created today
|
||
let todayNotes = 0;
|
||
try {
|
||
const notesRow = store.db.prepare(`
|
||
SELECT COUNT(*) as c FROM owner_notes
|
||
WHERE is_deleted = 0 AND created_at >= ?
|
||
`).get(todayStart.toISOString());
|
||
todayNotes = notesRow?.c || 0;
|
||
} catch (_) {}
|
||
|
||
// Emotion distribution today
|
||
const emotionCounts = {};
|
||
for (const i of todayInteractions) {
|
||
const em = i.emotion_after || 'unknown';
|
||
emotionCounts[em] = (emotionCounts[em] || 0) + 1;
|
||
}
|
||
const topEmotions = Object.entries(emotionCounts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 3)
|
||
.map(([e, c]) => `${e} (${c})`);
|
||
|
||
// Unique actors
|
||
const actors = new Set(todayInteractions.map(i => i.actor).filter(Boolean));
|
||
|
||
let text = `📊 Итоги дня:\n\n`;
|
||
text += `🐾 Взаимодействий: ${todayInteractions.length}\n`;
|
||
text += `⭐ XP заработано: +${todayXP}\n`;
|
||
text += `📝 Заметок создано: ${todayNotes}\n`;
|
||
text += `👥 Собеседников: ${actors.size}\n`;
|
||
if (topEmotions.length) {
|
||
text += `😊 Эмоции: ${topEmotions.join(', ')}\n`;
|
||
}
|
||
text += `\n📈 Уровень: ${stats.level?.toFixed?.(1) || stats.level}`;
|
||
text += `\n💚 Настроение: ${Math.round(stats.mood * 100)}%`;
|
||
|
||
return { ok: true, text, data: { interactions: todayInteractions.length, xp: todayXP, notes: todayNotes } };
|
||
},
|
||
},
|
||
|
||
crm_digest: {
|
||
name: 'CRM Digest',
|
||
description: 'Обзор активности в CRM пространстве',
|
||
levelReq: 10,
|
||
cooldownMs: 30 * 60 * 1000,
|
||
schedule: null,
|
||
async run(ctx) {
|
||
const { crm } = ctx;
|
||
if (!crm) return { ok: false, text: 'CRM не подключена' };
|
||
|
||
const tables = await crm.getTables();
|
||
if (!tables.length) return { ok: false, text: 'Нет таблиц' };
|
||
|
||
let text = `📊 CRM Digest — ${tables.length} таблиц\n\n`;
|
||
let totalRows = 0;
|
||
|
||
for (const table of tables.slice(0, 12)) {
|
||
try {
|
||
const rows = await crm.getRows(table.id, { limit: 1 });
|
||
const name = table.name || table.display_name || `#${table.id}`;
|
||
// Get actual count via a COUNT query approximation
|
||
const allRows = await crm.getRows(table.id, { limit: 100 });
|
||
const count = allRows.length;
|
||
totalRows += count;
|
||
text += `📁 ${name}: ${count}${count >= 100 ? '+' : ''} записей\n`;
|
||
} catch (_) {}
|
||
}
|
||
|
||
text += `\n📈 Всего: ~${totalRows} записей в ${tables.length} таблицах`;
|
||
|
||
return { ok: true, text, data: { tables: tables.length, totalRows } };
|
||
},
|
||
},
|
||
|
||
// ── L20: Automation workflows ────────────────────────
|
||
task_cleanup: {
|
||
name: 'Task Cleanup',
|
||
description: 'Архивирует старые заметки и просроченные напоминания',
|
||
levelReq: 15,
|
||
cooldownMs: 60 * 60 * 1000,
|
||
schedule: '0 3 * * *', // 3 AM daily
|
||
async run(ctx) {
|
||
const { store } = ctx;
|
||
let cleaned = 0;
|
||
|
||
// Archive delivered reminders older than 7 days
|
||
try {
|
||
const old = store.db.prepare(`
|
||
SELECT id FROM reminders
|
||
WHERE delivered = 1 AND delivered_at < datetime('now', '-7 days')
|
||
`).all();
|
||
if (old.length > 0) {
|
||
store.db.prepare(`
|
||
DELETE FROM reminders
|
||
WHERE delivered = 1 AND delivered_at < datetime('now', '-7 days')
|
||
`).run();
|
||
cleaned += old.length;
|
||
}
|
||
} catch (_) {}
|
||
|
||
// Count stale notes (>30 days, unpinned)
|
||
let staleNotes = 0;
|
||
try {
|
||
const stale = store.db.prepare(`
|
||
SELECT COUNT(*) as c FROM owner_notes
|
||
WHERE is_deleted = 0 AND pinned = 0 AND created_at < datetime('now', '-30 days')
|
||
`).get();
|
||
staleNotes = stale?.c || 0;
|
||
} catch (_) {}
|
||
|
||
let text = `🧹 Cleanup:\n`;
|
||
text += ` 🗑 Удалено доставленных напоминаний: ${cleaned}\n`;
|
||
if (staleNotes > 0) {
|
||
text += ` 📝 Старых заметок (>30д): ${staleNotes} — можно архивировать\n`;
|
||
}
|
||
text += cleaned === 0 && staleNotes === 0 ? '\n✅ Всё чисто!' : '';
|
||
|
||
return { ok: true, text, data: { cleaned, staleNotes } };
|
||
},
|
||
},
|
||
};
|
||
|
||
// ── Workflow Engine ──────────────────────────────────────
|
||
|
||
export class WorkflowEngine {
|
||
constructor({ store, crm, pes }) {
|
||
this.store = store;
|
||
this.crm = crm;
|
||
this.pes = pes;
|
||
this._lastRun = {}; // { workflowId: timestamp }
|
||
this._scheduledTimers = [];
|
||
this._firedThisMinute = new Set();
|
||
this._onAutoRun = null;
|
||
this.ensureTable();
|
||
}
|
||
|
||
// ── List available workflows for current level ────────
|
||
list(currentLevel) {
|
||
return Object.entries(WORKFLOW_DEFS)
|
||
.filter(([_, def]) => def.levelReq <= Math.floor(currentLevel))
|
||
.map(([id, def]) => ({
|
||
id,
|
||
name: def.name,
|
||
description: def.description,
|
||
levelReq: def.levelReq,
|
||
schedule: def.schedule || 'manual',
|
||
lastRun: this._lastRun[id] || null,
|
||
}));
|
||
}
|
||
|
||
// ── Run a workflow by ID ──────────────────────────────
|
||
async run(workflowId, currentLevel) {
|
||
const def = WORKFLOW_DEFS[workflowId];
|
||
if (!def) return { ok: false, text: `❌ Workflow "${workflowId}" не найден` };
|
||
if (def.levelReq > Math.floor(currentLevel)) {
|
||
return { ok: false, text: `🔒 ${def.name} разблокируется на уровне ${def.levelReq}` };
|
||
}
|
||
|
||
// Cooldown check
|
||
const lastRun = this._lastRun[workflowId];
|
||
if (lastRun && (Date.now() - lastRun) < def.cooldownMs) {
|
||
const waitSec = Math.ceil((def.cooldownMs - (Date.now() - lastRun)) / 1000);
|
||
return { ok: false, text: `⏳ ${def.name} — cooldown ${waitSec}с` };
|
||
}
|
||
|
||
// Execute
|
||
const startMs = Date.now();
|
||
try {
|
||
const ctx = { store: this.store, crm: this.crm, pes: this.pes };
|
||
const result = await def.run(ctx);
|
||
this._lastRun[workflowId] = Date.now();
|
||
|
||
// Log to DB
|
||
this._logRun(workflowId, result.ok, Date.now() - startMs, result.data);
|
||
|
||
// Trigger PES event
|
||
if (this.pes) {
|
||
this.pes.event({
|
||
type: 'workflow_completed',
|
||
from: 'system',
|
||
data: { workflow: workflowId, success: result.ok },
|
||
});
|
||
}
|
||
|
||
return result;
|
||
} catch (err) {
|
||
this._logRun(workflowId, false, Date.now() - startMs, { error: err.message });
|
||
return { ok: false, text: `❌ ${def.name} ошибка: ${err.message}` };
|
||
}
|
||
}
|
||
|
||
// ── Schedule-based auto-run (accurate time-based) ─────
|
||
startScheduler(currentLevel) {
|
||
this.stopScheduler();
|
||
this._schedulerLevel = currentLevel;
|
||
|
||
// Check every 60 seconds if any scheduled workflow should fire
|
||
this._schedulerTimer = setInterval(() => {
|
||
this._tickScheduler();
|
||
}, 60 * 1000);
|
||
|
||
// Run first tick immediately
|
||
this._tickScheduler();
|
||
console.log('[WORKFLOW] Scheduler started, level', Math.floor(currentLevel));
|
||
}
|
||
|
||
_tickScheduler() {
|
||
const now = new Date();
|
||
const currentMinute = now.getMinutes();
|
||
const currentHour = now.getHours();
|
||
|
||
// Clear fired set each tick (fires every 60s, so previous minute's keys are stale)
|
||
this._firedThisMinute.clear();
|
||
|
||
for (const [id, def] of Object.entries(WORKFLOW_DEFS)) {
|
||
if (!def.schedule) continue;
|
||
|
||
const parsed = this._parseCron(def.schedule);
|
||
if (!parsed) continue;
|
||
|
||
// Check if current time matches the cron pattern
|
||
if (!this._cronMatches(parsed, currentMinute, currentHour)) continue;
|
||
|
||
// Prevent double-fire within same minute
|
||
const key = `${id}_${currentHour}_${currentMinute}`;
|
||
if (this._firedThisMinute.has(key)) continue;
|
||
|
||
// Level check
|
||
let level = this._schedulerLevel;
|
||
try {
|
||
const stats = this.store.getStats();
|
||
if (stats) level = stats.level;
|
||
} catch (_) {}
|
||
|
||
if (def.levelReq > Math.floor(level)) continue;
|
||
|
||
this._firedThisMinute.add(key);
|
||
console.log(`[WORKFLOW] Auto-running ${id} (matched ${def.schedule})`);
|
||
|
||
this.run(id, level).then(result => {
|
||
// Notify via onAutoRun callback if set
|
||
if (this._onAutoRun && result.ok) {
|
||
this._onAutoRun(id, result);
|
||
}
|
||
}).catch(err => {
|
||
console.error(`[WORKFLOW] Scheduled ${id} error:`, err.message);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Set callback for auto-run notifications (telegram sends message)
|
||
onAutoRun(callback) {
|
||
this._onAutoRun = callback;
|
||
}
|
||
|
||
stopScheduler() {
|
||
if (this._schedulerTimer) clearInterval(this._schedulerTimer);
|
||
this._schedulerTimer = null;
|
||
this._scheduledTimers = [];
|
||
}
|
||
|
||
// ── Get run history ───────────────────────────────────
|
||
getHistory(limit = 10) {
|
||
try {
|
||
return this.store.db.prepare(`
|
||
SELECT * FROM workflow_runs ORDER BY id DESC LIMIT ?
|
||
`).all(limit);
|
||
} catch (_) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// ── Internal: log run to DB ───────────────────────────
|
||
_logRun(workflowId, success, durationMs, data) {
|
||
try {
|
||
this.store.db.prepare(`
|
||
INSERT INTO workflow_runs (workflow_id, success, duration_ms, result_data)
|
||
VALUES (?, ?, ?, ?)
|
||
`).run(workflowId, success ? 1 : 0, durationMs, data ? JSON.stringify(data) : null);
|
||
} catch (_) {
|
||
// Table might not exist yet on first run
|
||
}
|
||
}
|
||
|
||
// ── Internal: parse cron expression ──────────────────
|
||
_parseCron(schedule) {
|
||
// Supports: "*/N * * * *", "M H * * *"
|
||
const parts = schedule.trim().split(/\s+/);
|
||
if (parts.length < 5) return null;
|
||
const [minPart, hourPart] = parts;
|
||
return { minPart, hourPart };
|
||
}
|
||
|
||
_cronMatches(parsed, currentMin, currentHour) {
|
||
const { minPart, hourPart } = parsed;
|
||
|
||
// Check minute
|
||
if (minPart.startsWith('*/')) {
|
||
const interval = parseInt(minPart.slice(2));
|
||
if (isNaN(interval) || currentMin % interval !== 0) return false;
|
||
} else if (minPart !== '*') {
|
||
if (parseInt(minPart) !== currentMin) return false;
|
||
}
|
||
|
||
// Check hour
|
||
if (hourPart.startsWith('*/')) {
|
||
const interval = parseInt(hourPart.slice(2));
|
||
if (isNaN(interval) || currentHour % interval !== 0) return false;
|
||
} else if (hourPart !== '*') {
|
||
if (parseInt(hourPart) !== currentHour) return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// ── Ensure workflow_runs table exists ───────────────
|
||
ensureTable() {
|
||
try {
|
||
this.store.db.prepare(`
|
||
CREATE TABLE IF NOT EXISTS workflow_runs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
workflow_id TEXT NOT NULL,
|
||
success INTEGER DEFAULT 1,
|
||
duration_ms INTEGER DEFAULT 0,
|
||
result_data TEXT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
`).run();
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
export { WORKFLOW_DEFS };
|
||
export default WorkflowEngine;
|