diff --git a/README.md b/README.md index e4e5c5a..2e9c593 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,9 @@ Spark X2.5-4B должен быть доступен по OpenAI-совмест ## Roadmap -- [ ] SQLite-репозиторий вместо in-memory (персистентность) -- [ ] LanceDB/Qdrant integration для long-term -- [ ] Channel layer (web/SMS/Discord) +- [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) - [ ] Sleep & energy (циркадный ритм) - [ ] Web UI (persona setup) diff --git a/index.ts b/index.ts index 8fe5106..aafb518 100644 --- a/index.ts +++ b/index.ts @@ -15,11 +15,14 @@ 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'; const HEARTBEAT_MS = 5 * 60 * 1000; // 5 мин — idle-мысли 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(10, embedding); // last 10 thoughts const mind = new Mind( heartbeat, memory, @@ -27,11 +30,14 @@ const mind = new Mind( new SparkLLMClient('http://localhost:11434'), // Ollama: Spark X2.5-4B ); +// Подключаем 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); diff --git a/package.json b/package.json index 91215b8..8989bdf 100644 --- a/package.json +++ b/package.json @@ -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": [], diff --git a/src/embedding.ts b/src/embedding.ts new file mode 100644 index 0000000..afa2c0c --- /dev/null +++ b/src/embedding.ts @@ -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; +} + +/** Ollama EmbeddingClient — bge-m3. */ +export class OllamaEmbeddingClient implements EmbeddingClient { + constructor( + private baseUrl: string, + private modelName = 'bge-m3', + ) {} + + async embed(text: string): Promise { + 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; +} diff --git a/src/heartbeat.ts b/src/heartbeat.ts index c7a0800..e9cc521 100644 --- a/src/heartbeat.ts +++ b/src/heartbeat.ts @@ -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; constructor(config: Partial = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; } + /** Подключает обработчик тика (оркестратор mind). */ + setOnTick(fn: (trigger: TickTrigger) => Promise) { + 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; diff --git a/src/memory.ts b/src/memory.ts index 99cd512..5c735e3 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -7,6 +7,8 @@ * long-term — в LanceDB (или Qdrant на a2000 для прода). */ +import { EmbeddingClient, cosineSimilarity } from './embedding.js'; + export interface Thought { id: string; text: string; @@ -39,8 +41,12 @@ export class MemorySystem { private notepads = new Map(); private selfObservationsList: SelfObservation[] = []; private longTerm: LongTermMemory[] = []; + /** Embedding-клиент (Ollama bge-m3). Если null — long-term без векторного поиска. */ + private embeddingClient: EmbeddingClient | null; - constructor(private recentCount: number) {} + constructor(private recentCount: number, embeddingClient?: EmbeddingClient) { + this.embeddingClient = embeddingClient ?? null; + } // === Short-term: последние мысли/опыт/сообщения === addThought(text: string): Thought { @@ -74,19 +80,26 @@ export class MemorySystem { return this.selfObservationsList; } - // === Long-term: факты/опыт/процедуры/исходы + векторный поиск === - addLongTerm(text: string, type: LongTermMemory['type']): LongTermMemory { + // === Long-term: факты/опыт/процедуры/исходы + эмбедд для векторного поиска === + async addLongTerm(text: string, type: LongTermMemory['type']): Promise { const m: LongTermMemory = { id: `lt-${Date.now()}`, text, type, createdAt: Date.now() }; + if (this.embeddingClient) { + m.embedding = await this.embeddingClient.embed(text); + } this.longTerm.push(m); return m; } - /** Поиск по long-term по эмбедду (в прода заменяется на LanceDB/Qdrant query). */ - searchLongTerm(queryEmbedding: number[], topK = 3): LongTermMemory[] { - // cosine similarity — заглушка, в прода: LanceDB.search + /** Поиск по long-term по тексту через эмбедд (в прода заменяется на LanceDB/Qdrant). */ + async searchLongTerm(query: string, topK = 3): Promise { + if (!this.embeddingClient) { + // без embedding-клиента — простой fallback + return this.longTerm.slice(0, topK); + } + const q = await this.embeddingClient.embed(query); const scored = this.longTerm .filter(m => m.embedding) - .map(m => ({ m, score: cosine(queryEmbedding, m.embedding!) })) + .map(m => ({ m, score: cosineSimilarity(q, m.embedding!) })) .sort((a, b) => b.score - a.score) .slice(0, topK) .map(x => x.m); @@ -94,16 +107,10 @@ export class MemorySystem { } /** Консолидация: перенос недавних мыслей в long-term (по спецификации Animus). */ - consolidate(recent: Thought[]): void { + async consolidate(recent: Thought[]): Promise { 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; -} diff --git a/src/mind.ts b/src/mind.ts index a7b5423..d6b251f 100644 --- a/src/mind.ts +++ b/src/mind.ts @@ -51,6 +51,7 @@ export class SparkLLMClient implements LLMClient { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: this.modelName, + response_format: { type: 'json_object' }, // structured output messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: context }, @@ -62,21 +63,53 @@ 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. Пробуем распарсить JSON (structured output) + let parsed: any = null; + try { + parsed = JSON.parse(content); + } catch { + // fallback: ищем JSON-блок в тексте + const match = content.match(/({[\s\S]*})/); + if (match) { + try { parsed = JSON.parse(match[1]); } catch {} + } + } + + if (parsed && typeof parsed === 'object') { + return { + text: parsed.text ?? parsed.reply ?? '', + thoughts: parsed.thoughts ?? '', + emotion: { + valence: clampNum(parsed.emotion?.valence ?? parsed.valence, 0.5), + arousal: clampNum(parsed.emotion?.arousal ?? parsed.arousal, 0.5), + }, + decision: { + reply: parsed.decision?.reply ?? parsed.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; +} + export class Mind { private heartbeat: Heartbeat; private memory: MemorySystem; @@ -103,17 +136,17 @@ export class Mind { // 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) if (result.decision.subAgent) { - console.log('[sub-agent]', result.decision.subAgent, '— в MVP не запускается'); + await this.runSubAgent(result.decision.subAgent); } } @@ -128,7 +161,23 @@ export class Mind { } /** Консолидация: перенос недавних мыслей в long-term. */ - private consolidate(): void { - this.memory.consolidate(this.memory.recentThoughts()); + private async consolidate(): Promise { + await this.memory.consolidate(this.memory.recentThoughts()); + } + + /** + * Channel layer — отправка ответа. + * В MVP: console (вывод). В прода: Telegram/SMS/Web (подключается отдельно). + */ + private async sendReply(text: string): Promise { + console.log('[reply]', text); + } + + /** + * Sub-agent — делегирование тяжёлого reasoning. + * В прода: Ornith-9B через sub-agent SDK. Пока — заглушка. + */ + private async runSubAgent(agent: string): Promise { + console.log('[sub-agent]', agent, '— sub-agent SDK (P3)'); } } diff --git a/test-embedding.ts b/test-embedding.ts new file mode 100644 index 0000000..92002b4 --- /dev/null +++ b/test-embedding.ts @@ -0,0 +1,29 @@ +/** + * Тест embedding-потока (P2). + * Проверка: bge-m3 эмбеддит, long-term ищет по cosine, consolidation работает. + */ +import { OllamaEmbeddingClient, cosineSimilarity } from './src/embedding.js'; +import { MemorySystem } from './src/memory.js'; + +(async () => { +const embedding = new OllamaEmbeddingClient('http://localhost:11434'); +const memory = new MemorySystem(10, embedding); + +console.log('[test] эмбеддим 3 тестовые фразы...'); +await memory.addLongTerm('Как настроить Yandex Direct для локального бизнеса', 'fact'); +await memory.addLongTerm('Сайт-лидер рынка: структура и SEO', 'procedure'); +await memory.addLongTerm('Ипотечный калькулятор повышает конверсию', 'outcome'); + +console.log('[test] long-term наполнен, ищем по запросу...'); +const results = await memory.searchLongTerm('Как снизить CPA в Директе?'); +console.log('[test] top-K результатов:'); +for (const m of results) { + console.log(` - ${m.type}: ${m.text}`); +} + +console.log('[test] consolidation переносит мысли в long-term...'); +await memory.consolidate([ + { id: 'th-1', text: 'Нужно настроить sub-agent для Ornith-9B', createdAt: Date.now() }, +]); +console.log('[test] OK — embedding-поток работает'); +})(); diff --git a/tsconfig.json b/tsconfig.json index cd88ec4..4af6ca4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] }