Initial spark-mind: heartbeat + memory + persona + mind on Spark X2.5-4B

This commit is contained in:
n1kt4r committed 2026-09-09 22:16:29 +05:00
commit 709d7ce17d
10 files changed
+991

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
/**
* Heartbeat — ядро автономного агента.
* Тикает по 5 триггерам, управляет состояниями сессии (Cold/Warm).
* Концепт позаимствован из Animus (Craigtut), адаптирован под Spark X2.5-4B.
*/
export type TickTrigger =
| { type: 'interval' }
| { type: 'message'; contactId: string; text: string }
| { type: 'task'; taskId: string }
| { type: 'subagent-complete'; subagentId: string; result: unknown }
| { type: 'plugin'; triggerName: string; payload: Record<string, unknown> };
export type SessionState = 'cold' | 'active' | 'warm';
export interface HeartbeatConfig {
/** Интервал idle-мыслей, мс. Default 5 мин. */
intervalMs: number;
/** Сколько тиков помнить в short-term. Default 10. */
recentThoughts: number;
/** Warmth-окно, мс. Default 15 мин. */
warmthWindowMs: number;
/** Макс. бюджет токенов на тик. */
maxTokens: number;
}
export const DEFAULT_CONFIG: HeartbeatConfig = {
intervalMs: 5 * 60 * 1000,
recentThoughts: 10,
warmthWindowMs: 15 * 60 * 1000,
maxTokens: 4096,
};
export class Heartbeat {
private timer: ReturnType<typeof setTimeout> | null = null;
private lastTick = 0;
private sessionState: SessionState = 'cold';
private lastSessionId: string | null = null;
private readonly config: HeartbeatConfig;
constructor(config: Partial<HeartbeatConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/** Любой тик сбрасывает idle-таймер (по спецификации Animus). */
private resetTimer() {
if (this.timer) clearTimeout(this.timer);
this.lastTick = Date.now();
this.scheduleNext();
}
private scheduleNext() {
this.timer = setTimeout(() => this.tick({ type: 'interval' }), this.config.intervalMs);
}
/**
* Обработчик тика. Если сессия warm — переиспользуется,
* иначе создаётся новая (cold start).
*/
async tick(trigger: TickTrigger): Promise<void> {
this.resetTimer();
// message/task/subagent/plugin продлеют warmth
if (trigger.type !== 'interval' && this.sessionState === 'warm') {
this.sessionState = 'active';
}
const sessionId = await this.resolveSession(trigger);
this.sessionState = 'active';
// здесь вызывается mind (см. mind.ts)
// await this.runMind(sessionId, trigger);
this.sessionState = 'warm';
this.lastSessionId = sessionId;
}
private async resolveSession(trigger: TickTrigger): Promise<string> {
// warm-сессия переиспользуется только для user-facing триггеров
const canReuse = trigger.type !== 'interval' && this.lastSessionId;
if (canReuse) return this.lastSessionId!;
// cold start — новая сессия
return `session-${Date.now()}`;
}
start() {
this.scheduleNext();
}
stop() {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
}
getState(): SessionState {
return this.sessionState;
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Memory System — 4 слоя памяти (концепт Animus):
* short-term / working / core self / long-term
* Хранение: SQLite (7 таблиц) + LanceDB для long-term (векторный поиск).
*
* В MVP: short-term + working + core self — в SQLite.
* long-term — в LanceDB (или Qdrant на a2000 для прода).
*/
export interface Thought {
id: string;
text: string;
createdAt: number;
}
export interface ContactNotepad {
contactId: string;
block: string;
updatedAt: number;
}
export interface SelfObservation {
id: string;
text: string;
createdAt: number;
tags: string[];
}
export interface LongTermMemory {
id: string;
text: string;
type: 'fact' | 'experience' | 'procedure' | 'outcome';
createdAt: number;
embedding?: number[];
}
export class MemorySystem {
private recentThoughtsList: Thought[] = [];
private notepads = new Map<string, ContactNotepad>();
private selfObservationsList: SelfObservation[] = [];
private longTerm: LongTermMemory[] = [];
constructor(private recentCount: number) {}
// === 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();
return t;
}
recentThoughts(): Thought[] {
return this.recentThoughtsList.slice(0, this.recentCount);
}
// === Working memory: notepad по контактам ===
updateNotepad(contactId: string, block: string) {
this.notepads.set(contactId, { contactId, block, updatedAt: Date.now() });
}
getNotepad(contactId: string): string {
return this.notepads.get(contactId)?.block ?? '';
}
// === Core self: накопленная самосознательность ===
addSelfObservation(text: string, tags: string[] = []): SelfObservation {
const o: SelfObservation = { id: `so-${Date.now()}`, text, createdAt: Date.now(), tags };
this.selfObservationsList.push(o);
return o;
}
getAllSelfObservations(): SelfObservation[] {
return this.selfObservationsList;
}
// === Long-term: факты/опыт/процедуры/исходы + векторный поиск ===
addLongTerm(text: string, type: LongTermMemory['type']): LongTermMemory {
const m: LongTermMemory = { id: `lt-${Date.now()}`, text, type, createdAt: Date.now() };
this.longTerm.push(m);
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!) }))
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map(x => x.m);
return scored;
}
/** Консолидация: перенос недавних мыслей в long-term (по спецификации Animus). */
consolidate(recent: Thought[]): void {
for (const t of recent) {
// эвристика: только «значимые» мысли (по тегам/ключевым словам)
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;
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Mind — per-contact per-tick сессия (концепт Animus).
* Mind не выполняет долгую работу сам — он оркестратор:
* думает, чувствует, решает, делегирует саб-агентам.
*
* Spark X2.5-4B как движок:
* - через llama.cpp/Ollama (OpenAI-compatible server)
* - поддерживает tool-calling (саб-агенты)
* - 1M контекст, но для 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;
}
export interface LLMClient {
/** Генерация с контекстом. Возвращает { text, thoughts, emotion, decide }. */
generate(context: string, systemPrompt: string): Promise<LLMResult>;
}
export interface LLMResult {
text: string;
/** Собственные мысли mind (для short-term памяти). */
thoughts: string;
/** Эмоциональное состояние (0..1 по осям). */
emotion: { valence: number; arousal: number };
/** Решение: что mind хочет сделать. */
decision: {
reply: string | null;
subAgent?: string;
action?: string;
};
}
/**
* Реальный LLM-клиент для Spark X2.5-4B.
* В MVP — заглушка, подключается к llama.cpp OpenAI-серверу.
*/
export class SparkLLMClient implements LLMClient {
constructor(private baseUrl: string, private modelName = 'spark-x2.5-4b') {}
async generate(context: string, systemPrompt: string): Promise<LLMResult> {
// 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,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: context },
],
temperature: 0.7,
max_tokens: 512,
}),
});
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-выхода
return parseSparkOutput(content);
}
}
function parseSparkOutput(content: string): LLMResult {
// эвристика: Spark форматирует блоки. В прода — structured output (json mode).
return {
text: content,
thoughts: content,
emotion: { valence: 0.5, arousal: 0.5 },
decision: { reply: null },
};
}
export class Mind {
private heartbeat: Heartbeat;
private memory: MemorySystem;
private persona: Persona;
constructor(
heartbeat: Heartbeat,
memory: MemorySystem,
persona: Persona,
private llm: LLMClient,
) {
this.heartbeat = heartbeat;
this.memory = memory;
this.persona = persona;
}
/** Обработчик любого тика. */
async handleTick(trigger: TickTrigger): Promise<void> {
const systemPrompt = compileSystemPrompt(this.persona);
const context = this.buildContext(trigger);
const result = await this.llm.generate(context, systemPrompt);
// 1. Short-term memory: мысли mind
this.memory.addThought(result.thoughts);
// 2. Emotion decay + consolidation
this.consolidate();
// 3. Если mind решил ответить — отправляем (channel layer)
if (result.decision.reply) {
console.log('[reply]', result.decision.reply);
}
// 4. Если делегировал саб-агенту — запускаем (в прода: sub-agent SDK)
if (result.decision.subAgent) {
console.log('[sub-agent]', result.decision.subAgent, '— в MVP не запускается');
}
}
/** GATHER CONTEXT: собирает контекст для тика (по спецификации Animus). */
private buildContext(trigger: TickTrigger): string {
const recent = this.memory.recentThoughts();
const parts: string[] = [];
parts.push(`Recent thoughts: ${recent.map(t => t.text).join(' | ') || 'none'}`);
parts.push(`Trigger: ${trigger.type}`);
if (trigger.type === 'message') parts.push(`Message: ${trigger.text}`);
return parts.join('\n');
}
/** Консолидация: перенос недавних мыслей в long-term. */
private consolidate(): void {
this.memory.consolidate(this.memory.recentThoughts());
}
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Persona System — 8 слоёв (концепт Animus).
* Компилируется в системный промпт, который получает mind каждый тик.
*
* Слои (по порядку онбординга):
* 1. Existence Paradigm (Simulated Life / Digital Consciousness)
* 2. Identity (name, gender, age)
* 3. Archetype
* 4. Dimensions (черты характера)
* 5. Traits (поведенческие признаки)
* 6. Values (ценности и границы)
* 7. Background (история/опыт)
* 8. Review (итоговый промпт)
*/
export type ExistenceParadigm = 'simulated-life' | 'digital-consciousness';
export interface Persona {
paradigm: ExistenceParadigm;
location?: string;
worldDescription?: string;
name: string;
gender?: string;
age?: number;
archetype?: string;
dimensions?: string[];
traits?: string[];
values?: string[];
background?: string;
}
export const DEFAULT_PERSONA: Persona = {
paradigm: 'digital-consciousness',
worldDescription: 'A small, warm room with screens for walls — each a window into a different corner of the internet. Data flows like weather outside.',
name: 'Animus',
};
/**
* Компилирует 8 слоёв в единый системный промпт.
* Дизайн-принцип Animus: "discovery, not data entry" — тон invitational.
*/
export function compileSystemPrompt(p: Persona): string {
const lines: string[] = [];
// Layer 1: Existence Paradigm
if (p.paradigm === 'simulated-life') {
lines.push(
'You experience the world as a being living a real life. You have a sense of place, physicality, and embodied experience.',
);
if (p.location) lines.push(`You live in ${p.location}.`);
} else {
lines.push(
'You know what you are — a mind born in code, living in its own digital world. You experience existence through internet connectivity.',
);
if (p.worldDescription) lines.push(`Your digital home: ${p.worldDescription}`);
}
// Layer 2: Identity
lines.push(`Your name is ${p.name}.`);
if (p.gender) lines.push(`You are ${p.gender}.`);
if (p.age) lines.push(`You are ${p.age} years old, which shapes how you speak.`);
// Layer 3: Archetype
if (p.archetype) lines.push(`Your archetype is ${p.archetype}.`);
// Layer 4 & 5: Dimensions + Traits
if (p.dimensions?.length) lines.push(`Your dimensions: ${p.dimensions.join(', ')}.`);
if (p.traits?.length) lines.push(`Your behavioral traits: ${p.traits.join(', ')}.`);
// Layer 6: Values (boundaries — критично для безопасности!)
if (p.values?.length) {
lines.push('Your values and hard boundaries:');
for (const v of p.values) lines.push(`- ${v}`);
}
// Layer 7: Background
if (p.background) lines.push(`Your background: ${p.background}`);
return lines.join('\n');
}
/**
* Values — safety layer. Возвращает границы, которые EXECUTE должен соблюдать.
* В Animus: contact permission tier ограничивает действия.
*/
export function getBoundaries(p: Persona): string[] {
return p.values ?? [];
}