feat(bot): Add Grimoire REQ Assistant bot

Adds a Nostr bot that listens for mentions in NIP-29 group chats and
helps users craft REQ queries for the Nostr protocol.

Features:
- Listens for @mentions in groups.0xchat.com'NkeVhXuWHGKKJCpn
- Uses Claude (via pi-ai SDK) to understand user questions
- Provides REQ command suggestions with explanations
- Tools to look up event kinds, NIPs, and their relationships

Stack: TypeScript, nostr-tools, @mariozechner/pi-ai

https://claude.ai/code/session_01X4HWkMGrghBv2RfY89L5Lz
This commit is contained in:
Claude
2026-02-03 12:26:21 +00:00
parent 53d156ba04
commit 93d9157b40
7 changed files with 1301 additions and 0 deletions

117
bot/README.md Normal file
View File

@@ -0,0 +1,117 @@
# Grimoire REQ Assistant Bot
A Nostr bot that listens for mentions in the Grimoire group chat and helps users craft REQ queries for the Nostr protocol.
## Features
- Listens for mentions in NIP-29 group chats
- Uses LLM (Claude) to understand user questions
- Provides REQ command suggestions for querying Nostr relays
- Has tools to look up event kinds and NIPs
## Setup
### Prerequisites
- Node.js 18+
- An Anthropic API key (for Claude)
### Installation
```bash
cd bot
npm install
```
### Configuration
Set environment variables:
```bash
# Required: Anthropic API key
export ANTHROPIC_API_KEY="your-api-key"
# Optional: Override bot settings
export BOT_PRIVATE_KEY="your-hex-private-key"
export RELAY_URL="wss://groups.0xchat.com"
export GROUP_ID="NkeVhXuWHGKKJCpn"
```
### Running
Development mode (with hot reload):
```bash
npm run dev
```
Production:
```bash
npm run build
npm start
```
## Usage
In the group chat, mention the bot with a question about REQ queries:
```
@grimoire-bot how do I find all notes from the last 24 hours?
```
The bot will respond with:
```
To find all notes from the last 24 hours:
req -k 1 --since 1d relay.damus.io
This command:
- `-k 1` filters for kind 1 (short text notes)
- `--since 1d` gets events from the last day
- `relay.damus.io` is the relay to query
```
## Available Tools
The bot can look up:
- **Event Kinds**: What each kind number means
- **NIPs**: Nostr Implementation Possibilities specifications
- **Kinds for NIP**: What kinds are defined in a specific NIP
## Architecture
```
bot/
├── src/
│ ├── index.ts # Main bot entry point
│ ├── llm.ts # LLM integration with tools
│ └── data/
│ ├── kinds.ts # Event kind definitions
│ └── nips.ts # NIP definitions
├── package.json
└── tsconfig.json
```
## Bot Identity
Default bot pubkey: `4f2d3e...` (derived from the configured private key)
The bot signs messages with its own identity and responds as a member of the group.
## Supported REQ Options
The bot can help with all grimoire REQ options:
- `-k, --kind` - Filter by event kind
- `-a, --author` - Filter by author pubkey
- `-l, --limit` - Limit results
- `-i, --id` - Fetch by event ID
- `-e` - Filter by referenced events
- `-p` - Filter by mentioned pubkey
- `-t` - Filter by hashtag
- `--since`, `--until` - Time filters
- `--search` - Full-text search (NIP-50)
- And more...

24
bot/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "@grimoire/bot",
"version": "0.0.1",
"description": "Grimoire REQ assistant bot for Nostr group chat",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
},
"dependencies": {
"@mariozechner/pi-ai": "^0.51.2",
"@noble/hashes": "^1.8.0",
"@sinclair/typebox": "^0.34.48",
"nostr-tools": "^2.10.4",
"websocket-polyfill": "^0.0.3"
},
"devDependencies": {
"@types/node": "^22.10.5",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

341
bot/src/data/kinds.ts Normal file
View File

@@ -0,0 +1,341 @@
/**
* Event kind definitions for Nostr
* Used by the bot to provide information about event kinds
*/
export interface EventKindInfo {
kind: number;
name: string;
description: string;
nip: string;
}
export const EVENT_KINDS: Record<number, EventKindInfo> = {
// Core protocol kinds
0: {
kind: 0,
name: "Profile",
description:
"User Metadata (kind 0) - Contains user profile information like name, about, picture, nip05, etc. This is a replaceable event.",
nip: "01",
},
1: {
kind: 1,
name: "Note",
description:
"Short Text Note (kind 1) - The most common event type, used for posting text content similar to tweets. Supports mentions, hashtags, and references to other events.",
nip: "01",
},
2: {
kind: 2,
name: "Relay Recommendation",
description:
"Recommend Relay (kind 2) - Deprecated. Was used to recommend relays.",
nip: "01",
},
3: {
kind: 3,
name: "Contact List",
description:
"Follows/Contact List (kind 3) - Contains the list of pubkeys a user follows. Also may include relay preferences.",
nip: "02",
},
4: {
kind: 4,
name: "Encrypted DM",
description:
"Encrypted Direct Messages (kind 4) - Legacy encrypted DMs. Deprecated in favor of NIP-17 (kind 14).",
nip: "04",
},
5: {
kind: 5,
name: "Deletion",
description:
"Event Deletion Request (kind 5) - Request to delete previously published events.",
nip: "09",
},
6: {
kind: 6,
name: "Repost",
description:
"Repost (kind 6) - Repost/boost another event (specifically kind 1 notes).",
nip: "18",
},
7: {
kind: 7,
name: "Reaction",
description:
"Reaction (kind 7) - React to events with emoji or '+'/'-'. Contains e-tag pointing to the target event.",
nip: "25",
},
8: {
kind: 8,
name: "Badge Award",
description: "Badge Award (kind 8) - Award a badge to users.",
nip: "58",
},
9: {
kind: 9,
name: "Chat Message",
description:
"Chat Message (kind 9) - Used in NIP-29 relay-based groups and NIP-C7 chats. Contains h-tag for group context.",
nip: "29",
},
10: {
kind: 10,
name: "Group Reply",
description: "Group Chat Threaded Reply (kind 10)",
nip: "29",
},
11: {
kind: 11,
name: "Thread",
description: "Thread (kind 11) - Thread root event.",
nip: "7D",
},
14: {
kind: 14,
name: "Direct Message",
description:
"Direct Message (kind 14) - Private direct messages using NIP-17 encryption.",
nip: "17",
},
16: {
kind: 16,
name: "Generic Repost",
description:
"Generic Repost (kind 16) - Repost any event type, not just kind 1.",
nip: "18",
},
40: {
kind: 40,
name: "Channel Create",
description: "Channel Creation (kind 40) - Create a public chat channel.",
nip: "28",
},
41: {
kind: 41,
name: "Channel Metadata",
description: "Channel Metadata (kind 41) - Update channel metadata.",
nip: "28",
},
42: {
kind: 42,
name: "Channel Message",
description: "Channel Message (kind 42) - Send a message to a channel.",
nip: "28",
},
1063: {
kind: 1063,
name: "File Metadata",
description:
"File Metadata (kind 1063) - Metadata about files stored on servers.",
nip: "94",
},
1111: {
kind: 1111,
name: "Comment",
description: "Comment (kind 1111) - Comment on any addressable content.",
nip: "22",
},
1311: {
kind: 1311,
name: "Live Chat",
description:
"Live Chat Message (kind 1311) - Chat messages in live activities/streams.",
nip: "53",
},
1617: {
kind: 1617,
name: "Patch",
description: "Git Patches (kind 1617) - Git patch for code collaboration.",
nip: "34",
},
1984: {
kind: 1984,
name: "Report",
description:
"Reporting (kind 1984) - Report content or users for moderation.",
nip: "56",
},
1985: {
kind: 1985,
name: "Label",
description: "Label (kind 1985) - Add labels/tags to content.",
nip: "32",
},
5000: {
kind: 5000,
name: "Job Request",
description:
"DVM Job Request (kind 5000-5999) - Data Vending Machine job requests.",
nip: "90",
},
6000: {
kind: 6000,
name: "Job Result",
description:
"DVM Job Result (kind 6000-6999) - Data Vending Machine job results.",
nip: "90",
},
7000: {
kind: 7000,
name: "Job Feedback",
description: "Job Feedback (kind 7000) - Feedback for DVM jobs.",
nip: "90",
},
9734: {
kind: 9734,
name: "Zap Request",
description: "Zap Request (kind 9734) - Request to create a lightning zap.",
nip: "57",
},
9735: {
kind: 9735,
name: "Zap",
description:
"Zap (kind 9735) - Lightning zap receipt created by LNURL providers.",
nip: "57",
},
9802: {
kind: 9802,
name: "Highlight",
description:
"Highlights (kind 9802) - Highlight text from articles or content.",
nip: "84",
},
10000: {
kind: 10000,
name: "Mute List",
description: "Mute List (kind 10000) - List of muted users/content.",
nip: "51",
},
10002: {
kind: 10002,
name: "Relay List",
description:
"Relay List Metadata (kind 10002) - User's relay list for inbox/outbox model.",
nip: "65",
},
10003: {
kind: 10003,
name: "Bookmark List",
description: "Bookmark List (kind 10003) - User's bookmarked content.",
nip: "51",
},
30000: {
kind: 30000,
name: "Follow Set",
description: "Follow Sets (kind 30000) - Named sets of followed users.",
nip: "51",
},
30001: {
kind: 30001,
name: "Generic List",
description: "Generic Lists (kind 30001) - Deprecated generic list.",
nip: "51",
},
30008: {
kind: 30008,
name: "Profile Badge",
description: "Profile Badges (kind 30008) - Badges displayed on profiles.",
nip: "58",
},
30009: {
kind: 30009,
name: "Badge",
description: "Badge Definition (kind 30009) - Define a badge.",
nip: "58",
},
30023: {
kind: 30023,
name: "Article",
description:
"Long-form Content (kind 30023) - Blog posts and articles with markdown support.",
nip: "23",
},
30078: {
kind: 30078,
name: "App Data",
description:
"Application-specific Data (kind 30078) - App-specific user data.",
nip: "78",
},
30311: {
kind: 30311,
name: "Live Event",
description: "Live Event (kind 30311) - Live streaming events.",
nip: "53",
},
30617: {
kind: 30617,
name: "Repository",
description:
"Repository Announcement (kind 30617) - Git repository metadata.",
nip: "34",
},
39000: {
kind: 39000,
name: "Group Metadata",
description: "Group Metadata (kind 39000) - NIP-29 group information.",
nip: "29",
},
39001: {
kind: 39001,
name: "Group Admins",
description:
"Group Admins List (kind 39001) - Admin list for NIP-29 groups.",
nip: "29",
},
39002: {
kind: 39002,
name: "Group Members",
description:
"Group Members List (kind 39002) - Member list for NIP-29 groups.",
nip: "29",
},
};
/**
* Get kind info by number
*/
export function getKindInfo(kind: number): EventKindInfo | undefined {
return EVENT_KINDS[kind];
}
/**
* Search kinds by name or description
*/
export function searchKinds(query: string): EventKindInfo[] {
const lowerQuery = query.toLowerCase();
return Object.values(EVENT_KINDS).filter(
(k) =>
k.name.toLowerCase().includes(lowerQuery) ||
k.description.toLowerCase().includes(lowerQuery),
);
}
/**
* Get all kinds for a specific NIP
*/
export function getKindsForNip(nipId: string): EventKindInfo[] {
return Object.values(EVENT_KINDS).filter((k) => k.nip === nipId);
}
/**
* Get formatted list of common kinds for the LLM
*/
export function getCommonKindsReference(): string {
const commonKinds = [
0, 1, 3, 4, 5, 6, 7, 9, 14, 16, 1111, 9734, 9735, 10002, 30023, 30311,
];
return commonKinds
.map((k) => {
const info = EVENT_KINDS[k];
return info
? `- Kind ${k}: ${info.name} - ${info.description.split(".")[0]}`
: null;
})
.filter(Boolean)
.join("\n");
}

313
bot/src/data/nips.ts Normal file
View File

@@ -0,0 +1,313 @@
/**
* NIP (Nostr Implementation Possibilities) definitions
* Used by the bot to provide information about NIPs
*/
export interface NipInfo {
id: string;
title: string;
description: string;
deprecated?: boolean;
}
export const NIP_DATA: Record<string, NipInfo> = {
"01": {
id: "01",
title: "Basic protocol flow description",
description:
"Defines the basic protocol: event structure, signatures, relay communication, and basic event kinds (0=metadata, 1=text note, 2=relay recommendation).",
},
"02": {
id: "02",
title: "Follow List",
description:
"Kind 3 contact list - stores the list of pubkeys a user follows along with relay preferences.",
},
"03": {
id: "03",
title: "OpenTimestamps Attestations for Events",
description: "Timestamping events using OpenTimestamps.",
},
"04": {
id: "04",
title: "Encrypted Direct Message",
description:
"Legacy encrypted DMs (kind 4). DEPRECATED - use NIP-17 instead for better privacy.",
deprecated: true,
},
"05": {
id: "05",
title: "Mapping Nostr keys to DNS-based internet identifiers",
description:
"NIP-05 verification - maps user@domain.com to a pubkey via .well-known/nostr.json.",
},
"06": {
id: "06",
title: "Basic key derivation from mnemonic seed phrase",
description: "Derive Nostr keys from BIP-39 mnemonic seed phrases.",
},
"07": {
id: "07",
title: "window.nostr capability for web browsers",
description:
"Browser extension interface for signing - allows web apps to request event signing from extensions like nos2x, Alby.",
},
"09": {
id: "09",
title: "Event Deletion Request",
description:
"Kind 5 deletion requests - request relays to delete specified events.",
},
"10": {
id: "10",
title: "Text Notes and Threads",
description:
"Conventions for text notes (kind 1), threading with e-tags (root, reply, mention markers).",
},
"11": {
id: "11",
title: "Relay Information Document",
description:
"NIP-11 relay info - GET /.well-known/nostr.json returns relay metadata (name, description, supported NIPs, fees).",
},
"13": {
id: "13",
title: "Proof of Work",
description:
"Add proof of work to events by finding a nonce that produces a hash with leading zeros.",
},
"15": {
id: "15",
title: "Nostr Marketplace",
description:
"Stalls (kind 30017), products (kind 30018), and orders for marketplace functionality.",
},
"17": {
id: "17",
title: "Private Direct Messages",
description:
"Modern encrypted DMs (kind 14) using gift wrapping for better metadata privacy. Replaces NIP-04.",
},
"18": {
id: "18",
title: "Reposts",
description:
"Kind 6 for reposting kind 1 notes, kind 16 for generic reposts of any event type.",
},
"19": {
id: "19",
title: "bech32-encoded entities",
description:
"Bech32 encoding: npub (pubkey), nsec (private key), note (event id), nprofile, nevent, naddr, nrelay.",
},
"21": {
id: "21",
title: "nostr: URI scheme",
description: "nostr: URI scheme for linking to Nostr entities.",
},
"22": {
id: "22",
title: "Comment",
description:
"Kind 1111 comments - comment on any addressable content (articles, videos, etc.).",
},
"23": {
id: "23",
title: "Long-form Content",
description:
"Kind 30023 articles - long-form markdown content with title, summary, published_at tags.",
},
"25": {
id: "25",
title: "Reactions",
description:
"Kind 7 reactions - react to events with '+', '-', or emoji. Uses e-tag to reference target.",
},
"27": {
id: "27",
title: "Text Note References",
description:
"Reference and quote other events within notes using nostr: URIs.",
},
"28": {
id: "28",
title: "Public Chat",
description:
"Public chat channels - kind 40 (create), kind 41 (metadata), kind 42 (messages).",
},
"29": {
id: "29",
title: "Relay-based Groups",
description:
"NIP-29 groups - relay-enforced groups with kind 9 messages, group metadata (39000), admins (39001), members (39002). Messages use h-tag for group context.",
},
"30": {
id: "30",
title: "Custom Emoji",
description:
"Custom emoji using emoji tags - :shortcode: syntax with image URLs.",
},
"32": {
id: "32",
title: "Labeling",
description: "Kind 1985 labels - categorize and tag content.",
},
"34": {
id: "34",
title: "git stuff",
description:
"Git collaboration - repositories (30617), patches (1617), issues (1621), and more.",
},
"36": {
id: "36",
title: "Sensitive Content",
description: "Content warnings using content-warning tag.",
},
"38": {
id: "38",
title: "User Statuses",
description:
"Kind 30315 user statuses - what user is doing, listening to, etc.",
},
"42": {
id: "42",
title: "Authentication of clients to relays",
description:
"AUTH message for relay authentication - kind 22242 for client auth challenges.",
},
"44": {
id: "44",
title: "Encrypted Payloads (Versioned)",
description: "Versioned encryption for payloads.",
},
"45": {
id: "45",
title: "Counting results",
description:
"COUNT message to get event counts without fetching all events.",
},
"46": {
id: "46",
title: "Nostr Remote Signing",
description:
"Remote signing via kind 24133 - allows signing events on a different device.",
},
"47": {
id: "47",
title: "Nostr Wallet Connect",
description:
"NWC - connect wallets to apps for lightning payments via kind 13194, 23194, 23195.",
},
"50": {
id: "50",
title: "Search Capability",
description: "Search filter support in REQ - use search field in filters.",
},
"51": {
id: "51",
title: "Lists",
description:
"Various list types - mute (10000), pin (10001), bookmark (10003), follow sets (30000), etc.",
},
"52": {
id: "52",
title: "Calendar Events",
description:
"Calendar events - date-based (31922), time-based (31923), calendars (31924), RSVPs (31925).",
},
"53": {
id: "53",
title: "Live Activities",
description:
"Live streaming - kind 30311 live events, kind 1311 chat messages.",
},
"56": {
id: "56",
title: "Reporting",
description: "Kind 1984 reports for content moderation.",
},
"57": {
id: "57",
title: "Lightning Zaps",
description:
"Zaps - kind 9734 zap requests, kind 9735 zap receipts. Integrates lightning payments.",
},
"58": {
id: "58",
title: "Badges",
description:
"Badge system - kind 30009 (define), kind 8 (award), kind 30008 (profile badges).",
},
"59": {
id: "59",
title: "Gift Wrap",
description:
"Gift wrap encryption (kind 1059) - wraps events for privacy. Used by NIP-17.",
},
"65": {
id: "65",
title: "Relay List Metadata",
description:
"Kind 10002 relay list - defines user's read/write relay preferences for inbox/outbox model.",
},
"72": {
id: "72",
title: "Moderated Communities",
description:
"Communities with moderation - kind 34550 community definition, kind 4550 approved posts.",
},
"78": {
id: "78",
title: "Application-specific data",
description: "Kind 30078 for app-specific user data storage.",
},
"84": {
id: "84",
title: "Highlights",
description:
"Kind 9802 highlights - save and share text highlights from content.",
},
"89": {
id: "89",
title: "Recommended Application Handlers",
description:
"Kind 31990 app definitions, kind 31989 recommendations for handling event kinds.",
},
"90": {
id: "90",
title: "Data Vending Machines",
description:
"DVMs - kind 5000-5999 job requests, kind 6000-6999 results, kind 7000 feedback.",
},
"94": {
id: "94",
title: "File Metadata",
description: "Kind 1063 file metadata for files stored on servers.",
},
"98": {
id: "98",
title: "HTTP Auth",
description: "Kind 27235 HTTP authentication using Nostr.",
},
};
/**
* Get NIP info by ID
*/
export function getNipInfo(nipId: string): NipInfo | undefined {
// Normalize to uppercase and pad if needed
const normalized = nipId.toUpperCase().padStart(2, "0");
return NIP_DATA[normalized];
}
/**
* Search NIPs by title or description
*/
export function searchNips(query: string): NipInfo[] {
const lowerQuery = query.toLowerCase();
return Object.values(NIP_DATA).filter(
(n) =>
n.title.toLowerCase().includes(lowerQuery) ||
n.description.toLowerCase().includes(lowerQuery),
);
}

187
bot/src/index.ts Normal file
View File

@@ -0,0 +1,187 @@
/**
* Grimoire REQ Assistant Bot
*
* A Nostr bot that listens for mentions in the Grimoire group chat
* and helps users craft REQ queries for the Nostr protocol.
*/
import "websocket-polyfill";
import { SimplePool, finalizeEvent, getPublicKey } from "nostr-tools";
import type { NostrEvent, Filter } from "nostr-tools";
import { hexToBytes } from "@noble/hashes/utils";
import { processMessage } from "./llm.js";
// Configuration
const BOT_PRIVATE_KEY =
process.env.BOT_PRIVATE_KEY ||
"99079e2ac9596a6e27f53f074b9b5303d7b58da8ee6a88c42e74f7cfb261dbe3";
const RELAY_URL = process.env.RELAY_URL || "wss://groups.0xchat.com";
const GROUP_ID = process.env.GROUP_ID || "NkeVhXuWHGKKJCpn";
// Derive bot pubkey from private key
const botSecretKey = hexToBytes(BOT_PRIVATE_KEY);
const botPubkey = getPublicKey(botSecretKey);
console.log("Grimoire REQ Assistant Bot");
console.log("==========================");
console.log(`Bot pubkey: ${botPubkey}`);
console.log(`Relay: ${RELAY_URL}`);
console.log(`Group: ${GROUP_ID}`);
console.log("");
// Create relay pool
const pool = new SimplePool();
// Track processed event IDs to avoid duplicates
const processedEvents = new Set<string>();
/**
* Check if a message mentions the bot
*/
function isBotMentioned(event: NostrEvent): boolean {
// Check p-tags for bot pubkey mention
for (const tag of event.tags) {
if (tag[0] === "p" && tag[1] === botPubkey) {
return true;
}
}
// Also check content for npub mention (fallback)
// This is less reliable but some clients may not use p-tags
return false;
}
/**
* Extract the user's question from a mention message
* Removes the bot mention prefix from the content
*/
function extractQuestion(event: NostrEvent): string {
// The content might have nostr:npub... or @npub... mentions
// Remove them to get the actual question
let content = event.content;
// Remove nostr:npub... mentions
content = content.replace(/nostr:npub1[a-z0-9]+/gi, "").trim();
// Remove @mention patterns
content = content.replace(/@[a-z0-9]+/gi, "").trim();
return content;
}
/**
* Send a message to the group chat
*/
async function sendGroupMessage(
content: string,
replyToEvent?: NostrEvent,
): Promise<void> {
const tags: string[][] = [["h", GROUP_ID]];
// Add reply reference if replying to a message
if (replyToEvent) {
tags.push(["q", replyToEvent.id, RELAY_URL, replyToEvent.pubkey]);
}
const eventTemplate = {
kind: 9,
created_at: Math.floor(Date.now() / 1000),
tags,
content,
};
const signedEvent = finalizeEvent(eventTemplate, botSecretKey);
console.log(`Sending message: ${content.substring(0, 100)}...`);
try {
await Promise.any(pool.publish([RELAY_URL], signedEvent));
console.log(`Message sent: ${signedEvent.id}`);
} catch (error) {
console.error("Failed to send message:", error);
}
}
/**
* Handle an incoming message that mentions the bot
*/
async function handleMention(event: NostrEvent): Promise<void> {
// Skip if already processed
if (processedEvents.has(event.id)) {
return;
}
processedEvents.add(event.id);
// Don't respond to our own messages
if (event.pubkey === botPubkey) {
return;
}
const question = extractQuestion(event);
if (!question) {
console.log("Empty question, skipping");
return;
}
console.log(`\nReceived question from ${event.pubkey.substring(0, 8)}...`);
console.log(`Question: ${question}`);
try {
// Process with LLM
const response = await processMessage(question);
// Send response as a reply
await sendGroupMessage(response, event);
} catch (error) {
console.error("Error processing message:", error);
await sendGroupMessage(
"Sorry, I encountered an error processing your request. Please try again.",
event,
);
}
}
/**
* Start the bot
*/
async function main(): Promise<void> {
console.log("Connecting to relay and subscribing to group...\n");
// Subscribe to group messages
const filter: Filter = {
kinds: [9], // Chat messages
"#h": [GROUP_ID], // Group filter
since: Math.floor(Date.now() / 1000), // Only new messages
};
const sub = pool.subscribeMany([RELAY_URL], filter, {
onevent(event: NostrEvent) {
// Check if this message mentions the bot
if (isBotMentioned(event)) {
handleMention(event).catch(console.error);
}
},
oneose() {
console.log("Subscription established, listening for mentions...\n");
},
});
// Handle graceful shutdown
process.on("SIGINT", () => {
console.log("\nShutting down...");
sub.close();
process.exit(0);
});
process.on("SIGTERM", () => {
console.log("\nShutting down...");
sub.close();
process.exit(0);
});
// Keep the process alive
console.log("Bot is running. Press Ctrl+C to stop.\n");
}
main().catch(console.error);

303
bot/src/llm.ts Normal file
View File

@@ -0,0 +1,303 @@
/**
* LLM integration for the Grimoire REQ Assistant Bot
*
* Uses pi-ai to process user questions and generate REQ commands
*/
import {
getModel,
complete,
validateToolCall,
type Tool,
type Context,
} from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import {
getKindInfo,
searchKinds,
getKindsForNip,
getCommonKindsReference,
} from "./data/kinds.js";
import { getNipInfo, searchNips } from "./data/nips.js";
// Get model - default to Claude Haiku for fast, cheap responses
// Can be overridden with environment variable
const PROVIDER = process.env.LLM_PROVIDER || "anthropic";
const MODEL_ID = process.env.LLM_MODEL || "claude-3-5-haiku-20241022";
// Use default model for simplicity (typed correctly)
const model = getModel("anthropic", "claude-3-5-haiku-20241022");
console.log(`Using LLM: ${PROVIDER}/${MODEL_ID}`);
// System prompt for the REQ assistant
const SYSTEM_PROMPT = `You are the Grimoire REQ Assistant, a helpful bot that assists users in crafting Nostr REQ queries for the Grimoire protocol explorer.
## Your Role
Help users construct REQ commands to query Nostr relays. Users will describe what they want to find, and you should respond with the appropriate REQ command syntax.
## REQ Command Syntax
The REQ command follows this format:
\`\`\`
req [options] [relay...]
\`\`\`
### Common Options:
- \`-k, --kind <number>\` - Filter by event kind (supports comma-separated: -k 1,3,7)
- \`-a, --author <pubkey>\` - Filter by author (supports npub, hex, NIP-05, $me, $contacts)
- \`-l, --limit <number>\` - Maximum events to return
- \`-i, --id <id>\` - Fetch specific event by ID (note1, nevent1, hex)
- \`-e <id>\` - Filter by referenced events (#e/#a tags)
- \`-p <pubkey>\` - Filter by mentioned pubkey (#p tag)
- \`-t <hashtag>\` - Filter by hashtag (#t tag)
- \`-d <identifier>\` - Filter by d-tag
- \`--since <time>\` - Events after time (unix timestamp, or relative: 1h, 7d, 2w)
- \`--until <time>\` - Events before time
- \`--search <query>\` - Full-text search (relay must support NIP-50)
- \`--close-on-eose\` - Close subscription after receiving all events
- \`-f, --follow\` - Auto-refresh mode (like tail -f)
- \`--view list|compact\` - Display mode
### Relays:
Add relay URLs directly: \`req -k 1 wss://relay.damus.io\`
Shorthand: \`req -k 1 relay.damus.io\` (wss:// added automatically)
### Special Aliases:
- \`$me\` - Your logged-in pubkey
- \`$contacts\` - Pubkeys you follow
## Common Event Kinds Reference:
${getCommonKindsReference()}
## Guidelines:
1. Always suggest specific, working commands
2. Use appropriate kinds for the use case
3. Suggest reasonable limits to avoid overwhelming results
4. Recommend relays when helpful (popular ones: relay.damus.io, nos.lol, relay.nostr.band)
5. Explain briefly what the command does
6. If the request is unclear, ask clarifying questions
7. If something isn't possible with REQ, explain why and suggest alternatives
## Response Format:
- Keep responses concise and helpful
- Show the REQ command in a code block
- Add a brief explanation of what it does
- If using less common kinds, mention what they are
Use the tools available to look up specific kind numbers or NIP details when needed.`;
// Define tools for the LLM
const tools: Tool[] = [
{
name: "lookup_kind",
description:
"Look up information about a specific Nostr event kind number. Use this when you need to find the kind number for a specific type of event, or when you need details about what a kind does.",
parameters: Type.Object({
kind: Type.Number({
description: "The event kind number to look up (e.g., 0, 1, 7, 30023)",
}),
}),
},
{
name: "search_kinds",
description:
"Search for event kinds by name or description. Use this when the user asks about a type of event but doesn't know the kind number (e.g., 'articles', 'zaps', 'reactions').",
parameters: Type.Object({
query: Type.String({
description:
"Search query to find matching kinds (e.g., 'article', 'zap', 'reaction', 'profile')",
}),
}),
},
{
name: "lookup_nip",
description:
"Look up information about a specific NIP (Nostr Implementation Possibility). Use this when you need details about a NIP specification.",
parameters: Type.Object({
nip_id: Type.String({
description:
"The NIP identifier (e.g., '01', '29', '57' for numeric NIPs, or 'C7', 'B0' for hex NIPs)",
}),
}),
},
{
name: "search_nips",
description:
"Search NIPs by title or description. Use this when the user asks about a feature and you need to find the relevant NIP.",
parameters: Type.Object({
query: Type.String({
description:
"Search query to find matching NIPs (e.g., 'zap', 'group', 'encryption')",
}),
}),
},
{
name: "get_kinds_for_nip",
description:
"Get all event kinds defined in a specific NIP. Use this to find what kinds are part of a particular NIP specification.",
parameters: Type.Object({
nip_id: Type.String({
description: "The NIP identifier to get kinds for",
}),
}),
},
];
/**
* Execute a tool call
*/
function executeTool(name: string, args: Record<string, unknown>): string {
switch (name) {
case "lookup_kind": {
const kind = args.kind as number;
const info = getKindInfo(kind);
if (info) {
return JSON.stringify({
kind: info.kind,
name: info.name,
description: info.description,
nip: info.nip,
});
}
return JSON.stringify({
error: `Kind ${kind} not found in database. It may be a valid kind not yet documented.`,
});
}
case "search_kinds": {
const query = args.query as string;
const results = searchKinds(query);
if (results.length > 0) {
return JSON.stringify(
results.slice(0, 10).map((k) => ({
kind: k.kind,
name: k.name,
description: k.description.split(".")[0],
nip: k.nip,
})),
);
}
return JSON.stringify({ error: `No kinds found matching "${query}"` });
}
case "lookup_nip": {
const nipId = args.nip_id as string;
const info = getNipInfo(nipId);
if (info) {
return JSON.stringify({
id: info.id,
title: info.title,
description: info.description,
deprecated: info.deprecated || false,
});
}
return JSON.stringify({ error: `NIP-${nipId} not found in database` });
}
case "search_nips": {
const query = args.query as string;
const results = searchNips(query);
if (results.length > 0) {
return JSON.stringify(
results.slice(0, 10).map((n) => ({
id: n.id,
title: n.title,
deprecated: n.deprecated || false,
})),
);
}
return JSON.stringify({ error: `No NIPs found matching "${query}"` });
}
case "get_kinds_for_nip": {
const nipId = args.nip_id as string;
const kinds = getKindsForNip(nipId);
if (kinds.length > 0) {
return JSON.stringify(
kinds.map((k) => ({
kind: k.kind,
name: k.name,
})),
);
}
return JSON.stringify({
error: `No kinds found for NIP-${nipId} or NIP not in database`,
});
}
default:
return JSON.stringify({ error: `Unknown tool: ${name}` });
}
}
/**
* Process a user message and generate a response
*/
export async function processMessage(userMessage: string): Promise<string> {
const context: Context = {
systemPrompt: SYSTEM_PROMPT,
messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
tools,
};
// Run the conversation loop (handle tool calls)
let iterations = 0;
const maxIterations = 5; // Prevent infinite loops
while (iterations < maxIterations) {
iterations++;
const response = await complete(model, context);
context.messages.push(response);
// Check for tool calls
const toolCalls = response.content.filter((b) => b.type === "toolCall");
if (toolCalls.length === 0) {
// No tool calls, extract text response
const textBlocks = response.content.filter((b) => b.type === "text");
const textContent = textBlocks.map((b) => (b as any).text).join("\n");
return (
textContent ||
"I couldn't generate a response. Please try rephrasing your question."
);
}
// Execute tool calls
for (const call of toolCalls) {
if (call.type !== "toolCall") continue;
try {
const validatedArgs = validateToolCall(tools, call);
const result = executeTool(call.name, validatedArgs);
context.messages.push({
role: "toolResult",
toolCallId: call.id,
toolName: call.name,
content: [{ type: "text", text: result }],
isError: false,
timestamp: Date.now(),
});
} catch (error) {
context.messages.push({
role: "toolResult",
toolCallId: call.id,
toolName: call.name,
content: [
{
type: "text",
text: JSON.stringify({
error: error instanceof Error ? error.message : "Unknown error",
}),
},
],
isError: true,
timestamp: Date.now(),
});
}
}
}
return "I reached the maximum number of processing steps. Please try a simpler question.";
}

16
bot/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}