Imported from anojndr/llmcord-go (
AGENTS.md). Install upstream withnpx skills add anojndr/llmcord-go. Copyright stays with the author.
Repository Guidelines
Project Overview
Single-binary Discord bot (module llmcord-go, Go 1.26.1). Discord gateway (bwmarrin/discordgo) → reply-chain conversation assembly → provider-agnostic streaming LLM (google.golang.org/genai, OpenAI-compatible) → chunked Discord renderer. Features: reply chains, streaming embeds, multimodal (image/audio/doc/video), URL enrichment, FB/YT/TikTok fetchers, Exa/Tavily/TinyFish search, hot-reload config.
Architecture & Data Flow
No public library; everything under internal/. DI via constructors over one shared *http.Client.
main/runMain (cmd/llmcord-go/main.go)
└─ app.Run(ctx, configPath) (internal/app/bot.go)
├─ loadConfig → newBot → startPublicHTTPServer (/ + /healthz)
├─ instance.open: validateDiscordGateway → session.Open → syncCommands + status → watchers
├─ block: ctx.Done | http-server error
└─ instance.close: session.Close + persistBotStateSync + nodes.close (errors.Join)
- Message event:
bot.handleMessageCreate(internal/app/messages.go, wrapped inrecoverHandler) → 30s/1024-entry dedup (markMessageSeen) → maintenance gate →loadConfigCached→messageAllowedperms → short-circuits (handleXFixup, facebook-video / youtube-shorts) →respondToMessage: progress + typing →prepareMessageResponse→StreamChatCompletion→ live-edit render →nodes.evictExcess(). - Interaction event:
handleInteractionCreate(internal/app/interactions.go) switchesInteractionType; commands:/model /searchtype /grounding /createchannel /editchannelname /movechannel /maintenance /watcherstatus; buttons viaCustomID(sources/images/thinking/gist).10062 unknown interaction→slog.Infodiscard, not error. - Conversation:
buildConversation(internal/app/conversation.go) walks Discord reply chain tomaxMessages(default 25),nodes.getOrCreate+ lazyinitializeNode,buildMessageContentper media gates (maxImagesdefault 100). - Provider streaming:
ChatCompletionRouter.StreamChatCompletion(internal/providers/chat_client.go) rotatesAPIKeysviaAPIKeyRotator, dispatches byProviderAPIKind(OpenAI Chat-Completions SSE vs Responses API vs GeminiGenerateContentStream), streamsStreamDelta{Thinking, Content, FinishReason, ProviderResponseID, SearchMetadata, ToolCallResponse}. Tool calling: one tool round per reply — the app appends the executed round asChatCompletionRequest.ToolRounds(ToolCallResponse+ oneFunctionToolOutputper call), then forces the answer withToolChoice: ToolChoiceNone; providers replay rounds natively (internal/providers/tool_rounds.go: Chat Completions assistanttool_calls+toolmessages; Responses output items +function_call_output, orprevious_response_idof the tool-call response on chained turns). Retry: transient (EOF/5xx/429/reset) 5×/1s, queue-full 503 5×/3s, empty 5×; anydeltaReferencesContentis final. - Rendering:
responseTracker+segmentAccumulator(internal/app/response.go) splits at 2000 runes, edits in place with...indicator; embed green/amber/red;userFacingErrorMaxRunes=1500.
Key Directories
cmd/llmcord-go/: binary entry only (main.go,main_test.go). Keep minimal (depguard-constrained).internal/app/: ~100 files. Discord I/O, pipeline (bot.go,messages.go,interactions.go,conversation.go,response.go), augmentation (search.go,visual_search.go,image_search.go,website.go,url_context.go,media_analysis.go,pdf.go,ooxml.go,tiktok.go,facebook.go,youtube*.go,reddit.go,aliexpress.go), state (store.go,store_persistence.go,bot_state_persistence.go), cross-cutting (config.go,logging.go,concurrency.go,constants.go,permissions.go,service_http.go).internal/providers/: LLM wire clients behind router (types.go,chat_client.go,keys.go,openai.go,responses.go,gemini.go,gemini_cache.go,tools.go,openai_errors.go).internal/searchtypes/: sharedContentPart(map[string]any),SearchMetadata, source types.internal/support/: pure helpers (RuneCount,JoinNonEmpty).- Absent by design: no
tests/,testdata/,e2e/,scripts/,tools/,docs/,.github/,Makefile.
Development Commands
Setup: cp config-example.yaml config.yaml (fill bot_token + ≥1 providers + ≥1 models), then:
go run ./cmd/llmcord-go
LLMCORD_CONFIG_PATH=/path/to/config.yaml go run ./cmd/llmcord-go
docker compose up --build
./restart.sh # code changes only; config hot-reloads, no restart needed
./restart.sh --foreground
Quality gate (in order, from README.md Development):
gofmt -s -w .
go mod tidy
go test ./... -race -count=1
go test ./... -bench=. -benchmem -run=^$
go vet ./...
golangci-lint run --default=all
Single-package: go test ./internal/app -run TestGistClient -count=1, go test ./internal/providers -race -count=1.
Code Conventions & Common Patterns
- Format/lint:
gofmt -s -w .;golangci-lint run --default=all(.golangci.ymlv2:wsl_v5,cyclop20,funlen90 lines/60 stmts,gocognit35,tagliatellejson/yamlsnake_case,wrapcheck,depguardonmain). Imports: stdlib →llmcord-go/internal/...→ third-party. - Lint docs: Always use https://golangci-lint.run/docs/ with everything enabled, then fix all of the issues. Make sure to actually fix all of the issues instead of suppressing them.
- Naming: receiver always
instance *bot; constructorsnewXxxClient(httpClient, ...); handlershandleXxx, buildersbuildXxx/newXxxCommand, resolversloadConfigCached/channelByID. Constants ininternal/app/constants.go, lowerCamel (embedColorComplete,defaultMaxMessages). - Errors:
fmt.Errorf("<verb> <noun>: %w", err)every layer; sentinels +errors.As/Is(StatusError{StatusCode,Message},IsTransientStreamError,IsQueueFullQueueError,ErrEmptyModelResponse);os.ErrInvalidfor programmer misuse;io.ErrUnexpectedEOFfor truncated streams. Never drop%w(wrapcheck). - Logging:
log/slogonly,AddSource:true.LogError(msg, err, attrs...)(error + 32-framecaptureStack) for failures,logWarnrecoverable,slog.Info/Debuglifecycle with snake_case keys (channel_id,message_id). - Async/concurrency: one
Mutex/RWMutexper concern +atomic.Bool/Uint64flags/counters;safeGo+recoverAndLog/recoverHandlerfor all background goroutines; bounded poolrunTasksConcurrently[T](ctx, limit, taskCount, task)(e.g. attachment downloads limit 4);WaitGroup+CancelFuncfor reconnect-guard/watchers/save-worker. - Dependency injection:
newBot(ctx, configPath, loadedConfig)builds tuned transport (100 idle conns/host, 30s dial, HTTP/2) and injects small interfaces (chatCompletionStreamer,webSearcher,gistCreator, ...). Providers mirror:NewChatCompletionRouter(httpClient)→openAIClient+geminiClient+APIKeyRotator;geminiContentStreamer/geminiFilesClientinterfaces for tests. - State:
messageNodeStore(mutex map, cap 500, per-nodemu,snapshotCache, debouncedsaveRequestschannel → SQLite, background hydrate);configCachestamped by mtime+size (seedConfigCache/loadConfigCached);botState(RWMutex+ atomic generation +saveMu). - Config: dual
rawXxx(yaml,scalarString/idListtolerate scalar-or-list) → resolved structs with defaults inloadConfig; strict YAML (unknown keys rejected);filepath.Cleanall paths;api_keystring-or-list round-robin; name containinggemini= native Gemini (nobase_url/api:).
Important Files
- Entry:
cmd/llmcord-go/main.go(main,runMain:ConfigureLogging+RuntimeConfigPath+signal.NotifyContext+app.Run). - Config template:
config-example.yaml(~405 lines, copy toconfig.yaml; strict keys, YAML order irrelevant). Runtimeconfig.yaml+config.yaml.resume-state(session_id/sequence/gateway_url) are gitignored — never edit/commit. - Policy:
internal/app/config.go(rawConfig/config,loadConfig),internal/app/constants.go(env names, tuning),internal/app/logging.go+concurrency.go(ConfigureLogging,LogError,safeGo). - Pipeline:
internal/app/bot.go(bot,newBot,Run/open/close),messages.go,interactions.go,conversation.go,response.go,permissions.go(messageAllowed),service_http.go(/,/healthz),store.go+store_persistence.go. - Provider contract:
internal/providers/types.go(ChatCompletionRequest,StreamDelta,ProviderAPIKind),chat_client.go(ChatCompletionRouter),openai.go/responses.go/gemini.go. - Toolchain:
go.mod(go 1.26.1),go.sum,Dockerfile(CGO_ENABLED=0 go build -o /out/llmcord ./cmd/llmcord-go→debian:bookworm-slim),docker-compose.yaml,render.yaml(healthCheckPath: /healthz),.golangci.yml,.gitignore(whitelist!),restart.sh,README.md,.mcp.json.
Runtime/Tooling Preferences
- Runtime: Go
1.26+only (pinned1.26.1); no Node/Python. Package manager: Go modules (go mod tidy; never hand-editgo.sum/go.modversions). Build flags live inDockerfileonly. No Makefile/Taskfile/CI (.github/absent). - Env:
LLMCORD_CONFIG_PATH(fallback legacyCONFIG_PATH, defaultconfig.yaml);LLMCORD_HTTP_ADDRelsePORT(enables/+/healthz);LLMCORD_LOG_LEVEL=debug|info|warn|error(defaultinfo);LLMCORD_LOG_FORMAT=text|json;LLMCORD_RECONNECT=0/falsedisables gateway guard;TZ=UTCon Render. Secrets inconfig.yaml(bot_token,providers.*.api_key,database.connection_string), not env. - Constraints: whitelist
.gitignore—scripts//tools/NOT allow-listed (new files there silently ignored);docs//.github//plans//AGENTS.mdare.cmd/llmcord-go/main.godepguard:$gostd+llmcord-go/internal/{app,providers,searchtypes,support}+ named libs only. Gemini providers MUST NOT setapi:; OpenAI-compatible useapi: openai-chat-completions | openai-responses. Neverkill -9; userestart.sh(SIGTERM → 10s → SIGKILL, saves resume state). - Tooling: Always use codebase-memory-mcp.
Testing & QA
- Framework: stdlib
testingonly — no testify/gomock ingo.mod(only indirectgo-cmp). Co-located white-box*_test.go(~55 ininternal/app, ~12 ininternal/providers, 1 incmd/); notestdata//e2e/. Keep same-package tests (package app), allowed bytestpackagelinter forapp, providers, main. - Fakes:
net/http/httptestservers (httptest.NewServer,NewRecorder,NewRequestWithContext) + localroundTripFunctransport stub (bot_test.go:31) + hand-rolled per-file stubs (stubFacebookScraper,testBotStateBackend,mockNoteGPTCalls). No mock generator. - Idioms:
Test<Subject><Behavior>(e.g.TestGistClientCreateGistPostsJSONAndReturnsURL), tabletestCases/cases+t.Run,t.Parallel(),t.Helper()asserts (assertGistCreateRequest),t.TempDir()+os.WriteFile(..., 0o600)YAML fixtures,t.Setenv,t.Context(),sync/atomiccall counters,BenchmarkXxx(internal/app/text_bench_test.go). - Expectations: no coverage threshold/gate; gates are
go vet ./...+golangci-lint run --default=all+ race tests + bench smoke. Examples:internal/app/gist_test.go,internal/app/config_test.go,internal/providers/chat_client_queue_retry_test.go,cmd/llmcord-go/main_test.go.
