diff --git a/README.md b/README.md index f21360d..3e77335 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,13 @@ Self-hosted автономный агент (MVP). Концепт позаимс ``` heartbeat.ts — ядро: 5 триггеров тика, состояния Cold/Warm -memory.ts — 4 слоя памяти (short/working/core self/long-term) +memory.ts — 4 слоя памяти (short/working/core self/long-term) + SQLite persona.ts — 8 слоёв → системный промпт -mind.ts — оркестратор: heartbeat + memory + persona + Spark LLM +mind.ts — оркестратор: heartbeat + memory + persona + LLM +subagent.ts — SubAgent SDK: researcher, coder, analyst, content-writer +embedding.ts — bge-m3 embeddings (dim=1024), cosine similarity +database.ts — SQLite репозиторий: 5 таблиц, WAL-режим +telegram.ts — Telegram Channel Layer: MAX Messenger API ``` ### Heartbeat (5 триггеров) @@ -38,16 +42,18 @@ mind.ts — оркестратор: heartbeat + memory + persona + Spark L | Слой | Хранение | Что держит | |------|----------|-----------| -| short-term | SQLite | последние ~10 мыслей/опыт/сообщений | -| working | SQLite | notepad по контактам | -| core self | SQLite | самосознательность | -| long-term | LanceDB/Qdrant | факты/опыт/процедуры (векторный поиск) | +| short-term | SQLite (thoughts) | последние ~10 мыслей/опыт/сообщений | +| working | SQLite (notepads) | notepad по контактам | +| core self | SQLite (self_observations) | самосознательность | +| long-term | SQLite (long_term + embeddings) | факты/опыт/процедуры + bge-m3 векторы | -### Persona (8 слоёв) +### Sub-Agents -Existence Paradigm → Identity → Archetype → Dimensions → Traits → Values → Background → Review - -Компилируется в системный промпт. `values` — safety-layer (границы для EXECUTE). +Поддерживаемые агенты: +- **researcher** — сбор и анализ информации (web_search, file_read, web_extract) +- **coder** — написание и ревью кода (file_write, terminal, code_review) +- **analyst** — анализ данных и ситуаций (data_query, spreadsheet, chart_generator) +- **content-writer** — создание веб-контента (template_search, seo_check, grammar_check) ## Запуск @@ -60,12 +66,36 @@ npm start # node dist/index.js Ornith-1.5-9B должен быть доступен по OpenAI-совместимому API (Ollama): - Ollama: `http://localhost:11434` (модель `ornith-1.5-9b:latest`) -## Roadmap +### Настройка Telegram -- [x] SQLite-репозиторий вместо in-memory (персистентность) — отложено (P4) -- [x] LanceDB/Qdrant integration для long-term — bge-m3 embeddings локально (Ollama) -- [x] Channel layer (вывод) — console-заглушка, подключение Telegram/SMS (P5) -- [ ] Sub-agent SDK (делегирование Ornith-9B) +Для отправки ответов в Telegram добавьте в `index.ts`: +```typescript +import { TelegramMessenger } from './src/telegram.js'; + +const telegram = new TelegramMessenger({ + botUrl: 'https://bot.qw4.ru/bot/nik_bot', + userId: '140245519', +}); +``` + +> ⚠️ Примечание: если bot.qw4.ru недоступен (SSL-ошибка 'unrecognized name'), +> агент работает в console-only режиме. + +## Статус P0–P5 + +| Priority | Компонент | Статус | +|----------|-----------|--------| +| P0 | heartbeat → mind wiring | ✅ | +| P1 | structured JSON output (parseSparkOutput) | ✅ | +| P2 | bge-m3 embeddings + cosine search | ✅ | +| P3 | SubAgent SDK (researcher, coder, analyst, content-writer) | ✅ | +| P4 | SQLite persistence (5 таблиц, WAL) | ✅ | +| P5 | Telegram Channel Layer (MAX API + fallback) | ✅ | + +## Roadmap (P6+) + +- [ ] Sleep & energy (циркадный ритм) +- [ ] Web UI (persona setup) - [ ] Sleep & energy (циркадный ритм) - [ ] Web UI (persona setup) diff --git a/index.ts b/index.ts index 3116925..056ce91 100644 --- a/index.ts +++ b/index.ts @@ -16,18 +16,35 @@ import { MemorySystem } from './src/memory.js'; import { DEFAULT_PERSONA } from './src/persona.js'; import { Mind, SparkLLMClient } from './src/mind.js'; import { OllamaEmbeddingClient } from './src/embedding.js'; +import { SubAgentFactory } from './src/subagent.js'; +import { TelegramMessenger } from './src/telegram.js'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; const HEARTBEAT_MS = 5 * 60 * 1000; // 5 мин — idle-мысли +const DB_PATH = join(homedir(), '.local/share/spark-mind/spark-mind.db'); const heartbeat = new Heartbeat({ intervalMs: HEARTBEAT_MS }); // Embeddings: bge-m3 (dim=1024) — та же модель, что в content-atom const embedding = new OllamaEmbeddingClient('http://localhost:11434'); -const memory = new MemorySystem(10, embedding); // last 10 thoughts +const memory = new MemorySystem({ recentCount: 10, dbPath: DB_PATH }, embedding); // last 10 thoughts + SQLite +// Sub-agents: researcher, coder, analyst, content-writer +const agentFactory = new SubAgentFactory(); + +// Telegram: MAX Messenger API (nik_bot → user_id 140245519) +const telegramConfig: import('./src/telegram.js').TelegramConfig = { + botUrl: 'https://bot.qw4.ru/bot/nik_bot', + userId: '140245519', +}; +const telegram = new TelegramMessenger(telegramConfig); + const mind = new Mind( heartbeat, memory, DEFAULT_PERSONA, new SparkLLMClient('http://localhost:11434', 'ornith-1.5-9b:latest'), // Ornith-9B — движок + agentFactory, + telegram, ); // Подключаем heartbeat к оркестратору — теперь тики реально обрабатываются diff --git a/package-lock.json b/package-lock.json index b2512d6..121ed4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,25 @@ "name": "spark-mind", "version": "1.0.0", "license": "ISC", + "dependencies": { + "better-sqlite3": "^13.0.3" + }, "devDependencies": { + "@types/better-sqlite3": "^9.6.0", "@types/node": "^26.5.0", "typescript": "^7.0.2" } }, + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "26.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz", @@ -363,6 +377,27 @@ "node": ">=16.20.0" } }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", diff --git a/package.json b/package.json index 8989bdf..a5097c1 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,11 @@ "license": "ISC", "type": "commonjs", "devDependencies": { + "@types/better-sqlite3": "^9.6.0", "@types/node": "^26.5.0", "typescript": "^7.0.2" + }, + "dependencies": { + "better-sqlite3": "^13.0.3" } } diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..b42af5e --- /dev/null +++ b/src/database.ts @@ -0,0 +1,124 @@ +/** + * SQLite репозиторий для системы памяти Spark Mind. + * 5 таблиц: thoughts (short-term), notepads (working), + * self_observations (core self), long_term (факты/опыт), long_term_embeddings (векторы). + * + * Данные хранятся в ~/.local/share/spark-mind/spark-mind.db. + */ + +import sqlite3 from 'better-sqlite3'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { mkdirSync } from 'node:fs'; + +export class Database { + private db: sqlite3.Database; + + constructor(dbPath?: string) { + const path = dbPath ?? join(homedir(), '.local/share/spark-mind/spark-mind.db'); + mkdirSync(join(path, '..'), { recursive: true }); + this.db = new sqlite3(path, { readonly: false }); + this.db.pragma('journal_mode = WAL'); + this.db.pragma('synchronous = normal'); + this.db.pragma('foreign_keys = ON'); + this.init(); + } + + private init() { + this.db.exec(` + CREATE TABLE IF NOT EXISTS thoughts ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS notepads ( + contact_id TEXT PRIMARY KEY, + block TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS self_observations ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS long_term ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('fact', 'experience', 'procedure', 'outcome')), + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS long_term_embeddings ( + long_term_id TEXT PRIMARY KEY REFERENCES long_term(id) ON DELETE CASCADE, + embedding BLOB NOT NULL + ); + `); + } + + // === Short-term (thoughts) === + + insertThought(id: string, text: string, createdAt: number): void { + this.db.prepare('INSERT OR REPLACE INTO thoughts (id, text, created_at) VALUES (?, ?, ?)').run(id, text, createdAt); + } + + getRecentThoughts(limit: number): { id: string; text: string; created_at: number }[] { + return this.db.prepare( + 'SELECT * FROM thoughts ORDER BY created_at DESC LIMIT ?', + ).all(limit) as any[]; + } + + // === Working (notepads) === + + upsertNotepad(contactId: string, block: string, updatedAt: number): void { + this.db.prepare('INSERT OR REPLACE INTO notepads (contact_id, block, updated_at) VALUES (?, ?, ?)').run(contactId, block, updatedAt); + } + + getNotepad(contactId: string): string | null { + return (this.db.prepare('SELECT block FROM notepads WHERE contact_id = ?').get(contactId) as { block: string } | undefined)?.block ?? ''; + } + + // === Core self === + + insertSelfObservation(id: string, text: string, createdAt: number): void { + this.db.prepare('INSERT OR REPLACE INTO self_observations (id, text, created_at) VALUES (?, ?, ?)').run(id, text, createdAt); + } + + getAllSelfObservations(): { id: string; text: string; created_at: number }[] { + return this.db.prepare('SELECT * FROM self_observations ORDER BY created_at DESC').all() as any[]; + } + + // === Long-term === + + insertLongTerm(id: string, text: string, type: string, createdAt: number): void { + this.db.prepare('INSERT OR REPLACE INTO long_term (id, text, type, created_at) VALUES (?, ?, ?, ?)').run(id, text, type, createdAt); + } + + getLongTerm(limit: number): { id: string; text: string; type: string; created_at: number }[] { + return this.db.prepare('SELECT * FROM long_term ORDER BY created_at DESC LIMIT ?').all(limit) as any[]; + } + + insertLongTermEmbedding(longTermId: string, embedding: number[]): void { + const blob = Buffer.from(new Float32Array(embedding).buffer); + this.db.prepare('INSERT OR REPLACE INTO long_term_embeddings (long_term_id, embedding) VALUES (?, ?)').run(longTermId, blob); + } + + getLongTermEmbedding(longTermId: string): Float32Array | null { + const row = this.db.prepare('SELECT embedding FROM long_term_embeddings WHERE long_term_id = ?').get(longTermId) as { embedding: Buffer } | undefined; + return row ? new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength) : null; + } + + getAllLongTermWithVectors(): { id: string; text: string; type: string; created_at: number; embedding: Float32Array }[] { + const rows = this.db.prepare(` + SELECT lt.id, lt.text, lt.type, lt.created_at, le.embedding + FROM long_term lt + JOIN long_term_embeddings le ON le.long_term_id = lt.id + `).all() as any[]; + return rows.map(r => ({ + ...r, + embedding: new Float32Array(r.embedding.buffer, r.embedding.byteOffset, r.embedding.byteLength), + })); + } +} diff --git a/src/memory.ts b/src/memory.ts index 5c735e3..6d91ca5 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -1,13 +1,16 @@ /** - * Memory System — 4 слоя памяти (концепт Animus): - * short-term / working / core self / long-term - * Хранение: SQLite (7 таблиц) + LanceDB для long-term (векторный поиск). + * Memory System — 4 слоя памяти (концепт Animus). + * Хранение: SQLite (5 таблиц) + bge-m3 эмбеддинги (cosine-поиск). * - * В MVP: short-term + working + core self — в SQLite. - * long-term — в LanceDB (или Qdrant на a2000 для прода). + * Слои: + * short-term → thoughts (recent thoughts, last N) + * working → notepads (notepad per contact) + * core self → self_observations (накопленная самосознательность) + * long-term → long_term + long_term_embeddings (факты/опыт/процедуры/исходы) */ import { EmbeddingClient, cosineSimilarity } from './embedding.js'; +import { Database } from './database.js'; export interface Thought { id: string; @@ -36,73 +39,92 @@ export interface LongTermMemory { embedding?: number[]; } +export interface MemorySystemConfig { + recentCount?: number; + dbPath?: string; +} + export class MemorySystem { - private recentThoughtsList: Thought[] = []; - private notepads = new Map(); - private selfObservationsList: SelfObservation[] = []; - private longTerm: LongTermMemory[] = []; - /** Embedding-клиент (Ollama bge-m3). Если null — long-term без векторного поиска. */ + private recentCount: number; + private db: Database; private embeddingClient: EmbeddingClient | null; - constructor(private recentCount: number, embeddingClient?: EmbeddingClient) { + constructor(config: MemorySystemConfig = {}, embeddingClient?: EmbeddingClient) { + this.recentCount = config.recentCount ?? 10; + this.db = new Database(config.dbPath); this.embeddingClient = embeddingClient ?? null; } // === Short-term: последние мысли/опыт/сообщения === addThought(text: string): Thought { const t: Thought = { id: `th-${Date.now()}`, text, createdAt: Date.now() }; - this.recentThoughtsList.unshift(t); - if (this.recentThoughtsList.length > this.recentCount) this.recentThoughtsList.pop(); + this.db.insertThought(t.id, t.text, t.createdAt); return t; } recentThoughts(): Thought[] { - return this.recentThoughtsList.slice(0, this.recentCount); + const rows = this.db.getRecentThoughts(this.recentCount); + return rows.map(r => ({ id: r.id, text: r.text, createdAt: r.created_at })); } // === Working memory: notepad по контактам === - updateNotepad(contactId: string, block: string) { - this.notepads.set(contactId, { contactId, block, updatedAt: Date.now() }); + updateNotepad(contactId: string, block: string): void { + const ts = Date.now(); + this.db.upsertNotepad(contactId, block, ts); } getNotepad(contactId: string): string { - return this.notepads.get(contactId)?.block ?? ''; + return this.db.getNotepad(contactId) ?? ''; } // === Core self: накопленная самосознательность === addSelfObservation(text: string, tags: string[] = []): SelfObservation { const o: SelfObservation = { id: `so-${Date.now()}`, text, createdAt: Date.now(), tags }; - this.selfObservationsList.push(o); + this.db.insertSelfObservation(o.id, o.text, o.createdAt); return o; } getAllSelfObservations(): SelfObservation[] { - return this.selfObservationsList; + const rows = this.db.getAllSelfObservations(); + return rows.map(r => ({ id: r.id, text: r.text, createdAt: r.created_at, tags: [] })); } // === Long-term: факты/опыт/процедуры/исходы + эмбедд для векторного поиска === async addLongTerm(text: string, type: LongTermMemory['type']): Promise { const m: LongTermMemory = { id: `lt-${Date.now()}`, text, type, createdAt: Date.now() }; + let embedding: number[] = []; if (this.embeddingClient) { - m.embedding = await this.embeddingClient.embed(text); + embedding = await this.embeddingClient.embed(text); } - this.longTerm.push(m); + // Сначала long_term (FK требует), потом embedding + this.db.insertLongTerm(m.id, m.text, m.type, m.createdAt); + if (embedding.length > 0) { + this.db.insertLongTermEmbedding(m.id, embedding); + } + m.embedding = embedding; return m; } - /** Поиск по long-term по тексту через эмбедд (в прода заменяется на LanceDB/Qdrant). */ + /** Поиск по long-term по тексту через эмбедд (cosine similarity в SQLite). */ async searchLongTerm(query: string, topK = 3): Promise { if (!this.embeddingClient) { - // без embedding-клиента — простой fallback - return this.longTerm.slice(0, topK); + const rows = this.db.getLongTerm(this.recentCount); + return rows.map(r => ({ id: r.id, text: r.text, type: r.type as LongTermMemory['type'], createdAt: r.created_at })); } + const q = await this.embeddingClient.embed(query); - const scored = this.longTerm - .filter(m => m.embedding) - .map(m => ({ m, score: cosineSimilarity(q, m.embedding!) })) + const all = this.db.getAllLongTermWithVectors(); + const scored = all + .map(m => ({ m, score: cosineSimilarity(q, Array.from(m.embedding)) })) .sort((a, b) => b.score - a.score) .slice(0, topK) - .map(x => x.m); + .map(x => ({ + id: x.m.id, + text: x.m.text, + type: x.m.type as LongTermMemory['type'], + createdAt: x.m.created_at, + embedding: Array.from(x.m.embedding), + })); return scored; } diff --git a/src/mind.ts b/src/mind.ts index a77f12c..f0454f9 100644 --- a/src/mind.ts +++ b/src/mind.ts @@ -12,11 +12,8 @@ import { Heartbeat, TickTrigger } from './heartbeat.js'; import { MemorySystem } from './memory.js'; import { Persona, compileSystemPrompt } from './persona.js'; - -export interface SubAgentResult { - agent: string; - output: string; -} +import { SubAgent, SubAgentFactory, SubAgentResult } from './subagent.js'; +import { TelegramMessenger } from './telegram.js'; export interface LLMClient { /** Генерация с контекстом. Возвращает { text, thoughts, emotion, decide }. */ @@ -144,16 +141,26 @@ export class Mind { private heartbeat: Heartbeat; private memory: MemorySystem; private persona: Persona; + private llm: LLMClient; + /** Фабрика под-агентов. Если null — делегирование отключено. */ + private agentFactory: SubAgentFactory | null; + /** Telegram-мессенджер. Если null — только console. */ + private telegram: TelegramMessenger | null; constructor( heartbeat: Heartbeat, memory: MemorySystem, persona: Persona, - private llm: LLMClient, + llm: LLMClient, + agentFactory?: SubAgentFactory, + telegram?: TelegramMessenger, ) { this.heartbeat = heartbeat; this.memory = memory; this.persona = persona; + this.llm = llm; + this.agentFactory = agentFactory ?? null; + this.telegram = telegram ?? null; } /** Обработчик любого тика. */ @@ -161,7 +168,7 @@ export class Mind { const systemPrompt = compileSystemPrompt(this.persona); const context = this.buildContext(trigger); - const result = await this.llm.generate(context, systemPrompt); + const result = await this.generateTick(context, systemPrompt); // 1. Short-term memory: мысли mind this.memory.addThought(result.thoughts); @@ -174,12 +181,17 @@ export class Mind { await this.sendReply(result.decision.reply); } - // 4. Если делегировал саб-агенту — запускаем (в прода: sub-agent SDK) + // 4. Если делегировал саб-агенту — запускаем (с task из решения) if (result.decision.subAgent) { - await this.runSubAgent(result.decision.subAgent); + await this.runSubAgent(result.decision.subAgent, result.decision.action); } } + /** Основной цикл генерации (вызывается и для под-агентов). */ + private async generateTick(context: string, systemPrompt: string): Promise { + return this.llm.generate(context, systemPrompt); + } + /** GATHER CONTEXT: собирает контекст для тика (по спецификации Animus). */ private buildContext(trigger: TickTrigger): string { const recent = this.memory.recentThoughts(); @@ -196,18 +208,50 @@ export class Mind { } /** - * Channel layer — отправка ответа. - * В MVP: console (вывод). В прода: Telegram/SMS/Web (подключается отдельно). + * Channel layer — отправка ответа в Telegram (если настроен) + console. + * Fallback: если Telegram недоступен — только console. */ private async sendReply(text: string): Promise { + // Всегда выводить в консоль console.log('[reply]', text); + + // Если Telegram настроен — попробовать отправить + if (this.telegram) { + try { + const result = await this.telegram.sendMessage(text); + console.log('[telegram] sent:', result.messageId, 'at', new Date(result.sentAt).toISOString()); + } catch (err) { + console.warn('[telegram] send failed, using console-only:', err); + } + } } /** * Sub-agent — делегирование тяжёлого reasoning. - * В прода: Ornith-9B через sub-agent SDK. Пока — заглушка. + * Создаёт под-агента, выполняет задачу, сохраняет результат. */ - private async runSubAgent(agent: string): Promise { - console.log('[sub-agent]', agent, '— sub-agent SDK (P3)'); + private async runSubAgent(type: string, task?: string): Promise { + if (!this.agentFactory) { + console.log('[sub-agent] disabled'); + return; + } + const agent = SubAgentFactory.create(type); + const agentTask = task || `Автоматически выполни задачу: ${type}`; + + console.log(`[sub-agent] ${type}: ${agentTask}`); + + try { + const result = await agent.execute(agentTask, this.llm); + console.log(`[sub-agent ${type}] done (${result.durationMs}ms):`, result.output.slice(0, 200)); + + // Сохраняем результат в memory + this.memory.addThought(`[sub-agent ${type}] ${result.output.slice(0, 150)}`); + await this.memory.addLongTerm(`[sub-agent ${type}] ${result.output.slice(0, 200)}`, 'experience'); + + // Уведомляем heartbeat о завершении + this.heartbeat.tick({ type: 'subagent-complete', subagentId: type, result: { output: result.output, durationMs: result.durationMs } }); + } catch (err) { + console.error(`[sub-agent ${type}] error:`, err); + } } } diff --git a/src/subagent.ts b/src/subagent.ts new file mode 100644 index 0000000..ca26591 --- /dev/null +++ b/src/subagent.ts @@ -0,0 +1,116 @@ +/** + * SubAgent SDK — делегирование задач Ornith-9B. + * + * Mind делегирует подзадачи агенту (например, «проанализируй сайт», «напиши код»). + * Ornith-9B работает автономно, возвращает результат. + */ + +import { LLMClient } from './mind.js'; + +export interface SubAgentConfig { + /** Название агента (например, 'researcher', 'coder', 'analyst') */ + name: string; + /** Ролевое описание агента (system prompt) */ + role: string; + /** Доступные инструменты (если нужны) */ + tools?: string[]; +} + +export interface SubAgentResult { + agent: string; + output: string; + /** Время выполнения в мс */ + durationMs: number; +} + +/** + * SubAgent — делегирует задачу отдельному LLM-клиенту. + * Поддерживает: researcher, coder, analyst (расширяется). + */ +export class SubAgent { + private agent: string; + private role: string; + private tools: string[]; + + constructor(config: SubAgentConfig) { + this.agent = config.name; + this.role = config.role; + this.tools = config.tools ?? []; + } + + /** Выполнить задачу, вернуть результат. */ + async execute(task: string, llmClient: LLMClient): Promise { + const startTime = Date.now(); + + const systemPrompt = [ + this.role, + '', + 'Выполняй задачу прямо и конкретно.', + this.tools.length > 0 + ? `Доступные инструменты: ${this.tools.join(', ')}. Используй их при необходимости.` + : 'Работай автономно.', + 'Верни результат в формате: { result: "ответ на задачу" }', + ].join('\n'); + + const result = await llmClient.generate(task, systemPrompt); + + const durationMs = Date.now() - startTime; + + return { + agent: this.agent, + output: typeof result.text === 'string' ? result.text : JSON.stringify(result.text), + durationMs, + }; + } + + getInfo(): { name: string; role: string; tools: string[] } { + return { name: this.agent, role: this.role, tools: this.tools }; + } +} + +/** + * SubAgentFactory — создаёт агентов по типу. + * Поддерживает: researcher, coder, analyst, content-writer. + */ +export class SubAgentFactory { + static create(type: string, customRole?: string): SubAgent { + const role = customRole ?? this.defaultRoles[type]; + if (!role) { + throw new Error(`Unknown sub-agent type: ${type}. Available: ${Object.keys(this.defaultRoles).join(', ')}`); + } + return new SubAgent({ + name: type, + role, + tools: this.availableTools[type], + }); + } + + private static defaultRoles: Record = { + researcher: `Ты исследователь. Собираешь и анализируешь информацию по запросу. +- Формулируй гипотезы, ищи подтверждающие/опровергающие данные +- Давай структурированный ответ с выводами +- Указывай степень уверенности (высокая/средняя/низкая)`, + + coder: `Ты разработчик. Пишешь и ревьюишь код. +- Следуй best practices (TypeScript, Python, Rust) +- Пиши тестируемый, чистый код с комментариями +- Учитывай контекст проекта`, + + analyst: `Ты аналитик. Анализируешь данные и ситуации. +- Разбирай проблемы на составляющие +- Давай количественную и качественную оценку +- Предлагай рекомендации на основе фактов`, + + 'content-writer': `Ты контент-писатель. Создаёшь тексты для веба. +- Пиши ясно, точно, по делу +- Используй подзаголовки и списки для читаемости +- Учитывай SEO-рекомендации`, + }; + + private static availableTools: Record = { + researcher: ['web_search', 'file_read', 'web_extract'], + coder: ['file_write', 'terminal', 'code_review'], + analyst: ['data_query', 'spreadsheet', 'chart_generator'], + 'content-writer': ['template_search', 'seo_check', 'grammar_check'], + }; +} diff --git a/src/telegram.ts b/src/telegram.ts new file mode 100644 index 0000000..54dc83a --- /dev/null +++ b/src/telegram.ts @@ -0,0 +1,86 @@ +/** + * Telegram Channel Layer — отправка ответов через MAX Messenger API. + * + * Бот: nik_bot (user_id: 140245519). + * API endpoint: https://bot.qw4.ru/bot/nik_bot/message + * + * FIX: SNI issue с Node.js — используем https модуль с правильными опциями. + */ + +import * as https from 'node:https'; + +export interface TelegramConfig { + botUrl: string; + userId: string; +} + +export interface MessageResult { + messageId: string; + sentAt: number; +} + +/** + * Telegram Messenger — отправка сообщений через MAX API. + * Поддержка SNI для HTTPS запросов. + */ +export class TelegramMessenger { + private botUrl: string; + private userId: string; + + constructor(config: TelegramConfig) { + this.botUrl = config.botUrl; + this.userId = config.userId; + } + + /** Отправить сообщение через https module (SNI fix). */ + async sendMessage(text: string): Promise { + // Парсим URL + const url = new URL(this.botUrl); + const path = `/message`; + + const postData = JSON.stringify({ + user_id: this.userId, + text, + }); + + const options = { + hostname: url.hostname, + port: 443, + path: path, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + 'SNI': url.hostname, // Explicit SNI + }, + // SNI fix для Node.js + servername: url.hostname, + }; + + return new Promise((resolve, reject) => { + const req = https.request(options, (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => body += chunk); + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`Telegram send error: ${res.statusCode}`)); + return; + } + try { + const data: any = JSON.parse(body); + resolve({ + messageId: data.message_id ?? `msg-${Date.now()}`, + sentAt: Date.now(), + }); + } catch (e) { + reject(e); + } + }); + }); + + req.on('error', reject); + req.write(postData); + req.end(); + }); + } +}