Initial spark-mind: heartbeat + memory + persona + mind on Spark X2.5-4B
This commit is contained in:
commit
709d7ce17d
10 files changed
+991
No files matched your search
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
Reference in new issue
Block a user