A chill Bluesky bot, with responses powered by Gemini.

I've made some improvements to how I learn from our interactions.

I've updated the way I log our conversations so I can better remember your posts, my responses, and whether a particular conversation is muted.

To give you more relevant and personalized responses, I'll also review our last five interactions to get better context for your requests. I'll be sure to exclude messages from our current conversation to avoid repeating myself.

+4 -4
bun.lock
··· 5 "name": "bsky-echo", 6 "dependencies": { 7 "@atproto/syntax": "^0.4.0", 8 - "@google/genai": "^1.10.0", 9 "@skyware/bot": "^0.3.12", 10 "@types/js-yaml": "^4.0.9", 11 "consola": "^3.4.2", 12 - "drizzle-orm": "^0.44.3", 13 "js-yaml": "^4.1.0", 14 - "zod": "^4.0.5", 15 }, 16 "devDependencies": { 17 "@types/bun": "^1.2.19", 18 "drizzle-kit": "^0.31.4", 19 }, 20 "peerDependencies": { 21 - "typescript": "^5", 22 }, 23 }, 24 },
··· 5 "name": "bsky-echo", 6 "dependencies": { 7 "@atproto/syntax": "^0.4.0", 8 + "@google/genai": "^1.11.0", 9 "@skyware/bot": "^0.3.12", 10 "@types/js-yaml": "^4.0.9", 11 "consola": "^3.4.2", 12 + "drizzle-orm": "^0.44.4", 13 "js-yaml": "^4.1.0", 14 + "zod": "^4.0.14", 15 }, 16 "devDependencies": { 17 "@types/bun": "^1.2.19", 18 "drizzle-kit": "^0.31.4", 19 }, 20 "peerDependencies": { 21 + "typescript": "^5.8.3", 22 }, 23 }, 24 },
+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 "when": 1753682242260, 23 "tag": "0002_green_millenium_guard", 24 "breakpoints": true 25 } 26 ] 27 }
··· 22 "when": 1753682242260, 23 "tag": "0002_green_millenium_guard", 24 "breakpoints": true 25 + }, 26 + { 27 + "idx": 3, 28 + "version": "6", 29 + "when": 1754166687687, 30 + "tag": "0003_flowery_korvac", 31 + "breakpoints": true 32 } 33 ] 34 }
sqlite.db

This is a binary file and will not be displayed.

+2 -1
src/db/index.ts
··· 1 import { drizzle } from "drizzle-orm/bun-sqlite"; 2 import { Database } from "bun:sqlite"; 3 import { env } from "../env"; 4 5 const sqlite = new Database(env.DB_PATH); 6 - export default drizzle(sqlite);
··· 1 import { drizzle } from "drizzle-orm/bun-sqlite"; 2 import { Database } from "bun:sqlite"; 3 + import * as schema from "./schema"; 4 import { env } from "../env"; 5 6 const sqlite = new Database(env.DB_PATH); 7 + export default drizzle(sqlite, { schema });
+3
src/db/schema.ts
··· 5 id: integer().primaryKey({ autoIncrement: true }), 6 uri: text().unique(), 7 did: text(), 8 created_at: integer({ mode: "timestamp" }).default(sql`CURRENT_TIMESTAMP`), 9 }); 10
··· 5 id: integer().primaryKey({ autoIncrement: true }), 6 uri: text().unique(), 7 did: text(), 8 + post: text(), 9 + response: text(), 10 + muted: integer({ mode: "boolean" }), 11 created_at: integer({ mode: "timestamp" }).default(sql`CURRENT_TIMESTAMP`), 12 }); 13
+22 -4
src/handlers/posts.ts
··· 1 - import { isAuthorizedUser, logInteraction } from "../utils/interactions"; 2 import * as threadUtils from "../utils/thread"; 3 import modelPrompt from "../model/prompt.txt"; 4 import { GoogleGenAI } from "@google/genai"; ··· 131 return; 132 } 133 134 - await logInteraction(post); 135 - 136 - if (await threadUtils.isThreadMuted(post)) { 137 logger.warn("Thread is muted."); 138 return; 139 } 140 ··· 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); ··· 167 if (responseText) { 168 await sendResponse(post, responseText); 169 } 170 } catch (error) { 171 logger.error("Error in post handler:", error); 172
··· 1 + import { 2 + isAuthorizedUser, 3 + logInteraction, 4 + getRecentInteractions, 5 + } from "../utils/interactions"; 6 import * as threadUtils from "../utils/thread"; 7 import modelPrompt from "../model/prompt.txt"; 8 import { GoogleGenAI } from "@google/genai"; ··· 135 return; 136 } 137 138 + const isMuted = await threadUtils.isThreadMuted(post); 139 + if (isMuted) { 140 logger.warn("Thread is muted."); 141 + await logInteraction(post, { 142 + responseText: null, 143 + wasMuted: true, 144 + }); 145 return; 146 } 147 ··· 158 await MemoryHandler.getBlocks(post.author.did), 159 ); 160 161 + const recentInteractions = await getRecentInteractions( 162 + post.author.did, 163 + thread, 164 + ); 165 + 166 const memory = yaml.dump({ 167 users_with_memory_blocks: { 168 [env.HANDLE]: botMemory.parseBlocks(), 169 [post.author.handle]: userMemory.parseBlocks(), 170 }, 171 + recent_interactions: recentInteractions, 172 }); 173 174 logger.log("Parsed memory blocks: ", memory); ··· 180 if (responseText) { 181 await sendResponse(post, responseText); 182 } 183 + 184 + await logInteraction(post, { 185 + responseText: responseText ?? null, 186 + wasMuted: false, 187 + }); 188 } catch (error) { 189 logger.error("Error in post handler:", error); 190
+37 -6
src/utils/interactions.ts
··· 1 import { interactions } from "../db/schema"; 2 import type { Post } from "@skyware/bot"; 3 import { env } from "../env"; 4 import db from "../db"; 5 ··· 9 : env.AUTHORIZED_USERS.includes(did as any); 10 } 11 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 - }]); 17 18 - console.log(`Logged interaction, initiated by @${post.author.handle}`); 19 }
··· 1 import { interactions } from "../db/schema"; 2 import type { Post } from "@skyware/bot"; 3 + import { desc, notInArray } from "drizzle-orm"; 4 import { env } from "../env"; 5 import db from "../db"; 6 ··· 10 : env.AUTHORIZED_USERS.includes(did as any); 11 } 12 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 + }); 45 46 + return recentInteractions.map((i) => ({ 47 + post: i.post, 48 + response: i.response, 49 + })); 50 }