Components¶
phpbotscout is six components and some wiring. This is what each one is for, and why the seams are where they are.
| Component | Responsible for | Deliberately cannot |
|---|---|---|
transport |
Owning the Discord connection, one generation at a time | Let a handle outlive its generation |
ingest |
Reading one space's messages and handing each on | Post anything, connect, or close |
corpus |
Deciding what may be indexed, and cutting content into chunks | Reach a network |
indexer |
Running an indexing cycle: discover, fetch, write | Decide what qualifies |
index |
Storing chunks and retrieving them for a question | Compose prose |
answer |
Turning a question and its passages into a grounded answer or a decline | Invent a citation |
store |
Owning the single SQLite database | Know what is in it |
transport — owns the connection, one generation at a time¶
The transport holds the Discord client, one scope per guild over it, and the goroutines that read them, as a single unit: built whole, retired whole, exactly one live at a time. That unit is a generation. When the supervisor restarts the transport, the whole generation is rebuilt on a fresh connection and the old one is closed, so nothing can hold a connection that looks healthy and is not.
Building a generation connects nothing. Every scope is minted first, because a message for a guild whose scope does not exist yet is discarded by the platform; connecting happens only once the generation is installed, so a build that loses a race has not opened a gateway or spent one of the day's identifies. Closing is the mirror: a release closes every reader and then the client on its own goroutine and returns at once, so the supervisor's retry loop never blocks on Discord, and the client is never closed while a post is in flight because the Discord REST layer deadlocks on exactly that overlap.
Health is client-wide by construction. A single guild cannot fail a probe on its own, and a worker fault (a handler that ignores its deadline) fails readiness rather than health, because restarting the transport cannot cure it and would cost an identify per attempt. Restart exhaustion is terminal: liveness fails and the process exits non-zero, so an orchestrator and a bare process converge on replacing it.
This is the only package that imports the chat-platform client. Its supervision numbers are all
configuration; see the discord keys.
ingest — reads, and only reads¶
The pump reads one guild's feed, applies the channel allowlist, and hands each permitted message to a handler. In the current slice that handler logs the message and does nothing else.
It is incapable of posting, and that is a property of the types rather than a flag. It is built over the two reader methods it uses, messages and connection state, so it cannot connect, close, or reach an actor: the transport holds those. The scope it reads is minted read-only, so no actor exists to be reached anyway — not an actor that refuses, none — which removes moderation, interactive components and slash commands along with it. Shadow mode here is something you can read off the constructor rather than something somebody has to remember to check.
The pump ranges over its channel until the transport closes it and never selects on a context: everything buffered before the close is still delivered, so a restart drains what it already had rather than dropping it. The handler runs synchronously under a deadline it is required to honour.
The package depends on the chat-platform contract alone. The Discord implementation is imported at
the wiring point in the serve command, never inside the library, and a dependency-footprint test
fails the build if a vendor SDK reaches the library.
corpus — decides what the bot is allowed to know¶
Two jobs that both come down to "what counts as knowledge": the predicate that admits or rejects a repository, and the splitter that cuts files into retrievable chunks.
It is the project's security boundary, and it makes no network calls of its own. Everything it judges arrives as a value describing what the forge reported. That is what makes the boundary testable without a forge, and what keeps a directory name on somebody's laptop from ever being an input to a visibility decision.
See what the bot is allowed to read.
indexer — runs the cycle¶
Joins the three pieces: discovery says what may be indexed, a fetcher gets the content, the store holds it. Everything it depends on is an interface, so a full indexing cycle runs in a test with no forge, no clone and no network.
A source that fails is recorded against that source and the run continues. Abandoning the cycle on the first failure would leave the whole corpus as stale as its unluckiest repository.
The scheduled refresher inside serve calls the same Refresh that index refresh calls. One
implementation, so the scheduled and manual routes cannot drift into disagreeing about what is
indexed or where it lives.
index — stores and retrieves¶
Writes chunks, and answers a query with scored, provenance-carrying results.
Retrieval sits behind a narrow interface with one implementation over SQLite FTS5. The interface earns its keep by being the seam a vector retriever would arrive through, and the one the benchmark harness measures — the harness depends on a contract rather than on a database.
Writing a document replaces everything previously indexed for that file, in one transaction. Without the replacement a refresh would accumulate every previous version of a page and happily cite prose that has since been rewritten: a citation that resolves, to text that no longer says what the answer claimed.
answer — grounds, or declines¶
Retrieval in, prose and citations out — or a decline. The judgement about whether the passages actually answer the question lives here, and is made against the passages rather than against a score.
See how an answer is produced.
store — one database, one writer¶
Index and state share a single SQLite database: FTS5 for retrieval, ordinary tables for sources and documents. One daemon means one writer, so SQLite's headline weakness does not apply, and keeping the index in the same transactional store as the state it cites is worth more than concurrency nobody needs.
The driver is the native one, under cgo, taken on measurement (spec 0012 D2: 4.97 ms against
9.00 ms for the query that runs on every question) once the release matrix was cut to Linux. It
needs the sqlite_fts5 build tag, which the justfile, CI and the release config all set; without
it FTS5 is missing and every retrieval query fails at runtime rather than at build time.
Foreign keys are enabled explicitly — they are off by default in SQLite, and without them a document could outlive the source it came from, leaving a citation pointing at nothing. The journal is write-ahead, also explicitly: in the default rollback mode a reader on a second handle starves behind a busy writer and a writer fails behind a held read, which is exactly the shape of a CLI query overlapping the daemon, or of one component holding a transaction while another queries. Under WAL neither happens. The mode persists in the file, so the first open sets it for every later one.
Migrations are versioned and applied one transaction each, with the version recorded in the same transaction, so a failure part-way through leaves the database at the last version that fully applied. An applied migration is never edited: twelve-month audit retention means this schema outlives several releases of the code that wrote to it.
Two capabilities that live upstream¶
Chat platforms and forge access are not implemented here. They are toolkit modules —
chat-platform with its Discord provider, and forge with its GitLab provider — consumed as
released versions.
When a contract is wrong it gets fixed upstream and the release is consumed. A local shim would be quicker once and wrong every time after: two implementations of the same idea, diverging, with the local one untested against anybody else's use of it.