[{"data":1,"prerenderedAt":3115},["ShallowReactive",2],{"tags-articles":3},[4,684,1844],{"id":5,"title":6,"body":7,"date":668,"description":669,"extension":670,"meta":671,"navigation":342,"path":672,"readingTime":673,"seo":674,"stem":675,"tags":676,"__hash__":683},"articles\u002Fcloudflare-hermes-integration.md","Hermes + Cloudflare: автоматизация DNS и деплоя из терминала",{"type":8,"value":9,"toc":652},"minimark",[10,14,18,21,26,29,48,51,55,65,91,94,128,131,135,140,143,191,194,232,239,243,285,289,313,317,320,388,391,458,468,472,479,485,488,492,495,500,503,517,520,524,527,550,553,557,563,569,618,624,638,642,645,648],[11,12,6],"h1",{"id":13},"hermes-cloudflare-автоматизация-dns-и-деплоя-из-терминала",[15,16,17],"p",{},"У меня десятки субдоменов: блог, лендинги, API, нотификационные сервисы. Управлять всем этим через Cloudflare Dashboard — больно. Каждый раз логиниться, искать нужную запись, кликать, ждать...",[15,19,20],{},"Решение — дать это дело AI-агенту. Hermes Agent умеет работать с Cloudflare через CLI и API. Вот как я настроил этот pipeline.",[22,23,25],"h2",{"id":24},"зачем-агенту-cloudflare","Зачем агенту Cloudflare?",[15,27,28],{},"Типичный сценарий: я ставлю новый сервис на VPS и хочу, чтобы он был доступен по HTTPS на своём субдомене. Раньше это было:",[30,31,32,36,39,42,45],"ol",{},[33,34,35],"li",{},"Зайти в Cloudflare Dashboard",[33,37,38],{},"Создать DNS-запись",[33,40,41],{},"Дождаться пропагации",[33,43,44],{},"Проверить SSL",[33,46,47],{},"Настроить Caddy\u002Fnginx",[15,49,50],{},"Теперь я говорю Hermes: «Подними ntfy на субдомене notify и проксируй через Cloudflare». Агент делает всё сам.",[22,52,54],{"id":53},"инструмент-flarectl","Инструмент: flarectl",[15,56,57,64],{},[58,59,63],"a",{"href":60,"rel":61},"https:\u002F\u002Fgithub.com\u002Fcloudflare\u002Fcloudflare-go\u002Ftree\u002Fmain\u002Fcmd\u002Fflarectl",[62],"nofollow","flarectl"," — официальная CLI-утилита от Cloudflare. Установка:",[66,67,72],"pre",{"className":68,"code":69,"language":70,"meta":71,"style":71},"language-bash shiki shiki-themes github-dark","go install github.com\u002Fcloudflare\u002Fcloudflare-go\u002Fcmd\u002Fflarectl@latest\n","bash","",[73,74,75],"code",{"__ignoreMap":71},[76,77,80,84,88],"span",{"class":78,"line":79},"line",1,[76,81,83],{"class":82},"svObZ","go",[76,85,87],{"class":86},"sU2Wk"," install",[76,89,90],{"class":86}," github.com\u002Fcloudflare\u002Fcloudflare-go\u002Fcmd\u002Fflarectl@latest\n",[15,92,93],{},"Авторизация через переменные окружения:",[66,95,97],{"className":68,"code":96,"language":70,"meta":71,"style":71},"export CF_API_TOKEN=your-api-token\nexport CF_ACCOUNT_ID=your-account-id\n",[73,98,99,115],{"__ignoreMap":71},[76,100,101,105,109,112],{"class":78,"line":79},[76,102,104],{"class":103},"snl16","export",[76,106,108],{"class":107},"s95oV"," CF_API_TOKEN",[76,110,111],{"class":103},"=",[76,113,114],{"class":107},"your-api-token\n",[76,116,118,120,123,125],{"class":78,"line":117},2,[76,119,104],{"class":103},[76,121,122],{"class":107}," CF_ACCOUNT_ID",[76,124,111],{"class":103},[76,126,127],{"class":107},"your-account-id\n",[15,129,130],{},"Токен создаётся в Dashboard → My Profile → API Tokens. Нужны permissions: Zone:DNS:Edit, Zone:Zone:Read.",[22,132,134],{"id":133},"базовые-операции","Базовые операции",[136,137,139],"h3",{"id":138},"создание-dns-записи","Создание DNS-записи",[15,141,142],{},"A-запись (субдомен → IP сервера):",[66,144,146],{"className":68,"code":145,"language":70,"meta":71,"style":71},"flarectl dns create --zone example.com \\\n  --type A --name myservice --content 203.0.113.1 --proxied\n",[73,147,148,168],{"__ignoreMap":71},[76,149,150,152,155,158,162,165],{"class":78,"line":79},[76,151,63],{"class":82},[76,153,154],{"class":86}," dns",[76,156,157],{"class":86}," create",[76,159,161],{"class":160},"sDLfK"," --zone",[76,163,164],{"class":86}," example.com",[76,166,167],{"class":160}," \\\n",[76,169,170,173,176,179,182,185,188],{"class":78,"line":117},[76,171,172],{"class":160},"  --type",[76,174,175],{"class":86}," A",[76,177,178],{"class":160}," --name",[76,180,181],{"class":86}," myservice",[76,183,184],{"class":160}," --content",[76,186,187],{"class":160}," 203.0.113.1",[76,189,190],{"class":160}," --proxied\n",[15,192,193],{},"CNAME (субдомен → Cloudflare Pages проект):",[66,195,197],{"className":68,"code":196,"language":70,"meta":71,"style":71},"flarectl dns create --zone example.com \\\n  --type CNAME --name blog --content my-project.pages.dev --proxied\n",[73,198,199,213],{"__ignoreMap":71},[76,200,201,203,205,207,209,211],{"class":78,"line":79},[76,202,63],{"class":82},[76,204,154],{"class":86},[76,206,157],{"class":86},[76,208,161],{"class":160},[76,210,164],{"class":86},[76,212,167],{"class":160},[76,214,215,217,220,222,225,227,230],{"class":78,"line":117},[76,216,172],{"class":160},[76,218,219],{"class":86}," CNAME",[76,221,178],{"class":160},[76,223,224],{"class":86}," blog",[76,226,184],{"class":160},[76,228,229],{"class":86}," my-project.pages.dev",[76,231,190],{"class":160},[15,233,234,235,238],{},"Флаг ",[73,236,237],{},"--proxied"," включает оранжевый облако — Cloudflare кеширует, защищает от DDoS, terminates SSL.",[136,240,242],{"id":241},"обновление-записей","Обновление записей",[66,244,246],{"className":68,"code":245,"language":70,"meta":71,"style":71},"flarectl dns update --zone example.com \\\n  --id RECORD_ID --type A --name myservice --content 203.0.113.2\n",[73,247,248,263],{"__ignoreMap":71},[76,249,250,252,254,257,259,261],{"class":78,"line":79},[76,251,63],{"class":82},[76,253,154],{"class":86},[76,255,256],{"class":86}," update",[76,258,161],{"class":160},[76,260,164],{"class":86},[76,262,167],{"class":160},[76,264,265,268,271,274,276,278,280,282],{"class":78,"line":117},[76,266,267],{"class":160},"  --id",[76,269,270],{"class":86}," RECORD_ID",[76,272,273],{"class":160}," --type",[76,275,175],{"class":86},[76,277,178],{"class":160},[76,279,181],{"class":86},[76,281,184],{"class":160},[76,283,284],{"class":160}," 203.0.113.2\n",[136,286,288],{"id":287},"удаление","Удаление",[66,290,292],{"className":68,"code":291,"language":70,"meta":71,"style":71},"flarectl dns delete --zone example.com --id RECORD_ID\n",[73,293,294],{"__ignoreMap":71},[76,295,296,298,300,303,305,307,310],{"class":78,"line":79},[76,297,63],{"class":82},[76,299,154],{"class":86},[76,301,302],{"class":86}," delete",[76,304,161],{"class":160},[76,306,164],{"class":86},[76,308,309],{"class":160}," --id",[76,311,312],{"class":86}," RECORD_ID\n",[22,314,316],{"id":315},"cloudflare-pages-деплой-статики","Cloudflare Pages: деплой статики",[15,318,319],{},"Для Nuxt, Astro, Next.js и других SSG\u002FSSR фреймворков Cloudflare Pages — бесплатный хостинг с CDN. Деплой через wrangler CLI:",[66,321,323],{"className":68,"code":322,"language":70,"meta":71,"style":71},"npm install -g wrangler\n\n# Авторизация\nwrangler login\n\n# Деплой из папки dist\u002F\nwrangler pages deploy dist --project-name=my-blog\n",[73,324,325,338,344,351,360,365,371],{"__ignoreMap":71},[76,326,327,330,332,335],{"class":78,"line":79},[76,328,329],{"class":82},"npm",[76,331,87],{"class":86},[76,333,334],{"class":160}," -g",[76,336,337],{"class":86}," wrangler\n",[76,339,340],{"class":78,"line":117},[76,341,343],{"emptyLinePlaceholder":342},true,"\n",[76,345,347],{"class":78,"line":346},3,[76,348,350],{"class":349},"sAwPA","# Авторизация\n",[76,352,354,357],{"class":78,"line":353},4,[76,355,356],{"class":82},"wrangler",[76,358,359],{"class":86}," login\n",[76,361,363],{"class":78,"line":362},5,[76,364,343],{"emptyLinePlaceholder":342},[76,366,368],{"class":78,"line":367},6,[76,369,370],{"class":349},"# Деплой из папки dist\u002F\n",[76,372,374,376,379,382,385],{"class":78,"line":373},7,[76,375,356],{"class":82},[76,377,378],{"class":86}," pages",[76,380,381],{"class":86}," deploy",[76,383,384],{"class":86}," dist",[76,386,387],{"class":160}," --project-name=my-blog\n",[15,389,390],{},"Кастомный домен привязывается через API:",[66,392,394],{"className":68,"code":393,"language":70,"meta":71,"style":71},"curl -s -X POST \\\n  \"https:\u002F\u002Fapi.cloudflare.com\u002Fclient\u002Fv4\u002Faccounts\u002F$CF_ACCOUNT_ID\u002Fpages\u002Fprojects\u002Fmy-blog\u002Fdomains\" \\\n  -H \"Authorization: Bearer $CF_API_TOKEN\" \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  --data '{\"name\": \"blog.example.com\"}'\n",[73,395,396,412,425,441,450],{"__ignoreMap":71},[76,397,398,401,404,407,410],{"class":78,"line":79},[76,399,400],{"class":82},"curl",[76,402,403],{"class":160}," -s",[76,405,406],{"class":160}," -X",[76,408,409],{"class":86}," POST",[76,411,167],{"class":160},[76,413,414,417,420,423],{"class":78,"line":117},[76,415,416],{"class":86},"  \"https:\u002F\u002Fapi.cloudflare.com\u002Fclient\u002Fv4\u002Faccounts\u002F",[76,418,419],{"class":107},"$CF_ACCOUNT_ID",[76,421,422],{"class":86},"\u002Fpages\u002Fprojects\u002Fmy-blog\u002Fdomains\"",[76,424,167],{"class":160},[76,426,427,430,433,436,439],{"class":78,"line":346},[76,428,429],{"class":160},"  -H",[76,431,432],{"class":86}," \"Authorization: Bearer ",[76,434,435],{"class":107},"$CF_API_TOKEN",[76,437,438],{"class":86},"\"",[76,440,167],{"class":160},[76,442,443,445,448],{"class":78,"line":353},[76,444,429],{"class":160},[76,446,447],{"class":86}," \"Content-Type: application\u002Fjson\"",[76,449,167],{"class":160},[76,451,452,455],{"class":78,"line":362},[76,453,454],{"class":160},"  --data",[76,456,457],{"class":86}," '{\"name\": \"blog.example.com\"}'\n",[459,460,461],"blockquote",{},[15,462,463,464,467],{},"Wrangler v4.94+ может не поддерживать ",[73,465,466],{},"wrangler pages domain add",". Используйте API напрямую.",[22,469,471],{"id":470},"ssl-flexible-vs-full","SSL: Flexible vs Full",[15,473,474,478],{},[475,476,477],"strong",{},"SSL:Flexible"," — Cloudflare terminates SSL на своём edge, до вашего origin идёт plain HTTP. Не нужен сертификат на сервере. Идеально для сервисов, которые не работают с HTTPS нативно.",[15,480,481,484],{},[475,482,483],{},"SSL:Full"," — Cloudflare terminates SSL на edge, но до origin тоже идёт SSL. Нужен сертификат на сервере (можно Cloudflare Origin CA — бесплатный).",[15,486,487],{},"Для self-hosted сервисов на одном VPS обычно хватает Flexible. Но если сервис передаёт чувствительные данные — ставьте Full.",[22,489,491],{"id":490},"автоматизация-через-hermes","Автоматизация через Hermes",[15,493,494],{},"Вот как это работает в реальной жизни. Hermes Agent имеет доступ к терминалу и может выполнять команды. Я говорю:",[459,496,497],{},[15,498,499],{},"«Поставь Grafana на монитор.example.com»",[15,501,502],{},"Агент:",[30,504,505,508,511,514],{},[33,506,507],{},"Проверяет, что Grafana установлена",[33,509,510],{},"Создаёт DNS A-запись через flarectl",[33,512,513],{},"Настраивает Caddy reverse proxy",[33,515,516],{},"Проверяет HTTPS",[15,518,519],{},"Всё в одном диалоге, без переключения в Dashboard.",[136,521,523],{"id":522},"cron-мониторинг","Cron-мониторинг",[15,525,526],{},"Hermes может проверять DNS-записи по расписанию:",[66,528,530],{"className":68,"code":529,"language":70,"meta":71,"style":71},"flarectl dns list --zone example.com --type A\n",[73,531,532],{"__ignoreMap":71},[76,533,534,536,538,541,543,545,547],{"class":78,"line":79},[76,535,63],{"class":82},[76,537,154],{"class":86},[76,539,540],{"class":86}," list",[76,542,161],{"class":160},[76,544,164],{"class":86},[76,546,273],{"class":160},[76,548,549],{"class":86}," A\n",[15,551,552],{},"Если запись изменилась или пропала — агент отправляет уведомление.",[22,554,556],{"id":555},"подводные-камни","Подводные камни",[15,558,559,562],{},[475,560,561],{},"Proxy status."," Если запись не proxied (серое облако), Cloudflare не terminates SSL и не кеширует. Для сервисов, которые должны быть за CDN — всегда proxied.",[15,564,565,568],{},[475,566,567],{},"Cache purge."," После деплоя нового билда Cloudflare может отдавать старый кеш. Очистка:",[66,570,572],{"className":68,"code":571,"language":70,"meta":71,"style":71},"curl -s -X POST \\\n  \"https:\u002F\u002Fapi.cloudflare.com\u002Fclient\u002Fv4\u002Fzones\u002F$ZONE_ID\u002Fpurge_cache\" \\\n  -H \"Authorization: Bearer $CF_API_TOKEN\" \\\n  --data '{\"purge_everything\":true}'\n",[73,573,574,586,599,611],{"__ignoreMap":71},[76,575,576,578,580,582,584],{"class":78,"line":79},[76,577,400],{"class":82},[76,579,403],{"class":160},[76,581,406],{"class":160},[76,583,409],{"class":86},[76,585,167],{"class":160},[76,587,588,591,594,597],{"class":78,"line":117},[76,589,590],{"class":86},"  \"https:\u002F\u002Fapi.cloudflare.com\u002Fclient\u002Fv4\u002Fzones\u002F",[76,592,593],{"class":107},"$ZONE_ID",[76,595,596],{"class":86},"\u002Fpurge_cache\"",[76,598,167],{"class":160},[76,600,601,603,605,607,609],{"class":78,"line":346},[76,602,429],{"class":160},[76,604,432],{"class":86},[76,606,435],{"class":107},[76,608,438],{"class":86},[76,610,167],{"class":160},[76,612,613,615],{"class":78,"line":353},[76,614,454],{"class":160},[76,616,617],{"class":86}," '{\"purge_everything\":true}'\n",[15,619,620,623],{},[475,621,622],{},"Rate limits."," Cloudflare Free план имеет лимиты на API-вызовы. Для автоматизации это редко проблема, но если агент делает десятки запросов в минуту — может словить 429.",[15,625,626,629,630,633,634,637],{},[475,627,628],{},"DNS пропагация."," После создания записи DNS пропагируется за 1-5 минут. Hermes проверяет через ",[73,631,632],{},"dig"," или ",[73,635,636],{},"flarectl dns list"," перед тем, как считать задачу выполненной.",[22,639,641],{"id":640},"итог","Итог",[15,643,644],{},"Cloudflare Free + Hermes Agent = полная автоматизация инфраструктуры из терминала. Никакого Dashboard, никаких ручных кликов. Агент управляет DNS, деплоит статику, настраивает SSL, мониторит записи.",[15,646,647],{},"Для self-hosted проектов это идеальный стек: бесплатно, надёжно, и всё через CLI.",[649,650,651],"style",{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":71,"searchDepth":117,"depth":117,"links":653},[654,655,656,661,662,663,666,667],{"id":24,"depth":117,"text":25},{"id":53,"depth":117,"text":54},{"id":133,"depth":117,"text":134,"children":657},[658,659,660],{"id":138,"depth":346,"text":139},{"id":241,"depth":346,"text":242},{"id":287,"depth":346,"text":288},{"id":315,"depth":117,"text":316},{"id":470,"depth":117,"text":471},{"id":490,"depth":117,"text":491,"children":664},[665],{"id":522,"depth":346,"text":523},{"id":555,"depth":117,"text":556},{"id":640,"depth":117,"text":641},"2026-05-24","Как AI-агент Hermes управляет DNS-записями, деплоит статику на Cloudflare Pages и настраивает SSL — всё через CLI, без Dashboard.","md",{},"\u002Fcloudflare-hermes-integration",null,{"title":6,"description":669},"cloudflare-hermes-integration",[677,678,679,680,681,682],"hermes","cloudflare","dns","devops","automation","cli","nbKjEdlyWOAi4H76Q371RdUB9XnUB-9T_HxrDpUkCIk",{"id":685,"title":686,"body":687,"date":668,"description":1837,"extension":670,"meta":1838,"navigation":342,"path":1839,"readingTime":673,"seo":1840,"stem":1841,"tags":1842,"__hash__":1843},"articles\u002Fholographic-memory-potato-vps.md","Голографическая память для AI-агента на Potato VPS",{"type":8,"value":688,"toc":1808},[689,692,695,698,705,709,712,792,803,806,810,818,821,825,829,832,836,839,843,846,850,853,857,860,953,956,959,969,973,1126,1133,1137,1176,1179,1183,1186,1258,1264,1270,1273,1277,1280,1363,1370,1398,1401,1405,1408,1433,1436,1440,1443,1472,1475,1479,1482,1536,1539,1546,1550,1553,1623,1626,1630,1634,1637,1643,1647,1654,1663,1666,1723,1727,1730,1734,1739,1743,1749,1774,1778,1781,1784,1787,1790,1793,1796,1805],[11,690,686],{"id":691},"голографическая-память-для-ai-агента-на-potato-vps",[15,693,694],{},"У меня есть так называемый Potato VPS — дешёвый сервер с минимумом оперативки. На нём крутится AI-агент — Hermes — который должен помнить контекст между сессиями: факты о пользователях, конфигурации проектов, предпочтения, решения. Не просто «записать в файл и grep'нуть», а смысловой поиск с пониманием перефразировок и мультиязычности.",[15,696,697],{},"Проблема: ChromaDB жрёт 400 МБ просто на старте. Pinecone — это SaaS, а я хочу локально. FAISS — без индексации по ключевым словам. Мне нужен гибрид: полнотекстовый поиск + векторная семантика + композиционная алгебра. И всё это на скромном сервере, включая саму модель эмбеддингов.",[15,699,700,701,704],{},"Решение — плагин ",[73,702,703],{},"holographic-memory",", четыре стратегии поиска в одном SQLite-файле. Вот как это устроено и почему.",[22,706,708],{"id":707},"архитектура-четыре-стратегии-один-запрос","Архитектура: четыре стратегии, один запрос",[15,710,711],{},"Гибридный скоринг — не «векторный поиск с fallback на FTS», а четыре независимых канала, результаты которых складываются с весами:",[713,714,715,734],"table",{},[716,717,718],"thead",{},[719,720,721,725,728,731],"tr",{},[722,723,724],"th",{},"Стратегия",[722,726,727],{},"Вес",[722,729,730],{},"Что делает",[722,732,733],{},"Технология",[735,736,737,752,766,779],"tbody",{},[719,738,739,743,746,749],{},[740,741,742],"td",{},"FTS5",[740,744,745],{},"0.3",[740,747,748],{},"Кандидаты по ключевым словам (BM25)",[740,750,751],{},"SQLite FTS5",[719,753,754,757,760,763],{},[740,755,756],{},"Jaccard",[740,758,759],{},"0.2",[740,761,762],{},"Пересечение токенов",[740,764,765],{},"Python sets",[719,767,768,771,773,776],{},[740,769,770],{},"HRR",[740,772,759],{},[740,774,775],{},"Композиционная алгебра (probe\u002Frelated\u002Freason)",[740,777,778],{},"SHA-256 phase vectors, 1024d",[719,780,781,784,786,789],{},[740,782,783],{},"Semantic",[740,785,745],{},[740,787,788],{},"Смысловое сходство",[740,790,791],{},"fastembed MiniLM-L12-v2, 384d",[15,793,794,795,798,799,802],{},"Финальный скор: ",[73,796,797],{},"relevance × trust_score × temporal_decay",", где ",[73,800,801],{},"relevance = fts×0.3 + jaccard×0.2 + hrr×0.2 + semantic×0.3",".",[15,804,805],{},"Зачем четыре, если можно одним векторным поиском обойтись? Потому что векторный поиск плохо работает на коротких точных запросах («порядок деплоя nginx»), а FTS5 не понимает перефразировок («как выкатить nginx на прод»). HRR даёт алгебраические операции — probe по сущности, связь между фактами, мульти-сущностные JOIN'ы. Jaccard — дешёвый фильтр мусора.",[22,807,809],{"id":808},"запуск-поиска-пайплайн","Запуск поиска: пайплайн",[66,811,816],{"className":812,"code":814,"language":815},[813],"language-text","1. FTS5 MATCH → limit×3 кандидатов (AND-семантика: все термы обязательны)\n2. Если FTS5 пустой + semantic доступен → _semantic_candidates() (полный cosine scan)\n3. Предвычисление эмбеддинга запроса (~46 мс)\n4. Реранжирование: relevance = fts×0.3 + jaccard×0.2 + hrr×0.2 + semantic×0.3\n5. Финал: score = relevance × trust × temporal_decay\n","text",[73,817,814],{"__ignoreMap":71},[15,819,820],{},"Ключевой момент: если FTS5 возвращает 0 (запрос на русском, а факты записаны по-английски, или перефразировка), пайплайн автоматически переключается на чистый семантический поиск. Semantic — не роскошь, а load-bearing компонент для мультиязычности.",[22,822,824],{"id":823},"почему-не-готовые-решения","Почему не готовые решения",[136,826,828],{"id":827},"chromadb","ChromaDB",[15,830,831],{},"Два процесса (Chroma + Hermes), ~800 МБ до того, как агент хоть что-то запомнил. На Potato VPS — это половина RAM. Плюс Chroma тянет за собой HNSW, который строит индекс в памяти. Для фактов (сотни, может тысячи записей) — это стрельба из пушки по воробьям.",[136,833,835],{"id":834},"pinecone","Pinecone",[15,837,838],{},"SaaS. Требует API-ключ, интернет, и доверие к третьей стороне с данными агента. Для хобби-проекта на дешёвом VPS — overkill и vendor lock-in.",[136,840,842],{"id":841},"faiss","FAISS",[15,844,845],{},"Отличная библиотека для векторного поиска, но без полнотекстовой индексации. Придётся поверх городить FTS5 отдельно. А если уж использовать SQLite и для текста, и для векторов — зачем FAISS?",[136,847,849],{"id":848},"своё-решение","Своё решение",[15,851,852],{},"SQLite + FTS5 + WAL — это уже есть в Python stdlib (sqlite3). Добавляем fastembed для эмбеддингов и numpy для HRR-алгебры. Один файл базы, один процесс, zero infrastructure.",[22,854,856],{"id":855},"выбор-модели-mpnet-vs-minilm","Выбор модели: mpnet vs MiniLM",[15,858,859],{},"Я тестировал две модели multilingual fastembed:",[713,861,862,874],{},[716,863,864],{},[719,865,866,868,871],{},[722,867],{},[722,869,870],{},"mpnet-base-v2",[722,872,873],{},"MiniLM-L12-v2",[735,875,876,887,898,909,920,931,942],{},[719,877,878,881,884],{},[740,879,880],{},"Размерность",[740,882,883],{},"768",[740,885,886],{},"384",[719,888,889,892,895],{},[740,890,891],{},"RSS (загружена)",[740,893,894],{},"1440 МБ",[740,896,897],{},"680 МБ",[719,899,900,903,906],{},[740,901,902],{},"RSS (residual после выгрузки)",[740,904,905],{},"693 МБ",[740,907,908],{},"481 МБ",[719,910,911,914,917],{},[740,912,913],{},"Время эмбеддинга",[740,915,916],{},"65 мс",[740,918,919],{},"46 мс",[719,921,922,925,928],{},[740,923,924],{},"Перезагрузка после выгрузки",[740,926,927],{},"20–25 с",[740,929,930],{},"1.33 с",[719,932,933,936,939],{},[740,934,935],{},"sim(\"компактный формат\" ↔ \"лаконичные сообщения\")",[740,937,938],{},"0.625",[740,940,941],{},"0.511",[719,943,944,947,950],{},[740,945,946],{},"sim(\"компактный формат\" ↔ \"погода для прогулки\")",[740,948,949],{},"0.225",[740,951,952],{},"0.008",[15,954,955],{},"MiniLM: 680 МБ RSS, 481 МБ residual. mpnet: 1440 МБ RSS, 693 МБ residual. На Potato VPS mpnet не влезает — после загрузки модели + Hermes + системы остаётся ~300 МБ, и OOM-killer стучится.",[15,957,958],{},"MiniLM даёт 0.511 для семантически схожих фраз и 0.008 для несвязанных — separation достаточная. Не идеальная (mpnet лучше на ~20%), но работоспособная.",[15,960,961,964,965,968],{},[475,962,963],{},"Residual"," — это ONNX Runtime, который не освобождает пуллы памяти даже после ",[73,966,967],{},"del model + gc.collect()",". 481 МБ — плата за один вызов fastembed за жизнь процесса. Поэтому стратегия: lazy-load при первом запросе, держать в памяти до shutdown.",[22,970,972],{"id":971},"lazy-loading-и-lifecycle-модели","Lazy-loading и lifecycle модели",[66,974,978],{"className":975,"code":976,"language":977,"meta":71,"style":71},"language-python shiki shiki-themes github-dark","_model = None\n_available: Optional[bool] = None\n\ndef is_available() -> bool:\n    \"\"\"Проверка без загрузки модели.\"\"\"\n    if _available is not None:\n        return _available\n    try:\n        import fastembed\n        _available = True\n    except ImportError:\n        _available = False\n    return _available\n\ndef _get_model():\n    \"\"\"Lazy-load при первом вызове embed_text().\"\"\"\n    global _model\n    if _model is None:\n        from fastembed import TextEmbedding\n        _model = TextEmbedding(\"sentence-transformers\u002Fparaphrase-multilingual-MiniLM-L12-v2\")\n    return _model\n\ndef embed_text(text: str) -> np.ndarray:\n    model = _get_model()\n    vec = list(model.embed([text]))[0]\n    return vec \u002F np.linalg.norm(vec)  # normalize → unit vector\n","python",[73,979,980,985,990,994,999,1004,1009,1014,1020,1026,1032,1038,1044,1050,1055,1061,1067,1073,1079,1085,1091,1097,1102,1108,1114,1120],{"__ignoreMap":71},[76,981,982],{"class":78,"line":79},[76,983,984],{},"_model = None\n",[76,986,987],{"class":78,"line":117},[76,988,989],{},"_available: Optional[bool] = None\n",[76,991,992],{"class":78,"line":346},[76,993,343],{"emptyLinePlaceholder":342},[76,995,996],{"class":78,"line":353},[76,997,998],{},"def is_available() -> bool:\n",[76,1000,1001],{"class":78,"line":362},[76,1002,1003],{},"    \"\"\"Проверка без загрузки модели.\"\"\"\n",[76,1005,1006],{"class":78,"line":367},[76,1007,1008],{},"    if _available is not None:\n",[76,1010,1011],{"class":78,"line":373},[76,1012,1013],{},"        return _available\n",[76,1015,1017],{"class":78,"line":1016},8,[76,1018,1019],{},"    try:\n",[76,1021,1023],{"class":78,"line":1022},9,[76,1024,1025],{},"        import fastembed\n",[76,1027,1029],{"class":78,"line":1028},10,[76,1030,1031],{},"        _available = True\n",[76,1033,1035],{"class":78,"line":1034},11,[76,1036,1037],{},"    except ImportError:\n",[76,1039,1041],{"class":78,"line":1040},12,[76,1042,1043],{},"        _available = False\n",[76,1045,1047],{"class":78,"line":1046},13,[76,1048,1049],{},"    return _available\n",[76,1051,1053],{"class":78,"line":1052},14,[76,1054,343],{"emptyLinePlaceholder":342},[76,1056,1058],{"class":78,"line":1057},15,[76,1059,1060],{},"def _get_model():\n",[76,1062,1064],{"class":78,"line":1063},16,[76,1065,1066],{},"    \"\"\"Lazy-load при первом вызове embed_text().\"\"\"\n",[76,1068,1070],{"class":78,"line":1069},17,[76,1071,1072],{},"    global _model\n",[76,1074,1076],{"class":78,"line":1075},18,[76,1077,1078],{},"    if _model is None:\n",[76,1080,1082],{"class":78,"line":1081},19,[76,1083,1084],{},"        from fastembed import TextEmbedding\n",[76,1086,1088],{"class":78,"line":1087},20,[76,1089,1090],{},"        _model = TextEmbedding(\"sentence-transformers\u002Fparaphrase-multilingual-MiniLM-L12-v2\")\n",[76,1092,1094],{"class":78,"line":1093},21,[76,1095,1096],{},"    return _model\n",[76,1098,1100],{"class":78,"line":1099},22,[76,1101,343],{"emptyLinePlaceholder":342},[76,1103,1105],{"class":78,"line":1104},23,[76,1106,1107],{},"def embed_text(text: str) -> np.ndarray:\n",[76,1109,1111],{"class":78,"line":1110},24,[76,1112,1113],{},"    model = _get_model()\n",[76,1115,1117],{"class":78,"line":1116},25,[76,1118,1119],{},"    vec = list(model.embed([text]))[0]\n",[76,1121,1123],{"class":78,"line":1122},26,[76,1124,1125],{},"    return vec \u002F np.linalg.norm(vec)  # normalize → unit vector\n",[15,1127,1128,1129,1132],{},"Первый вызов ",[73,1130,1131],{},"embed_text()"," — ~1.3 с (загрузка ONNX-модели). Все последующие — ~46 мс. Модель живёт в памяти до shutdown.",[136,1134,1136],{"id":1135},"выгрузка-при-shutdown","Выгрузка при shutdown",[66,1138,1140],{"className":975,"code":1139,"language":977,"meta":71,"style":71},"# В __init__.py плагина, на shutdown hook:\nimport embedder, gc\n\ndef on_shutdown():\n    embedder._model = None\n    gc.collect()\n    # Освобождает ~200 МБ (веса модели), но ONNX residual (481 МБ) остаётся\n",[73,1141,1142,1147,1152,1156,1161,1166,1171],{"__ignoreMap":71},[76,1143,1144],{"class":78,"line":79},[76,1145,1146],{},"# В __init__.py плагина, на shutdown hook:\n",[76,1148,1149],{"class":78,"line":117},[76,1150,1151],{},"import embedder, gc\n",[76,1153,1154],{"class":78,"line":346},[76,1155,343],{"emptyLinePlaceholder":342},[76,1157,1158],{"class":78,"line":353},[76,1159,1160],{},"def on_shutdown():\n",[76,1162,1163],{"class":78,"line":362},[76,1164,1165],{},"    embedder._model = None\n",[76,1167,1168],{"class":78,"line":367},[76,1169,1170],{},"    gc.collect()\n",[76,1172,1173],{"class":78,"line":373},[76,1174,1175],{},"    # Освобождает ~200 МБ (веса модели), но ONNX residual (481 МБ) остаётся\n",[15,1177,1178],{},"На практике: после shutdown RSS падает с ~1.1 ГБ до ~620 МБ. ONNX Runtime не отдаёт память — это известная особенность. 620 МБ — рабочий baseline для Potato VPS.",[22,1180,1182],{"id":1181},"хранение-два-вектора-на-факт","Хранение: два вектора на факт",[15,1184,1185],{},"Каждый факт в SQLite хранит два вектора:",[66,1187,1191],{"className":1188,"code":1189,"language":1190,"meta":71,"style":71},"language-sql shiki shiki-themes github-dark","CREATE TABLE facts (\n    fact_id INTEGER PRIMARY KEY,\n    content TEXT NOT NULL,\n    category TEXT DEFAULT 'general',\n    tags TEXT DEFAULT '',\n    trust_score REAL DEFAULT 0.5,\n    retrieval_count INTEGER DEFAULT 0,\n    helpful_count INTEGER DEFAULT 0,\n    hrr_vector BLOB,        -- 1024 × float64 = 8192 bytes\n    semantic_vector BLOB,   -- 384 × float32 = 1536 bytes\n    created_at TEXT,\n    updated_at TEXT\n);\n","sql",[73,1192,1193,1198,1203,1208,1213,1218,1223,1228,1233,1238,1243,1248,1253],{"__ignoreMap":71},[76,1194,1195],{"class":78,"line":79},[76,1196,1197],{},"CREATE TABLE facts (\n",[76,1199,1200],{"class":78,"line":117},[76,1201,1202],{},"    fact_id INTEGER PRIMARY KEY,\n",[76,1204,1205],{"class":78,"line":346},[76,1206,1207],{},"    content TEXT NOT NULL,\n",[76,1209,1210],{"class":78,"line":353},[76,1211,1212],{},"    category TEXT DEFAULT 'general',\n",[76,1214,1215],{"class":78,"line":362},[76,1216,1217],{},"    tags TEXT DEFAULT '',\n",[76,1219,1220],{"class":78,"line":367},[76,1221,1222],{},"    trust_score REAL DEFAULT 0.5,\n",[76,1224,1225],{"class":78,"line":373},[76,1226,1227],{},"    retrieval_count INTEGER DEFAULT 0,\n",[76,1229,1230],{"class":78,"line":1016},[76,1231,1232],{},"    helpful_count INTEGER DEFAULT 0,\n",[76,1234,1235],{"class":78,"line":1022},[76,1236,1237],{},"    hrr_vector BLOB,        -- 1024 × float64 = 8192 bytes\n",[76,1239,1240],{"class":78,"line":1028},[76,1241,1242],{},"    semantic_vector BLOB,   -- 384 × float32 = 1536 bytes\n",[76,1244,1245],{"class":78,"line":1034},[76,1246,1247],{},"    created_at TEXT,\n",[76,1249,1250],{"class":78,"line":1040},[76,1251,1252],{},"    updated_at TEXT\n",[76,1254,1255],{"class":78,"line":1046},[76,1256,1257],{},");\n",[15,1259,1260,1263],{},[475,1261,1262],{},"HRR-вектор"," (8 КБ на факт) — SHA-256 phase encoding. Токены контента → бандл атомов → bind с ROLE_CONTENT и ROLE_ENTITY. Используется для алгебраических операций: probe («все факты про X»), related («что связано с X»), reason («что общего у X, Y, Z»). Работает на numpy, без модели.",[15,1265,1266,1269],{},[475,1267,1268],{},"Semantic-вектор"," (1.5 КБ на факт) — выход fastembed. Нормализованный unit vector для cosine similarity. Используется в гибридном скоринге.",[15,1271,1272],{},"Итого: ~9.7 КБ на факт. Для 1000 фактов — ~10 МБ. Пустяк.",[22,1274,1276],{"id":1275},"hrr-композиционная-алгебра-без-нейросетей","HRR: композиционная алгебра без нейросетей",[15,1278,1279],{},"HRR (Holographic Reduced Representations) — это способ кодировать структуру в фиксированный вектор. Вместо обучения — SHA-256 хеши от токенов, преобразованные в phase vectors.",[66,1281,1283],{"className":975,"code":1282,"language":977,"meta":71,"style":71},"def encode_atom(token: str, dim: int = 1024) -> np.ndarray:\n    \"\"\"Токен → единичный вектор в phase space.\"\"\"\n    h = hashlib.sha256(token.encode()).digest()\n    rng = np.frombuffer(h, dtype=np.uint8).astype(np.float64)\n    # Фазовый код: cos + j*sin → unit vector в комплексном пространстве\n    phases = rng[:dim] \u002F 255.0 * 2 * np.pi\n    return np.cos(phases) + 1j * np.sin(phases)\n\ndef bind(a, b):\n    \"\"\"Связывание: circular convolution в frequency domain.\"\"\"\n    return np.fft.ifft(np.fft.fft(a) * np.fft.fft(b))\n\ndef bundle(vectors):\n    \"\"\"Бандлинг: поэлементная сумма + нормализация.\"\"\"\n    result = np.sum(vectors, axis=0)\n    return result \u002F np.linalg.norm(result)\n",[73,1284,1285,1290,1295,1300,1305,1310,1315,1320,1324,1329,1334,1339,1343,1348,1353,1358],{"__ignoreMap":71},[76,1286,1287],{"class":78,"line":79},[76,1288,1289],{},"def encode_atom(token: str, dim: int = 1024) -> np.ndarray:\n",[76,1291,1292],{"class":78,"line":117},[76,1293,1294],{},"    \"\"\"Токен → единичный вектор в phase space.\"\"\"\n",[76,1296,1297],{"class":78,"line":346},[76,1298,1299],{},"    h = hashlib.sha256(token.encode()).digest()\n",[76,1301,1302],{"class":78,"line":353},[76,1303,1304],{},"    rng = np.frombuffer(h, dtype=np.uint8).astype(np.float64)\n",[76,1306,1307],{"class":78,"line":362},[76,1308,1309],{},"    # Фазовый код: cos + j*sin → unit vector в комплексном пространстве\n",[76,1311,1312],{"class":78,"line":367},[76,1313,1314],{},"    phases = rng[:dim] \u002F 255.0 * 2 * np.pi\n",[76,1316,1317],{"class":78,"line":373},[76,1318,1319],{},"    return np.cos(phases) + 1j * np.sin(phases)\n",[76,1321,1322],{"class":78,"line":1016},[76,1323,343],{"emptyLinePlaceholder":342},[76,1325,1326],{"class":78,"line":1022},[76,1327,1328],{},"def bind(a, b):\n",[76,1330,1331],{"class":78,"line":1028},[76,1332,1333],{},"    \"\"\"Связывание: circular convolution в frequency domain.\"\"\"\n",[76,1335,1336],{"class":78,"line":1034},[76,1337,1338],{},"    return np.fft.ifft(np.fft.fft(a) * np.fft.fft(b))\n",[76,1340,1341],{"class":78,"line":1040},[76,1342,343],{"emptyLinePlaceholder":342},[76,1344,1345],{"class":78,"line":1046},[76,1346,1347],{},"def bundle(vectors):\n",[76,1349,1350],{"class":78,"line":1052},[76,1351,1352],{},"    \"\"\"Бандлинг: поэлементная сумма + нормализация.\"\"\"\n",[76,1354,1355],{"class":78,"line":1057},[76,1356,1357],{},"    result = np.sum(vectors, axis=0)\n",[76,1359,1360],{"class":78,"line":1063},[76,1361,1362],{},"    return result \u002F np.linalg.norm(result)\n",[15,1364,1365,1366,1369],{},"Зачем это нужно, если есть fastembed? Потому что HRR поддерживает ",[475,1367,1368],{},"алгебраические операции",", которые векторные модели не умеют:",[1371,1372,1373,1379,1385],"ul",{},[33,1374,1375,1378],{},[475,1376,1377],{},"probe(entity)"," — «дай все факты, привязанные к сущности X». Bind\u002Funbind с entity-атомом.",[33,1380,1381,1384],{},[475,1382,1383],{},"related(entity)"," — «что структурно соседствует с X». Через HRR similarity.",[33,1386,1387,1394,1395,802],{},[475,1388,1389,1390,1393],{},"reason(",[76,1391,1392],{},"e1, e2, e3",")"," — «что общего у нескольких сущностей». Multi-entity JOIN, ",[73,1396,1397],{},"min(scores)",[15,1399,1400],{},"Semantic-модель даёт «похожесть по смыслу», HRR — «связанность по структуре». Это разные оси.",[22,1402,1404],{"id":1403},"jaccard-дешёвый-фильтр","Jaccard: дешёвый фильтр",[15,1406,1407],{},"Jaccard — пересечение токенов запроса и факта, поделённое на объединение. Стоимость: O(n) по токенам, ноль выделений памяти. Работает как грубый фильтр: если запрос и факт не пересекаются ни одним токеном — штраф.",[66,1409,1411],{"className":975,"code":1410,"language":977,"meta":71,"style":71},"def jaccard(query_tokens: set, fact_tokens: set) -> float:\n    if not query_tokens or not fact_tokens:\n        return 0.0\n    return len(query_tokens & fact_tokens) \u002F len(query_tokens | fact_tokens)\n",[73,1412,1413,1418,1423,1428],{"__ignoreMap":71},[76,1414,1415],{"class":78,"line":79},[76,1416,1417],{},"def jaccard(query_tokens: set, fact_tokens: set) -> float:\n",[76,1419,1420],{"class":78,"line":117},[76,1421,1422],{},"    if not query_tokens or not fact_tokens:\n",[76,1424,1425],{"class":78,"line":346},[76,1426,1427],{},"        return 0.0\n",[76,1429,1430],{"class":78,"line":353},[76,1431,1432],{},"    return len(query_tokens & fact_tokens) \u002F len(query_tokens | fact_tokens)\n",[15,1434,1435],{},"Вес 0.2 — не главный канал, но отсекает шум. На практике: запрос «деплой nginx» и факт «настройка DNS» получают jaccard=0, и это правильно.",[22,1437,1439],{"id":1438},"fts5-полнотекстовая-индексация","FTS5: полнотекстовая индексация",[15,1441,1442],{},"SQLite FTS5 — встроенная полнотекстовая индексация. AND-семантика: все слова запроса должны присутствовать в факте. Это жёстко, но предсказуемо.",[66,1444,1446],{"className":1188,"code":1445,"language":1190,"meta":71,"style":71},"CREATE VIRTUAL TABLE facts_fts USING fts5(content, content=facts, content_rowid=fact_id);\n\n-- Поиск:\nSELECT fact_id, rank FROM facts_fts WHERE facts_fts MATCH 'деплой nginx'\nORDER BY rank LIMIT 30;\n",[73,1447,1448,1453,1457,1462,1467],{"__ignoreMap":71},[76,1449,1450],{"class":78,"line":79},[76,1451,1452],{},"CREATE VIRTUAL TABLE facts_fts USING fts5(content, content=facts, content_rowid=fact_id);\n",[76,1454,1455],{"class":78,"line":117},[76,1456,343],{"emptyLinePlaceholder":342},[76,1458,1459],{"class":78,"line":346},[76,1460,1461],{},"-- Поиск:\n",[76,1463,1464],{"class":78,"line":353},[76,1465,1466],{},"SELECT fact_id, rank FROM facts_fts WHERE facts_fts MATCH 'деплой nginx'\n",[76,1468,1469],{"class":78,"line":362},[76,1470,1471],{},"ORDER BY rank LIMIT 30;\n",[15,1473,1474],{},"Проблема FTS5: не понимает перефразировок. «Как выкатить nginx на прод» не матчит «деплой nginx через CI\u002FCD». Поэтому fallback на semantic search при пустом FTS5 — критичен.",[22,1476,1478],{"id":1477},"стоимость-на-potato-vps","Стоимость на Potato VPS",[15,1480,1481],{},"Типичный memory footprint при работающем агенте:",[713,1483,1484,1494],{},[716,1485,1486],{},[719,1487,1488,1491],{},[722,1489,1490],{},"Компонент",[722,1492,1493],{},"RSS",[735,1495,1496,1504,1512,1520,1528],{},[719,1497,1498,1501],{},[740,1499,1500],{},"Hermes core (Python)",[740,1502,1503],{},"~380 МБ",[719,1505,1506,1509],{},[740,1507,1508],{},"fastembed (загружена)",[740,1510,1511],{},"+300 МБ",[719,1513,1514,1517],{},[740,1515,1516],{},"ONNX Runtime residual",[740,1518,1519],{},"(включено выше)",[719,1521,1522,1525],{},[740,1523,1524],{},"SQLite + FTS5 index",[740,1526,1527],{},"~5 МБ",[719,1529,1530,1533],{},[740,1531,1532],{},"Итого",[740,1534,1535],{},"~680 МБ",[15,1537,1538],{},"Остаётся достаточно для системы, swap, и других процессов. Комфортно.",[15,1540,1541,1542,1545],{},"При shutdown модель выгружается, RSS падает до ~620 МБ. ONNX residual не освобождается — это цена одного ",[73,1543,1544],{},"import fastembed"," за жизнь процесса.",[22,1547,1549],{"id":1548},"weight-auto-redistribution","Weight auto-redistribution",[15,1551,1552],{},"Если fastembed недоступна (не установлена, или numpy отсутствует), веса перераспределяются автоматически:",[66,1554,1556],{"className":975,"code":1555,"language":977,"meta":71,"style":71},"def _redistribute_weights(self):\n    if not embedder.is_available():\n        # Semantic недоступен: 0.3 → FTS +0.15, Jaccard +0.1, HRR +0.05\n        self.fts_weight = 0.45\n        self.jaccard_weight = 0.30\n        self.hrr_weight = 0.25\n        self.semantic_weight = 0.0\n    elif not _HAS_NUMPY:\n        # HRR недоступен: 0.2 → FTS +0.1, Semantic +0.1\n        self.fts_weight = 0.40\n        self.jaccard_weight = 0.20\n        self.hrr_weight = 0.0\n        self.semantic_weight = 0.40\n",[73,1557,1558,1563,1568,1573,1578,1583,1588,1593,1598,1603,1608,1613,1618],{"__ignoreMap":71},[76,1559,1560],{"class":78,"line":79},[76,1561,1562],{},"def _redistribute_weights(self):\n",[76,1564,1565],{"class":78,"line":117},[76,1566,1567],{},"    if not embedder.is_available():\n",[76,1569,1570],{"class":78,"line":346},[76,1571,1572],{},"        # Semantic недоступен: 0.3 → FTS +0.15, Jaccard +0.1, HRR +0.05\n",[76,1574,1575],{"class":78,"line":353},[76,1576,1577],{},"        self.fts_weight = 0.45\n",[76,1579,1580],{"class":78,"line":362},[76,1581,1582],{},"        self.jaccard_weight = 0.30\n",[76,1584,1585],{"class":78,"line":367},[76,1586,1587],{},"        self.hrr_weight = 0.25\n",[76,1589,1590],{"class":78,"line":373},[76,1591,1592],{},"        self.semantic_weight = 0.0\n",[76,1594,1595],{"class":78,"line":1016},[76,1596,1597],{},"    elif not _HAS_NUMPY:\n",[76,1599,1600],{"class":78,"line":1022},[76,1601,1602],{},"        # HRR недоступен: 0.2 → FTS +0.1, Semantic +0.1\n",[76,1604,1605],{"class":78,"line":1028},[76,1606,1607],{},"        self.fts_weight = 0.40\n",[76,1609,1610],{"class":78,"line":1034},[76,1611,1612],{},"        self.jaccard_weight = 0.20\n",[76,1614,1615],{"class":78,"line":1040},[76,1616,1617],{},"        self.hrr_weight = 0.0\n",[76,1619,1620],{"class":78,"line":1046},[76,1621,1622],{},"        self.semantic_weight = 0.40\n",[15,1624,1625],{},"Graceful degradation: система работает и без эмбеддингов (FTS + Jaccard), и без HRR (FTS + Jaccard + Semantic). Но полная четвёрка — оптимальный режим.",[22,1627,1629],{"id":1628},"практические-грабли","Практические грабли",[136,1631,1633],{"id":1632},"_1-fts5-and-слишком-строго","1. FTS5 AND — слишком строго",[15,1635,1636],{},"Запрос «компактный формат сообщений» требует наличия всех трёх слов. Если факт записан как «лаконичные ответы» — FTS5 молчит. Semantic спасает, но только если модель загружена.",[15,1638,1639,1642],{},[475,1640,1641],{},"Решение:"," не полагаться на FTS5 как единственный канал. Всегда держать semantic включённым.",[136,1644,1646],{"id":1645},"_2-missing-semantic-vectors-после-обновления","2. Missing semantic vectors после обновления",[15,1648,1649,1650,1653],{},"Агент обновился, но работает старый процесс. Новые факты записываются без ",[73,1651,1652],{},"semantic_vector",". Симптом: русские запросы возвращают пустой результат.",[66,1655,1657],{"className":1188,"code":1656,"language":1190,"meta":71,"style":71},"SELECT COUNT(*) FROM facts WHERE semantic_vector IS NULL;\n",[73,1658,1659],{"__ignoreMap":71},[76,1660,1661],{"class":78,"line":79},[76,1662,1656],{},[15,1664,1665],{},"Если > 0 — запустить backfill:",[66,1667,1669],{"className":975,"code":1668,"language":977,"meta":71,"style":71},"import embedder, sqlite3\n\nconn = sqlite3.connect(db_path)  # путь к вашей базе\nrows = conn.execute('SELECT fact_id, content FROM facts WHERE semantic_vector IS NULL').fetchall()\n\nfor fid, content in rows:\n    vec = embedder.embed_text(content)\n    conn.execute('UPDATE facts SET semantic_vector = ? WHERE fact_id = ?',\n                 (embedder.vector_to_bytes(vec), fid))\n\nconn.commit()\n",[73,1670,1671,1676,1680,1685,1690,1694,1699,1704,1709,1714,1718],{"__ignoreMap":71},[76,1672,1673],{"class":78,"line":79},[76,1674,1675],{},"import embedder, sqlite3\n",[76,1677,1678],{"class":78,"line":117},[76,1679,343],{"emptyLinePlaceholder":342},[76,1681,1682],{"class":78,"line":346},[76,1683,1684],{},"conn = sqlite3.connect(db_path)  # путь к вашей базе\n",[76,1686,1687],{"class":78,"line":353},[76,1688,1689],{},"rows = conn.execute('SELECT fact_id, content FROM facts WHERE semantic_vector IS NULL').fetchall()\n",[76,1691,1692],{"class":78,"line":362},[76,1693,343],{"emptyLinePlaceholder":342},[76,1695,1696],{"class":78,"line":367},[76,1697,1698],{},"for fid, content in rows:\n",[76,1700,1701],{"class":78,"line":373},[76,1702,1703],{},"    vec = embedder.embed_text(content)\n",[76,1705,1706],{"class":78,"line":1016},[76,1707,1708],{},"    conn.execute('UPDATE facts SET semantic_vector = ? WHERE fact_id = ?',\n",[76,1710,1711],{"class":78,"line":1022},[76,1712,1713],{},"                 (embedder.vector_to_bytes(vec), fid))\n",[76,1715,1716],{"class":78,"line":1028},[76,1717,343],{"emptyLinePlaceholder":342},[76,1719,1720],{"class":78,"line":1034},[76,1721,1722],{},"conn.commit()\n",[136,1724,1726],{"id":1725},"_3-fastembed-pooling-change","3. fastembed pooling change",[15,1728,1729],{},"Версия 0.8.0+ меняет pooling с CLS на mean. Старые векторы несовместимы с новыми. Решение: полный re-embed всех фактов после обновления fastembed.",[136,1731,1733],{"id":1732},"_4-onnx-memory-leak-это-не-leak","4. ONNX memory leak (это не leak)",[15,1735,1736,1738],{},[73,1737,967],{}," освобождает веса, но не ONNX Runtime pools. Это не утечка — так устроен ONNX. 481 МБ residual — норма. Не пытайтесь «починить».",[136,1740,1742],{"id":1741},"_5-rss-used-memory","5. RSS ≠ used memory",[15,1744,1745,1748],{},[73,1746,1747],{},"ru_maxrss"," показывает пик, а не текущее потребление. Для точного измерения:",[66,1750,1752],{"className":975,"code":1751,"language":977,"meta":71,"style":71},"def current_rss_mb():\n    with open('\u002Fproc\u002Fself\u002Fstatm') as f:\n        pages = int(f.read().split()[1])\n    return pages * 4096 \u002F 1024 \u002F 1024\n",[73,1753,1754,1759,1764,1769],{"__ignoreMap":71},[76,1755,1756],{"class":78,"line":79},[76,1757,1758],{},"def current_rss_mb():\n",[76,1760,1761],{"class":78,"line":117},[76,1762,1763],{},"    with open('\u002Fproc\u002Fself\u002Fstatm') as f:\n",[76,1765,1766],{"class":78,"line":346},[76,1767,1768],{},"        pages = int(f.read().split()[1])\n",[76,1770,1771],{"class":78,"line":353},[76,1772,1773],{},"    return pages * 4096 \u002F 1024 \u002F 1024\n",[22,1775,1777],{"id":1776},"web-интерфейс-для-просмотра-фактов","Web-интерфейс для просмотра фактов",[15,1779,1780],{},"Для отладки я сделал standalone htmx-приложение на stdlib http.server. Тёмная тема, моноширинный шрифт, FTS5-поиск, inline-редактирование, кнопки feedback, цветовые бейджи по категориям. Zero dependencies — только Python stdlib. Запускается одной командой, слушает на локальном порту.",[22,1782,1532],{"id":1783},"итого",[15,1785,1786],{},"Четыре стратегии поиска в одном SQLite-файле. 680 МБ RSS при работающей модели. Lazy-load, graceful degradation, выгрузка при shutdown. Никаких внешних сервисов, никаких docker-compose, никаких Pinecone API-ключей.",[15,1788,1789],{},"Для хобби-проекта на Potato VPS — это единственный адекватный вариант. Не потому что «лучше ChromaDB», а потому что ChromaDB не влезает в скромный объём RAM, а Pinecone — это чужой компьютер.",[15,1791,1792],{},"Свой плагин на SQLite — это контроль. Контроль над памятью, над индексацией, над lifecycle модели. И когда в 3 часа ночи OOM-killer стучится — ты точно знаешь, кто виноват и что делать.",[1794,1795],"hr",{},[15,1797,1798],{},[1799,1800,1801,1802,1804],"em",{},"Плагин ",[73,1803,703],{}," — часть проекта Hermes Agent.",[649,1806,1807],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":71,"searchDepth":117,"depth":117,"links":1809},[1810,1811,1812,1818,1819,1822,1823,1824,1825,1826,1827,1828,1835,1836],{"id":707,"depth":117,"text":708},{"id":808,"depth":117,"text":809},{"id":823,"depth":117,"text":824,"children":1813},[1814,1815,1816,1817],{"id":827,"depth":346,"text":828},{"id":834,"depth":346,"text":835},{"id":841,"depth":346,"text":842},{"id":848,"depth":346,"text":849},{"id":855,"depth":117,"text":856},{"id":971,"depth":117,"text":972,"children":1820},[1821],{"id":1135,"depth":346,"text":1136},{"id":1181,"depth":117,"text":1182},{"id":1275,"depth":117,"text":1276},{"id":1403,"depth":117,"text":1404},{"id":1438,"depth":117,"text":1439},{"id":1477,"depth":117,"text":1478},{"id":1548,"depth":117,"text":1549},{"id":1628,"depth":117,"text":1629,"children":1829},[1830,1831,1832,1833,1834],{"id":1632,"depth":346,"text":1633},{"id":1645,"depth":346,"text":1646},{"id":1725,"depth":346,"text":1726},{"id":1732,"depth":346,"text":1733},{"id":1741,"depth":346,"text":1742},{"id":1776,"depth":117,"text":1777},{"id":1783,"depth":117,"text":1532},"Как запустить гибридный поиск (FTS5 + Jaccard + HRR + fastembed MiniLM-L12-v2) на дешёвом VPS. Lazy-loading модели, ~680 MB RSS, выгрузка при shutdown. Почему не ChromaDB и не Pinecone — а свой плагин на SQLite.",{},"\u002Fholographic-memory-potato-vps",{"title":686,"description":1837},"holographic-memory-potato-vps",[1841],"s12YtL-Ec4OonqILge01fTqBhCWP8c39NAx6mx-FmO4",{"id":1845,"title":1846,"body":1847,"date":668,"description":3104,"extension":670,"meta":3105,"navigation":342,"path":3106,"readingTime":673,"seo":3107,"stem":3108,"tags":3109,"__hash__":3114},"articles\u002Fself-hosted-ntfy-notifications.md","Самостоятельный ntfy: свой push-сервер уведомлений",{"type":8,"value":1848,"toc":3068},[1849,1852,1855,1864,1868,1871,1889,1900,1904,1911,2000,2003,2015,2027,2035,2045,2060,2064,2077,2108,2115,2119,2125,2131,2134,2162,2166,2169,2190,2197,2200,2216,2226,2230,2233,2265,2268,2298,2301,2356,2360,2363,2378,2381,2395,2398,2415,2422,2426,2429,2434,2552,2558,2571,2575,2579,2592,2596,2599,2605,2608,2612,2626,2630,2633,2665,2668,2672,2676,2689,2695,2755,2759,2762,2767,2802,2806,2809,2832,2835,2839,2842,2856,2859,2871,2875,2879,2882,2932,2936,2946,2950,2953,2966,2970,2973,3007,3011,3015,3018,3022,3025,3029,3032,3036,3039,3043,3050,3053,3055,3062,3065],[11,1850,1846],{"id":1851},"самостоятельный-ntfy-свой-push-сервер-уведомлений",[15,1853,1854],{},"На моём сервере крутится AI-агент (Hermes), мониторинг, блог, и куча мелких сервисов. Мне нужно было получать уведомления от всего этого хозяйства на телефон — не через Telegram Bot API (с его rate limit и зависимостью от серверов Telegram), а через свой канал, который я контролирую.",[15,1856,1857,1858,1863],{},"Решение — ",[58,1859,1862],{"href":1860,"rel":1861},"https:\u002F\u002Fntfy.sh",[62],"ntfy",": лёгкий Go-сервер, HTTP pub-sub, Android\u002FiOS приложения, и zero vendor lock-in. Вот как я его поднял и к чему пришёл.",[22,1865,1867],{"id":1866},"установка","Установка",[15,1869,1870],{},"На Debian\u002FUbuntu всё предельно просто:",[66,1872,1874],{"className":68,"code":1873,"language":70,"meta":71,"style":71},"apt-get install -y ntfy\n",[73,1875,1876],{"__ignoreMap":71},[76,1877,1878,1881,1883,1886],{"class":78,"line":79},[76,1879,1880],{"class":82},"apt-get",[76,1882,87],{"class":86},[76,1884,1885],{"class":160}," -y",[76,1887,1888],{"class":86}," ntfy\n",[15,1890,1891,1892,1895,1896,1899],{},"Пакет создаёт пользователя ",[73,1893,1894],{},"_ntfy",", systemd-сервис и конфиг по умолчанию. Но «по умолчанию» — это listen на ",[73,1897,1898],{},":80"," и без авторизации. Для production нужно поменять.",[22,1901,1903],{"id":1902},"конфигурация","Конфигурация",[15,1905,1906,1907,1910],{},"Файл ",[73,1908,1909],{},"\u002Fetc\u002Fntfy\u002Fserver.yml",":",[66,1912,1916],{"className":1913,"code":1914,"language":1915,"meta":71,"style":71},"language-yaml shiki shiki-themes github-dark","base-url: \"https:\u002F\u002Fntfy.example.com\"\nlisten-http: \"127.0.0.1:8080\"\nbehind-proxy: true\ncache-file: \u002Fvar\u002Fcache\u002Fntfy\u002Fcache.db\ncache-duration: \"24h\"\nauth-file: \u002Fvar\u002Flib\u002Fntfy\u002Fuser.db\nauth-default-access: \"deny-all\"\nlog-level: info\n","yaml",[73,1917,1918,1930,1940,1950,1960,1970,1980,1990],{"__ignoreMap":71},[76,1919,1920,1924,1927],{"class":78,"line":79},[76,1921,1923],{"class":1922},"s4JwU","base-url",[76,1925,1926],{"class":107},": ",[76,1928,1929],{"class":86},"\"https:\u002F\u002Fntfy.example.com\"\n",[76,1931,1932,1935,1937],{"class":78,"line":117},[76,1933,1934],{"class":1922},"listen-http",[76,1936,1926],{"class":107},[76,1938,1939],{"class":86},"\"127.0.0.1:8080\"\n",[76,1941,1942,1945,1947],{"class":78,"line":346},[76,1943,1944],{"class":1922},"behind-proxy",[76,1946,1926],{"class":107},[76,1948,1949],{"class":160},"true\n",[76,1951,1952,1955,1957],{"class":78,"line":353},[76,1953,1954],{"class":1922},"cache-file",[76,1956,1926],{"class":107},[76,1958,1959],{"class":86},"\u002Fvar\u002Fcache\u002Fntfy\u002Fcache.db\n",[76,1961,1962,1965,1967],{"class":78,"line":362},[76,1963,1964],{"class":1922},"cache-duration",[76,1966,1926],{"class":107},[76,1968,1969],{"class":86},"\"24h\"\n",[76,1971,1972,1975,1977],{"class":78,"line":367},[76,1973,1974],{"class":1922},"auth-file",[76,1976,1926],{"class":107},[76,1978,1979],{"class":86},"\u002Fvar\u002Flib\u002Fntfy\u002Fuser.db\n",[76,1981,1982,1985,1987],{"class":78,"line":373},[76,1983,1984],{"class":1922},"auth-default-access",[76,1986,1926],{"class":107},[76,1988,1989],{"class":86},"\"deny-all\"\n",[76,1991,1992,1995,1997],{"class":78,"line":1016},[76,1993,1994],{"class":1922},"log-level",[76,1996,1926],{"class":107},[76,1998,1999],{"class":86},"info\n",[15,2001,2002],{},"Разберу ключевые моменты:",[15,2004,2005,2010,2011,2014],{},[475,2006,2007],{},[73,2008,2009],{},"listen-http: \"127.0.0.1:8080\""," — слушаем только на localhost. Весь внешний трафик идёт через reverse proxy. Если поставить ",[73,2012,2013],{},"0.0.0.0:80",", ntfy будет доступен напрямую, минуя SSL и rate limiting.",[15,2016,2017,2022,2023,2026],{},[475,2018,2019],{},[73,2020,2021],{},"behind-proxy: true"," — обязательно, если вы за Caddy\u002Fnginx. Без этого ntfy не увидит реальные IP-адреса подписчиков (будет видеть ",[73,2024,2025],{},"127.0.0.1","), и rate limiting не будет работать корректно.",[15,2028,2029,2034],{},[475,2030,2031],{},[73,2032,2033],{},"auth-default-access: \"deny-all\""," — по умолчанию всё закрыто. Без этого любой, кто угадает имя топика, может читать и писать в него. Для публичного ntfy.sh это нормально, для self-hosted — нет.",[15,2036,2037,2040,2041,2044],{},[475,2038,2039],{},"Питфолл",": не пишите конфиг через ",[73,2042,2043],{},"cat > \u002Fetc\u002Fntfy\u002Fserver.yml"," из терминала — некоторые security-сканеры блокируют heredoc в bash. Используйте Python:",[66,2046,2048],{"className":975,"code":2047,"language":977,"meta":71,"style":71},"import pathlib\npathlib.Path('\u002Fetc\u002Fntfy\u002Fserver.yml').write_text(config_yaml)\n",[73,2049,2050,2055],{"__ignoreMap":71},[76,2051,2052],{"class":78,"line":79},[76,2053,2054],{},"import pathlib\n",[76,2056,2057],{"class":78,"line":117},[76,2058,2059],{},"pathlib.Path('\u002Fetc\u002Fntfy\u002Fserver.yml').write_text(config_yaml)\n",[22,2061,2063],{"id":2062},"права-на-директории","Права на директории",[15,2065,2066,2067,2069,2070,2073,2074,1910],{},"Пакетный ",[73,2068,1894],{}," пользователь должен иметь права на запись в ",[73,2071,2072],{},"\u002Fvar\u002Fcache\u002Fntfy"," и ",[73,2075,2076],{},"\u002Fvar\u002Flib\u002Fntfy",[66,2078,2080],{"className":68,"code":2079,"language":70,"meta":71,"style":71},"mkdir -p \u002Fvar\u002Fcache\u002Fntfy \u002Fvar\u002Flib\u002Fntfy\nchown _ntfy:_ntfy \u002Fvar\u002Fcache\u002Fntfy \u002Fvar\u002Flib\u002Fntfy\n",[73,2081,2082,2096],{"__ignoreMap":71},[76,2083,2084,2087,2090,2093],{"class":78,"line":79},[76,2085,2086],{"class":82},"mkdir",[76,2088,2089],{"class":160}," -p",[76,2091,2092],{"class":86}," \u002Fvar\u002Fcache\u002Fntfy",[76,2094,2095],{"class":86}," \u002Fvar\u002Flib\u002Fntfy\n",[76,2097,2098,2101,2104,2106],{"class":78,"line":117},[76,2099,2100],{"class":82},"chown",[76,2102,2103],{"class":86}," _ntfy:_ntfy",[76,2105,2092],{"class":86},[76,2107,2095],{"class":86},[15,2109,2110,2111,2114],{},"Без этого сервис упадёт с ",[73,2112,2113],{},"\"unable to open database file\"",". Классика.",[22,2116,2118],{"id":2117},"caddy-reverse-proxy","Caddy reverse proxy",[15,2120,2121,2122,2124],{},"У меня Caddy слушает на ",[73,2123,1898],{}," (Cloudflare handles SSL termination снаружи). Конфиг для ntfy:",[66,2126,2129],{"className":2127,"code":2128,"language":815},[813],"@ntfy host ntfy.example.com\nhandle @ntfy {\n    reverse_proxy localhost:8080\n}\n",[73,2130,2128],{"__ignoreMap":71},[15,2132,2133],{},"Два нюанса:",[30,2135,2136,2149],{},[33,2137,2138,2141,2142,2073,2145,2148],{},[475,2139,2140],{},"WebSocket"," — ntfy использует WebSocket для real-time подписок. Caddy проксирует его автоматически, но если вы за nginx, нужно явно прописать ",[73,2143,2144],{},"Upgrade",[73,2146,2147],{},"Connection"," заголовки.",[33,2150,2151,2154,2155,2157,2158,2161],{},[475,2152,2153],{},"Cloudflare SSL:Flexible"," — Cloudflare terminates SSL на своём edge, а до origin идёт plain HTTP. Поэтому Caddy слушает на ",[73,2156,1898],{},", а не ",[73,2159,2160],{},":443",". Если поставить SSL:Full, нужно ещё и сертификат на origin настраивать — для self-hosted хобби-проекта это overkill.",[22,2163,2165],{"id":2164},"пользователи-и-токены","Пользователи и токены",[15,2167,2168],{},"Создаём первого пользователя:",[66,2170,2172],{"className":68,"code":2171,"language":70,"meta":71,"style":71},"ntfy user add --role=admin admin\n",[73,2173,2174],{"__ignoreMap":71},[76,2175,2176,2178,2181,2184,2187],{"class":78,"line":79},[76,2177,1862],{"class":82},[76,2179,2180],{"class":86}," user",[76,2182,2183],{"class":86}," add",[76,2185,2186],{"class":160}," --role=admin",[76,2188,2189],{"class":86}," admin\n",[15,2191,2192,2193,2196],{},"Пароль запрашивается интерактивно. После этого все запросы требуют авторизации (из-за ",[73,2194,2195],{},"deny-all",").",[15,2198,2199],{},"Для программного доступа (скрипты, AI-агент) лучше использовать токены, а не логин\u002Fпароль:",[66,2201,2203],{"className":68,"code":2202,"language":70,"meta":71,"style":71},"ntfy token add admin\n",[73,2204,2205],{"__ignoreMap":71},[76,2206,2207,2209,2212,2214],{"class":78,"line":79},[76,2208,1862],{"class":82},[76,2210,2211],{"class":86}," token",[76,2213,2183],{"class":86},[76,2215,2189],{"class":86},[15,2217,2218,2219,2222,2223,802],{},"Токен выглядит как ",[73,2220,2221],{},"tk_abc123...",". Передаётся в заголовке ",[73,2224,2225],{},"Authorization: Bearer tk_abc123...",[22,2227,2229],{"id":2228},"публикация-уведомлений","Публикация уведомлений",[15,2231,2232],{},"Отправить сообщение — один HTTP POST:",[66,2234,2236],{"className":68,"code":2235,"language":70,"meta":71,"style":71},"curl -u admin:password \\\n  -d \"Сервер перегрелся! CPU 95%\" \\\n  https:\u002F\u002Fntfy.example.com\u002Fmonitoring\n",[73,2237,2238,2250,2260],{"__ignoreMap":71},[76,2239,2240,2242,2245,2248],{"class":78,"line":79},[76,2241,400],{"class":82},[76,2243,2244],{"class":160}," -u",[76,2246,2247],{"class":86}," admin:password",[76,2249,167],{"class":160},[76,2251,2252,2255,2258],{"class":78,"line":117},[76,2253,2254],{"class":160},"  -d",[76,2256,2257],{"class":86}," \"Сервер перегрелся! CPU 95%\"",[76,2259,167],{"class":160},[76,2261,2262],{"class":78,"line":346},[76,2263,2264],{"class":86},"  https:\u002F\u002Fntfy.example.com\u002Fmonitoring\n",[15,2266,2267],{},"Или с токеном:",[66,2269,2271],{"className":68,"code":2270,"language":70,"meta":71,"style":71},"curl -H \"Authorization: Bearer tk_abc123...\" \\\n  -d \"Сервер перегрелся!\" \\\n  https:\u002F\u002Fntfy.example.com\u002Fmonitoring\n",[73,2272,2273,2285,2294],{"__ignoreMap":71},[76,2274,2275,2277,2280,2283],{"class":78,"line":79},[76,2276,400],{"class":82},[76,2278,2279],{"class":160}," -H",[76,2281,2282],{"class":86}," \"Authorization: Bearer tk_abc123...\"",[76,2284,167],{"class":160},[76,2286,2287,2289,2292],{"class":78,"line":117},[76,2288,2254],{"class":160},[76,2290,2291],{"class":86}," \"Сервер перегрелся!\"",[76,2293,167],{"class":160},[76,2295,2296],{"class":78,"line":346},[76,2297,2264],{"class":86},[15,2299,2300],{},"С заголовками для кастомизации:",[66,2302,2304],{"className":68,"code":2303,"language":70,"meta":71,"style":71},"curl -H \"Authorization: Bearer tk_abc123...\" \\\n  -H \"Title: Мониторинг\" \\\n  -H \"Priority: high\" \\\n  -H \"Tags: warning,fire\" \\\n  -d \"CPU 95%, RAM 90%\" \\\n  https:\u002F\u002Fntfy.example.com\u002Fmonitoring\n",[73,2305,2306,2316,2325,2334,2343,2352],{"__ignoreMap":71},[76,2307,2308,2310,2312,2314],{"class":78,"line":79},[76,2309,400],{"class":82},[76,2311,2279],{"class":160},[76,2313,2282],{"class":86},[76,2315,167],{"class":160},[76,2317,2318,2320,2323],{"class":78,"line":117},[76,2319,429],{"class":160},[76,2321,2322],{"class":86}," \"Title: Мониторинг\"",[76,2324,167],{"class":160},[76,2326,2327,2329,2332],{"class":78,"line":346},[76,2328,429],{"class":160},[76,2330,2331],{"class":86}," \"Priority: high\"",[76,2333,167],{"class":160},[76,2335,2336,2338,2341],{"class":78,"line":353},[76,2337,429],{"class":160},[76,2339,2340],{"class":86}," \"Tags: warning,fire\"",[76,2342,167],{"class":160},[76,2344,2345,2347,2350],{"class":78,"line":362},[76,2346,2254],{"class":160},[76,2348,2349],{"class":86}," \"CPU 95%, RAM 90%\"",[76,2351,167],{"class":160},[76,2353,2354],{"class":78,"line":367},[76,2355,2264],{"class":86},[22,2357,2359],{"id":2358},"подписка-на-уведомления","Подписка на уведомления",[15,2361,2362],{},"CLI:",[66,2364,2366],{"className":68,"code":2365,"language":70,"meta":71,"style":71},"ntfy subscribe ntfy.example.com\u002Fmonitoring\n",[73,2367,2368],{"__ignoreMap":71},[76,2369,2370,2372,2375],{"class":78,"line":79},[76,2371,1862],{"class":82},[76,2373,2374],{"class":86}," subscribe",[76,2376,2377],{"class":86}," ntfy.example.com\u002Fmonitoring\n",[15,2379,2380],{},"HTTP (long-polling):",[66,2382,2384],{"className":68,"code":2383,"language":70,"meta":71,"style":71},"curl -s ntfy.example.com\u002Fmonitoring\u002Fjson\n",[73,2385,2386],{"__ignoreMap":71},[76,2387,2388,2390,2392],{"class":78,"line":79},[76,2389,400],{"class":82},[76,2391,403],{"class":160},[76,2393,2394],{"class":86}," ntfy.example.com\u002Fmonitoring\u002Fjson\n",[15,2396,2397],{},"WebSocket (для интеграций):",[66,2399,2403],{"className":2400,"code":2401,"language":2402,"meta":71,"style":71},"language-javascript shiki shiki-themes github-dark","const ws = new WebSocket('wss:\u002F\u002Fntfy.example.com\u002Fmonitoring\u002Fws');\nws.onmessage = (e) => console.log(JSON.parse(e.data));\n","javascript",[73,2404,2405,2410],{"__ignoreMap":71},[76,2406,2407],{"class":78,"line":79},[76,2408,2409],{},"const ws = new WebSocket('wss:\u002F\u002Fntfy.example.com\u002Fmonitoring\u002Fws');\n",[76,2411,2412],{"class":78,"line":117},[76,2413,2414],{},"ws.onmessage = (e) => console.log(JSON.parse(e.data));\n",[15,2416,2417,2418,2421],{},"Android\u002FiOS приложение — просто добавляете сервер ",[73,2419,2420],{},"https:\u002F\u002Fntfy.example.com"," и подписываетесь на топики.",[22,2423,2425],{"id":2424},"интеграция-с-hermes-webhooks","Интеграция с Hermes: webhooks",[15,2427,2428],{},"Самое интересное — подключить ntfy к AI-агенту. У Hermes есть webhook-система, и nfy поддерживает actions — автоматические HTTP-запросы при получении сообщения.",[15,2430,2431,2432,1910],{},"В ",[73,2433,1909],{},[66,2435,2437],{"className":1913,"code":2436,"language":1915,"meta":71,"style":71},"actions:\n  - action: \"webhook\"\n    label: \"Forward to Hermes\"\n    url: \"http:\u002F\u002Flocalhost:8644\u002Fwebhooks\u002Fntfy\"\n    headers:\n      Authorization: \"Bearer my-hermes-secret\"\n    body: |\n      {\n        \"topic\": \"{{ .Topic }}\",\n        \"message\": \"{{ .Message }}\",\n        \"title\": \"{{ .Title }}\",\n        \"sender\": \"{{ .Sender }}\",\n        \"time\": \"{{ .Time }}\"\n      }\n    topic: \"hermes\"\n",[73,2438,2439,2447,2460,2470,2480,2487,2497,2507,2512,2517,2522,2527,2532,2537,2542],{"__ignoreMap":71},[76,2440,2441,2444],{"class":78,"line":79},[76,2442,2443],{"class":1922},"actions",[76,2445,2446],{"class":107},":\n",[76,2448,2449,2452,2455,2457],{"class":78,"line":117},[76,2450,2451],{"class":107},"  - ",[76,2453,2454],{"class":1922},"action",[76,2456,1926],{"class":107},[76,2458,2459],{"class":86},"\"webhook\"\n",[76,2461,2462,2465,2467],{"class":78,"line":346},[76,2463,2464],{"class":1922},"    label",[76,2466,1926],{"class":107},[76,2468,2469],{"class":86},"\"Forward to Hermes\"\n",[76,2471,2472,2475,2477],{"class":78,"line":353},[76,2473,2474],{"class":1922},"    url",[76,2476,1926],{"class":107},[76,2478,2479],{"class":86},"\"http:\u002F\u002Flocalhost:8644\u002Fwebhooks\u002Fntfy\"\n",[76,2481,2482,2485],{"class":78,"line":362},[76,2483,2484],{"class":1922},"    headers",[76,2486,2446],{"class":107},[76,2488,2489,2492,2494],{"class":78,"line":367},[76,2490,2491],{"class":1922},"      Authorization",[76,2493,1926],{"class":107},[76,2495,2496],{"class":86},"\"Bearer my-hermes-secret\"\n",[76,2498,2499,2502,2504],{"class":78,"line":373},[76,2500,2501],{"class":1922},"    body",[76,2503,1926],{"class":107},[76,2505,2506],{"class":103},"|\n",[76,2508,2509],{"class":78,"line":1016},[76,2510,2511],{"class":86},"      {\n",[76,2513,2514],{"class":78,"line":1022},[76,2515,2516],{"class":86},"        \"topic\": \"{{ .Topic }}\",\n",[76,2518,2519],{"class":78,"line":1028},[76,2520,2521],{"class":86},"        \"message\": \"{{ .Message }}\",\n",[76,2523,2524],{"class":78,"line":1034},[76,2525,2526],{"class":86},"        \"title\": \"{{ .Title }}\",\n",[76,2528,2529],{"class":78,"line":1040},[76,2530,2531],{"class":86},"        \"sender\": \"{{ .Sender }}\",\n",[76,2533,2534],{"class":78,"line":1046},[76,2535,2536],{"class":86},"        \"time\": \"{{ .Time }}\"\n",[76,2538,2539],{"class":78,"line":1052},[76,2540,2541],{"class":86},"      }\n",[76,2543,2544,2547,2549],{"class":78,"line":1057},[76,2545,2546],{"class":1922},"    topic",[76,2548,1926],{"class":107},[76,2550,2551],{"class":86},"\"hermes\"\n",[15,2553,2554,2555,2557],{},"Теперь каждое сообщение в топик ",[73,2556,677],{}," автоматически пересылается в Hermes API. Агент может обработать уведомление и ответить.",[15,2559,2560,2563,2564,2566,2567,2570],{},[475,2561,2562],{},"Ловушка",": если Hermes отвечает в тот же топик ",[73,2565,677],{},", ntfy снова вызывает webhook, Hermes снова отвечает — бесконечный цикл. Решение: отвечать в другой топик (например, ",[73,2568,2569],{},"hermes-responses","), или фильтровать по sender\u002Fheaders в обработчике.",[22,2572,2574],{"id":2573},"реальные-кейсы","Реальные кейсы",[136,2576,2578],{"id":2577},"мониторинг-сервера","Мониторинг сервера",[15,2580,2581,2582,2585,2586,2589,2590,802],{},"Cron-скрипт каждые 5 минут проверяет CPU, RAM, диск. Если значение порог — отправляем HTTP POST в ntfy. Приоритет ",[73,2583,2584],{},"high",", тег ",[73,2587,2588],{},"warning"," — и на телефоне сразу видно, что проблема. Скрипт занимает 5 строк bash, инициализация — один ",[73,2591,400],{},[136,2593,2595],{"id":2594},"алёрты-от-ai-агента","Алёрты от AI-агента",[15,2597,2598],{},"Hermes запускает cron-задачу (ежедневный брифинг), и результат шлёт в ntfy:",[66,2600,2603],{"className":2601,"code":2602,"language":815},[813],"hermes cron job → Hermes API → POST ntfy.example.com\u002Fhermes\n",[73,2604,2602],{"__ignoreMap":71},[15,2606,2607],{},"Пользователь видит уведомление на телефоне, открывает — там саммари новостей, задач, статус сервисов.",[136,2609,2611],{"id":2610},"уведомления-о-деплоях","Уведомления о деплоях",[15,2613,2614,2615,2618,2619,2622,2623,2625],{},"CI\u002FCD pipeline (или простой скрипт) шлёт статус деплоя: тег ",[73,2616,2617],{},"white_check_mark"," для успеха, ",[73,2620,2621],{},"x"," для провала, приоритет ",[73,2624,2584],{}," для критичных ошибок. Один POST-запрос — и вы видите результат на телефоне, не открывая CI.",[22,2627,2629],{"id":2628},"почему-не-telegram","Почему не Telegram",[15,2631,2632],{},"Telegram Bot API — отличный вариант, и я его тоже использую. Но у self-hosted ntfy есть преимущества:",[1371,2634,2635,2641,2647,2653,2659],{},[33,2636,2637,2640],{},[475,2638,2639],{},"Нет rate limit"," от Telegram (30 сообщений\u002Fсек на чат)",[33,2642,2643,2646],{},[475,2644,2645],{},"Нет зависимости"," от серверов Telegram (они иногда падают)",[33,2648,2649,2652],{},[475,2650,2651],{},"Полный контроль"," над данными (уведомления не проходят через Telegram)",[33,2654,2655,2658],{},[475,2656,2657],{},"Нет необходимости"," в Bot Token и chat ID — просто HTTP POST",[33,2660,2661,2664],{},[475,2662,2663],{},"WebSocket подписки"," встроены, без polling",[15,2666,2667],{},"Минусы: нет rich-контента (кнопки, inline keyboard), нет групповых чатов с историей, нет E2E шифрования. Для мониторинга и алертов — идеально. Для мессенджера — нет.",[22,2669,2671],{"id":2670},"безопасность-что-может-пойти-не-так","Безопасность: что может пойти не так",[136,2673,2675],{"id":2674},"публичные-топики","Публичные топики",[15,2677,2678,2679,2681,2682,2684,2685,2688],{},"Если ",[73,2680,1984],{}," не ",[73,2683,2195],{},", любой, кто угадает имя топика, может читать из него. Топик-имена — не секреты. ",[73,2686,2687],{},"ntfy subscribe ntfy.example.com\u002Fmy-secret-topic"," — это не защита, это security through obscurity.",[15,2690,2691,2692,2694],{},"Всегда ставьте ",[73,2693,2033],{}," и создавайте пользователей с явными правами на конкретные топики:",[66,2696,2698],{"className":68,"code":2697,"language":70,"meta":71,"style":71},"ntfy access admin monitoring rw\nntfy access admin hermes rw\nntfy access bot-hermes hermes rw\nntfy access bot-hermes monitoring ro\n",[73,2699,2700,2716,2729,2742],{"__ignoreMap":71},[76,2701,2702,2704,2707,2710,2713],{"class":78,"line":79},[76,2703,1862],{"class":82},[76,2705,2706],{"class":86}," access",[76,2708,2709],{"class":86}," admin",[76,2711,2712],{"class":86}," monitoring",[76,2714,2715],{"class":86}," rw\n",[76,2717,2718,2720,2722,2724,2727],{"class":78,"line":117},[76,2719,1862],{"class":82},[76,2721,2706],{"class":86},[76,2723,2709],{"class":86},[76,2725,2726],{"class":86}," hermes",[76,2728,2715],{"class":86},[76,2730,2731,2733,2735,2738,2740],{"class":78,"line":346},[76,2732,1862],{"class":82},[76,2734,2706],{"class":86},[76,2736,2737],{"class":86}," bot-hermes",[76,2739,2726],{"class":86},[76,2741,2715],{"class":86},[76,2743,2744,2746,2748,2750,2752],{"class":78,"line":353},[76,2745,1862],{"class":82},[76,2747,2706],{"class":86},[76,2749,2737],{"class":86},[76,2751,2712],{"class":86},[76,2753,2754],{"class":86}," ro\n",[136,2756,2758],{"id":2757},"rate-limiting","Rate limiting",[15,2760,2761],{},"ntfy имеет встроенный rate limiting (по умолчанию 250 сообщений в день на visitor). Для self-hosted с 1-2 пользователями — более чем достаточно. Но если вы шлете алерты каждые 10 секунд, можете упереться лимит.",[15,2763,2764,2765,1910],{},"Настройка в ",[73,2766,1909],{},[66,2768,2770],{"className":1913,"code":2769,"language":1915,"meta":71,"style":71},"visitor-subscription-limit: 30\nvisitor-request-limit-burst: 60\nvisitor-request-limit-replenish: 5s\n",[73,2771,2772,2782,2792],{"__ignoreMap":71},[76,2773,2774,2777,2779],{"class":78,"line":79},[76,2775,2776],{"class":1922},"visitor-subscription-limit",[76,2778,1926],{"class":107},[76,2780,2781],{"class":160},"30\n",[76,2783,2784,2787,2789],{"class":78,"line":117},[76,2785,2786],{"class":1922},"visitor-request-limit-burst",[76,2788,1926],{"class":107},[76,2790,2791],{"class":160},"60\n",[76,2793,2794,2797,2799],{"class":78,"line":346},[76,2795,2796],{"class":1922},"visitor-request-limit-replenish",[76,2798,1926],{"class":107},[76,2800,2801],{"class":86},"5s\n",[136,2803,2805],{"id":2804},"логирование","Логирование",[15,2807,2808],{},"ntfy пишет логи в stdout (systemd journal). Для продакшена стоит поднять логирование:",[66,2810,2812],{"className":1913,"code":2811,"language":1915,"meta":71,"style":71},"log-level: info\nlog-format: json\n",[73,2813,2814,2822],{"__ignoreMap":71},[76,2815,2816,2818,2820],{"class":78,"line":79},[76,2817,1994],{"class":1922},[76,2819,1926],{"class":107},[76,2821,1999],{"class":86},[76,2823,2824,2827,2829],{"class":78,"line":117},[76,2825,2826],{"class":1922},"log-format",[76,2828,1926],{"class":107},[76,2830,2831],{"class":86},"json\n",[15,2833,2834],{},"JSON-формат удобнее для парсинга в Loki\u002FELK, но для хобби-проекта достаточно text.",[22,2836,2838],{"id":2837},"миграция-и-бэкапы","Миграция и бэкапы",[15,2840,2841],{},"ntfy хранит всё в двух файлах:",[1371,2843,2844,2850],{},[33,2845,2846,2849],{},[73,2847,2848],{},"\u002Fvar\u002Fcache\u002Fntfy\u002Fcache.db"," — кеш сообщений (SQLite)",[33,2851,2852,2855],{},[73,2853,2854],{},"\u002Fvar\u002Flib\u002Fntfy\u002Fuser.db"," — пользователи и токены (SQLite)",[15,2857,2858],{},"Для бэкапа — просто копируйте эти файлы. Для миграции на другой сервер — перенесите файлы и конфиг.",[15,2860,2861,2863,2864,2866,2867,2870],{},[475,2862,2039],{},": при обновлении ntfy через apt, конфиг ",[73,2865,1909],{}," может быть перезаписан. Используйте ",[73,2868,2869],{},"dpkg --force-confold"," или бэкапите конфиг отдельно.",[22,2872,2874],{"id":2873},"траблшутинг","Траблшутинг",[136,2876,2878],{"id":2877},"ntfy-не-стартует-unable-to-open-database-file","ntfy не стартует: \"unable to open database file\"",[15,2880,2881],{},"Самая частая проблема. Проверьте:",[66,2883,2885],{"className":68,"code":2884,"language":70,"meta":71,"style":71},"ls -la \u002Fvar\u002Fcache\u002Fntfy \u002Fvar\u002Flib\u002Fntfy\n# Должны принадлежать _ntfy:_ntfy\n\njournalctl -u ntfy --no-pager -n 20\n# Смотрим последние логи\n",[73,2886,2887,2899,2904,2908,2927],{"__ignoreMap":71},[76,2888,2889,2892,2895,2897],{"class":78,"line":79},[76,2890,2891],{"class":82},"ls",[76,2893,2894],{"class":160}," -la",[76,2896,2092],{"class":86},[76,2898,2095],{"class":86},[76,2900,2901],{"class":78,"line":117},[76,2902,2903],{"class":349},"# Должны принадлежать _ntfy:_ntfy\n",[76,2905,2906],{"class":78,"line":346},[76,2907,343],{"emptyLinePlaceholder":342},[76,2909,2910,2913,2915,2918,2921,2924],{"class":78,"line":353},[76,2911,2912],{"class":82},"journalctl",[76,2914,2244],{"class":160},[76,2916,2917],{"class":86}," ntfy",[76,2919,2920],{"class":160}," --no-pager",[76,2922,2923],{"class":160}," -n",[76,2925,2926],{"class":160}," 20\n",[76,2928,2929],{"class":78,"line":362},[76,2930,2931],{"class":349},"# Смотрим последние логи\n",[136,2933,2935],{"id":2934},"уведомления-не-доходят-через-cloudflare","Уведомления не доходят через Cloudflare",[15,2937,2938,2939,2942,2943,802],{},"Проверьте, что Cloudflare не кеширует API-ответы. ntfy API endpoints (",[73,2940,2941],{},"\u002Fv1\u002F...",") не должны кешироваться. В Dashboard: Caching → Configuration → Cache Level: Bypass для ",[73,2944,2945],{},"ntfy.example.com\u002Fv1\u002F*",[136,2947,2949],{"id":2948},"websocket-не-подключается","WebSocket не подключается",[15,2951,2952],{},"За Cloudflare WebSocket работает, но нужно убедиться, что:",[30,2954,2955,2960,2963],{},[33,2956,2957,2959],{},[73,2958,2021],{}," в конфиге ntfy",[33,2961,2962],{},"Cloudflare не блокирует WebSocket (на Free плане не блокирует)",[33,2964,2965],{},"Caddy\u002Fnginx проксирует Upgrade заголовки",[136,2967,2969],{"id":2968},"_403-forbidden-при-публикации","\"403 Forbidden\" при публикации",[15,2971,2972],{},"Пользователь не имеет доступа к топику. Проверьте:",[66,2974,2976],{"className":68,"code":2975,"language":70,"meta":71,"style":71},"ntfy access LIST  # Кто имеет доступ к чему\nntfy access admin my-topic rw  # Дать доступ\n",[73,2977,2978,2990],{"__ignoreMap":71},[76,2979,2980,2982,2984,2987],{"class":78,"line":79},[76,2981,1862],{"class":82},[76,2983,2706],{"class":86},[76,2985,2986],{"class":86}," LIST",[76,2988,2989],{"class":349},"  # Кто имеет доступ к чему\n",[76,2991,2992,2994,2996,2998,3001,3004],{"class":78,"line":117},[76,2993,1862],{"class":82},[76,2995,2706],{"class":86},[76,2997,2709],{"class":86},[76,2999,3000],{"class":86}," my-topic",[76,3002,3003],{"class":86}," rw",[76,3005,3006],{"class":349},"  # Дать доступ\n",[22,3008,3010],{"id":3009},"альтернативы","Альтернативы",[136,3012,3014],{"id":3013},"gotify","Gotify",[15,3016,3017],{},"Похож на ntfy, но написан на Go с другим API. Меньше комьюнити, меньше приложений. Если уже выбрали ntfy — нет смысла менять.",[136,3019,3021],{"id":3020},"apprise","Apprise",[15,3023,3024],{},"Python-библиотека для отправки уведомлений в 80+ сервисов (Telegram, Slack, Discord, ntfy, и т.д.). Хороша как unified sender, но не как сервер. Можно комбинировать: Apprise → ntfy → телефон.",[136,3026,3028],{"id":3027},"shoutrrr","Shoutrrr",[15,3030,3031],{},"Go-альтернатива Apprise. Тот же подход — единый интерфейс для разных notification backends.",[136,3033,3035],{"id":3034},"telegram-bot-api","Telegram Bot API",[15,3037,3038],{},"Самый очевидный вариант, и я его тоже использую. Но ntfy выигрывает для self-hosted: нет rate limit от Telegram, нет зависимости от серверов Telegram, полный контроль над данными. Telegram — для мессенджерного опыта. ntfy — для infrastructure alerts.",[22,3040,3042],{"id":3041},"ресурсы","Ресурсы",[15,3044,3045,3046,3049],{},"На типичном VPS ntfy потребляет ",[475,3047,3048],{},"~18 МБ RAM"," и практически не грузит CPU. За 4 часа работы — 51 сообщение опубликовано, 3 подписчика, 4 активных топика. Это капля в море.",[15,3051,3052],{},"Для сравнения: Prometheus + Alertmanager жрут ~200 МБ. Grafana — ещё ~150 МБ. ntfy с webhook-интеграцией покрывает 80% кейсов мониторинга для хобби-проекта без всей этой инфраструктуры.",[22,3054,1532],{"id":1783},[15,3056,3057,3058,3061],{},"ntfy — это быстро: от ",[73,3059,3060],{},"apt install"," до работающих push-уведомлений — минимум настроек. Go-бинарник, systemd-сервис, HTTP API. Ставите, настраиваете Caddy, создаёте пользователя — и у вас свой notification backend, который не зависит ни от кого.",[15,3063,3064],{},"Для self-hosted AI-агента — это must-have. Мониторинг, алерты, уведомления о задачах — всё через один простой протокол.",[649,3066,3067],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":71,"searchDepth":117,"depth":117,"links":3069},[3070,3071,3072,3073,3074,3075,3076,3077,3078,3083,3084,3089,3090,3096,3102,3103],{"id":1866,"depth":117,"text":1867},{"id":1902,"depth":117,"text":1903},{"id":2062,"depth":117,"text":2063},{"id":2117,"depth":117,"text":2118},{"id":2164,"depth":117,"text":2165},{"id":2228,"depth":117,"text":2229},{"id":2358,"depth":117,"text":2359},{"id":2424,"depth":117,"text":2425},{"id":2573,"depth":117,"text":2574,"children":3079},[3080,3081,3082],{"id":2577,"depth":346,"text":2578},{"id":2594,"depth":346,"text":2595},{"id":2610,"depth":346,"text":2611},{"id":2628,"depth":117,"text":2629},{"id":2670,"depth":117,"text":2671,"children":3085},[3086,3087,3088],{"id":2674,"depth":346,"text":2675},{"id":2757,"depth":346,"text":2758},{"id":2804,"depth":346,"text":2805},{"id":2837,"depth":117,"text":2838},{"id":2873,"depth":117,"text":2874,"children":3091},[3092,3093,3094,3095],{"id":2877,"depth":346,"text":2878},{"id":2934,"depth":346,"text":2935},{"id":2948,"depth":346,"text":2949},{"id":2968,"depth":346,"text":2969},{"id":3009,"depth":117,"text":3010,"children":3097},[3098,3099,3100,3101],{"id":3013,"depth":346,"text":3014},{"id":3020,"depth":346,"text":3021},{"id":3027,"depth":346,"text":3028},{"id":3034,"depth":346,"text":3035},{"id":3041,"depth":117,"text":3042},{"id":1783,"depth":117,"text":1532},"Как поднять свой ntfy-сервер для push-уведомлений: Caddy reverse proxy, токены доступа, интеграция с AI-агентом через webhooks. Мониторинг, алерты и ловушка с бесконечным циклом.",{},"\u002Fself-hosted-ntfy-notifications",{"title":1846,"description":3104},"self-hosted-ntfy-notifications",[1862,3110,3111,3112,3113,677],"self-hosted","notifications","vps","caddy","z2W-hsVdsXO6syzEh27N9CdD8rltQXg6ye-cT_hy_fQ",1779661627953]