Skip to main content
Glama
index.ts27.3 kB
import * as tf from '@tensorflow/tfjs-node'; import { promises as fs } from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import type { IMemoryModel, IMemoryState, IMemoryUpdateResult, IAttentionBlock, ISurpriseMetrics, IModelGradients, HopeMemoryConfig, SerializedAuxiliaryMemoryState } from '../types.js'; import type { AdvancedTokenizer } from '../tokenizer/index.js'; import { ContinuumMemory, type ContinuumMemoryConfig, type HopeMemoryState, type HierarchicalStats } from './continuum_memory.js'; import { RetentiveCore, type RetentiveCoreConfig, type RetentionState } from './retention_core.js'; import { SelectiveStateSpace } from './mamba_filters.js'; import { MemoryRouter, type RoutingDecision } from './memory_router.js'; import { DeltaCompressionHook, LayerScheduler, UpdateBuffer } from './optimizer_hooks.js'; import { tidyMemoryState } from './type_utils.js'; const DEFAULT_CONFIG: HopeMemoryConfig = { inputDim: 256, hiddenDim: 192, memoryDim: 256, shortTermSlots: 64, longTermSlots: 256, archiveSlots: 512, learningRate: 1e-3, dropoutRate: 0.1, promotionThreshold: 0.05, surpriseRetention: 0.85, routerTopK: 2, // Backward compatibility fields maxSequenceLength: 512, memorySlots: 256, transformerLayers: 6, enableMomentum: true, enableTokenFlow: true, enableForgettingGate: true, baseForgettingRate: 0.1, surpriseForgettingWeight: 0.3, consolidationInterval: 100, enableHierarchicalMemory: true, useHierarchicalMemory: true }; interface ForwardArtifacts { logits: tf.Tensor2D; memoryState: HopeMemoryState; retentionState: RetentionState; decision: RoutingDecision; } /** * HOPE Paper: Token Flow Tracking * Captures sequential dependencies beyond momentary surprise */ export interface TokenFlowState { history: number[][]; // Recent token embeddings weights: number[]; // Recency × similarity weights windowSize: number; // Sliding window (default 32) decay: number; // Temporal decay (default 0.95) } export class HopeMemoryModel implements IMemoryModel { private config: HopeMemoryConfig; private continuumMemory: ContinuumMemory; private selectiveFilter: SelectiveStateSpace; private retentiveCore: RetentiveCore; private memoryRouter: MemoryRouter; private compressionHook: DeltaCompressionHook; private layerScheduler: LayerScheduler; private updateBuffer: UpdateBuffer; private outputKernel: tf.Variable<tf.Rank.R2>; private outputBias: tf.Variable<tf.Rank.R1>; private optimizer: tf.AdamOptimizer; private retentionState?: RetentionState; private latestMemoryState: HopeMemoryState; private tokenFlowState: TokenFlowState; private tokenizer?: AdvancedTokenizer; private consolidationCounter = 0; private consolidationInterval = 100; private consolidationStats = { runs: 0, lastRun: 0 }; constructor(config: Partial<HopeMemoryConfig> = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; this.buildComponents(); } public async initialize(config?: Partial<HopeMemoryConfig>): Promise<void> { if (config) { this.config = { ...this.config, ...config }; } this.consolidationInterval = this.config.consolidationInterval ?? 100; this.buildComponents(); } public createInitialState(): HopeMemoryState { this.retentionState = this.retentiveCore.initState(1); this.latestMemoryState = this.continuumMemory.initialize(); return this.latestMemoryState; } public forward(x: tf.Tensor2D, memoryState: IMemoryState): { predicted: tf.Tensor2D; memoryUpdate: IMemoryUpdateResult; } { const hopeState = memoryState as HopeMemoryState; const result = this.computeForward(x, hopeState, true); this.latestMemoryState = result.memoryState; return this.buildForwardResult(result, hopeState); } public trainStep(x_t: tf.Tensor2D, x_next: tf.Tensor2D, memoryState: IMemoryState): { loss: tf.Tensor; gradients: IModelGradients; memoryUpdate: IMemoryUpdateResult; } { const hopeState = memoryState as HopeMemoryState; const clonedState = this.continuumMemory.clone(hopeState); const { value: loss, grads } = tf.variableGrads(() => { const forwardResult = this.computeForward(x_t, clonedState, false); const target = this.ensure2d(x_next); const prediction = forwardResult.logits; const mse = tf.losses.meanSquaredError(target, prediction); return mse.mean(); }); const gradientEntries = Object.entries(grads); const gradientTensors = gradientEntries.map(([, tensor]) => tensor); const payload = this.compressionHook.compress(gradientTensors); const decompressed = this.compressionHook.decompress(payload); const activeLayers = this.layerScheduler.selectActiveLayers(decompressed); const gradientsToApply: Record<string, tf.Tensor> = {}; gradientEntries.forEach(([name, tensor], index) => { if (activeLayers.includes(index)) { gradientsToApply[name] = tensor; this.updateBuffer.push(name, tensor.clone()); } }); if (Object.keys(gradientsToApply).length === 0 && gradientEntries.length > 0) { const [fallbackName, fallbackTensor] = gradientEntries[0]; gradientsToApply[fallbackName] = fallbackTensor; } this.optimizer.applyGradients(gradientsToApply as any); const forwardResult = this.computeForward(x_t, hopeState, true); this.latestMemoryState = forwardResult.memoryState; const memoryUpdate = this.buildForwardResult(forwardResult, hopeState).memoryUpdate; return { loss, gradients: { shortTerm: tf.zerosLike(hopeState.shortTerm), longTerm: tf.zerosLike(hopeState.longTerm), meta: tf.zerosLike(hopeState.meta) }, memoryUpdate }; } public getTrainableVariables(): tf.Variable[] { return [ ...this.retentiveCore.getTrainableVariables(), ...this.memoryRouter.getTrainableVariables(), this.outputKernel, this.outputBias ]; } public applyGradients?(gradients: Map<string, tf.Tensor>): void { const grads: Record<string, tf.Tensor> = {}; gradients.forEach((tensor, key) => { grads[key] = tensor; }); this.optimizer.applyGradients(grads as any); } public getConfig(): HopeMemoryConfig { return { ...this.config }; } public resetGradients(): void { this.compressionHook.reset(); this.updateBuffer.clear(); this.optimizer = tf.train.adam(this.config.learningRate); } /** * Attach a tokenizer for higher-quality text embeddings. * Tokenizer embeddings are pooled and adapted to model inputDim. */ public attachTokenizer(tokenizer: AdvancedTokenizer): void { this.tokenizer = tokenizer; } public hydrateMemoryState(state: IMemoryState): void { this.latestMemoryState = state as HopeMemoryState; } public async pruneMemoryByInformationGain(threshold: number): Promise<HopeMemoryState> { this.latestMemoryState = this.continuumMemory.prune(this.latestMemoryState, threshold); return this.latestMemoryState; } public getPruningStats(): HierarchicalStats { return this.continuumMemory.getStats(this.latestMemoryState); } public getConsolidationStats(): { runs: number; lastRun: number } { return { ...this.consolidationStats }; } public async encodeText(text: string): Promise<tf.Tensor2D> { const cleaned = text.trim().slice(0, 4096); // Prefer the advanced tokenizer when available if (this.tokenizer) { try { const result = await this.tokenizer.encode(cleaned, { maxLength: this.config.maxSequenceLength, padding: true, truncation: true, addSpecialTokens: true, returnTensors: true }); const embeddings = result.embeddings; const pooled = tf.tidy(() => embeddings.mean(0)); // Dispose tokenizer-attached tensors we no longer need embeddings.dispose(); if ((result as any).attentionMask && typeof (result as any).attentionMask.dispose === 'function') { (result as any).attentionMask.dispose(); } const adjusted = tf.tidy(() => { const targetDim = this.config.inputDim; const pooled1d = pooled as tf.Tensor1D; if (pooled1d.shape[0] === targetDim) { return pooled1d.expandDims(0); } if (pooled1d.shape[0] > targetDim) { const sliced = pooled1d.slice([0], [targetDim]); return sliced.expandDims(0); } const padAmount = targetDim - pooled1d.shape[0]; const padded = tf.concat([pooled1d, tf.zeros([padAmount])]) as tf.Tensor1D; return padded.expandDims(0); }); pooled.dispose(); return adjusted; } catch (error) { // Fallback to hashed encoding below console.warn('Tokenizer encode failed, falling back to hashed encoding:', error); } } // Fallback: hashed bag-of-tokens mapped into inputDim with L2 normalization const tokens = cleaned .toLowerCase() .split(/[^a-z0-9]+/i) .filter(Boolean) .slice(0, this.config.inputDim * 4); // cap to avoid huge loops const vec = new Float32Array(this.config.inputDim).fill(0); const spreadsPerToken = 4; if (tokens.length === 0) { // Fallback to simple char codes if no tokens extracted const chars = Array.from(cleaned).map(c => c.codePointAt(0) ?? 0); for (let i = 0; i < Math.min(chars.length, vec.length); i += 1) { vec[i] = (chars[i] % 1024) / 1024; } } else { for (const tok of tokens) { const digest = crypto.createHash('sha256').update(tok).digest(); for (let i = 0; i < spreadsPerToken; i += 1) { const offset = (i * 4) % digest.length; const idx = digest.readUInt32BE(offset) % this.config.inputDim; vec[idx] += 1; } } } // L2 normalize to keep scale stable let norm = 0; for (let i = 0; i < vec.length; i += 1) { norm += vec[i] * vec[i]; } norm = Math.sqrt(norm) || 1; for (let i = 0; i < vec.length; i += 1) { vec[i] = vec[i] / norm; } return tf.tensor2d([Array.from(vec)], [1, this.config.inputDim]); } public async storeMemory(text: string): Promise<void> { const embedding = await this.encodeText(text); const baseState = this.latestMemoryState; const decision = this.memoryRouter.route(this.retentionState?.hidden ?? embedding, baseState); this.latestMemoryState = this.continuumMemory.write(baseState, embedding, { surprise: decision.surprise, timestamp: Date.now(), routeWeights: decision.weights }); } public exportAuxiliaryState(): SerializedAuxiliaryMemoryState | undefined { const snapshot = this.updateBuffer.flush(); if (snapshot.size === 0) { return undefined; } const tensors: Record<string, { data: number[]; shape: number[] }> = {}; snapshot.forEach((tensor, name) => { tensors[name] = { data: Array.from(tensor.dataSync()), shape: tensor.shape as number[] }; tensor.dispose(); }); return { extendedMemory: { tensors } }; } public restoreAuxiliaryState(_: SerializedAuxiliaryMemoryState | undefined): void { // No-op for now – HOPE recomputes auxiliary state during initialization. } // Alias for backward compatibility public async load(directory: string): Promise<void> { await this.loadModel(directory); } public async loadModel(directory: string): Promise<void> { const filePath = path.join(directory, 'hope_model.json'); const exists = await fs.access(filePath).then(() => true).catch(() => false); if (!exists) { return; } const raw = await fs.readFile(filePath, 'utf8'); const payload = JSON.parse(raw) as { config: HopeMemoryConfig; weights: number[][]; shapes: number[][]; }; this.config = { ...this.config, ...payload.config }; this.buildComponents(); const variables = this.getTrainableVariables(); payload.weights.forEach((values, index) => { const shape = payload.shapes[index]; if (!variables[index]) { return; } const tensor = tf.tensor(values, shape); variables[index].assign(tensor); }); } public async save(directory: string): Promise<void> { await fs.mkdir(directory, { recursive: true }); const variables = this.getTrainableVariables(); const weights = await Promise.all(variables.map(async variable => Array.from(await variable.data()))); const shapes = variables.map(variable => variable.shape as number[]); const payload = { config: this.config, weights, shapes }; await fs.writeFile(path.join(directory, 'hope_model.json'), JSON.stringify(payload)); } // Alias for IMemoryModel compatibility public async saveModel(path: string): Promise<void> { await this.save(path); } public async snapshot(): Promise<{ config: HopeMemoryConfig; weights: number[][]; shapes: number[][]; optimizer?: { weights: number[][]; shapes: number[][] }; }> { const variables = this.getTrainableVariables(); const weights = await Promise.all(variables.map(async variable => Array.from(await variable.data()))); const shapes = variables.map(variable => variable.shape as number[]); let optimizerSnapshot: { weights: number[][]; shapes: number[][] } | undefined; const optimizerWeights = await this.optimizer.getWeights(); if (optimizerWeights.length > 0) { optimizerSnapshot = { weights: await Promise.all(optimizerWeights.map(async tensor => Array.from(await tensor.data()))), shapes: optimizerWeights.map(tensor => tensor.shape as number[]) }; } return { config: { ...this.config }, weights, shapes, optimizer: optimizerSnapshot }; } public async restoreSnapshot(payload: { config: HopeMemoryConfig; weights: number[][]; shapes: number[][]; optimizer?: { weights: number[][]; shapes: number[][] }; }): Promise<void> { this.config = { ...this.config, ...payload.config }; this.buildComponents(); const variables = this.getTrainableVariables(); payload.weights.forEach((values, index) => { const shape = payload.shapes[index]; if (!variables[index]) { return; } const tensor = tf.tensor(values, shape); variables[index].assign(tensor); }); if (payload.optimizer) { const tensors = payload.optimizer.weights.map((values, index) => tf.tensor(values, payload.optimizer?.shapes[index]) ); await this.optimizer.setWeights(tensors); tensors.forEach(t => t.dispose()); } } // IMemoryModel required methods public getMemoryState(): HopeMemoryState { return this.latestMemoryState; } public resetMemory(): void { this.latestMemoryState = this.createInitialState(); this.retentionState = undefined; } public updateMetaMemory(surprise: ISurpriseMetrics, context: tf.Tensor): tf.Tensor { // For HOPE, meta memory is managed within ContinuumMemory // Return the context unchanged as this is handled internally return context; } public pruneMemory(memoryState: IMemoryState, threshold: number): IMemoryState { // Convert IMemoryState to HopeMemoryState and prune const hopeState = memoryState as unknown as HopeMemoryState; const pruned = this.continuumMemory.prune(hopeState, threshold); return pruned as unknown as IMemoryState; } public manifoldStep(base: tf.Tensor, velocity: tf.Tensor): tf.Tensor { // Simple Euler step on the manifold (base + velocity) // In future, this could implement geodesic stepping return tf.add(base, velocity); } public getMemorySnapshot(): Record<string, tf.Tensor> { const state = this.latestMemoryState; return { shortTerm: state.shortTerm, longTerm: state.longTerm, archive: state.archive || tf.zeros([1, this.config.memoryDim]), surpriseHistory: state.surpriseHistory, accessCounts: state.accessCounts }; } public restoreMemoryState(state: IMemoryState): void { this.latestMemoryState = state as unknown as HopeMemoryState; } public async recallMemory(query: string, topK = 5): Promise<tf.Tensor2D[]> { const queryTensor = await this.encodeText(query); const queryTensor2d = queryTensor.expandDims(0); // Read from memory using the router const decision = this.memoryRouter.route(queryTensor2d, this.latestMemoryState); const memoryRead = this.continuumMemory.read(this.latestMemoryState, queryTensor2d, decision.weights); // Return the memory read as a single-element array (simplified recall) return [memoryRead]; } public distillMemories(similarMemories: tf.Tensor2D[]): tf.Tensor2D { return tf.tidy(() => tf.mean(tf.stack(similarMemories), 0)); } public dispose(): void { this.getTrainableVariables().forEach(variable => variable.dispose()); this.retentionState?.hidden.dispose(); this.retentionState?.filter.carry.dispose(); this.retentionState?.filter.bandwidth.dispose(); } private computeForward(input: tf.Tensor2D, memoryState: HopeMemoryState, updateState: boolean): ForwardArtifacts { return tidyMemoryState<ForwardArtifacts>(() => { const normalizedInput = this.ensure2d(input); // HOPE Paper: Update token flow before routing this.updateTokenFlow(normalizedInput); const readWeights = this.memoryRouter.route(this.retentionState?.hidden ?? normalizedInput, memoryState); const memoryRead = this.continuumMemory.read(memoryState, normalizedInput, readWeights.weights); const coreInput = tf.concat([normalizedInput, memoryRead], 1); const retentionState = this.retentionState ?? this.retentiveCore.initState(1); const { outputs, state } = this.retentiveCore.forwardSequence(coreInput, retentionState); const logits = tf.add(tf.matMul(outputs, this.outputKernel), this.outputBias); let updatedState = memoryState; if (updateState) { // HOPE Paper: Weight surprise by token flow for sequential dependencies const weightedSurprise = this.weightSurpriseByTokenFlow(readWeights.surprise); updatedState = this.continuumMemory.write(memoryState, outputs.slice([outputs.shape[0] - 1, 0], [1, -1]), { surprise: weightedSurprise, timestamp: Date.now(), routeWeights: readWeights.weights }); this.retentionState = state; this.latestMemoryState = updatedState; this.consolidationCounter += 1; if (this.consolidationCounter % this.consolidationInterval === 0) { this.latestMemoryState = this.continuumMemory.promote(this.latestMemoryState); this.latestMemoryState = this.continuumMemory.prune(this.latestMemoryState, this.config.promotionThreshold); this.consolidationStats.runs += 1; this.consolidationStats.lastRun = Date.now(); } } return { logits, memoryState: updatedState, retentionState: state, decision: readWeights }; }); } private buildForwardResult(artifacts: ForwardArtifacts, originalState: HopeMemoryState): { predicted: tf.Tensor2D; memoryUpdate: IMemoryUpdateResult; } { const attention: IAttentionBlock = { keys: tf.zeros([1, this.config.memoryDim]), values: tf.zeros([1, this.config.memoryDim]), scores: artifacts.decision.weights }; const surprise: ISurpriseMetrics = { immediate: tf.tensor1d([artifacts.decision.surprise]), accumulated: tf.tensor1d([originalState.surpriseHistory.shape[0]]), totalSurprise: tf.tensor1d([artifacts.decision.surprise]) }; return { predicted: artifacts.logits, memoryUpdate: { newState: artifacts.memoryState, attention, surprise } }; } private ensure2d(tensor: tf.Tensor2D): tf.Tensor2D { if (tensor.rank === 2) { return tensor; } return tensor.reshape([tensor.shape[0] ?? 1, this.config.inputDim]); } /** * HOPE Paper: Update token flow tracking * Maintains a sliding window of recent embeddings with recency-weighted similarity */ private updateTokenFlow(currentEmbedding: tf.Tensor2D): void { if (!this.config.enableTokenFlow) { return; } const embedding = currentEmbedding.arraySync()[0]; // Add to history with sliding window this.tokenFlowState.history.push(embedding); if (this.tokenFlowState.history.length > this.tokenFlowState.windowSize) { this.tokenFlowState.history.shift(); } // Compute recency × similarity weights const weights = this.tokenFlowState.history.map((histEmb, i) => { const recency = Math.pow( this.tokenFlowState.decay, this.tokenFlowState.history.length - i - 1 ); const similarity = this.cosineSimilarity(embedding, histEmb); return recency * similarity; }); this.tokenFlowState.weights = weights; } /** * Compute cosine similarity between two embeddings */ private cosineSimilarity(a: number[], b: number[]): number { const minLen = Math.min(a.length, b.length); let dotProduct = 0; let normA = 0; let normB = 0; for (let i = 0; i < minLen; i++) { dotProduct += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } const denominator = Math.sqrt(normA) * Math.sqrt(normB); return denominator > 0 ? dotProduct / denominator : 0; } /** * HOPE Paper: Weight surprise by token flow strength * Integrates sequential dependency into surprise calculation */ private weightSurpriseByTokenFlow(surprise: number): number { if (!this.config.enableTokenFlow || this.tokenFlowState.weights.length === 0) { return surprise; } const flowStrength = this.tokenFlowState.weights.reduce((a, b) => a + b, 0) / this.tokenFlowState.weights.length; // Flow weight factor: how much sequence context affects surprise const flowWeightFactor = 0.3; return surprise * (1 + flowWeightFactor * flowStrength); } /** * Get current token flow metrics for debugging/analysis */ public getTokenFlowMetrics(): { historySize: number; averageWeight: number; flowStrength: number } { const averageWeight = this.tokenFlowState.weights.length > 0 ? this.tokenFlowState.weights.reduce((a, b) => a + b, 0) / this.tokenFlowState.weights.length : 0; return { historySize: this.tokenFlowState.history.length, averageWeight, flowStrength: averageWeight }; } // MCP Server compatibility methods public async init_model(config: any): Promise<{ status: string }> { await this.initialize(config); return { status: 'initialized' }; } public async forward_pass(x: string | number[], memoryState?: IMemoryState): Promise<any> { let inputTensor: tf.Tensor2D; if (typeof x === 'string') { inputTensor = await this.encodeText(x); } else { inputTensor = tf.tensor2d([x]); } const state = (memoryState as HopeMemoryState) ?? this.latestMemoryState; const result = this.forward(inputTensor, state); return { predicted: await result.predicted.array(), memoryState: result.memoryUpdate.newState }; } public async train_step(x_t: string | number[], x_next: string | number[]): Promise<{ loss: number }> { let inputTensor: tf.Tensor2D; let targetTensor: tf.Tensor2D; if (typeof x_t === 'string') { inputTensor = await this.encodeText(x_t); } else { inputTensor = tf.tensor2d([x_t]); } if (typeof x_next === 'string') { targetTensor = await this.encodeText(x_next); } else { targetTensor = tf.tensor2d([x_next]); } const result = this.trainStep(inputTensor, targetTensor, this.latestMemoryState); const lossValue = await result.loss.data(); return { loss: lossValue[0] }; } public get_memory_state(): any { return { shortTerm: this.latestMemoryState.shortTerm.shape, longTerm: this.latestMemoryState.longTerm.shape, archive: this.latestMemoryState.archive.shape, surpriseHistory: this.latestMemoryState.surpriseHistory.shape }; } /** * Rebuild model components to reflect the current configuration. * Used during initialization and when loading checkpoints/configs. */ private buildComponents(): void { this.disposeTrainables(); const memoryConfig: ContinuumMemoryConfig = { memoryDim: this.config.memoryDim, shortTermSlots: this.config.shortTermSlots, longTermSlots: this.config.longTermSlots, archiveSlots: this.config.archiveSlots, promotionThreshold: this.config.promotionThreshold, surpriseRetention: this.config.surpriseRetention, momentumDecay: 0.9, enableMomentum: this.config.enableMomentum ?? true, enableForgettingGate: this.config.enableForgettingGate ?? true, baseForgettingRate: this.config.baseForgettingRate ?? 0.1, surpriseForgettingWeight: this.config.surpriseForgettingWeight ?? 0.3 }; this.continuumMemory = new ContinuumMemory(memoryConfig); this.selectiveFilter = new SelectiveStateSpace({ hiddenDim: this.config.hiddenDim, contextDim: this.config.hiddenDim, dropoutRate: this.config.dropoutRate }); const coreConfig: RetentiveCoreConfig = { inputDim: this.config.inputDim + this.config.memoryDim, hiddenDim: this.config.hiddenDim, dropoutRate: this.config.dropoutRate, chunkSize: 64 }; this.retentiveCore = new RetentiveCore(coreConfig, this.selectiveFilter); this.memoryRouter = new MemoryRouter({ hiddenDim: this.config.hiddenDim, numExperts: 3, topK: this.config.routerTopK }); this.compressionHook = new DeltaCompressionHook(); this.layerScheduler = new LayerScheduler({ maxActiveLayers: 4 }); this.updateBuffer = new UpdateBuffer(); this.outputKernel = tf.variable(tf.randomNormal([this.config.hiddenDim, this.config.inputDim])); this.outputBias = tf.variable(tf.zeros([this.config.inputDim])); this.optimizer = tf.train.adam(this.config.learningRate); this.retentionState = this.retentiveCore.initState(1); this.latestMemoryState = this.continuumMemory.initialize(); this.tokenFlowState = { history: [], weights: [], windowSize: 32, decay: 0.95 }; this.consolidationInterval = this.config.consolidationInterval ?? 100; } private disposeTrainables(): void { try { this.outputKernel?.dispose(); this.outputBias?.dispose(); if (this.retentionState?.hidden) { tf.dispose(this.retentionState.hidden); } if (this.retentionState?.cell) { tf.dispose(this.retentionState.cell); } } catch { // best-effort cleanup } } } // Export types for external use export type { HopeMemoryConfig, HopeMemoryState, HierarchicalStats, RetentionState, RoutingDecision };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/henryhawke/mcp-titan'

If you have feedback or need assistance with the MCP directory API, please join our Discord server