This is the site you are reading. A SvelteKit front end over a Laravel CMS, with a terminal you can talk to, a search that blends keywords with meaning, and a live feed of what I have been committing. It went up in March 2026.
Two things this page got wrong
It said the interface blends Ubuntu and Windows 11. It does not. On 24 March a commit titled Changing to WSL look moved it to a WSL aesthetic — a Linux shell inside a Windows chrome — four days before the page went up saying otherwise.
It also said a pull request adding an elastic scroll effect was still open after five months, and read that as me being undecided about motion. It is closed now. Twenty-seven lines in one file, opened 24 March, closed unmerged on 29 August without ever being discussed. I was not undecided. I forgot about it.
Both corrections stay on the page. The point of writing this was never to be right the first time.
The terminal is a retrieval interface, not a chat box
The terminal answers questions about my work, and the interesting part is what it is allowed to see. It has nine tools: search and read for articles and for projects, search and statistics over my GitHub activity, the about sections, general site information, and counts. Which tools exist at all depends on which section of the site you are in, so the terminal in the articles area is not offered project tools it has no business calling.
Two design decisions I would defend in a review:
First, every content query in every tool filters on enable = 1. Unpublished and disabled content is invisible to the terminal as a property of the SQL, not as a request in a prompt. A semantic hit on a draft still gets dropped when the row is fetched. That is the difference between a boundary and a good intention, and it is the distinction I care most about in anything with a model in it.
Second, anything the browser sends with a system role is stripped before the conversation is assembled. A visitor cannot supply their own instructions and have them treated as mine.
The honest counterpart: the terminal's actual behaviour — tone, what it declines, how it presents me — lives in an editable text field in the admin panel, with a placeholder for today's date substituted in at request time. That is convenient and it is also a prompt, which means it is a request rather than a rule. The data boundary is enforced; the behavioural one is asked for politely. I know which of those two survives an adversarial visitor.
Hybrid search, tuned by hand
Search runs two retrievals and fuses them. MySQL full-text gives keyword matches. Embeddings give semantic ones. They are combined with reciprocal rank fusion, and the constants are all hand-set:
SEMANTIC_TOP_N 50 candidates from the vector side
SEMANTIC_THRESHOLD 0.5 cosine floor below which a hit is ignored
RRF_K 40 rank-fusion damping constant
SEMANTIC_WEIGHT 2.0 semantic ranks count double
MAX_FT_ROWS 200 cap on the full-text side
MAX_QUERY_WORDS 30 cap on the query itself
Before either retrieval runs, the query is expanded: the model is asked for three to five short variants, and those are searched too. This is why searching for a concept in one language finds an article written about it in another, and it is also an extra model round trip on the critical path of every search.
Now the part that will break first, stated plainly. The semantic side loads the entire embeddings table into the Node process, parses every vector out of JSON and computes every norm, then does the cosine arithmetic in application code. There is no vector index. At the size of one person's portfolio that is genuinely fine and measurably fast. It is a full table scan wearing a search engine's clothes, it grows linearly with everything I ever write, and when this page gets slow, that will be why.
Six months on from writing that paragraph, nothing about it has changed, and every article and project page I publish makes it slightly less true that the size is fine. That is not a complaint about the design; it is the design working exactly as predicted, on schedule, with me watching.
The contribute page, and what it costs a third party
There is a live feed of my GitHub activity: commits, pull requests, reviews, a contribution calendar, per-year totals, sortable server-side. It is cached in MySQL and Redis rather than fetched per visitor.
The sync interval is six minutes. It was ten, and a commit on 21 March titled Reduce sync to 6 minutes changed it — which reduced the interval and therefore increased the polling, and is a small lesson in reading my own commit messages carefully. It runs on a timer and is also checked lazily on request, so the data is fresh whether or not anyone has visited.
Which means this site polls somebody else's API every six minutes, permanently, whether a human is looking or not. Nobody asked me to be considerate about that and nothing stops me. It is a small cost imposed on a service I do not pay for, and the polite version of this feature is event-driven rather than scheduled. It is still six minutes. I wrote the sentence, agreed with it, and did not change the constant, which took one line.
In August I added a second thing to this feature: an endpoint that renders my GitHub statistics as an SVG card, 86 lines, so the numbers can be embedded anywhere an image can go. Building a new surface on top of a polling schedule I had just finished calling impolite is the kind of ordering that only looks odd when you write it down in sequence.
The private repository question is still open, and I am still on the wrong side of it. The feed includes activity from private repositories. I checked the code again before writing this: the repository's private flag is carried from the GitHub API into the database, out through the JSON endpoint, and rendered on the page as a small private badge next to the repository name and the commit subject line. So a visitor gets the name of a private repository, the subject of a commit in it, and a label confirming it is private. The commit subjects carry ticket numbers and, often, a plain description of what was broken.
The last version of this page said this was being dealt with directly rather than described. It has not been dealt with. It has now been described twice, which is not the same thing, and the people on the other side of it are the ones who did not get asked.
One evening in April, then silence, then a little maintenance
April produced twenty commits on the default branch. Sixteen of them landed in one sitting, between 17:09 and 19:27 UTC on 10 April: the embedding worker and its supervisor process, two migration fixes, a foreign key fix, a chunk size change, sorting moved to the server, and a loading spinner. That is the whole retrieval backend of this site arriving in two hours and eighteen minutes.
Then: one commit in May. Nothing in June. Nothing in July. Ten in August, two in September — the accent colour, the card endpoint, a slug fix, and a change to how image paths are built so uploads can come from object storage instead of the container's own disk. Over the same stretch the Discord bot took several hundred commits, and September was its biggest month since April.
I am not going to dress that up. The site that presents my work gets touched when something is broken or when a neighbouring project forces a change; the interesting work happens elsewhere. The only reason it does not rot visibly is that its content comes from a database rather than from code, which is a property of the architecture doing the job my attention is not.
Stack
Front end SvelteKit 5, Svelte 5, TypeScript
Data MySQL, read directly; Redis for caching
Retrieval MySQL full-text plus embeddings, fused with RRF in app code
AI any OpenAI-compatible endpoint, configured in the admin panel
Content Laravel CMS behind it, see the admin.dansday.com project
Media image paths resolve to object storage when configured
Telemetry OpenTelemetry
Hosting Docker on a single VPS, see the DISNUT project
What it demonstrates
A retrieval system built rather than bought: two retrievals, a fusion function, query expansion, and every constant chosen by looking at results. Tool-calling where the tool set is scoped to context and the data boundary is in SQL rather than in a prompt. A live third-party integration with caching, server-side sorting and a schedule.
And a page that tells you where it will break before you find out. The test of that habit is not the first version, it is this one — where the thing I predicted would go wrong has not gone wrong yet, the branch I said was undecided turned out to be abandoned, and the item I said was being handled is in exactly the state I left it. Writing accurately about your own work is easy. Acting on what you wrote is the part I keep failing.