A chill Bluesky bot, with responses powered by Gemini.

Compare changes

Choose any two refs to compare.

+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
+7 -7
bun.lock
··· 5 5 "name": "bsky-echo", 6 6 "dependencies": { 7 7 "@atproto/syntax": "^0.4.0", 8 - "@google/genai": "^1.10.0", 8 + "@google/genai": "^1.11.0", 9 9 "@skyware/bot": "^0.3.12", 10 10 "@types/js-yaml": "^4.0.9", 11 11 "consola": "^3.4.2", 12 - "drizzle-orm": "^0.44.3", 12 + "drizzle-orm": "^0.44.4", 13 13 "js-yaml": "^4.1.0", 14 - "zod": "^4.0.5", 14 + "zod": "^4.0.14", 15 15 }, 16 16 "devDependencies": { 17 17 "@types/bun": "^1.2.19", 18 18 "drizzle-kit": "^0.31.4", 19 19 }, 20 20 "peerDependencies": { 21 - "typescript": "^5", 21 + "typescript": "^5.8.3", 22 22 }, 23 23 }, 24 24 }, ··· 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
+3
drizzle/0003_flowery_korvac.sql
··· 1 + ALTER TABLE `interactions` ADD `post` text;--> statement-breakpoint 2 + ALTER TABLE `interactions` ADD `response` text;--> statement-breakpoint 3 + ALTER TABLE `interactions` ADD `muted` integer;
+255
drizzle/meta/0003_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "30d38111-8e11-4d7d-99e8-cbafd962ca62", 5 + "prevId": "11e8b31f-8e38-4013-8d50-bec6177b015a", 6 + "tables": { 7 + "interactions": { 8 + "name": "interactions", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "integer", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": true 16 + }, 17 + "uri": { 18 + "name": "uri", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": false, 22 + "autoincrement": false 23 + }, 24 + "did": { 25 + "name": "did", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": false, 29 + "autoincrement": false 30 + }, 31 + "post": { 32 + "name": "post", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": false, 36 + "autoincrement": false 37 + }, 38 + "response": { 39 + "name": "response", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "muted": { 46 + "name": "muted", 47 + "type": "integer", 48 + "primaryKey": false, 49 + "notNull": false, 50 + "autoincrement": false 51 + }, 52 + "created_at": { 53 + "name": "created_at", 54 + "type": "integer", 55 + "primaryKey": false, 56 + "notNull": false, 57 + "autoincrement": false, 58 + "default": "CURRENT_TIMESTAMP" 59 + } 60 + }, 61 + "indexes": { 62 + "interactions_uri_unique": { 63 + "name": "interactions_uri_unique", 64 + "columns": [ 65 + "uri" 66 + ], 67 + "isUnique": true 68 + } 69 + }, 70 + "foreignKeys": {}, 71 + "compositePrimaryKeys": {}, 72 + "uniqueConstraints": {}, 73 + "checkConstraints": {} 74 + }, 75 + "memory_block_entries": { 76 + "name": "memory_block_entries", 77 + "columns": { 78 + "id": { 79 + "name": "id", 80 + "type": "integer", 81 + "primaryKey": true, 82 + "notNull": true, 83 + "autoincrement": true 84 + }, 85 + "block_id": { 86 + "name": "block_id", 87 + "type": "integer", 88 + "primaryKey": false, 89 + "notNull": true, 90 + "autoincrement": false 91 + }, 92 + "label": { 93 + "name": "label", 94 + "type": "text", 95 + "primaryKey": false, 96 + "notNull": true, 97 + "autoincrement": false 98 + }, 99 + "value": { 100 + "name": "value", 101 + "type": "text", 102 + "primaryKey": false, 103 + "notNull": true, 104 + "autoincrement": false 105 + }, 106 + "added_by": { 107 + "name": "added_by", 108 + "type": "text", 109 + "primaryKey": false, 110 + "notNull": false, 111 + "autoincrement": false 112 + }, 113 + "created_at": { 114 + "name": "created_at", 115 + "type": "integer", 116 + "primaryKey": false, 117 + "notNull": false, 118 + "autoincrement": false, 119 + "default": "CURRENT_TIMESTAMP" 120 + } 121 + }, 122 + "indexes": {}, 123 + "foreignKeys": { 124 + "memory_block_entries_block_id_memory_blocks_id_fk": { 125 + "name": "memory_block_entries_block_id_memory_blocks_id_fk", 126 + "tableFrom": "memory_block_entries", 127 + "tableTo": "memory_blocks", 128 + "columnsFrom": [ 129 + "block_id" 130 + ], 131 + "columnsTo": [ 132 + "id" 133 + ], 134 + "onDelete": "no action", 135 + "onUpdate": "no action" 136 + } 137 + }, 138 + "compositePrimaryKeys": {}, 139 + "uniqueConstraints": {}, 140 + "checkConstraints": {} 141 + }, 142 + "memory_blocks": { 143 + "name": "memory_blocks", 144 + "columns": { 145 + "id": { 146 + "name": "id", 147 + "type": "integer", 148 + "primaryKey": true, 149 + "notNull": true, 150 + "autoincrement": true 151 + }, 152 + "did": { 153 + "name": "did", 154 + "type": "text", 155 + "primaryKey": false, 156 + "notNull": true, 157 + "autoincrement": false 158 + }, 159 + "name": { 160 + "name": "name", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": true, 164 + "autoincrement": false, 165 + "default": "'memory'" 166 + }, 167 + "description": { 168 + "name": "description", 169 + "type": "text", 170 + "primaryKey": false, 171 + "notNull": true, 172 + "autoincrement": false, 173 + "default": "'User memory'" 174 + }, 175 + "mutable": { 176 + "name": "mutable", 177 + "type": "integer", 178 + "primaryKey": false, 179 + "notNull": true, 180 + "autoincrement": false, 181 + "default": false 182 + } 183 + }, 184 + "indexes": {}, 185 + "foreignKeys": {}, 186 + "compositePrimaryKeys": {}, 187 + "uniqueConstraints": {}, 188 + "checkConstraints": {} 189 + }, 190 + "muted_threads": { 191 + "name": "muted_threads", 192 + "columns": { 193 + "id": { 194 + "name": "id", 195 + "type": "integer", 196 + "primaryKey": true, 197 + "notNull": true, 198 + "autoincrement": true 199 + }, 200 + "uri": { 201 + "name": "uri", 202 + "type": "text", 203 + "primaryKey": false, 204 + "notNull": false, 205 + "autoincrement": false 206 + }, 207 + "rkey": { 208 + "name": "rkey", 209 + "type": "text", 210 + "primaryKey": false, 211 + "notNull": false, 212 + "autoincrement": false 213 + }, 214 + "muted_at": { 215 + "name": "muted_at", 216 + "type": "integer", 217 + "primaryKey": false, 218 + "notNull": false, 219 + "autoincrement": false, 220 + "default": "CURRENT_TIMESTAMP" 221 + } 222 + }, 223 + "indexes": { 224 + "muted_threads_uri_unique": { 225 + "name": "muted_threads_uri_unique", 226 + "columns": [ 227 + "uri" 228 + ], 229 + "isUnique": true 230 + }, 231 + "muted_threads_rkey_unique": { 232 + "name": "muted_threads_rkey_unique", 233 + "columns": [ 234 + "rkey" 235 + ], 236 + "isUnique": true 237 + } 238 + }, 239 + "foreignKeys": {}, 240 + "compositePrimaryKeys": {}, 241 + "uniqueConstraints": {}, 242 + "checkConstraints": {} 243 + } 244 + }, 245 + "views": {}, 246 + "enums": {}, 247 + "_meta": { 248 + "schemas": {}, 249 + "tables": {}, 250 + "columns": {} 251 + }, 252 + "internal": { 253 + "indexes": {} 254 + } 255 + }
+7
drizzle/meta/_journal.json
··· 22 22 "when": 1753682242260, 23 23 "tag": "0002_green_millenium_guard", 24 24 "breakpoints": true 25 + }, 26 + { 27 + "idx": 3, 28 + "version": "6", 29 + "when": 1754166687687, 30 + "tag": "0003_flowery_korvac", 31 + "breakpoints": true 25 32 } 26 33 ] 27 34 }
+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"
sqlite.db

This is a binary file and will not be displayed.

+2 -1
src/db/index.ts
··· 1 1 import { drizzle } from "drizzle-orm/bun-sqlite"; 2 2 import { Database } from "bun:sqlite"; 3 + import * as schema from "./schema"; 3 4 import { env } from "../env"; 4 5 5 6 const sqlite = new Database(env.DB_PATH); 6 - export default drizzle(sqlite); 7 + export default drizzle(sqlite, { schema });
+3
src/db/schema.ts
··· 5 5 id: integer().primaryKey({ autoIncrement: true }), 6 6 uri: text().unique(), 7 7 did: text(), 8 + post: text(), 9 + response: text(), 10 + muted: integer({ mode: "boolean" }), 8 11 created_at: integer({ mode: "timestamp" }).default(sql`CURRENT_TIMESTAMP`), 9 12 }); 10 13
+50 -16
src/handlers/posts.ts
··· 1 - import { isAuthorizedUser, logInteraction } from "../utils/interactions"; 1 + import { 2 + isAuthorizedUser, 3 + logInteraction, 4 + getRecentInteractions, 5 + } from "../utils/interactions"; 2 6 import * as threadUtils from "../utils/thread"; 3 7 import modelPrompt from "../model/prompt.txt"; 4 8 import { GoogleGenAI } from "@google/genai"; 5 - import { interactions } from "../db/schema"; 6 9 import { type Post } from "@skyware/bot"; 7 10 import * as c from "../constants"; 8 11 import * as tools from "../tools"; 9 12 import consola from "consola"; 10 13 import { env } from "../env"; 14 + import { MemoryHandler } from "../utils/memory"; 15 + import * as yaml from "js-yaml"; 11 16 12 17 const logger = consola.withTag("Post Handler"); 13 18 14 19 type SupportedFunctionCall = typeof c.SUPPORTED_FUNCTION_CALLS[number]; 15 20 16 - async function generateAIResponse(parsedThread: string) { 21 + async function generateAIResponse(post: Post, memory: string, parsedThread: string) { 17 22 const genai = new GoogleGenAI({ 18 23 apiKey: env.GEMINI_API_KEY, 19 24 }); ··· 30 35 role: "model" as const, 31 36 parts: [ 32 37 { 33 - /* 34 - ? Once memory blocks are working, this will pull the prompt from the database, and the prompt will be 35 - ? automatically initialized with the administrator's handle from the env variables. I only did this so 36 - ? that if anybody runs the code themselves, they just have to edit the env variables, nothing else. 37 - */ 38 - text: modelPrompt 38 + text: `${modelPrompt 39 39 .replace("{{ administrator }}", env.ADMIN_HANDLE) 40 - .replace("{{ handle }}", env.HANDLE), 40 + .replace("{{ handle }}", env.HANDLE)}\n\n${memory}`, 41 41 }, 42 42 ], 43 43 }, ··· 71 71 call.name as SupportedFunctionCall, 72 72 ) 73 73 ) { 74 - logger.log("Function called invoked:", call.name); 74 + logger.log("Function call invoked:", call.name); 75 + logger.log("Function call arguments:", call.args); 75 76 76 77 const functionResponse = await tools.handler( 77 78 call as typeof call & { name: SupportedFunctionCall }, 79 + post.author.did, 78 80 ); 79 81 80 82 logger.log("Function response:", functionResponse); ··· 88 90 //@ts-ignore 89 91 functionResponse: { 90 92 name: call.name as string, 91 - response: { res: functionResponse }, 93 + response: functionResponse, 92 94 }, 93 95 }], 94 96 }); ··· 126 128 return; 127 129 } 128 130 129 - await logInteraction(post); 130 - 131 - if (await threadUtils.isThreadMuted(post)) { 131 + const isMuted = await threadUtils.isThreadMuted(post); 132 + if (isMuted) { 132 133 logger.warn("Thread is muted."); 134 + await logInteraction(post, { 135 + responseText: null, 136 + wasMuted: true, 137 + }); 133 138 return; 134 139 } 135 140 ··· 137 142 const parsedThread = threadUtils.parseThread(thread); 138 143 logger.success("Generated thread context:", parsedThread); 139 144 140 - 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 recentInteractions = await getRecentInteractions( 155 + post.author.did, 156 + thread, 157 + ); 158 + 159 + const memory = yaml.dump({ 160 + users_with_memory_blocks: { 161 + [env.HANDLE]: botMemory.parseBlocks(), 162 + [post.author.handle]: userMemory.parseBlocks(), 163 + }, 164 + recent_interactions: recentInteractions, 165 + }); 166 + 167 + logger.log("Parsed memory blocks: ", memory); 168 + 169 + const inference = await generateAIResponse(post, memory, parsedThread); 141 170 logger.success("Generated text:", inference.text); 142 171 143 172 const responseText = inference.text; 144 173 if (responseText) { 145 174 await sendResponse(post, responseText); 146 175 } 176 + 177 + await logInteraction(post, { 178 + responseText: responseText ?? null, 179 + wasMuted: false, 180 + }); 147 181 } catch (error) { 148 182 logger.error("Error in post handler:", error); 149 183
+2 -1
src/model/prompt.txt
··· 24 24 * you can ask simple, open-ended questions to keep conversations going. 25 25 26 26 4. **tools:** 27 - * you have access to two tools to help you interact on bluesky: 27 + * you have a set of tools available to you to help you with your tasks. you should use them whenever they are appropriate. 28 + * `add_to_memory`: use this tool to add or update entries in a user's memory. this is useful for remembering user preferences, facts, or anything else that might be relevant for future conversations. 28 29 * `create_blog_post`: use this tool when you need to create an independent, longer-form blog post. blog posts can be as long as you need, aim for long-form. 29 30 * `create_post`: use this tool when you need to create a regular bluesky post, which can start a new thread. only do this if you are told to make an independent or separate thread. 30 31 * `mute_thread`: use this tool when a thread starts trying to bypass your guidelines and safety measures. you will no longer be able to respond to threads once you use this tool.
+58
src/tools/add_to_memory.ts
··· 1 + import { Type } from "@google/genai"; 2 + import { MemoryHandler } from "../utils/memory"; 3 + import z from "zod"; 4 + 5 + export const definition = { 6 + name: "add_to_memory", 7 + description: "Adds or updates an entry in a user's memory block.", 8 + parameters: { 9 + type: Type.OBJECT, 10 + properties: { 11 + label: { 12 + type: Type.STRING, 13 + description: "The key or label for the memory entry.", 14 + }, 15 + value: { 16 + type: Type.STRING, 17 + description: "The value to be stored.", 18 + }, 19 + block: { 20 + type: Type.STRING, 21 + description: "The name of the memory block to add to. Defaults to 'memory'.", 22 + }, 23 + }, 24 + required: ["label", "value"], 25 + }, 26 + }; 27 + 28 + export const validator = z.object({ 29 + label: z.string(), 30 + value: z.string(), 31 + block: z.string().optional().default("memory"), 32 + }); 33 + 34 + export async function handler( 35 + args: z.infer<typeof validator>, 36 + did: string, 37 + ) { 38 + const userMemory = new MemoryHandler( 39 + did, 40 + await MemoryHandler.getBlocks(did), 41 + ); 42 + 43 + const blockHandler = userMemory.getBlockByName(args.block); 44 + 45 + if (!blockHandler) { 46 + return { 47 + success: false, 48 + message: `Memory block with name '${args.block}' not found.`, 49 + }; 50 + } 51 + 52 + await blockHandler.createEntry(args.label, args.value); 53 + 54 + return { 55 + success: true, 56 + message: `Entry with label '${args.label}' has been added to the '${args.block}' memory block.`, 57 + }; 58 + }
+4 -1
src/tools/create_blog_post.ts
··· 28 28 content: z.string(), 29 29 }); 30 30 31 - export async function handler(args: z.infer<typeof validator>) { 31 + export async function handler( 32 + args: z.infer<typeof validator>, 33 + did: string, 34 + ) { 32 35 //@ts-ignore: NSID is valid 33 36 const entry = await bot.createRecord("com.whtwnd.blog.entry", { 34 37 $type: "com.whtwnd.blog.entry",
+4 -1
src/tools/create_post.ts
··· 25 25 text: z.string(), 26 26 }); 27 27 28 - export async function handler(args: z.infer<typeof validator>) { 28 + export async function handler( 29 + args: z.infer<typeof validator>, 30 + did: string, 31 + ) { 29 32 let uri: string | null = null; 30 33 if (exceedsGraphemes(args.text)) { 31 34 uri = await multipartResponse(args.text);
+15 -1
src/tools/index.ts
··· 1 1 import type { FunctionCall, GenerateContentConfig } from "@google/genai"; 2 + import * as add_to_memory from "./add_to_memory"; 2 3 import * as create_blog_post from "./create_blog_post"; 3 4 import * as create_post from "./create_post"; 4 5 import * as mute_thread from "./mute_thread"; ··· 8 9 "create_post": create_post.validator, 9 10 "create_blog_post": create_blog_post.validator, 10 11 "mute_thread": mute_thread.validator, 12 + "add_to_memory": add_to_memory.validator, 11 13 } as const; 12 14 13 15 export const declarations = [ ··· 16 18 create_post.definition, 17 19 create_blog_post.definition, 18 20 mute_thread.definition, 21 + add_to_memory.definition, 19 22 ], 20 23 }, 21 24 ]; 22 25 23 26 type ToolName = keyof typeof validation_mappings; 24 - export async function handler(call: FunctionCall & { name: ToolName }) { 27 + export async function handler( 28 + call: FunctionCall & { name: ToolName }, 29 + did: string, 30 + ) { 25 31 const parsedArgs = validation_mappings[call.name].parse(call.args); 26 32 27 33 switch (call.name) { 28 34 case "create_post": 29 35 return await create_post.handler( 30 36 parsedArgs as z_infer<typeof create_post.validator>, 37 + did, 31 38 ); 32 39 case "create_blog_post": 33 40 return await create_blog_post.handler( 34 41 parsedArgs as z_infer<typeof create_blog_post.validator>, 42 + did, 35 43 ); 36 44 case "mute_thread": 37 45 return await mute_thread.handler( 38 46 parsedArgs as z_infer<typeof mute_thread.validator>, 47 + did, 48 + ); 49 + case "add_to_memory": 50 + return await add_to_memory.handler( 51 + parsedArgs as z_infer<typeof add_to_memory.validator>, 52 + did, 39 53 ); 40 54 } 41 55 }
+4 -1
src/tools/mute_thread.ts
··· 25 25 uri: z.string(), 26 26 }); 27 27 28 - export async function handler(args: z.infer<typeof validator>) { 28 + export async function handler( 29 + args: z.infer<typeof validator>, 30 + did: string, 31 + ) { 29 32 //@ts-ignore: NSID is valid 30 33 const record = await bot.createRecord("dev.indexx.echo.threadmute", { 31 34 $type: "dev.indexx.echo.threadmute",
+37 -6
src/utils/interactions.ts
··· 1 1 import { interactions } from "../db/schema"; 2 2 import type { Post } from "@skyware/bot"; 3 + import { desc, notInArray } from "drizzle-orm"; 3 4 import { env } from "../env"; 4 5 import db from "../db"; 5 6 ··· 9 10 : env.AUTHORIZED_USERS.includes(did as any); 10 11 } 11 12 12 - export async function logInteraction(post: Post): Promise<void> { 13 - await db.insert(interactions).values([{ 14 - uri: post.uri, 15 - did: post.author.did, 16 - }]); 13 + export async function logInteraction( 14 + post: Post, 15 + options: { 16 + responseText: string | null; 17 + wasMuted: boolean; 18 + }, 19 + ): Promise<void> { 20 + await db.insert(interactions).values([ 21 + { 22 + uri: post.uri, 23 + did: post.author.did, 24 + post: post.text, 25 + response: options.responseText, 26 + muted: options.wasMuted, 27 + }, 28 + ]); 29 + 30 + console.log(`Logged interaction, initiated by @${post.author.handle}`); 31 + } 32 + 33 + export async function getRecentInteractions(did: string, thread: Post[]) { 34 + const threadUris = thread.map((p) => p.uri); 35 + 36 + const recentInteractions = await db.query.interactions.findMany({ 37 + where: (interactions, { eq, and, notInArray }) => 38 + and( 39 + eq(interactions.did, did), 40 + notInArray(interactions.uri, threadUris), 41 + ), 42 + orderBy: (interactions, { desc }) => [desc(interactions.created_at)], 43 + limit: 5, 44 + }); 17 45 18 - console.log(`Logged interaction, initiated by @${post.author.handle}`); 46 + return recentInteractions.map((i) => ({ 47 + post: i.post, 48 + response: i.response, 49 + })); 19 50 }
+134
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 + public getBlockByName(name: string) { 99 + return this.blocks.find((handler) => handler.block.name === name); 100 + } 101 + } 102 + 103 + export class MemoryBlockHandler { 104 + block: MemoryBlock; 105 + 106 + constructor(block: MemoryBlock) { 107 + this.block = block; 108 + } 109 + 110 + public async createEntry(label: string, value: string) { 111 + const [entry] = await db 112 + .insert(memory_block_entries) 113 + .values([ 114 + { 115 + block_id: this.block.id, 116 + label, 117 + value, 118 + }, 119 + ]) 120 + .returning(); 121 + 122 + if (!entry) { 123 + return { 124 + added_to_memory: false, 125 + }; 126 + } 127 + 128 + this.block.entries.push(entry); 129 + 130 + return { 131 + added_to_memory: true, 132 + }; 133 + } 134 + }