diff --git a/.changeset/exec-enriched-reads.md b/.changeset/exec-enriched-reads.md
new file mode 100644
index 000000000..d246803eb
--- /dev/null
+++ b/.changeset/exec-enriched-reads.md
@@ -0,0 +1,5 @@
+---
+"@inkeep/open-knowledge": patch
+---
+
+`exec` reads now surface more of what OpenKnowledge already tracks, in the human-readable listing an agent reads. `ls` shows a folder's available templates (name, description, scope), its description and tags, and direct-vs-recursive file counts with the most-recent date; `cat` shows the backlink and forward-link source paths (not just counts) and a coarse graph role (hub / connector / leaf / orphan); both render triage frontmatter (`status`, `type`) per file.
diff --git a/docs/content/features/meta.json b/docs/content/features/meta.json
index 1fc2dde70..f84c518cf 100644
--- a/docs/content/features/meta.json
+++ b/docs/content/features/meta.json
@@ -8,6 +8,7 @@
"timeline-and-recovery",
"github-sync",
"share",
- "agent-activity"
+ "agent-activity",
+ "skills"
]
}
diff --git a/docs/content/features/skills.mdx b/docs/content/features/skills.mdx
new file mode 100644
index 000000000..81e8dd00b
--- /dev/null
+++ b/docs/content/features/skills.mdx
@@ -0,0 +1,79 @@
+---
+title: Skills
+icon: LuGraduationCap
+description: "What's different about skills in OpenKnowledge: you edit them in the WYSIWYG editor like any doc, version them as content, and symlink one skill into every editor you use."
+---
+
+A skill in OpenKnowledge is content, not a config file: authored in the WYSIWYG editor, versioned like any doc, and symlinked into every editor from one source.
+
+```html preview
+
+
+
+
edit in the WYSIWYG editor
+
+
Log a fishing trip
+
Capture date, spot, and catch, then link the spot.
+
same editor as your docs · versioned
+
+
+
→
install
+
+
symlinked into every editor
+
+
+
+
One .ok/skills source, symlinked into each editor. Edit once, no re-install, no drift.
+
+
+
+```
+
+## Edit skills like docs
+
+A project skill is not a raw text file you hand-edit in a dotfile. It is a document in your base: written in the same WYSIWYG editor, versioned, with the same backlinks and live preview as the rest of your knowledge. Improving a skill is the same flow as improving any page.
+
+## Write once, install everywhere
+
+`install` **symlinks** the skill into each editor's skills directory (Claude Code, Cursor, Codex, OpenCode). Every editor points at the one `.ok/skills/` source, so nothing is copied and nothing drifts: edit the skill once and every agent has the change, with no re-install.
+
+## Two scopes
+
+- **Project** skills live in the base, versioned and shared with it like any other content. The one every project ships with is what makes an agent behave well the moment you connect it: read and write through the tools, cite sources, keep the link graph healthy.
+- **Global** skills follow you across every project on your machine.
+
+
+Skills are an open standard, and a skill can carry scripts that run on your machine. Treat a third-party skill like installing software. Use trusted sources, and read what it does first.
+
+
+## Up next
+
+- **[Agentic search](../reference/agentic-search.mdx)**: how the agent reads your base.
+- **[LLM wiki](../workflows/karpathy-llm-wiki.mdx)**: build a source-grounded knowledge base an agent curates, end to end.
diff --git a/docs/content/reference/agentic-search.mdx b/docs/content/reference/agentic-search.mdx
new file mode 100644
index 000000000..b89fc9c84
--- /dev/null
+++ b/docs/content/reference/agentic-search.mdx
@@ -0,0 +1,252 @@
+---
+title: Agentic search
+icon: LuSearch
+description: "How an agent finds things with no vector database: it searches, greps, and follows backlinks in a loop over live files that come back with their graph context attached, not a vector copy."
+---
+
+OpenKnowledge answers questions across thousands of files with no vector database. Two techniques do the work: retrieval runs as a **loop**, over **virtualized files** that hand the agent a briefing on every read.
+
+## Retrieval is a loop
+
+```html preview
+
+
+
Each step is an MCP tool call; the model picks the next one. A wrong hit just loops back. No fixed pipeline.
+
+
+
+```
+
+Classic RAG embeds your question once and pastes in the nearest chunks; if they are wrong, so is the answer. Here each step is an MCP tool call and the model picks the next one: search, read, follow a backlink, reformulate. A wrong first hit costs one more step, not the whole answer.
+
+## Every read is a briefing
+
+A `cat` through OpenKnowledge returns more than the bytes on disk: the file, frontmatter and all, plus its place in the graph: the backlinks pointing at it, its outbound links, and its version history.
+
+```html preview
+
+
+
+
+
+
+
+
+
+
+```
+
+The same holds for a whole folder. A raw `ls` is a list of filenames; through OpenKnowledge it comes back as a map, so the agent knows where to look next without opening anything.
+
+```html preview
+
+
+
+
+
+
+
+
+
+
+```
+
+And a `grep` returns more than matching lines: each hit carries its file's title, status, and backlink count, so the agent can tell a well-connected hub from a stray mention before opening either.
+
+```html preview
+
+
+
+
+
+
+
+
+
+
+```
+
+That folder briefing is why the loop stays short: the backlinks are what it follows, and the purpose plus recency tell it where to look next.
+
+Three read tools ride this layer: ranked **search** (BM25 and recency, the same index as cmd-K), **`exec`** (sandboxed `grep`/`ls`/`cat`, straight off disk, works with the server down), and the **link graph** (`dead`, `orphans`, `hubs`, `suggest`).
+
+## Writing talks back too
+
+A `cat >>` or a vim save drops bytes on disk and goes quiet. A `write` or `edit` through OpenKnowledge validates what you wrote and hands back what it found, so mistakes surface at write time instead of rotting in the graph.
+
+```html preview
+
+
+
+
+
+
+
+
+
+
+```
+
+`brokenLinks` comes back on every write and edit (empty when they all resolve), so the agent never needs a follow-up dead-link check. A brand-new doc nothing points to comes back flagged as an orphan with a hub to link it from. Every write and edit is versioned, attributed, and the preview updates as it lands. None of that happens when you append to a file by hand. [Skills](../features/skills.mdx) teach the conventions, and the write path enforces the connective tissue.
+
+## The index is authored, not extracted
+
+No vector store means no second copy to rebuild, mis-chunk, or drift. Your links, folders, titles, and folder descriptions *are* the index. Other tools extract structure with an embedding model or an entity-guessing LLM; OpenKnowledge reads the structure you already wrote, so the agent traverses the source of truth. A well-linked base retrieves better because good links shorten the loop. [Folder templates](../advanced/folders-and-templates.mdx) keep that structure consistent as it grows: a folder can carry a template, so every new doc the agent creates starts with the same frontmatter and shape, and the fields search ranks on stay uniform.
+
+## Semantic search (optional)
+
+Off by default. Enable it per project and an embeddings signal blends into the ranking; it never replaces lexical search. Vectors sit in a local cache. Turning it on sends your query and matching text to your configured provider. See [Configuration](./configuration.mdx).
+
diff --git a/docs/content/reference/core-concepts.md b/docs/content/reference/core-concepts.md
index e8eb0aeca..1038d988c 100644
--- a/docs/content/reference/core-concepts.md
+++ b/docs/content/reference/core-concepts.md
@@ -10,6 +10,49 @@ This page is the precise reference for the ideas the rest of the docs build on.
OpenKnowledge is three layers working together: a surface you edit, an engine that keeps it consistent, and the files underneath.
+```html preview
+
+
+
↓ ↓ ↓
+
+
your-project/ · *.md
+
plain markdown, versioned by git
+
+
All three operate on the same files. Nothing locks you out.
+
+
+
+```
+
The application you see: a beautiful, themeable markdown editor that renders rich extensions (Mermaid, LaTeX, video and asset embeds, callouts, collapsible sections, interactive HTML) and lets you read and write your knowledge base directly.
@@ -44,6 +87,36 @@ Internal cross-references are written with **standard markdown links**. The reco
You never write backlinks by hand. They are computed from the links you already write, and together they form the **link graph**: the network of relationships across your knowledge base.
+```html preview
+
+
+
Write the link once; OpenKnowledge records the backlink on the target for you.
+
+
+
+```
+
Backlinks are the payoff of ordinary linking. Every internal link you write earns a backlink on the target for free, so the graph grows as a side effect of normal writing.
@@ -54,6 +127,8 @@ You never write backlinks by hand. They are computed from the links you already
> **A well-connected knowledge base = backlinks + the link-graph tools (dead / orphans / hubs / suggest) + closed-loop grounding.**
+An agent puts all three to work when it retrieves: it searches, greps, and follows backlinks in a loop over your live files, with no vector database. That mechanism is [Agentic search](./agentic-search.mdx).
+
### Backlinks
The automatic inverse relationships described above. They turn a pile of files into a navigable graph.
@@ -71,9 +146,42 @@ The knowledge engine exposes a [`links`](./mcp.mdx) tool whose `kind` selects a
Agents use these to repair and densify the graph as they work, instead of letting it rot.
+```html preview
+
+
+
+
+
+
+
+```
+
### Closed-loop grounding
-Every factual claim should trace back to a source **inside** the knowledge base. External material is pulled in and cited locally rather than linked off to the open web, so the knowledge base stays self-contained and auditable. This is the backbone of the source-grounded workflows: see [Karpathy's LLM wiki workflow](../workflows/karpathy-llm-wiki.mdx) and the [Entity vault (GBrain-compatible) workflow](../workflows/entity-vault.mdx).
+Every factual claim should trace back to a source **inside** the knowledge base. External material is pulled in and cited locally rather than linked off to the open web, so the knowledge base stays self-contained and auditable. This is the backbone of the source-grounded workflows: see the [LLM wiki workflow](../workflows/karpathy-llm-wiki.mdx) and the [Entity vault (GBrain-compatible) workflow](../workflows/entity-vault.mdx).
OpenKnowledge is unopinionated about which workflow you adopt; these are supported patterns, not requirements. Grounding, backlinks, and the graph tools work the same regardless of how you choose to organize.
@@ -83,6 +191,38 @@ Every factual claim should trace back to a source **inside** the knowledge base.
Every change made through OpenKnowledge is tracked, with **attribution** to whoever made it: a human author or a specific AI agent. The change history is persisted in the file system with no dependency beyond git.
+```html preview
+
+
+
Every edit is attributed — a human or a specific agent — and revertible, with nothing beyond git.
+
+
+
+```
+
That gives you:
- **A changelog** of every edit across the knowledge base.
diff --git a/docs/content/reference/mcp.mdx b/docs/content/reference/mcp.mdx
index e45728553..dbfdb30b1 100644
--- a/docs/content/reference/mcp.mdx
+++ b/docs/content/reference/mcp.mdx
@@ -4,9 +4,9 @@ icon: custom/MCP
description: Tools the OpenKnowledge MCP server exposes to AI agents.
---
-The MCP server gives AI agents structured access to your knowledge base. Two reads are server-free: `exec` (shell-style reads, fs-direct against the disk) and `preview_url` (reads `ui.lock` directly) both work even when the editor is down. Every other read tool (`search`, `links`, `history`, etc.) and all write tools route through the Hocuspocus server, which OpenKnowledge starts for you on the first write.
+The MCP server gives AI agents structured access to your knowledge base. A few tools answer without a running server: `exec` (fs-direct shell reads straight off the disk — only the backlink and history enrichment needs the server), plus `config` and `palette`. `preview_url` resolves from the project's lock files, but treats opening a preview as demand: if no server is running it auto-starts one (under the `OK_MCP_AUTOSTART` gate). Every other read tool (`search`, `links`, `history`, etc.) and all write tools route through the Hocuspocus server, which OpenKnowledge starts for you on the first write.
-The surface is **19 tools**. Three of the four write verbs — `write`, `edit`, `delete` — are native CRUD operations polymorphic over a target (`document`, `folder`, `template`, `asset`): per-target fields nest inside the address key (`write({ document: { path, content } })`), and you pass exactly one target per call. `move` is the fourth write verb but takes flat `from`/`to` paths and auto-detects whether the path is a document, folder, or asset (no `template` target).
+The surface is **19 tools**. The four write verbs — `write`, `edit`, `delete`, `move` — are native CRUD operations polymorphic over a target. `write`, `edit`, and `delete` nest per-target fields inside the address key (`write({ document: { path, content } })`) and take exactly one target per call: `document`, `folder`, `template`, or `skill` — plus `asset` for `write` and `delete`. `move` takes flat `from`/`to` paths and auto-detects a document, folder, or asset, with nested `template` and `skill` targets for those two.
## Tools
@@ -15,22 +15,22 @@ The surface is **19 tools**. Three of the four write verbs — `write`, `edit`,
| `exec` | Read-only shell (`cat`, `ls`, `grep`, `find`, `head`, `tail`, `wc`, `sort`, `uniq`, `cut`); reads return frontmatter, backlinks, and recent history. Fs-direct; works without the server. |
| `search` | Ranked workspace search (title boost + body BM25 + recency; same engine the cmd-K palette uses). When [semantic search](/docs/reference/configuration#semantic-search) is enabled, an embeddings signal is additionally fused into `full_text` ranking (opt-in, content egress) — pass `semantic: false` to force pure-lexical. |
| `links` | Wiki-link graph. `kind` selects: `backlinks`, `forward`, `dead`, `orphans`, `hubs`, or `suggest`. Accepts an array (e.g. `["dead", "orphans", "hubs"]`) to fetch several views in one call; each view nests under its own key in the response |
-| `history` | Version timeline for a doc. Each entry carries a `version` (commit SHA) you pass to `restore_version` |
+| `history` | Version timeline for a `document`, `folder`, or `skill`. Each entry carries a `version` (commit SHA) you pass to `restore_version` |
| `skills` | Find and read agent skills. Omit `name` to list every skill across Project + Global; pass `name` to read one (addressed by `name`+`scope`, never by path) |
| `config` | Read the effective merged config (`config({ key: "appearance.theme" })` for a sub-tree; omit `key` for the whole config) |
| `palette` | Markdown-native authoring forms, themed `html preview` embed starters, and theme tokens. Pass `components` for the canonical components' full JSX prop schemas |
| `preview_url` | Resolve the browser-reachable preview URL. Per-response `previewUrl` fields elsewhere are route-only (`/#/`); call this when you need the full openable URL |
| `share_link` | Build a GitHub-substrate share URL for a doc to send to a teammate (read-only against `.git/`; never publishes) |
-| `write` | Create or overwrite a `document`, `folder`, `template`, or `asset`. For a document: `{ path, content }`, or `{ path, template }` to instantiate from a folder template. `position` selects `replace` (full rewrite; the only mode that touches frontmatter), `append`, or `prepend` |
-| `edit` | Modify a `document` (body find/replace **or** a frontmatter merge-patch), a `folder` (frontmatter merge-patch), or a `template` |
-| `delete` | Delete a `document` (one path or an array), `folder`, `template`, or `asset` |
-| `move` | Move or rename a `document`, `folder`, or `asset` and rewrite every affected link |
+| `write` | Create or overwrite a `document`, `folder`, `template`, `skill`, or `asset`. For a document: `{ path, content }`, or `{ path, template }` to instantiate from a folder template. `position` selects `replace` (full rewrite; the only mode that touches frontmatter), `append`, or `prepend` |
+| `edit` | Modify a `document` (body find/replace **or** a frontmatter merge-patch), a `folder` (frontmatter merge-patch), a `template`, or a `skill` |
+| `delete` | Delete a `document` (one path or an array), `folder`, `template`, `skill`, or `asset` |
+| `move` | Move or rename a `document`, `folder`, `asset`, `template`, or `skill`, rewriting every affected link (templates and skills carry no inbound links) |
| `install` | Project an authored skill (the Draft produced by `write({ skill })`) into your editors — the Draft → Installed step |
| `checkpoint` | Save a project-wide version snapshot — a single restore point. Returns its `version` |
-| `restore_version` | Restore one document to a historical `version` (the SHA from `history` or `checkpoint`) |
+| `restore_version` | Restore one `document` or `skill` to a historical `version` (the SHA from `history` or `checkpoint`) |
| `conflicts` | Read GitHub-sync merge conflicts. `kind: "list"` enumerates tracked conflicts; `kind: "content"` returns one file's base / ours / theirs stages |
| `resolve_conflict` | Write a chosen resolution (`mine` / `theirs` / `content` / `delete`) and commit |
-| `workflow` | Procedural guides. `kind` selects: `ingest` (capture a source), `research` (gather + write provisional findings), `consolidate` (promote research to a canonical article), or `discover` (onboard an existing repo) |
+| `workflow` | Procedural guides. `kind` selects: `ingest` (capture a source), `research` (gather + write provisional findings), `consolidate` (promote research to a canonical article), `discover` (onboard an existing repo), or `wiki` (generate a navigable, source-grounded wiki of a codebase) |
## Output shape mirrors the input
@@ -48,7 +48,7 @@ Use `edit({ document: { path, frontmatter } })` for frontmatter edits: it applie
## Edit summaries
-The write tools (`write`, `edit`, `move`, `checkpoint`) accept an optional `summary` describing the intent of the edit, e.g. `"Fixed token-refresh race"`. Summaries appear on the document's timeline so readers can scan recent agent activity.
+The write tools (`write`, `edit`, `move`, `install`, `checkpoint`, `restore_version`) accept an optional `summary` describing the intent of the edit, e.g. `"Fixed token-refresh race"`. Summaries appear on the document's timeline so readers can scan recent agent activity.
Cap is 80 characters. Avoid secrets and PII; summaries persist to git history.
diff --git a/docs/content/reference/meta.json b/docs/content/reference/meta.json
index 66d12ee78..031d4bd54 100644
--- a/docs/content/reference/meta.json
+++ b/docs/content/reference/meta.json
@@ -1,5 +1,12 @@
{
"title": "Reference",
"icon": "LuBookText",
- "pages": ["core-concepts", "cli", "configuration", "mcp", "what-open-knowledge-writes"]
+ "pages": [
+ "core-concepts",
+ "agentic-search",
+ "cli",
+ "configuration",
+ "mcp",
+ "what-open-knowledge-writes"
+ ]
}
diff --git a/docs/content/workflows/karpathy-llm-wiki.mdx b/docs/content/workflows/karpathy-llm-wiki.mdx
index 6262b3c7e..7f9d5d095 100644
--- a/docs/content/workflows/karpathy-llm-wiki.mdx
+++ b/docs/content/workflows/karpathy-llm-wiki.mdx
@@ -1,12 +1,60 @@
---
-title: Karpathy's LLM wiki workflow
+title: LLM wiki
icon: LuBrain
-description: Build a source-grounded knowledge base where an LLM agent curates the wiki from the raw material you feed it. Every claim traces back to a preserved source. Maps directly to the Knowledge base starter pack.
+description: "An AI second brain that doesn't rot: a wiki of your own files an agent reads, writes, and keeps organized. What an LLM wiki is, and how to build one with the Knowledge base starter pack."
---
-A persistent, compounding knowledge base where you bring the raw material and the LLM does the summarization, cross-referencing, and maintenance. The pattern is Andrej Karpathy's, from [his April 2026 gist on LLM-curated wikis](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f). OpenKnowledge ships the **Knowledge base** starter pack as a direct implementation, with one extension Karpathy doesn't formalize: **the wiki layer is split** into `research/` (status: provisional) and `articles/` (status: canonical), with the `consolidate` workflow as the explicit promotion step, so premature canonicalization becomes a thing you opt into rather than something that drifts in.
+An LLM wiki is a knowledge base of your own markdown files that an agent reads, writes, and keeps organized. You supply the raw material. The agent files it, links it, and cites it. It gets more organized the more you use it.
+
+```html preview
+
+
+
Each file is a live CRDT (Yjs) document, so you and the agent can edit the same one and it converges.
+
+
+
+```
+
+The difference from a folder of notes or a RAG app is who maintains it, and how it is stored:
+
+| | Note pile / RAG app | An LLM wiki |
+| --- | --- | --- |
+| Who organizes | you, eventually, never | the agent, as you go |
+| What's stored | raw clips, or an opaque vector copy | authored markdown with structure |
+| Over time | it decays | it compounds |
+| Can you read it | the notes yes, the index no | all of it, it's just files |
+
+The structure you and the agent author (folders, titles, backlinks, a one-line purpose per folder) *is* the index. No second copy, nothing to keep in sync. The agent retrieves across it with [agentic search](../reference/agentic-search.mdx) — searching, grepping, and following backlinks over your live files, no vector database — and you teach it conventions with [skills](../features/skills.mdx) authored in the same editor as your docs.
+
+The rest of this guide builds one end to end. The pattern is Andrej Karpathy's, from [his April 2026 gist on LLM-curated wikis](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f). OpenKnowledge ships the **Knowledge base** starter pack as a direct implementation, with one extension Karpathy doesn't formalize: **the wiki layer is split** into `research/` (status: provisional) and `articles/` (status: canonical), with the `consolidate` workflow as the explicit promotion step, so premature canonicalization becomes a thing you opt into rather than something that drifts in.
-The point of this guide is to show how **OpenKnowledge's features compose** to make the pattern feel native: the starter-pack picker scaffolds the layout in one click; per-folder templates + agent-readable folder frontmatter teach the LLM the conventions (Karpathy's centralized `CLAUDE.md` / `AGENTS.md` schema, distributed per-folder); the `workflow` tool's three pipeline kinds (`ingest`, `research`, `consolidate`) cover the sources → provisional → canonical pipeline; the WYSIWYG editor + CRDT keep editing frictionless; the activity panel attributes every write; the `links` tool keeps the source graph clean. Every step uses three or four of these together. That's the product.
+The point of this guide is to show how **OpenKnowledge's features compose** to make the pattern feel native: the starter-pack picker scaffolds the layout in one click; per-folder templates + agent-readable folder frontmatter teach the LLM the conventions (Karpathy's centralized `CLAUDE.md` / `AGENTS.md` schema, distributed per-folder); the `workflow` tool's `ingest`, `research`, and `consolidate` kinds cover the sources → provisional → canonical pipeline; the WYSIWYG editor + CRDT keep editing frictionless; the activity panel attributes every write; the `links` tool keeps the source graph clean. Every step uses three or four of these together. That's the product.
## Who this is for
@@ -120,7 +168,7 @@ If you accepted the desktop app's first-launch offer to wire up your agent assis
list the workflow tools available
-You should see `mcp__open-knowledge__workflow` (its `kind` covers `ingest` / `research` / `consolidate` / `discover`), alongside the standard reads/writes (`exec`, `search`, `write`, `links`, etc.). The workflow guides cover the sources → provisional → canonical pipeline described in the mapping table above.
+You should see `mcp__open-knowledge__workflow` (its `kind` covers `ingest` / `research` / `consolidate` / `discover` / `wiki`), alongside the standard reads/writes (`exec`, `search`, `write`, `links`, etc.). The workflow guides cover the sources → provisional → canonical pipeline described in the mapping table above.
### 4. Ingest the five sources
@@ -166,7 +214,7 @@ You don't consolidate yet in our scenario; the team hasn't decided. Maybe in two
The `workflow` tool's `consolidate` kind starts with a STOP gate asking whether the decision is actually made. If yes, it writes `articles/agent-framework-evaluation.md` with `status: canonical` and a `supersedes:` chain pointing back to the research log. That `research/` doc isn't deleted; it's superseded, so the evidence chain stays intact.
-**What just composed.** Five sources became one canonical article via: the **Knowledge base starter pack** (three folders, three templates, agent-readable folder frontmatter, one click), the **`workflow` tool's three pipeline kinds** (`ingest`/`research`/`consolidate` mapping 1:1 to Karpathy's layers), the **agent activity panel** (every fetch + write attributed), the **wikilink graph + frontmatter `sources:` arrays** (citation chain audit-able doc by doc), and the **WYSIWYG editor** (review and refine without leaving the same surface). The same vault, the same product, the whole stack working as one thing.
+**What just composed.** Five sources became one canonical article via: the **Knowledge base starter pack** (three folders, three templates, agent-readable folder frontmatter, one click), the **`workflow` tool's `ingest` / `research` / `consolidate` kinds** (mapping 1:1 to Karpathy's layers), the **agent activity panel** (every fetch + write attributed), the **wikilink graph + frontmatter `sources:` arrays** (citation chain audit-able doc by doc), and the **WYSIWYG editor** (review and refine without leaving the same surface). The same vault, the same product, the whole stack working as one thing.
## The promotion rhythm
diff --git a/docs/source.config.ts b/docs/source.config.ts
index 263ea59a3..9e2cdc9ef 100644
--- a/docs/source.config.ts
+++ b/docs/source.config.ts
@@ -21,9 +21,51 @@ export const docs = defineDocs({
},
});
+/**
+ * Turn ` ```html preview ` fences into a live iframe, matching
+ * OpenKnowledge's own editor: the block renders as an interactive sandboxed
+ * preview on both surfaces (native in the OK editor, this plugin on the docs
+ * site). Raw HTML is base64'd onto the JSX attribute so nothing in the block
+ * (quotes, braces, `<`) collides with MDX parsing.
+ */
+function remarkHtmlPreview() {
+ return (tree: unknown) => {
+ const visit = (node: { children?: unknown[] } | null) => {
+ if (!node || !Array.isArray(node.children)) return;
+ for (let i = 0; i < node.children.length; i++) {
+ const child = node.children[i] as {
+ type?: string;
+ lang?: string;
+ meta?: string;
+ value?: string;
+ children?: unknown[];
+ };
+ if (
+ child.type === 'code' &&
+ child.lang === 'html' &&
+ typeof child.meta === 'string' &&
+ /(^|\s)preview(\s|$)/.test(child.meta)
+ ) {
+ const b64 = Buffer.from(child.value ?? '', 'utf8').toString('base64');
+ node.children[i] = {
+ type: 'mdxJsxFlowElement',
+ name: 'HtmlPreview',
+ attributes: [{ type: 'mdxJsxAttribute', name: 'code', value: b64 }],
+ children: [],
+ } as unknown;
+ } else {
+ visit(child as { children?: unknown[] });
+ }
+ }
+ };
+ visit(tree as { children?: unknown[] });
+ };
+}
+
export default defineConfig({
mdxOptions: {
remarkPlugins: [
+ remarkHtmlPreview,
remarkAutoTypeTable,
remarkMdxMermaid,
[mdxSnippet, { snippetsDir: path.resolve(process.cwd(), '_snippets') }],
diff --git a/docs/src/components/html-preview.tsx b/docs/src/components/html-preview.tsx
new file mode 100644
index 000000000..4e1cd55ba
--- /dev/null
+++ b/docs/src/components/html-preview.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { useTheme } from 'next-themes';
+import { useEffect, useId, useState } from 'react';
+
+const BASE =
+ '*{box-sizing:border-box}html,body{margin:0}body{background:transparent;color:var(--foreground);font-family:system-ui,-apple-system,sans-serif;font-size:14px;line-height:1.5}';
+
+const LIGHT =
+ ':root{--background:oklch(1 0 0);--foreground:oklch(0.145 0 0);--card:oklch(1 0 0);--card-foreground:oklch(0.145 0 0);--muted:oklch(0.97 0 0);--muted-foreground:oklch(0.556 0 0);--border:oklch(0.922 0 0);--primary:oklch(0.6321 0.1983 259.59);--accent-soft:#e6efff;--accent-ink:#00245d;--chart-1:oklch(0.62 0.19 259);--chart-2:oklch(0.58 0.14 145);--chart-3:oklch(0.62 0.15 70);--chart-4:oklch(0.55 0.18 290);--chart-5:oklch(0.58 0.21 25);--radius:0.625rem;color-scheme:light}';
+
+const DARK =
+ ':root{--background:oklch(0.205 0 0);--foreground:oklch(0.985 0 0);--card:oklch(0.245 0 0);--card-foreground:oklch(0.985 0 0);--muted:oklch(0.3 0 0);--muted-foreground:oklch(0.708 0 0);--border:oklch(1 0 0 / 0.14);--primary:#69a3ff;--accent-soft:#12233f;--accent-ink:#9ec3ff;--chart-1:oklch(0.72 0.14 259);--chart-2:oklch(0.73 0.13 145);--chart-3:oklch(0.77 0.14 70);--chart-4:oklch(0.72 0.16 290);--chart-5:oklch(0.72 0.2 25);--radius:0.625rem;color-scheme:dark}';
+
+function decode(b64: string): string {
+ if (typeof atob === 'undefined') return '';
+ const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
+ return new TextDecoder().decode(bytes);
+}
+
+export function HtmlPreview({ code }: { code: string }) {
+ const { resolvedTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+ const [height, setHeight] = useState(200);
+ const domId = useId().replace(/[^a-zA-Z0-9]/g, '');
+
+ useEffect(() => setMounted(true), []);
+
+ useEffect(() => {
+ const onMessage = (e: MessageEvent) => {
+ const d = e.data as { __okpreview?: string; h?: number } | null;
+ if (d && d.__okpreview === domId && typeof d.h === 'number') {
+ setHeight(Math.min(Math.ceil(d.h), 5000));
+ }
+ };
+ window.addEventListener('message', onMessage);
+ return () => window.removeEventListener('message', onMessage);
+ }, [domId]);
+
+ if (!mounted) {
+ return (
+
+ );
+ }
+
+ const vars = resolvedTheme === 'dark' ? DARK : LIGHT;
+ const inner = decode(code);
+ const srcDoc = `${inner}`;
+
+ return (
+
+ );
+}
diff --git a/docs/src/mdx-components.tsx b/docs/src/mdx-components.tsx
index 2e6cfa279..5213b73ba 100644
--- a/docs/src/mdx-components.tsx
+++ b/docs/src/mdx-components.tsx
@@ -9,6 +9,7 @@ import { AgentIcons } from '@/components/agent-icons';
import { CopyPrompt } from '@/components/copy-prompt';
import { CtaButton } from '@/components/cta-button';
import { DownloadButton } from '@/components/download-button';
+import { HtmlPreview } from '@/components/html-preview';
import { McpInstall } from '@/components/mcp-install';
import { Mermaid } from '@/components/mermaid';
import { LayerStack, WhereToStart } from '@/components/overview-blocks';
@@ -26,6 +27,7 @@ export function getMDXComponents(): MDXComponents {
CopyPrompt,
CtaButton,
DownloadButton,
+ HtmlPreview,
Image: ImageZoom,
LayerStack,
McpInstall,
diff --git a/packages/server/src/content/enrichment.test.ts b/packages/server/src/content/enrichment.test.ts
index 2cdd00c05..85246954f 100644
--- a/packages/server/src/content/enrichment.test.ts
+++ b/packages/server/src/content/enrichment.test.ts
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { commitWip, initShadowRepo, type WriterIdentity } from '@inkeep/open-knowledge-server';
import simpleGit from 'simple-git';
-import { enrichDirectory, enrichPath } from './enrichment.ts';
+import { computeGraphRole, enrichDirectory, enrichPath } from './enrichment.ts';
let tmpDir: string;
@@ -258,3 +258,30 @@ describe('enrichPath/enrichDirectory — defense-in-depth path containment', ()
);
});
});
+
+describe('computeGraphRole', () => {
+ test('null when neither count is known', () => {
+ expect(computeGraphRole(null, null)).toBe(null);
+ });
+ test('null on partial data (one count unknown)', () => {
+ expect(computeGraphRole(null, 3)).toBe(null);
+ expect(computeGraphRole(3, null)).toBe(null);
+ });
+ test('orphan when there are no links', () => {
+ expect(computeGraphRole(0, 0)).toBe('orphan');
+ });
+ test('hub at or above the inbound floor', () => {
+ expect(computeGraphRole(5, 0)).toBe('hub');
+ expect(computeGraphRole(9, 2)).toBe('hub');
+ });
+ test('connector with links in and out below the hub floor', () => {
+ expect(computeGraphRole(2, 3)).toBe('connector');
+ });
+ test('one below the hub floor is a connector, not a hub', () => {
+ expect(computeGraphRole(4, 1)).toBe('connector');
+ });
+ test('leaf with a few links in one direction only', () => {
+ expect(computeGraphRole(0, 2)).toBe('leaf');
+ expect(computeGraphRole(2, 0)).toBe('leaf');
+ });
+});
diff --git a/packages/server/src/content/enrichment.ts b/packages/server/src/content/enrichment.ts
index 4b2781160..69430e699 100644
--- a/packages/server/src/content/enrichment.ts
+++ b/packages/server/src/content/enrichment.ts
@@ -92,6 +92,24 @@ export interface EnrichedMeta {
historySource: HistorySource | null;
projectHistory: GitCommit[] | null;
projectHistorySource: ProjectHistorySource | null;
+ graphRole: GraphRole | null;
+}
+
+export type GraphRole = 'hub' | 'connector' | 'leaf' | 'orphan';
+
+const HUB_MIN_INBOUND = 5;
+
+export function computeGraphRole(
+ backlinkCount: number | null,
+ forwardLinkCount: number | null,
+): GraphRole | null {
+ if (backlinkCount === null || forwardLinkCount === null) return null;
+ const inbound = backlinkCount;
+ const outbound = forwardLinkCount;
+ if (inbound === 0 && outbound === 0) return 'orphan';
+ if (inbound >= HUB_MIN_INBOUND) return 'hub';
+ if (inbound > 0 && outbound > 0) return 'connector';
+ return 'leaf';
}
interface EnrichPathDeps {
@@ -277,6 +295,7 @@ export async function enrichPath(
historySource: null,
projectHistory: null,
projectHistorySource: null,
+ graphRole: null,
};
}
@@ -309,6 +328,7 @@ export async function enrichPath(
historySource: shadow.source,
projectHistory: project.commits,
projectHistorySource: project.source,
+ graphRole: computeGraphRole(backlinks?.length ?? null, forwardLinks?.length ?? null),
};
}
diff --git a/packages/server/src/mcp/tools/exec.test.ts b/packages/server/src/mcp/tools/exec.test.ts
index d0fa6bbe9..44108ffa1 100644
--- a/packages/server/src/mcp/tools/exec.test.ts
+++ b/packages/server/src/mcp/tools/exec.test.ts
@@ -11,6 +11,7 @@ import {
buildExecResult,
DESCRIPTION,
type ExecStructuredResult,
+ formatFileEntry,
RESULT_BODY_BUDGET_BYTES,
WIRE_BODY_COPIES,
} from './exec.ts';
@@ -183,6 +184,8 @@ describe('exec — happy path', () => {
expect(specAEntry?.mostRecentMd).toBeDefined();
expect(result.content[0].text).toContain('specs/spec-a/');
expect(result.content[0].text).toContain('md file');
+ expect(result.content[0].text).toContain('1 md file here (2 in tree)');
+ expect(result.content[0].text).toMatch(/most recent:.*\d{4}-\d{2}-\d{2}/);
});
test('ls with explicit dir arg surfaces nested .ok/frontmatter.yml folder defaults', async () => {
@@ -250,6 +253,8 @@ describe('exec — happy path', () => {
expect(rfc).toBeDefined();
expect(rfc?.title).toBe('RFC');
expect(rfc?.description).toBe('Request for comments');
+ expect(result.content[0].text).toContain('templates: rfc');
+ expect(result.content[0].text).toContain('Request for comments');
});
});
@@ -829,3 +834,111 @@ describe('exec — explicit subdirectory cwd', () => {
expect(fileEntries(structured(result))[0].path).toBe('articles/auth.md');
});
});
+
+describe('formatFileEntry rendering', () => {
+ test('renders graph role, status, and type in the text an agent reads', () => {
+ const line = formatFileEntry({
+ path: 'architecture/auth.md',
+ title: 'Authentication',
+ tags: [],
+ frontmatter: { status: 'canonical', type: 'spec' },
+ backlinkCount: 6,
+ backlinks: null,
+ forwardLinkCount: 2,
+ forwardLinks: null,
+ history: null,
+ historySource: null,
+ projectHistory: null,
+ projectHistorySource: null,
+ graphRole: 'hub',
+ });
+ expect(line).toContain('graph: hub');
+ expect(line).toContain('status: canonical');
+ expect(line).toContain('type: spec');
+ });
+
+ test('inlines backlink and forward-link source paths', () => {
+ const line = formatFileEntry({
+ path: 'architecture/auth.md',
+ title: 'Authentication',
+ tags: [],
+ frontmatter: {},
+ backlinkCount: 2,
+ backlinks: [
+ { source: 'login.md', title: 'Login' },
+ { source: 'sessions.md', title: 'Sessions' },
+ ],
+ forwardLinkCount: 1,
+ forwardLinks: [{ kind: 'doc', docName: 'tokens.md', title: 'Tokens' }],
+ history: null,
+ historySource: null,
+ projectHistory: null,
+ projectHistorySource: null,
+ graphRole: null,
+ });
+ expect(line).toContain('backlinks: 2 (login.md, sessions.md)');
+ expect(line).toContain('forward links: 1 (tokens.md)');
+ });
+
+ test('omits the graph line when graphRole is null', () => {
+ const line = formatFileEntry({
+ path: 'architecture/auth.md',
+ title: 'Authentication',
+ tags: [],
+ frontmatter: {},
+ backlinkCount: null,
+ backlinks: null,
+ forwardLinkCount: null,
+ forwardLinks: null,
+ history: null,
+ historySource: null,
+ projectHistory: null,
+ projectHistorySource: null,
+ graphRole: null,
+ });
+ expect(line).not.toContain('graph:');
+ });
+
+ test('truncates link source lists past 5 with an ellipsis', () => {
+ const line = formatFileEntry({
+ path: 'architecture/auth.md',
+ title: 'Authentication',
+ tags: [],
+ frontmatter: {},
+ backlinkCount: 7,
+ backlinks: [1, 2, 3, 4, 5, 6, 7].map((n) => ({ source: `src${n}.md`, title: `Src ${n}` })),
+ forwardLinkCount: 7,
+ forwardLinks: [1, 2, 3, 4, 5, 6, 7].map((n) => ({
+ kind: 'doc' as const,
+ docName: `dst${n}.md`,
+ title: `Dst ${n}`,
+ })),
+ history: null,
+ historySource: null,
+ projectHistory: null,
+ projectHistorySource: null,
+ graphRole: null,
+ });
+ expect(line).toContain('backlinks: 7 (src1.md, src2.md, src3.md, src4.md, src5.md, …)');
+ expect(line).toContain('forward links: 7 (dst1.md, dst2.md, dst3.md, dst4.md, dst5.md, …)');
+ });
+
+ test('renders the url for an external forward link', () => {
+ const line = formatFileEntry({
+ path: 'architecture/auth.md',
+ title: 'Authentication',
+ tags: [],
+ frontmatter: {},
+ backlinkCount: null,
+ backlinks: null,
+ forwardLinkCount: 1,
+ forwardLinks: [{ kind: 'external' as const, url: 'https://example.com', title: 'Example' }],
+ history: null,
+ historySource: null,
+ projectHistory: null,
+ projectHistorySource: null,
+ graphRole: null,
+ });
+ expect(line).toContain('forward links: 1 (https://example.com)');
+ });
+});
diff --git a/packages/server/src/mcp/tools/exec.ts b/packages/server/src/mcp/tools/exec.ts
index 35244dfa0..8b2e4d56f 100644
--- a/packages/server/src/mcp/tools/exec.ts
+++ b/packages/server/src/mcp/tools/exec.ts
@@ -177,28 +177,60 @@ function formatDirectoryEntry(d: DirectoryMeta): string {
const parts: string[] = [leader];
if (d.description) parts.push(d.description);
if (d.tags && d.tags.length > 0) parts.push(`tags: ${d.tags.join(', ')}`);
+ if (d.templates_available && d.templates_available.length > 0) {
+ const tpl = d.templates_available
+ .map((t) =>
+ t.description ? `${t.name} — ${t.description} (${t.scope})` : `${t.name} (${t.scope})`,
+ )
+ .join(', ');
+ parts.push(`templates: ${tpl}`);
+ }
const counts: string[] = [];
- counts.push(`${d.recursiveMdCount} md file${d.recursiveMdCount === 1 ? '' : 's'}`);
+ counts.push(
+ d.recursiveMdCount > d.directMdCount
+ ? `${d.directMdCount} md file${d.directMdCount === 1 ? '' : 's'} here (${d.recursiveMdCount} in tree)`
+ : `${d.directMdCount} md file${d.directMdCount === 1 ? '' : 's'}`,
+ );
if (d.childDirCount > 0) {
counts.push(`${d.childDirCount} subdir${d.childDirCount === 1 ? '' : 's'}`);
}
parts.push(counts.join(', '));
if (d.mostRecentMd) {
+ const when = d.mostRecentMd.updatedAt ? `, ${d.mostRecentMd.updatedAt.slice(0, 10)}` : '';
parts.push(
- `most recent: ${d.mostRecentMd.title ?? d.mostRecentMd.path} (${d.mostRecentMd.path})`,
+ `most recent: ${d.mostRecentMd.title ?? d.mostRecentMd.path} (${d.mostRecentMd.path}${when})`,
);
}
if (d.truncated) parts.push('scan truncated');
return `- ${parts.join(' — ')}`;
}
-function formatFileEntry(m: EnrichedMeta): string {
+export function formatFileEntry(m: EnrichedMeta): string {
const title = m.title ?? m.path;
const parts: string[] = [`**${title}** (${m.path})`];
if (m.description) parts.push(m.description);
if (m.tags.length > 0) parts.push(`tags: ${m.tags.join(', ')}`);
- if (m.backlinkCount !== null) parts.push(`backlinks: ${m.backlinkCount}`);
- if (m.forwardLinkCount !== null) parts.push(`forward links: ${m.forwardLinkCount}`);
+ if (m.graphRole) parts.push(`graph: ${m.graphRole}`);
+ const fmStatus = typeof m.frontmatter.status === 'string' ? m.frontmatter.status : null;
+ const fmType = typeof m.frontmatter.type === 'string' ? m.frontmatter.type : null;
+ if (fmStatus) parts.push(`status: ${fmStatus}`);
+ if (fmType) parts.push(`type: ${fmType}`);
+ if (m.backlinkCount !== null) {
+ let line = `backlinks: ${m.backlinkCount}`;
+ if (m.backlinks && m.backlinks.length > 0) {
+ const names = m.backlinks.slice(0, 5).map((b) => b.source);
+ line += ` (${names.join(', ')}${m.backlinks.length > 5 ? ', …' : ''})`;
+ }
+ parts.push(line);
+ }
+ if (m.forwardLinkCount !== null) {
+ let line = `forward links: ${m.forwardLinkCount}`;
+ if (m.forwardLinks && m.forwardLinks.length > 0) {
+ const names = m.forwardLinks.slice(0, 5).map((f) => (f.kind === 'doc' ? f.docName : f.url));
+ line += ` (${names.join(', ')}${m.forwardLinks.length > 5 ? ', …' : ''})`;
+ }
+ parts.push(line);
+ }
if (m.history && m.history.length > 0) {
const entries = m.history.map((h) => {
const who =
@@ -401,6 +433,7 @@ export async function buildExecResult(
historySource: null,
projectHistory: null,
projectHistorySource: null,
+ graphRole: null,
}),
),
),