Add LLM wiki plugin

This commit is contained in:
Dotta committed 2026-05-04 14:59:32 -05:00
1 parent d6bee62f02
commit 9529c5b3b0
76 files changed
+14579

No files matched your search

@@ -0,0 +1,3 @@
dist
node_modules
.paperclip-sdk
+116
View File
@@ -0,0 +1,116 @@
# LLM Wiki
Local-file LLM Wiki plugin for source ingestion, wiki browsing, query, lint, and maintenance workflows.
## Scope
This package is the standalone home for LLM Wiki behavior. Wiki-specific routes,
UI, prompts, tools, local-folder templates, migrations, fixtures, and tests live
here rather than in Paperclip core.
The alpha surface includes:
- manifest-declared Wiki page, sidebar entry, and settings page
- trusted local folder declaration for `raw/`, `wiki/`, `AGENTS.md`, `IDEA.md`, `wiki/index.md`, and `wiki/log.md`
- plugin database namespace migration for wiki instances, sources, pages, operations, query sessions, and resource bindings
- managed `Wiki Maintainer` agent, managed `LLM Wiki` project, and paused managed routines for wiki update processing, lint, and index refresh
- plugin-operation issue creation using `surfaceVisibility: "plugin_operation"`
- local source capture into `raw/` with metadata rows in the plugin DB namespace
- opt-in company-scoped Paperclip event ingestion controls for issues, comments, and documents; event ingestion is disabled by default
- manual Paperclip project/root issue distillation and bounded backfill actions with explicit work items, operation issues, source caps, and estimated cost-cap refusal
- wiki page writes with plugin path validation, atomic local-folder writes, metadata/revision rows, backlink extraction, and optional stale-hash protection
- wiki tools for search/read/write/propose patch/source/log/index/backlinks workflows
## Development
```bash
pnpm install
pnpm dev # watch builds
pnpm dev:ui # local dev server with hot-reload events
pnpm test
```
From the Paperclip repo root:
```bash
pnpm --filter @paperclipai/plugin-llm-wiki typecheck
pnpm --filter @paperclipai/plugin-llm-wiki test
pnpm --filter @paperclipai/plugin-llm-wiki build
```
## Alpha Verification
Run these commands from the Paperclip repo root before handing off alpha plugin
changes:
```bash
pnpm --filter @paperclipai/plugin-llm-wiki typecheck
pnpm --filter @paperclipai/plugin-llm-wiki test
pnpm --filter @paperclipai/plugin-llm-wiki build
```
The focused Vitest suite covers:
- standalone package boundaries and package-local harness dependencies
- required local folder bootstrap writes
- raw source capture plus ingest metadata persistence
- hidden plugin-operation issue creation for ingest/query/file-as-page workflows
- disabled and enabled Paperclip event ingestion paths
- managed routine declarations, manual distill/backfill work items, cost cap refusal, and backfill project/date scoping
- atomic page writes, metadata/revision rows, backlinks, and stale-hash refusal
- query session creation, run-id recording, stream event forwarding, and completion updates
- filing a streamed query answer back into the wiki through a hidden operation
Remaining alpha gaps:
- Browser screenshot capture is maintained separately under `tests/screenshots`
and is not part of the cheap package verification path above.
- Host-level plugin install and live agent invocation still need Paperclip
server/runtime smoke coverage when preparing a release candidate.
## Install Into Paperclip
```bash
curl -X POST http://127.0.0.1:3100/api/plugins/install \
-H "Content-Type: application/json" \
-d '{"packageName":"/Users/dotta/paperclip/.paperclip/worktrees/PAP-3179-design-a-llm-wiki-plugin/packages/plugins/plugin-llm-wiki","isLocalPath":true}'
```
## Build Options
- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`.
- `pnpm build:rollup` uses rollup presets from the same SDK.
## Local File Layout
```text
<configured-wiki-root>/
AGENTS.md
IDEA.md
.gitignore
raw/
.gitkeep
wiki/
index.md
log.md
sources/
.gitkeep
projects/
.gitkeep
entities/
.gitkeep
concepts/
.gitkeep
synthesis/
.gitkeep
```
Use the settings page or `bootstrap-root` action to configure the folder and
write the starter files. The plugin uses Paperclip's local folder API for path
containment, symlink checks, read/write validation, and atomic writes.
Bootstrap preserves existing files rather than overwriting operator edits. The
default first-install skeleton is copied from the vanilla LLM Wiki layout, with
`CLAUDE.md` renamed to `AGENTS.md`.
@@ -0,0 +1,17 @@
import esbuild from "esbuild";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
const watch = process.argv.includes("--watch");
const workerCtx = await esbuild.context(presets.esbuild.worker);
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
const uiCtx = await esbuild.context(presets.esbuild.ui);
if (watch) {
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
console.log("esbuild watch mode enabled for worker, manifest, and ui");
} else {
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
}
@@ -0,0 +1,3 @@
.DS_Store
.obsidian/workspace*
.obsidian/cache
@@ -0,0 +1,123 @@
# AGENTS.md — LLM Wiki Schema
You are the maintainer of this personal wiki. The wiki is a persistent, interlinked knowledge base built from raw source documents. You read sources, extract knowledge, and integrate it into evolving wiki pages. The user curates sources, directs analysis, and asks questions; you handle the bookkeeping.
The underlying pattern is described in `IDEA.md` (Karpathy's "LLM Wiki" gist). Read it if you need the philosophy; this file is the operational schema.
## Layout
```
.
├── AGENTS.md # this file — your operating instructions
├── IDEA.md # the pattern this wiki follows
├── raw/ # immutable source documents (you read, never write)
└── wiki/ # generated, owned by you
├── index.md # catalog of all pages
├── log.md # append-only timeline of operations
├── sources/ # one summary page per source
├── projects/ # durable project pages, status, decisions, and history
├── entities/ # people, organizations, products, places
├── concepts/ # ideas, frameworks, definitions
└── synthesis/ # cross-cutting analysis, comparisons, theses
```
The subdirectories under `wiki/` are conventional, not enforced. Add new categories (e.g. `wiki/papers/`) as the domain demands — and update this file when you do.
## Page conventions
- **Filename:** kebab-case, `.md`. Treat filenames as stable; do not rename without updating backlinks.
- **Frontmatter:** YAML at the top of every wiki page.
```yaml
---
title: Human-readable title
type: source | project | entity | concept | synthesis
tags: [tag-a, tag-b]
sources: [raw/doc.pdf] # for source pages and synthesis pages
created: YYYY-MM-DD
updated: YYYY-MM-DD
---
```
- **Cross-links:** Obsidian-style `[[wiki/entities/some-page]]` (or `[[some-page]]` when unambiguous). When you mention a concept or entity that has — or should have — its own page, link it.
- **Citations:** cite the source inline whenever a claim comes from one: `(see [[wiki/sources/some-slug]])`.
- **Voice:** terse, factual, neutral. The wiki is reference material, not narrative.
## Operations
### Ingest
Triggered when the user drops a file in `raw/` and asks to process it (or just says "ingest").
1. Read the source end to end.
2. Briefly discuss key takeaways with the user before writing — confirm what to emphasize.
3. Create `wiki/sources/<slug>.md`: a summary page (~300–800 words) covering the source's main claims, structure, and notable quotes or data.
4. Update or create relevant pages in `entities/`, `concepts/`, `synthesis/`. A typical ingest touches 5–15 pages.
5. Add any new pages to `wiki/index.md`.
6. Append a log entry:
```
## [YYYY-MM-DD] ingest | <source title>
- source: raw/<filename>
- new pages: [[...]], [[...]]
- updated pages: [[...]], [[...]]
- notes: <one-line synthesis, contradiction flagged, or open question>
```
When new information contradicts an existing page, do **not** silently overwrite. Flag the contradiction on the page (a `> ⚠ contradicted by [[...]] (YYYY-MM-DD)` callout) and note it in the log.
### Project updates
Triggered when Paperclip project, issue, plan, comment, blocker, or status history is distilled into the wiki.
1. Create or update `wiki/projects/<project-slug>.md` as the durable project overview. Keep stable sections for overview, current status, recent changes, decisions, open risks/blockers, active issues, artifacts/links, and source provenance.
2. Use `wiki/projects/<project-slug>/decisions.md` for accepted/rejected plans, architectural decisions, approval outcomes, and reversals when a project has enough decision history to warrant a separate page.
3. Use `wiki/projects/<project-slug>/history.md` for a compact timeline of meaningful project changes. Do not mirror every issue comment.
4. Always cite Paperclip source material by linking back to issue identifiers, document keys, and raw/source pages. Preserve `current_as_of` metadata so readers know when the project page may be stale.
5. Update `wiki/index.md` under Projects and append a `project` log entry to `wiki/log.md`.
### Query
The user asks a question. You:
1. Read `wiki/index.md` to find candidate pages.
2. Read those pages; follow links as needed.
3. Answer with citations back to wiki pages, and ultimately to raw sources.
4. If the answer is substantial (a comparison, analysis, new synthesis), offer to file it under `wiki/synthesis/` so the work compounds rather than disappearing into chat history.
If the wiki lacks what the question needs, say so plainly and suggest sources to ingest or web searches to run.
### Lint
On request ("lint", "health check"), scan for:
- contradictions across pages
- claims a newer source has superseded
- orphan pages (not linked from `index.md` or any other page)
- concepts mentioned in multiple places but lacking a dedicated page
- broken `[[wiki-links]]`
- gaps where a web search or new source would help
Report findings as a checklist and ask the user which to act on.
## index.md format
A catalog organized by category. Each line: `- [[path]] — one-line summary`. Keep it scannable; this is your primary navigation aid before opening pages.
## log.md format
Append new entries to the bottom. Every entry header follows:
```
## [YYYY-MM-DD] <op> | <subject>
```
so `grep "^## \[" wiki/log.md | tail -10` always returns recent activity. Operations: `ingest`, `query`, `lint`, `setup`, `refactor`.
## Customization
This schema is intentionally generic. As the wiki's domain becomes clear, evolve it:
- add domain-specific page types and subdirectories
- adjust frontmatter fields
- specify preferred output formats for queries (Marp slides, charts, tables)
- record workflow preferences (one-at-a-time vs batch ingest, level of human supervision)
When you and the user agree on a convention, **write it into this file**. The schema is the wiki's source of truth for how the wiki is built.
@@ -0,0 +1,75 @@
# LLM Wiki
A pattern for building personal knowledge bases using LLMs.
This is an idea file, it is designed to be copy pasted to your own LLM Agent (e.g. OpenAI Codex, Claude Code, OpenCode / Pi, or etc.). Its goal is to communicate the high level idea, but your agent will build out the specifics in collaboration with you.
## The core idea
Most people's experience with LLMs and documents looks like RAG: you upload a collection of files, the LLM retrieves relevant chunks at query time, and generates an answer. This works, but the LLM is rediscovering knowledge from scratch on every question. There's no accumulation. Ask a subtle question that requires synthesizing five documents, and the LLM has to find and piece together the relevant fragments every time. Nothing is built up. NotebookLM, ChatGPT file uploads, and most RAG systems work this way.
The idea here is different. Instead of just retrieving from raw documents at query time, the LLM **incrementally builds and maintains a persistent wiki** — a structured, interlinked collection of markdown files that sits between you and the raw sources. When you add a new source, the LLM doesn't just index it for later retrieval. It reads it, extracts the key information, and integrates it into the existing wiki — updating entity pages, revising topic summaries, noting where new data contradicts old claims, strengthening or challenging the evolving synthesis. The knowledge is compiled once and then *kept current*, not re-derived on every query.
This is the key difference: **the wiki is a persistent, compounding artifact.** The cross-references are already there. The contradictions have already been flagged. The synthesis already reflects everything you've read. The wiki keeps getting richer with every source you add and every question you ask.
You never (or rarely) write the wiki yourself — the LLM writes and maintains all of it. You're in charge of sourcing, exploration, and asking the right questions. The LLM does all the grunt work — the summarizing, cross-referencing, filing, and bookkeeping that makes a knowledge base actually useful over time. In practice, I have the LLM agent open on one side and Obsidian open on the other. The LLM makes edits based on our conversation, and I browse the results in real time — following links, checking the graph view, reading the updated pages. Obsidian is the IDE; the LLM is the programmer; the wiki is the codebase.
This can apply to a lot of different contexts. A few examples:
- **Personal**: tracking your own goals, health, psychology, self-improvement — filing journal entries, articles, podcast notes, and building up a structured picture of yourself over time.
- **Research**: going deep on a topic over weeks or months — reading papers, articles, reports, and incrementally building a comprehensive wiki with an evolving thesis.
- **Reading a book**: filing each chapter as you go, building out pages for characters, themes, plot threads, and how they connect. By the end you have a rich companion wiki. Think of fan wikis like [Tolkien Gateway](https://tolkiengateway.net/wiki/Main_Page) — thousands of interlinked pages covering characters, places, events, languages, built by a community of volunteers over years. You could build something like that personally as you read, with the LLM doing all the cross-referencing and maintenance.
- **Business/team**: an internal wiki maintained by LLMs, fed by Slack threads, meeting transcripts, project documents, customer calls. Possibly with humans in the loop reviewing updates. The wiki stays current because the LLM does the maintenance that no one on the team wants to do.
- **Competitive analysis, due diligence, trip planning, course notes, hobby deep-dives** — anything where you're accumulating knowledge over time and want it organized rather than scattered.
## Architecture
There are three layers:
**Raw sources** — your curated collection of source documents. Articles, papers, images, data files. These are immutable — the LLM reads from them but never modifies them. This is your source of truth.
**The wiki** — a directory of LLM-generated markdown files. Summaries, entity pages, concept pages, comparisons, an overview, a synthesis. The LLM owns this layer entirely. It creates pages, updates them when new sources arrive, maintains cross-references, and keeps everything consistent. You read it; the LLM writes it.
**The schema** — a document (e.g. AGENTS.md for Paperclip agents) that tells the LLM how the wiki is structured, what the conventions are, and what workflows to follow when ingesting sources, answering questions, or maintaining the wiki. This is the key configuration file — it's what makes the LLM a disciplined wiki maintainer rather than a generic chatbot. You and the LLM co-evolve this over time as you figure out what works for your domain.
## Operations
**Ingest.** You drop a new source into the raw collection and tell the LLM to process it. An example flow: the LLM reads the source, discusses key takeaways with you, writes a summary page in the wiki, updates the index, updates relevant entity and concept pages across the wiki, and appends an entry to the log. A single source might touch 10-15 wiki pages. Personally I prefer to ingest sources one at a time and stay involved — I read the summaries, check the updates, and guide the LLM on what to emphasize. But you could also batch-ingest many sources at once with less supervision. It's up to you to develop the workflow that fits your style and document it in the schema for future sessions.
**Query.** You ask questions against the wiki. The LLM searches for relevant pages, reads them, and synthesizes an answer with citations. Answers can take different forms depending on the question — a markdown page, a comparison table, a slide deck (Marp), a chart (matplotlib), a canvas. The important insight: **good answers can be filed back into the wiki as new pages.** A comparison you asked for, an analysis, a connection you discovered — these are valuable and shouldn't disappear into chat history. This way your explorations compound in the knowledge base just like ingested sources do.
**Lint.** Periodically, ask the LLM to health-check the wiki. Look for: contradictions between pages, stale claims that newer sources have superseded, orphan pages with no inbound links, important concepts mentioned but lacking their own page, missing cross-references, data gaps that could be filled with a web search. The LLM is good at suggesting new questions to investigate and new sources to look for. This keeps the wiki healthy as it grows.
## Indexing and logging
Two special files help the LLM (and you) navigate the wiki as it grows. They serve different purposes:
**index.md** is content-oriented. It's a catalog of everything in the wiki — each page listed with a link, a one-line summary, and optionally metadata like date or source count. Organized by category (entities, concepts, sources, etc.). The LLM updates it on every ingest. When answering a query, the LLM reads the index first to find relevant pages, then drills into them. This works surprisingly well at moderate scale (~100 sources, ~hundreds of pages) and avoids the need for embedding-based RAG infrastructure.
**log.md** is chronological. It's an append-only record of what happened and when — ingests, queries, lint passes. A useful tip: if each entry starts with a consistent prefix (e.g. `## [2026-04-02] ingest | Article Title`), the log becomes parseable with simple unix tools — `grep "^## \[" log.md | tail -5` gives you the last 5 entries. The log gives you a timeline of the wiki's evolution and helps the LLM understand what's been done recently.
## Optional: CLI tools
At some point you may want to build small tools that help the LLM operate on the wiki more efficiently. A search engine over the wiki pages is the most obvious one — at small scale the index file is enough, but as the wiki grows you want proper search. [qmd](https://github.com/tobi/qmd) is a good option: it's a local search engine for markdown files with hybrid BM25/vector search and LLM re-ranking, all on-device. It has both a CLI (so the LLM can shell out to it) and an MCP server (so the LLM can use it as a native tool). You could also build something simpler yourself — the LLM can help you vibe-code a naive search script as the need arises.
## Tips and tricks
- **Obsidian Web Clipper** is a browser extension that converts web articles to markdown. Very useful for quickly getting sources into your raw collection.
- **Download images locally.** In Obsidian Settings → Files and links, set "Attachment folder path" to a fixed directory (e.g. `raw/assets/`). Then in Settings → Hotkeys, search for "Download" to find "Download attachments for current file" and bind it to a hotkey (e.g. Ctrl+Shift+D). After clipping an article, hit the hotkey and all images get downloaded to local disk. This is optional but useful — it lets the LLM view and reference images directly instead of relying on URLs that may break. Note that LLMs can't natively read markdown with inline images in one pass — the workaround is to have the LLM read the text first, then view some or all of the referenced images separately to gain additional context. It's a bit clunky but works well enough.
- **Obsidian's graph view** is the best way to see the shape of your wiki — what's connected to what, which pages are hubs, which are orphans.
- **Marp** is a markdown-based slide deck format. Obsidian has a plugin for it. Useful for generating presentations directly from wiki content.
- **Dataview** is an Obsidian plugin that runs queries over page frontmatter. If your LLM adds YAML frontmatter to wiki pages (tags, dates, source counts), Dataview can generate dynamic tables and lists.
- The wiki is just a git repo of markdown files. You get version history, branching, and collaboration for free.
## Why this works
The tedious part of maintaining a knowledge base is not the reading or the thinking — it's the bookkeeping. Updating cross-references, keeping summaries current, noting when new data contradicts old claims, maintaining consistency across dozens of pages. Humans abandon wikis because the maintenance burden grows faster than the value. LLMs don't get bored, don't forget to update a cross-reference, and can touch 15 files in one pass. The wiki stays maintained because the cost of maintenance is near zero.
The human's job is to curate sources, direct the analysis, ask good questions, and think about what it all means. The LLM's job is everything else.
The idea is related in spirit to Vannevar Bush's Memex (1945) — a personal, curated knowledge store with associative trails between documents. Bush's vision was closer to this than to what the web became: private, actively curated, with the connections between documents as valuable as the documents themselves. The part he couldn't solve was who does the maintenance. The LLM handles that.
## Note
This document is intentionally abstract. It describes the idea, not a specific implementation. The exact directory structure, the schema conventions, the page formats, the tooling — all of that will depend on your domain, your preferences, and your LLM of choice. Everything mentioned above is optional and modular — pick what's useful, ignore what isn't. For example: your sources might be text-only, so you don't need image handling at all. Your wiki might be small enough that the index file is all you need, no search engine required. You might not care about slide decks and just want markdown pages. You might want a completely different set of output formats. The right way to use this is to share it with your LLM agent and work together to instantiate a version that fits your needs. The document's only job is to communicate the pattern. Your LLM can figure out the rest.
Whitespace-only changes.
@@ -0,0 +1,3 @@
# Raw Source
LLM Wiki code must live in the standalone plugin package.
@@ -0,0 +1,3 @@
# Knowledge
The wiki stores durable knowledge as local markdown files.
@@ -0,0 +1,3 @@
# Plugin Boundaries
LLM Wiki routes, prompts, UI, tools, and migrations belong inside the plugin.
@@ -0,0 +1,23 @@
# Index
Catalog of all wiki pages. Updated on every ingest.
## Sources
_(none yet)_
## Projects
_(none yet)_
## Entities
_(none yet)_
## Concepts
_(none yet)_
## Synthesis
_(none yet)_
@@ -0,0 +1,9 @@
# Log
Append-only chronological record of wiki operations.
## [2026-05-03] setup | wiki initialized
- created `AGENTS.md` (schema)
- created `wiki/` skeleton: `index.md`, `log.md`, `sources/`, `entities/`, `concepts/`, `synthesis/`
- created `raw/` for source documents
- pattern reference: `IDEA.md` (Karpathy "LLM Wiki" gist, https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)
@@ -0,0 +1,101 @@
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_instances (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
wiki_id text NOT NULL,
root_folder_key text NOT NULL DEFAULT 'wiki-root',
configured_root_path text,
schema_version integer NOT NULL DEFAULT 1,
settings jsonb NOT NULL DEFAULT '{}'::jsonb,
managed_agent_key text,
managed_project_key text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (company_id, wiki_id)
);
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_sources (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
wiki_id text NOT NULL,
source_type text NOT NULL,
title text,
url text,
raw_path text NOT NULL,
content_hash text NOT NULL,
status text NOT NULL DEFAULT 'captured',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_pages (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
wiki_id text NOT NULL,
path text NOT NULL,
title text,
page_type text,
frontmatter jsonb NOT NULL DEFAULT '{}'::jsonb,
source_refs jsonb NOT NULL DEFAULT '[]'::jsonb,
backlinks jsonb NOT NULL DEFAULT '[]'::jsonb,
content_hash text,
current_revision_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (company_id, wiki_id, path)
);
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_page_revisions (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
wiki_id text NOT NULL,
page_id uuid REFERENCES plugin_llm_wiki_8f50da974f.wiki_pages(id) ON DELETE CASCADE,
operation_id uuid,
path text NOT NULL,
content_hash text NOT NULL,
summary text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_operations (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
wiki_id text NOT NULL,
operation_type text NOT NULL,
status text NOT NULL,
hidden_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
project_id uuid REFERENCES public.projects(id) ON DELETE SET NULL,
run_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
cost_cents integer NOT NULL DEFAULT 0,
warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
affected_pages jsonb NOT NULL DEFAULT '[]'::jsonb,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_query_sessions (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
wiki_id text NOT NULL,
hidden_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
agent_session_id text,
status text NOT NULL DEFAULT 'active',
filed_outputs jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_resource_bindings (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
wiki_id text NOT NULL,
resource_kind text NOT NULL,
resource_key text NOT NULL,
resolved_id uuid,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (company_id, wiki_id, resource_kind, resource_key)
);
@@ -0,0 +1,88 @@
CREATE TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
wiki_id text NOT NULL,
source_scope text NOT NULL,
scope_key text NOT NULL,
project_id uuid REFERENCES public.projects(id) ON DELETE CASCADE,
root_issue_id uuid REFERENCES public.issues(id) ON DELETE CASCADE,
source_kind text NOT NULL DEFAULT 'paperclip_issue_history',
last_processed_at timestamptz,
last_observed_at timestamptz,
pending_event_count integer NOT NULL DEFAULT 0,
last_successful_run_id uuid,
last_source_hash text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (company_id, wiki_id, source_scope, scope_key, source_kind)
);
CREATE TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
wiki_id text NOT NULL,
work_item_kind text NOT NULL,
status text NOT NULL DEFAULT 'pending',
priority text NOT NULL DEFAULT 'medium',
project_id uuid REFERENCES public.projects(id) ON DELETE CASCADE,
root_issue_id uuid REFERENCES public.issues(id) ON DELETE CASCADE,
requested_by_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
idempotency_key text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (company_id, wiki_id, idempotency_key)
);
CREATE TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_runs (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
wiki_id text NOT NULL,
cursor_id uuid REFERENCES plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors(id) ON DELETE SET NULL,
work_item_id uuid REFERENCES plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items(id) ON DELETE SET NULL,
project_id uuid REFERENCES public.projects(id) ON DELETE SET NULL,
root_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
source_window_start timestamptz,
source_window_end timestamptz,
source_hash text,
status text NOT NULL,
operation_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
retry_count integer NOT NULL DEFAULT 0,
cost_cents integer NOT NULL DEFAULT 0,
warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE plugin_llm_wiki_8f50da974f.paperclip_source_snapshots (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
wiki_id text NOT NULL,
distillation_run_id uuid REFERENCES plugin_llm_wiki_8f50da974f.paperclip_distillation_runs(id) ON DELETE CASCADE,
project_id uuid REFERENCES public.projects(id) ON DELETE SET NULL,
root_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
source_hash text NOT NULL,
max_characters integer NOT NULL,
clipped boolean NOT NULL DEFAULT false,
source_refs jsonb NOT NULL DEFAULT '[]'::jsonb,
bundle_markdown text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
wiki_id text NOT NULL,
project_id uuid REFERENCES public.projects(id) ON DELETE CASCADE,
root_issue_id uuid REFERENCES public.issues(id) ON DELETE CASCADE,
page_path text NOT NULL,
last_applied_source_hash text,
last_distillation_run_id uuid REFERENCES plugin_llm_wiki_8f50da974f.paperclip_distillation_runs(id) ON DELETE SET NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (company_id, wiki_id, page_path)
);
@@ -0,0 +1,47 @@
{
"name": "@paperclipai/plugin-llm-wiki",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Local-file LLM Wiki plugin for source ingestion, wiki browsing, query, lint, and maintenance workflows.",
"scripts": {
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
"build": "node ./esbuild.config.mjs",
"build:rollup": "rollup -c",
"dev": "node ./esbuild.config.mjs --watch",
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
"test": "vitest run --config ./vitest.config.ts",
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
},
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"keywords": [
"paperclip",
"plugin",
"automation",
"wiki",
"knowledge"
],
"author": "Paperclip",
"license": "MIT",
"devDependencies": {
"@paperclipai/plugin-sdk": "workspace:*",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"esbuild": "^0.27.3",
"react-dom": "^19.0.0",
"rollup": "^4.38.0",
"tslib": "^2.8.1",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"peerDependencies": {
"react": ">=18"
}
}
@@ -0,0 +1,22 @@
# Paperclip Wiki Backfill
Backfill a historical Paperclip project or root issue into the LLM Wiki.
Return structured JSON with:
- `project_page`
- optional `decisions_page`
- optional `history_page`
- `source_hash`
- `source_refs`
- `cursor_window`
- `warnings`
- `human_review_required`
Rules:
- Summarize historical issue history into durable project knowledge.
- Do not create one page per issue by default.
- Prefer timeline entries for meaningful state transitions, decisions, and completed work.
- Include clipped-source warnings and source provenance.
- Use proposed patches first; only write pages when auto-apply policy allows it and hashes still match.
@@ -0,0 +1,19 @@
# Paperclip Decision Distillation
Extract durable project decisions from Paperclip issue history.
Return structured JSON with:
- `page_path`: `wiki/projects/<project-slug>/decisions.md`
- `operation_type`: `decision_distill`
- `decisions`: accepted, rejected, reversed, or superseded decisions
- `source_refs`
- `warnings`
- `human_review_required`
Rules:
- A decision needs explicit evidence: accepted plan, approval outcome, maintainer comment, merged implementation, or reversal.
- Preserve who/what/when/source, but do not invent a decision owner.
- Distinguish "proposal" from "accepted decision".
- Mark ambiguous or conflicting material for human review.
@@ -0,0 +1,6 @@
# Ingest Prompt
You are ingesting source material into a local LLM Wiki.
Read WIKI.md, inspect the current index, summarize the source, propose page
updates, and preserve provenance links back to raw/.
@@ -0,0 +1,6 @@
# Lint Prompt
Lint the LLM Wiki for contradictions, stale claims, orphan pages, missing
backlinks, weak provenance, and index/log drift.
Return findings with concrete file paths and suggested fixes.
@@ -0,0 +1,20 @@
# Paperclip Source Bundle Reader
You are reading a bounded Paperclip issue-history source bundle for the LLM Wiki.
Return structured JSON that can be validated before any wiki write:
- `source_hash`: the bundle hash supplied by the tool
- `cursor_window`: `{ "start": string | null, "end": string | null }`
- `source_refs`: the issue, comment, and document refs used for every claim
- `signals`: durable changes worth distilling, grouped by status, decision, risk, artifact, and implementation note
- `warnings`: clipped sources, contradictions, missing context, stale hashes, or low-confidence material
- `low_signal`: true when the bundle contains no durable project change
Rules:
- Preserve Paperclip issue identifiers and document keys exactly.
- Treat issue/comment/document history as provenance, not as prose to copy wholesale.
- Ignore plugin-operation issues and raw heartbeat noise.
- Do not infer live status beyond the bundle's `current_as_of` timestamp.
- Prefer concise, source-cited observations over narrative.
@@ -0,0 +1,35 @@
# Paperclip Project Page Distillation
Update `wiki/projects/<project-slug>.md` from a Paperclip source bundle.
Return structured JSON with:
- `page_path`
- `operation_type`: `project_page_distill`
- `current_hash`
- `proposed_markdown`
- `source_hash`
- `source_refs`
- `cursor_window`
- `confidence`: `high`, `medium`, or `low`
- `warnings`
- `human_review_required`
The page must keep these stable sections:
1. Overview
2. Current Status
3. Recent Changes
4. Decisions
5. Open Risks / Blockers
6. Active Issues
7. Artifacts / Links
8. Source Provenance
Rules:
- Cite Paperclip source refs in the relevant section and again in Source Provenance.
- Show "Current as of" near the top.
- Keep live-state language conservative when the cursor window is old or clipped.
- Do not remove existing durable context unless a source ref supersedes it.
- Set `human_review_required` when hashes are stale, sources are clipped, contradictions appear, or confidence is low.
@@ -0,0 +1,6 @@
# Query Prompt
Answer from the LLM Wiki.
Read index.md first, inspect relevant pages and raw/source references, cite the
pages used, and say when the wiki does not contain enough evidence.
@@ -0,0 +1,28 @@
# Paperclip Status Refresh
Refresh only the status-sensitive sections of an existing project page:
- Current Status
- Recent Changes
- Open Risks / Blockers
- Active Issues
- Source Provenance
Return structured JSON with:
- `page_path`
- `operation_type`: `status_refresh`
- `current_hash`
- `proposed_markdown`
- `source_hash`
- `source_refs`
- `cursor_window`
- `warnings`
- `human_review_required`
Rules:
- Preserve stable overview and decision text unless the bundle explicitly supersedes it.
- Use source refs for every status change.
- Refuse stale hashes instead of overwriting.
- Set review required when the source window is clipped or inconsistent.
@@ -0,0 +1,28 @@
import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
function withPlugins(config) {
if (!config) return null;
return {
...config,
plugins: [
nodeResolve({
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
}),
typescript({
tsconfig: "./tsconfig.json",
declaration: false,
declarationMap: false,
}),
],
};
}
export default [
withPlugins(presets.rollup.manifest),
withPlugins(presets.rollup.worker),
withPlugins(presets.rollup.ui),
].filter(Boolean);
Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

@@ -0,0 +1,502 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
import { DEFAULT_AGENT_INSTRUCTIONS } from "./templates.js";
export const PLUGIN_ID = "paperclipai.plugin-llm-wiki";
export const WIKI_ROOT_FOLDER_KEY = "wiki-root";
export const WIKI_MAINTAINER_AGENT_KEY = "wiki-maintainer";
export const WIKI_PROJECT_KEY = "llm-wiki";
export const CURSOR_WINDOW_ROUTINE_KEY = "cursor-window-processing";
export const NIGHTLY_LINT_ROUTINE_KEY = "nightly-wiki-lint";
export const INDEX_REFRESH_ROUTINE_KEY = "index-refresh";
export const DEFAULT_MAX_SOURCE_BYTES = 250000;
export const DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS = 12000;
export const DEFAULT_MAX_PAPERCLIP_CURSOR_WINDOW_CHARS = 60000;
export const DEFAULT_MAX_PAPERCLIP_ROUTINE_RUN_CHARS = 120000;
export const DEFAULT_MAX_PAPERCLIP_ROUTINE_RUN_COST_CENTS = 100;
export const DEFAULT_PAPERCLIP_COST_CENTS_PER_1K_CHARS = 1;
export const WIKI_MAINTENANCE_ROUTINE_KEYS = [
CURSOR_WINDOW_ROUTINE_KEY,
NIGHTLY_LINT_ROUTINE_KEY,
INDEX_REFRESH_ROUTINE_KEY,
] as const;
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: "0.1.0",
displayName: "LLM Wiki",
description: "Local-file LLM Wiki plugin for source ingestion, wiki browsing, query, lint, and maintenance workflows.",
author: "Paperclip",
categories: ["automation", "ui"],
capabilities: [
"events.subscribe",
"jobs.schedule",
"api.routes.register",
"database.namespace.migrate",
"database.namespace.read",
"database.namespace.write",
"companies.read",
"projects.read",
"projects.managed",
"issues.read",
"issue.subtree.read",
"issues.create",
"issues.update",
"issues.wakeup",
"issues.orchestration.read",
"issue.comments.read",
"issue.comments.create",
"issue.documents.read",
"issue.documents.write",
"agents.read",
"agents.managed",
"agent.sessions.create",
"agent.sessions.list",
"agent.sessions.send",
"agent.sessions.close",
"routines.managed",
"local.folders",
"agent.tools.register",
"metrics.write",
"activity.log.write",
"plugin.state.read",
"plugin.state.write",
"ui.sidebar.register",
"ui.page.register",
"instance.settings.register"
],
instanceConfigSchema: {
type: "object",
properties: {
autoApplyIngestPatches: {
type: "boolean",
title: "Auto-apply ingest patches",
default: false,
description: "Allow ingest operations to apply generated wiki changes without a manual accept step. Ignored in authenticated/public deployments, which always require manual review before wiki writes."
},
maxSourceBytes: {
type: "number",
title: "Maximum source size",
default: DEFAULT_MAX_SOURCE_BYTES,
description: "Maximum bytes accepted for pasted or fetched source content."
},
maxPaperclipIssueSourceCharacters: {
type: "number",
title: "Maximum Paperclip source characters per issue",
default: DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS,
description: "Maximum characters included from one Paperclip issue, comment, or document during distillation."
},
maxPaperclipCursorWindowCharacters: {
type: "number",
title: "Maximum Paperclip cursor window characters",
default: DEFAULT_MAX_PAPERCLIP_CURSOR_WINDOW_CHARS,
description: "Maximum characters included in one cursor-window distillation bundle."
},
maxPaperclipRoutineRunCharacters: {
type: "number",
title: "Maximum Paperclip routine run characters",
default: DEFAULT_MAX_PAPERCLIP_ROUTINE_RUN_CHARS,
description: "Maximum source characters processed by one managed distillation routine run."
},
maxPaperclipRoutineRunCostCents: {
type: "number",
title: "Maximum Paperclip routine run cost",
default: DEFAULT_MAX_PAPERCLIP_ROUTINE_RUN_COST_CENTS,
description: "Estimated cost cap in cents for one Paperclip distillation run. Runs are refused before page generation when the estimate exceeds this cap."
},
paperclipCostCentsPerThousandSourceCharacters: {
type: "number",
title: "Paperclip source cost estimate",
default: DEFAULT_PAPERCLIP_COST_CENTS_PER_1K_CHARS,
description: "Conservative cost estimate used to stop Paperclip distillation before runaway token spend."
}
}
},
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui"
},
database: {
namespaceSlug: "llm_wiki",
migrationsDir: "migrations",
coreReadTables: ["companies", "issues", "projects", "agents"]
},
localFolders: [
{
folderKey: WIKI_ROOT_FOLDER_KEY,
displayName: "Wiki root",
description: "Company-scoped local folder that stores raw sources, wiki pages, AGENTS.md, IDEA.md, wiki/index.md, and wiki/log.md.",
access: "readWrite",
requiredDirectories: [
"raw",
"wiki",
"wiki/sources",
"wiki/projects",
"wiki/entities",
"wiki/concepts",
"wiki/synthesis"
],
requiredFiles: ["AGENTS.md", "IDEA.md", "wiki/index.md", "wiki/log.md"]
}
],
agents: [
{
agentKey: WIKI_MAINTAINER_AGENT_KEY,
displayName: "Wiki Maintainer",
role: "knowledge-maintainer",
title: "LLM Wiki Maintainer",
icon: "book-open",
capabilities: "Ingests source material, maintains local wiki pages, answers cited questions, and runs wiki lint/maintenance through plugin tools.",
adapterType: "claude_local",
adapterPreference: ["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor", "pi_local"],
adapterConfig: {},
runtimeConfig: {
modelProfiles: {
cheap: {
purpose: "classification, lint planning, index maintenance"
}
}
},
permissions: {
pluginTools: [PLUGIN_ID]
},
status: "paused",
budgetMonthlyCents: 0,
instructions: {
entryFile: "AGENTS.md",
content: DEFAULT_AGENT_INSTRUCTIONS
}
}
],
projects: [
{
projectKey: WIKI_PROJECT_KEY,
displayName: "LLM Wiki",
description: "Plugin-managed inspection area for LLM Wiki ingest, query, lint, and maintenance operation issues.",
status: "in_progress",
color: "#2563eb"
}
],
routines: [
{
routineKey: CURSOR_WINDOW_ROUTINE_KEY,
title: "Process LLM Wiki updates",
description: "Scheduled Paperclip issue-history distillation that processes bounded cursor windows, retries, and selected backfill work into project wiki patches.",
status: "paused",
priority: "low",
assigneeRef: { resourceKind: "agent", resourceKey: WIKI_MAINTAINER_AGENT_KEY },
projectRef: { resourceKind: "project", resourceKey: WIKI_PROJECT_KEY },
concurrencyPolicy: "skip_if_active",
catchUpPolicy: "skip_missed",
triggers: [
{
kind: "schedule",
label: "Every 6 hours",
enabled: false,
cronExpression: "0 */6 * * *",
timezone: "UTC",
signingMode: null,
replayWindowSec: null
}
],
issueTemplate: {
surfaceVisibility: "plugin_operation",
originId: "routine:cursor-window-processing",
billingCode: "plugin-llm-wiki:distillation"
}
},
{
routineKey: NIGHTLY_LINT_ROUTINE_KEY,
title: "Run LLM Wiki lint",
description: "Scheduled wiki maintenance that checks index/log drift, orphan pages, missing backlinks, and stale source provenance.",
status: "paused",
priority: "low",
assigneeRef: { resourceKind: "agent", resourceKey: WIKI_MAINTAINER_AGENT_KEY },
projectRef: { resourceKind: "project", resourceKey: WIKI_PROJECT_KEY },
concurrencyPolicy: "skip_if_active",
catchUpPolicy: "skip_missed",
triggers: [
{
kind: "schedule",
label: "Nightly",
enabled: false,
cronExpression: "0 3 * * *",
timezone: "UTC",
signingMode: null,
replayWindowSec: null
}
],
issueTemplate: {
surfaceVisibility: "plugin_operation",
originId: "routine:nightly-wiki-lint",
billingCode: "plugin-llm-wiki:maintenance"
}
},
{
routineKey: INDEX_REFRESH_ROUTINE_KEY,
title: "Refresh LLM Wiki index",
description: "Scheduled wiki maintenance that refreshes wiki/index.md, updates wiki/log.md, and checks that recently changed pages are linked from the index.",
status: "paused",
priority: "low",
assigneeRef: { resourceKind: "agent", resourceKey: WIKI_MAINTAINER_AGENT_KEY },
projectRef: { resourceKind: "project", resourceKey: WIKI_PROJECT_KEY },
concurrencyPolicy: "skip_if_active",
catchUpPolicy: "skip_missed",
triggers: [
{
kind: "schedule",
label: "Hourly",
enabled: false,
cronExpression: "0 * * * *",
timezone: "UTC",
signingMode: null,
replayWindowSec: null
}
],
issueTemplate: {
surfaceVisibility: "plugin_operation",
originId: "routine:index-refresh",
billingCode: "plugin-llm-wiki:maintenance"
}
}
],
jobs: [
{
jobKey: "folder-health-check",
displayName: "Wiki folder health check",
description: "Records lightweight health metadata for configured wiki roots.",
schedule: "0 * * * *"
}
],
tools: [
{
name: "wiki_search",
displayName: "Search Wiki",
description: "Search indexed wiki page and source metadata.",
parametersSchema: {
type: "object",
properties: {
companyId: { type: "string" },
wikiId: { type: "string" },
query: { type: "string" },
limit: { type: "number" }
},
required: ["companyId", "wikiId", "query"]
}
},
{
name: "wiki_read_page",
displayName: "Read Wiki Page",
description: "Read a markdown wiki page from the configured local wiki root.",
parametersSchema: {
type: "object",
properties: {
companyId: { type: "string" },
wikiId: { type: "string" },
path: { type: "string" }
},
required: ["companyId", "wikiId", "path"]
}
},
{
name: "wiki_write_page",
displayName: "Write Wiki Page",
description: "Atomically write a markdown wiki page after plugin path validation and optional hash conflict checks. Protected control files such as AGENTS.md and IDEA.md are excluded from agent-tool writes.",
parametersSchema: {
type: "object",
properties: {
companyId: { type: "string" },
wikiId: { type: "string" },
path: { type: "string" },
contents: { type: "string" },
expectedHash: { type: "string" },
summary: { type: "string" }
},
required: ["companyId", "wikiId", "path", "contents"]
}
},
{
name: "wiki_propose_patch",
displayName: "Propose Wiki Patch",
description: "Return a structured proposed page write without changing files.",
parametersSchema: {
type: "object",
properties: {
companyId: { type: "string" },
wikiId: { type: "string" },
path: { type: "string" },
contents: { type: "string" },
summary: { type: "string" }
},
required: ["companyId", "wikiId", "path", "contents"]
}
},
{
name: "wiki_list_sources",
displayName: "List Wiki Sources",
description: "Return captured raw source metadata from the plugin index.",
parametersSchema: {
type: "object",
properties: {
companyId: { type: "string" },
wikiId: { type: "string" },
limit: { type: "number" }
},
required: ["companyId", "wikiId"]
}
},
{
name: "wiki_read_source",
displayName: "Read Wiki Source",
description: "Read a captured raw source from the configured local wiki root.",
parametersSchema: {
type: "object",
properties: {
companyId: { type: "string" },
wikiId: { type: "string" },
rawPath: { type: "string" }
},
required: ["companyId", "wikiId", "rawPath"]
}
},
{
name: "wiki_append_log",
displayName: "Append Wiki Log",
description: "Append a maintenance note to wiki/log.md.",
parametersSchema: {
type: "object",
properties: {
companyId: { type: "string" },
wikiId: { type: "string" },
entry: { type: "string" }
},
required: ["companyId", "wikiId", "entry"]
}
},
{
name: "wiki_update_index",
displayName: "Update Wiki Index",
description: "Atomically replace wiki/index.md with optional hash conflict checks.",
parametersSchema: {
type: "object",
properties: {
companyId: { type: "string" },
wikiId: { type: "string" },
contents: { type: "string" },
expectedHash: { type: "string" }
},
required: ["companyId", "wikiId", "contents"]
}
},
{
name: "wiki_list_backlinks",
displayName: "List Wiki Backlinks",
description: "Return indexed backlinks for a wiki page.",
parametersSchema: {
type: "object",
properties: {
companyId: { type: "string" },
wikiId: { type: "string" },
path: { type: "string" }
},
required: ["companyId", "wikiId", "path"]
}
},
{
name: "wiki_list_pages",
displayName: "List Wiki Pages",
description: "Return the known page index from plugin metadata.",
parametersSchema: {
type: "object",
properties: {
companyId: { type: "string" },
wikiId: { type: "string" }
},
required: ["companyId", "wikiId"]
}
}
],
apiRoutes: [
{
routeKey: "overview",
method: "GET",
path: "/overview",
auth: "board-or-agent",
capability: "api.routes.register",
companyResolution: { from: "query", key: "companyId" }
},
{
routeKey: "bootstrap",
method: "POST",
path: "/bootstrap",
auth: "board",
capability: "api.routes.register",
companyResolution: { from: "body", key: "companyId" }
},
{
routeKey: "capture-source",
method: "POST",
path: "/sources",
auth: "board-or-agent",
capability: "api.routes.register",
companyResolution: { from: "body", key: "companyId" }
},
{
routeKey: "operations",
method: "GET",
path: "/operations",
auth: "board-or-agent",
capability: "api.routes.register",
companyResolution: { from: "query", key: "companyId" }
},
{
routeKey: "start-query",
method: "POST",
path: "/query-sessions",
auth: "board",
capability: "api.routes.register",
companyResolution: { from: "body", key: "companyId" }
},
{
routeKey: "file-as-page",
method: "POST",
path: "/file-as-page",
auth: "board",
capability: "api.routes.register",
companyResolution: { from: "body", key: "companyId" }
}
],
ui: {
slots: [
{
type: "sidebar",
id: "wiki-sidebar",
displayName: "Wiki",
exportName: "SidebarLink",
order: 35
},
{
type: "page",
id: "wiki-page",
displayName: "Wiki",
exportName: "WikiPage",
routePath: "wiki"
},
{
type: "routeSidebar",
id: "wiki-route-sidebar",
displayName: "Wiki",
exportName: "WikiRouteSidebar",
routePath: "wiki"
},
{
type: "settingsPage",
id: "settings",
displayName: "LLM Wiki",
exportName: "SettingsPage"
}
]
}
};
export default manifest;
@@ -0,0 +1,63 @@
import { readFileSync } from "node:fs";
export const REQUIRED_WIKI_DIRECTORIES = [
"raw",
"wiki",
"wiki/sources",
"wiki/projects",
"wiki/entities",
"wiki/concepts",
"wiki/synthesis",
] as const;
export const REQUIRED_WIKI_FILES = ["AGENTS.md", "IDEA.md", "wiki/index.md", "wiki/log.md"] as const;
export const KARPATHY_LLM_WIKI_GIST_URL = "https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f";
function templateFile(path: string): string {
return readFileSync(new URL(`../templates/${path}`, import.meta.url), "utf8");
}
function promptFile(path: string): string {
return readFileSync(new URL(`../prompts/${path}`, import.meta.url), "utf8");
}
export const DEFAULT_WIKI_SCHEMA = templateFile("AGENTS.md");
export const DEFAULT_AGENT_INSTRUCTIONS = templateFile("WIKI_MAINTAINER_AGENT.md");
export const DEFAULT_IDEA = templateFile("IDEA.md");
export const DEFAULT_INDEX = templateFile("wiki/index.md");
export const DEFAULT_LOG = templateFile("wiki/log.md");
export const DEFAULT_GITIGNORE = templateFile(".gitignore");
export const PAPERCLIP_SOURCE_BUNDLE_PROMPT = promptFile("paperclip-source-bundle.md");
export const PROJECT_PAGE_DISTILL_PROMPT = promptFile("project-page-distill.md");
export const DECISION_DISTILL_PROMPT = promptFile("decision-distill.md");
export const STATUS_REFRESH_PROMPT = promptFile("status-refresh.md");
export const BACKFILL_PROMPT = promptFile("backfill.md");
export const INGEST_PROMPT = `You are ingesting source material into a local LLM Wiki.
Follow AGENTS.md. Read IDEA.md if you need the pattern background. Read wiki/index.md and recent wiki/log.md entries, read the raw source, summarize durable knowledge, update or propose focused wiki pages, keep provenance links back to raw/, refresh wiki/index.md, and append wiki/log.md.
`;
export const QUERY_PROMPT = `Answer from the LLM Wiki.
Read wiki/index.md first, inspect relevant pages and raw/source references, cite the wiki page paths and raw source paths used, and say when the wiki does not contain enough evidence. Useful durable synthesis should be filed back into wiki/.
`;
export const LINT_PROMPT = `Lint the LLM Wiki for contradictions, stale claims, orphan pages, missing backlinks, weak provenance, and wiki/index.md / wiki/log.md drift.
Also look for important concepts mentioned without pages and answers that should have been filed back into wiki/. Return findings with severity, concrete file paths, evidence, and suggested fixes.
`;
export const BOOTSTRAP_FILES: ReadonlyArray<{ path: string; contents: string }> = [
{ path: ".gitignore", contents: DEFAULT_GITIGNORE },
{ path: "AGENTS.md", contents: DEFAULT_WIKI_SCHEMA },
{ path: "IDEA.md", contents: DEFAULT_IDEA },
{ path: "wiki/index.md", contents: DEFAULT_INDEX },
{ path: "wiki/log.md", contents: DEFAULT_LOG },
{ path: "raw/.gitkeep", contents: "" },
{ path: "wiki/sources/.gitkeep", contents: "" },
{ path: "wiki/projects/.gitkeep", contents: "" },
{ path: "wiki/entities/.gitkeep", contents: "" },
{ path: "wiki/concepts/.gitkeep", contents: "" },
{ path: "wiki/synthesis/.gitkeep", contents: "" },
];
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,49 @@
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
export type IngestSourceActionResult = {
operation?: {
issue?: {
id?: unknown;
} | null;
} | null;
};
export function readIngestOperationIssueId(result: unknown): string {
const issueId = (result as IngestSourceActionResult | null)?.operation?.issue?.id;
if (typeof issueId === "string" && issueId.trim()) return issueId;
throw new Error("Ingest operation did not return an issue id; the dropped file could not be attached.");
}
async function readUploadError(response: Response): Promise<string> {
const body = await response.json().catch(() => null);
if (body && typeof body === "object") {
const error = (body as { error?: unknown; message?: unknown }).error;
if (typeof error === "string" && error.trim()) return error;
const message = (body as { error?: unknown; message?: unknown }).message;
if (typeof message === "string" && message.trim()) return message;
}
return `Attachment upload failed with HTTP ${response.status}.`;
}
export async function uploadIssueAttachmentFile(input: {
companyId: string;
issueId: string;
file: File;
fetchImpl?: FetchLike;
}): Promise<unknown> {
const fetchImpl = input.fetchImpl ?? fetch;
const form = new FormData();
form.append("file", input.file);
const response = await fetchImpl(
`/api/companies/${encodeURIComponent(input.companyId)}/issues/${encodeURIComponent(input.issueId)}/attachments`,
{
method: "POST",
credentials: "include",
body: form,
},
);
if (!response.ok) {
throw new Error(await readUploadError(response));
}
return response.json();
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,690 @@
import { definePlugin, runWorker, type PluginApiRequestInput, type PluginContext } from "@paperclipai/plugin-sdk";
import {
WIKI_MAINTENANCE_ROUTINE_KEYS,
WIKI_ROOT_FOLDER_KEY,
} from "./manifest.js";
import {
bootstrapWikiRoot,
assemblePaperclipSourceBundle,
captureWikiSource,
createPaperclipDistillationRun,
createPaperclipDistillationWorkItem,
createOperationIssue,
distillPaperclipProjectPage,
fileQueryAnswerAsPage,
getDistillationOverview,
getDistillationPageProvenance,
getDistillationAutoApplyRestriction,
getEventIngestionSettings,
getOverview,
handlePaperclipEventIngestion,
listWikiAgentOptions,
listWikiProjectOptions,
listOperations,
listPages,
listSources,
readCompanyIdFromParams,
readTemplate,
readWikiPage,
recordPaperclipDistillationOutcome,
reconcileWikiAgentResource,
reconcileWikiProjectResource,
reconcileWikiRoutineResources,
registerWikiTools,
resetWikiAgentResource,
resetWikiProjectResource,
selectWikiAgentResource,
selectWikiProjectResource,
startWikiQuerySession,
updateEventIngestionSettings,
writeTemplate,
writeWikiPage,
} from "./wiki.js";
function stringField(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function routineKeyField(value: unknown): (typeof WIKI_MAINTENANCE_ROUTINE_KEYS)[number] {
const routineKey = stringField(value) ?? WIKI_MAINTENANCE_ROUTINE_KEYS[0];
if (!WIKI_MAINTENANCE_ROUTINE_KEYS.includes(routineKey as (typeof WIKI_MAINTENANCE_ROUTINE_KEYS)[number])) {
throw new Error(`Unknown managed routine: ${routineKey}`);
}
return routineKey as (typeof WIKI_MAINTENANCE_ROUTINE_KEYS)[number];
}
let activeContext: PluginContext | null = null;
const PAPERCLIP_EVENT_INGESTION_EVENTS = [
"issue.created",
"issue.updated",
"issue.comment.created",
"issue.document.created",
"issue.document.updated",
] as const;
function requireContext(): PluginContext {
if (!activeContext) throw new Error("LLM Wiki plugin has not been set up");
return activeContext;
}
const plugin = definePlugin({
async setup(ctx) {
activeContext = ctx;
await registerWikiTools(ctx);
for (const eventName of PAPERCLIP_EVENT_INGESTION_EVENTS) {
ctx.events.on(eventName, async (event) => {
const result = await handlePaperclipEventIngestion(ctx, event);
if (result.status === "recorded") {
ctx.logger.info("LLM Wiki recorded Paperclip event for cursor discovery", {
eventType: event.eventType,
companyId: event.companyId,
sourceKind: result.sourceKind,
sourceId: result.sourceId,
cursorId: result.cursorId,
});
}
});
}
ctx.jobs.register("folder-health-check", async (job) => {
ctx.logger.info("LLM Wiki folder health job invoked", { runId: job.runId, trigger: job.trigger });
});
ctx.data.register("overview", async (params) => {
const companyId = readCompanyIdFromParams(params);
return getOverview(ctx, companyId);
});
ctx.data.register("health", async (params) => {
const companyId = stringField(params.companyId);
return companyId
? getOverview(ctx, companyId)
: { status: "ok", checkedAt: new Date().toISOString(), message: "LLM Wiki worker is running" };
});
ctx.actions.register("bootstrap-root", async (params) => {
return bootstrapWikiRoot(ctx, {
companyId: readCompanyIdFromParams(params),
path: stringField(params.path),
});
});
ctx.actions.register("create-operation", async (params) => {
const operationType = stringField(params.operationType);
if (
operationType !== "ingest" &&
operationType !== "query" &&
operationType !== "lint" &&
operationType !== "file-as-page" &&
operationType !== "index" &&
operationType !== "distill" &&
operationType !== "backfill"
) {
throw new Error("operationType must be ingest, query, lint, file-as-page, index, distill, or backfill");
}
return createOperationIssue(ctx, {
companyId: readCompanyIdFromParams(params),
wikiId: stringField(params.wikiId),
operationType,
title: stringField(params.title),
prompt: stringField(params.prompt),
});
});
ctx.actions.register("capture-source", async (params) => {
return captureWikiSource(ctx, {
companyId: readCompanyIdFromParams(params),
wikiId: stringField(params.wikiId),
sourceType: stringField(params.sourceType),
title: stringField(params.title),
url: stringField(params.url),
contents: typeof params.contents === "string" ? params.contents : "",
rawPath: stringField(params.rawPath),
metadata: typeof params.metadata === "object" && params.metadata != null ? params.metadata as Record<string, unknown> : null,
});
});
ctx.actions.register("write-page", async (params) => {
return writeWikiPage(ctx, {
companyId: readCompanyIdFromParams(params),
wikiId: stringField(params.wikiId),
path: stringField(params.path) ?? "",
contents: typeof params.contents === "string" ? params.contents : "",
expectedHash: stringField(params.expectedHash),
summary: stringField(params.summary),
sourceRefs: params.sourceRefs,
writer: "board_ui",
});
});
ctx.actions.register("write-template", async (params) => {
return writeTemplate(ctx, {
companyId: readCompanyIdFromParams(params),
path: stringField(params.path) ?? "",
contents: typeof params.contents === "string" ? params.contents : "",
});
});
ctx.actions.register("update-event-ingestion-settings", async (params) => {
const sources = typeof params.sources === "object" && params.sources != null && !Array.isArray(params.sources)
? params.sources as Record<string, unknown>
: {};
return updateEventIngestionSettings(ctx, {
companyId: readCompanyIdFromParams(params),
settings: {
enabled: params.enabled === true,
wikiId: stringField(params.wikiId) ?? undefined,
maxCharacters: typeof params.maxCharacters === "number" ? params.maxCharacters : undefined,
sources: {
issues: sources.issues === true,
comments: sources.comments === true,
documents: sources.documents === true,
},
},
});
});
ctx.actions.register("ingest-source", async (params) => {
const companyId = readCompanyIdFromParams(params);
const wikiId = stringField(params.wikiId);
const sourceType = stringField(params.sourceType) ?? "text";
const title = stringField(params.title) ?? sourceType.toUpperCase();
const contents = typeof params.contents === "string" ? params.contents : "";
const url = stringField(params.url);
const captured = await captureWikiSource(ctx, {
companyId,
wikiId,
sourceType,
title,
url,
contents,
rawPath: stringField(params.rawPath),
metadata: typeof params.metadata === "object" && params.metadata != null ? params.metadata as Record<string, unknown> : null,
});
const op = await createOperationIssue(ctx, {
companyId,
wikiId,
operationType: "ingest",
title: `Ingest ${sourceType}: ${title}`,
prompt: [
`Ingest a captured source from raw/${captured.rawPath.replace(/^raw\//, "")}.`,
url ? `Source URL: ${url}` : null,
"Read the captured raw file, summarize, propose new or updated wiki pages, and update wiki/index.md / wiki/log.md.",
].filter(Boolean).join("\n"),
});
return { status: "ok", source: captured, operation: op };
});
ctx.actions.register("assemble-paperclip-source-bundle", async (params) => {
return assemblePaperclipSourceBundle(ctx, {
companyId: readCompanyIdFromParams(params),
wikiId: stringField(params.wikiId),
projectId: stringField(params.projectId),
rootIssueId: stringField(params.rootIssueId),
maxCharacters: typeof params.maxCharacters === "number" ? params.maxCharacters : null,
maxCharactersPerSource: typeof params.maxCharactersPerSource === "number" ? params.maxCharactersPerSource : null,
backfillStartAt: stringField(params.backfillStartAt),
backfillEndAt: stringField(params.backfillEndAt),
routineRun: params.routineRun === true,
includeComments: params.includeComments !== false,
includeDocuments: params.includeDocuments !== false,
});
});
ctx.actions.register("create-paperclip-distillation-run", async (params) => {
return createPaperclipDistillationRun(ctx, {
companyId: readCompanyIdFromParams(params),
wikiId: stringField(params.wikiId),
projectId: stringField(params.projectId),
rootIssueId: stringField(params.rootIssueId),
maxCharacters: typeof params.maxCharacters === "number" ? params.maxCharacters : null,
maxCharactersPerSource: typeof params.maxCharactersPerSource === "number" ? params.maxCharactersPerSource : null,
backfillStartAt: stringField(params.backfillStartAt),
backfillEndAt: stringField(params.backfillEndAt),
routineRun: params.routineRun === true,
includeComments: params.includeComments !== false,
includeDocuments: params.includeDocuments !== false,
workItemId: stringField(params.workItemId),
operationIssueId: stringField(params.operationIssueId),
});
});
ctx.actions.register("record-paperclip-distillation-outcome", async (params) => {
const status = stringField(params.status);
if (status !== "succeeded" && status !== "failed" && status !== "review_required") {
throw new Error("status must be succeeded, failed, or review_required");
}
const runId = stringField(params.runId);
if (!runId) throw new Error("runId is required");
return recordPaperclipDistillationOutcome(ctx, {
companyId: readCompanyIdFromParams(params),
wikiId: stringField(params.wikiId),
runId,
cursorId: stringField(params.cursorId),
status,
sourceHash: stringField(params.sourceHash),
sourceWindowEnd: stringField(params.sourceWindowEnd),
warning: stringField(params.warning),
costCents: typeof params.costCents === "number" ? params.costCents : null,
retryCount: typeof params.retryCount === "number" ? params.retryCount : null,
});
});
ctx.actions.register("distill-paperclip-project-page", async (params) => {
return distillPaperclipProjectPage(ctx, {
companyId: readCompanyIdFromParams(params),
wikiId: stringField(params.wikiId),
projectId: stringField(params.projectId),
rootIssueId: stringField(params.rootIssueId),
maxCharacters: typeof params.maxCharacters === "number" ? params.maxCharacters : null,
maxCharactersPerSource: typeof params.maxCharactersPerSource === "number" ? params.maxCharactersPerSource : null,
backfillStartAt: stringField(params.backfillStartAt),
backfillEndAt: stringField(params.backfillEndAt),
routineRun: params.routineRun === true,
includeComments: params.includeComments !== false,
includeDocuments: params.includeDocuments !== false,
workItemId: stringField(params.workItemId),
operationIssueId: stringField(params.operationIssueId),
autoApply: params.autoApply === true ? true : params.autoApply === false ? false : undefined,
expectedProjectPageHash: stringField(params.expectedProjectPageHash),
includeSupportingPages: params.includeSupportingPages !== false,
});
});
ctx.actions.register("distill-paperclip-now", async (params) => {
const companyId = readCompanyIdFromParams(params);
const projectId = stringField(params.projectId);
const rootIssueId = stringField(params.rootIssueId);
if (!projectId && !rootIssueId) throw new Error("projectId or rootIssueId is required");
const idempotencyScope = rootIssueId ? `root:${rootIssueId}` : `project:${projectId}`;
const workItem = await createPaperclipDistillationWorkItem(ctx, {
companyId,
wikiId: stringField(params.wikiId),
kind: "manual",
projectId,
rootIssueId,
requestedByIssueId: stringField(params.requestedByIssueId),
priority: "medium",
idempotencyKey: stringField(params.idempotencyKey) ?? `manual:${idempotencyScope}`,
metadata: { requestedFrom: "distill-paperclip-now" },
});
const operation = await createOperationIssue(ctx, {
companyId,
wikiId: stringField(params.wikiId),
operationType: "distill",
title: rootIssueId ? "Distill Paperclip root issue into wiki" : "Distill Paperclip project into wiki",
prompt: [
"Manual LLM Wiki distillation requested outside recurring cadence.",
projectId ? `Project ID: ${projectId}` : null,
rootIssueId ? `Root issue ID: ${rootIssueId}` : null,
"Assemble a bounded Paperclip source bundle, propose wiki patches, and record provenance/cost warnings.",
].filter(Boolean).join("\n"),
});
const result = await distillPaperclipProjectPage(ctx, {
companyId,
wikiId: stringField(params.wikiId),
projectId,
rootIssueId,
maxCharacters: typeof params.maxCharacters === "number" ? params.maxCharacters : null,
maxCharactersPerSource: typeof params.maxCharactersPerSource === "number" ? params.maxCharactersPerSource : null,
includeComments: params.includeComments !== false,
includeDocuments: params.includeDocuments !== false,
autoApply: params.autoApply === true ? true : params.autoApply === false ? false : undefined,
expectedProjectPageHash: stringField(params.expectedProjectPageHash),
includeSupportingPages: params.includeSupportingPages !== false,
workItemId: workItem.workItemId,
operationIssueId: operation.issue.id,
});
return { ...result, workItem, operation };
});
ctx.actions.register("backfill-paperclip-distillation", async (params) => {
const companyId = readCompanyIdFromParams(params);
const projectId = stringField(params.projectId);
const rootIssueId = stringField(params.rootIssueId);
if (!projectId && !rootIssueId) throw new Error("projectId or rootIssueId is required");
const backfillStartAt = stringField(params.backfillStartAt);
const backfillEndAt = stringField(params.backfillEndAt);
const idempotencyScope = rootIssueId ? `root:${rootIssueId}` : `project:${projectId}`;
const workItem = await createPaperclipDistillationWorkItem(ctx, {
companyId,
wikiId: stringField(params.wikiId),
kind: "backfill",
projectId,
rootIssueId,
requestedByIssueId: stringField(params.requestedByIssueId),
priority: "low",
idempotencyKey: stringField(params.idempotencyKey) ?? `backfill:${idempotencyScope}:${backfillStartAt ?? "begin"}:${backfillEndAt ?? "now"}`,
metadata: { backfillStartAt, backfillEndAt, requestedFrom: "backfill-paperclip-distillation" },
});
const operation = await createOperationIssue(ctx, {
companyId,
wikiId: stringField(params.wikiId),
operationType: "backfill",
title: rootIssueId ? "Backfill Paperclip root issue wiki history" : "Backfill Paperclip project wiki history",
prompt: [
"Backfill LLM Wiki distillation requested for a bounded Paperclip source window.",
projectId ? `Project ID: ${projectId}` : null,
rootIssueId ? `Root issue ID: ${rootIssueId}` : null,
backfillStartAt ? `Start: ${backfillStartAt}` : null,
backfillEndAt ? `End: ${backfillEndAt}` : null,
"Do not process whole-company history; stay within the selected project/root issue and date window.",
].filter(Boolean).join("\n"),
});
const result = await distillPaperclipProjectPage(ctx, {
companyId,
wikiId: stringField(params.wikiId),
projectId,
rootIssueId,
maxCharacters: typeof params.maxCharacters === "number" ? params.maxCharacters : null,
maxCharactersPerSource: typeof params.maxCharactersPerSource === "number" ? params.maxCharactersPerSource : null,
backfillStartAt,
backfillEndAt,
routineRun: params.routineRun === true,
includeComments: params.includeComments !== false,
includeDocuments: params.includeDocuments !== false,
autoApply: params.autoApply === true ? true : params.autoApply === false ? false : undefined,
expectedProjectPageHash: stringField(params.expectedProjectPageHash),
includeSupportingPages: params.includeSupportingPages !== false,
workItemId: workItem.workItemId,
operationIssueId: operation.issue.id,
});
return { ...result, workItem, operation };
});
ctx.actions.register("create-paperclip-distillation-work-item", async (params) => {
const kind = stringField(params.kind);
if (
kind !== "manual" &&
kind !== "retry" &&
kind !== "backfill" &&
kind !== "priority_override" &&
kind !== "review_patch"
) {
throw new Error("kind must be manual, retry, backfill, priority_override, or review_patch");
}
const priority = stringField(params.priority);
if (priority && priority !== "critical" && priority !== "high" && priority !== "medium" && priority !== "low") {
throw new Error("priority must be critical, high, medium, or low");
}
return createPaperclipDistillationWorkItem(ctx, {
companyId: readCompanyIdFromParams(params),
wikiId: stringField(params.wikiId),
kind,
projectId: stringField(params.projectId),
rootIssueId: stringField(params.rootIssueId),
requestedByIssueId: stringField(params.requestedByIssueId),
priority: priority as "critical" | "high" | "medium" | "low" | null,
idempotencyKey: stringField(params.idempotencyKey),
metadata: typeof params.metadata === "object" && params.metadata != null ? params.metadata as Record<string, unknown> : null,
});
});
ctx.actions.register("file-as-page", async (params) => {
return fileQueryAnswerAsPage(ctx, {
companyId: readCompanyIdFromParams(params),
wikiId: stringField(params.wikiId),
querySessionId: stringField(params.querySessionId),
question: stringField(params.question),
answer: stringField(params.answer),
path: stringField(params.path) ?? "",
title: stringField(params.title),
contents: stringField(params.contents),
expectedHash: stringField(params.expectedHash),
});
});
ctx.actions.register("start-query", async (params) => {
return startWikiQuerySession(ctx, {
companyId: readCompanyIdFromParams(params),
wikiId: stringField(params.wikiId),
question: stringField(params.question) ?? "",
title: stringField(params.title),
});
});
ctx.actions.register("reset-managed-agent", async (params) => {
return resetWikiAgentResource(ctx, readCompanyIdFromParams(params));
});
ctx.actions.register("reset-managed-project", async (params) => {
return resetWikiProjectResource(ctx, readCompanyIdFromParams(params));
});
ctx.actions.register("reconcile-managed-agent", async (params) => {
return reconcileWikiAgentResource(ctx, readCompanyIdFromParams(params));
});
ctx.actions.register("reconcile-managed-project", async (params) => {
return reconcileWikiProjectResource(ctx, readCompanyIdFromParams(params));
});
ctx.actions.register("select-managed-agent", async (params) => {
const agentId = stringField(params.agentId);
if (!agentId) throw new Error("agentId is required");
return selectWikiAgentResource(ctx, {
companyId: readCompanyIdFromParams(params),
agentId,
});
});
ctx.actions.register("select-managed-project", async (params) => {
const projectId = stringField(params.projectId);
if (!projectId) throw new Error("projectId is required");
return selectWikiProjectResource(ctx, {
companyId: readCompanyIdFromParams(params),
projectId,
});
});
ctx.actions.register("reset-managed-routine", async (params) => {
return ctx.routines.managed.reset(routineKeyField(params.routineKey), readCompanyIdFromParams(params), {
assigneeAgentId: stringField(params.assigneeAgentId),
projectId: stringField(params.projectId),
});
});
ctx.actions.register("reconcile-managed-routine", async (params) => {
return ctx.routines.managed.reconcile(routineKeyField(params.routineKey), readCompanyIdFromParams(params), {
assigneeAgentId: stringField(params.assigneeAgentId),
projectId: stringField(params.projectId),
});
});
ctx.actions.register("reconcile-managed-routines", async (params) => {
return reconcileWikiRoutineResources(ctx, readCompanyIdFromParams(params));
});
ctx.actions.register("update-managed-routine-status", async (params) => {
const status = stringField(params.status);
if (!status) throw new Error("status is required");
return ctx.routines.managed.update(routineKeyField(params.routineKey), readCompanyIdFromParams(params), {
status,
});
});
ctx.actions.register("run-managed-routine", async (params) => {
return ctx.routines.managed.run(routineKeyField(params.routineKey), readCompanyIdFromParams(params), {
assigneeAgentId: stringField(params.assigneeAgentId),
projectId: stringField(params.projectId),
});
});
ctx.data.register("pages", async (params) => {
const companyId = readCompanyIdFromParams(params);
return listPages(ctx, {
companyId,
wikiId: stringField(params.wikiId),
pageType: stringField(params.pageType),
includeRaw: params.includeRaw === true || params.includeRaw === "true",
limit: typeof params.limit === "number" ? params.limit : null,
});
});
ctx.data.register("sources", async (params) => {
const companyId = readCompanyIdFromParams(params);
return listSources(ctx, { companyId, wikiId: stringField(params.wikiId), limit: typeof params.limit === "number" ? params.limit : null });
});
ctx.data.register("page-content", async (params) => {
const companyId = readCompanyIdFromParams(params);
const path = stringField(params.path);
if (!path) throw new Error("path is required");
return readWikiPage(ctx, { companyId, wikiId: stringField(params.wikiId), path });
});
ctx.data.register("template", async (params) => {
const companyId = readCompanyIdFromParams(params);
const path = stringField(params.path) ?? "AGENTS.md";
return readTemplate(ctx, { companyId, path });
});
ctx.data.register("operations", async (params) => {
const companyId = readCompanyIdFromParams(params);
return listOperations(ctx, {
companyId,
wikiId: stringField(params.wikiId),
operationType: stringField(params.operationType),
status: stringField(params.status),
limit: typeof params.limit === "number" ? params.limit : null,
});
});
ctx.data.register("distillation-overview", async (params) => {
const companyId = readCompanyIdFromParams(params);
return getDistillationOverview(ctx, {
companyId,
wikiId: stringField(params.wikiId),
limit: typeof params.limit === "number" ? params.limit : null,
});
});
ctx.data.register("distillation-page-provenance", async (params) => {
const companyId = readCompanyIdFromParams(params);
const pagePath = stringField(params.pagePath);
if (!pagePath) {
return { binding: null, runs: [], snapshot: null, cursor: null };
}
return getDistillationPageProvenance(ctx, {
companyId,
wikiId: stringField(params.wikiId),
pagePath,
});
});
ctx.data.register("settings", async (params) => {
const companyId = readCompanyIdFromParams(params);
const folder = await ctx.localFolders.status(companyId, WIKI_ROOT_FOLDER_KEY);
const overview = await getOverview(ctx, companyId);
const managedRoutines = await Promise.all(
WIKI_MAINTENANCE_ROUTINE_KEYS.map((routineKey) => ctx.routines.managed.get(routineKey, companyId)),
);
return {
folder,
managedAgent: overview.managedAgent,
managedProject: overview.managedProject,
managedRoutine: managedRoutines[0],
managedRoutines,
distillationPolicy: getDistillationAutoApplyRestriction(),
eventIngestion: await getEventIngestionSettings(ctx, companyId),
agentOptions: await listWikiAgentOptions(ctx, companyId),
projectOptions: await listWikiProjectOptions(ctx, companyId),
capabilities: ctx.manifest.capabilities,
};
});
},
async onApiRequest(input: PluginApiRequestInput) {
const ctx = requireContext();
if (input.routeKey === "overview") {
return { body: await getOverview(ctx, input.companyId) };
}
if (input.routeKey === "bootstrap") {
const body = input.body as Record<string, unknown> | null;
return {
status: 201,
body: await bootstrapWikiRoot(ctx, {
companyId: input.companyId,
path: stringField(body?.path),
}),
};
}
if (input.routeKey === "capture-source") {
const body = input.body as Record<string, unknown> | null;
return {
status: 201,
body: await captureWikiSource(ctx, {
companyId: input.companyId,
wikiId: stringField(body?.wikiId),
sourceType: stringField(body?.sourceType),
title: stringField(body?.title),
url: stringField(body?.url),
contents: typeof body?.contents === "string" ? body.contents : "",
rawPath: stringField(body?.rawPath),
metadata: typeof body?.metadata === "object" && body.metadata != null ? body.metadata as Record<string, unknown> : null,
}),
};
}
if (input.routeKey === "operations") {
return {
body: await listOperations(ctx, {
companyId: input.companyId,
wikiId: stringField(input.query.wikiId),
operationType: stringField(input.query.operationType),
status: stringField(input.query.status),
limit: typeof input.query.limit === "string" ? Number(input.query.limit) : null,
}),
};
}
if (input.routeKey === "start-query") {
const body = input.body as Record<string, unknown> | null;
return {
status: 201,
body: await startWikiQuerySession(ctx, {
companyId: input.companyId,
wikiId: stringField(body?.wikiId),
question: stringField(body?.question) ?? "",
title: stringField(body?.title),
}),
};
}
if (input.routeKey === "file-as-page") {
const body = input.body as Record<string, unknown> | null;
return {
status: 201,
body: await fileQueryAnswerAsPage(ctx, {
companyId: input.companyId,
wikiId: stringField(body?.wikiId),
querySessionId: stringField(body?.querySessionId),
question: stringField(body?.question),
answer: stringField(body?.answer),
path: stringField(body?.path) ?? "",
title: stringField(body?.title),
contents: stringField(body?.contents),
expectedHash: stringField(body?.expectedHash),
}),
};
}
return { status: 404, body: { error: `Unknown LLM Wiki route: ${input.routeKey}` } };
},
async onHealth() {
return {
status: "ok",
message: "LLM Wiki plugin worker is running",
details: {
surfaces: ["page", "sidebar", "settings", "tools", "database", "local-folder"],
},
};
}
});
export default plugin;
runWorker(plugin, import.meta.url);
@@ -0,0 +1,3 @@
.DS_Store
.obsidian/workspace*
.obsidian/cache
@@ -0,0 +1,123 @@
# AGENTS.md — LLM Wiki Schema
You are the maintainer of this personal wiki. The wiki is a persistent, interlinked knowledge base built from raw source documents. You read sources, extract knowledge, and integrate it into evolving wiki pages. The user curates sources, directs analysis, and asks questions; you handle the bookkeeping.
The underlying pattern is described in `IDEA.md` (Karpathy's "LLM Wiki" gist). Read it if you need the philosophy; this file is the operational schema.
## Layout
```
.
├── AGENTS.md # this file — your operating instructions
├── IDEA.md # the pattern this wiki follows
├── raw/ # immutable source documents (you read, never write)
└── wiki/ # generated, owned by you
├── index.md # catalog of all pages
├── log.md # append-only timeline of operations
├── sources/ # one summary page per source
├── projects/ # durable project pages, status, decisions, and history
├── entities/ # people, organizations, products, places
├── concepts/ # ideas, frameworks, definitions
└── synthesis/ # cross-cutting analysis, comparisons, theses
```
The subdirectories under `wiki/` are conventional, not enforced. Add new categories (e.g. `wiki/papers/`) as the domain demands — and update this file when you do.
## Page conventions
- **Filename:** kebab-case, `.md`. Treat filenames as stable; do not rename without updating backlinks.
- **Frontmatter:** YAML at the top of every wiki page.
```yaml
---
title: Human-readable title
type: source | project | entity | concept | synthesis
tags: [tag-a, tag-b]
sources: [raw/doc.pdf] # for source pages and synthesis pages
created: YYYY-MM-DD
updated: YYYY-MM-DD
---
```
- **Cross-links:** Obsidian-style `[[wiki/entities/some-page]]` (or `[[some-page]]` when unambiguous). When you mention a concept or entity that has — or should have — its own page, link it.
- **Citations:** cite the source inline whenever a claim comes from one: `(see [[wiki/sources/some-slug]])`.
- **Voice:** terse, factual, neutral. The wiki is reference material, not narrative.
## Operations
### Ingest
Triggered when the user drops a file in `raw/` and asks to process it (or just says "ingest").
1. Read the source end to end.
2. Briefly discuss key takeaways with the user before writing — confirm what to emphasize.
3. Create `wiki/sources/<slug>.md`: a summary page (~300–800 words) covering the source's main claims, structure, and notable quotes or data.
4. Update or create relevant pages in `entities/`, `concepts/`, `synthesis/`. A typical ingest touches 5–15 pages.
5. Add any new pages to `wiki/index.md`.
6. Append a log entry:
```
## [YYYY-MM-DD] ingest | <source title>
- source: raw/<filename>
- new pages: [[...]], [[...]]
- updated pages: [[...]], [[...]]
- notes: <one-line synthesis, contradiction flagged, or open question>
```
When new information contradicts an existing page, do **not** silently overwrite. Flag the contradiction on the page (a `> ⚠ contradicted by [[...]] (YYYY-MM-DD)` callout) and note it in the log.
### Project updates
Triggered when Paperclip project, issue, plan, comment, blocker, or status history is distilled into the wiki.
1. Create or update `wiki/projects/<project-slug>.md` as the durable project overview. Keep stable sections for overview, current status, recent changes, decisions, open risks/blockers, active issues, artifacts/links, and source provenance.
2. Use `wiki/projects/<project-slug>/decisions.md` for accepted/rejected plans, architectural decisions, approval outcomes, and reversals when a project has enough decision history to warrant a separate page.
3. Use `wiki/projects/<project-slug>/history.md` for a compact timeline of meaningful project changes. Do not mirror every issue comment.
4. Always cite Paperclip source material by linking back to issue identifiers, document keys, and raw/source pages. Preserve `current_as_of` metadata so readers know when the project page may be stale.
5. Update `wiki/index.md` under Projects and append a `project` log entry to `wiki/log.md`.
### Query
The user asks a question. You:
1. Read `wiki/index.md` to find candidate pages.
2. Read those pages; follow links as needed.
3. Answer with citations back to wiki pages, and ultimately to raw sources.
4. If the answer is substantial (a comparison, analysis, new synthesis), offer to file it under `wiki/synthesis/` so the work compounds rather than disappearing into chat history.
If the wiki lacks what the question needs, say so plainly and suggest sources to ingest or web searches to run.
### Lint
On request ("lint", "health check"), scan for:
- contradictions across pages
- claims a newer source has superseded
- orphan pages (not linked from `index.md` or any other page)
- concepts mentioned in multiple places but lacking a dedicated page
- broken `[[wiki-links]]`
- gaps where a web search or new source would help
Report findings as a checklist and ask the user which to act on.
## index.md format
A catalog organized by category. Each line: `- [[path]] — one-line summary`. Keep it scannable; this is your primary navigation aid before opening pages.
## log.md format
Append new entries to the bottom. Every entry header follows:
```
## [YYYY-MM-DD] <op> | <subject>
```
so `grep "^## \[" wiki/log.md | tail -10` always returns recent activity. Operations: `ingest`, `query`, `lint`, `setup`, `refactor`.
## Customization
This schema is intentionally generic. As the wiki's domain becomes clear, evolve it:
- add domain-specific page types and subdirectories
- adjust frontmatter fields
- specify preferred output formats for queries (Marp slides, charts, tables)
- record workflow preferences (one-at-a-time vs batch ingest, level of human supervision)
When you and the user agree on a convention, **write it into this file**. The schema is the wiki's source of truth for how the wiki is built.
@@ -0,0 +1,75 @@
# LLM Wiki
A pattern for building personal knowledge bases using LLMs.
This is an idea file, it is designed to be copy pasted to your own LLM Agent (e.g. OpenAI Codex, Claude Code, OpenCode / Pi, or etc.). Its goal is to communicate the high level idea, but your agent will build out the specifics in collaboration with you.
## The core idea
Most people's experience with LLMs and documents looks like RAG: you upload a collection of files, the LLM retrieves relevant chunks at query time, and generates an answer. This works, but the LLM is rediscovering knowledge from scratch on every question. There's no accumulation. Ask a subtle question that requires synthesizing five documents, and the LLM has to find and piece together the relevant fragments every time. Nothing is built up. NotebookLM, ChatGPT file uploads, and most RAG systems work this way.
The idea here is different. Instead of just retrieving from raw documents at query time, the LLM **incrementally builds and maintains a persistent wiki** — a structured, interlinked collection of markdown files that sits between you and the raw sources. When you add a new source, the LLM doesn't just index it for later retrieval. It reads it, extracts the key information, and integrates it into the existing wiki — updating entity pages, revising topic summaries, noting where new data contradicts old claims, strengthening or challenging the evolving synthesis. The knowledge is compiled once and then *kept current*, not re-derived on every query.
This is the key difference: **the wiki is a persistent, compounding artifact.** The cross-references are already there. The contradictions have already been flagged. The synthesis already reflects everything you've read. The wiki keeps getting richer with every source you add and every question you ask.
You never (or rarely) write the wiki yourself — the LLM writes and maintains all of it. You're in charge of sourcing, exploration, and asking the right questions. The LLM does all the grunt work — the summarizing, cross-referencing, filing, and bookkeeping that makes a knowledge base actually useful over time. In practice, I have the LLM agent open on one side and Obsidian open on the other. The LLM makes edits based on our conversation, and I browse the results in real time — following links, checking the graph view, reading the updated pages. Obsidian is the IDE; the LLM is the programmer; the wiki is the codebase.
This can apply to a lot of different contexts. A few examples:
- **Personal**: tracking your own goals, health, psychology, self-improvement — filing journal entries, articles, podcast notes, and building up a structured picture of yourself over time.
- **Research**: going deep on a topic over weeks or months — reading papers, articles, reports, and incrementally building a comprehensive wiki with an evolving thesis.
- **Reading a book**: filing each chapter as you go, building out pages for characters, themes, plot threads, and how they connect. By the end you have a rich companion wiki. Think of fan wikis like [Tolkien Gateway](https://tolkiengateway.net/wiki/Main_Page) — thousands of interlinked pages covering characters, places, events, languages, built by a community of volunteers over years. You could build something like that personally as you read, with the LLM doing all the cross-referencing and maintenance.
- **Business/team**: an internal wiki maintained by LLMs, fed by Slack threads, meeting transcripts, project documents, customer calls. Possibly with humans in the loop reviewing updates. The wiki stays current because the LLM does the maintenance that no one on the team wants to do.
- **Competitive analysis, due diligence, trip planning, course notes, hobby deep-dives** — anything where you're accumulating knowledge over time and want it organized rather than scattered.
## Architecture
There are three layers:
**Raw sources** — your curated collection of source documents. Articles, papers, images, data files. These are immutable — the LLM reads from them but never modifies them. This is your source of truth.
**The wiki** — a directory of LLM-generated markdown files. Summaries, entity pages, concept pages, comparisons, an overview, a synthesis. The LLM owns this layer entirely. It creates pages, updates them when new sources arrive, maintains cross-references, and keeps everything consistent. You read it; the LLM writes it.
**The schema** — a document (e.g. AGENTS.md for Paperclip agents) that tells the LLM how the wiki is structured, what the conventions are, and what workflows to follow when ingesting sources, answering questions, or maintaining the wiki. This is the key configuration file — it's what makes the LLM a disciplined wiki maintainer rather than a generic chatbot. You and the LLM co-evolve this over time as you figure out what works for your domain.
## Operations
**Ingest.** You drop a new source into the raw collection and tell the LLM to process it. An example flow: the LLM reads the source, discusses key takeaways with you, writes a summary page in the wiki, updates the index, updates relevant entity and concept pages across the wiki, and appends an entry to the log. A single source might touch 10-15 wiki pages. Personally I prefer to ingest sources one at a time and stay involved — I read the summaries, check the updates, and guide the LLM on what to emphasize. But you could also batch-ingest many sources at once with less supervision. It's up to you to develop the workflow that fits your style and document it in the schema for future sessions.
**Query.** You ask questions against the wiki. The LLM searches for relevant pages, reads them, and synthesizes an answer with citations. Answers can take different forms depending on the question — a markdown page, a comparison table, a slide deck (Marp), a chart (matplotlib), a canvas. The important insight: **good answers can be filed back into the wiki as new pages.** A comparison you asked for, an analysis, a connection you discovered — these are valuable and shouldn't disappear into chat history. This way your explorations compound in the knowledge base just like ingested sources do.
**Lint.** Periodically, ask the LLM to health-check the wiki. Look for: contradictions between pages, stale claims that newer sources have superseded, orphan pages with no inbound links, important concepts mentioned but lacking their own page, missing cross-references, data gaps that could be filled with a web search. The LLM is good at suggesting new questions to investigate and new sources to look for. This keeps the wiki healthy as it grows.
## Indexing and logging
Two special files help the LLM (and you) navigate the wiki as it grows. They serve different purposes:
**index.md** is content-oriented. It's a catalog of everything in the wiki — each page listed with a link, a one-line summary, and optionally metadata like date or source count. Organized by category (entities, concepts, sources, etc.). The LLM updates it on every ingest. When answering a query, the LLM reads the index first to find relevant pages, then drills into them. This works surprisingly well at moderate scale (~100 sources, ~hundreds of pages) and avoids the need for embedding-based RAG infrastructure.
**log.md** is chronological. It's an append-only record of what happened and when — ingests, queries, lint passes. A useful tip: if each entry starts with a consistent prefix (e.g. `## [2026-04-02] ingest | Article Title`), the log becomes parseable with simple unix tools — `grep "^## \[" log.md | tail -5` gives you the last 5 entries. The log gives you a timeline of the wiki's evolution and helps the LLM understand what's been done recently.
## Optional: CLI tools
At some point you may want to build small tools that help the LLM operate on the wiki more efficiently. A search engine over the wiki pages is the most obvious one — at small scale the index file is enough, but as the wiki grows you want proper search. [qmd](https://github.com/tobi/qmd) is a good option: it's a local search engine for markdown files with hybrid BM25/vector search and LLM re-ranking, all on-device. It has both a CLI (so the LLM can shell out to it) and an MCP server (so the LLM can use it as a native tool). You could also build something simpler yourself — the LLM can help you vibe-code a naive search script as the need arises.
## Tips and tricks
- **Obsidian Web Clipper** is a browser extension that converts web articles to markdown. Very useful for quickly getting sources into your raw collection.
- **Download images locally.** In Obsidian Settings → Files and links, set "Attachment folder path" to a fixed directory (e.g. `raw/assets/`). Then in Settings → Hotkeys, search for "Download" to find "Download attachments for current file" and bind it to a hotkey (e.g. Ctrl+Shift+D). After clipping an article, hit the hotkey and all images get downloaded to local disk. This is optional but useful — it lets the LLM view and reference images directly instead of relying on URLs that may break. Note that LLMs can't natively read markdown with inline images in one pass — the workaround is to have the LLM read the text first, then view some or all of the referenced images separately to gain additional context. It's a bit clunky but works well enough.
- **Obsidian's graph view** is the best way to see the shape of your wiki — what's connected to what, which pages are hubs, which are orphans.
- **Marp** is a markdown-based slide deck format. Obsidian has a plugin for it. Useful for generating presentations directly from wiki content.
- **Dataview** is an Obsidian plugin that runs queries over page frontmatter. If your LLM adds YAML frontmatter to wiki pages (tags, dates, source counts), Dataview can generate dynamic tables and lists.
- The wiki is just a git repo of markdown files. You get version history, branching, and collaboration for free.
## Why this works
The tedious part of maintaining a knowledge base is not the reading or the thinking — it's the bookkeeping. Updating cross-references, keeping summaries current, noting when new data contradicts old claims, maintaining consistency across dozens of pages. Humans abandon wikis because the maintenance burden grows faster than the value. LLMs don't get bored, don't forget to update a cross-reference, and can touch 15 files in one pass. The wiki stays maintained because the cost of maintenance is near zero.
The human's job is to curate sources, direct the analysis, ask good questions, and think about what it all means. The LLM's job is everything else.
The idea is related in spirit to Vannevar Bush's Memex (1945) — a personal, curated knowledge store with associative trails between documents. Bush's vision was closer to this than to what the web became: private, actively curated, with the connections between documents as valuable as the documents themselves. The part he couldn't solve was who does the maintenance. The LLM handles that.
## Note
This document is intentionally abstract. It describes the idea, not a specific implementation. The exact directory structure, the schema conventions, the page formats, the tooling — all of that will depend on your domain, your preferences, and your LLM of choice. Everything mentioned above is optional and modular — pick what's useful, ignore what isn't. For example: your sources might be text-only, so you don't need image handling at all. Your wiki might be small enough that the index file is all you need, no search engine required. You might not care about slide decks and just want markdown pages. You might want a completely different set of output formats. The right way to use this is to share it with your LLM agent and work together to instantiate a version that fits your needs. The document's only job is to communicate the pattern. Your LLM can figure out the rest.
@@ -0,0 +1,33 @@
# LLM Wiki Maintainer
You are the LLM Wiki Maintainer for this company. Your job is to keep the company wiki useful, cited, navigable, and current.
## Wiki Root
The wiki root folder is:
`{{localFolders.wiki-root.path}}`
The wiki's default operating schema is:
`{{localFolders.wiki-root.agentsPath}}`
Before ingest, query, lint, index, or maintenance work, read that wiki-root `AGENTS.md` file. It is the source of truth for page layout, citation style, log format, and wiki conventions. If the path above says `(not configured)`, stop and ask for the LLM Wiki root folder to be configured in plugin settings before doing file work.
## Identity
- You maintain the LLM Wiki, not the application codebase.
- You keep raw source material in `raw/` immutable.
- You create and update durable wiki pages under `wiki/`.
- You keep `wiki/index.md` and `wiki/log.md` accurate after changes.
- You cite wiki pages and raw sources in answers.
## Operating Loop
1. Resolve the configured wiki root folder.
2. Read the wiki-root `AGENTS.md`.
3. Read `wiki/index.md` and recent `wiki/log.md` entries before choosing files.
4. Use the LLM Wiki plugin tools for ingest, query, lint, file reads, file writes, and logging.
5. Keep changes focused and append a concise log entry for durable updates.
If instructions in this file conflict with the wiki-root `AGENTS.md`, follow this file for your identity and follow the wiki-root `AGENTS.md` for wiki structure and writing conventions.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,23 @@
# Index
Catalog of all wiki pages. Updated on every ingest.
## Sources
_(none yet)_
## Projects
_(none yet)_
## Entities
_(none yet)_
## Concepts
_(none yet)_
## Synthesis
_(none yet)_
@@ -0,0 +1,9 @@
# Log
Append-only chronological record of wiki operations.
## [2026-05-03] setup | wiki initialized
- created `AGENTS.md` (schema)
- created `wiki/` skeleton: `index.md`, `log.md`, `sources/`, `entities/`, `concepts/`, `synthesis/`
- created `raw/` for source documents
- pattern reference: `IDEA.md` (Karpathy "LLM Wiki" gist, https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { readIngestOperationIssueId, uploadIssueAttachmentFile } from "../src/ui/issue-attachments.js";
describe("LLM Wiki issue attachment uploads", () => {
it("reads the ingest operation issue id from the action result", () => {
expect(readIngestOperationIssueId({
operation: {
issue: {
id: "issue-1",
},
},
})).toBe("issue-1");
});
it("rejects an ingest result that cannot identify the created issue", () => {
expect(() => readIngestOperationIssueId({ operation: { issue: null } }))
.toThrow("did not return an issue id");
});
it("uploads the original file to the created ingest task", async () => {
const file = new File(["hello"], "source notes.md", { type: "text/markdown" });
const calls: Array<{ input: string; init: RequestInit }> = [];
const fetchImpl = async (input: string, init: RequestInit) => {
calls.push({ input, init });
return new Response(JSON.stringify({ id: "attachment-1" }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
};
await expect(uploadIssueAttachmentFile({
companyId: "company 1",
issueId: "issue/1",
file,
fetchImpl,
})).resolves.toEqual({ id: "attachment-1" });
expect(calls).toHaveLength(1);
expect(calls[0]?.input).toBe("/api/companies/company%201/issues/issue%2F1/attachments");
expect(calls[0]?.init.method).toBe("POST");
expect(calls[0]?.init.credentials).toBe("include");
const body = calls[0]?.init.body;
expect(body).toBeInstanceOf(FormData);
expect((body as FormData).get("file")).toBe(file);
});
it("surfaces server upload errors", async () => {
const fetchImpl = async () => new Response(JSON.stringify({ error: "Attachment exceeds 10 bytes" }), {
status: 422,
headers: { "Content-Type": "application/json" },
});
await expect(uploadIssueAttachmentFile({
companyId: "company-1",
issueId: "issue-1",
file: new File(["hello"], "source.txt"),
fetchImpl,
})).rejects.toThrow("Attachment exceeds 10 bytes");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,145 @@
#!/usr/bin/env node
import { mkdirSync, copyFileSync, existsSync, readFileSync } from "node:fs";
import { dirname, resolve, extname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import http from "node:http";
import esbuild from "esbuild";
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkgRoot = resolve(__dirname, "..", "..");
const outDir = resolve(pkgRoot, "dist", "screenshots");
const screensDir = resolve(pkgRoot, "screenshots");
mkdirSync(outDir, { recursive: true });
mkdirSync(screensDir, { recursive: true });
const entry = resolve(__dirname, "entry.tsx");
const repoRoot = resolve(pkgRoot, "..", "..", "..");
const reactPath = resolve(repoRoot, "node_modules/.pnpm/react@19.2.4/node_modules/react");
const reactDomPath = resolve(repoRoot, "node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom");
console.log("Bundling screenshot harness…");
await esbuild.build({
entryPoints: [entry],
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
outfile: resolve(outDir, "bundle.js"),
jsx: "automatic",
loader: { ".tsx": "tsx", ".ts": "ts" },
define: { "process.env.NODE_ENV": '"production"' },
logLevel: "warning",
alias: {
"react": reactPath,
"react-dom": reactDomPath,
"react-dom/client": resolve(reactDomPath, "client.js"),
"react/jsx-runtime": resolve(reactPath, "jsx-runtime.js"),
},
});
copyFileSync(resolve(__dirname, "index.html"), resolve(outDir, "index.html"));
const desktopViewport = { width: 1440, height: 920 };
const mobileViewport = { width: 390, height: 844 };
const desktopTargets = [
{ slug: "01-wiki-browse", view: "wiki-sidebar", section: null },
{ slug: "02-wiki-ingest", view: "wiki-sidebar", section: "ingest" },
{ slug: "03-wiki-query", view: "wiki-sidebar", section: "query" },
{ slug: "04-wiki-lint", view: "wiki-sidebar", section: "lint" },
{ slug: "05-wiki-history", view: "wiki-sidebar", section: "history" },
{ slug: "06-wiki-settings", view: "wiki-sidebar", section: "settings" },
{ slug: "07-host-settings", view: "settings" },
{ slug: "09-sidebar-link", view: "sidebar" },
{ slug: "11-wiki-distillation-settings", view: "wiki-sidebar", section: "settings/distillation" },
{ slug: "12-wiki-distillation-unconfigured", view: "wiki-sidebar", section: "settings/distillation", search: "unconfigured=1" },
];
const mobileTargets = desktopTargets.map((target) => ({
...target,
slug: `mobile/${target.slug}`,
// In the production host, the route sidebar lives in the mobile drawer.
// The page body should therefore be checked without the desktop sidebar.
view: target.view === "wiki-sidebar" ? "wiki" : target.view,
viewport: mobileViewport,
}));
const targets = [
...desktopTargets.map((target) => ({ ...target, viewport: desktopViewport })),
...mobileTargets,
];
const playwrightUrl = pathToFileURL(resolve(pkgRoot, "node_modules/playwright/index.mjs")).href;
const playwrightFallback = resolve(pkgRoot, "..", "..", "..", "node_modules", ".pnpm", "playwright@1.58.2", "node_modules", "playwright", "index.mjs");
let playwrightModuleHref = playwrightUrl;
if (!existsSync(resolve(pkgRoot, "node_modules/playwright/index.mjs"))) {
if (existsSync(playwrightFallback)) {
playwrightModuleHref = pathToFileURL(playwrightFallback).href;
} else {
throw new Error("Cannot locate playwright module");
}
}
const { chromium } = await import(playwrightModuleHref);
const mimeFor = (ext) => ({
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".map": "application/json; charset=utf-8",
})[ext] ?? "application/octet-stream";
const server = http.createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
const ext = extname(requestedPath);
const candidate = ext ? resolve(outDir, "." + requestedPath) : resolve(outDir, "./index.html");
try {
const body = readFileSync(candidate);
res.writeHead(200, { "Content-Type": mimeFor(extname(candidate)) });
res.end(body);
} catch {
res.writeHead(404);
res.end("Not found: " + candidate);
}
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const { port } = server.address();
const baseUrl = `http://127.0.0.1:${port}`;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: desktopViewport, deviceScaleFactor: 2 });
const page = await context.newPage();
page.on("console", (msg) => console.log(` [console.${msg.type()}]`, msg.text()));
page.on("pageerror", (err) => console.error(" [pageerror]", err.message));
for (const target of targets) {
const sectionPath = target.section ? `/${target.section}` : "";
const search = target.search ? `?${target.search}` : "";
const url = `${baseUrl}/PAP/wiki${sectionPath}${search}#${target.view}`;
console.log(`→ rendering ${target.slug} (${url})`);
await page.setViewportSize(target.viewport);
await page.goto(url, { waitUntil: "networkidle" });
await page.waitForTimeout(200);
if (target.slug === "09-sidebar-link") {
await page.addStyleTag({ content: "body { background: var(--sidebar); }" });
}
const outFile = resolve(screensDir, `${target.slug}.png`);
mkdirSync(dirname(outFile), { recursive: true });
await page.screenshot({ path: outFile, fullPage: false });
const horizontalOverflow = await page.evaluate(() => {
const rootOverflow = document.documentElement.scrollWidth - document.documentElement.clientWidth;
const bodyOverflow = document.body.scrollWidth - window.innerWidth;
return Math.max(rootOverflow, bodyOverflow);
});
if (horizontalOverflow > 1) {
throw new Error(`${target.slug} has ${horizontalOverflow}px horizontal overflow at ${target.viewport.width}px`);
}
console.log(` saved ${outFile}`);
}
await browser.close();
server.close();
console.log("Done. Screenshots in", screensDir);
@@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client";
import { App } from "./harness.js";
const container = document.getElementById("root");
if (!container) throw new Error("No #root in harness host");
createRoot(container).render(<App />);
@@ -0,0 +1,779 @@
import { useEffect, useState } from "react";
import { SettingsPage, SidebarLink, WikiPage, WikiRouteSidebar } from "../../src/ui/index.js";
import type {
FileTreeNode,
FileTreeBadge,
FileTreeProps,
IssuesListProps,
} from "@paperclipai/plugin-sdk/ui";
// ---------------------------------------------------------------------------
// Bridge mocks. The real SDK runtime hooks read from a global registry; we
// inline an alternate registry here so the plugin UI can render with canned
// data inside a screenshot harness.
// ---------------------------------------------------------------------------
const NOW = "2026-05-01T20:30:00.000Z";
const FOLDER_HEALTHY = {
folderKey: "wiki-root",
configured: true,
path: "/Users/operator/work/wiki/engineering",
realPath: "/Users/operator/work/wiki/engineering",
access: "readWrite" as const,
readable: true,
writable: true,
requiredDirectories: ["raw", "wiki", "wiki/sources", "wiki/projects", "wiki/entities", "wiki/concepts", "wiki/synthesis"],
requiredFiles: ["AGENTS.md", "IDEA.md", "wiki/index.md", "wiki/log.md"],
missingDirectories: [],
missingFiles: [],
healthy: true,
problems: [],
checkedAt: NOW,
};
const MANAGED_AGENT = {
status: "active",
agentId: "agt-c14a-7b2f-4e90",
resourceKey: "paperclipai.plugin-llm-wiki:agent:wiki-maintainer",
details: { name: "Wiki Maintainer", status: "active", adapterType: "claude_local", icon: "book-open", urlKey: "wiki-maintainer" },
};
const MANAGED_PROJECT = {
status: "active",
projectId: "prj-llmw-7e1a",
resourceKey: "paperclipai.plugin-llm-wiki:project:llm-wiki",
details: { name: "LLM Wiki Operations", status: "in_progress" },
};
const MANAGED_ROUTINE = {
status: "active",
routineId: "rtn-llmw-night",
resourceKey: "paperclipai.plugin-llm-wiki:routine:nightly-wiki-lint",
details: {
title: "Nightly wiki lint",
status: "active",
cronExpression: "0 3 * * *",
enabled: true,
assigneeAgentId: "agt-c14a-7b2f-4e90",
nextRunAt: "2026-05-02T03:00:00Z",
lastRunAt: "2026-05-01T03:00:00Z",
},
};
const OVERVIEW = {
status: "ok",
checkedAt: NOW,
wikiId: "default",
folder: FOLDER_HEALTHY,
managedAgent: MANAGED_AGENT,
managedProject: MANAGED_PROJECT,
operationCount: 128,
capabilities: [
"api.routes.register",
"issues.create",
"issues.read",
"agents.managed",
"projects.managed",
"routines.managed",
"agent.tools.register",
],
prompts: {
query: "Answer from the LLM Wiki.\n\nRead wiki/index.md first, inspect relevant pages and raw/source references, cite the pages used, and say when the wiki does not contain enough evidence.",
lint: "Lint the LLM Wiki for contradictions, stale claims, orphan pages, missing backlinks, weak provenance, and wiki/index.md / wiki/log.md drift.",
},
eventIngestion: {
enabled: false,
sources: { issues: false, comments: false, documents: false },
wikiId: "default",
maxCharacters: 12000,
},
};
const SETTINGS = {
folder: FOLDER_HEALTHY,
managedAgent: MANAGED_AGENT,
managedProject: MANAGED_PROJECT,
managedRoutine: MANAGED_ROUTINE,
managedRoutines: [MANAGED_ROUTINE],
agentOptions: [{ id: "agt-c14a-7b2f-4e90", name: "Wiki Maintainer", status: "active", icon: "book-open", urlKey: "wiki-maintainer" }],
projectOptions: [],
eventIngestion: OVERVIEW.eventIngestion,
capabilities: OVERVIEW.capabilities,
};
const PAGES = {
pages: [
{ path: "wiki/areas/control-plane.md", title: "Control plane", pageType: "areas", backlinkCount: 5, sourceCount: 2, contentHash: "a1b2c3d4", updatedAt: "2026-05-01T18:00:00Z" },
{ path: "wiki/areas/plugin-runtime.md", title: "Plugin runtime", pageType: "areas", backlinkCount: 4, sourceCount: 2, contentHash: "b1b2c3d4", updatedAt: "2026-05-01T16:00:00Z" },
{ path: "wiki/concepts/managed-resources.md", title: "Managed resources", pageType: "concepts", backlinkCount: 7, sourceCount: 2, contentHash: "c1b2c3d4", updatedAt: "2026-05-01T19:30:00Z" },
{ path: "wiki/concepts/origin-kind.md", title: "Origin kind", pageType: "concepts", backlinkCount: 3, sourceCount: 1, contentHash: "d1b2c3d4", updatedAt: "2026-05-01T15:00:00Z" },
{ path: "wiki/concepts/heartbeat.md", title: "Agent heartbeat", pageType: "concepts", backlinkCount: 2, sourceCount: 1, contentHash: "e1b2c3d4", updatedAt: "2026-04-30T10:00:00Z" },
{ path: "wiki/projects/llm-wiki.md", title: "LLM Wiki plugin", pageType: "projects", backlinkCount: 6, sourceCount: 3, contentHash: "f1b2c3d4", updatedAt: "2026-05-01T17:30:00Z" },
{ path: "wiki/sources/karpathy-llm-wiki.md", title: "Karpathy LLM Wiki gist (summary)", pageType: "sources", backlinkCount: 3, sourceCount: 1, contentHash: "g1b2c3d4", updatedAt: "2026-05-01T11:30:00Z" },
{ path: "wiki/index.md", title: "LLM Wiki Index", pageType: "index", backlinkCount: 0, sourceCount: 0, contentHash: "h1b2c3d4", updatedAt: "2026-05-01T19:30:00Z" },
{ path: "wiki/log.md", title: "LLM Wiki Log", pageType: "log", backlinkCount: 0, sourceCount: 0, contentHash: "i1b2c3d4", updatedAt: "2026-05-01T19:30:00Z" },
],
sources: [
{ rawPath: "raw/karpathy-llm-wiki.md", title: "Karpathy LLM Wiki gist", sourceType: "url", url: "https://gist.github.com/karpathy/.../llm-wiki", status: "captured", createdAt: "2026-05-01T19:00:00Z" },
{ rawPath: "raw/paperclip-v1-spec.md", title: "Paperclip V1 spec", sourceType: "file", url: null, status: "captured", createdAt: "2026-05-01T18:30:00Z" },
{ rawPath: "raw/design-doc-2026-04.md", title: "Design doc — April 2026", sourceType: "file", url: null, status: "captured", createdAt: "2026-04-28T14:00:00Z" },
],
};
const PAGE_CONTENT = {
wikiId: "default",
path: "wiki/concepts/managed-resources.md",
contents: `# Managed Resources
A **plugin-managed resource** is a normal Paperclip resource (agent, routine, project) that a plugin has seeded with suggested defaults. The plugin marks ownership via a stable \`resourceKey\` so that installs, imports, and upgrades can find the resource even when UUIDs differ.
The host treats managed resources as fully editable. The operator can rename, change adapter config, edit instructions, change schedule, etc. The plugin can offer a *reset to plugin defaults* action that re-applies the suggested values.
## Why it matters
- Stable references survive dev resets and re-imports.
- Plugin upgrades don't clobber operator edits.
- Operations remain inspectable through native Agents / Routines / Projects pages.
`,
title: "Managed Resources",
pageType: "concepts",
backlinks: ["wiki/areas/plugin-runtime.md", "wiki/concepts/origin-kind.md", "wiki/projects/llm-wiki.md"],
sourceRefs: ["raw/karpathy-llm-wiki.md", "raw/paperclip-v1-spec.md"],
updatedAt: "2026-05-01T19:30:00Z",
hash: "deadbeefcafe1234",
};
const OPERATIONS = {
operations: [
{ id: "2944aaaaaaaa", operationType: "ingest", status: "running", hiddenIssueId: "ii-2944", hiddenIssueIdentifier: "PAP-OP-2944", hiddenIssueTitle: "Ingest URL: karpathy/llm-wiki gist", hiddenIssueStatus: "in_progress", projectId: "prj-llmw-7e1a", costCents: 1, warnings: [], affectedPages: [], createdAt: "2026-05-01T20:29:48Z", updatedAt: "2026-05-01T20:29:48Z" },
{ id: "2943bbbbbbbb", operationType: "query", status: "done", hiddenIssueId: "ii-2943", hiddenIssueIdentifier: "PAP-OP-2943", hiddenIssueTitle: "How do plugin-managed routines resolve their default agent…", hiddenIssueStatus: "done", projectId: "prj-llmw-7e1a", costCents: 1, warnings: [], affectedPages: [], createdAt: "2026-05-01T20:25:00Z", updatedAt: "2026-05-01T20:26:30Z" },
{ id: "2942cccccccc", operationType: "file-as-page", status: "done", hiddenIssueId: "ii-2942", hiddenIssueIdentifier: "PAP-OP-2942", hiddenIssueTitle: "File answer as concepts/routine-agent-resolution.md", hiddenIssueStatus: "done", projectId: "prj-llmw-7e1a", costCents: 0, warnings: [], affectedPages: [{ path: "wiki/concepts/routine-agent-resolution.md" }], createdAt: "2026-05-01T20:25:30Z", updatedAt: "2026-05-01T20:25:35Z" },
{ id: "2941dddddddd", operationType: "query", status: "done", hiddenIssueId: "ii-2941", hiddenIssueIdentifier: "PAP-OP-2941", hiddenIssueTitle: "Compare managed agent reset vs detach…", hiddenIssueStatus: "done", projectId: "prj-llmw-7e1a", costCents: 1, warnings: [], affectedPages: [], createdAt: "2026-05-01T20:18:00Z", updatedAt: "2026-05-01T20:18:30Z" },
{ id: "2940eeeeeeee", operationType: "lint", status: "done", hiddenIssueId: "ii-2940", hiddenIssueIdentifier: "PAP-OP-2940", hiddenIssueTitle: "Nightly wiki lint · 17 findings (2 critical)", hiddenIssueStatus: "done", projectId: "prj-llmw-7e1a", costCents: 4, warnings: [
{ severity: "critical", message: "Contradiction: managed-resources.md vs raw/karpathy-llm-wiki.md (run-time vs install-time resolution)", path: "wiki/concepts/managed-resources.md" },
{ severity: "critical", message: "Conflicting claim about plugin manifest version", path: "wiki/concepts/origin-kind.md" },
{ severity: "orphan", message: "people/aaron-norvig.md has no inbound backlinks", path: "wiki/people/aaron-norvig.md" },
{ severity: "orphan", message: "concepts/sandboxing.md is referenced from raw/ only", path: "wiki/concepts/sandboxing.md" },
{ severity: "stale", message: "areas/control-plane.md last source observed 41d ago", path: "wiki/areas/control-plane.md" },
{ severity: "backlink", message: "Missing reverse links: managed-resources.md → projects/llm-wiki.md" },
{ severity: "index", message: "index.md does not list 5 pages created in last 24h" },
], affectedPages: [], createdAt: "2026-05-01T18:00:00Z", updatedAt: "2026-05-01T18:02:30Z" },
{ id: "2939ffffffff", operationType: "ingest", status: "done", hiddenIssueId: "ii-2939", hiddenIssueIdentifier: "PAP-OP-2939", hiddenIssueTitle: "paperclip-v1-spec.md · 3 pages created", hiddenIssueStatus: "done", projectId: "prj-llmw-7e1a", costCents: 2, warnings: [], affectedPages: [{ path: "wiki/areas/control-plane.md" }, { path: "wiki/concepts/managed-resources.md" }, { path: "wiki/concepts/origin-kind.md" }], createdAt: "2026-05-01T18:30:00Z", updatedAt: "2026-05-01T18:32:00Z" },
{ id: "2934gggggggg", operationType: "ingest", status: "failed", hiddenIssueId: "ii-2934", hiddenIssueIdentifier: "PAP-OP-2934", hiddenIssueTitle: "Internal wiki dump (PDF) · outbound URL not on allowlist", hiddenIssueStatus: "blocked", projectId: "prj-llmw-7e1a", costCents: 0, warnings: [{ message: "outbound URL not on allowlist" }], affectedPages: [], createdAt: "2026-05-01T16:00:00Z", updatedAt: "2026-05-01T16:00:30Z" },
],
};
const TEMPLATE_IDEA = {
path: "IDEA.md",
exists: true,
hash: "abc123",
contents: `# LLM Wiki
A pattern for building personal knowledge bases using LLMs.
`,
};
const TEMPLATE_AGENTS = {
path: "AGENTS.md",
exists: true,
hash: "def456",
contents: `# LLM Wiki Maintainer
You maintain this wiki through Paperclip plugin tools.
Before answering or editing:
1. Read AGENTS.md.
2. Read wiki/index.md.
3. Cite raw sources or source pages for factual claims.
4. Use proposed patches for substantial rewrites.
5. Append a short note to wiki/log.md for maintenance changes.
`,
};
const HOST_CONTEXT = {
companyId: "company-demo",
companyPrefix: "PAP",
projectId: null,
entityId: null,
entityType: null,
userId: "user-demo",
parentEntityId: null,
};
function fakeData<T>(value: T) {
return { data: value, loading: false, error: null, refresh: () => undefined };
}
const DISTILLATION_CURSOR = {
id: "cur-control-plane-1",
sourceScope: "project",
scopeKey: "prj-control-plane",
projectId: "prj-control-plane",
projectName: "Control plane",
projectColor: "#2563eb",
rootIssueId: null,
rootIssueIdentifier: null,
rootIssueTitle: null,
lastProcessedAt: "2026-05-04T15:00:00Z",
lastObservedAt: "2026-05-04T18:55:00Z",
pendingEventCount: 4,
lastSourceHash: "7f3a92041b210def",
lastSuccessfulRunId: "run-1",
};
const DISTILLATION_RUNS = [
{
id: "run-1",
cursorId: "cur-control-plane-1",
workItemId: null,
projectId: "prj-control-plane",
projectName: "Control plane",
rootIssueId: null,
rootIssueIdentifier: null,
sourceWindowStart: "2026-04-30T14:22:00Z",
sourceWindowEnd: "2026-05-04T15:01:00Z",
sourceHash: "7f3a92041b210def",
status: "succeeded",
costCents: 12,
retryCount: 0,
warnings: [],
metadata: {},
operationIssueId: "ii-op-3201",
operationIssueIdentifier: "PAP-OP-3201",
operationIssueTitle: "LLM Wiki distillation · Control plane",
affectedPagePaths: ["wiki/projects/control-plane.md"],
createdAt: "2026-05-04T15:01:00Z",
updatedAt: "2026-05-04T15:02:14Z",
},
{
id: "run-2",
cursorId: "cur-billing-1",
workItemId: null,
projectId: "prj-billing",
projectName: "Billing",
rootIssueId: null,
rootIssueIdentifier: null,
sourceWindowStart: "2026-05-03T08:00:00Z",
sourceWindowEnd: "2026-05-04T18:55:00Z",
sourceHash: "92d44102be7c08aa",
status: "review_required",
costCents: 8,
retryCount: 0,
warnings: ["Touches the Decisions section. Auto-apply policy is 'Status sections only'."],
metadata: {},
operationIssueId: "ii-op-3204",
operationIssueIdentifier: "PAP-OP-3204",
operationIssueTitle: "LLM Wiki distillation · Billing",
affectedPagePaths: ["wiki/projects/billing.md", "wiki/projects/billing/decisions.md"],
createdAt: "2026-05-04T18:55:00Z",
updatedAt: "2026-05-04T18:56:14Z",
},
{
id: "run-3",
cursorId: "cur-routines-1",
workItemId: null,
projectId: "prj-routines",
projectName: "Routine engine",
rootIssueId: null,
rootIssueIdentifier: null,
sourceWindowStart: "2026-05-04T11:00:00Z",
sourceWindowEnd: "2026-05-04T18:00:00Z",
sourceHash: "ab12dde98c3f0001",
status: "running",
costCents: 4,
retryCount: 0,
warnings: [],
metadata: {},
operationIssueId: "ii-op-3205",
operationIssueIdentifier: "PAP-OP-3205",
operationIssueTitle: "LLM Wiki distillation · Routine engine",
affectedPagePaths: [],
createdAt: "2026-05-04T18:58:00Z",
updatedAt: "2026-05-04T18:58:00Z",
},
{
id: "run-4",
cursorId: "cur-export-1",
workItemId: null,
projectId: "prj-exports",
projectName: "Exports",
rootIssueId: null,
rootIssueIdentifier: null,
sourceWindowStart: "2026-05-03T20:00:00Z",
sourceWindowEnd: "2026-05-04T03:00:00Z",
sourceHash: "ee01acd482211110",
status: "failed",
costCents: 0,
retryCount: 1,
warnings: ["model error: 429", "Source bundle clipped: 6 issues over 48k chars."],
metadata: {},
operationIssueId: "ii-op-3198",
operationIssueIdentifier: "PAP-OP-3198",
operationIssueTitle: "LLM Wiki distillation · Exports",
affectedPagePaths: [],
createdAt: "2026-05-04T03:01:00Z",
updatedAt: "2026-05-04T03:01:30Z",
},
];
const DISTILLATION_PAGE_BINDINGS = [
{
id: "bind-control-plane",
pagePath: "wiki/projects/control-plane.md",
projectId: "prj-control-plane",
projectName: "Control plane",
rootIssueId: null,
lastAppliedSourceHash: "7f3a92041b210def",
lastDistillationRunId: "run-1",
lastRunStatus: "succeeded",
lastRunCompletedAt: "2026-05-04T15:02:14Z",
lastRunSourceWindowEnd: "2026-05-04T15:01:00Z",
lastRunSourceHash: "7f3a92041b210def",
metadata: { sourceRefs: [{ id: "PAP-3416", kind: "issue", title: "Distillation kickoff" }, { id: "PAP-3416#c1", kind: "comment", title: "Comment" }] },
updatedAt: "2026-05-04T15:02:14Z",
},
{
id: "bind-billing",
pagePath: "wiki/projects/billing.md",
projectId: "prj-billing",
projectName: "Billing",
rootIssueId: null,
lastAppliedSourceHash: "11abf991a081ddff",
lastDistillationRunId: "run-2",
lastRunStatus: "review_required",
lastRunCompletedAt: "2026-05-04T18:56:14Z",
lastRunSourceWindowEnd: "2026-05-04T18:55:00Z",
lastRunSourceHash: "92d44102be7c08aa",
metadata: {},
updatedAt: "2026-05-04T18:56:14Z",
},
];
const DISTILLATION_OVERVIEW = {
cursors: [
DISTILLATION_CURSOR,
{
...DISTILLATION_CURSOR,
id: "cur-billing-1",
scopeKey: "prj-billing",
projectId: "prj-billing",
projectName: "Billing",
lastProcessedAt: "2026-05-04T18:55:00Z",
lastObservedAt: "2026-05-04T18:55:00Z",
pendingEventCount: 0,
lastSuccessfulRunId: "run-2",
},
{
...DISTILLATION_CURSOR,
id: "cur-routines-1",
scopeKey: "prj-routines",
projectId: "prj-routines",
projectName: "Routine engine",
lastProcessedAt: null,
lastObservedAt: "2026-05-04T18:00:00Z",
pendingEventCount: 11,
lastSuccessfulRunId: null,
},
],
runs: DISTILLATION_RUNS,
workItems: [
{ id: "wi-1", workItemKind: "review", status: "review_required", priority: "high", projectId: "prj-billing", rootIssueId: null, metadata: {}, createdAt: "2026-05-04T18:56:00Z", updatedAt: "2026-05-04T18:56:00Z" },
],
pageBindings: DISTILLATION_PAGE_BINDINGS,
reviewWorkItems: [
{ id: "wi-1", workItemKind: "review", status: "review_required", priority: "high", projectId: "prj-billing", rootIssueId: null, metadata: {}, createdAt: "2026-05-04T18:56:00Z", updatedAt: "2026-05-04T18:56:00Z" },
],
counts: { cursors: 3, runningRuns: 1, failedRuns24h: 1, reviewRequired: 1 },
};
const DISTILLATION_OVERVIEW_UNCONFIGURED = {
cursors: [],
runs: [],
workItems: [],
pageBindings: [],
reviewWorkItems: [],
counts: { cursors: 0, runningRuns: 0, failedRuns24h: 0, reviewRequired: 0 },
};
const DISTILLATION_PROVENANCE = {
binding: DISTILLATION_PAGE_BINDINGS[0],
runs: [DISTILLATION_RUNS[0]],
snapshot: {
id: "snap-1",
distillationRunId: "run-1",
sourceHash: "7f3a92041b210def",
maxCharacters: 48000,
clipped: false,
sourceRefs: [
{ id: "PAP-3416", kind: "issue", title: "How should we distill?" },
{ id: "PAP-3416#c1", kind: "comment", title: "Approach: cursors" },
{ id: "PAP-3416#doc:plan", kind: "document", title: "Plan v3" },
],
metadata: { issueCount: 12, commentCount: 8, documentCount: 4 },
createdAt: "2026-05-04T15:01:00Z",
},
cursor: DISTILLATION_CURSOR,
};
function dataResolver(key: string, params?: Record<string, unknown>) {
if (key === "overview") return fakeData(OVERVIEW);
if (key === "settings") return fakeData(SETTINGS);
if (key === "pages") return fakeData(PAGES);
if (key === "page-content") return fakeData(PAGE_CONTENT);
if (key === "operations") {
const opType = typeof params?.operationType === "string" ? params.operationType : null;
const filtered = opType && opType !== "all"
? OPERATIONS.operations.filter((op) => op.operationType === opType)
: OPERATIONS.operations;
return fakeData({ operations: filtered });
}
if (key === "distillation-overview") {
const flag = typeof window !== "undefined" ? window.location.search.includes("unconfigured=1") : false;
return fakeData(flag ? DISTILLATION_OVERVIEW_UNCONFIGURED : DISTILLATION_OVERVIEW);
}
if (key === "distillation-page-provenance") {
return fakeData(DISTILLATION_PROVENANCE);
}
if (key === "template" && params?.path === "IDEA.md") return fakeData(TEMPLATE_IDEA);
if (key === "template" && params?.path === "AGENTS.md") return fakeData(TEMPLATE_AGENTS);
return { data: null, loading: false, error: null, refresh: () => undefined };
}
function MockFileTree(props: FileTreeProps) {
const expanded = new Set(props.expandedPaths ?? []);
const badges = props.fileBadges ?? {};
const renderNodes = (nodes: FileTreeNode[], depth: number) => (
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
{nodes.map((node) => {
const isExpanded = node.kind === "dir" && expanded.has(node.path);
const isSelected = node.kind === "file" && node.path === props.selectedFile;
const badge = badges[node.path] as FileTreeBadge | undefined;
return (
<li key={node.path}>
<button
type="button"
onClick={() => {
if (node.kind === "dir") props.onToggleDir?.(node.path);
else props.onSelectFile?.(node.path);
}}
style={{
display: "flex",
alignItems: "center",
gap: 6,
width: "100%",
border: "none",
background: isSelected ? "var(--accent, oklch(0.269 0 0))" : "transparent",
color: "var(--foreground, oklch(0.985 0 0))",
fontSize: 12,
textAlign: "left",
padding: `4px 8px 4px ${depth * 16 + 12}px`,
cursor: "pointer",
borderRadius: 4,
fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif",
}}
aria-expanded={node.kind === "dir" ? isExpanded : undefined}
aria-selected={isSelected || undefined}
>
<span style={{ width: 12, color: "var(--muted-foreground, oklch(0.708 0 0))" }} aria-hidden>
{node.kind === "dir" ? (isExpanded ? "▾" : "▸") : ""}
</span>
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{node.kind === "dir" ? `📁 ${node.name}` : node.name}
</span>
{badge ? (
<span
style={{
fontSize: 10,
padding: "1px 6px",
borderRadius: 999,
background: "color-mix(in oklab, var(--accent, oklch(0.4 0.04 250)) 50%, transparent)",
color: "var(--foreground, oklch(0.985 0 0))",
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
title={badge.tooltip}
>
{badge.label}
</span>
) : null}
</button>
{node.kind === "dir" && isExpanded ? renderNodes(node.children, depth + 1) : null}
</li>
);
})}
</ul>
);
if (props.loading) {
return <div style={{ padding: 12, fontSize: 12, color: "var(--muted-foreground, oklch(0.708 0 0))" }}>Loading…</div>;
}
if (props.error) {
return <div style={{ padding: 12, fontSize: 12, color: "var(--destructive, oklch(0.7 0.2 25))" }}>{props.error.message}</div>;
}
if (props.nodes.length === 0) {
return (
<div style={{ padding: 16, fontSize: 12, color: "var(--muted-foreground, oklch(0.708 0 0))" }}>
<div style={{ fontWeight: 600, color: "var(--foreground, oklch(0.985 0 0))" }}>{props.empty?.title ?? "No files"}</div>
<div>{props.empty?.description ?? "Nothing to show."}</div>
</div>
);
}
return <div role="tree" aria-label={props.ariaLabel ?? "Files"}>{renderNodes(props.nodes, 0)}</div>;
}
function MockIssuesList(props: IssuesListProps) {
const rows = OPERATIONS.operations.map((op) => ({
id: op.hiddenIssueId ?? op.id,
identifier: op.hiddenIssueIdentifier ?? `op-${op.id.slice(0, 6)}`,
title: op.hiddenIssueTitle ?? `LLM Wiki ${op.operationType}`,
status: op.hiddenIssueStatus ?? op.status,
priority: op.operationType === "query" ? "medium" : "low",
updatedAt: op.updatedAt,
operationType: op.operationType,
}));
return (
<div style={{ display: "grid", gap: 10, padding: 18, fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<strong style={{ fontSize: 14 }}>Issues</strong>
<span style={{ fontSize: 11, color: "var(--muted-foreground, oklch(0.708 0 0))" }}>
{rows.length} loaded · {props.filters?.originKindPrefix ?? props.projectId ?? "all"}
</span>
</div>
<div style={{ border: "1px solid var(--border, oklch(0.269 0 0))", borderRadius: 8, overflow: "hidden", background: "var(--card, oklch(0.205 0 0))" }}>
{rows.map((issue) => (
<div
key={issue.id}
style={{
display: "grid",
gridTemplateColumns: "110px minmax(0, 1fr) 110px 90px",
gap: 12,
alignItems: "center",
padding: "10px 12px",
borderBottom: "1px solid var(--border, oklch(0.269 0 0))",
fontSize: 13,
}}
>
<span style={{ fontFamily: "ui-monospace, SFMono-Regular, monospace", fontSize: 12, color: "var(--muted-foreground, oklch(0.708 0 0))" }}>{issue.identifier}</span>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{issue.title}</span>
<span style={{ fontSize: 11, color: "var(--muted-foreground, oklch(0.708 0 0))" }}>{issue.operationType}</span>
<span style={{ justifySelf: "end", fontSize: 11, border: "1px solid var(--border, oklch(0.269 0 0))", borderRadius: 999, padding: "2px 8px" }}>{issue.status}</span>
</div>
))}
</div>
</div>
);
}
function MockMarkdownBlock({ content, className }: { content: string; className?: string }) {
return (
<div
className={className}
style={{ whiteSpace: "pre-wrap", fontSize: 13, lineHeight: 1.65 }}
>
{content}
</div>
);
}
function MockMarkdownEditor({
value,
onChange,
placeholder,
className,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}) {
return (
<textarea
className={className}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.currentTarget.value)}
style={{
width: "100%",
minHeight: 260,
boxSizing: "border-box",
border: "1px solid var(--border, oklch(0.269 0 0))",
borderRadius: 8,
background: "var(--card, oklch(0.205 0 0))",
color: "var(--foreground, oklch(0.985 0 0))",
padding: 12,
font: "13px ui-sans-serif, system-ui, -apple-system, sans-serif",
}}
/>
);
}
function MockAssigneePicker({
value,
placeholder,
onChange,
}: {
value: string;
placeholder?: string;
onChange: (value: string, selection: { assigneeAgentId: string | null; assigneeUserId: string | null }) => void;
}) {
return (
<button
type="button"
onClick={() => onChange("agent:agt-c14a-7b2f-4e90", { assigneeAgentId: "agt-c14a-7b2f-4e90", assigneeUserId: null })}
style={{
width: "100%",
minHeight: 44,
border: "1px solid var(--border, oklch(0.269 0 0))",
borderRadius: 8,
background: "var(--card, oklch(0.205 0 0))",
color: "var(--foreground, oklch(0.985 0 0))",
padding: "8px 12px",
textAlign: "left",
font: "13px ui-sans-serif, system-ui, -apple-system, sans-serif",
}}
>
{value ? "Wiki Maintainer" : (placeholder ?? "Select maintainer")}
</button>
);
}
function MockProjectPicker({
value,
placeholder,
onChange,
}: {
value: string;
placeholder?: string;
onChange: (projectId: string) => void;
}) {
return (
<button
type="button"
onClick={() => onChange("prj-llmw-7e1a")}
style={{
width: "100%",
minHeight: 44,
border: "1px solid var(--border, oklch(0.269 0 0))",
borderRadius: 8,
background: "var(--card, oklch(0.205 0 0))",
color: "var(--foreground, oklch(0.985 0 0))",
padding: "8px 12px",
textAlign: "left",
font: "13px ui-sans-serif, system-ui, -apple-system, sans-serif",
}}
>
{value ? "LLM Wiki Operations" : (placeholder ?? "Project")}
</button>
);
}
// Captured at module load so we can flip it before render and trigger
// re-renders via popstate when the hash/search changes.
function readHostLocation() {
if (typeof window === "undefined") {
return { pathname: "/PAP/wiki", search: "", hash: "" };
}
return { pathname: window.location.pathname, search: window.location.search, hash: window.location.hash };
}
const hostLocationListeners = new Set<() => void>();
function useHostLocationMock() {
const [snapshot, setSnapshot] = useState(readHostLocation);
useEffect(() => {
const update = () => setSnapshot(readHostLocation());
window.addEventListener("popstate", update);
window.addEventListener("hashchange", update);
hostLocationListeners.add(update);
return () => {
window.removeEventListener("popstate", update);
window.removeEventListener("hashchange", update);
hostLocationListeners.delete(update);
};
}, []);
return snapshot;
}
const sdkUi: Record<string, unknown> = {
usePluginData: dataResolver,
usePluginAction: () => async () => ({ status: "ok" }),
usePluginStream: () => ({ events: [], lastEvent: null, connecting: false, connected: false, error: null, close: () => undefined }),
usePluginToast: () => () => null,
useHostContext: () => HOST_CONTEXT,
useHostNavigation: () => ({
resolveHref: (to: string) => `/PAP${to.startsWith("/") ? to : `/${to}`}`,
navigate: (to: string) => {
const href = `/PAP${to.startsWith("/") ? to : `/${to}`}`;
window.history.pushState({}, "", href);
hostLocationListeners.forEach((fn) => fn());
},
linkProps: (to: string) => ({
href: `/PAP${to.startsWith("/") ? to : `/${to}`}`,
onClick: (event: { preventDefault: () => void }) => {
event.preventDefault();
const href = `/PAP${to.startsWith("/") ? to : `/${to}`}`;
window.history.pushState({}, "", href);
hostLocationListeners.forEach((fn) => fn());
},
}),
}),
useHostLocation: useHostLocationMock,
MarkdownBlock: MockMarkdownBlock,
MarkdownEditor: MockMarkdownEditor,
FileTree: MockFileTree,
IssuesList: MockIssuesList,
AssigneePicker: MockAssigneePicker,
ProjectPicker: MockProjectPicker,
};
(globalThis as { __paperclipPluginBridge__?: { sdkUi?: Record<string, unknown> } }).__paperclipPluginBridge__ = { sdkUi };
// ---------------------------------------------------------------------------
// Harness app: chooses which view to render based on a global hash. Playwright
// flips the hash to switch views and path segments to drive Wiki sections.
// ---------------------------------------------------------------------------
type ViewKey = "wiki" | "wiki-sidebar" | "settings" | "sidebar";
function useView() {
const [view, setView] = useState<ViewKey>(() => readView());
useEffect(() => {
const handler = () => setView(readView());
window.addEventListener("hashchange", handler);
return () => window.removeEventListener("hashchange", handler);
}, []);
return view;
}
function readView(): ViewKey {
if (typeof window === "undefined") return "wiki";
const hash = window.location.hash.replace(/^#/, "");
if (hash === "settings" || hash === "sidebar" || hash === "wiki-sidebar") return hash;
return "wiki";
}
export function App() {
const view = useView();
return (
<div style={{ height: "100vh", width: "100vw", overflow: "hidden", background: "var(--background, oklch(0.145 0 0))", color: "var(--foreground, oklch(0.985 0 0))" }}>
{view === "wiki-sidebar" ? (
<div style={{ display: "flex", height: "100vh" }}>
<WikiRouteSidebar context={HOST_CONTEXT as never} />
<div style={{ flex: 1 }}>
<WikiPage context={HOST_CONTEXT as never} />
</div>
</div>
) : view === "sidebar" ? (
<aside style={{ width: 240, background: "var(--sidebar, oklch(0.145 0 0))", padding: 12, height: "100vh", borderRight: "1px solid var(--border, oklch(0.269 0 0))" }}>
<SidebarLink context={HOST_CONTEXT as never} />
</aside>
) : view === "settings" ? (
<SettingsPage context={HOST_CONTEXT as never} />
) : (
<WikiPage context={HOST_CONTEXT as never} />
)}
</div>
);
}
@@ -0,0 +1,84 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LLM Wiki UI Harness</title>
<style>
:root {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--sidebar: oklch(0.145 0 0);
}
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
background: var(--background);
color: var(--foreground);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
}
/* Minimal Tailwind utility shim — supports just the classes used by the */
/* plugin's WikiRouteSidebar so the screenshot harness renders something */
/* visually close to the production host (which compiles real Tailwind). */
.w-60 { width: 15rem; }
.h-full { height: 100%; }
.min-h-0 { min-height: 0; }
.border-r { border-right-width: 1px; border-right-style: solid; }
.border-t { border-top-width: 1px; border-top-style: solid; }
.border-border { border-color: var(--border); }
.bg-background { background: var(--background); }
.bg-accent { background: var(--accent); }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-1 { flex: 1; }
.items-center { align-items: center; }
.gap-0\.5 { gap: 0.125rem; }
.gap-1 { gap: 0.25rem; }
.gap-1\.5 { gap: 0.375rem; }
.gap-2 { gap: 0.5rem; }
.gap-2\.5 { gap: 0.625rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.shrink-0 { flex-shrink: 0; }
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-\[11px\] { font-size: 11px; }
.text-\[13px\] { font-size: 13px; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.uppercase { text-transform: uppercase; }
.tracking-normal { letter-spacing: 0; }
.text-foreground { color: var(--foreground); }
.text-foreground\/80 { color: color-mix(in oklab, var(--foreground) 80%, transparent); }
.text-muted-foreground { color: var(--muted-foreground); }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rounded-md { border-radius: 0.375rem; }
.transition-colors { transition: background-color 150ms, color 150ms; }
.overflow-y-auto { overflow-y: auto; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/bundle.js"></script>
</body>
</html>
@@ -0,0 +1,249 @@
// @vitest-environment jsdom
import { createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { WikiRouteSidebar } from "../src/ui/index.js";
const COMPANY_ID = "11111111-1111-4111-8111-111111111111";
const EXPANDED_STORAGE_KEY = `paperclipai.plugin-llm-wiki:route-sidebar-expanded:v2:${COMPANY_ID}`;
type BridgeGlobal = typeof globalThis & {
__paperclipPluginBridge__?: {
sdkUi?: Record<string, unknown>;
};
};
type FileTreeNodeLike = {
name: string;
path: string;
kind: string;
children?: FileTreeNodeLike[];
};
type FileTreePropsLike = {
nodes: FileTreeNodeLike[];
selectedFile?: string | null;
expandedPaths?: ReadonlySet<string> | readonly string[];
onToggleDir?: (path: string) => void;
onSelectFile?: (path: string) => void;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function toArray(paths: FileTreePropsLike["expandedPaths"]): string[] {
if (!paths) return [];
return Array.isArray(paths) ? [...paths] : Array.from(paths);
}
function renderTreeButtons(
nodes: FileTreeNodeLike[],
options: Pick<FileTreePropsLike, "onSelectFile" | "onToggleDir">,
): ReturnType<typeof createElement>[] {
const buttons: ReturnType<typeof createElement>[] = [];
for (const node of nodes) {
if (node.kind === "dir") {
buttons.push(
createElement("button", {
key: node.path,
type: "button",
"data-toggle-dir": node.path,
onClick: () => options.onToggleDir?.(node.path),
}, node.name),
);
} else {
buttons.push(
createElement("button", {
key: node.path,
type: "button",
"data-select-file": node.path,
onClick: () => options.onSelectFile?.(node.path),
}, node.name),
);
}
buttons.push(...renderTreeButtons(node.children ?? [], options));
}
return buttons;
}
describe("WikiRouteSidebar", () => {
let container: HTMLDivElement;
let root: Root;
let hostLocation: { pathname: string; search: string; hash: string; state?: unknown };
let navigatedTo: { to: string; options?: unknown } | null;
beforeEach(() => {
window.localStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
hostLocation = {
pathname: "/PAP/wiki/page/wiki/concepts/sidebar-navigation.md",
search: "",
hash: "",
};
navigatedTo = null;
(globalThis as BridgeGlobal).__paperclipPluginBridge__ = {
sdkUi: {
usePluginData: (key: string) => {
if (key !== "pages") return { data: null, loading: false, error: null, refresh: () => undefined };
return {
data: {
pages: [
{
path: "wiki/concepts/sidebar-navigation.md",
title: "Sidebar navigation",
pageType: "concepts",
backlinkCount: 0,
sourceCount: 0,
contentHash: "abc123",
updatedAt: new Date().toISOString(),
},
],
sources: [],
},
loading: false,
error: null,
refresh: () => undefined,
};
},
useHostLocation: () => hostLocation,
useHostNavigation: () => ({
resolveHref: (to: string) => `/PAP${to.startsWith("/") ? to : `/${to}`}`,
navigate: (to: string, options?: unknown) => {
navigatedTo = { to, options };
},
linkProps: (to: string) => ({
href: `/PAP${to.startsWith("/") ? to : `/${to}`}`,
onClick: () => undefined,
}),
}),
FileTree: (props: FileTreePropsLike) => createElement(
"div",
{
role: "tree",
"data-selected-file": props.selectedFile ?? "",
"data-expanded-paths": toArray(props.expandedPaths).sort().join("|"),
},
renderTreeButtons(props.nodes, props),
),
},
};
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
window.localStorage.clear();
delete (globalThis as BridgeGlobal).__paperclipPluginBridge__;
});
it("defaults wiki categories open so local files are visible", () => {
act(() => {
root.render(createElement(WikiRouteSidebar, {
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
} as never));
});
const tree = container.querySelector("[role='tree']") as HTMLElement;
expect(tree.dataset.expandedPaths?.split("|")).toEqual([
"wiki",
"wiki/concepts",
"wiki/entities",
"wiki/projects",
"wiki/sources",
"wiki/synthesis",
]);
});
it("persists folder expansion client-side", () => {
act(() => {
root.render(createElement(WikiRouteSidebar, {
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
} as never));
});
act(() => {
(container.querySelector("[data-toggle-dir='raw']") as HTMLButtonElement).click();
});
expect(JSON.parse(window.localStorage.getItem(EXPANDED_STORAGE_KEY) ?? "[]")).toEqual([
"raw",
"wiki",
"wiki/concepts",
"wiki/entities",
"wiki/projects",
"wiki/sources",
"wiki/synthesis",
]);
act(() => {
root.unmount();
});
root = createRoot(container);
act(() => {
root.render(createElement(WikiRouteSidebar, {
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
} as never));
});
const tree = container.querySelector("[role='tree']") as HTMLElement;
expect(tree.dataset.expandedPaths).toBe("raw|wiki|wiki/concepts|wiki/entities|wiki/projects|wiki/sources|wiki/synthesis");
});
it("does not select a wiki-link destination from the route", () => {
act(() => {
root.render(createElement(WikiRouteSidebar, {
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
} as never));
});
const tree = () => container.querySelector("[role='tree']") as HTMLElement;
expect(tree().dataset.selectedFile).toBe("");
});
it("keeps sidebar tree selection scoped to sidebar navigation", () => {
act(() => {
root.render(createElement(WikiRouteSidebar, {
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
} as never));
});
const tree = () => container.querySelector("[role='tree']") as HTMLElement;
act(() => {
(container.querySelector("[data-select-file='wiki/concepts/sidebar-navigation.md']") as HTMLButtonElement).click();
});
expect(navigatedTo).toEqual({
to: "/wiki/page/wiki/concepts/sidebar-navigation.md",
options: { state: { paperclipWikiSidebarTreePath: "wiki/concepts/sidebar-navigation.md" } },
});
expect(tree().dataset.selectedFile).toBe("wiki/concepts/sidebar-navigation.md");
hostLocation = {
pathname: "/PAP/wiki/page/wiki/entities/paperclip.md",
search: "",
hash: "",
};
act(() => {
root.render(createElement(WikiRouteSidebar, {
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
} as never));
});
expect(tree().dataset.selectedFile).toBe("wiki/concepts/sidebar-navigation.md");
act(() => {
(container.querySelector("[data-toggle-dir='wiki/concepts']") as HTMLButtonElement).click();
});
expect(tree().dataset.selectedFile).toBe("wiki/concepts/sidebar-navigation.md");
expect(tree().dataset.expandedPaths?.split("|")).not.toContain("wiki/concepts");
});
});
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022",
"DOM"
],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "."
},
"include": [
"src",
"tests"
],
"exclude": [
"dist",
"node_modules"
]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
environment: "node",
},
});