Long-term memory for agents that work on a codebase over MCP.
A Markdown wiki with a full revision history, a REST API and a built-in MCP server —
the companion to a code graph such as
codebase-memory-mcp:
the graph knows where things are, the wiki knows what they mean.
Project site · Why · Quick start · REST API · Search · MCP · Related projects
The project was written as the knowledge half of MCP codebase memory, next to a code graph such as codebase-memory-mcp.
A code graph remembers structure: which functions exist, who calls whom, where a thing lives. What it does not remember is why it is that way. Why this library was picked, what breaks if it is replaced, which agreement stands behind a piece of code that looks strange. That knowledge lives in people's heads and in chat threads, and the agent that comes to edit the code tomorrow never sees it.
go-wiki covers that half: human-readable decisions, versioned and addressable. An agent reads them over the same protocol it uses for the graph, and a person edits them in the browser.
Three properties make a wiki usable as machine memory:
- Immutable addresses. The pair
(path, rev)points at the same text forever: revisions are never rewritten, and restoring one appends a new revision. Such a pair is safe to store in a graph node. - Cheap change detection. Every revision carries a
sha256, andformat=statanswers "did anything change" without transferring the text — so reindexing stays incremental. - One source for people and agents. An edit from the UI, from REST and from MCP goes through the same versioning machinery, so agent changes show up in the same history feed and the same diffs.
A single Go binary, no external services: no Postgres, no Redis, no Node.
| Capability | Available in |
|---|---|
| Markdown page CRUD | UI, REST, MCP |
| Revision history with author and comment | UI, REST, MCP |
| Diff between any two revisions (unified / per-line / side by side) | UI, REST, MCP |
| Restoring an older revision | UI, REST, MCP |
| Full-text search (SQLite FTS5, works with Cyrillic) | UI, REST, MCP |
| Search that also matches other word forms (Russian and English stemming) | UI, REST, MCP |
| Filters by section, author, contributor and dates | UI, REST, MCP |
| Moving a page together with its subtree | UI, REST, MCP |
| Rewriting links to the moved pages across the whole wiki | UI, REST, MCP |
| Walking the tree one level at a time, with subtree counts | REST, MCP |
| Creating a child page | UI, REST, MCP |
Optimistic locking through base_rev |
REST, MCP |
| Light and dark themes, responsive layout | UI |
The key decision: the database always holds the current version only, and
every superseded version is written out to its own .md file that the database
points at.
data/
├── wiki.db # SQLite: current page bodies + revision metadata
└── revisions/
└── docs/setup/
├── r0001.md # body of revision 1
└── r0002.md # body of revision 2
pages— one row per page: path, title, the current body, the current revision number, author, comment, timestamps, and the normalized word stems used by search.revisions— one row per superseded version: revision number, title, the path of the.mdfile, size,sha256, author, comment, when the version was created and when it was archived. Revision bodies are not kept in the database.pages_fts— an FTS5 virtual table overpages, kept in sync by triggers.
Consequences worth relying on:
- Revision files are plain Markdown with no front matter: you can grep them, diff them and commit them to git as they are.
- Deleting a page archives its last version first and only then drops the row
from
pages. The history of a deleted page stays readable throughGET /api/v1/revisions?path=…. - If a page is created again at the same path, revision numbering continues where it left off instead of restarting at one.
- Restoring a revision does not rewind the history: the body of the old version is written as a new revision. History is strictly append-only.
- An edit that changes neither the body nor the title creates no revision.
go build -o wiki ./cmd/wiki
./wiki -addr :8080 -data ./data- UI — http://localhost:8080
- REST API — http://localhost:8080/api/v1
- MCP (streamable HTTP) — http://localhost:8080/mcp
- MCP (stdio) —
./wiki -stdio -data ./data
| Flag | Environment variable | Default | Purpose |
|---|---|---|---|
-addr |
WIKI_ADDR |
:8080 |
HTTP listen address |
-data |
WIKI_DATA |
./data |
root data directory |
-db |
WIKI_DB |
<data>/wiki.db |
SQLite file |
-revisions |
WIKI_REVISIONS |
<data>/revisions |
directory of .md revisions |
-require-base-rev |
WIKI_REQUIRE_BASE_REV |
off | reject an edit of an existing page that arrives without base_rev |
-auth-tokens |
WIKI_AUTH_TOKENS |
— | token file: token author [read|write] per line |
-trust-header |
WIKI_TRUST_HEADER |
— | header a trusted proxy puts the user name into |
-trust-header-role |
WIKI_TRUST_HEADER_ROLE |
write |
role granted to clients arriving through the proxy |
-stdio-author |
WIKI_STDIO_AUTHOR |
— | name that signs writes in -stdio mode |
-stdio |
— | off | serve MCP over stdio instead of starting the HTTP server |
-version |
— | — | print the version |
The search index now carries a norm column with the word stems computed for
every page (see Search and word forms). A database
created before that migrates itself on the first start of the new binary: the
column is added, stems are computed for every page, and the full-text index is
rebuilt from scratch.
Nothing else is touched — pages, revision rows and the archived .md files stay
exactly as they were, and the migration is idempotent, so a repeated start does
nothing. The one thing to plan for: on a large wiki the first start takes
noticeably longer than usual, because every page body is stemmed and
reindexed. Subsequent starts are back to normal.
With no identity check configured, the author field comes from the client and
nothing confirms it: anyone who can reach the service writes anything under
anyone's name. For an internal setup with one person that is acceptable; the
moment agents on user-reachable machines start writing, it is not.
There are two mechanisms, and they combine:
Per-client tokens. A file of the form
# token author role
tok-postanovshchik постановщик write
tok-reports отчёты read
The client presents the token in Authorization: Bearer … or X-Api-Token.
Instead of the token itself the file may hold sha256:<hex>, so no secret sits
on disk in the clear. The read role forbids every mutating method.
A header from a trusted proxy. -trust-header X-Auth-User: the name is
taken from a header the proxy sets after its own authorization. The service
itself must then be unreachable except through the proxy, or anyone can forge
that header.
The important property: when a check is enabled, the author sent by the
client is ignored and replaced with the verified name — in REST, in the web
interface and in MCP. Otherwise the signature under a page means nothing again.
A token that is presented but unknown is rejected with 401 rather than
silently falling back to the header.
In -stdio mode the identity comes from -stdio-author: the peer there is the
local process that started the server, and the protocol has nothing to confirm
it with.
| HTTP | When |
|---|---|
401 unauthorized |
no identity presented, or the token is not recognized |
403 forbidden |
a mutating request arrived from a client with the read role |
The base prefix is /api/v1. Request and response bodies are JSON in UTF-8.
CRUD addresses a page by its path in the URL. Everything that needs a second
coordinate (a revision number, a diff range) takes the page as a ?path=
parameter — that way routes stay unambiguous even for a page named revisions
or diff.
Wherever a revision number is expected, these are accepted:
| Value | Meaning |
|---|---|
5 |
revision number 5 |
-1 |
the one before current (-2 — two back) |
current, latest, head, empty |
the current revision |
previous, prev |
the previous revision |
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/healthz |
status and page count |
GET |
/api/v1/pages |
list pages |
POST |
/api/v1/pages |
create a page |
GET |
/api/v1/pages/{path} |
the current version of a page |
PUT |
/api/v1/pages/{path} |
write a page (upsert) |
DELETE |
/api/v1/pages/{path} |
delete a page |
GET |
/api/v1/revisions?path= |
the history of a page |
GET |
/api/v1/revisions/{rev}?path= |
body and metadata of one revision |
GET |
/api/v1/diff?path= |
compare two revisions |
POST |
/api/v1/restore?path= |
restore a revision |
POST |
/api/v1/move?path= |
move a page together with its subtree |
GET |
/api/v1/tree?path=&depth= |
the children of one node of the tree |
GET |
/api/v1/search?q= |
search with filters |
GET |
/api/v1/authors |
authors and their edit counts |
Parameters: prefix (only pages under it), limit (200 by default, 1000 at
most), offset.
curl 'http://localhost:8080/api/v1/pages?prefix=docs&limit=50'{
"pages": [
{"path":"docs/setup","title":"Установка","rev":2,"size":53,"author":"lead","updated_at":"2026-08-25T22:07:36Z"}
],
"count": 1
}Creates a page. If the path is taken — 409 already_exists.
curl -X POST http://localhost:8080/api/v1/pages \
-H 'Content-Type: application/json' \
-d '{
"path": "docs/setup",
"title": "Установка",
"content": "# Установка\n\nШаг один.\nШаг два.\n",
"author": "n36",
"comment": "первая версия"
}'Fields: path (required), content (required), title, author, comment.
A missing title is derived from the last path segment. The response is 201
and the page object.
curl http://localhost:8080/api/v1/pages/docs/setup
curl 'http://localhost:8080/api/v1/pages/docs/setup?format=raw' # Markdown only, text/markdown{
"path": "docs/setup",
"title": "Установка",
"content": "# Установка\n\nШаг один, уточнённый.\nШаг два.\nШаг три.\n",
"rev": 2,
"author": "lead",
"comment": "добавлен шаг три",
"created_at": "2026-08-25T22:07:36.372Z",
"updated_at": "2026-08-25T22:07:36.382Z"
}An idempotent write: it creates the page if it does not exist (201), otherwise
it updates it (200). content is the complete new body, not a patch.
curl -X PUT http://localhost:8080/api/v1/pages/docs/setup \
-H 'Content-Type: application/json' \
-d '{"content":"# Установка\n\nШаг один, уточнённый.\nШаг два.\nШаг три.\n","author":"lead","comment":"добавлен шаг три","base_rev":1}'base_rev is optimistic locking: if the page has already moved on, the answer
is 409 revision_conflict and nothing is written. For agents this is the main
way not to overwrite someone else's edit.
If the service runs with -require-base-rev, an edit of an existing page
without base_rev is rejected with 428 base_rev_required. A convention
that "every client passes base_rev" is not a mechanism: one agent with a
forgotten field silently overwrites another's work. With the flag, the omission
becomes an error instead of a lost text. Creating a new page is unaffected —
there is nothing to conflict with.
curl -X DELETE 'http://localhost:8080/api/v1/pages/docs/setup?author=n36&comment=устарело'The last version is archived before deletion, so the history stays available.
curl 'http://localhost:8080/api/v1/revisions?path=docs/setup'{
"path": "docs/setup",
"count": 2,
"revisions": [
{"path":"docs/setup","rev":2,"title":"Установка","author":"lead","comment":"добавлен шаг три",
"size":91,"sha256":"d18c09…","current":true,"created_at":"2026-08-25T22:07:36.382Z"},
{"path":"docs/setup","rev":1,"title":"Установка","author":"n36","comment":"первая версия",
"size":54,"sha256":"95ef4b…","file":"docs/setup/r0001.md","current":false,
"created_at":"2026-08-25T22:07:36.372Z","archived_at":"2026-08-25T22:07:36.382Z"}
]
}Newest first. The current revision has current: true and no file field: its
body lives in the database. file is a path relative to the revisions directory.
curl 'http://localhost:8080/api/v1/revisions/1?path=docs/setup'
curl 'http://localhost:8080/api/v1/revisions/-1?path=docs/setup&format=raw'Returns the revision metadata plus a content field with the body.
| Parameter | Default | Meaning |
|---|---|---|
path |
— | required, the page |
from |
to - 1 |
the left (older) revision |
to |
current |
the right (newer) revision |
format |
unified |
unified, json (also lines), side, stat |
context |
3 |
lines of context for unified |
raw |
— | raw=1 together with format=unified returns text/x-diff instead of JSON |
# a ready-made patch as text
curl 'http://localhost:8080/api/v1/diff?path=docs/setup&raw=1'--- docs/setup@1
+++ docs/setup@2
@@ -1,4 +1,5 @@
# Установка
-Шаг один.
+Шаг один, уточнённый.
Шаг два.
+Шаг три.# per-line operations — convenient to parse mechanically
curl 'http://localhost:8080/api/v1/diff?path=docs/setup&format=json'{
"path": "docs/setup",
"from": { "rev": 1, "file": "docs/setup/r0001.md", "…": "…" },
"to": { "rev": 2, "current": true, "…": "…" },
"stat": { "added": 3, "removed": 1 },
"format": "lines",
"lines": [
{"op":"equal","text":"# Установка","old_line":1,"new_line":1},
{"op":"delete","text":"Шаг один.","old_line":3},
{"op":"insert","text":"Шаг один, уточнённый.","new_line":3}
]
}format=side returns rows — aligned line pairs with per-character
highlighting inside a changed line (kind: equal / replace / insert /
delete). format=stat returns the counters only — the cheapest way to learn
whether anything changed at all.
curl -X POST 'http://localhost:8080/api/v1/restore?path=docs/setup' \
-H 'Content-Type: application/json' \
-d '{"rev":1,"author":"n36","comment":"откат неудачной правки"}'The body of revision 1 is written as a new revision on top of the current one.
The response is the page object with the new revision number.
Renames a page and moves it elsewhere in the tree. The place in the tree is the path itself: every segment but the last is a parent.
curl -X POST 'http://localhost:8080/api/v1/move?path=продукт/cardflow' \
-H 'Content-Type: application/json' \
-d '{"to":"продукт/каталог/cardflow","author":"n36"}'{
"from": "продукт/cardflow",
"to": "продукт/каталог/cardflow",
"moved": 3,
"pages": ["продукт/cardflow/интеграции/1c", "продукт/cardflow/карточки", "продукт/cardflow/обзор"],
"relinked": 2,
"relinked_pages": ["продукт/каталог/cardflow/карточки", "реестр/домены"]
}Nested pages travel with their parent. History follows them: revision rows are
re-pointed and the archived .md files are physically moved to the new
directory. pages lists the old addresses of everything that moved.
A section without a page of its own can be moved too. In the example above
продукт/cardflow has no page — only its children do — and renaming a domain
means renaming everything under it. What matters is that the subtree is not
empty; an empty node is 404.
Links are rewritten across the whole wiki. A move renames pages but leaves
the text of every other page alone, so without this step the registries that
referred to the subtree keep pointing at addresses that no longer exist. Every
reference of the form ](/w/old/path) is repointed, in each of the shapes it
can be written in — with an anchor, in a reference definition, and
percent-encoded, which is what a browser produces for Cyrillic paths:
- [CardFlow](/w/продукт/каталог/cardflow/обзор) — карточки товаров
- [Обмен с 1С](/w/%D0%BF%D1%80%D0%BE%D0%B4%D1%83%D0%BA%D1%82/%D0%BA%D0%B0%D1%82%D0%B0%D0%BB%D0%BE%D0%B3/cardflow/…)
- [Раздел про карточки][cards]
[cards]: /w/продукт/каталог/cardflow/карточки#созданиеA link is rewritten in the same shape it was written in: a percent-encoded address stays percent-encoded, because changing it to raw text would change what the reader copied.
The move itself still creates no revision, because the text of the moved
pages does not change and a revision with identical content would be
indistinguishable from a real edit. The link rewrite does create one — it is
a genuine change of text — signed by whoever performed the move and commented
перенос A → B:
{"path":"реестр/домены","rev":2,"author":"n36",
"comment":"перенос продукт/cardflow → продукт/каталог/cardflow",
"size":453,"current":true,"created_at":"2026-08-25T22:07:36.960Z"}That is what the author field in the request body is for: without it those
revisions would be unsigned. Under -require-base-rev the rewrite works as
well — the server supplies base_rev for its own edit and retries on a
conflicting concurrent edit.
Failures: 404 — nothing under the source path; 409 — the target path is
taken; 400 — an attempt to move a page into its own subtree. All checks run
before the first write, so a partial move cannot happen.
Returns the children of one node instead of a flat list of every page. A client drawing a tree of thousands of pages should not have to pull all of them to find out what lies one level down.
| Parameter | Default | Meaning |
|---|---|---|
path |
empty | the node to look under; empty means the top of the tree |
depth |
1 |
how many levels to expand, 5 at most |
curl -G http://localhost:8080/api/v1/tree \
--data-urlencode 'path=продукт' --data-urlencode 'depth=2'{
"path": "продукт",
"node": {"path":"продукт","name":"продукт","title":"Продукт","exists":false,"pages":3,"children":1},
"nodes": [
{
"path": "продукт/cardflow", "name": "cardflow", "title": "Cardflow",
"exists": false, "pages": 3, "children": 3,
"nodes": [
{"path":"продукт/cardflow/интеграции","name":"интеграции","title":"Интеграции",
"exists":false,"pages":1,"children":1},
{"path":"продукт/cardflow/карточки","name":"карточки","title":"Карточки товаров",
"exists":true,"rev":1,"author":"постановщик","size":121,
"updated_at":"2026-08-25T22:07:36.403Z","pages":1,"children":0},
{"path":"продукт/cardflow/обзор","name":"обзор","title":"CardFlow: обзор",
"exists":true,"rev":1,"author":"постановщик","size":123,
"updated_at":"2026-08-25T22:07:36.391Z","pages":1,"children":0}
]
}
],
"count": 1
}node describes the node that was asked about, nodes are its children, and
count is how many children came back. Per node:
| Field | Meaning |
|---|---|
path, name |
the full path and the last segment |
exists |
whether the node has a page of its own |
pages |
how many pages lie in this subtree, the node's own page included |
children |
how many direct children the node has |
rev, author, size, updated_at |
present only when exists is true |
nodes |
the next level, when depth > 1 |
A section without a page of its own is a node too ("exists": false).
Without that a tree like продукт/<domain>/обзор would break in the middle:
продукт/cardflow has no page, and skipping it would hide everything below.
Such a node gets its title derived from its name, exactly the way page
creation derives one.
depth is capped at 5 and the answer as a whole at 2000 nodes, because the
point of this endpoint is that clients stop pulling the entire wiki to draw a
tree — it must not become a way to do exactly that. Both counters, pages and
children, are computed in SQL: counting them in Go would mean reading every
path in the subtree, which is the cost this endpoint exists to avoid.
An empty subtree is 404 — the root (path empty) is always a valid node.
curl -G http://localhost:8080/api/v1/search --data-urlencode 'q=карточку'{
"query": "карточку",
"count": 4,
"hits": [
{"path":"продукт/cardflow/карточки","title":"Карточки товаров","rev":1,
"snippet":"# Карточки товаров\n\nКарточки создаются импортом и правятся вручную. …",
"author":"постановщик","size":121,"updated_at":"2026-08-25T22:07:36.403Z"}
]
}Every word of the query is looked up twice: as a prefix over the path, the title
and the body, and as a stem over the normalized column — so карточку finds a
page that only ever says карточки. Punctuation is escaped, so arbitrary user
input cannot break the query. The search runs over the current versions of
pages; archived revisions are not indexed. See
Search and word forms for what the stemmer does and
does not manage.
Filters. Any of them works without q as well — then it is simply a
selection:
| Parameter | Meaning |
|---|---|
prefix |
only pages of the given subtree |
author |
the author of the current revision |
contributor |
the author of any revision of the page, however old |
created_after, created_before |
bounds on creation time |
updated_after, updated_before |
bounds on the time of the last edit |
sort |
relevance, updated, -updated, created, -created, path, title |
limit, offset |
pagination |
Dates are accepted as 2026-08-25 or as full RFC3339. The default sort is by
relevance when there is a query and newest-first without one.
# what one person edited over the last week
curl 'http://localhost:8080/api/v1/search?author=n36&updated_after=2026-08-18&sort=-updated'
# every page of a section they have ever touched
curl 'http://localhost:8080/api/v1/search?contributor=n36&prefix=docs'GET /api/v1/pages accepts the same filters: with them the listing behaves like
a search.
Everyone who is on record as the author of a page or of a revision, with edit counts — a hint for the author filter.
{ "authors": [{"author": "n36", "edits": 3}, {"author": "постановщик", "edits": 3},
{"author": "lead", "edits": 1}], "count": 3 }{ "error": { "code": "revision_conflict", "message": "page is at revision 4, not 2" } }| HTTP | code |
When |
|---|---|---|
| 400 | bad_path |
the path is empty, contains .., control characters or forbidden signs |
| 400 | bad_request |
unparsable JSON or an unknown format |
| 404 | not_found |
no such page, revision or tree node |
| 409 | already_exists |
POST to a taken path, including two simultaneous creations |
| 428 | base_rev_required |
the server runs with -require-base-rev and the edit arrived without it |
| 409 | revision_conflict |
base_rev did not match the current revision |
| 500 | internal |
everything else |
Full-text search is FTS5 with the unicode61 tokenizer, which compares word
forms letter by letter and can only match prefixes. That is enough for Latin
text and hopeless for Russian: карточк found both карточки and карточку,
but карточку found neither — prefix matching only works in one direction, and
the words a person types are inflected.
The fix is a second index column. When a page is written, Go reduces its title
and body to word stems and stores them in the norm column, which is part of
the full-text index. Every word of a query is stemmed by the same code, so both
sides of the comparison end up in one normal form. A query term therefore
produces two conditions joined by OR: the literal prefix over path, title and
body, and the stem over norm. Terms are joined by AND.
- Russian words go through the Snowball (Porter) algorithm, English words
through a light suffix stripper; digits, mixed-script tokens (
utf8,v2) and words shorter than three letters are left alone.ёis folded toеbefore anything else, because both spellings occur in the same texts. - The path is deliberately left out of the normalization. It is made of slugs, it is already searched as a prefix in its own column, and it would have to be recomputed on every move.
- Highlighting in
snippetstill comes from literal matches in the body, so a hit found only through its stem comes back without brackets around the word.
It is a stemmer, not a lemmatizer: it strips endings by rule and knows neither a
dictionary nor the alternations inside a root. Two honest limits, both pinned by
tests in internal/morph/morph_test.go:
- A fleeting vowel.
карточек(genitive plural) does not reduce toкарточк, because-екis not an ending here — there is nothing to strip. The same goes forпроверок,ссылок. - An alternation in the root.
переноситьstems toперенос, whileперенестиgivesперенестandперенесённыйgivesперенесен. The algorithm has no way to know these are one word.
Live, on the same wiki: карточку and карточки both return 4 pages, while
карточек returns only the 2 pages that literally contain that form. The
trade-off is deliberate — precision is exchanged for having no dictionaries and
no dependencies.
The server announces itself as go-wiki and comes up on two transports at once:
| Transport | Address | When to use it |
|---|---|---|
| stdio | wiki -stdio -data ./data |
a local agent starts the wiki as a subprocess |
| streamable HTTP | http://<host>:8080/mcp |
the wiki is already deployed and the agent reaches it over the network |
The protocol is 2025-06-18, the SDK is github.com/modelcontextprotocol/go-sdk.
Claude Code, locally over stdio:
claude mcp add go-wiki -- /path/to/wiki -stdio -data /var/lib/go-wikiOver HTTP to a deployed service — and, next to it, the code graph the wiki is meant to be read together with:
claude mcp add --transport http go-wiki http://wiki.internal:8080/mcp
claude mcp add codebase-memory -- codebase-memory-mcpBy configuration file (.mcp.json / claude_desktop_config.json):
{
"mcpServers": {
"go-wiki": {
"command": "/usr/local/bin/wiki",
"args": ["-stdio", "-data", "/var/lib/go-wiki"]
},
"go-wiki-http": {
"type": "http",
"url": "http://wiki.internal:8080/mcp"
}
}
}Page paths are slash-separated everywhere (docs/setup) and normalized on
input. Revision references take the same syntax as REST (5, -1, current,
previous).
Pages without bodies — a cheap way to build a table of contents.
| Parameter | Type | Req. | Meaning |
|---|---|---|---|
prefix |
string | no | only pages under this prefix |
limit |
int | no | 200 by default |
offset |
int | no | for paging through |
Returns: { pages: [{path,title,rev,size,author,updated_at}], count }.
Search with filters. Any filter works without a query, so "pages by author X over the last week" is a plain call rather than a workaround.
| Parameter | Type | Req. | Meaning |
|---|---|---|---|
query |
string | no | query words; each is matched as a prefix and as a stem |
prefix |
string | no | only the given subtree |
author |
string | no | the author of the current revision |
contributor |
string | no | the author of any revision of the page |
created_after, created_before |
string | no | 2026-08-25 or RFC3339 |
updated_after, updated_before |
string | no | the same for edit time |
sort |
string | no | relevance, updated, -updated, created, -created, path, title |
limit, offset |
int | no | pagination |
Returns: { hits: [{path,title,rev,snippet,author,size,created_at,updated_at}], count }.
Read a page or one specific revision.
| Parameter | Type | Req. | Meaning |
|---|---|---|---|
path |
string | yes | page path |
rev |
string | no | revision reference; current by default |
Returns: { path, title, rev, current, author, comment, content, file, modified }.
file is filled in only for archived revisions and points at the .md on disk.
Create a new page. Fails if the path is taken.
| Parameter | Type | Req. | Meaning |
|---|---|---|---|
path |
string | yes | page path |
content |
string | yes | Markdown body |
title |
string | no | title; otherwise derived from the path |
author |
string | no | who is editing |
comment |
string | no | what the edit is |
Returns: { path, title, rev, created, size }.
Replace the body of a page with a new revision. Creates the page if it does not exist.
| Parameter | Type | Req. | Meaning |
|---|---|---|---|
path |
string | yes | page path |
content |
string | yes | the complete new body, not a patch |
title |
string | no | new title; otherwise the old one is kept |
author |
string | no | who is editing |
comment |
string | no | what the edit is |
base_rev |
int | no | refuse the write if the page has been changed meanwhile |
Returns: { path, title, rev, created, size }.
Delete a page; its last version is archived first.
| Parameter | Type | Req. | Meaning |
|---|---|---|---|
path |
string | yes | page path |
author |
string | no | who is deleting |
comment |
string | no | why |
Returns: { path, deleted }.
The history of a page, newest first.
| Parameter | Type | Req. | Meaning |
|---|---|---|---|
path |
string | yes | page path |
Returns: { path, revisions: [{rev,title,author,comment,size,sha256,file,current,created_at,archived_at}], count }.
Compare two revisions.
| Parameter | Type | Req. | Meaning |
|---|---|---|---|
path |
string | yes | page path |
from |
string | no | the older revision; the one before to by default |
to |
string | no | the newer revision; current by default |
format |
string | no | unified (default), lines, stat |
context |
int | no | lines of context for unified, 3 by default |
Returns: { path, from, to, stat:{added,removed}, format, unified?, lines? }.
An example format=unified answer:
{
"path": "docs/setup", "from": 1, "to": 2,
"stat": { "added": 3, "removed": 1 },
"format": "unified",
"unified": "--- docs/setup@1\n+++ docs/setup@2\n@@ -1,4 +1,5 @@\n # Установка\n \n-Шаг один.\n+Шаг один, уточнённый.\n Шаг два.\n+Шаг три.\n"
}Move or rename a page together with its subtree.
| Parameter | Type | Req. | Meaning |
|---|---|---|---|
from |
string | yes | the current path |
to |
string | yes | the new path; the parent segments set the place in the tree |
author |
string | no | who is moving; signs the revisions that repoint links |
Returns: { from, to, moved, pages, relinked, relinked_pages }. History follows
the pages and the move itself creates no revision; repointing the links to them
does, on every page it touches. A section that has no page of its own can be
moved as well.
Walk the page tree one level at a time.
| Parameter | Type | Req. | Meaning |
|---|---|---|---|
path |
string | no | the node to look under; empty means the top of the tree |
depth |
int | no | how many levels to expand, 1 by default and 5 at most |
Returns { path, exists, pages, children, nodes, count }: the counters of the
node that was asked about, then its descendants. Unlike the REST answer the
nodes come back flat, in depth-first order, each carrying parent and
depth instead of a nested nodes array — the SDK's JSON-schema generator does
not build a schema for a self-referencing type, it panics on one and takes the
whole server down with it. count is therefore the size of the flat list across
all levels, not the number of direct children.
{
"path": "продукт", "exists": false, "pages": 3, "children": 1, "count": 2,
"nodes": [
{"path":"продукт/каталог","name":"каталог","title":"Каталог","parent":"продукт",
"depth":1,"exists":false,"pages":3,"children":1},
{"path":"продукт/каталог/cardflow","name":"cardflow","title":"Cardflow",
"parent":"продукт/каталог","depth":2,"exists":false,"pages":3,"children":3}
]
}A node with a page of its own also carries rev, author, size and updated
(RFC3339 — the REST field is spelled updated_at). Sections with no page of
their own are nodes too (exists: false), so the tree never breaks in the
middle. This is the call to use instead of wiki_list_pages when the question is
"what is under this node", not "give me everything".
The authors with their edit counts — so a filter value is not a guess.
Returns: { authors: [{author, edits}], count }.
Bring an old version back as a new revision.
| Parameter | Type | Req. | Meaning |
|---|---|---|---|
path |
string | yes | page path |
rev |
int | yes | the revision whose body becomes current |
author |
string | no | who is restoring |
comment |
string | no | why |
Returns: { path, title, rev, created, size }.
curl -sS -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"curl","version":"1"}}}'The response carries Mcp-Session-Id in the headers; it has to be passed along
in the tools/list and tools/call requests that follow.
"Why this exists" covers the motivation; this section is the mechanics of pairing the wiki with codebase-memory-mcp or any other codebase indexer.
The division of labour is the point. codebase-memory-mcp answers where things
are: it indexes a repository into a graph of files, functions, classes and
routes connected by CALLS, IMPORTS and similar edges, and answers questions
about callers, dependencies and dead code. go-wiki answers what it is about
and why it was decided that way: statements of work, contracts, domain
decisions — with a revision history that says who changed a decision and when.
One agent talks to both servers in the same session: the structure comes from
the graph, the meaning comes from the wiki.
{
"mcpServers": {
"codebase-memory": {
"command": "codebase-memory-mcp"
},
"go-wiki": {
"type": "http",
"url": "http://wiki.internal:8080/mcp"
}
}
}With both connected, a question like "why does the card import go through a
queue" turns into two calls: search_graph finds the code that does it,
wiki_search finds the page that explains why it was built that way — and
wiki_list_revisions shows when that reasoning last changed.
What makes the wiki side usable from an indexer:
- Stable addresses. The pair
(path, rev)identifies a text forever: revisions are immutable, and restoring one appends a new revision instead of editing an old one. Such a pair is safe to store in a graph node. - Checksums. Every revision has a
sha256of its body, so you can tell whether the content changed without fetching it. - Cheap change detection.
GET /api/v1/diff?path=…&format=statorwiki_diffwithformat=statanswer with counters and no text — enough for incremental reindexing. - Files right there. Archived revisions are ordinary
.mdfiles under<data>/revisions/<path>/rNNNN.md— an indexer can read them straight off disk, bypassing HTTP, and take the metadata from the database. - Tree instead of a flat list.
wiki_treewalks the wiki by domain the way a graph is walked by module, without pulling every page first. - One history. A write through MCP is indistinguishable from a write through the UI, so agent edits appear in the same revision feed and the same diffs.
- Race protection. Pass
base_revon automated writes — then a person's concurrent edit is not silently overwritten but comes back as409.
A typical reindexing cycle:
wiki_list_pages → the list of (path, rev)
compare rev with the one stored in the graph
wiki_diff format=stat → did anything change in substance
wiki_get_page → fetch the body only for what changed
Neighbouring tools of the same workflow. Nothing links them automatically — there is no code between go-wiki and either of them; they are separate servers an agent or a person happens to use together.
- codebase-memory-mcp — an MCP server that indexes a repository into a code knowledge graph and answers structural questions: who calls what, what a change touches, where a symbol is defined. It is the "where things are" half of the pair described above.
- kaneo — a fork of the usekaneo/kaneo task board that renders the board in three dimensions with camera controls (live demo). That is where a task lives as a card and moves between statuses; go-wiki is where the statement of work behind that card is written down and kept with its history.
/— pages as cards/w/<path>— reading (Markdown is rendered with GFM: tables, task lists, strikethrough)/edit/<path>,/new— the editor/history/<path>— revision history/diff/<path>?from=&to=&view=side|unified— comparison, two columns or a patch/search?q=— search with a filter panel: section, author, contributor, dates, sorting
The tree is edited right in the interface: a page has a "Child page" button (which creates a nested page), and the editor has a "Move in the tree" block that moves the page along with its whole subtree — links to it are repointed the same way as through the API.
The styling follows the BusinessPad design system: colour, typography, spacing
and radius tokens are taken from @business-pad/theme and laid out as CSS
custom properties (the React packages are not available to a Go server). Both
light and dark themes are supported plus a "system" mode: the choice is a
button in the header and is kept in localStorage. The layout is responsive
across the theme's breakpoints (481 / 769 / 1025 / 1441 / 1921 px): below md
the left menu moves into an overlay, the diff table scrolls inside its own
container, and lists turn into cards.
Raw HTML inside Markdown is not rendered — page bodies arrive from agents as well, and none of it counts as trusted markup.
scripts/import_yandex_wiki.py moves pages along with their subtrees: it
converts Yandex Flavored Markdown to plain Markdown, rewrites links between the
imported pages to local ones, and creates index pages for empty sections so the
tree does not fall apart.
export WIKI_CLOUD_ORG_ID=... # the IAM token comes from `yc iam create-token`
python3 scripts/import_yandex_wiki.py \
--root homepage/business-pad \
--match 'obekty-ucheta|obekt-uchjota' \
--target-prefix objekty-ucheta \
--wiki http://127.0.0.1:8080 --dry-runSelection goes by slug (Latin): walking the tree yields only identifiers and
slugs, and checking titles would mean a separate request for each of thousands
of pages. Exact roots can be listed with --slug.
Re-running is idempotent: pages are written with PUT, and go-wiki creates no
revision when the text has not changed.
What the import does not do: images stay as links to wiki.yandex.ru —
go-wiki has no attachment storage, and without access to Yandex Wiki they will
not open. mermaid blocks are kept as code: rendering them is not supported yet.
go test ./... # storage, stemmer, diff engine and API tests
go vet ./...
go build ./cmd/wikiLayout:
cmd/wiki/ entry point: flags, routing, graceful shutdown
internal/wiki/ page path normalization, link rewriting on a move
internal/morph/ word stemming for the search index
internal/store/ SQLite + the .md revision archive
internal/diffx/ per-line diff, unified patch, two columns, inline highlighting
internal/api/ REST
internal/mcpsrv/ MCP tools
internal/web/ HTML templates, CSS with BusinessPad tokens
Apache License 2.0. Copyright 2026 BusinessPad.
The design tokens (palette, typography, grid, radii) come from the BusinessPad design system. The BusinessPad name and logo are trademarks and are not covered by the Apache license, see NOTICE.