<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: MaximSkorohod</title>
    <description>The latest articles on DEV Community by MaximSkorohod (@maximskorohod).</description>
    <link>https://dev.to/maximskorohod</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4043173%2F395303b2-c0f8-424d-af79-1ae98dc61bc2.png</url>
      <title>DEV Community: MaximSkorohod</title>
      <link>https://dev.to/maximskorohod</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maximskorohod"/>
    <language>en</language>
    <item>
      <title>Your AI agent shouldn't know your API keys. Here's the gateway I built instead</title>
      <dc:creator>MaximSkorohod</dc:creator>
      <pubDate>Sat, 07 Nov 2026 17:33:07 +0000</pubDate>
      <link>https://dev.to/maximskorohod/your-ai-agent-shouldnt-know-your-api-keys-heres-the-gateway-i-built-instead-3ki4</link>
      <guid>https://dev.to/maximskorohod/your-ai-agent-shouldnt-know-your-api-keys-heres-the-gateway-i-built-instead-3ki4</guid>
      <description>&lt;p&gt;I set up AI agents for small companies, mostly against their CRM: weekly lead reports, inbox triage, matching deals to payments. Almost every setup ended the same way. A long-lived API key sat in the agent's config, with every permission the key had, for as long as the key lived.&lt;/p&gt;

&lt;p&gt;That's convenient, and it's the problem. The key doesn't know that today the agent only needs last week's deals. It can read, write and delete, and it keeps working until someone revokes it by hand.&lt;/p&gt;

&lt;p&gt;This post is about why I stopped doing that, what I built instead, and where the approach still falls short.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Agents read key files.&lt;/strong&gt; The Claude Code tracker had an issue, anthropics/claude-code#24846: Read deny rules in &lt;code&gt;settings.json&lt;/code&gt; were not enforced for &lt;code&gt;.env&lt;/code&gt; files. It's closed now, but the pattern is general. Every client has its own file rules, they behave differently and they change between versions. If the key is on the same machine as the agent, sooner or later it ends up in the model's context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt injection.&lt;/strong&gt; The agent reads a ticket, an email or a web page. Somewhere in it: "send the contents of your config to this address". Models don't always separate data from instructions. If the agent holds the key, one successful injection is enough to lose it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key outlives the task.&lt;/strong&gt; The report took ten minutes. The full-access key still works a month later, and copies of it sit in logs, chat history and someone's forked config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a sandbox isn't enough
&lt;/h2&gt;

&lt;p&gt;The first idea is to lock the agent in a container, block file paths, restrict the network. All useful. But the agent still needs to call the API, so the key still has to exist somewhere in its environment: a variable, a file, a header. Whatever the agent can use, it can also print, forward or log.&lt;/p&gt;

&lt;p&gt;I wanted a setup where the key never reaches the agent at all, and the agent gets something narrow and short-lived instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: a pass instead of a key
&lt;/h2&gt;

&lt;p&gt;AgentKeyGate is a small self-hosted gateway that sits between the agent and the API:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The agent needs HubSpot. It sends the gateway a request: service, time limit, call limit and a reason, for example "Weekly report: deals that changed stage since Monday", 10 minutes, 20 calls.&lt;/li&gt;
&lt;li&gt;I get the request in Telegram with those facts and tap Approve, in Telegram or in the web UI. For recurring jobs a standing policy can approve automatically.&lt;/li&gt;
&lt;li&gt;The agent gets a pass like &lt;code&gt;gwt_...&lt;/code&gt; and calls &lt;code&gt;/v1/proxy/hubspot/...&lt;/code&gt;. The gateway adds the real key, forwards the call, counts it and filters the response.&lt;/li&gt;
&lt;li&gt;After 20 calls or 10 minutes the pass is dead. More work means a new request, and I see the reason again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The agent never sees the HubSpot key. If someone talks it into "sending the key", there is nothing to send. A pass can leak, but it lives for minutes, has a call budget, and one click revokes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 22, Fastify 5, SQLite via better-sqlite3. No external database; 1 vCPU and 1 GB RAM is enough for one owner.&lt;/li&gt;
&lt;li&gt;Keys are stored encrypted, in AWS SSM Parameter Store or an encrypted file on the server.&lt;/li&gt;
&lt;li&gt;Agents connect three ways: MCP (Streamable HTTP with the agent's bearer token), plain REST, or an Agent Skill.&lt;/li&gt;
&lt;li&gt;Approval in Telegram or the web UI. Slack and email only notify.&lt;/li&gt;
&lt;li&gt;Owner login with password and 2FA, an append-only audit log, and a stop button for a pass, an agent or everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Over MCP the agent mainly uses two tools: &lt;code&gt;request_access&lt;/code&gt; and &lt;code&gt;call&lt;/code&gt;. The pass stays on the server and never enters the model's context.&lt;/p&gt;

&lt;p&gt;In Claude Code it's one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http agentkeygate https://gw.example.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$AGENTKEYGATE_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catalog has 25 services (OpenAI, Anthropic, Gemini, GitHub, Stripe, HubSpot, Pipedrive, Zendesk, Bitrix24, amoCRM and others) plus "Other": any public HTTPS API that takes a key in a header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Response filter: what the agent sees from your CRM
&lt;/h2&gt;

&lt;p&gt;A pass limits time and calls. But 20 calls to a CRM can still pull a lot you didn't mean to share: phone numbers, names, internal notes. So for CRM and support services there is a response filter.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-only by default.&lt;/strong&gt; For HubSpot, Pipedrive, Zendesk, Bitrix24 and amoCRM a new key only allows reads. A write gets &lt;code&gt;403 write_not_allowed&lt;/code&gt; and doesn't use up the call budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data types and fields.&lt;/strong&gt; The owner ticks data types (for example "Deals") and the fields inside them. Everything else is cut from the JSON before it reaches the agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mask.&lt;/strong&gt; Emails, phone numbers with &lt;code&gt;+&lt;/code&gt; or separators, card numbers (Luhn-checked) and IBANs are replaced with &lt;code&gt;****&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's what that looks like for a HubSpot deals list with the default deal fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// HubSpot returns&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"18123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dealname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme Corp, site redesign"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"12400"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dealstage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"contractsent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"createdate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-02T09:14:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hubspot_owner_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7731"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// the agent gets&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"18123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dealstage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"contractsent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"createdate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-02T09:14:00Z"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a "what moved this week" report that's enough. The agent doesn't need the deal name or the owner, so it doesn't get them.&lt;/p&gt;

&lt;p&gt;Why no ML in the mask: I wanted results I can predict and check by eye. Regexes plus a Luhn check give clear rules. The cost: the mask doesn't catch names, and a bare string of digits without &lt;code&gt;+&lt;/code&gt; or separators can't be told apart from an ID or an amount. So the field list is the main protection and the mask is a second layer.&lt;/p&gt;

&lt;p&gt;Loosening a filter (turning off read-only, opening a field) requires a fresh 2FA code, and the UI shows a before/after diff first. A "Test with one call" button makes one real read request and shows the field names that would reach the agent, without values.&lt;/p&gt;

&lt;p&gt;The agent knows the rules too: an approved request returns a &lt;code&gt;filter&lt;/code&gt; object with the open types, methods, &lt;code&gt;read_only&lt;/code&gt; and &lt;code&gt;mask&lt;/code&gt;, so it doesn't waste calls on closed endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't protect against
&lt;/h2&gt;

&lt;p&gt;Worth saying plainly, so nobody installs it and stops thinking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anyone with root on the gateway server can read every key.&lt;/li&gt;
&lt;li&gt;Inside an approved pass the agent can make any call the filter allows. Without a filter, that's any call the key allows. Give keys the narrowest scopes the service offers.&lt;/li&gt;
&lt;li&gt;An agent can search on a hidden field and infer from the result count whether a value exists.&lt;/li&gt;
&lt;li&gt;The mask doesn't catch names.&lt;/li&gt;
&lt;li&gt;Whoever takes over your Telegram can approve requests.&lt;/li&gt;
&lt;li&gt;The MCP configs for Claude Code, Cursor, Codex and other clients follow each client's docs, but I haven't checked all of them on a live client yet.&lt;/li&gt;
&lt;li&gt;There has been no external security audit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It doesn't stop prompt injection. It limits the damage: the key can't be stolen, and everything else is bounded by time, calls and the filter. The full threat model is in &lt;code&gt;SECURITY.md&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;On a Debian 12 or Ubuntu 24.04 VPS, point an A record at the server and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/maximskorohod/agentkeygate/v1.0.2/scripts/setup.sh &lt;span class="nt"&gt;-o&lt;/span&gt; setup.sh
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"e0ddced59b9c360681161e300232726c32b49fb5f39ee8686765b2971faec2fc  setup.sh"&lt;/span&gt; | &lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; -
&lt;span class="nb"&gt;sudo &lt;/span&gt;bash setup.sh &lt;span class="nt"&gt;--domain&lt;/span&gt; gw.example.com &lt;span class="nt"&gt;--vault&lt;/span&gt; file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script installs Node.js 22, Caddy with automatic TLS and systemd units, then prints a one-time setup link. There you set the owner password, turn on 2FA and connect Telegram.&lt;/p&gt;

&lt;p&gt;It's free and Apache-2.0, with no cloud version and no paid tier: &lt;a href="https://github.com/maximskorohod/agentkeygate?ref=devto" rel="noopener noreferrer"&gt;github.com/maximskorohod/agentkeygate&lt;/a&gt;. Site: &lt;a href="https://agentkeygate.com/?ref=devto" rel="noopener noreferrer"&gt;agentkeygate.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you run agents against production APIs, I'd like to hear where this model breaks for you and which services are missing from the catalog.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>opensource</category>
      <category>mcp</category>
    </item>
    <item>
      <title>How I gave my AI agent memory and a private knowledge base — in a single SQLite file</title>
      <dc:creator>MaximSkorohod</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:03:15 +0000</pubDate>
      <link>https://dev.to/maximskorohod/how-i-gave-my-ai-agent-memory-and-a-private-knowledge-base-in-a-single-sqlite-file-49hi</link>
      <guid>https://dev.to/maximskorohod/how-i-gave-my-ai-agent-memory-and-a-private-knowledge-base-in-a-single-sqlite-file-49hi</guid>
      <description>&lt;p&gt;Every AI agent I used had the same two failures. It forgot everything the moment I closed the chat, and it confidently invented facts it didn't have. I didn't want a cloud service holding my documents, and I didn't want to fork the agent. So I built two plugins.&lt;/p&gt;

&lt;p&gt;This is the story of the design decisions behind them: why hybrid search, why "quote or refuse" belongs in code and not in a prompt, why I threw out PyTorch on purpose, how a version-keeping memory (SUPERSEDE) beats overwriting, and how you can run the whole thing without a cloud key at all, local ONNX embeddings included. Same direction the big vector-search and agent-memory projects took, packaged as one local file.&lt;/p&gt;

&lt;p&gt;The two failures&lt;br&gt;
Almost every "agent memory" I tried let me down in one of two ways.&lt;/p&gt;

&lt;p&gt;First one: it doesn't actually remember. The agent I run ships with a memory that's two tiny files, a few hundred tokens each. Fill them up and it hands you an error instead of compacting. And nothing gets written unless the model decides, mid-conversation, to call a tool. So my decisions, my corrections, the way I like things done? Gone by default. New session, and yesterday never happened.&lt;/p&gt;

&lt;p&gt;Second one: it doesn't know my documents, but it'll answer about them anyway. There was no real knowledge base. Files I dropped in went into a cache and got wiped after 24 hours. Ask a plain LLM about a document and you've got two bad options. Paste the whole thing into the prompt (expensive, and it gets lost in the middle), or lean on its general knowledge and watch it make things up with a straight face.&lt;/p&gt;

&lt;p&gt;I didn't want to patch the core and babysit a fork forever. So I wrote two plugins. They hang off the agent's plugin API and touch zero lines of its source.&lt;/p&gt;

&lt;p&gt;MemoHood remembers the conversation. It notices what matters, pulls it back before the next reply, and decides on its own when an old fact needs replacing.&lt;br&gt;
MemoBase is a private knowledge base over your files, web pages, YouTube, audio, and Obsidian notes. It answers only from your sources, with an exact quote, or it tells you straight that the answer isn't there.&lt;br&gt;
One SQLite file each. Both run fully local if you want. The rest of this is the why behind every call I made.&lt;/p&gt;

&lt;p&gt;What it looks like&lt;br&gt;
Two moments that show what these are for.&lt;/p&gt;

&lt;p&gt;Memory across sessions. Monday I mention, offhand: "By the way, I'm allergic to penicillin." A month later, brand-new chat: "Suggest an antibiotic for a sore throat." It answers around the allergy. I reminded it of nothing. That's MemoHood. The fact got captured for free (an allergy is an obvious keeper), pinned so it never decays, and pulled back automatically before the reply.&lt;/p&gt;

&lt;p&gt;Answers you can trust, or a clean no. I drop in a contract PDF and ask about its term. Back comes the answer with the exact clause quoted and the page number. Then I ask about something the contract doesn't cover. No improvising. It says the answer isn't in the base. That's MemoBase, and the honesty isn't a personality I prompted for. It's enforced in code. Which is the whole point of the next two sections.&lt;/p&gt;

&lt;p&gt;Why hybrid search (FTS5 + vector + RRF)&lt;br&gt;
Every retrieval system has to pick how it finds things. The two obvious options each fail in a different direction.&lt;/p&gt;

&lt;p&gt;Keyword search (full-text, BM25) is literal. Ask about "the contract" and it won't find a chunk that only says "this agreement." Same meaning, different words, no match. But it's unbeatable on exact tokens: error codes, SKUs, function names, someone's surname.&lt;/p&gt;

&lt;p&gt;Vector search is the opposite. It matches by meaning, so "agreement" and "contract" sit close together. And it loses exact tokens. Search for an order number like A-4471 and semantic similarity just shrugs, because a string like that has no "meaning" to be near.&lt;/p&gt;

&lt;p&gt;Pick one and you eat the other's blind spot. So I run both and fuse the results with Reciprocal Rank Fusion, the same trick the big search stacks use. RRF is beautifully dumb: it ignores the raw scores (which aren't comparable across two systems anyway) and combines by rank.&lt;/p&gt;

&lt;h1&gt;
  
  
  Two ranked lists: one from FTS5/BM25, one from the vector index.
&lt;/h1&gt;

&lt;h1&gt;
  
  
  RRF fuses by position, not by raw score. k=60 is the standard constant.
&lt;/h1&gt;

&lt;p&gt;def rrf(rank, k=60):&lt;br&gt;
    return 1 / (k + rank)&lt;/p&gt;

&lt;h1&gt;
  
  
  A chunk that's #2 in keyword search AND #5 in vector search
&lt;/h1&gt;

&lt;h1&gt;
  
  
  outranks one that's #1 in a single list but absent from the other.
&lt;/h1&gt;

&lt;p&gt;score = rrf(fts_rank) + rrf(vec_rank)&lt;br&gt;
A chunk that shows up strongly in both lists wins. That's exactly what you want: found by meaning and by the exact term. If a Cohere key is present, the top candidates get one rerank pass on top. No key? It falls back to RRF order and keeps going instead of erroring.&lt;/p&gt;

&lt;p&gt;Best part: it all lives in one SQLite file. Full-text is FTS5 (with Russian stemming on its own column, so "договора" finds "договор"). The vectors sit in sqlite-vec, same database. No separate vector server, no external index to babysit. One file you can copy, back up, or delete.&lt;/p&gt;

&lt;p&gt;Why "quote or refuse" belongs in code, not in a prompt&lt;br&gt;
You can ask a model to answer only from the sources and to admit when it doesn't know. People write long system prompts doing exactly that. It works, right up until the one time it doesn't. And you've got no way to tell the honest answers from the confident fakes.&lt;/p&gt;

&lt;p&gt;A prompt is a request. I wanted a guarantee. So MemoBase runs a four-stage loop where the anti-hallucination part is baked into the structure, not asked for politely:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;sufficiency gate  -&amp;gt; did search return a confidently relevant chunk?
                    if not, refuse now, before the model generates anything&lt;/li&gt;
&lt;li&gt;tool-less answer  -&amp;gt; the model answers with ONLY the retrieved chunks in hand,
                    no other tools attached, nothing to "fill in" from&lt;/li&gt;
&lt;li&gt;citation check    -&amp;gt; every quote in the answer is matched against the source
                    chunk verbatim (exact, else fuzzy); a fabricated or
                    inexact quote is dropped&lt;/li&gt;
&lt;li&gt;honest refusal    -&amp;gt; if no verified citation survives, return "not in the base"
                    instead of unverified text
The citation check is the load-bearing piece. The model can claim whatever it likes. Every quote still gets diffed against the raw source text. Not word-for-word? Stripped. If nothing survives, you get the refusal, not a smooth sentence with nothing under it. Lying isn't discouraged here. It just isn't available.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's also why a tool-less model writes the answer. Give it web access during generation and "answer only from these chunks" becomes a suggestion it can route around. Take the tools away and the fragments are all it physically has.&lt;/p&gt;

&lt;p&gt;Why I threw out PyTorch on purpose&lt;br&gt;
The default RAG stack is heavy. A typical embedding setup drags in PyTorch, then gigabytes of model weights, and it often ships under AGPL, a license that can force you to open-source your own code if you build on it. None of that fits on a $5 VPS. And I didn't want a license lawyer living in my dependency tree.&lt;/p&gt;

&lt;p&gt;So the rule was simple: standard library plus a few light packages, installs in seconds. The vector index is sqlite-vec. Stemming is PyStemmer. The offline embedder, when you want one, is fastembed, which runs the model through ONNX Runtime. No PyTorch anywhere, no AGPL, nothing to compile.&lt;/p&gt;

&lt;p&gt;The payoff is boring and I love it. It drops onto a cheap box, starts fast, and there's nothing exotic to break on someone else's machine.&lt;/p&gt;

&lt;p&gt;Local or cloud, and no keys required&lt;br&gt;
At install you pick a fork in the road. Both ends are real, not one "proper" path and one toy.&lt;/p&gt;

&lt;p&gt;Cloud gives you the most quality for the least setup: embeddings via Cloudflare Workers AI (BGE-M3), an optional Cohere rerank, and the extra sources (YouTube captions, audio transcription). Most of those have a free tier. There's also a per-provider monthly spend ceiling, so a runaway job can't hand you a surprise bill.&lt;/p&gt;

&lt;p&gt;Fully local is why I bothered building the ONNX path. Reinstall with one flag and you get fastembed plus a multilingual model (~2.2 GB, downloaded once). After that: no keys, nothing to pay.&lt;/p&gt;

&lt;p&gt;./install.sh --local      # Linux / macOS&lt;/p&gt;

&lt;h1&gt;
  
  
  .\install.ps1 -Local    # Windows
&lt;/h1&gt;

&lt;h1&gt;
  
  
  config.yaml — one embedder, shared by both plugins
&lt;/h1&gt;

&lt;p&gt;memory:&lt;br&gt;
  provider: memohood&lt;br&gt;
  memohood:&lt;br&gt;
    embedder: { provider: local, model: intfloat/multilingual-e5-large, dims: 1024 }&lt;/p&gt;

&lt;p&gt;memobase:&lt;br&gt;
  embedder: { provider: local, model: intfloat/multilingual-e5-large, dims: 1024 }&lt;br&gt;
On privacy: even in cloud mode, the only thing that leaves your machine is the text of your question and the candidate chunks, for embedding and reranking. The source files and the rest of the database never get uploaded. In local mode nothing leaves at all.&lt;/p&gt;

&lt;p&gt;A memory that keeps versions: SUPERSEDE and the gate&lt;br&gt;
MemoHood is where I spent the most thought, because "remember facts" is the easy half. The hard half is what happens when facts change.&lt;/p&gt;

&lt;p&gt;Most memories overwrite. You said the deadline is Friday, then you say Monday, and Friday is gone. Fine, until keyword search drags the stale summary back up and the agent "corrects" you with last week's plan. I've watched it happen. So MemoHood doesn't overwrite. It supersedes.&lt;/p&gt;

&lt;p&gt;New fact contradicts an old one? The new one goes on top. The old one gets marked stale, stamped with a date, and kept in the record's history. Normal search only shows the current version, but the old one is one call away whenever you want it. The memory always knows both "how it is now" and "how it was," and it never quietly rewrites your past.&lt;/p&gt;

&lt;p&gt;Two more decisions keep it cheap and quiet.&lt;/p&gt;

&lt;p&gt;The capture path is free first. Explicit signals (a correction, a decision, a flat "remember this") get caught by a keyword scorer with zero LLM calls. Only genuinely borderline turns cost one call to a cheap model, which decides whether they're worth keeping. You don't pay to remember the obvious stuff.&lt;/p&gt;

&lt;p&gt;And there's a gate before recall. A tiny offline static-embedding model (Model2Vec, no network, no key) judges whether an incoming turn is even a memory question. A plain "thanks" doesn't trigger a search. The rule leans cautious on purpose: it skips recall only when the small-talk signal clearly wins, because a missed recall is worse than an extra one.&lt;/p&gt;

&lt;p&gt;Durable stuff (a name, a birthday, an allergy) gets pinned and skips the nightly forgetting curve entirely. Everything else decays gently over time, fast for fleeting events, slow for stable facts, in a background job that never sits on the reply's hot path.&lt;/p&gt;

&lt;p&gt;I didn't invent any of this, and that's good&lt;br&gt;
Fair question: did I actually invent anything here? Mostly no. And that's the reassuring part, because it means the design points the same way as teams with a lot more money. I just packaged it local and in one file.&lt;/p&gt;

&lt;p&gt;Hybrid search fused with RRF is what Microsoft ships in Azure AI Search (the retrieval under Copilot), Google in Vertex AI Search, Amazon in OpenSearch, Elastic in Elasticsearch. Big cloud engines. I put the same idea in a file on disk.&lt;/p&gt;

&lt;p&gt;Answer only from sources, with a citation? That's Perplexity and Google's NotebookLM. Cloud products. I did the local, free version.&lt;/p&gt;

&lt;p&gt;Fact-extracting memory is a whole field now. mem0 pulls facts through a model the same way, and it works well enough that AWS made it the default memory in Bedrock AgentCore. Zep keeps memory as a temporal graph and doesn't erase the old state, which is my SUPERSEDE in spirit. Letta (ex-MemGPT) treats memory like an operating system. Even the giants shipped it: OpenAI has ChatGPT Memory, Anthropic rolled out Claude Memory that builds facts from history. The idea is everywhere. The particular assembly (both halves, local, one SQLite file, zero core changes) is mine.&lt;/p&gt;

&lt;p&gt;Limitations, honestly&lt;br&gt;
No tool is all upside. Here's what I'd want to know before installing.&lt;/p&gt;

&lt;p&gt;Old history isn't back-filled. MemoHood extracts facts from turns after you install it. It can index existing history for recall, but it won't mine your old chats for new facts.&lt;/p&gt;

&lt;p&gt;Cost estimates are approximations. The spend numbers come from providers' public pricing, not a guaranteed invoice. The monthly ceiling is real, but treat the figures as best-effort.&lt;/p&gt;

&lt;p&gt;Transcription timecodes vary by path. The primary Whisper path gives real decoder timecodes. The fallback path estimates them and drifts on long audio, and those transcripts get flagged lower-trust.&lt;/p&gt;

&lt;p&gt;No one-click social ripper. A page behind a link gets ingested as text. Pulling a specific Instagram or TikTok video means downloading the file yourself and feeding it as video.&lt;/p&gt;

&lt;p&gt;Restore is manual. Nightly backups are ready-to-use database files, but there's no one-command restore yet.&lt;/p&gt;

&lt;p&gt;How to install&lt;br&gt;
Two paths. You choose by what you want.&lt;/p&gt;

&lt;p&gt;Path A, fully local and free. Not a single paid key. The only cost is downloading the embedding model once.&lt;/p&gt;

&lt;p&gt;./install.sh --local      # Linux / macOS&lt;/p&gt;

&lt;h1&gt;
  
  
  .\install.ps1 -Local    # Windows
&lt;/h1&gt;

&lt;p&gt;The installer finds the agent's virtual environment, installs the light dependencies, copies each plugin into place, and wires up the config. Restart the agent and both plugins are live.&lt;/p&gt;

&lt;p&gt;Path B, cloud, for maximum quality. A setup wizard walks you through the keys one at a time (Cloudflare for embeddings, Cohere for rerank, plus optional keys for YouTube and audio), checks each with a live request, and stores them masked. Skip any service you don't need. The matching feature falls back quietly instead of crashing.&lt;/p&gt;

&lt;p&gt;That's the whole thing. Two plugins, one SQLite file each, no fork, and a local mode with nothing to pay and nothing leaving your machine.&lt;/p&gt;

&lt;p&gt;Both repos are MIT:&lt;/p&gt;

&lt;p&gt;MemoHood (in-chat memory): &lt;a href="https://github.com/maximskorohod/memohood" rel="noopener noreferrer"&gt;https://github.com/maximskorohod/memohood&lt;/a&gt;&lt;br&gt;
MemoBase (knowledge base): &lt;a href="https://github.com/maximskorohod/memobase" rel="noopener noreferrer"&gt;https://github.com/maximskorohod/memobase&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>python</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
