P0-P2: heartbeat→mind wiring, structured JSON output, bge-m3 embeddings
This commit is contained in:
1 parent
702700dcb9
commit
a6fde100ca
9 files changed
+184
-35
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
+5
-2
@@ -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": [],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
+22
-15
@@ -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<string, ContactNotepad>();
|
||||
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<LongTermMemory> {
|
||||
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<LongTermMemory[]> {
|
||||
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<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;
|
||||
}
|
||||
+58
-9
@@ -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<void> {
|
||||
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)');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user