feat: paperclip-plugin-llm-wiki v0.1#1
Merged
Conversation
SPEC.md is the proposal for paperclip-plugin-llm-wiki — a Paperclip plugin that surfaces the LLM Wiki inside Paperclip's UI as a read-only context lens. Targets v0.1, will live at integrations/paperclip/plugin/ once implemented. Also gitignore .leankg/ and .remember/ tool caches.
Validates the v0.1 plugin design against the live paperclipai/paperclip
SDK on master. Verdict: GO. Workers have raw node:fs access via the
file-browser-example pattern (ctx.projects.listWorkspaces -> path.resolve
+ containment guard -> fs.readFileSync).
Documents 14 SPEC corrections that must land in Phase 1 — most notably:
manifest field is `categories` (plural array) not `category`; sdkVersion
isn't validated and shouldn't be declared; tool result shape is
{content?, data?, error?} not {content, structured}; ErrorBoundary
isn't re-exported from @paperclipai/plugin-sdk/ui (roll our own); SDK
uses calver (2026.428.0) not semver. Also flags issue #2276 (open
validator bug affecting dashboardWidget plugins) as a smoke-test risk
to monitor in Phase 7.
Per the approved plan, Phase 0 is a discrete deliverable. Stop here for
review before Phase 1.
Phase 1 of the paperclip-plugin-llm-wiki implementation, per the
approved plan and the Phase 0 feasibility report.
Skipped the @paperclipai/create-paperclip-plugin scaffold — outside the
Paperclip monorepo it tries to pnpm-pack a local SDK checkout and
silently fails when none exists. Hand-built the package using
plugin-authoring-smoke-example as the canonical reference instead.
Highlights:
- package.json: name paperclip-plugin-llm-wiki, version 0.0.1,
files: ["dist","package.json",...], paperclipPlugin entries pointing
at ./dist/{manifest.js,worker.js,ui/}, peerDeps pin SDK 2026.428.0
exactly (calver — not a range).
- TypeScript strict + bundler resolution, JSX react-jsx.
- Vitest test runner, single _smoke.spec.ts asserting createTestHarness
imports and constructs from a minimal manifest. Manifest uses
apiVersion: 1, categories: ["workspace"] (plural array, no
sdkVersion) per FEASIBILITY findings.
- GitHub Actions workflow runs typecheck + test on changes under
integrations/paperclip/**; pnpm with frozen lockfile. `pnpm run build`
will be added to CI in Phase 5 once esbuild config lands.
- Stub READMEs for the integrations/ tree and the plugin package itself
so links resolve; full content lands in Phase 6.
- SPEC.md moved to its final home at integrations/paperclip/SPEC.md.
Phase 0 verified: pnpm install resolves deps cleanly, typecheck green,
the smoke test passes locally.
…t (Phase 2) TDD per the plan: tests-first per module, then implementation. All four modules under src/lib/ are byte-for-byte parity ports of the canonical Python scripts in skills/llm-wiki/scripts/. The plugin worker (Phase 4) will import these directly — no shelling out, no Python at runtime. Modules: - frontmatter.ts (port of wiki_lint.py:46-73 + wiki_search.py tokenizer): parseFrontmatter, extractWikilinks, tokenize. Lightweight YAML-ish parser, intentionally not pulling in a YAML library so quirks match Python exactly (key regex /^[a-zA-Z_]+:/ — no hyphens or digits in keys, lines that don't match are silently dropped). - bm25.ts (port of wiki_search.py): collectPages, searchPages, backlinks, topLinked. Same constants (k1=1.5, b=0.75), same IDF formula, same skip rules. Filtering happens before index construction (so IDF reflects the filtered set, not the full corpus). Page collection is sorted by relPath so behavior is portable across filesystems — Python's Path.rglob iteration order is unspecified, which the snapshot generator accounts for by recomputing backlinks/top-linked over a sorted page list. - lint.ts (port of wiki_lint.py): lintWiki returns LintFindings with orphans, brokenLinks, oversizedHard/Soft, missingFrontmatter, malformedFrontmatter, duplicateSlugs, stalePages, readErrors. Skip rules add README.md beyond search's set, matching Python. --suggest-pages capitalized-phrase mining is intentionally deferred to v0.2. - stats.ts (port of wiki_stats.py): computeStats returns counts, page groupings, link density, largest pages, most-linked hubs, plus the scaling-message chain (the if/elif sequence that emits "below first threshold", "below shard threshold", "AT SHARD THRESHOLD", "past 300", "past 500" depending on totalPages, indexLines, and whether indexes/ exists). Skip rules deliberately differ from search/lint — index.md is read for indexLines and excluded from page stats. Parity is mechanically enforced by tests/fixtures/_gen_bm25_expectations.py: it runs wiki_search.py against tests/fixtures/wiki/ and snapshots the ranked slugs into tests/fixtures/bm25-expectations.json. The TS BM25 test loads that snapshot and asserts identical rank order across 10 queries + 6 filter cases + 4 backlinks + topLinked. Regenerate via `python3 integrations/paperclip/plugin/tests/fixtures/_gen_bm25_expectations.py` when fixtures or query coverage change. Test coverage: 92/92 passing. Fixture wiki has 7 pages spanning source/entity/concept/synthesis types so BM25 ranking is non-trivial. Lint and stats fixtures are built dynamically via mkdtempSync to avoid checking in long dummy files.
…ase 3)
TDD per the plan: tests first, then implementation. The manifest types
against PaperclipPluginManifestV1 and is accepted by the SDK's
validator (verified end-to-end via createTestHarness in the test).
All 14 SPEC errata from Phase 0's FEASIBILITY.md are applied:
- categories: ["workspace"] (plural array, not category: "workspace")
- No sdkVersion field — the validator's Zod schema does not check it
- entrypoints: { worker, ui } (required object, not just package.json's
paperclipPlugin field)
- Tool descriptor includes parametersSchema in the manifest entry
(worker re-uses the same shape via Pick<...>)
- Capability list (8 items): four ui.*.register, agent.tools.register,
projects.read + project.workspaces.read (the FS gate from
plugin-file-browser-example), issues.read. events.subscribe dropped
for v0.1 — we don't subscribe to events
- No write capabilities — the plugin is strictly read-only
- No http.outbound — no external network calls
Tests cover 30 contract points: identity (id format, apiVersion=1, semver,
length caps); slot↔capability gating via the verbatim
UI_SLOT_CAPABILITIES table from FEASIBILITY §3; tool shape and required
fields; instanceConfigSchema defaults and bounds; exportName alignment
with the named exports the UI bundle will ship in Phase 5;
read-only / no-write enforcement; and final harness validation.
TDD per the plan. Worker registers six data providers consumed by the
UI bundle (Phase 5) and one agent-callable tool — all read-only, no
event subscriptions in v0.1.
Filesystem pattern lifted verbatim from plugin-file-browser-example
(FEASIBILITY §1):
1. ctx.projects.getPrimaryWorkspace(projectId, companyId) → workspace.path
2. path.resolve(workspace.path, config.wiki_path) → root
3. containment guard: !path.relative(workspace.path, root).startsWith("..")
4. node:fs.readFileSync / readdirSync directly
Data providers:
- readPage({ companyId, projectId, slug })
→ { slug, meta, body, links } | { error }
Path-traversal guard: slugs that resolve outside the wiki root
return error, never read.
- searchWiki({ companyId, projectId, query, topK, filters })
→ { results: [{ slug, title, type, score }] }
Delegates to lib/bm25.ts unchanged.
- loadIndex({ companyId, projectId })
→ { index, shards, pages }
Reads index.md plus indexes/*.md shards if present, plus a flat
page list as a fallback.
- lintWiki({ companyId, projectId }) → LintFindings (lib/lint shape)
- wikiHealth({ companyId, projectId })
→ { pageCount, indexLines, linkDensity, scalingMessages,
lintStatus: pass|warn|fail, lintFindings, wikiPathMissing }
Hard-fail conditions (oversize hard, malformed FM, dup slugs)
yield "fail"; soft conditions yield "warn"; clean yields "pass".
- relevantForIssue({ companyId, issueId, topK })
→ BM25 over issue.title + issue.description, scoped via
ctx.projects.getWorkspaceForIssue with a Company-level fallback.
Tool:
- wiki.query (capability: agent.tools.register)
Returns ToolResult { content, data, error } per FEASIBILITY §4
(NOT { content, structured } as SPEC said — corrected here).
content is a markdown summary; data.results is the structured rank.
All host-RPC calls are wrapped so the worker returns graceful
{ error: ... } responses instead of propagating Errors. Capability
denial test exercises this against a manifest stripped of
project.workspaces.read; the harness's stub throws but the worker
catches and surfaces an error string.
onHealth() returns { status: "ok" } unconditionally — wiki misconfig
surfaces via the wikiHealth provider, not via the liveness probe.
Test coverage: 18 new worker tests (14 happy-path, 2 traversal/denial,
2 onHealth/no-actions). Total project test count: 140/140 green;
typecheck clean.
…(Phase 5)
TDD per the plan: tests-first per component using @testing-library/react +
jsdom (opt-in via per-spec environment directive); SDK ui hooks mocked
through vi.mock so tests stay independent of bridge wiring.
Components:
- ErrorBoundary Class boundary; rolled our own because
@paperclipai/plugin-sdk/ui doesn't re-export
it from index.ts (FEASIBILITY §4 / §8 #4).
Wraps every top-level slot.
- WikiPageView Markdown renderer with remark-gfm. Wikilinks
([[slug]] / [[slug|display]]) are expanded to
standard markdown links with a `wiki:`
sentinel scheme; a custom <a> renderer turns
them into internal links with data-wiki-slug.
Custom urlTransform preserves the sentinel
(react-markdown's default sanitizer drops
unknown schemes).
- WikiBrowser Shared "browser" surface used by Sidebar and
Page. Search input dispatches searchWiki on
change; list of pages comes from loadIndex;
clicking either drills into readPage and
renders WikiPageView. Single state machine,
no router.
- WikiSidebar Thin wrapper over WikiBrowser with a sidebar
class hook. Type: PluginSidebarProps.
- WikiPage Thin wrapper over WikiBrowser at full width.
Type: PluginPageProps.
- WikiContextTab Issue-detail tab. Reads companyId + entityId
from the slot context (PluginDetailTabProps
guarantees both are non-null for this slot)
and dispatches relevantForIssue.
- WikiHealthIndicator Dashboard widget. Renders pageCount, lint
status badge (data-lint-status pass|warn|
fail), link density, and the scaling-message
chain from worker's wikiHealth payload.
Special "wiki not configured" state when
wikiPathMissing is true.
src/ui/index.tsx exposes the named exports the manifest's slot
exportName fields point at (WikiSidebar, WikiPage, WikiContextTab,
WikiHealthIndicator) plus WikiPageView and ErrorBoundary as utilities.
Build: esbuild.config.mjs uses createPluginBundlerPresets from
@paperclipai/plugin-sdk/bundlers (NOT a hand-rolled config —
FEASIBILITY §4). Externals are set by the preset:
worker: ["react", "react-dom"] (worker bundles the SDK)
ui: ["@paperclipai/plugin-sdk/ui",
"@paperclipai/plugin-sdk/ui/hooks",
"react", "react-dom", "react/jsx-runtime"]
`pnpm run build` produces dist/manifest.js, dist/worker.js,
dist/ui/index.js — exactly the paths package.json#paperclipPlugin
references.
CI now runs `pnpm run build` after typecheck/test, then verifies that
`npm pack --dry-run` actually lists the dist/ entries (the most-cited
publishing failure per FEASIBILITY §7 / SPEC §"Build and release").
Test coverage: 169/169 green (29 new UI tests). Vitest config enables
globals so @testing-library/react@16 auto-cleans the jsdom container
between renders.
Four doc updates that ship with the Paperclip integration: - integrations/paperclip/plugin/README.md — npm-facing surface. Explains the five UI surfaces, the eight capabilities the plugin requests with per-capability rationale, the three instanceConfigSchema fields with defaults and bounds, the pairing with the agent-side skill, and troubleshooting (including the upstream issue #2276 workaround). - integrations/paperclip/README.md — operator-facing. Two-tier integration model: skill is always required, plugin is optional but high-value if you curate the wiki, want issue-relevant context inline, want a dashboard health card, or run HTTP-only adapters that need wiki.query. Heartbeat pattern unchanged. Cross-links to SPEC, FEASIBILITY, the npm-package readme, and the agent-memory stanza. - README.md (root) — new "Integrations" section between "Tooling" and the existing top-level content. Short paragraph explaining the human-side plugin and pointing at the three integrations/paperclip/ doc files. - CHANGELOG.md — entry under [Unreleased] covering the new plugin package, algorithmic-parity testing against the Python reference, the SPEC + FEASIBILITY documents, the dedicated CI job, and notes on Paperclip SDK calver pinning + the two upstream issues we work around.
scripts/prepublish-checks.mjs walks every machine-checkable contract
the publish tarball needs to satisfy, in 13 sections:
1. TypeScript typecheck
2. Full test suite (lib parity + worker harness + UI rendering)
3. Clean rebuild from rm -rf dist
4. Built artifacts present at expected paths
5. Built artifacts are syntactically valid ESM
6. package.json publishing contract (name, version, files,
paperclipPlugin paths, peerDeps SDK pin, license)
7. Manifest accepted by the SDK validator end-to-end
8. Slot/tool/capability gating per FEASIBILITY §3 + Issue #2276
9. UI bundle exposes the named exports the manifest references
(loaded under jsdom — react-markdown's transitives touch DOM at
module-load time)
10. UI bundle externalizes host-provided runtime
11. No credential-shaped strings in dist/
12. Publish tarball contains the right files, no surprises
13. Documentation cross-references intact + CHANGELOG entry present
Two scripts:
- prepublish:check — explicit invocation. Pass --fast to skip the
rebuild + tests when iterating on docs/manifest.
- prepublishOnly — npm/pnpm runs this automatically before
`pnpm publish`. Safety net so a publish without
passing checks aborts before pushing to npm.
CI workflow consolidates to a single gate: install + prepublish:check.
The previous separate typecheck/test/build/tarball steps duplicated what
the script now covers.
Verified: 54/54 pass on the full pipeline; 51/51 in --fast mode.
Three doc files updated so a new user can install, use, and
troubleshoot the plugin without reading code.
integrations/paperclip/plugin/README.md (453 lines, was 102)
Restructured as the comprehensive user guide. New sections:
- What you get — feature summary table, what it
explicitly doesn't do
- Quick start — 3-step walkthrough with expected output
(skill install → wiki bootstrap →
plugin install) including the
local-path HTTP install for dev
- How to use it — per-surface usage guide for all five
surfaces, including the wiki.query
tool's JSON descriptor and ToolResult
shape so agent authors can call it
directly
- Configuration — full table with bounds + per-Company
isolation note
- Pairing with the skill — agent-memory stanza pointer
- Security and privacy — concrete table of every read/write
boundary, path-traversal blocking,
capability denial behavior
- Versioning and upgrades — semver vs. SDK calver, hot upgrade,
wiki schema evolution
- Troubleshooting — 7 named issues, each with cause + fix
- FAQ — 11 questions covering common decisions
- Capability reference — full 8-cap table with rationale,
explicit list of what's NOT requested
- Architecture (brief) — ASCII diagram of host ↔ worker ↔ disk
- Repo layout — file-tree reference
integrations/paperclip/README.md
Rewritten as the operator-facing decision page. New sections:
- At-a-glance comparison table — agent side vs. human side
- Should I install the plugin? — bulleted criteria (install if /
skip if)
- First-time setup walkthrough — 5 numbered steps with expected
output from a green-field state
to first ingest
- How they fit together — diagram showing skill writes,
plugin reads, both share disk
- Heartbeat pattern — explicit 5-step flow
- Multi-Company guidance — what happens with several
Companies on one Paperclip
instance, plus the HQ-Company
caveat
- When something goes wrong — quick triage table mapping
symptom → cause → fix, with
deep links to the plugin
README's troubleshooting
- Cross-references — every adjacent doc
README.md (root)
Integrations section expanded:
- Surface-by-surface table — same table the plugin README
opens with, so root visitors get
the value prop without clicking
through
- Read-only design note — preempts the "where's the
editor" question
- Per-doc breadcrumb — comprehensive guide / decision
page / SPEC / FEASIBILITY all
linked with one-line descriptions
of what's in each
prepublish-checks.mjs still passes 51/51 (fast mode); all internal TOC
anchors verified resolved.
Workers used statSync (which follows symlinks) inside their recursive
walkers. A symlink under wiki/ could point anywhere on disk and the
plugin would read it via readPage / searchWiki / loadIndex / lintWiki /
wikiHealth / collectPages.
The path.relative() containment check the worker already had only
catches lexical "../" escapes — it does NOT detect symlinks. statSync
transparently followed them.
Fix is the standard realpathSync + prefix-check pattern, applied at
every recursive walker:
1. lstat each entry (don't follow symlinks).
2. realpath the entry; verify the real target stays under
realpath(wikiRoot).
3. If contained, follow normally; if not, skip.
Within-tree symlinks (e.g. wiki/inside-link.md → ./innocent.md) still
resolve and are read correctly. Out-of-tree symlinks
(wiki/escape.md → ../../external/secret.txt) are silently skipped by
the lib walkers, and produce { error } from the worker's readPage.
The same realpathContained() helper now lives in src/worker.ts and in
each of src/lib/{bm25,lint,stats}.ts. Inline duplication keeps the lib
modules independent of the worker (they're already imported by tests
that don't go through the worker), and the helper is small enough to
not warrant a new shared module.
Tests: new tests/security/symlinks.spec.ts builds a fixture wiki with:
workspace/wiki/innocent.md (real)
workspace/wiki/inside-link.md → innocent.md (allowed)
workspace/wiki/escape.md → ../../external/secret.txt (rejected)
workspace/wiki/escape-dir → ../../external (rejected)
external/secret.txt (contains a canary marker)
Asserts:
- readPage(escape) returns error and the canary marker is NOT
present in any field of the response
- searchWiki for the canary marker returns no results
- loadIndex.pages omits 'escape' and anything under escape-dir
- lintWiki / wikiHealth / collectPages count only the 2 contained
pages (innocent + inside-link)
- inside-link.md is fully readable (within-tree symlinks remain valid)
7 new tests; total 176/176 green; typecheck clean.
Defense in depth — the existing path.relative() containment in
resolveWikiRoot/resolveWikiRootForIssue is preserved as a first-line
syntactic check; realpath is the second line that catches the case
path.relative() can't see.
The manifest's instanceConfigSchema exposes search_top_k (default 5,
bounded [1, 20]) and both READMEs claimed it controlled the issue tab
and the search box. In practice the UI hardcoded topK: 10 (search) and
topK: 5 (issue tab) and the worker fell back to 5 in three places —
operators changing the setting saw zero effect.
Worker becomes the single source of truth for the default. New
resolveTopK(ctx, paramTopK) helper applies the precedence:
explicit param > config.search_top_k > 5
Out-of-range values (param OR config) fall through to the next
candidate so the schema's [1, 20] bounds are always respected. The
helper is used by searchWiki, relevantForIssue, and the wiki.query
tool handler.
UI side: WikiBrowser drops topK: 10 and WikiContextTab drops topK: 5.
Both now omit the field entirely — the worker default applies. UI
override remains available for future surfaces that need it (just pass
topK explicitly).
Tests:
- 6 new worker cases covering: config wins when param absent, param
wins over config, out-of-range config clamped to default,
no-config falls back to 5, parity across searchWiki /
relevantForIssue / wiki.query.
- 2 new UI cases asserting WikiBrowser and WikiContextTab no longer
pass topK in their usePluginData params (verified via
vi.mocked(usePluginData).mock.calls).
184/184 green; typecheck clean.
… refresh (P2)
The manifest's instanceConfigSchema exposed lint_check_interval_minutes
and the README claimed periodic re-checking, but the dashboard widget
loaded once and never refreshed. Operators changing the setting saw no
effect.
Worker side: wikiHealth payload now includes a lintCheckIntervalMinutes
field, sourced from config via a new resolveLintIntervalMinutes() helper
that defaults to 60, clamps below-minimum values to 5 (matching the
schema's `minimum: 5`), and falls back to 60 on non-numeric configs.
The field is included even in the wikiPathMissing fallback so the UI
can still tick over and detect when the wiki becomes available.
UI side: WikiHealthIndicator destructures `refresh` from
PluginDataResult and runs a useEffect that sets up setInterval(refresh,
intervalMinutes * 60_000). Cleanup on unmount or interval change. No
polling while loading or in error state — wait for the first valid
payload before scheduling.
Tests:
- 5 new worker cases: default 60, respect operator config, clamp
below-min to 5, non-numeric falls back to 60, included even in
wikiPathMissing path.
- 3 new UI cases (with vi.useFakeTimers): refresh fires after the
configured interval, fires again on the next tick, clears on
unmount, does NOT fire while loading or in error state.
192/192 green; typecheck clean.
WikiBrowser unconditionally invokes loadIndex, searchWiki, and readPage
on every slot mount. With currentSlug = null and an empty query,
readPage(slug: "") and searchWiki(query: "") both walked the entire
wiki tree before producing nothing useful. On large wikis simply
opening the sidebar caused redundant filesystem walks and host-RPC
round-trips.
Worker now short-circuits at the entry of the two affected handlers,
before any host call:
- searchWiki: trim+empty query → { results: [] } immediately
- readPage: trim+empty slug → { error: "no slug provided" }
loadIndex is unchanged — it legitimately needs the walk; that's the
user's intent. The UI is unchanged because React hooks can't be
conditional and the worker-side fix is the correct location.
Tests: 4 new cases. We can't spyOn(node:fs) directly because builtin
ESM exports are non-configurable, so we spy on
ctx.projects.getPrimaryWorkspace instead — the first thing the worker
would do on any non-short-circuit path. If the short-circuit fires,
the workspace lookup never happens.
196/196 green; typecheck clean.
Two inaccuracies the maintainer flagged in pre-merge review.
(a) plugin/README.md claimed schema-drift fallback that doesn't exist.
The plugin had a paragraph saying it would "fall back to a permissive
read mode and surface a warning to the operator on startup" if the
wiki schema evolved. We have no schema-version check, no startup
validation hook, no warning. Replaced with an accurate statement:
plugin and skill ship from the same repo so they're always in
lockstep — there's no runtime fallback because there's no schema
version negotiation, full stop.
(b) Issue #2276 was framed as affecting plugins that declare
dashboardWidget. Re-reading the live issue body and current master
plugin-capability-validator.ts (verified 2026-05-05): the bug
actually affects WORKER-ONLY plugins with no ui.slots[] — the
pre-fix validator iterated UI_SLOT_CAPABILITIES even when
manifest.ui was undefined, so worker-only plugins got rejected with
"Missing required capabilities for declared features:
ui.dashboardWidget.register". Current master gates the loop on
`uiSlots.length > 0`, fixing the bug; the issue stays OPEN until
the fix tags a release.
Our plugin declares a full ui.slots[] AND every matching capability,
so we never tripped this — neither in the pre-fix nor the post-fix
validator. The "workaround" we documented was never necessary.
Touched files:
- plugin/README.md Drop the schema-evolution paragraph; drop
the "Plugin install fails with..."
troubleshooting section (~14 lines);
rewrite the FAQ entry that linked to it
(no broken anchor).
- integrations/README.md Remove the dashboardWidget triage row.
- FEASIBILITY.md §7 Rewrite the Issue #2276 entry to
accurately describe the bug as a
worker-only-plugin issue. Add the master
validator snippet so future readers can
verify. Note the earlier framing was
wrong.
- FEASIBILITY.md TL;DR Update the open-caveat paragraph to match.
- FEASIBILITY.md §9 Drop the Phase 7 workaround note.
- CHANGELOG.md Notes Update the #2276 line to reflect the
corrected scope.
Pre-publish checks: 54/54 pass on the full pipeline.
…ads (P1)
The previous symlink fix hardened the recursive walkers but missed two
escape vectors that a maintainer review caught:
(a) The wiki root itself can be a symlink. resolveWikiRoot only ran a
lexical path.relative() containment check on the resolved
`<workspace>/wiki_path`. If `<workspace>/wiki` is a symlink to
`/etc`, the lexical check passes (`relative` is just "wiki"), and
every realpathContained() call downstream anchors on the escape
destination — every entry under /etc has a realpath under /etc,
which IS the wiki root, so the walker treats /etc as the wiki.
(b) loadIndex reads `index.md` and `indexes/*.md` directly via
fs.readFileSync. The recursive walkers don't see those reads, so
a symlinked `wiki/index.md` (or a symlinked `wiki/indexes/foo.md`,
or a symlinked `wiki/indexes/` directory) leaked target content
even after the prior fix.
Fix:
- resolveWikiRoot / resolveWikiRootForIssue now route through
resolvedContainedRoot(workspaceRoot, wikiRoot) which:
1. realpaths both ends
2. asserts realpath(wikiRoot) is contained under
realpath(workspaceRoot)
3. returns the realpath on success so all downstream walkers
anchor on the canonical resolved path
Failure (escape, missing, capability error) returns null — the
data handlers convert null to a graceful response.
- loadIndex's index.md and shard reads now run through
realpathContained() before fs.readFileSync. The shard dir's own
containment is checked too, so a symlinked `indexes/` pointing
outside the wiki yields no shards.
Tests: 8 new cases extending tests/security/symlinks.spec.ts:
- symlinked wiki root (4 cases): readPage/searchWiki/loadIndex/
wikiHealth all surface a graceful "not accessible" or empty
state, never leak the canary marker.
- direct loadIndex reads (4 cases): real index.md happy path,
symlinked index.md rejected, symlinked indexes/foo.md shard
excluded but real shards preserved, symlinked indexes/ directory
yields zero shards.
204/204 green; typecheck clean; 54/54 prepublish checks pass.
The new tests in fix(P1) Fix 1b passed on macOS but failed on Linux
during cleanup. Three issues:
1. rmSync(symlink_to_directory) without {recursive,force} flags throws
EISDIR on Linux (the Node implementation lstats the path, sees it
resolves to a directory, then refuses to remove without recursive).
On macOS the same call succeeds. POSIX leaves this ambiguous; the
reliable cross-platform primitive for "remove only the link, never
the target" is unlinkSync().
2. rmSync(symlink_to_file) is mostly safe but for consistency every
symlink removal should go through unlinkSync.
3. Cleanup ran AFTER assertions, so a failing assertion left the
fixture in a corrupted state and bled into downstream tests. Wrap
each mutating test in try/finally so cleanup always runs.
Three tests touched (the new wiki-root and direct-loadIndex-read
cases from Fix 1b):
- "does not return content from a symlinked index.md..."
- "does not return content from symlinked indexes/foo.md shards..."
- "does not return shards when the indexes/ directory itself is a
symlink escape"
Each now: builds the symlink → runs the assertion in a try block →
restores the fixture in finally with unlinkSync (and rmSync only on
real directories with {recursive: true, force: true}).
Other rmSync calls in the file are unchanged because they target
either top-level mkdtempSync directories (cleanup-everything case,
{recursive,force} appropriate) or real directories with that same
flag set. The grep audit at commit time confirms every code path
that touches a symlink uses unlinkSync.
15/15 symlink tests still green on macOS; expected to pass on Linux
now per the unlinkSync POSIX contract. 204/204 total tests; 54/54
prepublish checks pass.
(a) Company-scoped data calls always returned wikiPathMissing.
resolveWikiRoot's fallback path (when no projectId is supplied,
e.g. on the dashboard widget) walked all projects and broke on the
first non-null workspace. Real Paperclip's getPrimaryWorkspace
synthesizes a workspace for every project — it falls back to
project.codebase.effectiveLocalFolder even when no explicit
workspace row exists (see plugin-host-services.ts on master).
So the worker happily picked the first project's synthetic
workspace, which doesn't have the wiki, and reported missing.
Test fixtures patched ctx.projects.getPrimaryWorkspace directly
and so didn't exercise this; only a real Paperclip install with
multiple projects (where only one has the wiki) reproduces it.
Fix: walk every project, accept only the one whose path actually
contains the configured wiki dir (resolveWikiAt helper). Honor
explicit projectId — if the slot context names a project that
doesn't have the wiki, return null rather than silently drifting
to a different project.
Three new regression tests in tests/worker.spec.ts seed two
projects (one with wiki, one without) and patch
getPrimaryWorkspace to mimic Paperclip's synthetic-workspace
behavior. They would have caught this had we tested it earlier.
(b) UI surfaces shipped no styles. Class names were declared
(.llm-wiki-browser, .llm-wiki-result-link, .llm-wiki-type-badge,
.llm-wiki-status-badge[data-lint-status], etc.) but no CSS backed
them. The host doesn't ship a shared component kit, so the slots
rendered as raw stacked text — sidebar list cramped without
visual hierarchy, issue context tab titles run together with
type labels (e.g., "Attention Is All You Needsource").
Fix: src/ui/styles.ts — a baseline stylesheet (~150 lines)
injected once into document.head on first slot mount, idempotent
via a sentinel <style id>. Uses currentColor / inherit /
transparent throughout so the plugin tracks the host theme
automatically. Adds layout, type badges, lint-status pill colors,
markdown prose styles, and an error-boundary fallback.
Each top-level slot calls injectWikiStyles() in a useEffect on
mount. Existing tests still pass — the injection is a no-op in
jsdom test env where multiple components share document.head.
207/207 tests green; typecheck clean. Visually verified end-to-end
against a live Paperclip 2026.428.0 instance: dashboard widget
renders pageCount=7 / lint=warn / link density=2.6 with proper badge
styling; issue context tab renders 5 ranked wiki pages with title +
type pill side-by-side; sidebar groups pages by frontmatter type
under uppercase headings.
Adds the URL helpers and React hook that the new three-column wiki workspace will dispatch on. Hash routing is the only feasible per-page URL scheme since Paperclip registers a single non-wildcarded route segment per page slot. - src/ui/href.ts — wikiHref / parseWikiLocation / useWikiLocation / navigateTo spans landing, page, folder (#@type), search (?q=), and setup (?view=setup). - src/ui/recent.ts — sessionStorage-backed last-N pages list, cap=8, dedup by slug, robust against malformed payloads. - 30 new tests (href grammar round-trip, hook hashchange/popstate behavior, recent cap/dedup/persistence). 237/237 green. Phase A of the v0.4 plan; subsequent phases consume these helpers.
The reader now produces real navigation, not callback-driven local state:
wikilinks render as `<a href="/{prefix}/llm-wiki#{slug}">`, so middle-click,
copy-link, and the back-button all work. The URL is the source of truth.
- Add rehype-slug (stable heading ids), rehype-autolink-headings (hover
permalinks), rehype-highlight (hljs language classes on code blocks).
- Drop the `onWikilinkClick` callback from WikiPageView; require a
`companyPrefix` instead. Wikilinks resolve via the new wikiHref helper.
- WikiBrowser passes `context.companyPrefix` through. The browser itself is
superseded in Phase G; this preserves it as a working surface during the
transition.
- 4 new tests for href shape, slug encoding, heading ids, and hljs classes.
241/241 green.
Phase B of the v0.4 plan.
Replaces the v0.3 single-column WikiBrowser at /{prefix}/llm-wiki with an
Obsidian-style three-pane layout: folder tree (derived from pages[].relPath)
on the left, URL-driven Reader in the center, Properties + Outline on the
right. Backlinks panel lands in Phase D.
- src/ui/page/FolderTree.tsx — recursive collapsible tree, auto-expands
ancestors of currentSlug, supports a substring titleFilter.
- src/ui/page/PropertiesPanel.tsx — frontmatter as definition list,
filters out title/body/nested-objects, joins string arrays with commas.
- src/ui/page/OutlinePanel.tsx — TOC linking to in-page heading anchors;
includes extractHeadings() helper for the Reader to extract from rendered
DOM (relies on rehype-slug ids landed in Phase B).
- src/ui/page/Reader.tsx — center dispatcher: landing (renders index.md),
folder (#@type), page (renders WikiPageView + emits headings to right
rail), search (?q=), setup placeholder for Phase H.
- src/ui/WikiPage.tsx — workspace shell wiring the columns. Keeps the
legacy .llm-wiki-page-surface class for the existing slots.spec.tsx
surface assertion.
- src/ui/styles.ts — three-column grid (collapses at 1100px / 720px), tree
styles, panel typography, hljs palette, autolink-headings hover.
22 new tests across FolderTree (8), PropertiesPanel (5), OutlinePanel (3),
Reader dispatch + heading extraction (6). 263/263 green; typecheck clean.
Phase C of the v0.4 plan.
Pages whose body links to the active page surface in the workspace's right rail. Reuses the existing collectPages walker (already symlink-hardened) and the canonical extractWikilinks regex — no new FS surface, no new capability declared. - src/worker.ts — new `backlinks` data provider. Honours both [[slug]] and [[slug|alias]] forms (extractWikilinks already canonicalises). The active page is excluded from its own backlinks. Empty slug short- circuits before any walk. - src/ui/page/BacklinksPanel.tsx — right-rail section using the same visual language as Properties + Outline; results are real anchor links resolving via wikiHref. - src/ui/WikiPage.tsx — wires the panel into the workspace right rail. - 5 worker test cases (linked/unlinked/empty/self-exclusion/aliased) and 3 UI cases (renders/empty-state/usePluginData arg shape). 271/271 green. Phase D of the v0.4 plan.
The sidebar slot stops trying to be a reader. Instead it offers an Open
link, a search input that submits to /{prefix}/llm-wiki?q=…, a Browse-by-
type list with #@type folder hrefs, a Recent list backed by sessionStorage,
and a condensed health badge. When the wiki is missing entirely it
collapses to a single "Set up the wiki →" CTA pointing at ?view=setup.
- src/ui/launcher/Launcher.tsx (new) — the launcher's body. Submitting
the search form calls navigateTo so middle-click and copy-link work,
and the URL becomes the wiki workspace's input directly.
- src/ui/WikiSidebar.tsx — slimmed down to the ErrorBoundary + Launcher.
- src/ui/page/Reader.tsx — calls recordRecent() on every successful page
load. recordRecent dedups by slug, so revisits float to the front.
- src/ui/styles.ts — launcher layout namespaced under .llm-wiki-launcher-*.
- tests/ui/Launcher.spec.tsx (new) — 6 cases for the launcher contract.
- tests/ui/slots.spec.tsx — rewrote the WikiSidebar block to assert the
new launcher surface, repointed the topK-not-hardcoded check at Reader
(the new searchWiki caller), and added a per-test URL/sessionStorage
reset to keep state from leaking across cases.
277/277 green; typecheck clean.
Phase E of the v0.4 plan.
…owser
The Issue detail tab's result links used `#wiki/{slug}` — a meaningless
hash on the issue page. Switch to `wikiHref(prefix, { kind: "page", slug })`
so clicking opens the wiki workspace at that page.
WikiBrowser.tsx is now superseded by Reader (center column) + Launcher
(sidebar), neither of which import it. Delete it.
- src/ui/WikiContextTab.tsx — result anchors now use wikiHref.
- src/ui/WikiBrowser.tsx — deleted.
- tests/ui/WikiContextTab.spec.tsx — new spec asserting the new href shape.
- tests/ui/slots.spec.tsx — comment refresh.
278/278 green; typecheck clean.
Phase G of the v0.4 plan.
Adds the missing piece between "I installed the plugin" and "my agents
actually use the wiki." The plugin's sandbox can't auto-install the
agent-side skill or modify any agent's heartbeat instructions, so this
view lays out the runbook in one place with copy-paste-ready blocks and
a live verifier.
Surfaces:
- /{prefix}/llm-wiki?view=setup — five-step walkthrough rendered by the
Reader's setup branch:
1. Wiki content (live: ✅ found / ❌ missing with /wiki:init guidance)
2. wiki.query tool (live: registered + sample query timing)
3. Per-adapter install commands (Claude Code, Codex, Cursor, Gemini
CLI, OpenCode, Pi) with one-click copy buttons
4. The canonical heartbeat stanza (CLAUDE.md / AGENTS.md / GEMINI.md)
5. HTTP-only-agents system-prompt block (Hermes etc.)
- Dashboard widget: an "Open setup →" link in the OK state plus a Setup
link in the missing-wiki state, both pointing at ?view=setup.
Worker:
- New `verifySetup` data provider — composes resolveWikiRoot +
collectPages + searchPages into a structured payload the Setup view
renders ✅/❌ from. No new capability; reuses the existing
project.workspaces.read surface.
- `wiki.query` tool description rewritten to be self-instructive: tells
the agent WHEN to call it (before answering Company-specific
questions) instead of just describing the algorithm. Mirrored in
manifest.ts so the toolbelt presents the same wording.
Files:
- src/ui/setup/snippets.ts — adapter install commands + canonical
heartbeat stanza + HTTP-only system-prompt suggestion. Hand-maintained
source of truth for now; auto-generation from the repo can land in a
future enhancement.
- src/ui/setup/SetupView.tsx — checklist UI + ChecklistItem +
CopyBlock primitives.
- src/ui/page/Reader.tsx — dispatch the setup view via SetupContainer.
- src/ui/WikiHealthIndicator.tsx — Open-setup link in OK state, Setup
link in missing-wiki branch.
- src/ui/styles.ts — Setup-view styling (status-coloured step borders,
copy block layout, health setup link).
- src/manifest.ts + src/worker.ts — synchronised tool description.
- tests/worker.spec.ts — verifySetup shape + missing-wiki branch (2 cases).
- tests/manifest.spec.ts — description includes "source of truth" /
"before answering" trigger phrasing.
- tests/ui/SetupView.spec.tsx — 4 cases (renders steps, found state,
missing-init guidance, copy blocks present).
285/285 green; typecheck clean.
Phase H of the v0.4 plan. Phase F (cmdk quick switcher + topbar) is the
remaining nice-to-have; landed separately.
Closes the last gap on the v0.4 plan. The page slot now has a topbar with back/forward arrows, a clickable breadcrumb, and a ⌘K trigger that opens a fuzzy-search modal of every wiki page. Components: - src/ui/page/Topbar.tsx — back/forward arrows wired to window.history, ⌘K trigger button, breadcrumb derived from the parsed location. Intermediate path segments link to their folder views (#@type) so the user can drill back up the tree by clicking. - src/ui/page/QuickSwitcher.tsx — built on Vercel's `cmdk`. Pages are grouped by frontmatter type, fuzzy-filtered by typed query; selecting an item calls navigateTo and closes the dialog. Includes a useQuickSwitcherShortcut hook that listens for Cmd/Ctrl-K at the document level so the parent owns the open state cleanly. Wiring + plumbing: - src/ui/WikiPage.tsx — Topbar + QuickSwitcher mount above the existing three-column grid; the grid is now wrapped in .llm-wiki-workspace-grid to give the topbar its own row. - src/ui/styles.ts — Topbar layout, cmdk dialog (modal positioning, overlay, item selection state), responsive grid wrapper. - tests/_setup.ts (new) — installs no-op ResizeObserver + Element.prototype.scrollIntoView polyfills for jsdom; cmdk uses both unconditionally and jsdom 29 ships neither. - vitest.config.ts — wires the setup file via setupFiles. Tests: tests/ui/QuickSwitcher.spec.tsx (4 cases — closed-state, all pages on open, query filtering, click-to-navigate-and-close) and tests/ui/Topbar.spec.tsx (6 cases — breadcrumb shapes per location kind, back wiring, switcher trigger). 295/295 green; typecheck clean; prepublish 51/51. Phase F of the v0.4 plan — completes the eight-phase redesign.
Cleanup commit after a Karpathy-guided audit of the v0.4 work. All real
behavior bugs fixed; over-engineered bits trimmed; dead exports removed.
Real bugs:
- FolderTree: clicking an auto-expanded folder now collapses it on the
first click. The previous implementation tracked a separate
`userToggled` Map that defaulted unset entries to "open", so a folder
that was open via `autoExpand` (ancestor of currentSlug) couldn't be
collapsed without first being clicked twice. One Set is now the
source of truth; useEffect unions in new autoExpand entries on
navigation, toggle flips membership directly. +1 regression test.
- Launcher recent staleness: the sidebar launcher reads sessionStorage
on mount, but the page slot writes recents from a different React
tree. Without a refresh signal the launcher would never see new
entries until Paperclip re-mounted the sidebar. recordRecent now
dispatches a `llm-wiki:recent-updated` CustomEvent on every write;
Launcher subscribes and re-reads. +2 tests (event dispatch in
recent.spec, live refresh in Launcher.spec).
Bloat / over-engineering removed:
- Lifted "clear page metadata when leaving page view" out of Reader and
into the workspace shell. Removes the `useNotifyClear` helper hook
(with its eslint-disable for exhaustive-deps) and four child
consumers' onPageLoaded(null) calls. Reader's onPageLoaded now only
fires from PageRead — its real owner.
- PageRead: narrowed `data` once at the top instead of casting `as
WikiPageData & { error?: string }` five times.
- WikiPageView: replaced the `as unknown as never` rehype plugin cast
with a clean type derived from ReactMarkdown's prop type.
- Removed unused `HEARTBEAT_STANZA_SHORT` export from snippets.ts and
the inconsistently-applied `notes` field on adapters.
- Stale doc comment in WikiPage referring to phases that have all
shipped.
Drift prevention:
- The `wiki.query` tool description was duplicated in manifest.ts and
worker.ts. Extracted to a single `WIKI_QUERY_DESCRIPTION` exported
from manifest.ts; worker.ts imports it.
CHANGELOG: added a v0.4 entry under [Unreleased] documenting the
workspace, launcher, setup walkthrough, backlinks, verifySetup, tool
description, and quick switcher.
298/298 green; typecheck clean; prepublish 51/51.
Behaviour-preserving cleanup; no API surface changes.
…ading state
Two real bugs caught by the live Paperclip install of v0.4.
cmdk + ErrorBoundary fallback ("useInsertionEffect is not a function")
- Paperclip's plugin React shim re-exports a fixed allowlist of hooks
to plugin bundles (slots.tsx getShimBlobUrl). That allowlist does NOT
include useInsertionEffect, so any plugin that pulls in cmdk —
which depends on @radix-ui/react-dialog, which uses useInsertionEffect
— crashes at first render. ErrorBoundary catches it; the workspace
becomes unusable ("Something went wrong rendering this section").
- Replace cmdk with an in-house ~70-line modal that uses only the hooks
the host shim actually exposes (useState/useEffect/useCallback/useRef/
useMemo). Same observable behaviour: ↑/↓ navigates, Enter commits,
Esc/click-outside closes, query filters by case-insensitive substring
on title and slug. Removes the cmdk dep + 11 transitive Radix deps
from the plugin bundle.
- Existing QuickSwitcher tests pass unchanged (they assert behaviour,
not implementation: filter, click-to-navigate, closed-state).
- _setup.ts polyfills (ResizeObserver, scrollIntoView) kept defensively
but no longer load-bearing.
SetupView step 1 rendered missing-wiki body during loading
- The status badge correctly showed the loading spinner ("…") via
`loading || !data`, but the body fell through to the missing-wiki
copy ("No wiki yet for this Company..."). Operators saw alarming
init guidance for a half-second on every load even when their wiki
was fine. Add an explicit loading branch ("Verifying…") + an error
branch.
Verified end-to-end against the live smoke instance:
- Workspace three-column layout renders without ErrorBoundary fallback.
- Topbar ⌘K + breadcrumb + back/forward visible.
- FolderTree shows concepts/entities/sources/synthesis; pages render.
- ?view=setup reports ✅ wiki found at /private/tmp/.../wiki + 7 pages,
✅ tool registered, sample query timing visible. All 5 setup steps
with copy-paste-ready blocks.
- Sidebar launcher shows: LLM Wiki / Open / RECENT (live, populated by
the page slot's recordRecent event) / BROWSE by type with counts /
health badge.
298/298 green; typecheck clean.
Three follow-ups to round out v0.4.
1. Setup N/4 counter on the dashboard widget (#11)
The plan called for a "Setup: N/4 steps complete" badge linking to
?view=setup, with a Dismiss button once all four pass. Shipped.
The four auto-detectable signals:
- Wiki resolved (path exists)
- At least one page
- Tool registered (always true once the plugin loads)
- Lint passing
The badge stays out of the way once dismissed, but reappears if any
signal regresses (e.g. lint warns on a new ingest). Verified live
on the smoke instance: "🟡 Setup: 3/4 steps complete / Open setup →"
on a Company whose wiki lints with one warning.
2. Index page rendered through the page pipeline (the missing piece
for users with real index.md files)
Reader's Landing previously rendered index.md with a stripped-down
`<ReactMarkdown remarkPlugins={[remarkGfm]}>`, so [[wikilinks]] in
the index appeared as plain text and code blocks had no
highlighting. Extracted the shared rendering pipeline into a new
WikiMarkdown component (wikilink expansion + rehype-slug + rehype-
autolink-headings + rehype-highlight + sentinel-aware urlTransform);
both WikiPageView and Landing now consume it. The index becomes a
first-class navigable surface — wikilinks resolve to real
/{prefix}/llm-wiki#{slug} URLs.
3. Setup snippet drift check (#12)
Plan #12 was a generator that derived setup snippets from canonical
sources. Per Karpathy: lightest thing that solves the problem is a
check, not a generator. scripts/check-setup-snippets.mjs parses the
canonical heartbeat stanza out of skills/llm-wiki/references/agent-
memory-integration.md and asserts byte-for-byte equality with
HEARTBEAT_STANZA in src/ui/setup/snippets.ts. Wired into
prepublish:check as section 14 (52/52 now, was 51/51); CI fails on
drift in either file.
Files:
- src/ui/WikiMarkdown.tsx (new) — shared markdown component.
- src/ui/WikiPageView.tsx — slimmed to article chrome + WikiMarkdown.
- src/ui/page/Reader.tsx — Landing accepts companyPrefix and uses
WikiMarkdown.
- src/ui/WikiHealthIndicator.tsx — SetupStatusBanner sub-component
with the N/4 counter, dismissal, sessionStorage gate.
- src/ui/styles.ts — banner layout.
- scripts/check-setup-snippets.mjs (new).
- scripts/prepublish-checks.mjs — section 14 wires the check in.
- tests/ui/WikiMarkdown.spec.tsx (new, 4 cases).
- tests/ui/Reader.landing.spec.tsx (new, 1 case asserting wikilink
rewriting in landing-rendered index.md).
- tests/ui/WikiHealthSetupBanner.spec.tsx (new, 5 cases).
308/308 green; typecheck clean; prepublish 52/52.
Two related fixes from a screenshot review. The grown-input abnormality - The workspace's left rail (FolderTree column) used .llm-wiki-search- input for its "Filter pages…" field. That class still carried a `flex: 1 1 auto` rule left over from v0.3's WikiBrowser horizontal header. In a flex-direction:column container, that made the input grow to fill the entire column height — a giant empty box with the placeholder near the bottom and the folder tree squeezed below. - Replace `flex: 1 1 auto` with `width: 100%` + `box-sizing: border- box`. The input now spans the column width without stealing vertical space; the FolderTree sits directly below it as intended. Sidebar simplification - The sidebar slot becomes one navigation link to the wiki workspace. Drops the search input, the Recent list, the Browse-by-type list, the health badge, and the wikiPathMissing setup CTA. Anything richer lives in the workspace itself; the sidebar exists only to make the wiki one click away from any Company-scoped Paperclip route. - The dashboard widget (which already covers setup status via its N/4 banner and the Open setup link) is untouched. Orphans my changes created (deleted) - src/ui/recent.ts — sessionStorage list + RECENT_UPDATED_EVENT. - tests/ui/recent.spec.ts. - recordRecent call inside Reader's PageRead. - ~75 lines of launcher-section CSS that styled the dropped Recent / Browse / Health / Setup-CTA blocks. Tests: 292/292 (was 308; -16 from the dropped recent.spec + slimmed launcher + slots WikiSidebar block). Typecheck clean. Verified live in the smoke instance: - Sidebar HTML is a single anchor: `<a href="/SEE/llm-wiki" class= "llm-wiki-launcher-link" data-testid="wiki-open">LLM Wiki ↗</a>`. - Workspace's left-rail filter input clientHeight = 37px (not the grown box); folder tree below it shows 4 top-level entries. - Setup walkthrough, Topbar, Reader page rendering, dashboard N/4 banner all still working.
The previous launcher used custom .llm-wiki-launcher* CSS that didn't
match Paperclip's other sidebar entries (Dashboard / Inbox / Issues /
Routines / Goals / etc.). The user flagged the inconsistency.
Match the host's SidebarNavItem instead:
- Borrow Paperclip's exact Tailwind utility classes for nav rows:
`flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium`,
`text-foreground/80 hover:bg-accent/50 hover:text-foreground` for
the inactive state, `bg-accent text-foreground` for active. These
classes are already compiled into the host's stylesheet (same
classes appear in ui/src/components/SidebarNavItem.tsx) — the
plugin doesn't ship any new CSS for the launcher.
- Inline a Lucide-style "book" SVG so the row has an icon at the
same h-4 w-4 size as Dashboard / Inbox / etc. We can't pull in
lucide-react (the host's plugin React shim only exposes a fixed
hook allowlist, plus icons aren't in the bridge), but a hand-
inlined SVG with `stroke="currentColor"` matches the visual
weight.
- Add active-route detection — `aria-current="page"` and the active
Tailwind classes set when the URL is on `/{prefix}/llm-wiki`.
Listens to `popstate` so it updates after hash navigations within
the workspace.
Drop the .llm-wiki-launcher* rules from styles.ts; the launcher no
longer carries any plugin-owned CSS.
296/296 green; typecheck clean. Verified visually in the smoke
instance: the "LLM Wiki" row sits in the same nav column as
Dashboard / Inbox / Issues with identical icon, typography, padding,
and hover/active treatment. Lights up with bg-accent when on the
workspace route.
Clicking the LLM Wiki sidebar link did a hard browser navigation, unlike Dashboard / Inbox / etc. which use React Router's <NavLink>. Same bug existed on every other plugin link that changed the path or query string: Open setup, Set up the wiki, issue tab results, topbar breadcrumb, FolderTree leaves, search/folder result lists, backlinks panel, wikilinks rendered inside markdown. Add a small <HostLink> component that intercepts the left-click, calls history.pushState, and dispatches a synthetic popstate event so React Router's history listener (and our own useWikiLocation hook) re-render in place. Modifier-clicks (cmd/ctrl/shift/alt), middle-clicks, target="_blank", and bare-fragment hrefs all fall through to the browser's default so open-in-new-tab and in-page heading anchors keep working. Replace plain <a href> with <HostLink> in every internal-route spot: - Launcher (the user's complaint) - WikiHealthIndicator (Open setup, Set up) - WikiContextTab (issue→wiki page) - WikiMarkdown's MarkdownAnchor (wiki: scheme branch only; external links keep target="_blank") - FolderTree leaves - Reader's FolderView + SearchView result lists - Topbar breadcrumb - BacklinksPanel Tests: tests/ui/HostLink.spec.tsx (7 cases — anchor render, click intercept, modifier-click fall-through, middle-click fall-through, target=_blank fall-through, bare-fragment fall-through, consumer onClick still fires). 303/303 green; typecheck clean. Verified live: clicking LLM Wiki from /SEE/dashboard transitions to /SEE/llm-wiki without a page reload (probe attribute on <html> survives the click), workspace mounts, URL updates. Same behaviour as Dashboard / Inbox.
…t omits companyPrefix Smoke testing the onboarding flow caught a real bug: from the dashboard the missing-wiki Setup link rendered as "/undefined/llm-wiki?view=setup" — clicking it 404'd. The sidebar slot's link to "/SEE/llm-wiki" worked fine on the same page, so this is a context-shape difference between PluginSidebarProps and PluginWidgetProps: the host surfaces companyPrefix as `undefined` in the widget context even though the SDK type says `string | null`. Two layers of defense: - src/ui/href.ts — wikiHref signature now accepts `string | null | undefined` and treats undefined the same as null (returns "#"). Callers no longer build "/undefined/..." paths even if the host serves an unexpected shape. - src/ui/WikiHealthIndicator.tsx — small effectiveCompanyPrefix helper that falls back to the first non-"instance" path segment of window.location when context.companyPrefix is empty. The Setup links and SetupStatusBanner consume the resolved value. Verified live by walking the onboarding flow with the smoke wiki moved aside: - Dashboard widget detects state="missing", surfaces the Setup link as "/SEE/llm-wiki?view=setup" (was "/undefined/..."). - Click bypasses full reload (probe attribute on <html> survives). - Step 1 shows the /wiki:init guidance. - After restoring the wiki and clicking "Re-run verifier", step 1 flips to ✅ with the resolved path + page count. - Dashboard widget transitions back to state="ok" with the "🟡 SETUP: 3/4 STEPS COMPLETE" banner linking back to setup. 303/303 green; typecheck clean.
The previous fix inlined an effectiveCompanyPrefix() inside
WikiHealthIndicator. That left the plugin one re-discovery away
from the same drift if Paperclip ever introduces another mount
point that omits companyPrefix from the slot context.
Move the fallback into src/ui/href.ts as `resolveCompanyPrefix()`.
WikiHealthIndicator imports it; future consumers can reuse it
without re-deriving. Documented the upstream root cause inline
on the helper:
// Paperclip's dashboard mounts the dashboardWidget slot with
// <PluginSlotOutlet context={{ companyId }} /> — only companyId
// is passed (ui/src/pages/Dashboard.tsx:311), so companyPrefix
// arrives as undefined for that slot type. Other slot types
// (sidebar, page, detailTab) pass it correctly.
The helper prefers the context-supplied value when present; falls
back to the first non-"instance" path segment of window.location.
Returns null when neither is available.
5 new tests in tests/ui/href.spec.ts cover all branches:
- prefers context value when supplied
- falls back to URL segment when undefined
- falls back to URL segment when null
- returns null on /instance/* routes
- returns null when there's no leading segment
308/308 green; typecheck clean. Verified live in the smoke instance:
both wiki-found and wiki-missing dashboard states resolve the Setup
link to "/SEE/llm-wiki?view=setup" (was "/undefined/..." before).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new sub-deliverable:
paperclip-plugin-llm-wiki, a Paperclip plugin that surfaces the LLM Wiki inside Paperclip's UI as a read-only context lens. Source lives atintegrations/paperclip/plugin/; ships to npm at v0.0.1 once smoke-tested. The skill atskills/llm-wiki/is unchanged — the plugin only reads what the skill writes.Five surfaces, all read-only:
/companies/:c/plugins/llm-wiki)lint_check_interval_minuteswiki.queryagent toolWhat's in the box
FEASIBILITY.md— validation against the livepaperclipai/paperclipSDK; documents 14 SPEC errata caught before any code shippedcreate-paperclip-pluginscaffold is unusable outside the Paperclip monorepo)wiki_search.py,wiki_lint.py,wiki_stats.py— byte-for-byte parity, mechanically tested via Python snapshotcategoriesplural, nosdkVersion, exact-pinned SDK calver)wiki.querytool; graceful handling of capability denial; no writes, no event subscriptionsWikiBrowser+ customErrorBoundary(the SDK doesn't re-export one)scripts/prepublish-checks.mjs— 13-section gate withprepublishOnlysafety net; CI workflow consolidates to one stepPlus five maintainer-flagged fixes after the initial implementation:
realpathSync(recursive walkers + wiki root + directloadIndexreads)search_top_kconfig plumbed end-to-end (hardcoded values dropped from UI)lint_check_interval_minutesplumbed end-to-end (refresh()on configured interval)Plus a follow-up cross-platform cleanup fix (
unlinkSyncfor symlink removal,try/finallyso assertions gate the test result) so the security tests pass on Linux as well as macOS.Stats
dist/, tarball contents complete with no surprises, doc cross-refs, CHANGELOG entry)Test plan
Automated (run by CI):
pnpm typecheck— strict TS + bundler resolutionpnpm test— 204/204 across vitest node + jsdom environmentspnpm run build— producesdist/manifest.js,dist/worker.js,dist/ui/index.jspnpm run prepublish:check— full pipeline gate (also wired asprepublishOnlysopnpm publishaborts on failure).github/workflows/paperclip-plugin.ymlruns on PR + push to main, gated to changes underintegrations/paperclip/**Manual (still required before npm publish):
python skills/llm-wiki/scripts/wiki_search.py "<query>"from the same wiki on diskwiki.queryand confirm structureddata.resultscome backOnce the smoke-test passes, the next steps are
pnpm publishfromintegrations/paperclip/plugin/, listing PR againstgsxdsm/awesome-paperclip, and a discussion onpaperclipai/paperclipDiscussions.Documentation
integrations/paperclip/plugin/README.md— npm-facing user guide (install, per-surface usage, configuration, security, troubleshooting, FAQ)integrations/paperclip/README.md— operator-facing decision page + first-time setup walkthroughintegrations/paperclip/SPEC.md— v0.1 design with verbatim SDK source citationsintegrations/paperclip/FEASIBILITY.md— Phase 0 validation report[Unreleased]entry covering the new plugin, parity tests, SPEC + FEASIBILITY, dedicated CI job, and upstream issue notes