OpenKnowledge

v0.4.0

Part of the OpenKnowledge changelog.

Minor Changes

  • feat(api): RFC 9457 Problem Details envelope across all HTTP handlers (api-design-hardening)

    All 57 handlers in packages/server/src/api-extension.ts now share a single canonical wire format:

    • Errors emit Content-Type: application/problem+json with a flat body { type, title, status, instance?, detail? } per RFC 9457. The type field is a closed-enum URN of the form urn:ok:error:<kebab> (per RFC 9457 §3.1.1 — URN form is routing-independent and won't change meaning under reverse-proxy / path-prefix). The title is a required short English summary; instance is a UUID correlation ID emitted alongside the structured Pino log line for grep-correlated triage; detail is an optional longer explanation.
    • Success drops the { ok: true, ... } wrapper and emits a flat { ...data } body with Content-Type: application/json. Clients narrow on the HTTP status code (if (!res.ok)) before parsing — the RFC 9457 two-step parse pattern.

    This is a wire-format breaking change for any direct in-process consumer of the HTTP API. The @inkeep/open-knowledge MCP shim's internal httpGet / httpPost helpers wrap the new flat success body with {ok: true, ...body} for in-process MCP tools so existing if (!result.ok) return error short-circuits keep working — MCP's own {content, isError?} envelope is unchanged.

    Structural enforcement:

    • Per-handler XyzRequestSchema + XyzSuccessSchema Zod schemas live in @inkeep/open-knowledge-core/schemas/api. Every schema exports satisfies StandardSchemaV1<...>.
    • Request bodies validated through a withValidation() middleware wrapper at packages/server/src/http/request-validation.ts (handlers can't be added without going through it).
    • Errors emit through errorResponse(res, status, type, title, options) at packages/server/src/http/error-response.ts — the only sanctioned site (packages/app/tests/integration/error-envelope-coverage.test.ts runs in fail-on-any-occurrence mode and AST-scans api-extension.ts for inline { ok: false, ... } and { ok: true, ... } literals).
    • NDJSON streaming endpoints (clone, auth-login, auth-repos) emit pre-stream errors through errorResponse and mid-stream errors through streamingProblemEvent({type: 'error', problem: ProblemDetails}) events — typed envelope preserved across the streaming protocol.
    • Closed-enum ProblemType URNs (~40 tokens) plus assertNeverProblemType / assertNeverLinkTarget exhaustiveness helpers are structurally enforced by packages/app/tests/integration/exhaustiveness-coverage.test.ts — derived from the schema at test-discovery time so the registry never drifts.
    • Telemetry: ok.api.error.count{type, handler} counter increments on every error emit.

    Client lockstep: 23 sites in packages/app/src reading data.ok / body.ok / raw.ok migrated to the two-step parse pattern. class HttpResponseParseError distinguishes contract-shape responses (RFC 9457 problem details) from non-contract responses (proxy 502 HTML, network failures).

    Defense-in-depth: SVG is in IMAGE_EXTENSIONS (so the editor's <img src=svg> rendering still works — browsers ignore Content-Disposition for embed contexts) but excluded from INLINE_RENDERABLE_EXTENSIONS so top-level navigation to .svg (web fallback window.open from a markdown SVG link) downloads instead of executing embedded <script> under same origin. Aligns with Docmost's posture; cf. GHSA-rcg8-g69v-x23j (Plane SVG XSS).

    Full spec + decision log (D1–D38, US-001 through US-014): specs/2026-04-30-api-design-hardening/SPEC.md. Canonical pattern guide: packages/server/src/http/README.md.

  • feat(editor): asset upload + ![[file.ext]] wiki-embed surface

    Any file drop is accepted by the editor — there is no user-facing byte cap. PDFs, video, audio, archives, and fonts stop hitting the old "Unsupported file type" dead-end. The emit shape is picked by extension: markdown files (.md / .mdx) emit as [[basename]] wiki-links (link-semantic, navigable on Cmd-click, resolved via fileIndex — markdown is a first-class OK doc, not an opaque asset); images + typed renderable files (PDF, MP4, WebM, MP3, WAV, OGG, M4A, MOV) emit as ![[file.ext]] wiki-embeds; opaque files emit as [name](path) markdown links. Uploads stream to disk end-to-end (memory footprint is O(1), not O(fileSize)), so the only rejection axis is disk fullness (storage-full → HTTP 507). See reports/streaming-upload-refactor/REPORT.md for the refactor rationale.

    Same-directory sha256 dedup returns existing paths on duplicate drops with a toast ("Already at <path> — reusing."). Renaming a doc that contains image refs recomputes the relative path; absolute refs and wiki-embed refs are untouched because the basename index resolves them dynamically.

    New HTTP surface on the server:

    • POST /api/upload — upload endpoint. Success response (per UploadAssetSuccessSchema in @inkeep/open-knowledge-core): flat { src, path, deduped, sha?, byteLength? } where src is the asset's basename and path is the contentDir-relative location (colocated with the referencing doc). Error responses are RFC 9457 problem details (application/problem+json) with type ∈ {urn:ok:error:malformed-upload, urn:ok:error:storage-full, urn:ok:error:storage-readonly, urn:ok:error:collision-exhaustion, urn:ok:error:storage-error} plus title, status, and instance correlation UUID. See the api-design-hardening changeset for the cross-handler RFC 9457 envelope.

    No user-facing upload.* config. Attachment placement (co-located), emit shape (![[...]] for supported extensions), same-directory sha256 dedup with a toast notice, and the wiki-embed extension list are fixed defaults. Every value is a module-level constant in @inkeep/open-knowledge-core/constants/upload.ts. One-shot Obsidian-vault migration CLI deferred to a future spec — OK does not read .obsidian/app.json at runtime; refugees whose vault uses non-default config shape wait for the future migrator. Legacy configs still carrying upload.* keys parse cleanly (unknown keys are silently stripped).

    File watcher now emits asset-create / asset-delete DiskEvents alongside the existing markdown events; CC1 ch:'files' signal coalesces both so file-sidebar and basename-index rebuilds piggyback on one broadcast. sanitizeFilename preserves Unicode code points (letters, digits, marks, punctuation, emoji) while stripping path separators and control bytes.

    Full spec + decision log (D1–D-M): specs/2026-04-16-editor-asset-and-embed-surface/SPEC.md. Operator-facing guide: Assets and embeds.

    Asset-click dispatcher + OS-integration surface (2026-04-23 amendment). Click a ![[meeting.pdf]] embed and the PDF opens predictably — a new browser tab in web, shell.openPath in Electron. Previously post-reload clicks routed through the doc-link navigator and failed silently (Gap 3b); Electron drop-time clicks replaced the editor window (Gap 4). Both gaps close.

    • ClassifiedLinkTarget gains a first-class {kind: 'asset', url, ext} variant; resolveAssetProjectPath resolves relative hrefs against the source doc's directory.
    • Renderer-side dispatcher + empty-at-landing viewer registry at packages/app/src/editor/asset-dispatch/ — future PRs register PDF.js / image lightbox / video-audio viewers as ~40-60 LOC plugins without modifying the dispatch layer.
    • Three new Electron IPC channels (ok:shell:open-asset, ok:shell:reveal-asset, ok:shell:show-asset-menu). Main-process openAssetSafely enforces path containment (realpath + isPathWithinProject), existence, and an executable-extension blocklist (.exe/.sh/.html/.svg/…) source-verified from Obsidian 1.12.7. Renderer sends project-relative paths; containment fires at the IPC boundary.
    • Right-click any on-disk reference (asset chip, wiki-link chip, image) → native OS menu with Reveal in Finder / Show in Explorer + Open in default app + Copy link. Gesture-attested (main observes the click directly).
    • Defense-in-depth: setWindowOpenHandler + will-navigate on the editor webContents intercept any asset URL that escapes the renderer dispatcher (pasted <a href>, plugin content, drop-time <a target="_blank">). Same path containment + blocklist enforced on every entry point.

    Full amendment (US-A1..A6, FR-A1..A8, NG-A1..A6, D-A1..A12): specs/2026-04-16-editor-asset-and-embed-surface/SPEC.md §Post-finalization amendment (2026-04-23). Research: reports/electron-os-integration-patterns/ + reports/editor-asset-embed-patterns-across-universe/ D9.

  • feat: Component Blocks v2 — 5-pack foundation (Callout + Image + Video + Audio + Accordion)

    The editor now ships five built-in component primitives — Callout, Image, Video, Audio, and Accordion — each with a WYSIWYG settings panel, a slash-command insertion menu, and lossless on-disk round-trip for both the MDX form and the markdown form (where one exists). Every primitive is a DIY React component on Open Knowledge's own brand (shadcn / Tailwind); the editor bundle no longer pulls in fumadocs-ui's React surface or its CSS variable bridge.

    What you get out of the box:

    • Callout — five GFM alert types (note / tip / important / warning / caution) plus optional title / icon / color / and Obsidian-style foldable chrome (> [!NOTE]+ / -). Authoring works in any of three forms: GFM alert blockquote, foldable Obsidian opener, or <Callout type="…">…</Callout> MDX JSX. Common alias tokens (successtip, dangercaution, etc.) fold to the GFM 5 on disk.
    • Image<Image src=… alt=… width=… caption=… /> MDX, plus standard CommonMark ![alt](src). Both forms render through the same descriptor with click-to-zoom on by default; the MDX form additionally exposes caption (renders as <figure> + <figcaption>), explicit dimensions, and loading / zoom toggles.
    • Video — pure HTML5 <video> wrapper with native controls. No YouTube / Vimeo URL sniffing — embed services with a raw <iframe> in MDX (matches Mintlify's pattern). <track> and <source> children round-trip.
    • Audio — pure HTML5 <audio> wrapper with native controls always on. <source> and <track> children round-trip.
    • Accordion — standalone HTML5 <details> / <summary> substrate, no wrapper component required. Cross-browser exclusive grouping via HTML5 <details name="…"> (Chrome 120+, Safari 17.2+, Firefox 130+). Authors can write either <details><summary>X</summary>Y</details> or <Accordion title="X">Y</Accordion> — both render the same descriptor.

    Other improvements:

    • Auto-generated settings panel from each component's prop types (string / boolean / number / enum) — no separate component prop docs required.
    • Slash-command insertion with sensible defaults; the settings panel auto-opens on insertion so required fields are filled in before you move on.
    • Hover chrome with move-up / move-down / delete / settings buttons.
    • Keyboard navigation throughout (Tab / Esc / arrow keys with context-aware handling).
    • Broken or unrecognized MDX components automatically open in an embedded source-code editor so authored content stays editable — nothing silently disappears.
    • Both pristine and dirty save paths preserve the on-disk shape: unedited blocks round-trip byte-for-byte; edited blocks canonicalize to the MDX JSX form.

    Breaking changes:

    • Both the inline MDX element node (jsxInline) and the block MDX component node (jsxComponent) changed PM-schema shape in this release. jsxInline drops its attributes and sourceRaw attrs — its text content IS the source of truth. jsxComponent widens from an atom with a raw-content attr to a non-atom block with block* children and new structured attrs (componentName, kind, attributes, sourceRaw, sourceDirty, props). This is a load-bearing change for collaborative editing — older clients coexisting with this version in the same live session substitute both nodes to rawMdxFallback (raw source preserved as editable text) via the y-tiptap schema-throw substitution patch. Upgrade all clients in a session together — both inline JSX authoring and component-block authoring are affected, not just inline. Persisted documents are unaffected; the on-disk MDX is preserved.
    • Content using component names that are no longer built in (Tabs, Card, CardGroup, Steps, Banner, Files, TypeTable, InlineTOC, Mermaid, AudioPlaceholder, ImageZoom) opens as an editable raw-source block. Content is preserved verbatim. Rename <AudioPlaceholder /><Audio /> and <ImageZoom><Image> to pick up the new descriptors.

    The compound-component tier (Tabs + Tab grouping, Accordion grouping with shared chrome, Steps + Step) is not built in today; it returns when concrete dev-docs / help-center authoring demand surfaces. No public API will change for existing 5-pack consumers when that happens.

    Bundle size:

    • Main app bundle stays flat (~210 kB gzipped) — the fumadocs-ui drop and 12-descriptor cut offset the 5-pack prop-surface widening and new selection-chrome plugins.
    • Total JS across lazy-loaded chunks grows ~100 kB gzipped (~978 kB → ~1.08 MB) to accommodate CB-v2 feature surface (descriptor-dispatch registry, V2 editor cache, SelectionStatePlugin + Breadcrumb + SelectionAnnouncer + BlockDragHandle, nested CodeMirror for rawMdxFallback, slash-command menu, canonical/compat descriptor split with three additional read-only source-form descriptors (GFMCallout, CommonMarkImage, HtmlDetailsAccordion) for round-trip preservation). The all JS chunks combined size-limit ceiling is raised 1050 → 1100 kB (~2% headroom) to match. Delivered via on-demand chunk loading — users don't pay the full bill on first paint.
  • Config Editing Paths — end-to-end UX for editing Open Knowledge configuration:

    • Settings pane in the editor area (Cmd-, / App menu / HelpPopover / Command Palette) with This project and User scope tabs. Each field auto-saves; per-field reset; modified-at-scope indicator on cross-scope fields.
    • Real-time sync — Settings pane is bound to two Y.Text-only synthetic Hocuspocus docs (__config__/workspace, __user__/config.yml). External edits via CLI, MCP, IDE hand-edit, or another ok start instance propagate via a chokidar file watcher into Y.Text and refresh any open pane within ~500ms.
    • Three-layer defense-in-depth validation — client walker (L1) → fs writer (L2) → persistence-hook (L3). Invalid mutations revert to LKG and surface a toast + brief field flash.
    • MCP toolsget_config, set_folder_rule. fs-direct (no running server required); auto-scope inference via the inspectConfig ladder; mixed-scope rejection. (set_config was subsequently removed in the schema-simplification changeset; see config-schema-simplify.md.)
    • CLIok config validate (exits 0/1 with source-located errors) + ok config migrate (idempotent codemod that drops sync.*, persistence.{debounceMs,maxDebounceMs}, server.port).
    • ok init scaffolds the workspace config.yml with a magic-comment $schema URL pinned to the schema major (v0) + @latest of the npm package — additive schema changes reach existing users automatically; breaking changes bump the path to v1 and old majors stay published forever.
    • Per-scope JSON Schemasdist/schemas/v0/config.workspace.schema.json and …/config.user.schema.json so VS Code's Red Hat YAML LSP only suggests fields valid AT the file's scope.
    • Schema cleanup — drops sync.* (7), persistence.{debounceMs,maxDebounceMs} (2), server.port (1); adds appearance.theme and appearance.editorModeDefault (user-scope, both UNSET by default; chrome <ThemeToggle> writes through userBinding.patch so localStorage stays a derived cache). content.* is workspace-scope-only.
    • OTel — five new config.* spans (config.bind, config.patch, config.validate, config.persist, config.revert) trace the full edit chain.
  • Config schema simplification — six fields removed, replaced with constants:

    • Schema fields removedgithub.oauthAppClientId, server.host, server.openOnAgentEdit, mcp.autoStart, mcp.tools.read_document.historyDepth, mcp.tools.grep.maxResults. None were genuinely user-configurable in practice. Their default values now live as plain export const in @inkeep/open-knowledge-core (constants/github.ts, constants/mcp.ts, constants/server.ts): DEFAULT_GITHUB_OAUTH_CLIENT_ID, DEFAULT_SERVER_HOST, READ_DOCUMENT_HISTORY_DEPTH, GREP_MAX_RESULTS.
    • Config type contract — TypeScript consumers reading config.server, config.github, or config.mcp will now get never. The schema's top-level surface shrinks to content, preview, appearance, autoSync. Layered config infrastructure (project/user merge, scope inference, __config__/project + __user__/config.yml Y.Text transports) is unchanged for the day a real user-configurable layered field needs it.
    • set_config MCP tool removed — the agent-settable surface was empty after the two mcp.tools.* fields became constants. get_config and set_folder_rule remain. Re-introduce when an agent-tunable field actually returns.
    • Runtime overrides preserved--host flag and HOST env var (resolved at the start command via the new resolveHost() helper, mirroring the D29 port pattern); OPEN_KNOWLEDGE_GITHUB_CLIENT_ID env var; OK_MCP_AUTOSTART=0 env var.
    • server.openOnAgentEdit — the auto-open-browser-on-first-agent-write feature was deleted entirely (default was off; collapsed dead conditional).
    • Loose-mode tolerance — existing user/project YAML configs that still set the removed keys parse without throwing; the loader emits a per-key deprecation warn pointing at the right knob (--host flag, HOST env, OPEN_KNOWLEDGE_GITHUB_CLIENT_ID, OK_MCP_AUTOSTART=0, or "hardcoded in @inkeep/open-knowledge-core" when no override exists). Old mcp.tools.search.maxResults (pre-#491 rename) is also flagged.
    • Settings pane — Server, GitHub, and MCP sections removed. set_config MCP tool description and reference docs cleaned up.

    Migration notes for consumers:

    • Reading config.server.host, config.mcp.autoStart, etc. → switch to runtime override (--host/HOST, OK_MCP_AUTOSTART=0) or import the constant directly from @inkeep/open-knowledge-core.
    • Calling set_config MCP tool → use the Settings pane or hand-edit config.yml. Use set_folder_rule for folder rules; get_config for reads.
  • feat(desktop): replace the "Start fresh" Welcome card with an in-app "Create new project" dialog.

    The third Welcome card now opens a shadcn dialog asking for a name and parent location, with checkboxes for which AI editors to wire on first run. A pre-submit cascade surfaces structural conditions before any filesystem write:

    • Nested-project block — when the picked parent sits inside an existing Open Knowledge project, the dialog renders a red banner naming the enclosing rootPath with an inline "Open " action. Create is disabled.
    • Git-root promote confirm — when the picked parent is inside a git working tree with no enclosing .ok/, the dialog renders a blue banner explaining that .ok/config.yml will land at the git root with content.dir scoped to the new folder (one project per git repo). Create stays enabled.
    • Target-non-empty block — when the resolved parent/<name> already exists with content, the dialog renders a neutral banner pointing the user at "Open folder on disk" instead.

    The IPC handler (ok:project:create-new) re-runs every check server-side as defense-in-depth — a stale renderer state or a hostile renderer cannot scaffold a project inside an existing one.

    Breaking (internal-only surfaces):

    • OkProjectEntryPoint value 'start-fresh' is removed; replaced by 'create-new' and the new 'create-new-nested-redirect' value (fired by the red banner's inline action).
    • bridge.dialog.createFolder, the ok:dialog:create-folder IPC channel, and dialog-helpers.ts's promptForFolder (createDirectory variant) are deleted. The Pick-existing flow keeps promptForExistingFolder unchanged.
    • The silent-scaffold branch in openProject (entryPoint 'start-fresh') is gone — every project create now goes through the dialog with explicit user consent on AI-editor wiring.

    No public API change — @inkeep/open-knowledge (the CLI) is unaffected.

    Telemetry continuity. The ok.desktop.onboardingConsent span keeps its shape; the flowKind enum gains 'create-new-default' (all editors selected, the canonical happy path) and 'create-new-customized' (one or more editors toggled off). The legacy 'fresh-silent' variant is no longer emitted; downstream dashboards should treat 'create-new-default' as its successor during the transition window.

    Spec: specs/2026-05-10-create-new-project-dialog/SPEC.md.

  • feat(desktop): per-project onboarding consent dialog + git-root promotion + boot-time config load.

    Opening a folder in the macOS desktop app no longer scaffolds .ok/ silently. Two paths now split on the user's Navigator gesture:

    • Pick Existing Project (and Recents, deep-link, drag-drop) → a per-window consent dialog opens to confirm scaffolding details before any filesystem write. The dialog covers content directory (with a Browse button for sub-folder selection), .okignore patterns, AI-tool integration multi-select (all editors checked by default — explicit, not detected), and sensitive-path warnings (/, ~, ~/Documents, ~/Desktop, ~/Downloads, /Volumes/<mount>). Git is initialized implicitly when the picked path has no real .git/ — no UI toggle. Picking a folder via the dialog == agreeing to scaffold; users who don't want OK in their folder simply Cancel.
    • Start Fresh → silent path; defaults applied without prompting.

    Re-opening a folder that already has .ok/ never re-fires the dialog.

    One .ok/ per git repo (G11). When the picked path sits inside a git working tree and no ancestor .ok/ is present, .ok/ materializes at the git working-tree root and content.dir is pre-filled with the picked sub-path. Eliminates orphan sibling .ok/s within the same repo and aligns OK's project boundary with git's. Applies to desktop (Start Fresh + Pick Existing) AND CLI (ok init).

    Sub-folders of an OK-managed project promote to the ancestor .ok/. No nested .ok/ ever materializes. The picked sub-path is dropped; the editor opens at the ancestor with a 4 s "Opened existing OK project at <ancestor>" toast.

    Desktop boot now reads project config. The per-window utility calls readConfigSafely against .ok/config.yml BEFORE bootServer, so content.dir from the project file wins over the IPC default. Invalid YAML logs [config] desktop boot config invalid and falls back to schema defaults; the editor still opens.

    ensureProjectGit hardened. Now validates .git/HEAD (not just .git/) and auto-repairs the shell-.git/ regression class — folders left with .git/ok/ (the shadow subtree) but no .git/HEAD get a single silent git init on next open. git status works in OK projects from the moment OK touches them. .git/ok/ is preserved; Save Version's parent-git path produces ok/v<N> tags going forward.

    CLI parity:

    • ok init from a git sub-folder writes .ok/ at the git root via the new shared resolveProjectRoot helper, with content.dir scoped to the sub-path. Surfaces a single disclosure line on first-time scaffold: [ok] Initialized OK at <gitRoot> (scoped to <subPath>/) for git-root promotion, [ok] Opened existing project at <ancestor> for ancestor promotion.
    • ok start operates against the cwd directly and does not scaffold — single-responsibility per command, init initializes, start starts. OkDirMissingError fires loud when .ok/ is missing.

    New OTel span. Each onboarding flow emits one ok.desktop.onboardingConsent span with bounded-cardinality attributes (flow_kind, entry_point, git_init_requested, content_dir_changed, warnings_count, ai_integrations_failed_count). No raw paths.

    New degraded subsystem. 'project-git-shell-only' joins shadow-repo / head-watcher / file-watcher in the utility's degraded array when discoverProject saw a shell-only .git/ even after auto-repair (defense-in-depth — should not occur in practice).

    Spec: specs/2026-05-07-desktop-onboarding-consent/SPEC.md.

  • feat(frontmatter-editing-ux): top-of-document property panel + per-key Y.Map('metadata') storage + frontmatter_patch MCP tool. Frontmatter is now editable inline in WYSIWYG mode through typed widgets, and concurrent edits from a human and an agent to different properties merge at the field level instead of clobbering each other through document-level last-write-wins.

    • @inkeep/open-knowledge-core — new packages/core/src/frontmatter/ module exporting FrontmatterValueSchema, FrontmatterPatchSchema, FRONTMATTER_TYPES, and the comment-preserving YAML codec (parseFrontmatterYaml / serializeFrontmatterMap over yaml@2.x's parseDocument). Bridge readers/writers in packages/core/src/bridge/frontmatter-y.ts extended with getFrontmatterMap, setFrontmatterFromYaml, setFrontmatterProperty, and composeFrontmatterForStore. getFrontmatter(doc) now synthesizes from per-key entries when present, falls back to the legacy single-string slot otherwise — existing string-shape callers continue to compile unchanged.
    • @inkeep/open-knowledge-serverY.Map('metadata') now carries one entry per frontmatter property (Y.Text for editable strings, Y.Array<Y.Text> for lists, primitives for atomics). New POST /api/frontmatter-patch route + handleFrontmatterPatch handler applies JSON Merge Patch (RFC 7396) atomically under a per-session, not paired formOrigin. Observer A's metaMap deep-observer recomposes YAML+body and propagates to Y.Text after settlement. onLoadDocument runs an eager-on-load migration; applyExternalChange (file watcher) and Observer B reconciliation use per-key diff so undoing a single property reverts only that property. onStoreDocument's composeFrontmatterForStore writes the legacy YAML byte-string verbatim when the per-key map still matches it — comments, blank lines, and scalar styles round-trip losslessly. agent-patch (/api/agent-patch) returns HTTP 400 on FM-intersecting find/replace calls with a migration hint pointing at frontmatter_patch. New OTel spans frontmatter.patch + frontmatter.form_write; new counter ok.frontmatter.edit_surface_total labels writes by source (form / mcp-patch / mcp-write / file-watcher / source-mode).
    • @inkeep/open-knowledge — new frontmatter_patch MCP tool. Set / create / delete frontmatter properties with {patch: {key: value | null}}; optional types map overrides per-key widget inference (text / number / boolean / date / list); optional summary threads through to the per-contributor attribution journal under the same 80-char cap as the other write tools.
    • @inkeep/open-knowledge-app — new top-of-document Properties panel above the body in WYSIWYG mode. Five widget types (Text / Number / Boolean / Date / List), inline add / delete / rename, type picker dropdown, per-row hover chrome, collapse via chevron, empty-state seeded via the editor toolbar's Add Properties button. All form interactions wire through POST /api/frontmatter-patch with source: 'form'.

    Storage migration is automatic on document load — no user action required. The legacy single-string metaMap.get('frontmatter') slot is retained as a transitional byte-identical mirror so YAML comments and scalar styles survive doc-load → no-op-edit → doc-save round-trips. frontmatter_patch is the only MCP surface for frontmatter edits going forward; the soft-deprecation window for agent-patch FM-touching calls is closed and those now return HTTP 400.

    Full spec + decision log: specs/2026-04-24-frontmatter-editing-ux/SPEC.md.

  • Add the Gbrain starter pack — entity-grounded second-brain layout (typed dossiers for people/, companies/, meetings/, concepts/, originals/, media/) with compiled-truth-above / append-only-timeline-below body convention, per-folder templates, agent-readable folder frontmatter, and five root files (USER.md, SOUL.md, ACCESS_POLICY.md, HEARTBEAT.md, log.md). Selectable as Gbrain in the desktop app's pack picker or via ok seed --pack gbrain. Pattern inspired by Garry Tan's gbrain; OK ships the layout natively.

    Also ships two new docs-site guides under docs/content/workflows/:

    • karpathy-llm-wiki.mdx — source-grounded knowledge base via the existing knowledge-base starter pack, mapping 1:1 onto Karpathy's three-layer pattern (external-sources/research/articles/) with the ingest / research / consolidate MCP tools.
    • gbrain.mdx — entity-grounded second brain via the new gbrain starter pack, demonstrating the dead-links → triage → WYSIWYG loop and end-to-end agent-driven dossier maintenance using OK's MCP surface.

    Both guides land under a new Workflows section in the docs IA (docs/content/meta.json + docs/content/workflows/meta.json).

    Pinned-count test in starter.test.ts updated 5 → 6 to include the new pack.

  • feat(mcp): ok mcp is now an inline stdio MCP server with per-call project routing.

    The default mode replaces the previous stdio→HTTP shim that bound to a single project at startup. One stdio process can now serve any number of OK projects on the host: each tool call resolves its own cwd argument, walks up to the nearest .ok/ directory (must be a directory; regular files and dangling symlinks named .ok are rejected), loads the matching project config, and proxies HTTP traffic to that project's running ok start (auto-spawned when OK_MCP_AUTOSTART is unset or non-zero).

    This makes it safe to register ok mcp once globally in MCP hosts (Claude Code, Cursor, Codex). The host can call any tool against any local OK project by passing an absolute cwd argument.

    Breaking change for direct MCP clients: the global path requires an explicit absolute cwd on every tool call. The previous in-project mode silently defaulted to the configured project root. The global server has no fixed root and throws a clear error when cwd is omitted. Tool-arg descriptions have been tightened to document the new contract. The legacy --port <port> mode (single-backend stdio→HTTP shim) is unchanged.

    Existing on-disk MCP host registrations from prior ok init runs continue to register the binary; modern MCP-aware agents will read the updated cwd description and pass it per call.

  • MCP search/grep realignment — rename the existing search tool to grep, add a new ranked search tool that mirrors cmd-K.

    • Tool removed: search (the previous tool — fixed-string grep with frontmatter enrichment).
    • Tool added: grep — replaces the legacy search tool with identical behavior. Use it when you need every literal occurrence of a string across content.
    • Tool added: search — new ranked workspace retrieval. Wraps POST /api/search so results match the cmd-K palette (Orama-backed lexical + body BM25 + recency). Parameters: query, intent ('omnibar' | 'full_text', default 'full_text'), scopes, limit (default 20, max 100), cwd. Both new tools register MCP annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }.
    • Config key: the grep tool's result cap is mcp.tools.grep.maxResults (default 50).
    • Server-side instructions (buildInstructions) now surface the four-way read-tool routing (exec / read_document / search / grep).

    Migration:

    1. Rename any caller using mcp__open-knowledge__search for grep semantics to mcp__open-knowledge__grep.
    2. Switch to the new mcp__open-knowledge__search for relevance-driven retrieval (the same ranking the cmd-K palette uses).
    3. If your .ok/config.yml contains mcp.tools.search.maxResults, rename the key to mcp.tools.grep.maxResults. The legacy key is silently ignored (loose-object pass-through), so a custom value will not carry over automatically.

    Spec: specs/2026-05-05-mcp-search-grep-rename/SPEC.md. Background research: reports/file-search-tool-mcp-exposure/REPORT.md.

  • feat(seed): multi-scaffold templates picker — five Initialize packs + per-folder extra templates + opt-out personal-templates pack

    Generalizes the single-scaffold Initialize LLM brain seed into a five-card picker in SeedDialog:

    • Knowledge base (existing, renamed from "LLM brain") — Karpathy three-layer source-grounded KB (external-sources/research/articles/).
    • Software lifecycleproposals/ + decisions/ + specs/ + postmortems/ + guides/. Industry-current naming per dotnet/designs / withastro/roadmap / Google Cloud ADR doc / github/spec-kit. specs/ ships spec + spec-plan + spec-tasks templates so the github/spec-kit per-spec triple shape is one click each. guides/ ships guide + onboarding-guide + runbook.
    • Plain notesnotes/ + daily/. Escape hatch for casual users.
    • Worldbuildingcharacters/ + settings/ + themes/ + factions/ + lore/ (Fiction variant). factions/ ships faction + political-faction + religion; lore/ ships lore + magic-system + historical-event.
    • Writing pipelineideas/ + drafts/ + published/. Lean three-stage; book pipeline deferred to a future pack.

    New public surface:

    • STARTER_PACKS: Record<PackId, StarterPack> registry (server). PackId is a closed enum.
    • StarterFolder.extraTemplates?: readonly string[] — additional templates installed alongside the starter in <folder>/.ok/templates/. Picker pre-selects the starter; extras available via New from template….
    • resolvePack / coercePackId / isKnownPackId / listStarterPacks helpers — single source of truth for HTTP, IPC, and CLI.
    • GET /api/seed/packs + okDesktop.seed.listPacks() — enumerate available packs (returns id, name, description, defaultSubfolder?, folders[] with per-folder summaries). Schemas: SeedListPacksSuccessSchema + SeedPackInfoSchema + SeedPackFolderInfoSchema.
    • planSeed / applySeed accept packId + includePersonalTemplates options. ScaffoldPlan carries optional personalTemplates preview; ApplyResult carries optional personalTemplates write summary.
    • HTTP / IPC / CLI all reject explicit-but-unknown packId with a structured error (trust-boundary symmetry).
    • /api/seed/apply added to MUTATING_ROUTES for DNS-rebinding defense-in-depth.
    • CLI ok seed gains --pack <id> + --list-packs + --personal-templates flags. Default packId stays knowledge-base for back-compat.

    Personal-templates pack (opt-out, default checked):

    • Seven universal templates land at user scope (~/.ok/templates/): daily-journal, meeting-notes, weekly-review, reading-log-entry, gym-log, recipe, travel-trip.
    • Idempotent at file level — never overwrites existing user-edited templates.
    • OK_USER_HOME env override is gated on NODE_ENV === 'test' so a stray env var can't misdirect production writes.

    Back-compat preserved:

    • Legacy STARTER_FOLDERS, STARTER_TEMPLATES, LOG_MD_TEMPLATE exports are @deprecated aliases pointing at STARTER_PACKS['knowledge-base'].
    • Default packId = 'knowledge-base' everywhere; callers that don't pass packId get the same behavior as before.
    • okDesktop.seed.plan() / okDesktop.seed.apply(plan) still callable without options.

    Empty-state probe simplified: previously checked only the Knowledge base pack, leaving projects seeded with other packs stuck showing the CTA forever. Now keys off documentCount === 0 — works for all packs.

    Bridge contract: OkSeedPlanOptions / OkSeedApplyOptions / OkSeedPackInfo / OkSeedListPacksResult added; three copies (core, app, desktop) kept in lockstep; drift catcher passes. OkScaffoldPlan gains optional personalTemplates; OkSeedApplyResult gains optional personalTemplates.

    Spec: specs/2026-05-08-multi-scaffold-templates-experience/SPEC.md (D1–D14 LOCKED). Research: reports/llm-brain-scaffolds-research/{REPORT,PROPOSAL,USE-CASES,PACK-REFINEMENT-2026}.md.

  • feat(rename): rename .open-knowledge/.ok/ everywhere (per-project, ~/.ok/, and the shadow repo at .git/ok/); replace content.include and content.exclude in config.yml with a .okignore file at the project root using gitignore syntax.

    Hard cutover for the directory rename (pre-release license to break — no auto-rename of .open-knowledge/.ok/):

    • The per-project state directory is now .ok/ instead of .open-knowledge/. Same for the user-global directory (~/.ok/) and the shadow repo (.git/ok/).
    • content.include and content.exclude are removed from ConfigSchema. If they appear in your config.yml, OK rejects the file with a source-located error directing you to move the patterns into a project-root .okignore.
    • .okignore uses gitignore syntax (parsed by the ignore npm library) and is evaluated alongside .gitignore in a single ignore-lib instance — cross-source ! overrides work (e.g. !secret.md in .okignore re-includes a file .gitignore excluded). Nested .okignore files at any folder depth are honored.
    • ok init now scaffolds both .ok/ and a project-root .okignore (commented header, no example excludes).
    • The Settings pane's Content section is removed; content.dir becomes YAML-only (default . covers the common case).
    • The MCP set_config allowlist drops to 3 paths: folders[], mcp.tools.search.maxResults, mcp.tools.read_document.historyDepth.

    For pre-existing OK projects:

    1. Rename your .open-knowledge/ directory to .ok/ (manual — no auto-rename shim).
    2. Lift any content.include / content.exclude patterns into a project-root .okignore (recreate exclusion patterns; remember the .okignore mental model is exclude-only).
    3. Run ok config migrate to strip the obsolete content.{include,exclude} keys from config.yml. The codemod is idempotent and also clears the other deprecated keys (sync.*, persistence.{debounceMs,maxDebounceMs}, server.port).
    4. Delete the orphan .git/open-knowledge/ shadow repo.
    5. Re-authenticate.

    The dogfood team will see one re-prompt for stored credentials (~/.ok/auth.yml), first-launch consent (~/.ok/mcp-status.json), and the skill-installed marker on first run after merging — expected behavior for the hard cutover on the user-home rename.

    The protected identifiers (MCP server name open-knowledge, writer-ID openknowledge-service, bundle ID com.inkeep.open-knowledge, URL scheme openknowledge://, package names) are unchanged.

  • Remove the preview.baseUrl config field and the OPEN_KNOWLEDGE_PREVIEW_BASE_URL environment variable. The deployed-wiki use case isn't supported in this greenfield; the previewUrl MCP resolver now collapses to electron-protocol → lock and emits URLs that point at the running UI process only. Migration: start a local UI with ok ui (or the desktop app) — preview URLs resolve from the ui.lock it writes. Stale preview: { baseUrl: ... } keys in .ok/config.yml are accepted silently by looseObject but ignored; the CLI loader now emits a deprecation warn pointing at ok ui.

  • feat(rename): consolidate /api/rename and /api/rename-path into a single polymorphic endpoint, lift the link-rewrite spine, extend the recovery journal to v2, and add principal-attribution fallback for rename + rollback handlers.

    Breaking changePOST /api/rename is removed. Clients (UI, MCP, scripts) must use POST /api/rename-path with the polymorphic body shape:

    { "kind": "file" | "folder", "fromPath": "<path>", "toPath": "<path>", ...identity, "summary": "..." }
    

    The MCP rename_document tool's outward API is unchanged (parameters, response shape, identity passthrough are all functionally equivalent) — only the internal HTTP target changed.

    New capabilities:

    • Folder rename now rewrites all inbound wiki-links and supported markdown links across linking docs (was previously a CONFIRMED gap — folder rename moved files but left link text untouched).
    • Folder rename is now crash-safe — process kill mid-batch is recoverable on next startup with no partial state.
    • File rename via the consolidated endpoint now updates the in-memory backlink index (was missing on the file branch of /api/rename-path).
    • UI-driven rename and rollback now attribute to the server-loaded principal (principal-<uuid>) when no agent identity is supplied. Body-supplied principalId is silently ignored — server's getPrincipal() is the only source of principal identity.

    The recovery journal schema is bumped from v1 (single source/destination) to v2 (multi-doc affectedDocs[]). The v1 parser is preserved alongside v2 — legacy v1 journals on disk at startup still recover correctly.

    Side-effect docs (backlink-rewrite cascades) remain anonymous for both agent-driven and principal-driven renames — only the renamed doc itself is attributed to the actor.

    Other behavior changes worth noting:

    • POST /api/rollback 500 responses no longer echo the underlying error message; clients now see a generic Failed to roll back document string. Eliminates a path / internal-state leak channel for an unauthenticated body endpoint.
    • ok init adds state.json to the bootstrapped .ok/.gitignore alongside sync-state.json and principal.json — covers newly-added local-runtime state at <contentDir>/.ok/state.json (also emitted as a separate init-gitignore-consolidation changeset for the broader scaffold rework, but the state.json line specifically lands here).
  • feat(server): boot in linked git worktrees + fail-fast on missing .ok/config.yml.

    Open Knowledge now boots correctly inside linked git worktrees (git worktree add <path> <ref>).

    • Main worktree — shadow at <projectRoot>/.git/ok/
    • Linked worktree — shadow at <repo>/.git/worktrees/<name>/ok/

    ok init works identically in main and linked worktrees with no special handling — the existing writeIfMissing safeguard in the init scaffold prevents clobbering committed configs that git checkout materialized.

    Fail-fast on missing config. bootServer now runs a pre-listen check for <contentDir>/.ok/config.yml. When absent (whether .ok/ is missing entirely or just config.yml), boot rejects with the new typed MissingOkConfigError (exported from @inkeep/open-knowledge-server):

    Open Knowledge config not found at .ok/config.yml. Run ok init to scaffold OK in this directory.

    When only .ok/.gitignore is missing (config present), boot emits a one-time stderr warning recommending ok init and proceeds — per-machine state files in .ok/ may show up as untracked changes until the recommended ignore entries are merged in.

    Desktop GUI: silent scaffold on first open. When you open a folder that doesn't yet have .ok/config.yml, the Open Knowledge desktop app now scaffolds it silently and opens the editor — same idempotent flow as ok init. The user's Navigator / recents / deep-link / drag-drop gesture is treated as consent for the scaffold. writeIfMissing ensures committed configs (materialized by git checkout) are never clobbered. Explicit-consent dialog UX (Yes/No modal before scaffold) is a follow-up.

    Unusable .git paths surface as typed errors with the right recovery hint. Two distinct failure modes, two distinct typed errors (both exported from @inkeep/open-knowledge-server and @inkeep/open-knowledge-core/shadow-repo-layout):

    • MalformedGitPointerError<projectRoot>/.git is a file but its gitdir: target is unreadable, has no gitdir: line, or references a missing admin directory. Typical cause: a partial git worktree remove race or rm -rf of the admin directory without git worktree prune. Recovery hint names git worktree prune.
    • GitDirAccessErrorstatSync on <projectRoot>/.git failed for a reason other than ENOENT (typically EACCES / EPERM on a misconfigured ACL or stale mount). The shape of .git is undetermined. Recovery hint names filesystem permissions and mount state.

    Both errors carry the original errno exception as cause so log consumers can branch on EACCES vs parse-failure without parsing strings.

    Observability. The boot path emits an OTel ok.boot span carrying ok.worktree.kind (main | linked) and a normalized ok.worktree.gitdir so failure rates can be sliced by worktree shape.

    Two worktrees on different branches running ok start concurrently are fully independent: separate shadows, separate .ok/server.lock files, separate ports, separate MCP endpoints. Per-worktree isolation is the intended model — writer history, upstream imports, and checkpoints stay scoped to the worktree they were authored in.

Patch Changes

  • Fix Cannot find package '@inquirer/checkbox' crash on every CLI invocation in the packaged desktop .app. tsdown.config.ts alwaysBundle listed @inquirer/password but missed @inquirer/checkboxcli.ts eagerly imports initCommand, which top-level imports @inquirer/checkbox, so every subcommand (auth status, auth repos, clone, …) crashed before producing any output. Dev mode resolved the dep from the workspace node_modules and hid the bug. The packaged .app ships no node_modules next to dist/cli.mjs, so the bare specifier failed at module-load time. Added the matching pattern to alwaysBundle so the dep is force-inlined into dist/cli.mjs.

    User-visible symptom: clicking "Clone from GitHub" in the published Open Knowledge desktop app surfaced auth status exited with code 1 because the renderer's IPC auth status probe spawned ok.sh auth status --json and got a Node module-not-found crash with no JSON output.

  • ok (no positional args) on macOS now launches the desktop Electron app when it's installed in /Applications/Open Knowledge.app (or ~/Applications/). Detection is interactive-only — SSH sessions, non-TTY stdout, and missing bundles all fall through to the existing ok start behavior (server + browser). On Linux, Windows, and any other platform without the macOS desktop bundle, ok runs start exactly as before.

    Two new escape hatches:

    • ok start --mode=browser|app is an explicit modal selector. --mode=browser (or omitted) keeps today's server-only behavior bit-for-bit; --mode=app forces the desktop hand-off and errors with a clear message if the bundle isn't found. --open continues to mean "open a browser tab against the local server" and is mutually exclusive with --mode=app.
    • OK_FORCE_BROWSER=1 env var disables the dispatch entirely for the current process. Use this in scripts/CI/MCP wrappers that need the server even on a workstation with the desktop installed. OK_FORCE_DESKTOP=1 does the inverse (force-launch desktop, bypassing the headless gate).

    Non-breaking — ok start (no flag) is unchanged. The dispatch only fires when ok is invoked with no subcommand. All existing subcommands (mcp, init, status, stop, etc.) keep current behavior. See specs/2026-05-04-cli-default-desktop-launch/SPEC.md.

  • Fix TS2559: Type 'ProcessEnv' has no properties in common with type '{ HOST?: string | undefined; }' at packages/cli/src/commands/start.ts:540. The resolveHost helper's env parameter type was a TypeScript "weak type" (only optional declared properties) that triggers TS2559's weak-type detection when called with process.envNodeJS.ProcessEnv declares no HOST property; its index signature alone doesn't satisfy the rule. Widened the parameter to { HOST?: string | undefined; [key: string]: string | undefined } so production callers passing process.env and existing unit-test literals ({}, { HOST: '...' }) all type-check. Function body and runtime behavior unchanged. Restores bun run typecheck to green at the monorepo level (canonical CI gate; introduced in #492 and slipped through that PR's typecheck job).

  • Fix the "Install for Claude Chat & Cowork" handoff in the packaged Electron app, which previously failed with build-failed: Could not resolve @inkeep/open-knowledge CLI version. The cause was a heuristic two-path filesystem probe in build-skill-zip that didn't match the node_modules/@inkeep/open-knowledge/ layout shipped inside the .app — and the version it was looking up was not actually used in this code path.

    build-skill-zip is now a pure ZIP builder: BuildSkillZipOptions.skipVersionCheck and BuildSkillZipResult.cliVersion are removed in favor of a presence-based BuildSkillZipOptions.expectedSkillVersion?: string (pass it from release builds that need to assert SKILL ↔ CLI alignment, omit otherwise). BuildAndOpenSkillResult.cliVersion is also removed — skillVersion is the canonical artifact version.

    A new resolvePackageVersion(packageName, fromUrl) utility is exported from @inkeep/open-knowledge-server for any caller that needs to look up an installed package's version at runtime. It uses Node's module resolver plus a directory walk, which is robust across workspace symlinks, hoisted node_modules, pnpm .pnpm/-flat, and asar-packed Electron.

    The ok install-skill post-build message now reads Skill v<x.y.z> (presence-checked) instead of CLI v<x.y.z>.

  • fix(dev): three blockers in the fresh-checkout bun run dev boot path

    • Vite config bundling: vite.config.ts (via hocuspocus-plugin.ts) imports @inkeep/open-knowledge-core and -server. Vite's nodeResolveWithVite uses conditions ['node', module-sync] only, so it fell through to defaultdist/index.mjs and threw Failed to resolve entry for package on any checkout where the workspace dists weren't built. Add a predev hook in packages/app/package.json that builds both deps (turbo handles caching) so default → dist always resolves. A node → src/index.ts exports condition would also work for Vite but breaks the packaged Electron main process (which runs Node 22 without --conditions=development and would resolve workspace deps to TypeScript source → ERR_UNKNOWN_FILE_EXTENSION); predev side-steps that conflict.
    • Blank page on first render (asset-click-dispatch.e2e.ts P9._ regressions): the dev plugin's asset-serve middleware 404 guard intercepted Vite-internal paths (/favicon.svg, /src/foo.png?import, /@vite/_). Keep the synchronous registration (load-bearing — must run BEFORE Vite's spaFallbackMiddleware so unknown asset URLs return 404, not the SPA shell — confirmed by P9.22) and add an explicit Vite-internal-path bypass list. A post-hook approach (return () => server.middlewares.use(...)) would defer registration AFTER spaFallbackMiddleware, breaking the asset 404 guard and routing PDF/m4v asset URLs through the SPA fallback.
    • Backlinks ENOENT on .mdx files: BacklinkIndex.rebuildFromDisk looked up file extensions via getDocExtension, which reads from the file-watcher's extension registry. Boot order calls rebuildFromDisk BEFORE startWatcher, so the registry is empty and every docName defaults to .md — ENOENT on every .mdx. Align with the sibling reconcileWithDisk path: use walkForPaths to thread the observed on-disk path through to the read.
  • Docs accuracy pass — surgical fixes for drift between docs/content/ and the shipped CLI/MCP/integration code.

    reference/cli.mdx:

    • Fixed broken markdown table in the start flags section (missing | --- | --- | separator row meant the table didn't render).
    • Removed duplicate --open row.
    • Added the --mode <browser|app> flag on start (escape hatch for the desktop-dispatch path).
    • Added the ps command to the Lifecycle section (lists running OK servers).

    integrations/codex.mdx: clarified that init also installs the Open Knowledge skill globally (via npx skills add) so Codex can discover the MCP tools — previously the doc said only "registers the MCP server."

    integrations/claude-desktop.mdx: added a date-stamped callout flagging the Cowork-bug workaround section as upstream-bug-driven, so it gets revisited when Anthropic ships a fix.

    get-started/quickstart.mdx: corrected the post-init description — init registers MCP for all 6 supported editors (Claude Code, Cursor, Codex, Claude Desktop, VS Code, Windsurf) and installs a skill for Claude Code, Cursor, and Codex. Previous wording omitted Codex from the skill list and named only Claude Code + Cursor for the "every supported editor" claim.

    reference/configuration.mdx and reference/mcp.mdx were re-verified during the pass and need no changes.

  • fix(open-knowledge/server): serve doc-referenced assets that live in a dedicated assets/ directory

    Images (and other media) referenced from markdown via a doc-relative path into a dedicated assets tree — ![alt](../../assets/images/foo.png) — failed to load with a 404. In the Electron app the symptom was a console error like Failed to load resource: 404 (Not Found) http://localhost:<port>/assets/images/characters/aang.png; the same gap affected ok ui and bun run dev.

    Root cause: createAssetServeMiddleware gated serving on contentFilter.isExcluded(), which applies the D11 sibling-asset heuristic — an asset path is admitted only if its directory also contains an included .md document. That heuristic is a file-watcher index-walk concern (don't index loose binaries that nothing references); it is wrong for the serve path, where the request is an explicit reference. Assets organized the standard way — under assets/ / images/ / attachments/ with no sibling .md — were therefore treated as excluded and 404'd, while an otherwise-identical copy sitting next to a .md file served fine.

    Fix: the serve middleware now uses contentFilter.isPathIgnored() — the security-boundary-only check (.gitignore / .okignore patterns, BUILTIN_SKIP_DIRS like node_modules/ / dist/ / .git/, reserved system-doc names) without the sibling-asset heuristic — plus an explicit "only .md/.mdx and known content-asset extensions stream a contentDir file" gate that restores the job isExcluded's default-exclude branch was doing (so .html / .exe / extensionless paths still fall through). This matches what handleAsset / collectReferencedAssets in api-extension.ts already do for the same reason. User-configured exclusions still apply, so an asset matched by .gitignore/.okignore is still refused; the /node_modules/... / /dist/... Vite-internal fall-through is unchanged.

    Side effects: (1) missing asset-extension and executable-blocklist URLs now consistently hit the fail-closed 404 guard instead of leaking the editor HTML via the SPA fallback in directories without a sibling .md (an improvement — that leak is the very anti-pattern the 404 guard exists to prevent). (2) .svg files in dedicated assets directories are now reachable through this serve path (previously D11-blocked); since .svg is in INLINE_RENDERABLE_EXTENSIONS it serves with Content-Disposition: inline, so the middleware now also sets the same Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline' header handleAsset applies — a top-level GET of a contentDir SVG can no longer execute embedded <script>. (Aligning the two serve paths' SVG handling further — e.g. a shared helper — is left for a follow-up.)

    This completes the Electron asset-origin work from the prior fix (which routed the request to the utility server's apiOrigin but left the serve-side admission gate as the remaining gap).

  • Fix CLI npm publish: set repository.url to inkeep/open-knowledge-legacy (matches OIDC build context, satisfies sigstore provenance) and drop leading "./" from bin paths so npm publish stops auto-removing them.

  • Drop repository field from CLI package.json so npm sigstore provenance no longer rejects publish with E422. The previous URL pointed at the future inkeep/open-knowledge repo while builds run from inkeep/open-knowledge-legacy; with the field removed, npm infers the repo from the OIDC build context and provenance matches by construction. No revert needed after the planned repo rename.

  • chore(init): consolidate Open Knowledge ignore rules into .open-knowledge/.gitignore. The scaffold now writes cache/, server.lock, ui.lock, sync-state.json, principal.json, and last-spawn-error.log, and ok init merges missing entries into pre-existing files (existing user lines preserved). ok clone no longer mutates the cloned repo's tracked .gitignore; per-clone protection lives in .git/info/exclude (local-only, never committed) instead.

  • Fix agent-presence icon flickering between MCP tool calls. The ok mcp shim now forwards its keepalive WS connectionId via x-ok-connection-id, and the MCP HTTP session adopts that id as identity.connectionId instead of minting a fresh UUID. With both surfaces sharing one id, the 3s bumpPresenceTs heartbeat keeps the broadcaster entry fresh between writes, and on-close clearPresence finds the right key. The icon now stays visible for the lifetime of the keepalive WS instead of disappearing 5s after each tool call. Header values are validated through validateAgentId. Invalid values fall back to randomUUID() so non-shim MCP clients still get a working session.

  • Refuse to run ok mcp in directories that haven't been ok init'd. Closes a regression where invoking npx @inkeep/open-knowledge mcp from a non-OK directory eagerly scaffolded .ok/, .openknowledge/ (legacy bare shadow), and .gitignore as a side effect of MCP startup — observed when the OK MCP was registered globally (e.g. ~/.claude.json top-level mcpServers) and the user opened claude in any directory.

    ok mcp now exits cleanly at startup if <cwd>/.ok/ doesn't exist, before registering tools or discovering servers. --port bypasses the gate (explicit user intent). The skill description already says "Skip if no .ok/ — not an Open Knowledge project"; this enforces the same contract at the server level.

  • refactor(mcp): route ok mcp through the shared HTTP server. The CLI now uses a thin stdio-to-HTTP shim, removes legacy --pin setup, and relies on the running ok start server for MCP tool execution.

  • chore(layout): move per-machine runtime files from .ok/<name> to .ok/local/<name>.

    <contentDir>/.ok/ now separates committed config (config.yml, frontmatter.yml, templates/) from per-machine runtime state, which moves under .ok/local/: server.lock, ui.lock, state.json, principal.json, sync-state.json, conflicts.json, last-spawn-error.log, managed-rename.json, cache/<branch>/backlinks.json, tmp/upload-<uuid>. OK_GITIGNORE_CONTENT is now a single line (local/) — adding a new runtime file no longer requires a coordinated .gitignore edit, and .ok/local/ documents the contract by name. All in-process consumers resolve the path through the new getLocalDir(contentDir) helper exported from @inkeep/open-knowledge-core.

    If you're an OK developer pulling this branch and your project has runtime files at .ok/ root, run rm -rf .ok/ then bun run --filter=@inkeep/open-knowledge ok init. The boot logs a one-shot warning if it finds legacy files at .ok/ root; the new code reads/writes only under .ok/local/, so legacy files sit inert until you clean them up. There is no auto-migration — Open Knowledge has not shipped to real users yet, so the developer-only re-init path is the right tradeoff (per spec D2 LOCKED).

    Spec: specs/2026-05-05-ok-local-folder-pattern/SPEC.md.

  • ok ps and ok stop now surface and act on hostname-drifted servers correctly, label desktop-spawned servers as desktop, and call out orphaned UI processes:

    • Foreign-host visible by default in ok ps. macOS hostname drift (BonjourName ↔ FQDN across DHCP/VPN/sleep) flips lock entries to foreign-host; the default filter now keeps them alongside alive so same-machine servers don't vanish from the table. --all is now narrowed to "include stale (dead-pid) entries"; the help text and docs/content/reference/cli.mdx are updated to match.
    • Desktop label. Desktop and CLI both write kind: 'interactive', so the lock alone can't tell them apart. ok ps classifies via the live process command — Electron utility processes carry --type=utility AND --utility-sub-type=node.mojom.NodeService (the latter is what utilityProcess.fork specifically registers, so VS Code / Slack / Discord helpers don't false-match). Renders as desktop (blue), overriding running / foreign. Falls back when the command lookup is unavailable.
    • ok stop accepts hostname-drifted entries. buildStopPlan, findLockDirByNumber, and the stop all filter now also stop foreign-host entries when the PID is locally live (verified via the canonical isProcessAlive probe from @inkeep/open-knowledge-server, not a duplicate). Truly cross-host locks fail the liveness probe and are left alone.
    • Hostname-drifted dead locks classify as dead-pid. inspectLock previously short-circuited to foreign-host on hostname mismatch before the liveness probe, leaving stale drift locks stuck visible forever. Liveness now runs first; foreign-host means specifically "different hostname AND PID exists locally" (the genuine drift case). Stale drift locks now fall out of ok ps by default and become eligible for ok clean pruning. The next ok start / desktop launch in that dir also auto-replaces them via the existing acquireProcessLock stale-replacement path, so manual cleanup is rarely needed.
    • ui-orphan status. When the server lock is dead-pid but the UI sidekick is alive (or foreign-host-with-live-PID), ok ps now renders the row as ui-orphan (magenta) instead of hiding the orphan behind a stale label or blanking the UI port. Surfaces the lifecycle hole where ok ui survives an ungraceful server crash; the underlying self-shutdown / boot-time-reap fix is tracked separately.
    • Foreign-host UI port shown in PORTS. Post the inspectLock reorder, foreign-host UIs have a live local PID listening on that port — show it. Hiding it (the prior behavior) made orphan UIs invisible because hostname drift forces them into foreign-host.

    JSON output of ok ps gains an isDesktop: boolean field on each entry for tooling consumers.

  • Remove the dead "Folders" section from the Settings pane.

    The folders[] cascade was retired in spec 2026-05-01-folder-level-metadata-and-templates (FR8 / D19) — folder defaults moved into nested <folder>/.ok/frontmatter.yml files, edited via the set_folder_rule MCP tool. The FoldersSection.tsx Settings UI and its applyFolderRulesUpsert write path were left behind in that change; entries typed into the section persisted into config.yml via z.looseObject passthrough but no production code read them.

    This removes:

    • FoldersSection.tsx + its smoke test
    • apply-folder-rules-upsert.ts (the dead UI was the only caller; set_folder_rule uses its own applyNestedFolderRulesUpsert)
    • folder-rules.ts legacy resolver (zero production callers)
    • the applyFolderRulesUpsert re-export from @inkeep/open-knowledge-core/server
    • stale "config.yml folders:" references in enrichment.ts, exec.ts, and seed.ts JSDoc/CLI descriptions

    FolderRuleSchema and FolderFrontmatterSchema exports remain in place — set_folder_rule reuses them.

  • fix(rename): preserve file content when renaming via the sidebar.

    Renaming a file via the sidebar inline-rename (right-click → Rename, or F2) was erasing the file content on disk: the editor showed an empty placeholder under the new name, and the original content survived only in the renaming tab's IndexedDB cache (which is why renaming back made it "reappear"). Cold reloads or other tabs/clients saw the renamed file as empty.

    Two layered fixes:

    • FileTree ignores selection-change events whose docName isn't yet in the local documents list. @pierre/trees fires onSelectionChange synchronously when an inline rename commits — before the rename API has written the file at the new path — and the resulting premature navigation was opening a server-side Y.Doc against a missing file, which the persistence layer subsequently flushed back to disk as 0 bytes.
    • persistence.onStoreDocument refuses to materialize a 0-byte file when the Y.Doc was never confirmed to exist on disk AND the serialized markdown is empty. This blocks accidental orphan files from any code path that opens a Y.Doc for a non-existent docName (browser races, /api/document?docName=<missing>, MCP queries on deleted docs, future callers). Legitimate first-write paths (/api/create-page, agent writes via /api/agent-write-md) are unaffected.
  • Production hardening + test helper disambiguation for the sidebar resize race (#576). Retroactive changeset — this PR landed without one, this triggers the next VP cycle so the changes flow through the beta cadence.

  • The collab server now caps the number of distinct agent sessions per Document at 256 and refuses additional agent-write / agent-write-md / agent-patch requests with 503 too-many-agent-sessions once the cap is reached. Without the cap, an unauthenticated peer (or a runaway agent loop) could mint unlimited fresh agentIds, each spawning a new server-side session with its own bounded undo manager and presence entry, exhausting memory and overwhelming the awareness map. The 256 ceiling is well above any legitimate per-document agent fleet and triggers a AgentSessionCapacityError log line including docName and agentId so operators can identify the offending peer.

  • Bound desktop auth-query IPC subprocess fan-out. The authStatus/authRepos IPC handlers in packages/desktop/src/main/ipc/local-op.ts now coalesce concurrent requests for the same host and enforce a per-handler concurrency cap; overflow is reported back to the renderer as a structured response rather than spawning another open-knowledge auth … child process. Closes the unbounded-spawn DoS path where a compromised renderer could exhaust the process table by tight-looping window.okDesktop.localOp.authStatus() / authRepos().

  • The backlink index's wiki-link extractor now linearly skips backtick spans rather than falling into a quadratic regex on long unclosed backtick runs. A doc containing thousands of consecutive backticks (no closing pair) previously sent the parser into pathological backtracking and pinned a CPU core for seconds-to-minutes per re-index. The new path scans backtick boundaries with a single pass and short-circuits when no closing pair is found, keeping per-doc parse time linear in document size regardless of backtick density.

  • The chokidar fallback file-watcher now refuses to forward events whose path resolves through a symlink to a target outside the project's contentDir. Previously a symlink planted in the watched tree (via git clone, an extracted archive, or a co-tenant write) could surface as an add/change event whose readFile would dereference the symlink and pull in /etc/passwd or another sensitive target. The new eventEscapesContentDir helper lstats the event path, resolves the symlink with realpathSync, and refuses unless the canonical target is within contentDir. The lstat and realpath catch blocks distinguish ENOENT (delete-race, pass through) and ELOOP (broken symlink, drop quietly) from other errno codes (EPERM / EACCES / EIO — drop and log) so a hostile symlink with restricted intermediate-path permissions can't bypass the gate by triggering an unexpected error.

  • ok init now refuses to overwrite project-scope config paths (.mcp.json, .cursor/mcp.json, .claude/skills/open-knowledge) when any segment of the destination is a symlink whose realpath escapes the project cwd. A malicious repo could previously plant a symlink that pointed git cloned files at /etc/passwd or another sensitive location, then have ok init follow it and overwrite the target. The new assertProjectPathSafe guard rejects leaf-symlink, ancestor-symlink, and parent-traversal escapes while still allowing in-cwd symlinks. Editor-ID lookups also switched from bracket access to Object.hasOwn so prototype-chain pollution can't surface forged editor labels.

  • Treat lock-file PIDs as untrusted input across every signal-delivery and liveness-probe site. The desktop's collision-recovery path in packages/desktop/src/main/window-manager.ts previously read the holder PID straight from <contentDir>/.ok/local/server.lock (user-writable JSON) and called process.kill(pid, 'SIGTERM') after only a typeof pid === 'number' shape check, allowing crafted lock entries with negative PIDs (process-group signaling on Unix), 0, 1 (init), NaN, or values above 0x7fffffff to direct signals at unintended processes.

    The fix layers three defenses through the new shared isValidLockPid validator (packages/server/src/process-alive.ts):

    • Parse-time validation. process-lock.ts, lock-state.ts (CLI ok stop / ok ps), shadow-lock.ts, and the desktop window manager now all reject hostile PIDs as corrupt/stale before isProcessAlive or process.kill ever sees them.
    • TOCTOU re-verification. WindowManager.verifyHolderStillOwnsLock re-reads the lock right before signaling and confirms the holder PID hasn't changed since the collision report.
    • Self-PID guard. The auto-kill path refuses to send a signal when existingLock.pid === process.pid, blocking a self-SIGTERM if PID-recycling ever produces a collision metadata that names the desktop itself.
  • The forward-links endpoint now passes every wiki-link target through the project's ContentFilter before reading the target document's frontmatter. Previously a wiki-link to a path excluded from the project (via .gitignore or .okignore) would still resolve, surface the excluded doc's title in the response, and effectively leak the file's existence + title to anyone with read access to the linking doc. The exclusion gate is now applied per-target so excluded docs render as plain wikilinks (no resolved title) without revealing whether they exist.

  • The /api/history/:docName endpoint now rejects docName values that contain .., leading slashes, drive letters, or otherwise resolve outside the project's content root. Previously the docName was concatenated into a safeContentPath lookup without an isSafeDocName gate; a request like GET /api/history/..%2F..%2Fetc%2Fpasswd could traverse out of contentDir and surface git history (or, on systems where the timeline-query path read file contents, the file itself). The handler now returns 400 invalid-docName when the input fails isSafeDocName, matching the contract used by every other docName-keyed endpoint.

  • The local-op path validators (assertLocalOpPathSafe and friends) now resolve every path through realpathSync and refuse paths whose canonical target lies outside the project's content root. Previously a .local/op payload with a path like subdir/symlink-to-etc/passwd only ran a lexical containment check; if subdir/symlink-to-etc was a symlink pointing outside the project, the local-op handler would happily read or write the target. Symlink loops surface as ELOOP and are also rejected. The path-safety helpers are now shared with the seed-walk equivalents so the two security boundaries stay in sync.

  • The ok ui static-server /api reverse proxy now enforces the same loopback-only and Host-header-allowlist gates that the upstream server applies before forwarding any request, instead of trusting that requests reaching the proxy are loopback-bound. Without the gate, a non-loopback peer could reach the upstream API by talking to the static server's port directly. The gate refuses with 403 {ok:false, error} (and X-Content-Type-Options: nosniff to match the upstream's response posture) when either condition fails, before any proxy handshake.

  • The reconciliation engine's LCS-based diff path now caps the input length and falls back to a whole-doc replace once the cap is exceeded, instead of allocating an O(n*m) table over arbitrary user input. A multi-megabyte agent write previously sent the LCS path into a memory allocation proportional to prevLength * newLength, exhausting the Node heap and crashing the server. The cap is sized so that legitimate edits (well below 1 MiB per side) continue through the precise LCS diff while pathological inputs get the coarser whole-doc replace path; either way the persistence layer ends up with the user's bytes.

  • POST /api/seed/plan and POST /api/seed/apply now refuse seed entries whose resolved target path lies outside the project directory, either via lexical ..-traversal in the entry's relative path or via any symlink in the target's resolved chain whose realpath escapes the project root. The new assertNoSymlinkEscape walks each existing ancestor of the target, calls realpathSync, and rejects paths that resolve outside realpath(projectDir) with a SeedRootDirError. Symlink loops surface as ELOOP and are also rejected. Without these guards a seed plan could write to /etc/, the user's home directory, or any other writable location reachable through a symlink the project happens to ship.

  • The sourceLiteral mark's mdast-conversion path now refuses to persist a sourceRaw attribute that doesn't match the visible PM text it's attached to. Previously a caller that could mutate the document (agent API, synced collaborator, crafted clipboard paste) could store one byte sequence as visible text and persist an arbitrarily different sequence on save — a hidden-content injection vector where the editor displays "Hello" but the on-disk file or downstream LLM transcript contains a prompt-injection / commit-message-injection / structural-markdown payload. isValidSourceLiteralRaw gates the single-child path; the multi-child / non-text fallback now drops sourceRaw entirely (rather than re-using the unvalidated raw as both the synthetic value and the synthetic sourceRaw, which made the downstream defense-in-depth gate vacuous). sourceLiteral declares excludes: '' so it can co-occur with strong / emphasis / link / etc., which is exactly the multi-child path; closing this gap completes the threat-model coverage.

  • Agent and contributor summaries are now stripped of newlines, control characters, and other commit-message-meaningful bytes before they're concatenated into shadow-repo commit messages. Without the strip, an agent or actor could inject \n\nSigned-off-by: someone-else <fake@example.com> (or any git trailer / multi-line block) into the commit body by embedding the bytes in a summary field, producing forged author attribution or trailer-driven side effects in downstream tooling. normalizeSummary already capped length and rejected non-strings; the new step also strips line breaks and control bytes from base + per-element summaries before they reach git commit -m.

  • The collab server now releases empty Y.Docs that were never confirmed to back an on-disk file once their last connection drops. Without the carve-out, a local peer (browser tab, MCP agent, DNS-rebound origin) could connect to an arbitrary stream of unique non-existent docNames over the loopback /collab WS and grow hocuspocus.documents without bound — each allocating a Y.Doc + persistence-store handle that lived until process shutdown. The carve-out only releases phantom docs (no getReconciledBase and empty fragment + empty Y.Text); file-backed and content-bearing docs continue to follow the resident-for-lifetime rule the cache-epoch-recovery defense relies on.

    Also drops a stray docName / agentId shorthand reference from three AgentSessionCapacityError log calls in api-extension.ts — a typecheck regression that's been on main since #449's merge dropped my earlier revert. Tests + typecheck pass with this PR's branch but main itself does not type-check until this lands.

  • The asset upload endpoint (POST /api/upload-asset) now refuses to write when any segment of the destination directory is a symbolic link, before performing the recursive mkdirSync(destDir, { recursive: true }) that previously could traverse a symlink and place uploaded files outside the project's content root. An attacker who could plant a symlink at <contentDir>/some/dir (via git clone of a malicious repo, file-watcher race, or another endpoint that creates uncanonicalized paths) could otherwise have uploads land in /etc/, ~/.ssh/, or any other writable location the server process could reach. The handler now lstats each ancestor segment and refuses with symlink-escape when it finds a symlink, regardless of whether the symlink's realpath would resolve back inside contentDir.

  • openBrowser (called by ok start --open / ok ui --open) now refuses URLs that contain shell-metacharacters (& | < > ^ ( ) ; $ \\ " ' [ ] plus newlines and spaces) and limits the scheme to http(s) before invoking cmd /c start "" on Windows. Previously a malicious or misconfigured --host / HOST env / server.host config value (e.g. localhost&calc) was concatenated into the launcher URL and could reach ShellExecute after cmd.exe parsed the metacharacters as additional commands. Validation runs on every platform but the practical RCE is Windows-only; macOS / Linux launchers (open / xdg-open) are not vulnerable to the same expansion. Validation tests now run in CI (the inner URL validation describe was previously nested inside the CI-skip-gated outer block; it's been hoisted to a top-level describe using _bunDescribe directly so regressions are caught by the automated pipeline).

0.4.0-beta.36

Patch Changes

  • fix(dev): three blockers in the fresh-checkout bun run dev boot path

    • Vite config bundling: vite.config.ts (via hocuspocus-plugin.ts) imports @inkeep/open-knowledge-core and -server. Vite's nodeResolveWithVite uses conditions ['node', module-sync] only, so it fell through to defaultdist/index.mjs and threw Failed to resolve entry for package on any checkout where the workspace dists weren't built. Add a predev hook in packages/app/package.json that builds both deps (turbo handles caching) so default → dist always resolves. A node → src/index.ts exports condition would also work for Vite but breaks the packaged Electron main process (which runs Node 22 without --conditions=development and would resolve workspace deps to TypeScript source → ERR_UNKNOWN_FILE_EXTENSION); predev side-steps that conflict.
    • Blank page on first render (asset-click-dispatch.e2e.ts P9._ regressions): the dev plugin's asset-serve middleware 404 guard intercepted Vite-internal paths (/favicon.svg, /src/foo.png?import, /@vite/_). Keep the synchronous registration (load-bearing — must run BEFORE Vite's spaFallbackMiddleware so unknown asset URLs return 404, not the SPA shell — confirmed by P9.22) and add an explicit Vite-internal-path bypass list. A post-hook approach (return () => server.middlewares.use(...)) would defer registration AFTER spaFallbackMiddleware, breaking the asset 404 guard and routing PDF/m4v asset URLs through the SPA fallback.
    • Backlinks ENOENT on .mdx files: BacklinkIndex.rebuildFromDisk looked up file extensions via getDocExtension, which reads from the file-watcher's extension registry. Boot order calls rebuildFromDisk BEFORE startWatcher, so the registry is empty and every docName defaults to .md — ENOENT on every .mdx. Align with the sibling reconcileWithDisk path: use walkForPaths to thread the observed on-disk path through to the read.

0.4.0-beta.35

0.4.0-beta.34

Minor Changes

  • Add the Gbrain starter pack — entity-grounded second-brain layout (typed dossiers for people/, companies/, meetings/, concepts/, originals/, media/) with compiled-truth-above / append-only-timeline-below body convention, per-folder templates, agent-readable folder frontmatter, and five root files (USER.md, SOUL.md, ACCESS_POLICY.md, HEARTBEAT.md, log.md). Selectable as Gbrain in the desktop app's pack picker or via ok seed --pack gbrain. Pattern inspired by Garry Tan's gbrain; OK ships the layout natively.

    Also ships two new docs-site guides under docs/content/workflows/:

    • karpathy-llm-wiki.mdx — source-grounded knowledge base via the existing knowledge-base starter pack, mapping 1:1 onto Karpathy's three-layer pattern (external-sources/research/articles/) with the ingest / research / consolidate MCP tools.
    • gbrain.mdx — entity-grounded second brain via the new gbrain starter pack, demonstrating the dead-links → triage → WYSIWYG loop and end-to-end agent-driven dossier maintenance using OK's MCP surface.

    Both guides land under a new Workflows section in the docs IA (docs/content/meta.json + docs/content/workflows/meta.json).

    Pinned-count test in starter.test.ts updated 5 → 6 to include the new pack.

0.4.0-beta.33

Minor Changes

  • Remove the preview.baseUrl config field and the OPEN_KNOWLEDGE_PREVIEW_BASE_URL environment variable. The deployed-wiki use case isn't supported in this greenfield; the previewUrl MCP resolver now collapses to electron-protocol → lock and emits URLs that point at the running UI process only. Migration: start a local UI with ok ui (or the desktop app) — preview URLs resolve from the ui.lock it writes. Stale preview: { baseUrl: ... } keys in .ok/config.yml are accepted silently by looseObject but ignored; the CLI loader now emits a deprecation warn pointing at ok ui.

0.4.0-beta.32

Patch Changes

  • fix(open-knowledge/server): serve doc-referenced assets that live in a dedicated assets/ directory

    Images (and other media) referenced from markdown via a doc-relative path into a dedicated assets tree — ![alt](../../assets/images/foo.png) — failed to load with a 404. In the Electron app the symptom was a console error like Failed to load resource: 404 (Not Found) http://localhost:<port>/assets/images/characters/aang.png; the same gap affected ok ui and bun run dev.

    Root cause: createAssetServeMiddleware gated serving on contentFilter.isExcluded(), which applies the D11 sibling-asset heuristic — an asset path is admitted only if its directory also contains an included .md document. That heuristic is a file-watcher index-walk concern (don't index loose binaries that nothing references); it is wrong for the serve path, where the request is an explicit reference. Assets organized the standard way — under assets/ / images/ / attachments/ with no sibling .md — were therefore treated as excluded and 404'd, while an otherwise-identical copy sitting next to a .md file served fine.

    Fix: the serve middleware now uses contentFilter.isPathIgnored() — the security-boundary-only check (.gitignore / .okignore patterns, BUILTIN_SKIP_DIRS like node_modules/ / dist/ / .git/, reserved system-doc names) without the sibling-asset heuristic — plus an explicit "only .md/.mdx and known content-asset extensions stream a contentDir file" gate that restores the job isExcluded's default-exclude branch was doing (so .html / .exe / extensionless paths still fall through). This matches what handleAsset / collectReferencedAssets in api-extension.ts already do for the same reason. User-configured exclusions still apply, so an asset matched by .gitignore/.okignore is still refused; the /node_modules/... / /dist/... Vite-internal fall-through is unchanged.

    Side effects: (1) missing asset-extension and executable-blocklist URLs now consistently hit the fail-closed 404 guard instead of leaking the editor HTML via the SPA fallback in directories without a sibling .md (an improvement — that leak is the very anti-pattern the 404 guard exists to prevent). (2) .svg files in dedicated assets directories are now reachable through this serve path (previously D11-blocked); since .svg is in INLINE_RENDERABLE_EXTENSIONS it serves with Content-Disposition: inline, so the middleware now also sets the same Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline' header handleAsset applies — a top-level GET of a contentDir SVG can no longer execute embedded <script>. (Aligning the two serve paths' SVG handling further — e.g. a shared helper — is left for a follow-up.)

    This completes the Electron asset-origin work from the prior fix (which routed the request to the utility server's apiOrigin but left the serve-side admission gate as the remaining gap).

0.4.0-beta.31

0.4.0-beta.30

Minor Changes

  • feat(desktop): replace the "Start fresh" Welcome card with an in-app "Create new project" dialog.

    The third Welcome card now opens a shadcn dialog asking for a name and parent location, with checkboxes for which AI editors to wire on first run. A pre-submit cascade surfaces structural conditions before any filesystem write:

    • Nested-project block — when the picked parent sits inside an existing Open Knowledge project, the dialog renders a red banner naming the enclosing rootPath with an inline "Open " action. Create is disabled.
    • Git-root promote confirm — when the picked parent is inside a git working tree with no enclosing .ok/, the dialog renders a blue banner explaining that .ok/config.yml will land at the git root with content.dir scoped to the new folder (one project per git repo). Create stays enabled.
    • Target-non-empty block — when the resolved parent/<name> already exists with content, the dialog renders a neutral banner pointing the user at "Open folder on disk" instead.

    The IPC handler (ok:project:create-new) re-runs every check server-side as defense-in-depth — a stale renderer state or a hostile renderer cannot scaffold a project inside an existing one.

    Breaking (internal-only surfaces):

    • OkProjectEntryPoint value 'start-fresh' is removed; replaced by 'create-new' and the new 'create-new-nested-redirect' value (fired by the red banner's inline action).
    • bridge.dialog.createFolder, the ok:dialog:create-folder IPC channel, and dialog-helpers.ts's promptForFolder (createDirectory variant) are deleted. The Pick-existing flow keeps promptForExistingFolder unchanged.
    • The silent-scaffold branch in openProject (entryPoint 'start-fresh') is gone — every project create now goes through the dialog with explicit user consent on AI-editor wiring.

    No public API change — @inkeep/open-knowledge (the CLI) is unaffected.

    Telemetry continuity. The ok.desktop.onboardingConsent span keeps its shape; the flowKind enum gains 'create-new-default' (all editors selected, the canonical happy path) and 'create-new-customized' (one or more editors toggled off). The legacy 'fresh-silent' variant is no longer emitted; downstream dashboards should treat 'create-new-default' as its successor during the transition window.

    Spec: specs/2026-05-10-create-new-project-dialog/SPEC.md.

0.4.0-beta.29

0.4.0-beta.28

Minor Changes

  • feat(seed): multi-scaffold templates picker — five Initialize packs + per-folder extra templates + opt-out personal-templates pack

    Generalizes the single-scaffold Initialize LLM brain seed into a five-card picker in SeedDialog:

    • Knowledge base (existing, renamed from "LLM brain") — Karpathy three-layer source-grounded KB (external-sources/research/articles/).
    • Software lifecycleproposals/ + decisions/ + specs/ + postmortems/ + guides/. Industry-current naming per dotnet/designs / withastro/roadmap / Google Cloud ADR doc / github/spec-kit. specs/ ships spec + spec-plan + spec-tasks templates so the github/spec-kit per-spec triple shape is one click each. guides/ ships guide + onboarding-guide + runbook.
    • Plain notesnotes/ + daily/. Escape hatch for casual users.
    • Worldbuildingcharacters/ + settings/ + themes/ + factions/ + lore/ (Fiction variant). factions/ ships faction + political-faction + religion; lore/ ships lore + magic-system + historical-event.
    • Writing pipelineideas/ + drafts/ + published/. Lean three-stage; book pipeline deferred to a future pack.

    New public surface:

    • STARTER_PACKS: Record<PackId, StarterPack> registry (server). PackId is a closed enum.
    • StarterFolder.extraTemplates?: readonly string[] — additional templates installed alongside the starter in <folder>/.ok/templates/. Picker pre-selects the starter; extras available via New from template….
    • resolvePack / coercePackId / isKnownPackId / listStarterPacks helpers — single source of truth for HTTP, IPC, and CLI.
    • GET /api/seed/packs + okDesktop.seed.listPacks() — enumerate available packs (returns id, name, description, defaultSubfolder?, folders[] with per-folder summaries). Schemas: SeedListPacksSuccessSchema + SeedPackInfoSchema + SeedPackFolderInfoSchema.
    • planSeed / applySeed accept packId + includePersonalTemplates options. ScaffoldPlan carries optional personalTemplates preview; ApplyResult carries optional personalTemplates write summary.
    • HTTP / IPC / CLI all reject explicit-but-unknown packId with a structured error (trust-boundary symmetry).
    • /api/seed/apply added to MUTATING_ROUTES for DNS-rebinding defense-in-depth.
    • CLI ok seed gains --pack <id> + --list-packs + --personal-templates flags. Default packId stays knowledge-base for back-compat.

    Personal-templates pack (opt-out, default checked):

    • Seven universal templates land at user scope (~/.ok/templates/): daily-journal, meeting-notes, weekly-review, reading-log-entry, gym-log, recipe, travel-trip.
    • Idempotent at file level — never overwrites existing user-edited templates.
    • OK_USER_HOME env override is gated on NODE_ENV === 'test' so a stray env var can't misdirect production writes.

    Back-compat preserved:

    • Legacy STARTER_FOLDERS, STARTER_TEMPLATES, LOG_MD_TEMPLATE exports are @deprecated aliases pointing at STARTER_PACKS['knowledge-base'].
    • Default packId = 'knowledge-base' everywhere; callers that don't pass packId get the same behavior as before.
    • okDesktop.seed.plan() / okDesktop.seed.apply(plan) still callable without options.

    Empty-state probe simplified: previously checked only the Knowledge base pack, leaving projects seeded with other packs stuck showing the CTA forever. Now keys off documentCount === 0 — works for all packs.

    Bridge contract: OkSeedPlanOptions / OkSeedApplyOptions / OkSeedPackInfo / OkSeedListPacksResult added; three copies (core, app, desktop) kept in lockstep; drift catcher passes. OkScaffoldPlan gains optional personalTemplates; OkSeedApplyResult gains optional personalTemplates.

    Spec: specs/2026-05-08-multi-scaffold-templates-experience/SPEC.md (D1–D14 LOCKED). Research: reports/llm-brain-scaffolds-research/{REPORT,PROPOSAL,USE-CASES,PACK-REFINEMENT-2026}.md.

0.4.0-beta.27

0.4.0-beta.26

0.4.0-beta.25

Minor Changes

  • feat(mcp): ok mcp is now an inline stdio MCP server with per-call project routing.

    The default mode replaces the previous stdio→HTTP shim that bound to a single project at startup. One stdio process can now serve any number of OK projects on the host: each tool call resolves its own cwd argument, walks up to the nearest .ok/ directory (must be a directory; regular files and dangling symlinks named .ok are rejected), loads the matching project config, and proxies HTTP traffic to that project's running ok start (auto-spawned when OK_MCP_AUTOSTART is unset or non-zero).

    This makes it safe to register ok mcp once globally in MCP hosts (Claude Code, Cursor, Codex). The host can call any tool against any local OK project by passing an absolute cwd argument.

    Breaking change for direct MCP clients: the global path requires an explicit absolute cwd on every tool call. The previous in-project mode silently defaulted to the configured project root. The global server has no fixed root and throws a clear error when cwd is omitted. Tool-arg descriptions have been tightened to document the new contract. The legacy --port <port> mode (single-backend stdio→HTTP shim) is unchanged.

    Existing on-disk MCP host registrations from prior ok init runs continue to register the binary; modern MCP-aware agents will read the updated cwd description and pass it per call.

0.4.0-beta.24

0.4.0-beta.23

0.4.0-beta.22

Minor Changes

  • feat(api): RFC 9457 Problem Details envelope across all HTTP handlers (api-design-hardening)

    All 57 handlers in packages/server/src/api-extension.ts now share a single canonical wire format:

    • Errors emit Content-Type: application/problem+json with a flat body { type, title, status, instance?, detail? } per RFC 9457. The type field is a closed-enum URN of the form urn:ok:error:<kebab> (per RFC 9457 §3.1.1 — URN form is routing-independent and won't change meaning under reverse-proxy / path-prefix). The title is a required short English summary; instance is a UUID correlation ID emitted alongside the structured Pino log line for grep-correlated triage; detail is an optional longer explanation.
    • Success drops the { ok: true, ... } wrapper and emits a flat { ...data } body with Content-Type: application/json. Clients narrow on the HTTP status code (if (!res.ok)) before parsing — the RFC 9457 two-step parse pattern.

    This is a wire-format breaking change for any direct in-process consumer of the HTTP API. The @inkeep/open-knowledge MCP shim's internal httpGet / httpPost helpers wrap the new flat success body with {ok: true, ...body} for in-process MCP tools so existing if (!result.ok) return error short-circuits keep working — MCP's own {content, isError?} envelope is unchanged.

    Structural enforcement:

    • Per-handler XyzRequestSchema + XyzSuccessSchema Zod schemas live in @inkeep/open-knowledge-core/schemas/api. Every schema exports satisfies StandardSchemaV1<...>.
    • Request bodies validated through a withValidation() middleware wrapper at packages/server/src/http/request-validation.ts (handlers can't be added without going through it).
    • Errors emit through errorResponse(res, status, type, title, options) at packages/server/src/http/error-response.ts — the only sanctioned site (packages/app/tests/integration/error-envelope-coverage.test.ts runs in fail-on-any-occurrence mode and AST-scans api-extension.ts for inline { ok: false, ... } and { ok: true, ... } literals).
    • NDJSON streaming endpoints (clone, auth-login, auth-repos) emit pre-stream errors through errorResponse and mid-stream errors through streamingProblemEvent({type: 'error', problem: ProblemDetails}) events — typed envelope preserved across the streaming protocol.
    • Closed-enum ProblemType URNs (~40 tokens) plus assertNeverProblemType / assertNeverLinkTarget exhaustiveness helpers are structurally enforced by packages/app/tests/integration/exhaustiveness-coverage.test.ts — derived from the schema at test-discovery time so the registry never drifts.
    • Telemetry: ok.api.error.count{type, handler} counter increments on every error emit.

    Client lockstep: 23 sites in packages/app/src reading data.ok / body.ok / raw.ok migrated to the two-step parse pattern. class HttpResponseParseError distinguishes contract-shape responses (RFC 9457 problem details) from non-contract responses (proxy 502 HTML, network failures).

    Defense-in-depth: SVG is in IMAGE_EXTENSIONS (so the editor's <img src=svg> rendering still works — browsers ignore Content-Disposition for embed contexts) but excluded from INLINE_RENDERABLE_EXTENSIONS so top-level navigation to .svg (web fallback window.open from a markdown SVG link) downloads instead of executing embedded <script> under same origin. Aligns with Docmost's posture; cf. GHSA-rcg8-g69v-x23j (Plane SVG XSS).

    Full spec + decision log (D1–D38, US-001 through US-014): specs/2026-04-30-api-design-hardening/SPEC.md. Canonical pattern guide: packages/server/src/http/README.md.

  • feat(desktop): per-project onboarding consent dialog + git-root promotion + boot-time config load.

    Opening a folder in the macOS desktop app no longer scaffolds .ok/ silently. Two paths now split on the user's Navigator gesture:

    • Pick Existing Project (and Recents, deep-link, drag-drop) → a per-window consent dialog opens to confirm scaffolding details before any filesystem write. The dialog covers content directory (with a Browse button for sub-folder selection), .okignore patterns, AI-tool integration multi-select (all editors checked by default — explicit, not detected), and sensitive-path warnings (/, ~, ~/Documents, ~/Desktop, ~/Downloads, /Volumes/<mount>). Git is initialized implicitly when the picked path has no real .git/ — no UI toggle. Picking a folder via the dialog == agreeing to scaffold; users who don't want OK in their folder simply Cancel.
    • Start Fresh → silent path; defaults applied without prompting.

    Re-opening a folder that already has .ok/ never re-fires the dialog.

    One .ok/ per git repo (G11). When the picked path sits inside a git working tree and no ancestor .ok/ is present, .ok/ materializes at the git working-tree root and content.dir is pre-filled with the picked sub-path. Eliminates orphan sibling .ok/s within the same repo and aligns OK's project boundary with git's. Applies to desktop (Start Fresh + Pick Existing) AND CLI (ok init).

    Sub-folders of an OK-managed project promote to the ancestor .ok/. No nested .ok/ ever materializes. The picked sub-path is dropped; the editor opens at the ancestor with a 4 s "Opened existing OK project at <ancestor>" toast.

    Desktop boot now reads project config. The per-window utility calls readConfigSafely against .ok/config.yml BEFORE bootServer, so content.dir from the project file wins over the IPC default. Invalid YAML logs [config] desktop boot config invalid and falls back to schema defaults; the editor still opens.

    ensureProjectGit hardened. Now validates .git/HEAD (not just .git/) and auto-repairs the shell-.git/ regression class — folders left with .git/ok/ (the shadow subtree) but no .git/HEAD get a single silent git init on next open. git status works in OK projects from the moment OK touches them. .git/ok/ is preserved; Save Version's parent-git path produces ok/v<N> tags going forward.

    CLI parity:

    • ok init from a git sub-folder writes .ok/ at the git root via the new shared resolveProjectRoot helper, with content.dir scoped to the sub-path. Surfaces a single disclosure line on first-time scaffold: [ok] Initialized OK at <gitRoot> (scoped to <subPath>/) for git-root promotion, [ok] Opened existing project at <ancestor> for ancestor promotion.
    • ok start operates against the cwd directly and does not scaffold — single-responsibility per command, init initializes, start starts. OkDirMissingError fires loud when .ok/ is missing.

    New OTel span. Each onboarding flow emits one ok.desktop.onboardingConsent span with bounded-cardinality attributes (flow_kind, entry_point, git_init_requested, content_dir_changed, warnings_count, ai_integrations_failed_count). No raw paths.

    New degraded subsystem. 'project-git-shell-only' joins shadow-repo / head-watcher / file-watcher in the utility's degraded array when discoverProject saw a shell-only .git/ even after auto-repair (defense-in-depth — should not occur in practice).

    Spec: specs/2026-05-07-desktop-onboarding-consent/SPEC.md.

Patch Changes

  • ok ps and ok stop now surface and act on hostname-drifted servers correctly, label desktop-spawned servers as desktop, and call out orphaned UI processes:

    • Foreign-host visible by default in ok ps. macOS hostname drift (BonjourName ↔ FQDN across DHCP/VPN/sleep) flips lock entries to foreign-host; the default filter now keeps them alongside alive so same-machine servers don't vanish from the table. --all is now narrowed to "include stale (dead-pid) entries"; the help text and docs/content/reference/cli.mdx are updated to match.
    • Desktop label. Desktop and CLI both write kind: 'interactive', so the lock alone can't tell them apart. ok ps classifies via the live process command — Electron utility processes carry --type=utility AND --utility-sub-type=node.mojom.NodeService (the latter is what utilityProcess.fork specifically registers, so VS Code / Slack / Discord helpers don't false-match). Renders as desktop (blue), overriding running / foreign. Falls back when the command lookup is unavailable.
    • ok stop accepts hostname-drifted entries. buildStopPlan, findLockDirByNumber, and the stop all filter now also stop foreign-host entries when the PID is locally live (verified via the canonical isProcessAlive probe from @inkeep/open-knowledge-server, not a duplicate). Truly cross-host locks fail the liveness probe and are left alone.
    • Hostname-drifted dead locks classify as dead-pid. inspectLock previously short-circuited to foreign-host on hostname mismatch before the liveness probe, leaving stale drift locks stuck visible forever. Liveness now runs first; foreign-host means specifically "different hostname AND PID exists locally" (the genuine drift case). Stale drift locks now fall out of ok ps by default and become eligible for ok clean pruning. The next ok start / desktop launch in that dir also auto-replaces them via the existing acquireProcessLock stale-replacement path, so manual cleanup is rarely needed.
    • ui-orphan status. When the server lock is dead-pid but the UI sidekick is alive (or foreign-host-with-live-PID), ok ps now renders the row as ui-orphan (magenta) instead of hiding the orphan behind a stale label or blanking the UI port. Surfaces the lifecycle hole where ok ui survives an ungraceful server crash; the underlying self-shutdown / boot-time-reap fix is tracked separately.
    • Foreign-host UI port shown in PORTS. Post the inspectLock reorder, foreign-host UIs have a live local PID listening on that port — show it. Hiding it (the prior behavior) made orphan UIs invisible because hostname drift forces them into foreign-host.

    JSON output of ok ps gains an isDesktop: boolean field on each entry for tooling consumers.

0.4.0-beta.21

0.4.0-beta.20

0.4.0-beta.19

0.4.0-beta.18

Patch Changes

  • Fix agent-presence icon flickering between MCP tool calls. The ok mcp shim now forwards its keepalive WS connectionId via x-ok-connection-id, and the MCP HTTP session adopts that id as identity.connectionId instead of minting a fresh UUID. With both surfaces sharing one id, the 3s bumpPresenceTs heartbeat keeps the broadcaster entry fresh between writes, and on-close clearPresence finds the right key. The icon now stays visible for the lifetime of the keepalive WS instead of disappearing 5s after each tool call. Header values are validated through validateAgentId. Invalid values fall back to randomUUID() so non-shim MCP clients still get a working session.

0.4.0-beta.17

0.4.0-beta.16

0.4.0-beta.15

0.4.0-beta.14

0.4.0-beta.13

Patch Changes

  • Fix TS2559: Type 'ProcessEnv' has no properties in common with type '{ HOST?: string | undefined; }' at packages/cli/src/commands/start.ts:540. The resolveHost helper's env parameter type was a TypeScript "weak type" (only optional declared properties) that triggers TS2559's weak-type detection when called with process.envNodeJS.ProcessEnv declares no HOST property; its index signature alone doesn't satisfy the rule. Widened the parameter to { HOST?: string | undefined; [key: string]: string | undefined } so production callers passing process.env and existing unit-test literals ({}, { HOST: '...' }) all type-check. Function body and runtime behavior unchanged. Restores bun run typecheck to green at the monorepo level (canonical CI gate; introduced in #492 and slipped through that PR's typecheck job).

0.4.0-beta.12

Patch Changes

  • Production hardening + test helper disambiguation for the sidebar resize race (#576). Retroactive changeset — this PR landed without one, this triggers the next VP cycle so the changes flow through the beta cadence.

0.4.0-beta.11

Patch Changes

  • The sourceLiteral mark's mdast-conversion path now refuses to persist a sourceRaw attribute that doesn't match the visible PM text it's attached to. Previously a caller that could mutate the document (agent API, synced collaborator, crafted clipboard paste) could store one byte sequence as visible text and persist an arbitrarily different sequence on save — a hidden-content injection vector where the editor displays "Hello" but the on-disk file or downstream LLM transcript contains a prompt-injection / commit-message-injection / structural-markdown payload. isValidSourceLiteralRaw gates the single-child path; the multi-child / non-text fallback now drops sourceRaw entirely (rather than re-using the unvalidated raw as both the synthetic value and the synthetic sourceRaw, which made the downstream defense-in-depth gate vacuous). sourceLiteral declares excludes: '' so it can co-occur with strong / emphasis / link / etc., which is exactly the multi-child path; closing this gap completes the threat-model coverage.

0.4.0-beta.10

Patch Changes

  • openBrowser (called by ok start --open / ok ui --open) now refuses URLs that contain shell-metacharacters (& | < > ^ ( ) ; $ \\ " ' [ ] plus newlines and spaces) and limits the scheme to http(s) before invoking cmd /c start "" on Windows. Previously a malicious or misconfigured --host / HOST env / server.host config value (e.g. localhost&calc) was concatenated into the launcher URL and could reach ShellExecute after cmd.exe parsed the metacharacters as additional commands. Validation runs on every platform but the practical RCE is Windows-only; macOS / Linux launchers (open / xdg-open) are not vulnerable to the same expansion. Validation tests now run in CI (the inner URL validation describe was previously nested inside the CI-skip-gated outer block; it's been hoisted to a top-level describe using _bunDescribe directly so regressions are caught by the automated pipeline).

0.4.0-beta.9

Patch Changes

  • Agent and contributor summaries are now stripped of newlines, control characters, and other commit-message-meaningful bytes before they're concatenated into shadow-repo commit messages. Without the strip, an agent or actor could inject \n\nSigned-off-by: someone-else <fake@example.com> (or any git trailer / multi-line block) into the commit body by embedding the bytes in a summary field, producing forged author attribution or trailer-driven side effects in downstream tooling. normalizeSummary already capped length and rejected non-strings; the new step also strips line breaks and control bytes from base + per-element summaries before they reach git commit -m.

0.4.0-beta.8

0.4.0-beta.7

Patch Changes

  • The reconciliation engine's LCS-based diff path now caps the input length and falls back to a whole-doc replace once the cap is exceeded, instead of allocating an O(n*m) table over arbitrary user input. A multi-megabyte agent write previously sent the LCS path into a memory allocation proportional to prevLength * newLength, exhausting the Node heap and crashing the server. The cap is sized so that legitimate edits (well below 1 MiB per side) continue through the precise LCS diff while pathological inputs get the coarser whole-doc replace path; either way the persistence layer ends up with the user's bytes.

0.4.0-beta.6

Patch Changes

  • Fix CLI npm publish: set repository.url to inkeep/open-knowledge-legacy (matches OIDC build context, satisfies sigstore provenance) and drop leading "./" from bin paths so npm publish stops auto-removing them.
  • Drop repository field from CLI package.json so npm sigstore provenance no longer rejects publish with E422. The previous URL pointed at the future inkeep/open-knowledge repo while builds run from inkeep/open-knowledge-legacy; with the field removed, npm infers the repo from the OIDC build context and provenance matches by construction. No revert needed after the planned repo rename.

0.4.0-beta.5

Patch Changes

  • The local-op path validators (assertLocalOpPathSafe and friends) now resolve every path through realpathSync and refuse paths whose canonical target lies outside the project's content root. Previously a .local/op payload with a path like subdir/symlink-to-etc/passwd only ran a lexical containment check; if subdir/symlink-to-etc was a symlink pointing outside the project, the local-op handler would happily read or write the target. Symlink loops surface as ELOOP and are also rejected. The path-safety helpers are now shared with the seed-walk equivalents so the two security boundaries stay in sync.

0.4.0-beta.4

Patch Changes

  • The backlink index's wiki-link extractor now linearly skips backtick spans rather than falling into a quadratic regex on long unclosed backtick runs. A doc containing thousands of consecutive backticks (no closing pair) previously sent the parser into pathological backtracking and pinned a CPU core for seconds-to-minutes per re-index. The new path scans backtick boundaries with a single pass and short-circuits when no closing pair is found, keeping per-doc parse time linear in document size regardless of backtick density.

0.4.0-beta.3

Patch Changes

  • The /api/history/:docName endpoint now rejects docName values that contain .., leading slashes, drive letters, or otherwise resolve outside the project's content root. Previously the docName was concatenated into a safeContentPath lookup without an isSafeDocName gate; a request like GET /api/history/..%2F..%2Fetc%2Fpasswd could traverse out of contentDir and surface git history (or, on systems where the timeline-query path read file contents, the file itself). The handler now returns 400 invalid-docName when the input fails isSafeDocName, matching the contract used by every other docName-keyed endpoint.

0.4.0-beta.2

Patch Changes

  • The collab server now releases empty Y.Docs that were never confirmed to back an on-disk file once their last connection drops. Without the carve-out, a local peer (browser tab, MCP agent, DNS-rebound origin) could connect to an arbitrary stream of unique non-existent docNames over the loopback /collab WS and grow hocuspocus.documents without bound — each allocating a Y.Doc + persistence-store handle that lived until process shutdown. The carve-out only releases phantom docs (no getReconciledBase and empty fragment + empty Y.Text); file-backed and content-bearing docs continue to follow the resident-for-lifetime rule the cache-epoch-recovery defense relies on.

    Also drops a stray docName / agentId shorthand reference from three AgentSessionCapacityError log calls in api-extension.ts — a typecheck regression that's been on main since #449's merge dropped my earlier revert. Tests + typecheck pass with this PR's branch but main itself does not type-check until this lands.

0.4.0-beta.1

Patch Changes

  • The forward-links endpoint now passes every wiki-link target through the project's ContentFilter before reading the target document's frontmatter. Previously a wiki-link to a path excluded from the project (via .gitignore or .okignore) would still resolve, surface the excluded doc's title in the response, and effectively leak the file's existence + title to anyone with read access to the linking doc. The exclusion gate is now applied per-target so excluded docs render as plain wikilinks (no resolved title) without revealing whether they exist.

0.4.0-beta.0

Minor Changes

  • feat(editor): asset upload + ![[file.ext]] wiki-embed surface

    Any file drop is accepted by the editor — there is no user-facing byte cap. PDFs, video, audio, archives, and fonts stop hitting the old "Unsupported file type" dead-end. The emit shape is picked by extension: markdown files (.md / .mdx) emit as [[basename]] wiki-links (link-semantic, navigable on Cmd-click, resolved via fileIndex — markdown is a first-class OK doc, not an opaque asset); images + typed renderable files (PDF, MP4, WebM, MP3, WAV, OGG, M4A, MOV) emit as ![[file.ext]] wiki-embeds; opaque files emit as [name](path) markdown links. Uploads stream to disk end-to-end (memory footprint is O(1), not O(fileSize)), so the only rejection axis is disk fullness (storage-full → HTTP 507). See reports/streaming-upload-refactor/REPORT.md for the refactor rationale.

    Same-directory sha256 dedup returns existing paths on duplicate drops with a toast ("Already at <path> — reusing."). Renaming a doc that contains image refs recomputes the relative path; absolute refs and wiki-embed refs are untouched because the basename index resolves them dynamically.

    New HTTP surface on the server:

    • POST /api/upload — upload endpoint. Success response: { ok, src, path, deduped } where src is the asset's basename and path is the contentDir-relative location (colocated with the referencing doc). Error responses carry a typed error reason (malformed-upload / storage-full / storage-readonly / collision-exhaustion / storage-error) plus a human-readable message.

    No user-facing upload.* config. Attachment placement (co-located), emit shape (![[...]] for supported extensions), same-directory sha256 dedup with a toast notice, and the wiki-embed extension list are fixed defaults. Every value is a module-level constant in @inkeep/open-knowledge-core/constants/upload.ts. One-shot Obsidian-vault migration CLI deferred to a future spec — OK does not read .obsidian/app.json at runtime; refugees whose vault uses non-default config shape wait for the future migrator. Legacy configs still carrying upload.* keys parse cleanly (unknown keys are silently stripped).

    File watcher now emits asset-create / asset-delete DiskEvents alongside the existing markdown events; CC1 ch:'files' signal coalesces both so file-sidebar and basename-index rebuilds piggyback on one broadcast. sanitizeFilename preserves Unicode code points (letters, digits, marks, punctuation, emoji) while stripping path separators and control bytes.

    Full spec + decision log (D1–D-M): specs/2026-04-16-editor-asset-and-embed-surface/SPEC.md. Operator-facing guide: Assets and embeds.

    Asset-click dispatcher + OS-integration surface (2026-04-23 amendment). Click a ![[meeting.pdf]] embed and the PDF opens predictably — a new browser tab in web, shell.openPath in Electron. Previously post-reload clicks routed through the doc-link navigator and failed silently (Gap 3b); Electron drop-time clicks replaced the editor window (Gap 4). Both gaps close.

    • ClassifiedLinkTarget gains a first-class {kind: 'asset', url, ext} variant; resolveAssetProjectPath resolves relative hrefs against the source doc's directory.
    • Renderer-side dispatcher + empty-at-landing viewer registry at packages/app/src/editor/asset-dispatch/ — future PRs register PDF.js / image lightbox / video-audio viewers as ~40-60 LOC plugins without modifying the dispatch layer.
    • Three new Electron IPC channels (ok:shell:open-asset, ok:shell:reveal-asset, ok:shell:show-asset-menu). Main-process openAssetSafely enforces path containment (realpath + isPathWithinProject), existence, and an executable-extension blocklist (.exe/.sh/.html/.svg/…) source-verified from Obsidian 1.12.7. Renderer sends project-relative paths; containment fires at the IPC boundary.
    • Right-click any on-disk reference (asset chip, wiki-link chip, image) → native OS menu with Reveal in Finder / Show in Explorer + Open in default app + Copy link. Gesture-attested (main observes the click directly).
    • Defense-in-depth: setWindowOpenHandler + will-navigate on the editor webContents intercept any asset URL that escapes the renderer dispatcher (pasted <a href>, plugin content, drop-time <a target="_blank">). Same path containment + blocklist enforced on every entry point.

    Full amendment (US-A1..A6, FR-A1..A8, NG-A1..A6, D-A1..A12): specs/2026-04-16-editor-asset-and-embed-surface/SPEC.md §Post-finalization amendment (2026-04-23). Research: reports/electron-os-integration-patterns/ + reports/editor-asset-embed-patterns-across-universe/ D9.

  • feat: Component Blocks v2 — 5-pack foundation (Callout + Image + Video + Audio + Accordion)

    The editor now ships five built-in component primitives — Callout, Image, Video, Audio, and Accordion — each with a WYSIWYG settings panel, a slash-command insertion menu, and lossless on-disk round-trip for both the MDX form and the markdown form (where one exists). Every primitive is a DIY React component on Open Knowledge's own brand (shadcn / Tailwind); the editor bundle no longer pulls in fumadocs-ui's React surface or its CSS variable bridge.

    What you get out of the box:

    • Callout — five GFM alert types (note / tip / important / warning / caution) plus optional title / icon / color / and Obsidian-style foldable chrome (> [!NOTE]+ / -). Authoring works in any of three forms: GFM alert blockquote, foldable Obsidian opener, or <Callout type="…">…</Callout> MDX JSX. Common alias tokens (successtip, dangercaution, etc.) fold to the GFM 5 on disk.
    • Image<Image src=… alt=… width=… caption=… /> MDX, plus standard CommonMark ![alt](src). Both forms render through the same descriptor with click-to-zoom on by default; the MDX form additionally exposes caption (renders as <figure> + <figcaption>), explicit dimensions, and loading / zoom toggles.
    • Video — pure HTML5 <video> wrapper with native controls. No YouTube / Vimeo URL sniffing — embed services with a raw <iframe> in MDX (matches Mintlify's pattern). <track> and <source> children round-trip.
    • Audio — pure HTML5 <audio> wrapper with native controls always on. <source> and <track> children round-trip.
    • Accordion — standalone HTML5 <details> / <summary> substrate, no wrapper component required. Cross-browser exclusive grouping via HTML5 <details name="…"> (Chrome 120+, Safari 17.2+, Firefox 130+). Authors can write either <details><summary>X</summary>Y</details> or <Accordion title="X">Y</Accordion> — both render the same descriptor.

    Other improvements:

    • Auto-generated settings panel from each component's prop types (string / boolean / number / enum) — no separate component prop docs required.
    • Slash-command insertion with sensible defaults; the settings panel auto-opens on insertion so required fields are filled in before you move on.
    • Hover chrome with move-up / move-down / delete / settings buttons.
    • Keyboard navigation throughout (Tab / Esc / arrow keys with context-aware handling).
    • Broken or unrecognized MDX components automatically open in an embedded source-code editor so authored content stays editable — nothing silently disappears.
    • Both pristine and dirty save paths preserve the on-disk shape: unedited blocks round-trip byte-for-byte; edited blocks canonicalize to the MDX JSX form.

    Breaking changes:

    • Both the inline MDX element node (jsxInline) and the block MDX component node (jsxComponent) changed PM-schema shape in this release. jsxInline drops its attributes and sourceRaw attrs — its text content IS the source of truth. jsxComponent widens from an atom with a raw-content attr to a non-atom block with block* children and new structured attrs (componentName, kind, attributes, sourceRaw, sourceDirty, props). This is a load-bearing change for collaborative editing — older clients coexisting with this version in the same live session substitute both nodes to rawMdxFallback (raw source preserved as editable text) via the y-tiptap schema-throw substitution patch. Upgrade all clients in a session together — both inline JSX authoring and component-block authoring are affected, not just inline. Persisted documents are unaffected; the on-disk MDX is preserved.
    • Content using component names that are no longer built in (Tabs, Card, CardGroup, Steps, Banner, Files, TypeTable, InlineTOC, Mermaid, AudioPlaceholder, ImageZoom) opens as an editable raw-source block. Content is preserved verbatim. Rename <AudioPlaceholder /><Audio /> and <ImageZoom><Image> to pick up the new descriptors.

    The compound-component tier (Tabs + Tab grouping, Accordion grouping with shared chrome, Steps + Step) is not built in today; it returns when concrete dev-docs / help-center authoring demand surfaces. No public API will change for existing 5-pack consumers when that happens.

    Bundle size:

    • Main app bundle stays flat (~210 kB gzipped) — the fumadocs-ui drop and 12-descriptor cut offset the 5-pack prop-surface widening and new selection-chrome plugins.
    • Total JS across lazy-loaded chunks grows ~100 kB gzipped (~978 kB → ~1.08 MB) to accommodate CB-v2 feature surface (descriptor-dispatch registry, V2 editor cache, SelectionStatePlugin + Breadcrumb + SelectionAnnouncer + BlockDragHandle, nested CodeMirror for rawMdxFallback, slash-command menu, canonical/compat descriptor split with three additional read-only source-form descriptors (GFMCallout, CommonMarkImage, HtmlDetailsAccordion) for round-trip preservation). The all JS chunks combined size-limit ceiling is raised 1050 → 1100 kB (~2% headroom) to match. Delivered via on-demand chunk loading — users don't pay the full bill on first paint.
  • Config Editing Paths — end-to-end UX for editing Open Knowledge configuration:

    • Settings pane in the editor area (Cmd-, / App menu / HelpPopover / Command Palette) with This project and User scope tabs. Each field auto-saves; per-field reset; modified-at-scope indicator on cross-scope fields.
    • Real-time sync — Settings pane is bound to two Y.Text-only synthetic Hocuspocus docs (__config__/workspace, __user__/config.yml). External edits via CLI, MCP, IDE hand-edit, or another ok start instance propagate via a chokidar file watcher into Y.Text and refresh any open pane within ~500ms.
    • Three-layer defense-in-depth validation — client walker (L1) → fs writer (L2) → persistence-hook (L3). Invalid mutations revert to LKG and surface a toast + brief field flash.
    • MCP toolsget_config, set_folder_rule. fs-direct (no running server required); auto-scope inference via the inspectConfig ladder; mixed-scope rejection. (set_config was subsequently removed in the schema-simplification changeset; see config-schema-simplify.md.)
    • CLIok config validate (exits 0/1 with source-located errors) + ok config migrate (idempotent codemod that drops sync.*, persistence.{debounceMs,maxDebounceMs}, server.port).
    • ok init scaffolds the workspace config.yml with a magic-comment $schema URL pinned to the schema major (v0) + @latest of the npm package — additive schema changes reach existing users automatically; breaking changes bump the path to v1 and old majors stay published forever.
    • Per-scope JSON Schemasdist/schemas/v0/config.workspace.schema.json and …/config.user.schema.json so VS Code's Red Hat YAML LSP only suggests fields valid AT the file's scope.
    • Schema cleanup — drops sync.* (7), persistence.{debounceMs,maxDebounceMs} (2), server.port (1); adds appearance.theme and appearance.editorModeDefault (user-scope, both UNSET by default; chrome <ThemeToggle> writes through userBinding.patch so localStorage stays a derived cache). content.* is workspace-scope-only.
    • OTel — five new config.* spans (config.bind, config.patch, config.validate, config.persist, config.revert) trace the full edit chain.
  • Config schema simplification — six fields removed, replaced with constants:

    • Schema fields removedgithub.oauthAppClientId, server.host, server.openOnAgentEdit, mcp.autoStart, mcp.tools.read_document.historyDepth, mcp.tools.grep.maxResults. None were genuinely user-configurable in practice. Their default values now live as plain export const in @inkeep/open-knowledge-core (constants/github.ts, constants/mcp.ts, constants/server.ts): DEFAULT_GITHUB_OAUTH_CLIENT_ID, DEFAULT_SERVER_HOST, READ_DOCUMENT_HISTORY_DEPTH, GREP_MAX_RESULTS.
    • Config type contract — TypeScript consumers reading config.server, config.github, or config.mcp will now get never. The schema's top-level surface shrinks to content, preview, appearance, autoSync. Layered config infrastructure (project/user merge, scope inference, __config__/project + __user__/config.yml Y.Text transports) is unchanged for the day a real user-configurable layered field needs it.
    • set_config MCP tool removed — the agent-settable surface was empty after the two mcp.tools.* fields became constants. get_config and set_folder_rule remain. Re-introduce when an agent-tunable field actually returns.
    • Runtime overrides preserved--host flag and HOST env var (resolved at the start command via the new resolveHost() helper, mirroring the D29 port pattern); OPEN_KNOWLEDGE_GITHUB_CLIENT_ID env var; OK_MCP_AUTOSTART=0 env var.
    • server.openOnAgentEdit — the auto-open-browser-on-first-agent-write feature was deleted entirely (default was off; collapsed dead conditional).
    • Loose-mode tolerance — existing user/project YAML configs that still set the removed keys parse without throwing; the loader emits a per-key deprecation warn pointing at the right knob (--host flag, HOST env, OPEN_KNOWLEDGE_GITHUB_CLIENT_ID, OK_MCP_AUTOSTART=0, or "hardcoded in @inkeep/open-knowledge-core" when no override exists). Old mcp.tools.search.maxResults (pre-#491 rename) is also flagged.
    • Settings pane — Server, GitHub, and MCP sections removed. set_config MCP tool description and reference docs cleaned up.

    Migration notes for consumers:

    • Reading config.server.host, config.mcp.autoStart, etc. → switch to runtime override (--host/HOST, OK_MCP_AUTOSTART=0) or import the constant directly from @inkeep/open-knowledge-core.
    • Calling set_config MCP tool → use the Settings pane or hand-edit config.yml. Use set_folder_rule for folder rules; get_config for reads.
  • feat(frontmatter-editing-ux): top-of-document property panel + per-key Y.Map('metadata') storage + frontmatter_patch MCP tool. Frontmatter is now editable inline in WYSIWYG mode through typed widgets, and concurrent edits from a human and an agent to different properties merge at the field level instead of clobbering each other through document-level last-write-wins.

    • @inkeep/open-knowledge-core — new packages/core/src/frontmatter/ module exporting FrontmatterValueSchema, FrontmatterPatchSchema, FRONTMATTER_TYPES, and the comment-preserving YAML codec (parseFrontmatterYaml / serializeFrontmatterMap over yaml@2.x's parseDocument). Bridge readers/writers in packages/core/src/bridge/frontmatter-y.ts extended with getFrontmatterMap, setFrontmatterFromYaml, setFrontmatterProperty, and composeFrontmatterForStore. getFrontmatter(doc) now synthesizes from per-key entries when present, falls back to the legacy single-string slot otherwise — existing string-shape callers continue to compile unchanged.
    • @inkeep/open-knowledge-serverY.Map('metadata') now carries one entry per frontmatter property (Y.Text for editable strings, Y.Array<Y.Text> for lists, primitives for atomics). New POST /api/frontmatter-patch route + handleFrontmatterPatch handler applies JSON Merge Patch (RFC 7396) atomically under a per-session, not paired formOrigin. Observer A's metaMap deep-observer recomposes YAML+body and propagates to Y.Text after settlement. onLoadDocument runs an eager-on-load migration; applyExternalChange (file watcher) and Observer B reconciliation use per-key diff so undoing a single property reverts only that property. onStoreDocument's composeFrontmatterForStore writes the legacy YAML byte-string verbatim when the per-key map still matches it — comments, blank lines, and scalar styles round-trip losslessly. agent-patch (/api/agent-patch) returns HTTP 400 on FM-intersecting find/replace calls with a migration hint pointing at frontmatter_patch. New OTel spans frontmatter.patch + frontmatter.form_write; new counter ok.frontmatter.edit_surface_total labels writes by source (form / mcp-patch / mcp-write / file-watcher / source-mode).
    • @inkeep/open-knowledge — new frontmatter_patch MCP tool. Set / create / delete frontmatter properties with {patch: {key: value | null}}; optional types map overrides per-key widget inference (text / number / boolean / date / list); optional summary threads through to the per-contributor attribution journal under the same 80-char cap as the other write tools.
    • @inkeep/open-knowledge-app — new top-of-document Properties panel above the body in WYSIWYG mode. Five widget types (Text / Number / Boolean / Date / List), inline add / delete / rename, type picker dropdown, per-row hover chrome, collapse via chevron, empty-state seeded via the editor toolbar's Add Properties button. All form interactions wire through POST /api/frontmatter-patch with source: 'form'.

    Storage migration is automatic on document load — no user action required. The legacy single-string metaMap.get('frontmatter') slot is retained as a transitional byte-identical mirror so YAML comments and scalar styles survive doc-load → no-op-edit → doc-save round-trips. frontmatter_patch is the only MCP surface for frontmatter edits going forward; the soft-deprecation window for agent-patch FM-touching calls is closed and those now return HTTP 400.

    Full spec + decision log: specs/2026-04-24-frontmatter-editing-ux/SPEC.md.

  • MCP search/grep realignment — rename the existing search tool to grep, add a new ranked search tool that mirrors cmd-K.

    • Tool removed: search (the previous tool — fixed-string grep with frontmatter enrichment).
    • Tool added: grep — replaces the legacy search tool with identical behavior. Use it when you need every literal occurrence of a string across content.
    • Tool added: search — new ranked workspace retrieval. Wraps POST /api/search so results match the cmd-K palette (Orama-backed lexical + body BM25 + recency). Parameters: query, intent ('omnibar' | 'full_text', default 'full_text'), scopes, limit (default 20, max 100), cwd. Both new tools register MCP annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }.
    • Config key: the grep tool's result cap is mcp.tools.grep.maxResults (default 50).
    • Server-side instructions (buildInstructions) now surface the four-way read-tool routing (exec / read_document / search / grep).

    Migration:

    1. Rename any caller using mcp__open-knowledge__search for grep semantics to mcp__open-knowledge__grep.
    2. Switch to the new mcp__open-knowledge__search for relevance-driven retrieval (the same ranking the cmd-K palette uses).
    3. If your .ok/config.yml contains mcp.tools.search.maxResults, rename the key to mcp.tools.grep.maxResults. The legacy key is silently ignored (loose-object pass-through), so a custom value will not carry over automatically.

    Spec: specs/2026-05-05-mcp-search-grep-rename/SPEC.md. Background research: reports/file-search-tool-mcp-exposure/REPORT.md.

  • feat(rename): rename .open-knowledge/.ok/ everywhere (per-project, ~/.ok/, and the shadow repo at .git/ok/); replace content.include and content.exclude in config.yml with a .okignore file at the project root using gitignore syntax.

    Hard cutover for the directory rename (pre-release license to break — no auto-rename of .open-knowledge/.ok/):

    • The per-project state directory is now .ok/ instead of .open-knowledge/. Same for the user-global directory (~/.ok/) and the shadow repo (.git/ok/).
    • content.include and content.exclude are removed from ConfigSchema. If they appear in your config.yml, OK rejects the file with a source-located error directing you to move the patterns into a project-root .okignore.
    • .okignore uses gitignore syntax (parsed by the ignore npm library) and is evaluated alongside .gitignore in a single ignore-lib instance — cross-source ! overrides work (e.g. !secret.md in .okignore re-includes a file .gitignore excluded). Nested .okignore files at any folder depth are honored.
    • ok init now scaffolds both .ok/ and a project-root .okignore (commented header, no example excludes).
    • The Settings pane's Content section is removed; content.dir becomes YAML-only (default . covers the common case).
    • The MCP set_config allowlist drops to 3 paths: folders[], mcp.tools.search.maxResults, mcp.tools.read_document.historyDepth.

    For pre-existing OK projects:

    1. Rename your .open-knowledge/ directory to .ok/ (manual — no auto-rename shim).
    2. Lift any content.include / content.exclude patterns into a project-root .okignore (recreate exclusion patterns; remember the .okignore mental model is exclude-only).
    3. Run ok config migrate to strip the obsolete content.{include,exclude} keys from config.yml. The codemod is idempotent and also clears the other deprecated keys (sync.*, persistence.{debounceMs,maxDebounceMs}, server.port).
    4. Delete the orphan .git/open-knowledge/ shadow repo.
    5. Re-authenticate.

    The dogfood team will see one re-prompt for stored credentials (~/.ok/auth.yml), first-launch consent (~/.ok/mcp-status.json), and the skill-installed marker on first run after merging — expected behavior for the hard cutover on the user-home rename.

    The protected identifiers (MCP server name open-knowledge, writer-ID openknowledge-service, bundle ID com.inkeep.open-knowledge, URL scheme openknowledge://, package names) are unchanged.

  • feat(rename): consolidate /api/rename and /api/rename-path into a single polymorphic endpoint, lift the link-rewrite spine, extend the recovery journal to v2, and add principal-attribution fallback for rename + rollback handlers.

    Breaking changePOST /api/rename is removed. Clients (UI, MCP, scripts) must use POST /api/rename-path with the polymorphic body shape:

    { "kind": "file" | "folder", "fromPath": "<path>", "toPath": "<path>", ...identity, "summary": "..." }
    

    The MCP rename_document tool's outward API is unchanged (parameters, response shape, identity passthrough are all functionally equivalent) — only the internal HTTP target changed.

    New capabilities:

    • Folder rename now rewrites all inbound wiki-links and supported markdown links across linking docs (was previously a CONFIRMED gap — folder rename moved files but left link text untouched).
    • Folder rename is now crash-safe — process kill mid-batch is recoverable on next startup with no partial state.
    • File rename via the consolidated endpoint now updates the in-memory backlink index (was missing on the file branch of /api/rename-path).
    • UI-driven rename and rollback now attribute to the server-loaded principal (principal-<uuid>) when no agent identity is supplied. Body-supplied principalId is silently ignored — server's getPrincipal() is the only source of principal identity.

    The recovery journal schema is bumped from v1 (single source/destination) to v2 (multi-doc affectedDocs[]). The v1 parser is preserved alongside v2 — legacy v1 journals on disk at startup still recover correctly.

    Side-effect docs (backlink-rewrite cascades) remain anonymous for both agent-driven and principal-driven renames — only the renamed doc itself is attributed to the actor.

    Other behavior changes worth noting:

    • POST /api/rollback 500 responses no longer echo the underlying error message; clients now see a generic Failed to roll back document string. Eliminates a path / internal-state leak channel for an unauthenticated body endpoint.
    • ok init adds state.json to the bootstrapped .ok/.gitignore alongside sync-state.json and principal.json — covers newly-added local-runtime state at <contentDir>/.ok/state.json (also emitted as a separate init-gitignore-consolidation changeset for the broader scaffold rework, but the state.json line specifically lands here).
  • feat(server): boot in linked git worktrees + fail-fast on missing .ok/config.yml.

    Open Knowledge now boots correctly inside linked git worktrees (git worktree add <path> <ref>).

    • Main worktree — shadow at <projectRoot>/.git/ok/
    • Linked worktree — shadow at <repo>/.git/worktrees/<name>/ok/

    ok init works identically in main and linked worktrees with no special handling — the existing writeIfMissing safeguard in the init scaffold prevents clobbering committed configs that git checkout materialized.

    Fail-fast on missing config. bootServer now runs a pre-listen check for <contentDir>/.ok/config.yml. When absent (whether .ok/ is missing entirely or just config.yml), boot rejects with the new typed MissingOkConfigError (exported from @inkeep/open-knowledge-server):

    Open Knowledge config not found at .ok/config.yml. Run ok init to scaffold OK in this directory.

    When only .ok/.gitignore is missing (config present), boot emits a one-time stderr warning recommending ok init and proceeds — per-machine state files in .ok/ may show up as untracked changes until the recommended ignore entries are merged in.

    Desktop GUI: silent scaffold on first open. When you open a folder that doesn't yet have .ok/config.yml, the Open Knowledge desktop app now scaffolds it silently and opens the editor — same idempotent flow as ok init. The user's Navigator / recents / deep-link / drag-drop gesture is treated as consent for the scaffold. writeIfMissing ensures committed configs (materialized by git checkout) are never clobbered. Explicit-consent dialog UX (Yes/No modal before scaffold) is a follow-up.

    Unusable .git paths surface as typed errors with the right recovery hint. Two distinct failure modes, two distinct typed errors (both exported from @inkeep/open-knowledge-server and @inkeep/open-knowledge-core/shadow-repo-layout):

    • MalformedGitPointerError<projectRoot>/.git is a file but its gitdir: target is unreadable, has no gitdir: line, or references a missing admin directory. Typical cause: a partial git worktree remove race or rm -rf of the admin directory without git worktree prune. Recovery hint names git worktree prune.
    • GitDirAccessErrorstatSync on <projectRoot>/.git failed for a reason other than ENOENT (typically EACCES / EPERM on a misconfigured ACL or stale mount). The shape of .git is undetermined. Recovery hint names filesystem permissions and mount state.

    Both errors carry the original errno exception as cause so log consumers can branch on EACCES vs parse-failure without parsing strings.

    Observability. The boot path emits an OTel ok.boot span carrying ok.worktree.kind (main | linked) and a normalized ok.worktree.gitdir so failure rates can be sliced by worktree shape.

    Two worktrees on different branches running ok start concurrently are fully independent: separate shadows, separate .ok/server.lock files, separate ports, separate MCP endpoints. Per-worktree isolation is the intended model — writer history, upstream imports, and checkpoints stay scoped to the worktree they were authored in.

Patch Changes

  • Fix Cannot find package '@inquirer/checkbox' crash on every CLI invocation in the packaged desktop .app. tsdown.config.ts alwaysBundle listed @inquirer/password but missed @inquirer/checkboxcli.ts eagerly imports initCommand, which top-level imports @inquirer/checkbox, so every subcommand (auth status, auth repos, clone, …) crashed before producing any output. Dev mode resolved the dep from the workspace node_modules and hid the bug. The packaged .app ships no node_modules next to dist/cli.mjs, so the bare specifier failed at module-load time. Added the matching pattern to alwaysBundle so the dep is force-inlined into dist/cli.mjs.

    User-visible symptom: clicking "Clone from GitHub" in the published Open Knowledge desktop app surfaced auth status exited with code 1 because the renderer's IPC auth status probe spawned ok.sh auth status --json and got a Node module-not-found crash with no JSON output.

  • ok (no positional args) on macOS now launches the desktop Electron app when it's installed in /Applications/Open Knowledge.app (or ~/Applications/). Detection is interactive-only — SSH sessions, non-TTY stdout, and missing bundles all fall through to the existing ok start behavior (server + browser). On Linux, Windows, and any other platform without the macOS desktop bundle, ok runs start exactly as before.

    Two new escape hatches:

    • ok start --mode=browser|app is an explicit modal selector. --mode=browser (or omitted) keeps today's server-only behavior bit-for-bit; --mode=app forces the desktop hand-off and errors with a clear message if the bundle isn't found. --open continues to mean "open a browser tab against the local server" and is mutually exclusive with --mode=app.
    • OK_FORCE_BROWSER=1 env var disables the dispatch entirely for the current process. Use this in scripts/CI/MCP wrappers that need the server even on a workstation with the desktop installed. OK_FORCE_DESKTOP=1 does the inverse (force-launch desktop, bypassing the headless gate).

    Non-breaking — ok start (no flag) is unchanged. The dispatch only fires when ok is invoked with no subcommand. All existing subcommands (mcp, init, status, stop, etc.) keep current behavior. See specs/2026-05-04-cli-default-desktop-launch/SPEC.md.

  • Fix the "Install for Claude Chat & Cowork" handoff in the packaged Electron app, which previously failed with build-failed: Could not resolve @inkeep/open-knowledge CLI version. The cause was a heuristic two-path filesystem probe in build-skill-zip that didn't match the node_modules/@inkeep/open-knowledge/ layout shipped inside the .app — and the version it was looking up was not actually used in this code path.

    build-skill-zip is now a pure ZIP builder: BuildSkillZipOptions.skipVersionCheck and BuildSkillZipResult.cliVersion are removed in favor of a presence-based BuildSkillZipOptions.expectedSkillVersion?: string (pass it from release builds that need to assert SKILL ↔ CLI alignment, omit otherwise). BuildAndOpenSkillResult.cliVersion is also removed — skillVersion is the canonical artifact version.

    A new resolvePackageVersion(packageName, fromUrl) utility is exported from @inkeep/open-knowledge-server for any caller that needs to look up an installed package's version at runtime. It uses Node's module resolver plus a directory walk, which is robust across workspace symlinks, hoisted node_modules, pnpm .pnpm/-flat, and asar-packed Electron.

    The ok install-skill post-build message now reads Skill v<x.y.z> (presence-checked) instead of CLI v<x.y.z>.

  • Docs accuracy pass — surgical fixes for drift between docs/content/ and the shipped CLI/MCP/integration code.

    reference/cli.mdx:

    • Fixed broken markdown table in the start flags section (missing | --- | --- | separator row meant the table didn't render).
    • Removed duplicate --open row.
    • Added the --mode <browser|app> flag on start (escape hatch for the desktop-dispatch path).
    • Added the ps command to the Lifecycle section (lists running OK servers).

    integrations/codex.mdx: clarified that init also installs the Open Knowledge skill globally (via npx skills add) so Codex can discover the MCP tools — previously the doc said only "registers the MCP server."

    integrations/claude-desktop.mdx: added a date-stamped callout flagging the Cowork-bug workaround section as upstream-bug-driven, so it gets revisited when Anthropic ships a fix.

    get-started/quickstart.mdx: corrected the post-init description — init registers MCP for all 6 supported editors (Claude Code, Cursor, Codex, Claude Desktop, VS Code, Windsurf) and installs a skill for Claude Code, Cursor, and Codex. Previous wording omitted Codex from the skill list and named only Claude Code + Cursor for the "every supported editor" claim.

    reference/configuration.mdx and reference/mcp.mdx were re-verified during the pass and need no changes.

  • chore(init): consolidate Open Knowledge ignore rules into .open-knowledge/.gitignore. The scaffold now writes cache/, server.lock, ui.lock, sync-state.json, principal.json, and last-spawn-error.log, and ok init merges missing entries into pre-existing files (existing user lines preserved). ok clone no longer mutates the cloned repo's tracked .gitignore; per-clone protection lives in .git/info/exclude (local-only, never committed) instead.

  • Refuse to run ok mcp in directories that haven't been ok init'd. Closes a regression where invoking npx @inkeep/open-knowledge mcp from a non-OK directory eagerly scaffolded .ok/, .openknowledge/ (legacy bare shadow), and .gitignore as a side effect of MCP startup — observed when the OK MCP was registered globally (e.g. ~/.claude.json top-level mcpServers) and the user opened claude in any directory.

    ok mcp now exits cleanly at startup if <cwd>/.ok/ doesn't exist, before registering tools or discovering servers. --port bypasses the gate (explicit user intent). The skill description already says "Skip if no .ok/ — not an Open Knowledge project"; this enforces the same contract at the server level.

  • refactor(mcp): route ok mcp through the shared HTTP server. The CLI now uses a thin stdio-to-HTTP shim, removes legacy --pin setup, and relies on the running ok start server for MCP tool execution.

  • chore(layout): move per-machine runtime files from .ok/<name> to .ok/local/<name>.

    <contentDir>/.ok/ now separates committed config (config.yml, frontmatter.yml, templates/) from per-machine runtime state, which moves under .ok/local/: server.lock, ui.lock, state.json, principal.json, sync-state.json, conflicts.json, last-spawn-error.log, managed-rename.json, cache/<branch>/backlinks.json, tmp/upload-<uuid>. OK_GITIGNORE_CONTENT is now a single line (local/) — adding a new runtime file no longer requires a coordinated .gitignore edit, and .ok/local/ documents the contract by name. All in-process consumers resolve the path through the new getLocalDir(contentDir) helper exported from @inkeep/open-knowledge-core.

    If you're an OK developer pulling this branch and your project has runtime files at .ok/ root, run rm -rf .ok/ then bun run --filter=@inkeep/open-knowledge ok init. The boot logs a one-shot warning if it finds legacy files at .ok/ root; the new code reads/writes only under .ok/local/, so legacy files sit inert until you clean them up. There is no auto-migration — Open Knowledge has not shipped to real users yet, so the developer-only re-init path is the right tradeoff (per spec D2 LOCKED).

    Spec: specs/2026-05-05-ok-local-folder-pattern/SPEC.md.

  • Remove the dead "Folders" section from the Settings pane.

    The folders[] cascade was retired in spec 2026-05-01-folder-level-metadata-and-templates (FR8 / D19) — folder defaults moved into nested <folder>/.ok/frontmatter.yml files, edited via the set_folder_rule MCP tool. The FoldersSection.tsx Settings UI and its applyFolderRulesUpsert write path were left behind in that change; entries typed into the section persisted into config.yml via z.looseObject passthrough but no production code read them.

    This removes:

    • FoldersSection.tsx + its smoke test
    • apply-folder-rules-upsert.ts (the dead UI was the only caller; set_folder_rule uses its own applyNestedFolderRulesUpsert)
    • folder-rules.ts legacy resolver (zero production callers)
    • the applyFolderRulesUpsert re-export from @inkeep/open-knowledge-core/server
    • stale "config.yml folders:" references in enrichment.ts, exec.ts, and seed.ts JSDoc/CLI descriptions

    FolderRuleSchema and FolderFrontmatterSchema exports remain in place — set_folder_rule reuses them.

  • fix(rename): preserve file content when renaming via the sidebar.

    Renaming a file via the sidebar inline-rename (right-click → Rename, or F2) was erasing the file content on disk: the editor showed an empty placeholder under the new name, and the original content survived only in the renaming tab's IndexedDB cache (which is why renaming back made it "reappear"). Cold reloads or other tabs/clients saw the renamed file as empty.

    Two layered fixes:

    • FileTree ignores selection-change events whose docName isn't yet in the local documents list. @pierre/trees fires onSelectionChange synchronously when an inline rename commits — before the rename API has written the file at the new path — and the resulting premature navigation was opening a server-side Y.Doc against a missing file, which the persistence layer subsequently flushed back to disk as 0 bytes.
    • persistence.onStoreDocument refuses to materialize a 0-byte file when the Y.Doc was never confirmed to exist on disk AND the serialized markdown is empty. This blocks accidental orphan files from any code path that opens a Y.Doc for a non-existent docName (browser races, /api/document?docName=<missing>, MCP queries on deleted docs, future callers). Legitimate first-write paths (/api/create-page, agent writes via /api/agent-write-md) are unaffected.
  • The collab server now caps the number of distinct agent sessions per Document at 256 and refuses additional agent-write / agent-write-md / agent-patch requests with 503 too-many-agent-sessions once the cap is reached. Without the cap, an unauthenticated peer (or a runaway agent loop) could mint unlimited fresh agentIds, each spawning a new server-side session with its own bounded undo manager and presence entry, exhausting memory and overwhelming the awareness map. The 256 ceiling is well above any legitimate per-document agent fleet and triggers a AgentSessionCapacityError log line including docName and agentId so operators can identify the offending peer.

  • Bound desktop auth-query IPC subprocess fan-out. The authStatus/authRepos IPC handlers in packages/desktop/src/main/ipc/local-op.ts now coalesce concurrent requests for the same host and enforce a per-handler concurrency cap; overflow is reported back to the renderer as a structured response rather than spawning another open-knowledge auth … child process. Closes the unbounded-spawn DoS path where a compromised renderer could exhaust the process table by tight-looping window.okDesktop.localOp.authStatus() / authRepos().

  • The chokidar fallback file-watcher now refuses to forward events whose path resolves through a symlink to a target outside the project's contentDir. Previously a symlink planted in the watched tree (via git clone, an extracted archive, or a co-tenant write) could surface as an add/change event whose readFile would dereference the symlink and pull in /etc/passwd or another sensitive target. The new eventEscapesContentDir helper lstats the event path, resolves the symlink with realpathSync, and refuses unless the canonical target is within contentDir. The lstat and realpath catch blocks distinguish ENOENT (delete-race, pass through) and ELOOP (broken symlink, drop quietly) from other errno codes (EPERM / EACCES / EIO — drop and log) so a hostile symlink with restricted intermediate-path permissions can't bypass the gate by triggering an unexpected error.

  • ok init now refuses to overwrite project-scope config paths (.mcp.json, .cursor/mcp.json, .claude/skills/open-knowledge) when any segment of the destination is a symlink whose realpath escapes the project cwd. A malicious repo could previously plant a symlink that pointed git cloned files at /etc/passwd or another sensitive location, then have ok init follow it and overwrite the target. The new assertProjectPathSafe guard rejects leaf-symlink, ancestor-symlink, and parent-traversal escapes while still allowing in-cwd symlinks. Editor-ID lookups also switched from bracket access to Object.hasOwn so prototype-chain pollution can't surface forged editor labels.

  • Treat lock-file PIDs as untrusted input across every signal-delivery and liveness-probe site. The desktop's collision-recovery path in packages/desktop/src/main/window-manager.ts previously read the holder PID straight from <contentDir>/.ok/local/server.lock (user-writable JSON) and called process.kill(pid, 'SIGTERM') after only a typeof pid === 'number' shape check, allowing crafted lock entries with negative PIDs (process-group signaling on Unix), 0, 1 (init), NaN, or values above 0x7fffffff to direct signals at unintended processes.

    The fix layers three defenses through the new shared isValidLockPid validator (packages/server/src/process-alive.ts):

    • Parse-time validation. process-lock.ts, lock-state.ts (CLI ok stop / ok ps), shadow-lock.ts, and the desktop window manager now all reject hostile PIDs as corrupt/stale before isProcessAlive or process.kill ever sees them.
    • TOCTOU re-verification. WindowManager.verifyHolderStillOwnsLock re-reads the lock right before signaling and confirms the holder PID hasn't changed since the collision report.
    • Self-PID guard. The auto-kill path refuses to send a signal when existingLock.pid === process.pid, blocking a self-SIGTERM if PID-recycling ever produces a collision metadata that names the desktop itself.
  • The ok ui static-server /api reverse proxy now enforces the same loopback-only and Host-header-allowlist gates that the upstream server applies before forwarding any request, instead of trusting that requests reaching the proxy are loopback-bound. Without the gate, a non-loopback peer could reach the upstream API by talking to the static server's port directly. The gate refuses with 403 {ok:false, error} (and X-Content-Type-Options: nosniff to match the upstream's response posture) when either condition fails, before any proxy handshake.

  • POST /api/seed/plan and POST /api/seed/apply now refuse seed entries whose resolved target path lies outside the project directory, either via lexical ..-traversal in the entry's relative path or via any symlink in the target's resolved chain whose realpath escapes the project root. The new assertNoSymlinkEscape walks each existing ancestor of the target, calls realpathSync, and rejects paths that resolve outside realpath(projectDir) with a SeedRootDirError. Symlink loops surface as ELOOP and are also rejected. Without these guards a seed plan could write to /etc/, the user's home directory, or any other writable location reachable through a symlink the project happens to ship.

  • The asset upload endpoint (POST /api/upload-asset) now refuses to write when any segment of the destination directory is a symbolic link, before performing the recursive mkdirSync(destDir, { recursive: true }) that previously could traverse a symlink and place uploaded files outside the project's content root. An attacker who could plant a symlink at <contentDir>/some/dir (via git clone of a malicious repo, file-watcher race, or another endpoint that creates uncanonicalized paths) could otherwise have uploads land in /etc/, ~/.ssh/, or any other writable location the server process could reach. The handler now lstats each ancestor segment and refuses with symlink-escape when it finds a symlink, regardless of whether the symlink's realpath would resolve back inside contentDir.

View v0.4.0 on GitHub