Compare commits

..
5 Commits
Author SHA1 Message Date
n1kt4r 288d05825f Add TODO.md: verified remaining gaps (sleep/energy, cron, dead memory layers, tool-runtime, config, tests, Telegram SSL, Spark arch) 2026-09-23 14:06:46 +05:00
n1kt4r bae171d6b4 P3-P5: SubAgent SDK, Telegram Channel, SQLite persistence, full pipeline
P3 - SubAgent SDK:
  - src/subagent.ts: researcher, coder, analyst, content-writer
  - Mind.runSubAgent() → SubAgentFactory.create() → execute()
  - Результаты сохраняются в long-term memory
  - Heartbeat.notify(subagent-complete) после завершения

P4 - SQLite persistence (5 таблиц, WAL):
  - src/database.ts: thoughts, notepads, self_observations, long_term, embeddings
  - memory.ts → Database (полная замена in-memory)
  - index.ts: dbPath → ~/.local/share/spark-mind/spark-mind.db

P5 - Telegram Channel Layer:
  - src/telegram.ts: https module (SNI fix), MAX Messenger API
  - mind.ts: sendReply() → Telegram + console fallback
  - index.ts: TelegramMessenger(config) → Mind

Fixes:
  - FK constraint: long_term INSERT → long_term_embeddings INSERT
  - JSON parse: nested objects, markdown fences
  - P5 telegram: graceful degradation on SSL error
2026-09-15 12:49:33 +05:00
n1kt4r a01f183680 Switch LLM engine to Ornith-1.5-9B (Spark X2.5-4B not loadable in Ollama); robust nested-JSON parsing; update README 2026-09-14 17:28:40 +05:00
n1kt4r 89b73db8a4 P1: robust JSON structured output (markdown fence cleanup, structured-output instruction, subAgent excluded from base schema to prevent gemma4:E4B silence); P2: bge-m3 embeddings via Ollama + cosine search; full pipeline verified on gemma4:E4B 2026-09-14 16:26:09 +05:00
n1kt4r a6fde100ca P0-P2: heartbeat→mind wiring, structured JSON output, bge-m3 embeddings 2026-09-14 16:20:45 +05:00
13 changed files with 788 additions and 95 deletions

No files matched your search

+60 -28
View File
@@ -2,25 +2,29 @@
Self-hosted автономный агент (MVP). Концепт позаимствован из
[Animus](https://github.com/Craigtut/animus) (Craigtut): heartbeat,
4 слоя памяти, persona в 8 слоёв. Движок — **Spark X2.5-4B** (локально, self-hosted).
4 слоя памяти, persona в 8 слоёв. Движок — **Ornith-1.5-9B** (локально, self-hosted, Ollama).
## Почему Spark X2.5-4B
## Почему Ornith-1.5-9B
- Локальный inference (llama.cpp / Ollama), никаких облачных ключей
- Нативная поддержка agent-инструментов (tool-calling) — саб-агенты
- 1M контекст (для heartbeat держим 4K-16K + внешняя память)
- ~59 t/s на Q4_K_M (RTX A2000 12GB)
- Локальный inference (Ollama), никаких облачных ключей
- Нативная поддержка tool-calling (саб-агенты) + thinking
- 262K контекст (для heartbeat держим 4K-16K + внешняя память)
- ~40-60 t/s на Q6_K
> ⚠️ 4B — это слабость для сложного reasoning. Стратегия:
> heartbeat-мысли на Spark (быстро/дёшево), саб-агенты на Ornith-9B (качественнее).
> ⚠️ Spark X2.5-4B не грузится в Ollama (unknown architecture 'spark2_5').
> Ornith-9B — рабочая замена: thinking + tools, дефицитный ресурс.
## Архитектура
```
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)
## Запуск
@@ -57,18 +63,44 @@ npm run build # tsc -p tsconfig.json (проверка типов)
npm start # node dist/index.js
```
Spark X2.5-4B должен быть доступен по OpenAI-совместимому API:
- Ollama: `http://localhost:11434`
- llama.cpp: `http://localhost:8080/v1`
Ornith-1.5-9B должен быть доступен по OpenAI-совместимому API (Ollama):
- Ollama: `http://localhost:11434` (модель `ornith-1.5-9b:latest`)
## Roadmap
### Настройка Telegram
- [ ] SQLite-репозиторий вместо in-memory (персистентность)
- [ ] LanceDB/Qdrant integration для long-term
- [ ] Channel layer (web/SMS/Discord)
- [ ] Sub-agent SDK (делегирование Ornith-9B)
- [ ] Sleep & energy (циркадный ритм)
- [ ] Web UI (persona setup)
Для отправки ответов в 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+)
Полный список незакрытых задач — в [TODO.md](./TODO.md).
Кратко:
- P6: sleep & energy, cron-планировщик, рабочие слои памяти (notepads / core self) в цикле mind, tool-runtime для под-агентов
- P7: Web UI (persona setup), конфиг/.env, retry+timeout, тесты
- Каналы: Telegram — обход SSL-проблемы bot.qw4.ru
- Движок: Spark X2.5-4B (unknown architecture в Ollama)
## License
+63
View File
@@ -0,0 +1,63 @@
# spark-mind — Оставшиеся TODO
Статус: **P0–P5 завершены** (heartbeat→mind, JSON output, embeddings, sub-agents,
SQLite, Telegram). Ниже — реально незакрытые пункты, проверенные по коду
(2026-09-15). Порядок — по приоритету.
## P6 — Функциональная полнота
- [ ] **Sleep & energy (циркадный ритм).** Сейчас heartbeat тикает всегда
с фиксированным интервалом 5 мин. Нужно: окна активности/сна, накопление
«энергии», снижение частоты тиков в пассивные периоды.
- [ ] **Планировщик задач (cron).** Тип триггера `{ type: 'task' }` объявлен
в `heartbeat.ts`, но **ничто его не эмитит** — планировщика нет
(`scheduleNext` — это idle-таймер, не cron). Нужен движок отложенных задач.
- [ ] **Рабочая память (notepads) в цикле mind.** Таблица `notepads` и методы
`updateNotepad()`/`getNotepad()` реализованы, но **вне memory.ts не
вызываются** — слой не участвует в `handleTick`.
- [ ] **Core self (self_observations) в цикле mind.** Аналогично: `addSelfObservation()` /
`getAllSelfObservations()` реализованы, но из mind не вызываются.
Самосознательность не накапливается автоматически.
- [ ] **Реальное исполнение инструментов под-агентов.** `subagent.ts` объявляет
список tools (web_search, terminal, file_write…), но это **строки-декларации** —
исполнения нет. Нужен tool-runtime для под-агентов.
## P7 — Инфраструктура
- [ ] **Web UI (persona setup).** Настройка 8 слоёв persona через интерфейс
вместо `DEFAULT_PERSONA` в коде.
- [ ] **Конфиг / .env.** URL-ы Ollama (`localhost:11434`), dbPath, Telegram
захардкожены в `index.ts`. Вынести в конфиг/`.env`.
- [ ] **Retry + timeout для LLM-запросов.** `fetch` в `mind.ts` и `embedding.ts`
без retry и без `AbortController` — один сетевой сбой роняет тик.
- [ ] **Тесты.** Их нет вообще. Минимум: unit на `parseSparkOutput` (nested JSON,
fences, fallback), на `Database` CRUD, на cosine-поиск.
## Каналы вывода
- [ ] **Telegram: обход SSL-проблемы bot.qw4.ru.** Сервер отдаёт
`tlsv1 unrecognized name` (SSL alert 112) при TLS-handshake — проблема
на стороне сервера, не клиента (проверено curl-ом и Node https).
Агент сейчас работает в console-only fallback. Варианты: починить
SNI/сертификат на qw4.ru, поднять локальный MAX-релей, или другой канал
(Telegram Bot API напрямую).
## Движок
- [ ] **Spark X2.5-4B.** Не грузится в текущей Ollama:
`error loading model: unknown model architecture: 'spark2_5'`.
Варианты: собрать совместимый GGUF, поднять через llama.cpp напрямую
(OpenAI-сервер на :8080), или оставить Ornith-1.5-9B как основной движок.
**Важно:** локальные модели пумолкают (пустой ответ), если `subAgent`
присутствует в JSON-схеме structured output — учитывать при правках промпта.
## Косметика
- [ ] Устаревший doc-комментарий в `mind.ts`: класс `SparkLLMClient` всё ещё
описан как «Реальный LLM-клиент для Spark X2.5-4B. В MVP — заглушка»,
хотя работает с Ornith-9B и без заглушки.
- [ ] Дефолтное имя модели в конструкторе `SparkLLMClient` = `'spark-x2.5-4b'`
(index.ts переопределяет на `ornith-1.5-9b:latest`, но дефолт вводит в заблуждение).
---
Проверено `grep`-ом по коду: dead-code слои, отсутствие cron/retry/tests/.env, SSL-ошибка Telegram.
+26 -3
View File
@@ -15,23 +15,46 @@ import { Heartbeat, DEFAULT_CONFIG } from './src/heartbeat.js';
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 });
const memory = new MemorySystem(10); // last 10 thoughts
// Embeddings: bge-m3 (dim=1024) — та же модель, что в content-atom
const embedding = new OllamaEmbeddingClient('http://localhost:11434');
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'), // Ollama: Spark X2.5-4B
new SparkLLMClient('http://localhost:11434', 'ornith-1.5-9b:latest'), // Ornith-9B — движок
agentFactory,
telegram,
);
// Подключаем heartbeat к оркестратору — теперь тики реально обрабатываются
heartbeat.setOnTick((trigger) => mind.handleTick(trigger));
// Старт heartbeat
heartbeat.start();
console.log('[spark-mind] started — heartbeat every', HEARTBEAT_MS / 1000, 's');
// Демо: симулируем тик через 10 сек (в прода — реальные триггеры)
setTimeout(() => {
void mind.handleTick({ type: 'interval' });
void mind.handleTick({ type: 'message', contactId: 'demo', text: 'Привет' });
}, 10_000);
+35
View File
@@ -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",
+9 -2
View File
@@ -1,9 +1,12 @@
{
"name": "spark-mind",
"version": "1.0.0",
"description": "",
"main": "index.js",
"description": "Self-hosted autonomous agent (heartbeat + memory + persona) on Spark X2.5-4B",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"dev": "node --loader ts-node/esm index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
@@ -11,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"
}
}
+124
View File
@@ -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),
}));
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Embedding Client — локальные эмбеддинги через Ollama /api/embeddings.
*
* Модель: bge-m3 (dim=1024) — та же, что в content-atom.
* НЕ переключаться на nomic-embed-text (dim=768) — разный размер вектора
* = переиндексация Qdrant.
*/
export interface EmbeddingClient {
embed(text: string): Promise<number[]>;
}
/** Ollama EmbeddingClient — bge-m3. */
export class OllamaEmbeddingClient implements EmbeddingClient {
constructor(
private baseUrl: string,
private modelName = 'bge-m3',
) {}
async embed(text: string): Promise<number[]> {
const resp = await fetch(`${this.baseUrl}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: this.modelName, prompt: text }),
});
if (!resp.ok) throw new Error(`Embedding error: ${resp.status}`);
const data: any = await resp.json();
return data.embedding ?? [];
}
}
/**
* Векторная арифметика — cosine similarity.
* Держим здесь, чтобы memory.ts не зависел от внешних библиотек.
*/
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length === 0 || b.length === 0) return 0;
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return Math.sqrt(na) * Math.sqrt(nb) ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
}
+11 -2
View File
@@ -37,11 +37,18 @@ export class Heartbeat {
private sessionState: SessionState = 'cold';
private lastSessionId: string | null = null;
private readonly config: HeartbeatConfig;
/** Колбэк, который выполняется на каждом тике (в прода: mind.handleTick). */
private onTick?: (trigger: TickTrigger) => Promise<void>;
constructor(config: Partial<HeartbeatConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/** Подключает обработчик тика (оркестратор mind). */
setOnTick(fn: (trigger: TickTrigger) => Promise<void>) {
this.onTick = fn;
}
/** Любой тик сбрасывает idle-таймер (по спецификации Animus). */
private resetTimer() {
if (this.timer) clearTimeout(this.timer);
@@ -68,8 +75,10 @@ export class Heartbeat {
const sessionId = await this.resolveSession(trigger);
this.sessionState = 'active';
// здесь вызывается mind (см. mind.ts)
// await this.runMind(sessionId, trigger);
// вызываем mind — теперь реальный запуск оркестратора
if (this.onTick) {
await this.onTick(trigger);
}
this.sessionState = 'warm';
this.lastSessionId = sessionId;
+66 -37
View File
@@ -1,12 +1,17 @@
/**
* 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;
text: string;
@@ -34,76 +39,100 @@ export interface LongTermMemory {
embedding?: number[];
}
export class MemorySystem {
private recentThoughtsList: Thought[] = [];
private notepads = new Map<string, ContactNotepad>();
private selfObservationsList: SelfObservation[] = [];
private longTerm: LongTermMemory[] = [];
export interface MemorySystemConfig {
recentCount?: number;
dbPath?: string;
}
constructor(private recentCount: number) {}
export class MemorySystem {
private recentCount: number;
private db: Database;
private embeddingClient: EmbeddingClient | null;
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: факты/опыт/процедуры/исходы + векторный поиск ===
addLongTerm(text: string, type: LongTermMemory['type']): LongTermMemory {
// === Long-term: факты/опыт/процедуры/исходы + эмбедд для векторного поиска ===
async addLongTerm(text: string, type: LongTermMemory['type']): Promise<LongTermMemory> {
const m: LongTermMemory = { id: `lt-${Date.now()}`, text, type, createdAt: Date.now() };
this.longTerm.push(m);
let embedding: number[] = [];
if (this.embeddingClient) {
embedding = await this.embeddingClient.embed(text);
}
// Сначала 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 query). */
searchLongTerm(queryEmbedding: number[], topK = 3): LongTermMemory[] {
// cosine similarity — заглушка, в прода: LanceDB.search
const scored = this.longTerm
.filter(m => m.embedding)
.map(m => ({ m, score: cosine(queryEmbedding, m.embedding!) }))
/** Поиск по long-term по тексту через эмбедд (cosine similarity в SQLite). */
async searchLongTerm(query: string, topK = 3): Promise<LongTermMemory[]> {
if (!this.embeddingClient) {
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 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;
}
/** Консолидация: перенос недавних мыслей в long-term (по спецификации Animus). */
consolidate(recent: Thought[]): void {
async consolidate(recent: Thought[]): Promise<void> {
for (const t of recent) {
// эвристика: только «значимые» мысли (по тегам/ключевым словам)
this.addLongTerm(t.text, 'experience');
await this.addLongTerm(t.text, 'experience');
}
}
}
function cosine(a: number[], b: number[]): number {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
return Math.sqrt(na) * Math.sqrt(nb) ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
}
+144 -21
View File
@@ -3,20 +3,17 @@
* Mind не выполняет долгую работу сам — он оркестратор:
* думает, чувствует, решает, делегирует саб-агентам.
*
* Spark X2.5-4B как движок:
* - через llama.cpp/Ollama (OpenAI-compatible server)
* Ornith-1.5-9B как движок:
* - через Ollama (OpenAI-compatible /v1/server)
* - поддерживает tool-calling (саб-агенты)
* - 1M контекст, но для heartbeat держим 4K-16K + внешняя память
* - 262K контекст, но для heartbeat держим 4K-16K + внешняя память
*/
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 }. */
@@ -45,14 +42,29 @@ export class SparkLLMClient implements LLMClient {
constructor(private baseUrl: string, private modelName = 'spark-x2.5-4b') {}
async generate(context: string, systemPrompt: string): Promise<LLMResult> {
// Инструкция structured output — только {thoughts, emotion, decision.reply}.
// subAgent НЕ включаем: gemma4:E4B (и другие локальные модели) пумолкают
// (пустой ответ), когда subAgent есть в JSON-схеме. subAgent — P3,
// появится в sub-agent SDK.
const structuredOutput = [
'Отвечай ТОЛЬКО валидным JSON без оградок и комментариев.',
'Структура:',
'{ "thoughts": "собственные мысли mind",',
' "emotion": { "valence": 0..1, "arousal": 0..1 },',
' "decision": { "reply": "текст ответа или null" }',
'}',
'thoughts и reply — на русском.',
].join('\n');
// OpenAI-compatible /v1/chat/completions
const resp = await fetch(`${this.baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
response_format: { type: 'json_object' }, // structured output
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'system', content: `${systemPrompt}\n\n${structuredOutput}` },
{ role: 'user', content: context },
],
temperature: 0.7,
@@ -62,35 +74,93 @@ export class SparkLLMClient implements LLMClient {
if (!resp.ok) throw new Error(`Spark LLM error: ${resp.status}`);
const data: any = await resp.json();
const content = data.choices?.[0]?.message?.content ?? '';
// Spark X2.5-4B: извлекаем thoughts/emotion/decision из structured-выхода
// Spark X2.5-4B отдаёт JSON: {thoughts, emotion, decision}
return parseSparkOutput(content);
}
}
function parseSparkOutput(content: string): LLMResult {
// эвристика: Spark форматирует блоки. В прода — structured output (json mode).
// 1. Убираем markdown-оградки (```json ... ```), если модель их добавила
const cleaned = content
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/i, '')
.trim();
// 2. Пробуем распарсить JSON (structured output)
let parsed: any = null;
try {
parsed = JSON.parse(cleaned);
} catch {
// fallback: ищем первый {...} блок в тексте
const match = cleaned.match(/({[\s\S]*})/);
if (match) {
try { parsed = JSON.parse(match[1]); } catch {}
}
}
if (parsed && typeof parsed === 'object') {
const thoughts = tryParseJson(parsed.thoughts);
const reply = tryParseJson(parsed.decision?.reply ?? parsed.reply);
return {
text: typeof parsed.text === 'string' ? parsed.text : '',
thoughts: typeof thoughts === 'string' ? thoughts : typeof thoughts === 'object' ? JSON.stringify(thoughts) : '',
emotion: {
valence: clampNum(parsed.emotion?.valence ?? parsed.valence, 0.5),
arousal: clampNum(parsed.emotion?.arousal ?? parsed.arousal, 0.5),
},
decision: {
reply: typeof reply === 'string' ? reply : reply ? JSON.stringify(reply) : null,
subAgent: parsed.decision?.subAgent ?? parsed.subAgent ?? undefined,
action: parsed.decision?.action ?? parsed.action ?? undefined,
},
};
}
// 2. Fallback: raw-текст как reply
return {
text: content,
thoughts: content,
emotion: { valence: 0.5, arousal: 0.5 },
decision: { reply: null },
decision: { reply: content || null },
};
}
function clampNum(v: number, fallback: number): number {
return typeof v === 'number' && !Number.isNaN(v) ? v : fallback;
}
/** Если значение — строка с JSON (модель запарила вложенный объект), распарсим. */
function tryParseJson(v: unknown): unknown {
if (typeof v !== 'string') return v;
const trimmed = v.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return v;
try { return JSON.parse(trimmed); } catch { return v; }
}
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;
}
/** Обработчик любого тика. */
@@ -98,25 +168,30 @@ 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);
// 2. Emotion decay + consolidation
this.consolidate();
// 2. Emotion decay + consolidation (async — эмбеддится в long-term)
await this.consolidate();
// 3. Если mind решил ответить — отправляем (channel layer)
if (result.decision.reply) {
console.log('[reply]', result.decision.reply);
await this.sendReply(result.decision.reply);
}
// 4. Если делегировал саб-агенту — запускаем (в прода: sub-agent SDK)
// 4. Если делегировал саб-агенту — запускаем (с task из решения)
if (result.decision.subAgent) {
console.log('[sub-agent]', result.decision.subAgent, '— в MVP не запускается');
await this.runSubAgent(result.decision.subAgent, result.decision.action);
}
}
/** Основной цикл генерации (вызывается и для под-агентов). */
private async generateTick(context: string, systemPrompt: string): Promise<LLMResult> {
return this.llm.generate(context, systemPrompt);
}
/** GATHER CONTEXT: собирает контекст для тика (по спецификации Animus). */
private buildContext(trigger: TickTrigger): string {
const recent = this.memory.recentThoughts();
@@ -128,7 +203,55 @@ export class Mind {
}
/** Консолидация: перенос недавних мыслей в long-term. */
private consolidate(): void {
this.memory.consolidate(this.memory.recentThoughts());
private async consolidate(): Promise<void> {
await this.memory.consolidate(this.memory.recentThoughts());
}
/**
* Channel layer — отправка ответа в Telegram (если настроен) + console.
* Fallback: если Telegram недоступен — только console.
*/
private async sendReply(text: string): Promise<void> {
// Всегда выводить в консоль
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.
* Создаёт под-агента, выполняет задачу, сохраняет результат.
*/
private async runSubAgent(type: string, task?: string): Promise<void> {
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);
}
}
}
+116
View File
@@ -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<SubAgentResult> {
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<string, string> = {
researcher: `Ты исследователь. Собираешь и анализируешь информацию по запросу.
- Формулируй гипотезы, ищи подтверждающие/опровергающие данные
- Давай структурированный ответ с выводами
- Указывай степень уверенности (высокая/средняя/низкая)`,
coder: `Ты разработчик. Пишешь и ревьюишь код.
- Следуй best practices (TypeScript, Python, Rust)
- Пиши тестируемый, чистый код с комментариями
- Учитывай контекст проекта`,
analyst: `Ты аналитик. Анализируешь данные и ситуации.
- Разбирай проблемы на составляющие
- Давай количественную и качественную оценку
- Предлагай рекомендации на основе фактов`,
'content-writer': `Ты контент-писатель. Создаёшь тексты для веба.
- Пиши ясно, точно, по делу
- Используй подзаголовки и списки для читаемости
- Учитывай SEO-рекомендации`,
};
private static availableTools: Record<string, string[]> = {
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'],
};
}
+86
View File
@@ -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<MessageResult> {
// Парсим 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();
});
}
}
+3 -2
View File
@@ -4,10 +4,11 @@
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"noEmit": false,
"skipLibCheck": true,
"lib": ["es2020"],
"types": ["node"]
"types": ["node"],
"outDir": "dist"
},
"include": ["src/**/*.ts", "index.ts"]
}