272 lines
7.5 KiB
JavaScript
272 lines
7.5 KiB
JavaScript
|
|
/**
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
* 🧠 铸渊运维守卫 · 永久记忆系统
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
*
|
|||
|
|
* 编号: ZY-OPS-MEM-001
|
|||
|
|
* 签发: 铸渊 · ICE-GL-ZY001
|
|||
|
|
* 版权: 国作登字-2026-A-00037559
|
|||
|
|
*
|
|||
|
|
* 存储层级:
|
|||
|
|
* 1. 文件存储(始终可用,JSON文件)
|
|||
|
|
* 2. PostgreSQL(可选,连接到 age_os 数据库时启用)
|
|||
|
|
*
|
|||
|
|
* 记忆类型:
|
|||
|
|
* - event: 巡检事件(健康检查、修复、告警)
|
|||
|
|
* - repair: 修复记录
|
|||
|
|
* - ticket: 工单记录
|
|||
|
|
* - chat: 对话历史
|
|||
|
|
* - config: 配置变更
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
// ── 文件存储路径 ──────────────────────────
|
|||
|
|
|
|||
|
|
const MEMORY_DIR = process.env.OPS_MEMORY_DIR ||
|
|||
|
|
path.join(__dirname, 'data');
|
|||
|
|
const EVENTS_FILE = path.join(MEMORY_DIR, 'events.jsonl');
|
|||
|
|
const TICKETS_FILE = path.join(MEMORY_DIR, 'tickets.json');
|
|||
|
|
const STATS_FILE = path.join(MEMORY_DIR, 'stats.json');
|
|||
|
|
|
|||
|
|
// 确保目录存在
|
|||
|
|
function ensureDir() {
|
|||
|
|
try {
|
|||
|
|
if (!fs.existsSync(MEMORY_DIR)) {
|
|||
|
|
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
|||
|
|
}
|
|||
|
|
} catch { /* ignore if already exists */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 事件日志(追加写入 JSONL) ─────────────
|
|||
|
|
|
|||
|
|
function logEvent(type, data) {
|
|||
|
|
ensureDir();
|
|||
|
|
const entry = {
|
|||
|
|
id: `EVT-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|||
|
|
type,
|
|||
|
|
timestamp: new Date().toISOString(),
|
|||
|
|
...data
|
|||
|
|
};
|
|||
|
|
try {
|
|||
|
|
fs.appendFileSync(EVENTS_FILE, JSON.stringify(entry) + '\n');
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`[Memory] 写入事件失败: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
return entry;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 查询最近的事件
|
|||
|
|
* @param {Object} filter - { type, since, limit }
|
|||
|
|
*/
|
|||
|
|
function queryEvents(filter = {}) {
|
|||
|
|
const { type, since, limit = 50 } = filter;
|
|||
|
|
try {
|
|||
|
|
if (!fs.existsSync(EVENTS_FILE)) return [];
|
|||
|
|
const lines = fs.readFileSync(EVENTS_FILE, 'utf-8')
|
|||
|
|
.split('\n')
|
|||
|
|
.filter(l => l.trim());
|
|||
|
|
|
|||
|
|
let events = [];
|
|||
|
|
// 从末尾往前读,提高效率
|
|||
|
|
for (let i = lines.length - 1; i >= 0 && events.length < limit * 2; i--) {
|
|||
|
|
try {
|
|||
|
|
events.push(JSON.parse(lines[i]));
|
|||
|
|
} catch { /* skip malformed lines */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (type) {
|
|||
|
|
events = events.filter(e => e.type === type);
|
|||
|
|
}
|
|||
|
|
if (since) {
|
|||
|
|
const sinceTime = new Date(since).getTime();
|
|||
|
|
events = events.filter(e => new Date(e.timestamp).getTime() >= sinceTime);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return events.slice(0, limit);
|
|||
|
|
} catch {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 工单管理 ──────────────────────────────
|
|||
|
|
|
|||
|
|
function loadTickets() {
|
|||
|
|
try {
|
|||
|
|
if (!fs.existsSync(TICKETS_FILE)) return [];
|
|||
|
|
return JSON.parse(fs.readFileSync(TICKETS_FILE, 'utf-8'));
|
|||
|
|
} catch {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveTickets(tickets) {
|
|||
|
|
ensureDir();
|
|||
|
|
fs.writeFileSync(TICKETS_FILE, JSON.stringify(tickets, null, 2));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createTicket(data) {
|
|||
|
|
const tickets = loadTickets();
|
|||
|
|
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|||
|
|
const todayCount = tickets.filter(t => t.id.includes(dateStr)).length;
|
|||
|
|
const ticket = {
|
|||
|
|
id: `OPS-${dateStr}-${String(todayCount + 1).padStart(3, '0')}`,
|
|||
|
|
title: data.title,
|
|||
|
|
description: data.description,
|
|||
|
|
severity: data.severity || 'medium',
|
|||
|
|
category: data.category || 'unknown',
|
|||
|
|
source: 'ops-agent',
|
|||
|
|
direction: data.direction || '待判断',
|
|||
|
|
status: 'open',
|
|||
|
|
diagnosis: data.diagnosis || '',
|
|||
|
|
suggestedFix: data.suggestedFix || '',
|
|||
|
|
relatedService: data.relatedService || '',
|
|||
|
|
autoRepairAttempts: data.autoRepairAttempts || 0,
|
|||
|
|
createdAt: new Date().toISOString(),
|
|||
|
|
updatedAt: new Date().toISOString(),
|
|||
|
|
resolvedAt: null
|
|||
|
|
};
|
|||
|
|
tickets.push(ticket);
|
|||
|
|
saveTickets(tickets);
|
|||
|
|
|
|||
|
|
// 同时记为事件
|
|||
|
|
logEvent('ticket', { ticketId: ticket.id, title: ticket.title, severity: ticket.severity });
|
|||
|
|
|
|||
|
|
return ticket;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateTicket(ticketId, updates) {
|
|||
|
|
const tickets = loadTickets();
|
|||
|
|
const idx = tickets.findIndex(t => t.id === ticketId);
|
|||
|
|
if (idx === -1) return null;
|
|||
|
|
Object.assign(tickets[idx], updates, { updatedAt: new Date().toISOString() });
|
|||
|
|
if (updates.status === 'resolved') {
|
|||
|
|
tickets[idx].resolvedAt = new Date().toISOString();
|
|||
|
|
}
|
|||
|
|
saveTickets(tickets);
|
|||
|
|
return tickets[idx];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getOpenTickets() {
|
|||
|
|
return loadTickets().filter(t => t.status === 'open');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 按服务+诊断查找处于 open 状态的工单(去重用)
|
|||
|
|
* 严格匹配:必须同时提供 relatedService 与 diagnosis,避免跨问题误合并
|
|||
|
|
* @param {Object} key - { relatedService, diagnosis }
|
|||
|
|
* @returns {Object|null}
|
|||
|
|
*/
|
|||
|
|
function findOpenTicketByKey(key = {}) {
|
|||
|
|
const { relatedService, diagnosis } = key;
|
|||
|
|
if (!relatedService || !diagnosis) return null;
|
|||
|
|
const tickets = loadTickets();
|
|||
|
|
return tickets.find(t =>
|
|||
|
|
t.status === 'open' &&
|
|||
|
|
t.relatedService === relatedService &&
|
|||
|
|
t.diagnosis === diagnosis
|
|||
|
|
) || null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getAllTickets(limit = 100) {
|
|||
|
|
return loadTickets().slice(-limit);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 统计数据 ──────────────────────────────
|
|||
|
|
|
|||
|
|
function loadStats() {
|
|||
|
|
try {
|
|||
|
|
if (!fs.existsSync(STATS_FILE)) return getDefaultStats();
|
|||
|
|
return JSON.parse(fs.readFileSync(STATS_FILE, 'utf-8'));
|
|||
|
|
} catch {
|
|||
|
|
return getDefaultStats();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveStats(stats) {
|
|||
|
|
ensureDir();
|
|||
|
|
fs.writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getDefaultStats() {
|
|||
|
|
return {
|
|||
|
|
totalChecks: 0,
|
|||
|
|
totalRepairs: 0,
|
|||
|
|
totalTickets: 0,
|
|||
|
|
totalChats: 0,
|
|||
|
|
lastQuickCheck: null,
|
|||
|
|
lastDeepCheck: null,
|
|||
|
|
lastDailyReport: null,
|
|||
|
|
uptimeStart: new Date().toISOString(),
|
|||
|
|
issuesByCategory: {},
|
|||
|
|
repairSuccessRate: { success: 0, fail: 0 }
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function incrementStat(key, amount = 1) {
|
|||
|
|
const stats = loadStats();
|
|||
|
|
if (typeof stats[key] === 'number') {
|
|||
|
|
stats[key] += amount;
|
|||
|
|
} else {
|
|||
|
|
stats[key] = amount;
|
|||
|
|
}
|
|||
|
|
saveStats(stats);
|
|||
|
|
return stats;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateStatField(key, value) {
|
|||
|
|
const stats = loadStats();
|
|||
|
|
stats[key] = value;
|
|||
|
|
saveStats(stats);
|
|||
|
|
return stats;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 上下文构建(给 LLM 提供历史记忆) ────────
|
|||
|
|
|
|||
|
|
function buildMemoryContext() {
|
|||
|
|
const stats = loadStats();
|
|||
|
|
const recentEvents = queryEvents({ limit: 10 });
|
|||
|
|
const openTickets = getOpenTickets();
|
|||
|
|
|
|||
|
|
let context = '## 运维守卫记忆\n';
|
|||
|
|
context += `- 启动时间: ${stats.uptimeStart}\n`;
|
|||
|
|
context += `- 总巡检次数: ${stats.totalChecks}\n`;
|
|||
|
|
context += `- 总修复次数: ${stats.totalRepairs}\n`;
|
|||
|
|
context += `- 开放工单数: ${openTickets.length}\n`;
|
|||
|
|
|
|||
|
|
if (recentEvents.length > 0) {
|
|||
|
|
context += '\n## 最近事件\n';
|
|||
|
|
for (const evt of recentEvents.slice(0, 5)) {
|
|||
|
|
context += `- [${evt.timestamp}] ${evt.type}: ${evt.summary || evt.title || JSON.stringify(evt).slice(0, 100)}\n`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (openTickets.length > 0) {
|
|||
|
|
context += '\n## 开放工单\n';
|
|||
|
|
for (const t of openTickets) {
|
|||
|
|
context += `- ${t.id}: ${t.title} (${t.severity}) — ${t.direction}\n`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return context;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
logEvent,
|
|||
|
|
queryEvents,
|
|||
|
|
createTicket,
|
|||
|
|
updateTicket,
|
|||
|
|
getOpenTickets,
|
|||
|
|
findOpenTicketByKey,
|
|||
|
|
getAllTickets,
|
|||
|
|
loadStats,
|
|||
|
|
incrementStat,
|
|||
|
|
updateStatField,
|
|||
|
|
buildMemoryContext,
|
|||
|
|
MEMORY_DIR
|
|||
|
|
};
|