A chill Bluesky bot, with responses powered by Gemini.

feat: basic parsing

Index 16d51a1d ca336a8c

Changed files
+165 -11
src
handlers
utils
+3 -1
.env.example
··· 1 1 # Comma-separated list of users who can use the bot (delete var if you want everyone to be able to use it) 2 2 AUTHORIZED_USERS="" 3 3 4 - SERVICE="https://pds.indexx.dev" # PDS service URL (optional) 4 + # PDS service URL (optional) 5 + SERVICE="https://pds.indexx.dev" 6 + 5 7 DB_PATH="data/sqlite.db" 6 8 GEMINI_MODEL="gemini-2.0-flash-lite" 7 9
+3 -3
bun.lock
··· 107 107 108 108 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], 109 109 110 - "@google/genai": ["@google/genai@1.10.0", "", { "dependencies": { "google-auth-library": "^9.14.2", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.11.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-PR4tLuiIFMrpAiiCko2Z16ydikFsPF1c5TBfI64hlZcv3xBEApSCceLuDYu1pNMq2SkNh4r66J4AG+ZexBnMLw=="], 110 + "@google/genai": ["@google/genai@1.11.0", "", { "dependencies": { "google-auth-library": "^9.14.2", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.11.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-4XFAHCvU91ewdWOU3RUdSeXpDuZRJHNYLqT9LKw7WqPjRQcEJvVU+VOU49ocruaSp8VuLKMecl0iadlQK+Zgfw=="], 111 111 112 112 "@skyware/bot": ["@skyware/bot@0.3.12", "", { "dependencies": { "@atcute/bluesky": "^1.0.7", "@atcute/bluesky-richtext-builder": "^1.0.1", "@atcute/client": "^2.0.3", "@atcute/ozone": "^1.0.5", "quick-lru": "^7.0.0", "rate-limit-threshold": "^0.1.5" }, "optionalDependencies": { "@skyware/firehose": "^0.3.2", "@skyware/jetstream": "^0.2.2" } }, "sha512-5OqTtwItYsBFMh0nwrxfsqgXrvRaJzg1P+ghMV4rlRGwHhdRgBJcnYQYgUqqREFcB247yGo73LNyqq7kHEwV7Q=="], 113 113 ··· 145 145 146 146 "drizzle-kit": ["drizzle-kit@0.31.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA=="], 147 147 148 - "drizzle-orm": ["drizzle-orm@0.44.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-8nIiYQxOpgUicEL04YFojJmvC4DNO4KoyXsEIqN44+g6gNBr6hmVpWk3uyAt4CaTiRGDwoU+alfqNNeonLAFOQ=="], 148 + "drizzle-orm": ["drizzle-orm@0.44.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-ZyzKFpTC/Ut3fIqc2c0dPZ6nhchQXriTsqTNs4ayRgl6sZcFlMs9QZKPSHXK4bdOf41GHGWf+FrpcDDYwW+W6Q=="], 149 149 150 150 "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], 151 151 ··· 217 217 218 218 "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], 219 219 220 - "zod": ["zod@4.0.5", "", {}, "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA=="], 220 + "zod": ["zod@4.0.14", "", {}, "sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw=="], 221 221 222 222 "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], 223 223
+4 -4
package.json
··· 14 14 "drizzle-kit": "^0.31.4" 15 15 }, 16 16 "peerDependencies": { 17 - "typescript": "^5" 17 + "typescript": "^5.8.3" 18 18 }, 19 19 "dependencies": { 20 20 "@atproto/syntax": "^0.4.0", 21 - "@google/genai": "^1.10.0", 21 + "@google/genai": "^1.11.0", 22 22 "@skyware/bot": "^0.3.12", 23 23 "@types/js-yaml": "^4.0.9", 24 24 "consola": "^3.4.2", 25 - "drizzle-orm": "^0.44.3", 25 + "drizzle-orm": "^0.44.4", 26 26 "js-yaml": "^4.1.0", 27 - "zod": "^4.0.5" 27 + "zod": "^4.0.14" 28 28 }, 29 29 "repository": { 30 30 "url": "https://github.com/indexxing/echo"
+25 -3
src/handlers/posts.ts
··· 2 2 import * as threadUtils from "../utils/thread"; 3 3 import modelPrompt from "../model/prompt.txt"; 4 4 import { GoogleGenAI } from "@google/genai"; 5 - import { interactions } from "../db/schema"; 6 5 import { type Post } from "@skyware/bot"; 7 6 import * as c from "../constants"; 8 7 import * as tools from "../tools"; 9 8 import consola from "consola"; 10 9 import { env } from "../env"; 10 + import { MemoryHandler } from "../utils/memory"; 11 + import * as yaml from "js-yaml"; 11 12 12 13 const logger = consola.withTag("Post Handler"); 13 14 14 15 type SupportedFunctionCall = typeof c.SUPPORTED_FUNCTION_CALLS[number]; 15 16 16 - async function generateAIResponse(parsedThread: string) { 17 + async function generateAIResponse(memory: string, parsedThread: string) { 17 18 const genai = new GoogleGenAI({ 18 19 apiKey: env.GEMINI_API_KEY, 19 20 }); ··· 39 40 "{{ administrator }}", 40 41 env.ADMIN_HANDLE, 41 42 ), 43 + }, 44 + { 45 + text: memory, 42 46 }, 43 47 ], 44 48 }, ··· 138 142 const parsedThread = threadUtils.parseThread(thread); 139 143 logger.success("Generated thread context:", parsedThread); 140 144 141 - const inference = await generateAIResponse(parsedThread); 145 + const botMemory = new MemoryHandler( 146 + env.DID, 147 + await MemoryHandler.getBlocks(env.DID), 148 + ); 149 + const userMemory = new MemoryHandler( 150 + post.author.did, 151 + await MemoryHandler.getBlocks(post.author.did), 152 + ); 153 + 154 + const memory = yaml.dump({ 155 + users_with_memory_blocks: { 156 + [env.HANDLE]: botMemory.parseBlocks(), 157 + [post.author.handle]: userMemory.parseBlocks(), 158 + }, 159 + }); 160 + 161 + logger.log("Parsed memory blocks: ", memory); 162 + 163 + const inference = await generateAIResponse(memory, parsedThread); 142 164 logger.success("Generated text:", inference.text); 143 165 144 166 const responseText = inference.text;
+130
src/utils/memory.ts
··· 1 + import { and, desc, eq } from "drizzle-orm"; 2 + import db from "../db"; 3 + import { memory_block_entries, memory_blocks } from "../db/schema"; 4 + import * as yaml from "js-yaml"; 5 + 6 + type MemoryBlock = { 7 + id: number; 8 + name: string; 9 + description: string; 10 + mutable: boolean; 11 + entries: Entry[]; 12 + }; 13 + 14 + type Entry = { 15 + id: number; 16 + block_id: number; 17 + label: string; 18 + value: string; 19 + added_by: string | null; 20 + created_at: Date | null; 21 + }; 22 + 23 + export class MemoryHandler { 24 + did: string; 25 + blocks: MemoryBlockHandler[]; 26 + 27 + constructor(did: string, blocks: MemoryBlockHandler[]) { 28 + this.did = did; 29 + this.blocks = blocks; 30 + } 31 + 32 + static async getBlocks(did: string) { 33 + const blocks = await db 34 + .select({ 35 + id: memory_blocks.id, 36 + name: memory_blocks.name, 37 + description: memory_blocks.description, 38 + mutable: memory_blocks.mutable, 39 + }) 40 + .from(memory_blocks) 41 + .where(eq(memory_blocks.did, did)); 42 + 43 + const hydratedBlocks = []; 44 + 45 + for (const block of blocks) { 46 + const entries = await db 47 + .select() 48 + .from(memory_block_entries) 49 + .where(eq(memory_block_entries.block_id, block.id)) 50 + .orderBy(desc(memory_block_entries.id)) 51 + .limit(15); 52 + 53 + hydratedBlocks.push({ 54 + ...block, 55 + entries, 56 + }); 57 + } 58 + 59 + if (hydratedBlocks.length == 0) { 60 + const [newBlock] = await db 61 + .insert(memory_blocks) 62 + .values([ 63 + { 64 + did, 65 + name: "memory", 66 + description: "User memory", 67 + mutable: false, 68 + }, 69 + ]) 70 + .returning(); 71 + 72 + hydratedBlocks.push({ 73 + ...newBlock, 74 + entries: [], 75 + }); 76 + } 77 + 78 + return hydratedBlocks.map( 79 + (block) => 80 + new MemoryBlockHandler( 81 + block as MemoryBlock, 82 + ), 83 + ); 84 + } 85 + 86 + public parseBlocks() { 87 + return this.blocks.map((handler) => ({ 88 + name: handler.block.name, 89 + description: handler.block.description, 90 + entries: handler.block.entries.map((entry) => ({ 91 + label: entry.label, 92 + value: entry.value, 93 + added_by: entry.added_by || "nobody", 94 + })), 95 + })); 96 + } 97 + } 98 + 99 + export class MemoryBlockHandler { 100 + block: MemoryBlock; 101 + 102 + constructor(block: MemoryBlock) { 103 + this.block = block; 104 + } 105 + 106 + public async createEntry(label: string, value: string) { 107 + const [entry] = await db 108 + .insert(memory_block_entries) 109 + .values([ 110 + { 111 + block_id: this.block.id, 112 + label, 113 + value, 114 + }, 115 + ]) 116 + .returning(); 117 + 118 + if (!entry) { 119 + return { 120 + added_to_memory: false, 121 + }; 122 + } 123 + 124 + this.block.entries.push(entry); 125 + 126 + return { 127 + added_to_memory: true, 128 + }; 129 + } 130 + }