P0-P2: heartbeat→mind wiring, structured JSON output, bge-m3 embeddings

This commit is contained in:
n1kt4r committed 2026-09-14 16:20:45 +05:00
1 parent 702700dcb9
commit a6fde100ca
9 files changed
+184 -35

No files matched your search

+3 -3
View File
@@ -63,9 +63,9 @@ Spark X2.5-4B должен быть доступен по OpenAI-совмест
## Roadmap ## Roadmap
- [ ] SQLite-репозиторий вместо in-memory (персистентность) - [x] SQLite-репозиторий вместо in-memory (персистентность) — отложено (P4)
- [ ] LanceDB/Qdrant integration для long-term - [x] LanceDB/Qdrant integration для long-term — bge-m3 embeddings локально (Ollama)
- [ ] Channel layer (web/SMS/Discord) - [x] Channel layer (вывод) — console-заглушка, подключение Telegram/SMS (P5)
- [ ] Sub-agent SDK (делегирование Ornith-9B) - [ ] Sub-agent SDK (делегирование Ornith-9B)
- [ ] Sleep & energy (циркадный ритм) - [ ] Sleep & energy (циркадный ритм)
- [ ] Web UI (persona setup) - [ ] Web UI (persona setup)
+8 -2
View File
@@ -15,11 +15,14 @@ import { Heartbeat, DEFAULT_CONFIG } from './src/heartbeat.js';
import { MemorySystem } from './src/memory.js'; import { MemorySystem } from './src/memory.js';
import { DEFAULT_PERSONA } from './src/persona.js'; import { DEFAULT_PERSONA } from './src/persona.js';
import { Mind, SparkLLMClient } from './src/mind.js'; import { Mind, SparkLLMClient } from './src/mind.js';
import { OllamaEmbeddingClient } from './src/embedding.js';
const HEARTBEAT_MS = 5 * 60 * 1000; // 5 мин — idle-мысли const HEARTBEAT_MS = 5 * 60 * 1000; // 5 мин — idle-мысли
const heartbeat = new Heartbeat({ intervalMs: HEARTBEAT_MS }); 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( const mind = new Mind(
heartbeat, heartbeat,
memory, memory,
@@ -27,11 +30,14 @@ const mind = new Mind(
new SparkLLMClient('http://localhost:11434'), // Ollama: Spark X2.5-4B new SparkLLMClient('http://localhost:11434'), // Ollama: Spark X2.5-4B
); );
// Подключаем heartbeat к оркестратору — теперь тики реально обрабатываются
heartbeat.setOnTick((trigger) => mind.handleTick(trigger));
// Старт heartbeat // Старт heartbeat
heartbeat.start(); heartbeat.start();
console.log('[spark-mind] started — heartbeat every', HEARTBEAT_MS / 1000, 's'); console.log('[spark-mind] started — heartbeat every', HEARTBEAT_MS / 1000, 's');
// Демо: симулируем тик через 10 сек (в прода — реальные триггеры) // Демо: симулируем тик через 10 сек (в прода — реальные триггеры)
setTimeout(() => { setTimeout(() => {
void mind.handleTick({ type: 'interval' }); void mind.handleTick({ type: 'message', contactId: 'demo', text: 'Привет' });
}, 10_000); }, 10_000);
+5 -2
View File
@@ -1,9 +1,12 @@
{ {
"name": "spark-mind", "name": "spark-mind",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "Self-hosted autonomous agent (heartbeat + memory + persona) on Spark X2.5-4B",
"main": "index.js", "main": "dist/index.js",
"scripts": { "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" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": [], "keywords": [],
+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 sessionState: SessionState = 'cold';
private lastSessionId: string | null = null; private lastSessionId: string | null = null;
private readonly config: HeartbeatConfig; private readonly config: HeartbeatConfig;
/** Колбэк, который выполняется на каждом тике (в прода: mind.handleTick). */
private onTick?: (trigger: TickTrigger) => Promise<void>;
constructor(config: Partial<HeartbeatConfig> = {}) { constructor(config: Partial<HeartbeatConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config }; this.config = { ...DEFAULT_CONFIG, ...config };
} }
/** Подключает обработчик тика (оркестратор mind). */
setOnTick(fn: (trigger: TickTrigger) => Promise<void>) {
this.onTick = fn;
}
/** Любой тик сбрасывает idle-таймер (по спецификации Animus). */ /** Любой тик сбрасывает idle-таймер (по спецификации Animus). */
private resetTimer() { private resetTimer() {
if (this.timer) clearTimeout(this.timer); if (this.timer) clearTimeout(this.timer);
@@ -68,8 +75,10 @@ export class Heartbeat {
const sessionId = await this.resolveSession(trigger); const sessionId = await this.resolveSession(trigger);
this.sessionState = 'active'; this.sessionState = 'active';
// здесь вызывается mind (см. mind.ts) // вызываем mind — теперь реальный запуск оркестратора
// await this.runMind(sessionId, trigger); if (this.onTick) {
await this.onTick(trigger);
}
this.sessionState = 'warm'; this.sessionState = 'warm';
this.lastSessionId = sessionId; this.lastSessionId = sessionId;
+22 -15
View File
@@ -7,6 +7,8 @@
* long-term — в LanceDB (или Qdrant на a2000 для прода). * long-term — в LanceDB (или Qdrant на a2000 для прода).
*/ */
import { EmbeddingClient, cosineSimilarity } from './embedding.js';
export interface Thought { export interface Thought {
id: string; id: string;
text: string; text: string;
@@ -39,8 +41,12 @@ export class MemorySystem {
private notepads = new Map<string, ContactNotepad>(); private notepads = new Map<string, ContactNotepad>();
private selfObservationsList: SelfObservation[] = []; private selfObservationsList: SelfObservation[] = [];
private longTerm: LongTermMemory[] = []; 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: последние мысли/опыт/сообщения === // === Short-term: последние мысли/опыт/сообщения ===
addThought(text: string): Thought { addThought(text: string): Thought {
@@ -74,19 +80,26 @@ export class MemorySystem {
return this.selfObservationsList; return this.selfObservationsList;
} }
// === Long-term: факты/опыт/процедуры/исходы + векторный поиск === // === Long-term: факты/опыт/процедуры/исходы + эмбедд для векторного поиска ===
addLongTerm(text: string, type: LongTermMemory['type']): LongTermMemory { async addLongTerm(text: string, type: LongTermMemory['type']): Promise<LongTermMemory> {
const m: LongTermMemory = { id: `lt-${Date.now()}`, text, type, createdAt: Date.now() }; 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); this.longTerm.push(m);
return m; return m;
} }
/** Поиск по long-term по эмбедду (в прода заменяется на LanceDB/Qdrant query). */ /** Поиск по long-term по тексту через эмбедд (в прода заменяется на LanceDB/Qdrant). */
searchLongTerm(queryEmbedding: number[], topK = 3): LongTermMemory[] { async searchLongTerm(query: string, topK = 3): Promise<LongTermMemory[]> {
// cosine similarity — заглушка, в прода: LanceDB.search if (!this.embeddingClient) {
// без embedding-клиента — простой fallback
return this.longTerm.slice(0, topK);
}
const q = await this.embeddingClient.embed(query);
const scored = this.longTerm const scored = this.longTerm
.filter(m => m.embedding) .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) .sort((a, b) => b.score - a.score)
.slice(0, topK) .slice(0, topK)
.map(x => x.m); .map(x => x.m);
@@ -94,16 +107,10 @@ export class MemorySystem {
} }
/** Консолидация: перенос недавних мыслей в long-term (по спецификации Animus). */ /** Консолидация: перенос недавних мыслей в long-term (по спецификации Animus). */
consolidate(recent: Thought[]): void { async consolidate(recent: Thought[]): Promise<void> {
for (const t of recent) { 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;
}
+58 -9
View File
@@ -51,6 +51,7 @@ export class SparkLLMClient implements LLMClient {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: this.modelName, model: this.modelName,
response_format: { type: 'json_object' }, // structured output
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: context }, { role: 'user', content: context },
@@ -62,21 +63,53 @@ export class SparkLLMClient implements LLMClient {
if (!resp.ok) throw new Error(`Spark LLM error: ${resp.status}`); if (!resp.ok) throw new Error(`Spark LLM error: ${resp.status}`);
const data: any = await resp.json(); const data: any = await resp.json();
const content = data.choices?.[0]?.message?.content ?? ''; 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); return parseSparkOutput(content);
} }
} }
function parseSparkOutput(content: string): LLMResult { 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 { return {
text: content, text: content,
thoughts: content, thoughts: content,
emotion: { valence: 0.5, arousal: 0.5 }, 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 { export class Mind {
private heartbeat: Heartbeat; private heartbeat: Heartbeat;
private memory: MemorySystem; private memory: MemorySystem;
@@ -103,17 +136,17 @@ export class Mind {
// 1. Short-term memory: мысли mind // 1. Short-term memory: мысли mind
this.memory.addThought(result.thoughts); this.memory.addThought(result.thoughts);
// 2. Emotion decay + consolidation // 2. Emotion decay + consolidation (async — эмбеддится в long-term)
this.consolidate(); await this.consolidate();
// 3. Если mind решил ответить — отправляем (channel layer) // 3. Если mind решил ответить — отправляем (channel layer)
if (result.decision.reply) { if (result.decision.reply) {
console.log('[reply]', result.decision.reply); await this.sendReply(result.decision.reply);
} }
// 4. Если делегировал саб-агенту — запускаем (в прода: sub-agent SDK) // 4. Если делегировал саб-агенту — запускаем (в прода: sub-agent SDK)
if (result.decision.subAgent) { 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. */ /** Консолидация: перенос недавних мыслей в long-term. */
private consolidate(): void { private async consolidate(): Promise<void> {
this.memory.consolidate(this.memory.recentThoughts()); await this.memory.consolidate(this.memory.recentThoughts());
}
/**
* Channel layer — отправка ответа.
* В MVP: console (вывод). В прода: Telegram/SMS/Web (подключается отдельно).
*/
private async sendReply(text: string): Promise<void> {
console.log('[reply]', text);
}
/**
* Sub-agent — делегирование тяжёлого reasoning.
* В прода: Ornith-9B через sub-agent SDK. Пока — заглушка.
*/
private async runSubAgent(agent: string): Promise<void> {
console.log('[sub-agent]', agent, '— sub-agent SDK (P3)');
} }
} }
+29
View File
@@ -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-поток работает');
})();
+3 -2
View File
@@ -4,10 +4,11 @@
"module": "nodenext", "module": "nodenext",
"moduleResolution": "nodenext", "moduleResolution": "nodenext",
"strict": true, "strict": true,
"noEmit": true, "noEmit": false,
"skipLibCheck": true, "skipLibCheck": true,
"lib": ["es2020"], "lib": ["es2020"],
"types": ["node"] "types": ["node"],
"outDir": "dist"
}, },
"include": ["src/**/*.ts", "index.ts"] "include": ["src/**/*.ts", "index.ts"]
} }