diff --git a/.changeset/config.json b/.changeset/config.json
new file mode 100644
index 0000000..0ffcf5b
--- /dev/null
+++ b/.changeset/config.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json",
+ "changelog": "@changesets/cli/changelog",
+ "commit": false,
+ "fixed": [
+ [
+ "@visimer/core",
+ "@visimer/dom",
+ "@visimer/react",
+ "@visimer/codemirror",
+ "@visimer/monaco"
+ ]
+ ],
+ "linked": [],
+ "access": "public",
+ "baseBranch": "main",
+ "updateInternalDependencies": "patch",
+ "ignore": ["playground", "js-playground", "site"]
+}
diff --git a/.changeset/rename-visimer.md b/.changeset/rename-visimer.md
new file mode 100644
index 0000000..9be07c3
--- /dev/null
+++ b/.changeset/rename-visimer.md
@@ -0,0 +1,12 @@
+---
+"@visimer/core": major
+"@visimer/dom": major
+"@visimer/react": major
+"@visimer/codemirror": major
+"@visimer/monaco": major
+---
+
+Rename the library to visimer. Packages move from `@inkeep/mermaid-wysiwyg-*` to
+scoped `@visimer/*` (`core`, `dom`, `react`, `codemirror`, `monaco`); the public
+repo moves from `inkeep/mermaid-wysiwyg` to `inkeep/visimer`. Update imports and
+install commands accordingly.
diff --git a/.changeset/visimer-editing-polish.md b/.changeset/visimer-editing-polish.md
new file mode 100644
index 0000000..06579e7
--- /dev/null
+++ b/.changeset/visimer-editing-polish.md
@@ -0,0 +1,23 @@
+---
+"@visimer/core": minor
+"@visimer/dom": minor
+"@visimer/react": minor
+"@visimer/codemirror": minor
+"@visimer/monaco": minor
+---
+
+Textbox-grade inline editing: the canvas holds re-renders while an in-place
+label session is typing (live commits still sync the code surfaces), long
+labels stay on one line instead of wrapping into the clip, Escape reverts
+reliably, and a swap already in flight carries typed text and caret across.
+
+Popovers behave like part of the diagram: they anchor at the exact twin
+element clicked, flip below the node instead of clipping in overflow-hidden
+hosts, clamp horizontally, follow external pan/zoom transforms, and close on
+any interaction outside the canvas.
+
+New opt-in `panZoom` canvas option (DOM + React bindings): fit-to-canvas by
+default, drag empty space to pan, pinch or ctrl+wheel to zoom at the cursor,
+and corner zoom controls.
+
+All packages are relicensed from GPL-3.0-or-later to MIT.
diff --git a/.github/scripts/bridge-public-pr-to-monorepo.mjs b/.github/scripts/bridge-public-pr-to-monorepo.mjs
new file mode 100644
index 0000000..664c53c
--- /dev/null
+++ b/.github/scripts/bridge-public-pr-to-monorepo.mjs
@@ -0,0 +1,908 @@
+import { execFileSync } from 'node:child_process';
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+// Keep the public PR bridge copies code-shape aligned. They ship to
+// separate public repos through Copybara, so they cannot import shared code.
+// Sibling bridge copies:
+// - public/agents/.github/scripts/bridge-public-pr-to-monorepo.mjs
+// - public/agents-optional-local-dev/.github/scripts/bridge-public-pr-to-monorepo.mjs
+// - public/open-knowledge/.github/scripts/bridge-public-pr-to-monorepo.mjs
+const OSS_SYNC_BOT_NAME = 'inkeep-oss-sync[bot]';
+const OSS_SYNC_BOT_EMAIL = '274976938+inkeep-oss-sync[bot]@users.noreply.github.com';
+
+// Strip x-access-token credentials from any string that might end up in an
+// error message, log line, or thrown exception. GitHub Actions masks repo
+// secrets in its own job log, but this script's exceptions can also surface in
+// failure-alert issues or future error-reporting integrations — none of which
+// inherit the Actions log mask. Defense-in-depth: redact at the boundary.
+function sanitizeErrorMessage(value) {
+ if (typeof value !== 'string') return value;
+ return value.replace(/https:\/\/x-access-token:[^@\s]+@/g, 'https://x-access-token:***@');
+}
+
+function run(command, args, options = {}) {
+ try {
+ return execFileSync(command, args, {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ ...options,
+ }).trim();
+ } catch (error) {
+ const stderr = sanitizeErrorMessage(error.stderr?.toString().trim() ?? '');
+ const stdout = sanitizeErrorMessage(error.stdout?.toString().trim() ?? '');
+ const details = [stderr, stdout].filter(Boolean).join('\n');
+ const fallback = sanitizeErrorMessage(`${command} ${args.join(' ')} failed`);
+ throw new Error(details || fallback);
+ }
+}
+
+async function githubRequest({
+ token,
+ method = 'GET',
+ path: requestPath,
+ body,
+ accept = 'application/vnd.github+json',
+}) {
+ const response = await fetch(`https://api.github.com${requestPath}`, {
+ method,
+ headers: {
+ Accept: accept,
+ Authorization: `Bearer ${token}`,
+ 'User-Agent': 'inkeep-public-pr-bridge',
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+
+ const text = await response.text();
+ if (!response.ok) {
+ throw new Error(`${method} ${requestPath} failed (${response.status}): ${text}`);
+ }
+
+ // .patch and .diff return raw text, not JSON. All other accept types
+ // (incl. the default application/vnd.github+json) return JSON.
+ const isTextResponse =
+ accept === 'application/vnd.github.patch' || accept === 'application/vnd.github.diff';
+ return isTextResponse ? text : text ? JSON.parse(text) : null;
+}
+
+async function githubGraphql({ token, query, variables }) {
+ const result = await githubRequest({
+ token,
+ method: 'POST',
+ path: '/graphql',
+ body: { query, variables },
+ });
+ if (result?.errors?.length) {
+ const messages = result.errors.map((e) => e.message).join('; ');
+ throw new Error(`GraphQL error: ${messages}`);
+ }
+ return result;
+}
+
+function requireEnv(name) {
+ const value = process.env[name];
+ if (!value) {
+ throw new Error(`Missing required environment variable: ${name}`);
+ }
+ return value;
+}
+
+function getPublicPrBranchName(prefix, prNumber) {
+ return `${prefix}-${prNumber}`;
+}
+
+function parseJsonEnv(name, fallback) {
+ const value = process.env[name];
+ if (!value) {
+ return fallback;
+ }
+
+ try {
+ return JSON.parse(value);
+ } catch (error) {
+ throw new Error(`Invalid JSON in ${name}: ${error.message}`);
+ }
+}
+
+function publicPrAuthor(publicPr) {
+ return {
+ name: publicPr.user.login,
+ email: `${publicPr.user.id}+${publicPr.user.login}@users.noreply.github.com`,
+ };
+}
+
+function normalizeGitHubUserAuthor(user) {
+ const login = user?.login?.trim();
+ const id = user?.id;
+ if (!login || id === undefined || id === null) return null;
+ return {
+ name: login,
+ email: `${id}+${login}@users.noreply.github.com`,
+ };
+}
+
+function normalizeCommitAuthor(author) {
+ const name = author?.name?.trim();
+ const email = author?.email?.trim();
+ if (!name || !email || !email.includes('@')) return null;
+ if (/[\r\n<>]/.test(name) || /[\r\n<>]/.test(email)) return null;
+ return { name, email };
+}
+
+function parseCoauthorTrailer(line) {
+ const match = line.match(/^Co-authored-by:\s*(.+?)\s*<([^<>\s]+@[^<>\s]+)>\s*$/i);
+ if (!match) return null;
+ return normalizeCommitAuthor({ name: match[1], email: match[2] });
+}
+
+function coauthorsFromCommitMessage(message) {
+ return normalizeCommitMessage(message)
+ .split('\n')
+ .map((line) => parseCoauthorTrailer(line.trim()))
+ .filter(Boolean);
+}
+
+function uniqueCommitAuthors(authors) {
+ const unique = new Map();
+ for (const author of authors) {
+ const normalized = normalizeCommitAuthor(author);
+ if (!normalized) continue;
+ unique.set(`${normalized.name.toLowerCase()} <${normalized.email.toLowerCase()}>`, normalized);
+ }
+ return [...unique.values()];
+}
+
+function normalizePublicPrCommit(commit) {
+ return {
+ sha: typeof commit?.sha === 'string' ? commit.sha : null,
+ author: normalizeGitHubUserAuthor(commit?.author) ?? commit?.commit?.author,
+ message: typeof commit?.commit?.message === 'string' ? commit.commit.message : '',
+ };
+}
+
+async function listPublicPrCommits({ token, repo, prNumber, request = githubRequest }) {
+ const publicCommits = [];
+ let page = 1;
+ while (true) {
+ const commits = await request({
+ token,
+ path: `/repos/${repo}/pulls/${prNumber}/commits?per_page=100&page=${page}`,
+ });
+ publicCommits.push(...commits.map((commit) => normalizePublicPrCommit(commit)));
+ if (commits.length < 100) break;
+ page++;
+ }
+ return publicCommits;
+}
+
+function normalizeCommitMessage(message) {
+ if (typeof message !== 'string') return '';
+ return message.replace(/\r\n?/g, '\n').replace(/\0/g, '').trim();
+}
+
+function formatOriginalCommitMessages(commitMessages, publicRepo) {
+ const entries = commitMessages
+ .map((commit) => {
+ const message = normalizeCommitMessage(commit?.message);
+ if (!message) return null;
+ const rawSha = typeof commit?.sha === 'string' ? commit.sha : '';
+ const sha = /^[0-9a-f]{7,40}$/i.test(rawSha) ? rawSha : null;
+ const shortSha = sha ? sha.slice(0, 7) : null;
+ const author = normalizeCommitAuthor(commit?.author);
+ return { author, sha, shortSha, message };
+ })
+ .filter(Boolean);
+
+ if (entries.length === 0) return '';
+
+ const formatted = entries.map((entry) => {
+ const lines = [];
+ if (entry.sha && entry.shortSha && publicRepo) {
+ lines.push(`[${entry.shortSha}](https://github.com/${publicRepo}/commit/${entry.sha})`);
+ } else if (entry.shortSha) {
+ lines.push(entry.shortSha);
+ } else {
+ lines.push('Commit');
+ }
+ if (entry.author) {
+ lines.push(`Author: ${entry.author.name} <${entry.author.email}>`);
+ }
+ lines.push('');
+ lines.push(entry.message);
+ return lines.join('\n');
+ });
+
+ return ['Commits:', '', formatted.join('\n\n')].join('\n');
+}
+
+function buildCommitAttribution({ commitAuthors, commitMessages = [], publicRepo }) {
+ const authors = uniqueCommitAuthors([
+ ...commitAuthors,
+ ...commitMessages.map((commit) => commit?.author),
+ ...commitMessages.flatMap((commit) => coauthorsFromCommitMessage(commit?.message)),
+ ]);
+ const trailers = authors.map((author) => `Co-authored-by: ${author.name} <${author.email}>`);
+ const originalCommitMessages = formatOriginalCommitMessages(commitMessages, publicRepo);
+ const body = [originalCommitMessages, trailers.join('\n')].filter(Boolean).join('\n\n');
+ return { trailers, originalCommitMessages, body };
+}
+
+// True when a `githubRequest` failed because the PR diff exceeds GitHub's
+// hard cap on the diff endpoint (currently 20,000 lines). The API surfaces
+// this as a 406 with body `diff exceeded the maximum number of lines (20000)`,
+// or a JSON error with `diff_too_large` in the message. Detect by message
+// text since we don't preserve the HTTP status separately. The patterns are
+// kept narrow on purpose: a bare `too_large` would also match unrelated 422s
+// (e.g. PR body validation `{"code":"too_long"}` is adjacent — `too_large`
+// itself is rare for non-diff endpoints, but we don't rely on coincidence).
+function isDiffTooLargeError(error) {
+ if (!error || typeof error.message !== 'string') return false;
+ return /diff exceeded the maximum number of lines|diff is too large|diff_too_large/i.test(
+ error.message
+ );
+}
+
+// Compute the PR's diff locally from the public PR refs that syncPublicPr has
+// already fetched into agents-private's object store. 3-dot diff mirrors
+// GitHub's `.diff` semantics (compares against merge-base). Used as the
+// fallback when the API rejects the PR as too large; also implicitly helps
+// `git apply --3way` later because the same fetch made the patch's base blobs
+// reachable in agents-private's clone.
+//
+// maxBuffer is bumped to 50 MB because this fallback fires specifically for
+// oversized PRs (>20,000 lines on the API endpoint). Node's default 1 MB
+// would truncate the very diffs this path is meant to handle.
+function fetchPullRequestDiffViaLocalGit({ internalRepoDir, sourceBaseRef, sourceHeadRef }) {
+ return run('git', ['-C', internalRepoDir, 'diff', `${sourceBaseRef}...${sourceHeadRef}`], {
+ maxBuffer: 50 * 1024 * 1024,
+ });
+}
+
+async function fetchPullRequestDiff({
+ publicToken,
+ publicRepo,
+ publicPr,
+ internalRepoDir,
+ sourceBaseRef,
+ sourceHeadRef,
+ refsFetched,
+}) {
+ try {
+ return await githubRequest({
+ token: publicToken,
+ path: `/repos/${publicRepo}/pulls/${publicPr.number}`,
+ accept: 'application/vnd.github.diff',
+ });
+ } catch (error) {
+ if (!isDiffTooLargeError(error)) throw error;
+ if (!refsFetched) {
+ throw new Error(
+ `Bridge: cannot use local-git-diff fallback for PR #${publicPr.number} — ` +
+ `the public PR refs failed to fetch into agents-private earlier in this run. ` +
+ `See the preceding "Bridge: fetch at --depth=..." warning for the original ` +
+ `fetch failure; resolve that and re-run.`
+ );
+ }
+ console.log(
+ `Bridge: GitHub diff API rejected PR #${publicPr.number} as too large; ` +
+ 'falling back to local git diff against fetched public PR refs.'
+ );
+ return fetchPullRequestDiffViaLocalGit({
+ internalRepoDir,
+ sourceBaseRef,
+ sourceHeadRef,
+ });
+ }
+}
+
+// Drop diff sections whose old or new path matches any excluded prefix.
+// Excluded paths are relative to the PUBLIC repo root (pre-prefix). Used to
+// stop pre-cutover branches from re-introducing internal-only paths
+// (`specs/`, `reports/`, `.codex/`, etc.) that the public mirror no longer
+// exports — those paths exist on agents-private's side but should not flow
+// back through the bridge.
+function filterDiffByPath(patch, excludedPrefixes) {
+ if (!excludedPrefixes || excludedPrefixes.length === 0) return patch;
+
+ const sections = patch.split(/(?=^diff --git )/m);
+ const kept = [];
+ const dropped = [];
+
+ for (const section of sections) {
+ if (!section.startsWith('diff --git ')) {
+ kept.push(section);
+ continue;
+ }
+ const match = section.match(/^diff --git a\/(.+?) b\/(.+?)\n/);
+ if (!match) {
+ kept.push(section);
+ continue;
+ }
+ const aPath = match[1].replace(/^"(.+)"$/, '$1');
+ const bPath = match[2].replace(/^"(.+)"$/, '$1');
+
+ const isExcluded = excludedPrefixes.some(
+ (prefix) => aPath.startsWith(prefix) || bPath.startsWith(prefix)
+ );
+
+ if (isExcluded) {
+ dropped.push(aPath === bPath ? aPath : `${aPath} -> ${bPath}`);
+ } else {
+ kept.push(section);
+ }
+ }
+
+ if (dropped.length > 0) {
+ const preview = dropped.slice(0, 20).join('\n ');
+ const more = dropped.length > 20 ? `\n ...and ${dropped.length - 20} more` : '';
+ console.log(
+ `Bridge: filtered ${dropped.length} diff section(s) matching excluded prefixes:\n ${preview}${more}`
+ );
+ }
+
+ return kept.join('');
+}
+
+function prefixPatchPaths(patch, prefix, pathRewrites = {}) {
+ const normalizedPrefix = prefix.replace(/^\/+|\/+$/g, '');
+ const prefixedPath = (value) => {
+ if (value === '/dev/null') {
+ return value;
+ }
+
+ const unquoted = value.replace(/^"(.+)"$/, '$1');
+ const segments = unquoted.split('/');
+ if (segments.some((s) => s === '..' || s === '.')) {
+ throw new Error(`Rejecting patch with path traversal: ${unquoted}`);
+ }
+
+ const rewrite = pathRewrites[unquoted];
+ if (rewrite) {
+ const rewriteSegments = rewrite.split('/');
+ if (rewriteSegments.some((s) => s === '..' || s === '.')) {
+ throw new Error(`Rejecting patch rewrite with path traversal: ${rewrite}`);
+ }
+ }
+
+ const nextValue = rewrite ?? `${normalizedPrefix}/${unquoted}`.replace(/\/+/g, '/');
+ return value.startsWith('"') ? `"${nextValue}"` : nextValue;
+ };
+
+ return patch
+ .split('\n')
+ .map((line) => {
+ if (line.startsWith('diff --git a/')) {
+ const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
+ if (!match) {
+ return line;
+ }
+ return `diff --git a/${prefixedPath(match[1])} b/${prefixedPath(match[2])}`;
+ }
+ if (line.startsWith('--- a/')) {
+ return `--- a/${prefixedPath(line.slice(6))}`;
+ }
+ if (line.startsWith('+++ b/')) {
+ return `+++ b/${prefixedPath(line.slice(6))}`;
+ }
+ if (line.startsWith('rename from ')) {
+ return `rename from ${prefixedPath(line.slice('rename from '.length))}`;
+ }
+ if (line.startsWith('rename to ')) {
+ return `rename to ${prefixedPath(line.slice('rename to '.length))}`;
+ }
+ if (line.startsWith('copy from ')) {
+ return `copy from ${prefixedPath(line.slice('copy from '.length))}`;
+ }
+ if (line.startsWith('copy to ')) {
+ return `copy to ${prefixedPath(line.slice('copy to '.length))}`;
+ }
+ return line;
+ })
+ .join('\n');
+}
+
+function internalPullRequestTitle(publicPr) {
+ return `Sync ${publicPr.base.repo.full_name} public PR #${publicPr.number}: ${publicPr.title}`;
+}
+
+function buildBridgeMetadata(publicPr, mirrorPath) {
+ return [
+ '',
+ ].join('\n');
+}
+
+// GitHub PR body hard limit. Exceeding returns 422 "body is too long".
+const GITHUB_PR_BODY_LIMIT = 65536;
+
+function buildInternalPrBody({ publicPr, branchName, mirrorPath }) {
+ const rawOriginal = publicPr.body?.trim()
+ ? publicPr.body.trim()
+ : '_No public PR body was provided._';
+
+ const compose = (original) => `## Summary
+Mirror public PR [#${publicPr.number}](${publicPr.html_url}) from \`${publicPr.base.repo.full_name}\` into \`inkeep/agents-private\` for internal review and merge.
+
+## Attribution
+- Original author: @${publicPr.user.login}
+- Public branch: \`${publicPr.head.label}\`
+- Monorepo branch: \`${branchName}\`
+- Monorepo path: \`${mirrorPath}\`
+
+## Original PR Body
+
+Expand
+
+${original}
+
+
+
+## Notes
+- Do not edit this PR directly. This branch is fully managed by the public PR bridge and may be overwritten on the next sync.
+- Do not merge the public repo PR. Public mirror PRs cannot land changes directly.
+- To accept the contribution, merge this monorepo PR. The change will sync back to the public repo automatically, and the public PR will close automatically.
+- To make edits or updates to these changes, they should be made directly to the public PR. Contributor updates there will sync back into this monorepo PR.
+
+${buildBridgeMetadata(publicPr, mirrorPath)}`;
+
+ let body = compose(rawOriginal);
+ if (body.length > GITHUB_PR_BODY_LIMIT) {
+ const footer = `\n\n_...truncated. Original body exceeded GitHub's ${GITHUB_PR_BODY_LIMIT}-char PR body limit; see [original PR](${publicPr.html_url}) for full content._`;
+ const scaffolding = body.length - rawOriginal.length;
+ const budget = GITHUB_PR_BODY_LIMIT - scaffolding - footer.length - 100;
+ const truncated = rawOriginal.slice(0, Math.max(budget, 0)) + footer;
+ console.log(
+ `Bridge: PR body exceeded GitHub's ${GITHUB_PR_BODY_LIMIT}-char limit ` +
+ `(original: ${rawOriginal.length} chars, truncated to: ${truncated.length} chars).`
+ );
+ body = compose(truncated);
+ }
+ return body;
+}
+
+function buildWelcomePublicComment() {
+ return `Thanks for the contribution!
+
+**What happens next:**
+
+- A maintainer will review your PR.
+- If you don't hear back within a few business days, please comment here to nudge our team.
+- This repository is maintained through an internal mirror. When your change is accepted, this PR will close automatically. Don't be alarmed when it closes — that's how it merges, and your authorship is preserved.`;
+}
+
+async function createIssueComment({ token, repo, issueNumber, body }) {
+ const created = await githubRequest({
+ token,
+ method: 'POST',
+ path: `/repos/${repo}/issues/${issueNumber}/comments`,
+ body: { body },
+ });
+ return created.html_url;
+}
+
+async function acknowledgePublicPr() {
+ const publicToken = requireEnv('PUBLIC_TOKEN');
+ const publicRepo = requireEnv('PUBLIC_REPO');
+ const publicPrNumber = Number.parseInt(requireEnv('PUBLIC_PR_NUMBER'), 10);
+
+ await createIssueComment({
+ token: publicToken,
+ repo: publicRepo,
+ issueNumber: publicPrNumber,
+ body: buildWelcomePublicComment(),
+ });
+}
+
+async function findOpenInternalPr({ token, repo, owner, branchName }) {
+ const pulls = await githubRequest({
+ token,
+ path: `/repos/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${branchName}`)}`,
+ });
+ return pulls[0] ?? null;
+}
+
+async function ensureDraftState({ token, pullRequest, shouldBeDraft }) {
+ if (Boolean(pullRequest.draft) === Boolean(shouldBeDraft)) {
+ return;
+ }
+
+ const query = shouldBeDraft
+ ? `mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { id } } }`
+ : `mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { id } } }`;
+
+ await githubGraphql({
+ token,
+ query,
+ variables: { id: pullRequest.node_id },
+ });
+}
+
+/**
+ * Apply monorepo-specific patches that upstream configs don't have.
+ * Currently patches next.config.ts to add outputFileTracingRoot which is
+ * required for Next.js standalone builds in a monorepo context.
+ * Returns true if any files were modified.
+ */
+function reconcileMonorepoPatches(repoDir, mirrorPath) {
+ let changed = false;
+
+ // Patch next.config.ts files under the mirror path to add outputFileTracingRoot
+ const nextConfigPaths = [path.join(repoDir, mirrorPath, 'agents-manage-ui', 'next.config.ts')];
+
+ for (const configPath of nextConfigPaths) {
+ if (!existsSync(configPath)) continue;
+
+ let content = readFileSync(configPath, 'utf8');
+
+ // Skip if already has outputFileTracingRoot
+ if (content.includes('outputFileTracingRoot')) continue;
+
+ // Add outputFileTracingRoot next to the output: 'standalone' line
+ if (content.includes("output: 'standalone'")) {
+ content = content.replace(
+ "output: 'standalone'",
+ "output: 'standalone',\n outputFileTracingRoot: monorepoRoot"
+ );
+ writeFileSync(configPath, content, 'utf8');
+ console.log(`Patched outputFileTracingRoot into ${configPath}`);
+ changed = true;
+ }
+ }
+
+ return changed;
+}
+
+async function syncPublicPr() {
+ const publicToken = requireEnv('PUBLIC_TOKEN');
+ const internalToken = requireEnv('INTERNAL_TOKEN');
+ const publicRepo = requireEnv('PUBLIC_REPO');
+ const internalRepo = requireEnv('INTERNAL_REPO');
+ const internalRepoDir = requireEnv('INTERNAL_REPO_DIR');
+ const mirrorPath = requireEnv('MONOREPO_PATH_PREFIX');
+ const internalBaseRef = requireEnv('INTERNAL_BASE_REF');
+ const internalBranchPrefix = requireEnv('INTERNAL_BRANCH_PREFIX');
+ const publicPrAction = process.env.PUBLIC_PR_ACTION ?? 'opened';
+ const publicPrNumber = Number.parseInt(requireEnv('PUBLIC_PR_NUMBER'), 10);
+ const pathRewrites = parseJsonEnv('PUBLIC_PR_PATH_REWRITES', {});
+ const internalOwner = internalRepo.split('/')[0];
+ const branchName = getPublicPrBranchName(internalBranchPrefix, publicPrNumber);
+
+ const publicPr = await githubRequest({
+ token: publicToken,
+ path: `/repos/${publicRepo}/pulls/${publicPrNumber}`,
+ });
+
+ let internalPr = await findOpenInternalPr({
+ token: internalToken,
+ repo: internalRepo,
+ owner: internalOwner,
+ branchName,
+ });
+
+ const metadataOnlyAction =
+ internalPr &&
+ (publicPrAction === 'edited' ||
+ publicPrAction === 'ready_for_review' ||
+ publicPrAction === 'converted_to_draft');
+
+ let hasStagedChanges = false;
+ if (!metadataOnlyAction) {
+ // Bring agents-private's main into the local clone and check out the new
+ // branch first. We need this in place before the public-PR-refs fetch so
+ // any blob already on main is deduplicated; we also need it before
+ // `git apply --3way` (later) regardless.
+ run('git', ['-C', internalRepoDir, 'fetch', 'origin', internalBaseRef, '--prune']);
+ run('git', ['-C', internalRepoDir, 'checkout', '-B', branchName, `origin/${internalBaseRef}`]);
+
+ // Fetch the public PR's base + head into agents-private's object store.
+ // Two purposes:
+ // 1. `git apply --3way` resolves the patch's base blobs locally even
+ // when public-mirror-sync is stalled and agents-private/main has
+ // drifted from `inkeep//main`. Without this, every conflicting
+ // hunk fails with "repository lacks the necessary blob to perform
+ // 3-way merge" — the dominant bridge-failure pattern for drifted
+ // public PRs.
+ // 2. Provides the baseline pair of refs for the local-git-diff fallback
+ // when the GitHub diff endpoint rejects the PR as too large.
+ const sourceRemote = `bridge-public-${publicPrNumber}`;
+ const sourceBaseRef = `refs/remotes/${sourceRemote}/pr-base`;
+ const sourceHeadRef = `refs/remotes/${sourceRemote}/pr-head`;
+ const publicRepoUrl = `https://x-access-token:${publicToken}@github.com/${publicRepo}.git`;
+
+ try {
+ run('git', ['-C', internalRepoDir, 'remote', 'remove', sourceRemote]);
+ } catch {
+ // remote did not exist; harmless
+ }
+ run('git', ['-C', internalRepoDir, 'remote', 'add', sourceRemote, publicRepoUrl]);
+
+ try {
+ // Initial fetch: --depth=10000 covers the long-running branches that
+ // trip the size-fallback. On the rare branch whose merge-base is
+ // deeper, the subsequent `git diff base...head` errors clearly with
+ // "no merge base" rather than producing a wrong diff — so we re-fetch
+ // with increasing depth before giving up.
+ let refsFetched = false;
+ for (const depth of [10000, 50000]) {
+ try {
+ run('git', [
+ '-C',
+ internalRepoDir,
+ 'fetch',
+ '--no-tags',
+ `--depth=${depth}`,
+ sourceRemote,
+ `+refs/pull/${publicPrNumber}/head:${sourceHeadRef}`,
+ `+refs/heads/${publicPr.base.ref}:${sourceBaseRef}`,
+ ]);
+ refsFetched = true;
+ break;
+ } catch (error) {
+ console.log(
+ `Bridge: fetch at --depth=${depth} failed: ${error.message}. ` +
+ `Retrying with deeper history if available.`
+ );
+ }
+ }
+ if (!refsFetched) {
+ console.log(
+ 'Bridge: warning: could not fetch public PR refs into agents-private at any depth. ' +
+ "Continuing — `git apply --3way` will still succeed if the public mirror's blobs already match agents-private/main, " +
+ 'but the local-git-diff fallback for oversized PRs will not be available.'
+ );
+ }
+
+ // Use .diff (unified squash) not .patch (multi-commit mailbox). .patch
+ // returns one patch per commit with intermediate blob SHAs that only
+ // exist in the public repo; any conflicting hunk forces --3way to look
+ // up those intermediates and fail. See agents copy of this script for
+ // full rationale.
+ //
+ // For PRs whose .diff exceeds GitHub's 20,000-line endpoint cap,
+ // fetchPullRequestDiff falls back to a local 3-dot `git diff` against
+ // the refs we just fetched.
+ const rawPatch = await fetchPullRequestDiff({
+ publicToken,
+ publicRepo,
+ publicPr,
+ internalRepoDir,
+ sourceBaseRef,
+ sourceHeadRef,
+ refsFetched,
+ });
+ const excludedPrefixes = parseJsonEnv('BRIDGE_EXCLUDED_PATHS', []);
+ const patch = filterDiffByPath(rawPatch, excludedPrefixes);
+
+ if (!patch.trim()) {
+ return;
+ }
+
+ const tempDir = mkdtempSync(path.join(tmpdir(), 'public-pr-bridge-'));
+ const patchFile = path.join(tempDir, 'public-pr.patch');
+ writeFileSync(patchFile, prefixPatchPaths(patch, mirrorPath, pathRewrites), 'utf8');
+
+ try {
+ try {
+ run('git', ['-C', internalRepoDir, 'apply', '--index', '--3way', patchFile]);
+ } catch (error) {
+ throw error;
+ }
+
+ hasStagedChanges = (() => {
+ const output = run('git', ['-C', internalRepoDir, 'diff', '--cached', '--name-only']);
+ return output.length > 0;
+ })();
+
+ if (hasStagedChanges) {
+ run('git', ['-C', internalRepoDir, 'config', 'user.name', OSS_SYNC_BOT_NAME]);
+ run('git', ['-C', internalRepoDir, 'config', 'user.email', OSS_SYNC_BOT_EMAIL]);
+
+ let publicCommits = [];
+ try {
+ publicCommits = await listPublicPrCommits({
+ token: publicToken,
+ repo: publicRepo,
+ prNumber: publicPr.number,
+ });
+ } catch (error) {
+ console.warn(
+ `Bridge: could not fetch public PR commit messages; using PR opener attribution only: ${error.message}`
+ );
+ }
+ const { body } = buildCommitAttribution({
+ commitAuthors: [publicPrAuthor(publicPr)],
+ commitMessages: publicCommits,
+ publicRepo,
+ });
+ run('git', [
+ '-C',
+ internalRepoDir,
+ 'commit',
+ '-m',
+ `sync(oss): mirror ${publicRepo}#${publicPr.number}`,
+ '-m',
+ body,
+ ]);
+
+ // Run monorepo reconciliation patches (e.g. outputFileTracingRoot for Next.js)
+ const reconciled = reconcileMonorepoPatches(internalRepoDir, mirrorPath);
+ if (reconciled) {
+ run('git', ['-C', internalRepoDir, 'add', '-A']);
+ run('git', [
+ '-C',
+ internalRepoDir,
+ 'commit',
+ '--author',
+ `${OSS_SYNC_BOT_NAME} <${OSS_SYNC_BOT_EMAIL}>`,
+ '-m',
+ `sync(oss): reconcile monorepo patches for ${publicRepo}#${publicPr.number}`,
+ ]);
+ }
+
+ run('git', [
+ '-C',
+ internalRepoDir,
+ 'push',
+ '--force-with-lease',
+ '--set-upstream',
+ 'origin',
+ branchName,
+ ]);
+ }
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ } finally {
+ // Always tear down the bridge-public remote, even on early return or
+ // throw, so subsequent runs (or a retry of the same PR) start clean.
+ try {
+ run('git', ['-C', internalRepoDir, 'remote', 'remove', sourceRemote]);
+ } catch {
+ // best-effort
+ }
+ }
+
+ internalPr = await findOpenInternalPr({
+ token: internalToken,
+ repo: internalRepo,
+ owner: internalOwner,
+ branchName,
+ });
+
+ if (!internalPr && !hasStagedChanges) {
+ return;
+ }
+ }
+
+ const title = internalPullRequestTitle(publicPr);
+ const body = buildInternalPrBody({ publicPr, branchName, mirrorPath });
+
+ if (internalPr) {
+ internalPr = await githubRequest({
+ token: internalToken,
+ method: 'PATCH',
+ path: `/repos/${internalRepo}/pulls/${internalPr.number}`,
+ body: { title, body },
+ });
+ await ensureDraftState({
+ token: internalToken,
+ pullRequest: internalPr,
+ shouldBeDraft: publicPr.draft,
+ });
+ } else {
+ internalPr = await githubRequest({
+ token: internalToken,
+ method: 'POST',
+ path: `/repos/${internalRepo}/pulls`,
+ body: {
+ title,
+ head: branchName,
+ base: internalBaseRef,
+ body,
+ draft: publicPr.draft,
+ },
+ });
+ }
+}
+
+async function closeLinkedInternalPr() {
+ const publicToken = requireEnv('PUBLIC_TOKEN');
+ const internalToken = requireEnv('INTERNAL_TOKEN');
+ const publicRepo = requireEnv('PUBLIC_REPO');
+ const internalRepo = requireEnv('INTERNAL_REPO');
+ const internalBranchPrefix = requireEnv('INTERNAL_BRANCH_PREFIX');
+ const publicPrNumber = Number.parseInt(requireEnv('PUBLIC_PR_NUMBER'), 10);
+ const internalOwner = internalRepo.split('/')[0];
+ const branchName = getPublicPrBranchName(internalBranchPrefix, publicPrNumber);
+
+ const publicPr = await githubRequest({
+ token: publicToken,
+ path: `/repos/${publicRepo}/pulls/${publicPrNumber}`,
+ });
+
+ const internalPr = await findOpenInternalPr({
+ token: internalToken,
+ repo: internalRepo,
+ owner: internalOwner,
+ branchName,
+ });
+
+ if (!internalPr) {
+ return;
+ }
+
+ if (publicPr.merged_at) {
+ return;
+ }
+
+ await githubRequest({
+ token: internalToken,
+ method: 'POST',
+ path: `/repos/${internalRepo}/issues/${internalPr.number}/comments`,
+ body: {
+ body: `Closing because the linked public PR [#${publicPr.number}](${publicPr.html_url}) was closed without merge.`,
+ },
+ });
+
+ await githubRequest({
+ token: internalToken,
+ method: 'PATCH',
+ path: `/repos/${internalRepo}/pulls/${internalPr.number}`,
+ body: { state: 'closed' },
+ });
+
+ try {
+ await githubRequest({
+ token: internalToken,
+ method: 'DELETE',
+ path: `/repos/${internalRepo}/git/refs/heads/${branchName}`,
+ });
+ } catch (error) {
+ console.log(`Branch cleanup skipped: ${error.message}`);
+ }
+}
+
+async function main() {
+ const mode = process.argv[2];
+ if (mode === 'acknowledge') {
+ await acknowledgePublicPr();
+ return;
+ }
+
+ if (mode === 'sync') {
+ await syncPublicPr();
+ return;
+ }
+
+ if (mode === 'close') {
+ await closeLinkedInternalPr();
+ return;
+ }
+
+ throw new Error(`Unsupported mode: ${mode}`);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ console.error(error.stack ?? error.message);
+ process.exitCode = 1;
+ });
+}
+
+export {
+ buildCommitAttribution,
+ buildInternalPrBody,
+ buildWelcomePublicComment,
+ listPublicPrCommits,
+ prefixPatchPaths,
+};
diff --git a/.github/workflows/monorepo-pr-bridge.yml b/.github/workflows/monorepo-pr-bridge.yml
new file mode 100644
index 0000000..6645422
--- /dev/null
+++ b/.github/workflows/monorepo-pr-bridge.yml
@@ -0,0 +1,127 @@
+name: Monorepo PR Bridge
+
+on:
+ pull_request_target:
+ types: [opened, reopened, synchronize, ready_for_review, converted_to_draft, edited, closed]
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+
+concurrency:
+ group: monorepo-pr-bridge-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ acknowledge:
+ # Public-facing acknowledgement should happen immediately on a new PR and
+ # stay decoupled from the inkeep-oss-sync environment approval that imports
+ # the PR into agents-private.
+ if: github.event.action == 'opened' && github.event.pull_request.head.ref != 'copybara/sync'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Checkout public repo automation
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 1
+
+ - name: Setup Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 22
+
+ - name: Acknowledge public PR
+ env:
+ PUBLIC_PR_NUMBER: ${{ github.event.pull_request.number }}
+ PUBLIC_REPO: ${{ github.repository }}
+ PUBLIC_TOKEN: ${{ github.token }}
+ run: node .github/scripts/bridge-public-pr-to-monorepo.mjs acknowledge
+
+ sync:
+ # Pause before minting the agents-private App token or writing any internal
+ # branch/PR. The environment has required reviewers configured in the public
+ # repo, so every public PR revision needs maintainer approval before crossing
+ # into agents-private.
+ if: github.event.action != 'closed' && github.event.pull_request.head.ref != 'copybara/sync'
+ environment: inkeep-oss-sync
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout public repo automation
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 1
+
+ - name: Setup Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 22
+
+ - name: Generate inkeep-oss-sync App token
+ id: app-token
+ uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
+ with:
+ app-id: ${{ secrets.OSS_SYNC_APP_ID }}
+ private-key: ${{ secrets.OSS_SYNC_APP_PRIVATE_KEY }}
+ owner: inkeep
+ repositories: agents-private
+
+ - name: Checkout agents-private
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ repository: inkeep/agents-private
+ ref: main
+ fetch-depth: 0
+ token: ${{ steps.app-token.outputs.token }}
+ path: agents-private
+
+ - name: Sync public PR into agents-private
+ env:
+ INTERNAL_BASE_REF: main
+ INTERNAL_BRANCH_PREFIX: public-pr/visimer
+ INTERNAL_REPO: inkeep/agents-private
+ INTERNAL_REPO_DIR: ${{ github.workspace }}/agents-private
+ INTERNAL_TOKEN: ${{ steps.app-token.outputs.token }}
+ MONOREPO_PATH_PREFIX: public/visimer
+ PUBLIC_PR_ACTION: ${{ github.event.action }}
+ PUBLIC_PR_NUMBER: ${{ github.event.pull_request.number }}
+ PUBLIC_REPO: ${{ github.repository }}
+ PUBLIC_TOKEN: ${{ github.token }}
+ run: node .github/scripts/bridge-public-pr-to-monorepo.mjs sync
+
+ close:
+ # Skip PRs that originated from agents-private via Copybara (prevents circular sync)
+ if: github.event.action == 'closed' && github.event.pull_request.head.ref != 'copybara/sync'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout public repo automation
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 1
+
+ - name: Setup Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 22
+
+ - name: Generate inkeep-oss-sync App token
+ id: app-token
+ uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
+ with:
+ app-id: ${{ secrets.OSS_SYNC_APP_ID }}
+ private-key: ${{ secrets.OSS_SYNC_APP_PRIVATE_KEY }}
+ owner: inkeep
+ repositories: agents-private
+
+ - name: Handle public PR closure
+ env:
+ INTERNAL_BRANCH_PREFIX: public-pr/visimer
+ INTERNAL_REPO: inkeep/agents-private
+ INTERNAL_TOKEN: ${{ steps.app-token.outputs.token }}
+ PUBLIC_PR_NUMBER: ${{ github.event.pull_request.number }}
+ PUBLIC_REPO: ${{ github.repository }}
+ PUBLIC_TOKEN: ${{ github.token }}
+ run: node .github/scripts/bridge-public-pr-to-monorepo.mjs close
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dd6e803
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+*.log
+.DS_Store
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6627490
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Inkeep
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5ab104a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,130 @@
+
+
+# visimer
+
+**Visual editing for Mermaid diagrams. Every gesture becomes a minimal text edit.**
+
+Click, drag, and type directly on the diagram; your Mermaid source updates surgically.
+Type in the code; the canvas follows live. One document, two surfaces, zero lock-in.
+
+[](./LICENSE)
+[](https://mermaid.js.org)
+[](#packages)
+
+