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

+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;
+22 -15
View File
@@ -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
View File
@@ -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)');
}
}