Compare commits

..

4 commits

Author SHA1 Message Date
76b07f76cd
Merge pull request #2 from indexxing/feature/improve-interaction-logging 2025-08-02 15:45:19 -05:00
google-labs-jules[bot]
f6132057a2 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.
2025-08-02 20:34:14 +00:00
16d51a1d29 feat: basic parsing 2025-08-02 15:16:43 -05:00
b69b285fe0
Merge pull request #1 from indexxing/improve-prompt-context 2025-08-02 15:09:31 -05:00
12 changed files with 498 additions and 26 deletions

View file

@ -1,7 +1,9 @@
# Comma-separated list of users who can use the bot (delete var if you want everyone to be able to use it)
AUTHORIZED_USERS=""
SERVICE="https://pds.indexx.dev" # PDS service URL (optional)
# PDS service URL (optional)
SERVICE="https://pds.indexx.dev"
DB_PATH="data/sqlite.db"
GEMINI_MODEL="gemini-2.0-flash-lite"

View file

@ -5,20 +5,20 @@
"name": "bsky-echo",
"dependencies": {
"@atproto/syntax": "^0.4.0",
"@google/genai": "^1.10.0",
"@google/genai": "^1.11.0",
"@skyware/bot": "^0.3.12",
"@types/js-yaml": "^4.0.9",
"consola": "^3.4.2",
"drizzle-orm": "^0.44.3",
"drizzle-orm": "^0.44.4",
"js-yaml": "^4.1.0",
"zod": "^4.0.5",
"zod": "^4.0.14",
},
"devDependencies": {
"@types/bun": "^1.2.19",
"drizzle-kit": "^0.31.4",
},
"peerDependencies": {
"typescript": "^5",
"typescript": "^5.8.3",
},
},
},
@ -107,7 +107,7 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="],
"@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=="],
"@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=="],
"@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=="],
@ -145,7 +145,7 @@
"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=="],
"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=="],
"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=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
@ -217,7 +217,7 @@
"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=="],
"zod": ["zod@4.0.5", "", {}, "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA=="],
"zod": ["zod@4.0.14", "", {}, "sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw=="],
"@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=="],

View file

@ -0,0 +1,3 @@
ALTER TABLE `interactions` ADD `post` text;--> statement-breakpoint
ALTER TABLE `interactions` ADD `response` text;--> statement-breakpoint
ALTER TABLE `interactions` ADD `muted` integer;

View file

@ -0,0 +1,255 @@
{
"version": "6",
"dialect": "sqlite",
"id": "30d38111-8e11-4d7d-99e8-cbafd962ca62",
"prevId": "11e8b31f-8e38-4013-8d50-bec6177b015a",
"tables": {
"interactions": {
"name": "interactions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"uri": {
"name": "uri",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"did": {
"name": "did",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"post": {
"name": "post",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"response": {
"name": "response",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"muted": {
"name": "muted",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"interactions_uri_unique": {
"name": "interactions_uri_unique",
"columns": [
"uri"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"memory_block_entries": {
"name": "memory_block_entries",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"block_id": {
"name": "block_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"memory_block_entries_block_id_memory_blocks_id_fk": {
"name": "memory_block_entries_block_id_memory_blocks_id_fk",
"tableFrom": "memory_block_entries",
"tableTo": "memory_blocks",
"columnsFrom": [
"block_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"memory_blocks": {
"name": "memory_blocks",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"did": {
"name": "did",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'memory'"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'User memory'"
},
"mutable": {
"name": "mutable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"muted_threads": {
"name": "muted_threads",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"uri": {
"name": "uri",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rkey": {
"name": "rkey",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"muted_at": {
"name": "muted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"muted_threads_uri_unique": {
"name": "muted_threads_uri_unique",
"columns": [
"uri"
],
"isUnique": true
},
"muted_threads_rkey_unique": {
"name": "muted_threads_rkey_unique",
"columns": [
"rkey"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -22,6 +22,13 @@
"when": 1753682242260,
"tag": "0002_green_millenium_guard",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1754166687687,
"tag": "0003_flowery_korvac",
"breakpoints": true
}
]
}

View file

@ -14,17 +14,17 @@
"drizzle-kit": "^0.31.4"
},
"peerDependencies": {
"typescript": "^5"
"typescript": "^5.8.3"
},
"dependencies": {
"@atproto/syntax": "^0.4.0",
"@google/genai": "^1.10.0",
"@google/genai": "^1.11.0",
"@skyware/bot": "^0.3.12",
"@types/js-yaml": "^4.0.9",
"consola": "^3.4.2",
"drizzle-orm": "^0.44.3",
"drizzle-orm": "^0.44.4",
"js-yaml": "^4.1.0",
"zod": "^4.0.5"
"zod": "^4.0.14"
},
"repository": {
"url": "https://github.com/indexxing/echo"

BIN
sqlite.db Normal file

Binary file not shown.

View file

@ -1,6 +1,7 @@
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
import * as schema from "./schema";
import { env } from "../env";
const sqlite = new Database(env.DB_PATH);
export default drizzle(sqlite);
export default drizzle(sqlite, { schema });

View file

@ -5,6 +5,9 @@ export const interactions = sqliteTable("interactions", {
id: integer().primaryKey({ autoIncrement: true }),
uri: text().unique(),
did: text(),
post: text(),
response: text(),
muted: integer({ mode: "boolean" }),
created_at: integer({ mode: "timestamp" }).default(sql`CURRENT_TIMESTAMP`),
});

View file

@ -1,19 +1,24 @@
import { isAuthorizedUser, logInteraction } from "../utils/interactions";
import {
isAuthorizedUser,
logInteraction,
getRecentInteractions,
} from "../utils/interactions";
import * as threadUtils from "../utils/thread";
import modelPrompt from "../model/prompt.txt";
import { GoogleGenAI } from "@google/genai";
import { interactions } from "../db/schema";
import { type Post } from "@skyware/bot";
import * as c from "../constants";
import * as tools from "../tools";
import consola from "consola";
import { env } from "../env";
import { MemoryHandler } from "../utils/memory";
import * as yaml from "js-yaml";
const logger = consola.withTag("Post Handler");
type SupportedFunctionCall = typeof c.SUPPORTED_FUNCTION_CALLS[number];
async function generateAIResponse(parsedThread: string) {
async function generateAIResponse(memory: string, parsedThread: string) {
const genai = new GoogleGenAI({
apiKey: env.GEMINI_API_KEY,
});
@ -39,6 +44,9 @@ async function generateAIResponse(parsedThread: string) {
.replace("{{ administrator }}", env.ADMIN_HANDLE)
.replace("{{ handle }}", env.HANDLE),
},
{
text: memory,
},
],
},
{
@ -126,10 +134,13 @@ export async function handler(post: Post): Promise<void> {
return;
}
await logInteraction(post);
if (await threadUtils.isThreadMuted(post)) {
const isMuted = await threadUtils.isThreadMuted(post);
if (isMuted) {
logger.warn("Thread is muted.");
await logInteraction(post, {
responseText: null,
wasMuted: true,
});
return;
}
@ -137,13 +148,42 @@ export async function handler(post: Post): Promise<void> {
const parsedThread = threadUtils.parseThread(thread);
logger.success("Generated thread context:", parsedThread);
const inference = await generateAIResponse(parsedThread);
const botMemory = new MemoryHandler(
env.DID,
await MemoryHandler.getBlocks(env.DID),
);
const userMemory = new MemoryHandler(
post.author.did,
await MemoryHandler.getBlocks(post.author.did),
);
const recentInteractions = await getRecentInteractions(
post.author.did,
thread,
);
const memory = yaml.dump({
users_with_memory_blocks: {
[env.HANDLE]: botMemory.parseBlocks(),
[post.author.handle]: userMemory.parseBlocks(),
},
recent_interactions: recentInteractions,
});
logger.log("Parsed memory blocks: ", memory);
const inference = await generateAIResponse(memory, parsedThread);
logger.success("Generated text:", inference.text);
const responseText = inference.text;
if (responseText) {
await sendResponse(post, responseText);
}
await logInteraction(post, {
responseText: responseText ?? null,
wasMuted: false,
});
} catch (error) {
logger.error("Error in post handler:", error);

View file

@ -1,5 +1,6 @@
import { interactions } from "../db/schema";
import type { Post } from "@skyware/bot";
import { desc, notInArray } from "drizzle-orm";
import { env } from "../env";
import db from "../db";
@ -9,11 +10,41 @@ export function isAuthorizedUser(did: string) {
: env.AUTHORIZED_USERS.includes(did as any);
}
export async function logInteraction(post: Post): Promise<void> {
await db.insert(interactions).values([{
uri: post.uri,
did: post.author.did,
}]);
export async function logInteraction(
post: Post,
options: {
responseText: string | null;
wasMuted: boolean;
},
): Promise<void> {
await db.insert(interactions).values([
{
uri: post.uri,
did: post.author.did,
post: post.text,
response: options.responseText,
muted: options.wasMuted,
},
]);
console.log(`Logged interaction, initiated by @${post.author.handle}`);
console.log(`Logged interaction, initiated by @${post.author.handle}`);
}
export async function getRecentInteractions(did: string, thread: Post[]) {
const threadUris = thread.map((p) => p.uri);
const recentInteractions = await db.query.interactions.findMany({
where: (interactions, { eq, and, notInArray }) =>
and(
eq(interactions.did, did),
notInArray(interactions.uri, threadUris),
),
orderBy: (interactions, { desc }) => [desc(interactions.created_at)],
limit: 5,
});
return recentInteractions.map((i) => ({
post: i.post,
response: i.response,
}));
}

130
src/utils/memory.ts Normal file
View file

@ -0,0 +1,130 @@
import { and, desc, eq } from "drizzle-orm";
import db from "../db";
import { memory_block_entries, memory_blocks } from "../db/schema";
import * as yaml from "js-yaml";
type MemoryBlock = {
id: number;
name: string;
description: string;
mutable: boolean;
entries: Entry[];
};
type Entry = {
id: number;
block_id: number;
label: string;
value: string;
added_by: string | null;
created_at: Date | null;
};
export class MemoryHandler {
did: string;
blocks: MemoryBlockHandler[];
constructor(did: string, blocks: MemoryBlockHandler[]) {
this.did = did;
this.blocks = blocks;
}
static async getBlocks(did: string) {
const blocks = await db
.select({
id: memory_blocks.id,
name: memory_blocks.name,
description: memory_blocks.description,
mutable: memory_blocks.mutable,
})
.from(memory_blocks)
.where(eq(memory_blocks.did, did));
const hydratedBlocks = [];
for (const block of blocks) {
const entries = await db
.select()
.from(memory_block_entries)
.where(eq(memory_block_entries.block_id, block.id))
.orderBy(desc(memory_block_entries.id))
.limit(15);
hydratedBlocks.push({
...block,
entries,
});
}
if (hydratedBlocks.length == 0) {
const [newBlock] = await db
.insert(memory_blocks)
.values([
{
did,
name: "memory",
description: "User memory",
mutable: false,
},
])
.returning();
hydratedBlocks.push({
...newBlock,
entries: [],
});
}
return hydratedBlocks.map(
(block) =>
new MemoryBlockHandler(
block as MemoryBlock,
),
);
}
public parseBlocks() {
return this.blocks.map((handler) => ({
name: handler.block.name,
description: handler.block.description,
entries: handler.block.entries.map((entry) => ({
label: entry.label,
value: entry.value,
added_by: entry.added_by || "nobody",
})),
}));
}
}
export class MemoryBlockHandler {
block: MemoryBlock;
constructor(block: MemoryBlock) {
this.block = block;
}
public async createEntry(label: string, value: string) {
const [entry] = await db
.insert(memory_block_entries)
.values([
{
block_id: this.block.id,
label,
value,
},
])
.returning();
if (!entry) {
return {
added_to_memory: false,
};
}
this.block.entries.push(entry);
return {
added_to_memory: true,
};
}
}