# Federated knowledge query — `knowledge.ask_projects` / `knowledge.get_from` **Status:** design approved 2026-04-30 **Repo:** `projects-meta-mcp` **Adds:** 2 read-only MCP tools + 1 sync extension + 1 cache field ## Problem Сейчас `knowledge.search` / `knowledge.get` читают только из shared `projects-wiki` (общая мета-вики). Если знания там нет — у агента тупик. При этом локальные `.wiki/` отдельных проектов могут это знание содержать (например, `node-utils/.wiki/concepts/windows-yarn-exec.md` — частный кейс который никто ещё не промоутил в shared). Хотим: если в shared не нашлось → опросить **конкретные** другие проекты (юзер указывает имена) на предмет того же знания. Если и там нет — оформить через уже существующий `tasks.create({target_project, body})`. ## Non-goals - **Broadcast по всем проектам.** Спрашиваем только перечисленные явно — экономим токены и сетку, юзер знает кого спрашивать. - **Авто-fallback внутри `knowledge.search`.** `search` остаётся узким (только shared); агент сам решает звать ли `ask_projects`. - **Новый "request-knowledge-prep" механизм.** `tasks.create` уже умеет ставить задачу другому проекту — Оккам. - **Confirm-gate / dry-run.** Оба новых тула read-only. ## Architecture ### Tools (новые, read-only) #### `knowledge.ask_projects(query, projects[], limit?, types?)` Ищет в `.wiki/` указанных репо. **Input:** | field | type | required | default | |---|---|---|---| | `query` | string | yes | — | | `projects` | string[] | yes | — | | `limit` | int (1–20) | no | `5` (per project) | | `types` | string[] | no | `["entities","concepts","packages","sources"]` (`raw` исключён по дефолту — тяжёлые dumps; чтобы включить, явно перечисли все 5) | **Output:** ```json { "asked_projects": ["foo", "bar"], "results": [ { "project": "foo", "matches": [ {"slug": "concepts/x", "type": "concepts", "title": "...", "snippet": "..."} ] }, {"project": "bar", "error": "no wiki — repo has no .wiki/index.md"} ], "next_action_hint": "knowledge.get_from(project, slug) для полного текста; tasks.create(target_project, body) если знание не оформлено" } ``` #### `knowledge.get_from(project, slug)` Полный текст одной страницы из конкретного проекта (live через Gitea API). **Input:** | field | type | required | |---|---|---| | `project` | string | yes | | `slug` | string | yes — относительный путь под `.wiki/` без `.md`, включает type-префикс (например `concepts/foo` или `entities/bar`) | **Output:** raw markdown body, либо `{error}` если не найдено. ### Sync extension Расширяем `runSync` в [src/lib/sync-runner.ts](../../src/lib/sync-runner.ts): - В per-repo worker добавляется параллельный `getRawFile(user, repo, '.wiki/index.md', branch)` рядом с уже существующим `.tasks/STATUS.md`. - Если оба `null` → skip как и раньше. Если есть хоть один — пишем `ProjectStatus`. ### Cache extension В [src/lib/cache.ts](../../src/lib/cache.ts): ```ts interface ProjectStatus { // ...existing fields wiki_index?: string; // NEW — raw .wiki/index.md, undefined если нет } ``` Один общий `cache.json`, не плодим параллельный кэш. ### Index parser Новый `src/lib/wiki-index-parser.ts`: - Парсит canonical `index.md` (формат из [src/lib/wiki-writer.ts](../../src/lib/wiki-writer.ts) `freshIndexMd()` / `insertIndexEntry()`). - Возвращает `Array<{slug: string; type: WikiPageType; title: string}>`. - Структура — секции по типам, под каждой list `- [title](type/slug.md)`. ### Read flow `ask_projects` 1. `readCache(cacheFile)` → найти проекты из `projects[]` параметра по `name`. 2. Для каждого: - `wiki_index === undefined` → `{project, error: "no wiki — ..."}` - Иначе: парсим index, фильтр по `types`, substring case-insensitive по `slug` + `title` против `query`. - Берём первые `limit` совпадений. - Для каждого — `backend.getRawFile(user, project, '.wiki/' + type + '/' + slug + '.md', branch)` (concurrency-pool). - Из body вырезаем snippet (~80 char вокруг матча, как в `knowledge.search`). 3. Если проекта нет в кэше → `{project, error: "unknown project — run sync first"}`. 4. Если live fetch упал → matched entry с `snippet: "(fetch failed)"`, slug+title сохраняем. ### Read flow `get_from` 1. `readCache` → найти project → `default_branch`. 2. `backend.getRawFile(user, project, '.wiki/' + slug + '.md', branch)` — slug включает type-префикс (тот же формат что отдаёт `ask_projects` в `matches[].slug`). 3. `null` → error `page not found: /.wiki/.md`. ## Matching semantics Реплицируем `knowledge.search`: - Substring case-insensitive - Сначала по `slug` + `title` (на index) - Потом по `body` (для snippet) при fetched-странице - `raw` тип исключён по дефолту (тяжёлые dumps); включается явным перечислением — `types: ["concepts","raw"]` или все 5 типов ## Error branches | Случай | Поведение | |---|---| | Index.md отсутствует в репо | `{project, error: "no wiki"}` — тихо, остальные проекты обрабатываются | | Project не в `cache.projects[]` | `{project, error: "unknown project — run sync first"}` | | Live fetch страницы упал | matched entry с `snippet: "(fetch failed)"` | | Парсинг index.md упал | `{project, error: "wiki index malformed: "}` | | `backend` не сконфигурирован (auth.toml нет) | tool возвращает error — как сейчас у write-tools | ## Configuration Новых конфиг-полей нет. Целевые проекты передаются параметром `projects[]`. Sync уже знает все репы юзера через `listUserRepos`. ## Testing Все новые тесты — vitest, stub Backend. Никаких реальных Gitea-вызовов. | Файл | Что покрывает | |---|---| | `tests/lib/wiki-index-parser.test.ts` | Валидный index.md, пустые секции, поломанный markdown, nested slugs (`concepts/foo/bar`) | | `tests/lib/sync-runner.test.ts` (расширение) | `wiki_index` подтягивается параллельно с STATUS.md; репо только с `.wiki/` без `.tasks/` попадает в кэш; ни того ни другого → skip | | `tests/tools/knowledge-ask-projects.test.ts` | Match по slug+title, default types-фильтр (raw исключён), limit per project, error-branches (`no wiki`, `unknown project`, fetch fail, malformed index), partial success (один проект упал, другие отдали) | | `tests/tools/knowledge-get-from.test.ts` | Happy path, project not found, page not found | ## Files **New:** - `src/lib/wiki-index-parser.ts` - `tests/lib/wiki-index-parser.test.ts` - `tests/tools/knowledge-ask-projects.test.ts` - `tests/tools/knowledge-get-from.test.ts` **Modify:** - `src/lib/cache.ts` — `wiki_index?: string` в `ProjectStatus` - `src/lib/sync-runner.ts` — параллельный `getRawFile` для `.wiki/index.md` - `src/tools/knowledge.ts` — 2 новых тула, описания включают цепочку с `tasks.create` для случая "знания нет" - `src/server.ts` — проверить что `cacheFile` + `backend` пробрасываются в `makeKnowledgeTools` (уже частично есть) - `tests/lib/sync-runner.test.ts` — расширить existing ## Implementation phases 1. **Parser** — `wiki-index-parser.ts` + тест. 2. **Cache + sync** — поле `wiki_index`, расширенный `runSync`, тесты. 3. **`ask_projects`** — тул + тест. 4. **`get_from`** — тул + тест. 5. **Wiring** — `server.ts` deps + `description` тулов с hint про `tasks.create`. 6. **Smoke** — `npm run build`, реальный sync, ручной вызов из новой Claude-сессии. ## Out of scope (для будущих итераций) - Авто-suggest проектов в response от `knowledge.search` (heuristic / тематический mapping). - Federated write — пробрасывать ingest сразу в чужой репо (можно уже сейчас через `knowledge.ingest({target_project: "foo", ...})` — есть в MVP-4). - Кэширование тел страниц (сейчас кэшируем только index — это компромисс размер/свежесть).