/**
 * Studiograph MCP tool definitions
 *
 * Each tool is a thin wrapper around existing WorkspaceManager / BaseGraphManager
 * methods — no new business logic. Results are returned as JSON text so the
 * MCP client (Claude Desktop) can process structured data.
 *
 * Every tool enforces per-user access control via `services/access-control.ts`.
 * Callers (the HTTP /mcp route, the stdio MCP server) must resolve a user
 * identity before registering tools — `registerTools` requires a user in its
 * `McpAccessContext` argument so there is no anonymous MCP path.
 *
 * Note: `inputSchema` is cast to `any` at each call site because the MCP SDK's
 * ZodRawShapeCompat type does not accept ZodObject in TypeScript's strict mode.
 * Runtime schema validation is handled correctly by the SDK regardless.
 */
import { z } from 'zod';
import { ENTITY_SCHEMAS } from '../core/validation.js';
import { getAccessibleRepos, hasRepoAccess, canWriteRepo, getAccessibleEntityIds, filterWikilinks, } from '../services/access-control.js';
import { createSharedFolder, FolderCreationError } from '../services/folder-creation.js';
import { isCuratedEntityType } from '../core/entity-types-catalog.js';
import { getAgentAllowedEntityTypes } from '../agent/prompts/entity-types-section.js';
import * as commentVirtualEntity from './comment-virtual-entity.js';
import { buildActor, prepareCreatePayload, prepareUpdatePayload, diagnosedUpdate, DroppedAssetError, } from '../services/entity-mutations.js';
import { extractAssetEmbeds, stripFrontmatter, assetEmbed } from '../core/embeds.js';
import { listRepoAssets } from '../services/asset-listing.js';
// ── helpers ───────────────────────────────────────────────────────────────────
function ok(data) {
    return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
}
function fail(err) {
    const msg = err instanceof Error ? err.message : String(err);
    return { content: [{ type: 'text', text: msg }], isError: true };
}
/** Deny-without-leaking helper — same wording as a missing repo/entity. */
function notFound(repo) {
    return fail(new Error(`Folder "${repo}" not found`));
}
// Strip frontmatter fields that duplicate the top-level id/type keys
function shape(id, entityType, data, extra) {
    const { id: _id, entity_type: _et, ...rest } = data;
    return { id, type: entityType, ...rest, ...extra };
}
/**
 * Default subfolders for entity types when the caller omits `folder_path`.
 * Existing entries with no folder live at the repo root; new entries of these
 * types route into a typed subfolder so the repo stays organised by default.
 * An explicit `folder_path: ""` (or any explicit value) overrides this map.
 */
const DEFAULT_FOLDER_BY_TYPE = {
    person: 'People',
    organization: 'Organizations',
};
/** Convert an absolute entity path to a repo-relative POSIX path. */
function relPathOf(absPath, repoRoot) {
    return absPath.startsWith(repoRoot + '/') ? absPath.slice(repoRoot.length + 1) : absPath;
}
// ── tool registration ─────────────────────────────────────────────────────────
export function registerTools(server, wm, authService, ctx) {
    const { user, wsHub, apiTokenName, commentService } = ctx;
    // External-agent identity: "<owner>'s <token name>", or "<owner>'s agent"
    // as a fallback when the token has no name (older tokens or contexts that
    // didn't thread the token through). `userId` lets the client paint the chip
    // with the same accent as the owning user's avatar.
    const agentLabel = apiTokenName
        ? `${user.displayName}'s ${apiTokenName}`
        : `${user.displayName}'s agent`;
    const agentIdentity = {
        kind: 'external',
        label: agentLabel,
        userId: String(user.id),
    };
    // Match the broadcast shape used by src/server/routes/graph-api.ts so the
    // realtime listener in web/src/lib/stores/realtime.svelte.ts treats MCP
    // mutations identically to web-UI mutations. The `agent` tag lets clients
    // distinguish agent-driven mutations from human-driven ones.
    function emitEntityChange(action, repo, entityType, entityId) {
        wsHub?.broadcast({
            type: 'entity_change',
            action,
            repo,
            entityType,
            entityId,
            actor: user.displayName,
            source: 'api',
            agent: agentIdentity,
            timestamp: new Date().toISOString(),
        });
    }
    /**
     * Wrap a mutation tool call so the sidebar can show a live "X's agent is
     * acting" chip in the affected folder. `enter` fires before the work,
     * `leave` after — wsHub TTL-cleans on crash. `verb` shapes the chip copy.
     */
    async function withAgentActivity(repo, verb, fn) {
        wsHub?.handleAgentActivity('enter', repo, agentIdentity, verb);
        try {
            return await fn();
        }
        finally {
            wsHub?.handleAgentActivity('leave', repo, agentIdentity, verb);
        }
    }
    // Entity types the MCP surface may create. Matches the agent's allowlist so
    // a workspace looks identical across the main app and MCP clients: the full
    // canonical catalog plus workspace custom types. See
    // src/core/entity-types-catalog.ts.
    const builtinTypeKeys = new Set(Object.keys(ENTITY_SCHEMAS));
    const customWorkspaceTypes = wm.getSchemaRegistry().getAllTypes()
        .filter(t => !builtinTypeKeys.has(t));
    const mcpAllowedCreateTypes = getAgentAllowedEntityTypes(customWorkspaceTypes);
    const mcpAllowedSet = new Set(mcpAllowedCreateTypes);
    // ── list_repos ────────────────────────────────────────────────────────────
    server.registerTool('list_repos', { description: 'List folders in the Studiograph workspace that you have access to' }, async () => ok(getAccessibleRepos(user, wm, authService)));
    // ── create_repo ───────────────────────────────────────────────────────────
    //
    // Creates a new shared folder in the workspace and grants the
    // caller folder-admin access. Shares its implementation with the web route
    // POST /api/repos via services/folder-creation.ts so both surfaces behave
    // identically. After creation, use create_entity with the new folder name
    // to add entries. Folder names are immutable slugs; the display name is
    // editable later via the web UI.
    const createRepoSchema = z.object({
        name: z.string().describe('Folder slug — lowercase kebab-case (letters, digits, hyphens; must start with a letter or digit). ' +
            'This becomes the folder id and directory name; it cannot be changed without a rename.'),
        display_name: z.string().optional().describe('Human-readable name shown in the UI (e.g. "Client Projects"). Defaults to the slug.'),
        description: z.string().optional().describe('Short description of what this folder is for.'),
    });
    server.registerTool('create_repo', {
        description: 'Create a new shared folder in the workspace. Use this when an agent or ' +
            'user needs a fresh container for a new project, client area, or topic and no existing ' +
            'folder fits. The caller is automatically granted folder-admin access. ' +
            'Follow up with create_entity (passing the new folder name as `repo`) to seed entries. ' +
            'Folder names must be kebab-case and unique; the private per-user "entries-<id>" ' +
            'namespace is reserved and will be rejected.',
        inputSchema: createRepoSchema,
    }, async (args) => {
        const { name, display_name, description } = createRepoSchema.parse(args);
        try {
            const repo = await createSharedFolder(wm, authService, { name, display_name, description }, user, wsHub);
            return ok({ success: true, ...repo });
        }
        catch (err) {
            if (err instanceof FolderCreationError) {
                return fail(new Error(err.message));
            }
            return fail(err);
        }
    });
    // ── list_entities ─────────────────────────────────────────────────────────
    const listEntitiesSchema = z.object({
        repo: z.string().optional().describe('Folder name — omit to list across all folders'),
        entity_type: z.string().optional().describe('Entity type filter (project, client, person, event…)'),
        limit: z.number().int().positive().optional().describe('Maximum number of results'),
    });
    server.registerTool('list_entities', { description: 'List entities in a folder (or all folders). Optionally filter by entity type.', inputSchema: listEntitiesSchema }, async (args) => {
        const { repo, entity_type, limit } = listEntitiesSchema.parse(args);
        try {
            // Comments live in SQLite, not the filesystem graph — dispatch through
            // the virtual-entity adapter when the caller asks for them explicitly.
            if (entity_type === 'comment' && commentService) {
                if (repo && !hasRepoAccess(user, repo, wm, authService))
                    return notFound(repo);
                const accessibleNames = new Set(getAccessibleRepos(user, wm, authService).map(r => r.name));
                const envelopes = commentVirtualEntity.listComments(commentService, { repo, limit });
                const filtered = envelopes.filter(e => accessibleNames.has(e.data.repo));
                return ok(filtered.map(e => shape(e.id, e.entityType, e.data, { repo: e.data.repo })));
            }
            if (repo) {
                if (!hasRepoAccess(user, repo, wm, authService))
                    return notFound(repo);
                const entities = wm.getGraph(repo).list(entity_type, limit);
                return ok(entities.map(e => shape(e.id, e.entityType, e.data)));
            }
            // No repo specified → iterate only over the user's accessible folders.
            const accessible = getAccessibleRepos(user, wm, authService);
            const results = [];
            for (const r of accessible) {
                try {
                    const list = wm.getGraph(r.name).list(entity_type);
                    for (const e of list) {
                        results.push(shape(e.id, e.entityType, e.data, { repo: r.name }));
                        if (limit && results.length >= limit)
                            return ok(results);
                    }
                }
                catch { /* skip unmounted repos */ }
            }
            return ok(results);
        }
        catch (err) {
            return fail(err);
        }
    });
    // ── get_entity ────────────────────────────────────────────────────────────
    const getEntitySchema = z.object({
        repo: z.string().describe('Folder name'),
        entity_type: z.string().describe('Entity type (project, client, person…)'),
        entity_id: z.string().describe('Entity ID (kebab-case slug)'),
    });
    server.registerTool('get_entity', { description: 'Get a specific entity — includes full markdown content and frontmatter', inputSchema: getEntitySchema }, async (args) => {
        const { repo, entity_type, entity_id } = getEntitySchema.parse(args);
        if (!hasRepoAccess(user, repo, wm, authService))
            return notFound(repo);
        try {
            if (entity_type === 'comment' && commentService) {
                const envelope = commentVirtualEntity.getComment(commentService, entity_id);
                if (!envelope)
                    return fail(new Error(`Comment "${entity_id}" not found`));
                if (envelope.data.repo !== repo)
                    return notFound(repo);
                return ok({ id: envelope.id, type: envelope.entityType, data: envelope.data, content: envelope.document.content });
            }
            const graph = wm.getGraph(repo);
            const entity = graph.get(entity_type, entity_id);
            // Prefer the live editor body when a seeded Y.Doc is open: graph.get
            // reads disk, which lags the editor by the autosave debounce, so a
            // user's freshly-placed image/asset embed may not be on disk yet.
            // Returning disk content here is what let agents edit-then-overwrite
            // and silently drop those embeds. null => no live doc, disk is
            // authoritative.
            const liveBody = graph.getLiveBody(entity_type, entity_id);
            const content = liveBody ?? entity.document.content;
            return ok({ id: entity.id, type: entity.entityType, data: entity.data, content });
        }
        catch (err) {
            return fail(err);
        }
    });
    // ── search_entities ───────────────────────────────────────────────────────
    const searchSchema = z.object({
        query: z.string().optional().describe('Text search query'),
        entity_type: z.string().optional().describe('Filter by entity type'),
        repos: z.array(z.string()).optional().describe('Limit to specific folder names'),
        limit: z.number().int().positive().optional().describe('Maximum number of results'),
    });
    server.registerTool('search_entities', { description: 'Full-text search for entities across folders you can access', inputSchema: searchSchema }, async (args) => {
        const { query, entity_type, repos, limit } = searchSchema.parse(args);
        try {
            const accessibleNames = new Set(getAccessibleRepos(user, wm, authService).map(r => r.name));
            const scoped = repos
                ? repos.filter(r => accessibleNames.has(r))
                : [...accessibleNames];
            if (scoped.length === 0)
                return ok([]);
            const results = await wm.search({ query, entityType: entity_type, repoNames: scoped, limit });
            return ok(results.map(e => shape(e.id, e.entityType, e.data, { repo: e.repoName })));
        }
        catch (err) {
            return fail(err);
        }
    });
    // ── create_entity ─────────────────────────────────────────────────────────
    // Constrain `entity_type` to the allowlist so MCP clients (e.g. Claude Desktop)
    // see the same type surface as the main agent. Existing canonical-type entities
    // remain readable/updatable via other tools.
    const createEntityTypeSchema = mcpAllowedCreateTypes.length > 0
        ? z.enum(mcpAllowedCreateTypes)
        : z.string();
    const createSchema = z.object({
        repo: z.string().describe('Folder name'),
        entity_type: createEntityTypeSchema.describe(`Entity type. Allowed: ${mcpAllowedCreateTypes.join(', ')}.`),
        entity_id: z.string().describe('Entity ID (kebab-case slug)'),
        data: z.record(z.string(), z.unknown()).describe('Type-specific frontmatter fields (e.g. for person: name, email, organization, role). ' +
            'Base fields (entity_id, entity_type, created_at, updated_at, created_by, updated_by) ' +
            'are stamped automatically — do not include them.'),
        content: z.string().optional().describe('Markdown body content'),
        folder_path: z.string().optional().describe('Repo-relative subfolder to place the entry in (e.g. "People", "clients/acme"). ' +
            'Omit to use the per-type default (person → People, organization → Organizations); ' +
            'pass an empty string to force the repo root. Parent folders are auto-created.'),
    });
    server.registerTool('create_entity', {
        description: 'Create a new entity in a folder. Commits to git automatically. ' +
            'Base frontmatter (entity_id, created_at, updated_at, created_by, updated_by) is ' +
            'stamped automatically — `data` should only carry type-specific fields. ' +
            'For person entries, `status` accepts synonyms (active/prospective/inactive/past/etc.) ' +
            'and normalizes to `current` | `former`; defaults to `current` when omitted. ' +
            'Without `folder_path`, person entries route to People/ and organization entries to Organizations/; ' +
            'other types land at the repo root. Pass `folder_path: ""` to force the repo root, ' +
            'or any subfolder string (e.g. "Clients/Acme") to place it explicitly. ' +
            'Response includes the final repo-relative `path`.',
        inputSchema: createSchema,
    }, async (args) => {
        const { repo, entity_type, entity_id, data, content, folder_path } = createSchema.parse(args);
        if (!hasRepoAccess(user, repo, wm, authService))
            return notFound(repo);
        if (!canWriteRepo(user, repo, wm, authService)) {
            return fail(new Error(`You do not have write access to folder "${repo}"`));
        }
        // Defense in depth: MCP SDK may not enforce zod enum strictly on all clients.
        if (!mcpAllowedSet.has(entity_type)) {
            if (entity_type === 'asset') {
                // The enum already excludes `asset` (see getAgentAllowedEntityTypes).
                // This handles MCP clients that bypass the enum schema.
                return fail(new Error('Asset entities cannot be created via create_entity — they require a binary blob plus a sidecar text file. Use the workspace upload UI or POST /api/repos/:repo/assets directly.'));
            }
            const hint = isCuratedEntityType(entity_type)
                ? '' // shouldn't happen for any other curated type — they're all allowed
                : ` Allowed: ${mcpAllowedCreateTypes.join(', ')}.`;
            return fail(new Error(`Entity type "${entity_type}" is not a known canonical or workspace custom type.${hint}`));
        }
        // Explicit folder_path (including "") wins; otherwise apply the per-type
        // default so the agent doesn't have to know the workspace convention.
        const effectiveFolder = folder_path !== undefined
            ? folder_path
            : (DEFAULT_FOLDER_BY_TYPE[entity_type] ?? '');
        const actor = buildActor(user);
        const options = prepareCreatePayload({
            entityType: entity_type,
            entityId: entity_id,
            data: data,
            content,
            folderPath: effectiveFolder || undefined,
        }, actor);
        return withAgentActivity(repo, 'creating', async () => {
            try {
                const graph = wm.getGraph(repo);
                const entity = await graph.create(options);
                emitEntityChange('created', repo, entity.entityType, entity.id);
                return ok({
                    success: true,
                    id: entity.id,
                    type: entity.entityType,
                    repo,
                    path: relPathOf(entity.path, graph.getRepoPath()),
                    folder_path: entity.folder_path,
                });
            }
            catch (err) {
                return fail(err);
            }
        });
    });
    // ── update_entity ─────────────────────────────────────────────────────────
    //
    // Bare success envelopes were misleading agents: callers couldn't tell
    // whether their write landed, whether validation silently dropped a field,
    // or whether they'd just rewritten the file with identical bytes. The
    // response now includes path, commit, before/after hashes, per-section
    // changed flags, and the post-write entity snapshot so agents can verify
    // their intent matches reality without a follow-up get_entity round-trip.
    //
    // No-op detection compares semantic frontmatter (excluding auto-stamped
    // `updated_at` / `updated_by`) and raw content. A no-op is reported as
    // success with `noop: true` — not an error — so idempotent re-runs are
    // explicit. If the caller passed data or content but the on-disk file is
    // byte-identical after the write, that's a silent-drop signal: the call
    // fails loudly with a message that names which inputs were ignored.
    const updateSchema = z.object({
        repo: z.string().describe('Folder name'),
        entity_type: z.string().describe('Entity type'),
        entity_id: z.string().describe('Entity ID'),
        data: z.record(z.string(), z.unknown()).optional().describe('Frontmatter fields to update (merged with existing)'),
        content: z.string().optional().describe('New markdown body content (replaces the entire body)'),
        allow_image_removal: z.boolean().optional().describe('Whole-body writes are rejected with code ASSET_REMOVAL_BLOCKED when the submitted content would drop ' +
            'image/attachment embeds that the current document still has (a common silent-data-loss bug when an agent ' +
            'edits stale content). Set true ONLY when you intend to delete those embeds. Prefer patch_entity for small edits.'),
    });
    server.registerTool('update_entity', {
        description: 'Update an existing entity. Data fields are merged with existing frontmatter; content replaces the markdown body. ' +
            'Callers do not need to resend immutable base fields (entity_id, created_at, created_by) — they are preserved from disk. ' +
            '`updated_at` and `updated_by` are stamped automatically. ' +
            'For person entries, `status` synonyms are normalized to `current` | `former` when supplied. ' +
            'Returns the post-write entity snapshot plus diagnostic flags (frontmatter_changed, content_changed, noop, commit) ' +
            'so the caller can verify the change landed without a separate get_entity call.',
        inputSchema: updateSchema,
    }, async (args) => {
        const { repo, entity_type, entity_id, data, content, allow_image_removal } = updateSchema.parse(args);
        if (!hasRepoAccess(user, repo, wm, authService))
            return notFound(repo);
        if (!canWriteRepo(user, repo, wm, authService)) {
            return fail(new Error(`You do not have write access to folder "${repo}"`));
        }
        if (data === undefined && content === undefined) {
            return fail(new Error('update_entity requires at least one of `data` or `content`'));
        }
        const actor = buildActor(user);
        const options = prepareUpdatePayload({
            entityType: entity_type,
            entityId: entity_id,
            data: data,
            content,
        }, actor);
        return withAgentActivity(repo, 'editing', async () => {
            try {
                const graph = wm.getGraph(repo);
                // Diagnosed update (shared with the agent surface): snapshots
                // before, runs the server-write path (disk + commit serialize via
                // the per-doc lock; live clients get the post-commit broadcast),
                // and throws SilentDropError when the caller intended a change
                // that produced no on-disk diff. The outer catch maps that to fail.
                const { entity, frontmatter_changed, content_changed, noop, broadcast } = await diagnosedUpdate(graph, options, { data, content }, { allowAssetRemoval: allow_image_removal });
                emitEntityChange('updated', repo, entity.entityType, entity.id);
                // Repo-relative path so external agents don't see the workspace
                // absolute path. The HEAD commit is captured after graph.update
                // commits the transaction — null when noGit is on or no commit
                // was produced (e.g. transaction self-cleared because there was
                // no staged diff).
                const repoRoot = graph.getRepoPath();
                const relPath = entity.path.startsWith(repoRoot + '/')
                    ? entity.path.slice(repoRoot.length + 1)
                    : entity.path;
                const commit = graph.getGitService()?.getHeadCommit() ?? null;
                return ok({
                    success: true,
                    id: entity.id,
                    type: entity.entityType,
                    repo,
                    path: relPath,
                    commit,
                    frontmatter_changed,
                    content_changed,
                    noop,
                    broadcast,
                    updated_at: entity.data.updated_at ?? null,
                    entity: {
                        id: entity.id,
                        type: entity.entityType,
                        data: entity.data,
                        content: entity.document.content,
                    },
                });
            }
            catch (err) {
                if (err instanceof DroppedAssetError) {
                    return ok({
                        success: false,
                        code: 'ASSET_REMOVAL_BLOCKED',
                        droppedAssets: err.dropped,
                        message: err.message,
                    });
                }
                return fail(err);
            }
        });
    });
    // ── patch_entity ──────────────────────────────────────────────────────────
    //
    // Surgical body edit: replace an exact substring without resending the
    // whole body. The motivating failure was an agent fetching content,
    // renaming a heading, and resending the entire body — which dropped
    // editor-placed image embeds it never saw. patch_entity can't drop what it
    // doesn't send: it reads the CURRENT body (live editor when open, else
    // disk), applies the literal find→replace, and writes the result through
    // the same guarded server-write path. The asset-drop guard still runs, so
    // a patch that happens to delete an embed is rejected unless the substring
    // is itself the embed being intentionally removed.
    const patchEntitySchema = z.object({
        repo: z.string().describe('Folder name'),
        entity_type: z.string().describe('Entity type'),
        entity_id: z.string().describe('Entity ID'),
        find: z.string().describe('Exact substring to locate in the current body (verbatim, including whitespace).'),
        replace: z.string().describe('Replacement text. Empty string deletes the matched substring.'),
        expected_count: z.number().int().positive().optional().describe('Optional safety check: require `find` to occur exactly this many times. The patch is rejected with ' +
            'NO_MATCH / AMBIGUOUS_MATCH when the actual count differs, so you never edit the wrong occurrence.'),
    });
    server.registerTool('patch_entity', {
        description: 'Surgically edit an entry body by replacing an exact substring — without rewriting the whole body. ' +
            'Prefer this over update_entity for small edits (renaming a heading, fixing a sentence): it operates on ' +
            'the live editor body and cannot drop image/asset embeds it did not touch. Returns the same diagnostic ' +
            'flags as update_entity. Use `expected_count` to guard against ambiguous matches.',
        inputSchema: patchEntitySchema,
    }, async (args) => {
        const { repo, entity_type, entity_id, find, replace, expected_count } = patchEntitySchema.parse(args);
        if (!hasRepoAccess(user, repo, wm, authService))
            return notFound(repo);
        if (!canWriteRepo(user, repo, wm, authService)) {
            return fail(new Error(`You do not have write access to folder "${repo}"`));
        }
        if (find === '')
            return fail(new Error('patch_entity requires a non-empty `find` string'));
        if (find === replace)
            return fail(new Error('patch_entity `find` and `replace` are identical — nothing to do'));
        return withAgentActivity(repo, 'editing', async () => {
            try {
                const graph = wm.getGraph(repo);
                const entity = graph.get(entity_type, entity_id);
                // Patch against the live editor body when one is open, so we don't
                // reintroduce the stale-read problem we're fixing.
                const currentBody = graph.getLiveBody(entity_type, entity_id) ?? entity.document.content;
                // Count occurrences of the literal substring (no regex).
                let count = 0;
                for (let i = currentBody.indexOf(find); i !== -1; i = currentBody.indexOf(find, i + find.length))
                    count++;
                if (count === 0) {
                    return ok({ success: false, code: 'NO_MATCH', message: `\`find\` substring not present in ${entity_type}/${entity_id}.` });
                }
                if (expected_count !== undefined && count !== expected_count) {
                    return ok({ success: false, code: 'AMBIGUOUS_MATCH', occurrences: count, message: `Expected ${expected_count} occurrence(s) of \`find\`, found ${count}.` });
                }
                const newBody = currentBody.split(find).join(replace);
                const options = prepareUpdatePayload({ entityType: entity_type, entityId: entity_id, content: newBody }, buildActor(user));
                const { entity: updated, frontmatter_changed, content_changed, noop, broadcast } = await diagnosedUpdate(graph, options, { content: newBody });
                emitEntityChange('updated', repo, updated.entityType, updated.id);
                const repoRoot = graph.getRepoPath();
                const relPath = updated.path.startsWith(repoRoot + '/') ? updated.path.slice(repoRoot.length + 1) : updated.path;
                return ok({
                    success: true,
                    id: updated.id, type: updated.entityType, repo, path: relPath,
                    commit: graph.getGitService()?.getHeadCommit() ?? null,
                    occurrences: count, frontmatter_changed, content_changed, noop, broadcast,
                });
            }
            catch (err) {
                if (err instanceof DroppedAssetError) {
                    return ok({ success: false, code: 'ASSET_REMOVAL_BLOCKED', droppedAssets: err.dropped, message: err.message });
                }
                return fail(err);
            }
        });
    });
    // ── list_assets ─────────────────────────────────────────────────────────
    //
    // Read-only discovery of assets (images, PDFs, files) already uploaded to a
    // folder. Shares listRepoAssets() with the HTTP asset endpoints, so the
    // shape — including the render-safe `embed` string — is identical. Assets
    // are created only through the upload routes / workspace UI; this tool never
    // creates one, it only surfaces existing ones so an agent can reference them
    // in an entry body without guessing a URL or embed syntax.
    const listAssetsSchema = z.object({
        repo: z.string().describe('Folder name'),
        entity_type: z.string().optional().describe('Filter to assets owned by entries of this type'),
        entity_id: z.string().optional().describe('Filter to assets owned by this entry id'),
    });
    server.registerTool('list_assets', {
        description: 'List assets (images, PDFs, files) already uploaded to a folder. Each result includes a ready-to-paste ' +
            '`embed` — standard markdown `![name](url)` — that renders in the editor. To display an asset in an entry, ' +
            'place that exact `embed` string via attach_asset_to_entity (or patch_entity for append/insert). ' +
            'NEVER write `![[filename]]` transclusion syntax: it does not resolve to a stored asset and shows as dead ' +
            'text. Assets are uploaded via the workspace UI — this tool only references existing ones. Optionally ' +
            'filter by entity_type and/or entity_id to list a single entry\'s assets.',
        inputSchema: listAssetsSchema,
    }, async (args) => {
        const { repo, entity_type, entity_id } = listAssetsSchema.parse(args);
        if (!hasRepoAccess(user, repo, wm, authService))
            return notFound(repo);
        try {
            wm.getGraph(repo);
        }
        catch {
            return notFound(repo);
        }
        let assets = await listRepoAssets(repo, wm);
        if (entity_type)
            assets = assets.filter(a => a.entityType === entity_type);
        if (entity_id)
            assets = assets.filter(a => a.entityId === entity_id);
        return ok({
            repo,
            count: assets.length,
            assets: assets.map(a => ({
                asset_id: a.assetId,
                filename: a.filename,
                mime_type: a.contentType,
                entity_type: a.entityType,
                entity_id: a.entityId,
                entity_name: a.entityName,
                url: a.url,
                embed: a.embed,
            })),
        });
    });
    // ── attach_asset_to_entity ──────────────────────────────────────────────
    //
    // Place an already-uploaded asset (from list_assets) into an entry body
    // without rewriting the whole body. Thin convenience over the patch_entity
    // machinery: it builds the render-safe `![alt](url)` embed and either
    // appends it or swaps a placeholder for it, then writes through the same
    // guarded server-write path.
    //
    // `replace_placeholder` is the sanctioned way to fix a dead `![[Foo.png]]`
    // placeholder. Note: that swap necessarily *drops* the transclusion target
    // (`Foo.png`) the asset-drop guard recognizes, so a plain patch_entity of
    // the same span would be rejected with ASSET_REMOVAL_BLOCKED. Here we pass
    // allowAssetRemoval for replace_placeholder only — the agent is explicitly
    // targeting that placeholder and we're re-adding the image as a working
    // embed, so no real asset is lost. `append` keeps the guard on.
    const attachAssetSchema = z.object({
        repo: z.string().describe('Folder name'),
        entity_type: z.string().describe('Entity type of the target entry'),
        entity_id: z.string().describe('Entity ID of the target entry'),
        url: z.string().describe('Asset url, taken verbatim from list_assets (e.g. /api/assets/<id>/<file>)'),
        alt: z.string().optional().describe('Alt text / caption for the embed. Defaults to the asset filename.'),
        mode: z.enum(['append', 'replace_placeholder']).default('append').describe('"append" adds the embed at the end of the body. "replace_placeholder" swaps `placeholder` text ' +
            '(e.g. a dead `![[Foo.png]]` written earlier) for the working embed.'),
        placeholder: z.string().optional().describe('Required when mode="replace_placeholder": exact text to replace.'),
        expected_count: z.number().int().positive().optional().describe('For replace_placeholder: require `placeholder` to occur exactly this many times, else reject (AMBIGUOUS_MATCH).'),
    });
    server.registerTool('attach_asset_to_entity', {
        description: 'Embed an already-uploaded asset (from list_assets) into an entry body using its render-safe markdown, ' +
            'without rewriting the whole body. mode="append" adds it at the end; mode="replace_placeholder" swaps dead ' +
            'placeholder text (e.g. an `![[Foo.png]]` an agent wrote earlier) for the working embed. Drop-guarded on ' +
            'append: rejects with ASSET_REMOVAL_BLOCKED if it would remove other image embeds.',
        inputSchema: attachAssetSchema,
    }, async (args) => {
        const { repo, entity_type, entity_id, url, alt, mode, placeholder, expected_count } = attachAssetSchema.parse(args);
        if (!hasRepoAccess(user, repo, wm, authService))
            return notFound(repo);
        if (!canWriteRepo(user, repo, wm, authService)) {
            return fail(new Error(`You do not have write access to folder "${repo}"`));
        }
        if (!url.trim())
            return fail(new Error('attach_asset_to_entity requires a non-empty `url`'));
        if (mode === 'replace_placeholder' && !placeholder) {
            return fail(new Error('mode="replace_placeholder" requires a non-empty `placeholder`'));
        }
        const filename = alt ?? (url.split('/').pop() || 'asset').split('?')[0];
        const embed = assetEmbed(filename, url);
        return withAgentActivity(repo, 'editing', async () => {
            try {
                const graph = wm.getGraph(repo);
                const entity = graph.get(entity_type, entity_id);
                const currentBody = graph.getLiveBody(entity_type, entity_id) ?? entity.document.content;
                let newBody;
                let occurrences;
                let allowAssetRemoval = false;
                if (mode === 'replace_placeholder') {
                    const ph = placeholder;
                    let count = 0;
                    for (let i = currentBody.indexOf(ph); i !== -1; i = currentBody.indexOf(ph, i + ph.length))
                        count++;
                    if (count === 0) {
                        return ok({ success: false, code: 'NO_MATCH', message: `placeholder not present in ${entity_type}/${entity_id}.` });
                    }
                    if (expected_count !== undefined && count !== expected_count) {
                        return ok({ success: false, code: 'AMBIGUOUS_MATCH', occurrences: count, message: `Expected ${expected_count} occurrence(s) of placeholder, found ${count}.` });
                    }
                    occurrences = count;
                    newBody = currentBody.split(ph).join(embed);
                    // The placeholder we're removing is itself a recognized embed
                    // target; swapping it for the working embed is the whole point.
                    allowAssetRemoval = true;
                }
                else {
                    if (currentBody.includes(embed)) {
                        return ok({ success: false, code: 'ALREADY_PRESENT', message: `Asset embed already present in ${entity_type}/${entity_id}.` });
                    }
                    newBody = currentBody.trimEnd() + '\n\n' + embed + '\n';
                }
                const options = prepareUpdatePayload({ entityType: entity_type, entityId: entity_id, content: newBody }, buildActor(user));
                const { entity: updated, frontmatter_changed, content_changed, noop, broadcast } = await diagnosedUpdate(graph, options, { content: newBody }, { allowAssetRemoval });
                emitEntityChange('updated', repo, updated.entityType, updated.id);
                const repoRoot = graph.getRepoPath();
                const relPath = updated.path.startsWith(repoRoot + '/') ? updated.path.slice(repoRoot.length + 1) : updated.path;
                return ok({
                    success: true,
                    id: updated.id, type: updated.entityType, repo, path: relPath,
                    embed, mode, occurrences,
                    commit: graph.getGitService()?.getHeadCommit() ?? null,
                    frontmatter_changed, content_changed, noop, broadcast,
                });
            }
            catch (err) {
                if (err instanceof DroppedAssetError) {
                    return ok({ success: false, code: 'ASSET_REMOVAL_BLOCKED', droppedAssets: err.dropped, message: err.message });
                }
                return fail(err);
            }
        });
    });
    // ── move_entity ───────────────────────────────────────────────────────────
    //
    // Relocate an entity to a different folder within the same repo. Wraps
    // graph.move() which renames the file on disk and commits the rename so
    // git's similarity detection preserves history. The (type, id) identity
    // is unchanged — get_entity, search, list, validate_wikilinks, and the
    // history tools all walk the entire repo tree and continue to find the
    // entry at its new path. For cross-repo transfers, the web UI's bulk-move
    // dialog or POST /api/repos/:repo/transfer is the right surface.
    const moveEntitySchema = z.object({
        repo: z.string().describe('Folder name containing the entry'),
        entity_type: z.string().describe('Entity type'),
        entity_id: z.string().describe('Entity ID'),
        folder_path: z.string().describe('Target repo-relative folder path (e.g. "People", "Clients/Acme"). ' +
            'Empty string moves the entry to the repo root. Parent folders are auto-created.'),
        overwrite: z.boolean().optional().describe('When true, replace an existing entry at the destination. The occupant ' +
            'must itself be a parseable entity — README/.gitkeep/etc. are never ' +
            'clobbered. Defaults to false; the call errors with "Path already ' +
            'occupied" when the destination is taken.'),
    });
    server.registerTool('move_entity', {
        description: 'Move an existing entry to a different folder within the same folder/repo. ' +
            'Preserves frontmatter, markdown content, entity ID, and git history (file is renamed, not rewritten). ' +
            'Use this to organise entries into subfolders after creation — e.g. moving a person into People/. ' +
            'By default, errors if the destination already has an entry; pass `overwrite: true` to replace.',
        inputSchema: moveEntitySchema,
    }, async (args) => {
        const { repo, entity_type, entity_id, folder_path, overwrite } = moveEntitySchema.parse(args);
        if (!hasRepoAccess(user, repo, wm, authService))
            return notFound(repo);
        if (!canWriteRepo(user, repo, wm, authService)) {
            return fail(new Error(`You do not have write access to folder "${repo}"`));
        }
        const actor = buildActor(user);
        return withAgentActivity(repo, 'editing', async () => {
            try {
                const graph = wm.getGraph(repo);
                const moved = await graph.move(entity_type, entity_id, folder_path, undefined, actor, overwrite);
                emitEntityChange('updated', repo, moved.entityType, moved.id);
                return ok({
                    success: true,
                    id: moved.id,
                    type: moved.entityType,
                    repo,
                    path: relPathOf(moved.path, graph.getRepoPath()),
                    folder_path: moved.folder_path,
                });
            }
            catch (err) {
                return fail(err);
            }
        });
    });
    // ── validate_wikilinks ────────────────────────────────────────────────────
    const validateWikilinksSchema = z.object({
        repo: z.string().describe('Folder name'),
        entity_type: z.string().describe('Entity type'),
        entity_id: z.string().describe('Entity ID'),
    });
    server.registerTool('validate_wikilinks', { description: 'Check which wikilinks in an entity resolve to real entities and which are broken', inputSchema: validateWikilinksSchema }, async (args) => {
        const { repo, entity_type, entity_id } = validateWikilinksSchema.parse(args);
        if (!hasRepoAccess(user, repo, wm, authService))
            return notFound(repo);
        try {
            const result = wm.getGraph(repo).validateWikilinks(entity_type, entity_id);
            const accessibleIds = getAccessibleEntityIds(user, wm, authService);
            const valid = filterWikilinks(result.valid, accessibleIds);
            // Anything the user can't see is reported as broken rather than valid —
            // it shouldn't leak the target's existence.
            const hiddenFromValid = result.valid.filter(id => !valid.includes(id));
            const broken = [...result.broken, ...hiddenFromValid];
            return ok({ entity_id, valid, broken, total: valid.length + broken.length });
        }
        catch (err) {
            return fail(err);
        }
    });
    // ── find_related ──────────────────────────────────────────────────────────
    const relatedSchema = z.object({
        repo: z.string().describe('Folder name'),
        entity_type: z.string().describe('Entity type'),
        entity_id: z.string().describe('Entity ID'),
        direction: z.enum(['outgoing', 'incoming', 'both']).default('both')
            .describe('outgoing = links from this entity, incoming = links to this entity'),
    });
    server.registerTool('find_related', { description: 'Find entities related via wikilinks (outgoing) or backlinks (incoming)', inputSchema: relatedSchema }, async (args) => {
        const { repo, entity_type, entity_id, direction } = relatedSchema.parse(args);
        if (!hasRepoAccess(user, repo, wm, authService))
            return notFound(repo);
        try {
            const accessibleIds = getAccessibleEntityIds(user, wm, authService);
            const results = [];
            if (direction === 'outgoing' || direction === 'both') {
                const related = wm.getGraph(repo).getRelated(entity_type, entity_id);
                for (const e of related) {
                    if (accessibleIds && !accessibleIds.has(e.id))
                        continue;
                    results.push(shape(e.id, e.entityType, e.data, { direction: 'outgoing' }));
                }
            }
            if (direction === 'incoming' || direction === 'both') {
                // Comments anchored to this entry surface as incoming backlinks.
                if (commentService) {
                    const anchored = commentVirtualEntity.findRelatedComments(commentService, repo, entity_type, entity_id);
                    for (const e of anchored) {
                        results.push(shape(e.id, e.entityType, e.data, { direction: 'incoming', repo }));
                    }
                }
                const backlinks = wm.getBacklinks(repo, entity_type, entity_id);
                for (const e of backlinks) {
                    if (!hasRepoAccess(user, e.repoName, wm, authService))
                        continue;
                    results.push(shape(e.id, e.entityType, e.data, { direction: 'incoming', repo: e.repoName }));
                }
            }
            return ok(results);
        }
        catch (err) {
            return fail(err);
        }
    });
    // ── list_skills ──────────────────────────────────────────────────────────
    //
    // Surfaces user-authored `skill` entities to external MCP clients so
    // connected agents (Claude Desktop, Cursor, etc.) can discover and run
    // the workspace's codified workflows. Only graph skills (entities) are
    // exposed; bundled filesystem skills (onboarding/interview/enrichment
    // prompts) stay internal to Studiograph's own agent.
    const listSkillsSchema = z.object({
        repo: z.string().optional().describe('Folder name — omit to list across all folders you have access to'),
    });
    function projectSkill(entity, repo) {
        const data = entity.data;
        return {
            id: entity.id,
            name: data.name || entity.id,
            description: data.description ?? '',
            repo,
            loading: data.loading === 'eager' ? 'eager' : 'on-demand',
        };
    }
    // `graph.list('skill')` is lenient — root-level .md files without a
    // declared `entity_type` (READMEs, CHANGELOGs) get tagged as whatever the
    // caller asks for. For MCP we want only real skills, so require the
    // frontmatter to explicitly say `entity_type: skill`.
    function isRealSkill(entity) {
        return entity.data.entity_type === 'skill';
    }
    server.registerTool('list_skills', {
        description: 'List skills (reusable agent instructions) available in the Studiograph workspace. ' +
            'Skills are user-authored markdown — call get_skill to read the full body and follow ' +
            'its instructions. Optionally filter by folder.',
        inputSchema: listSkillsSchema,
    }, async (args) => {
        const { repo } = listSkillsSchema.parse(args);
        try {
            if (repo) {
                if (!hasRepoAccess(user, repo, wm, authService))
                    return notFound(repo);
                const skills = wm.getGraph(repo).list('skill');
                return ok(skills.filter(isRealSkill).map(e => projectSkill(e, repo)));
            }
            const accessible = getAccessibleRepos(user, wm, authService);
            const results = [];
            for (const r of accessible) {
                try {
                    const skills = wm.getGraph(r.name).list('skill');
                    for (const e of skills) {
                        if (isRealSkill(e))
                            results.push(projectSkill(e, r.name));
                    }
                }
                catch { /* skip unmounted repos */ }
            }
            return ok(results);
        }
        catch (err) {
            return fail(err);
        }
    });
    // ── get_skill ────────────────────────────────────────────────────────────
    //
    // Returns the full markdown body of a skill alongside its metadata. The
    // body is instructions to follow — the MCP client is the runtime. No
    // server-side agent loop, no parameter substitution in v1.
    const getSkillSchema = z.object({
        name: z.string().describe('Skill id (the entity_id of the skill entity)'),
        repo: z.string().optional().describe('Folder to look in. Omit to search across all accessible folders.'),
    });
    server.registerTool('get_skill', {
        description: 'Fetch the full content of a skill. Returns the skill\'s markdown body along with its ' +
            'metadata. The body contains instructions to follow — read it and apply it to the current task.',
        inputSchema: getSkillSchema,
    }, async (args) => {
        const { name, repo } = getSkillSchema.parse(args);
        try {
            if (repo) {
                if (!hasRepoAccess(user, repo, wm, authService))
                    return notFound(repo);
                try {
                    const entity = wm.getGraph(repo).get('skill', name);
                    if (!isRealSkill({ data: entity.data })) {
                        return fail(new Error(`Skill "${name}" not found`));
                    }
                    return ok({
                        ...projectSkill({ id: entity.id, data: entity.data }, repo),
                        content: entity.document.content,
                    });
                }
                catch {
                    return fail(new Error(`Skill "${name}" not found`));
                }
            }
            // No repo — search accessible folders for the skill by id.
            const accessible = getAccessibleRepos(user, wm, authService);
            const matches = [];
            for (const r of accessible) {
                try {
                    const entity = wm.getGraph(r.name).get('skill', name);
                    if (!isRealSkill({ data: entity.data }))
                        continue;
                    matches.push({ repo: r.name, entity });
                }
                catch { /* not in this repo */ }
            }
            if (matches.length === 0)
                return fail(new Error(`Skill "${name}" not found`));
            if (matches.length > 1) {
                const folders = matches.map(m => m.repo).join(', ');
                return fail(new Error(`Skill "${name}" exists in multiple folders (${folders}). Specify repo.`));
            }
            const { repo: matchRepo, entity } = matches[0];
            return ok({
                ...projectSkill({ id: entity.id, data: entity.data }, matchRepo),
                content: entity.document.content,
            });
        }
        catch (err) {
            return fail(err);
        }
    });
    // ── Git history tools ──────────────────────────────────────────────────────
    //
    // Mirrors the agent's history-tools.ts surface so external MCP clients get
    // the same primitives. Reads gated on hasRepoAccess; restore_file gated on
    // canWriteRepo. Single-entity scope only — multi-entity rollback is
    // intentionally absent (deferred to a future revert_commit).
    const listFileHistorySchema = z.object({
        repo: z.string().describe('Folder name'),
        entity_type: z.string().describe('Entity type'),
        entity_id: z.string().describe('Entry ID'),
        limit: z.number().optional().describe('Max commits (default 20, max 100)'),
    });
    server.registerTool('list_file_history', { description: 'List commits that touched a single entry, newest first.', inputSchema: listFileHistorySchema }, async (args) => {
        const { repo, entity_type, entity_id, limit } = listFileHistorySchema.parse(args);
        try {
            if (!hasRepoAccess(user, repo, wm, authService))
                return fail(`Folder "${repo}" not found`);
            const graph = wm.getGraph(repo);
            const gitService = graph.getGitService();
            if (!gitService)
                return fail('Folder is not git-backed');
            let entityPath;
            try {
                const entity = graph.get(entity_type, entity_id);
                const { relative: relativePath } = await import('path');
                entityPath = relativePath(graph.getRepoPath(), entity.path);
            }
            catch {
                const idLower = `${entity_id}.md`.toLowerCase();
                const match = gitService.listRecentDeletions(30).find((d) => d.filePath.toLowerCase().endsWith(idLower));
                if (!match)
                    return fail(`Entry ${entity_type}/${entity_id} not found in "${repo}".`);
                entityPath = match.filePath;
            }
            const cap = Math.min(100, Math.max(1, limit ?? 20));
            const commits = gitService.getFileHistory(entityPath, cap).map((c) => ({
                hash: c.hash.slice(0, 8),
                fullHash: c.hash,
                author: c.author,
                timestamp: c.timestamp,
                message: c.message,
            }));
            return ok({ repo, entityType: entity_type, entityId: entity_id, commits });
        }
        catch (err) {
            return fail(err);
        }
    });
    const listFolderHistorySchema = z.object({
        repo: z.string().describe('Folder name'),
        limit: z.number().optional().describe('Max commits (default 30, max 200)'),
    });
    server.registerTool('list_folder_history', { description: 'List recent commits across an entire folder.', inputSchema: listFolderHistorySchema }, async (args) => {
        const { repo, limit } = listFolderHistorySchema.parse(args);
        try {
            if (!hasRepoAccess(user, repo, wm, authService))
                return fail(`Folder "${repo}" not found`);
            const gitService = wm.getGraph(repo).getGitService();
            if (!gitService)
                return fail('Folder is not git-backed');
            const cap = Math.min(200, Math.max(1, limit ?? 30));
            const commits = gitService.getHistory(cap).map((c) => ({
                hash: c.hash.slice(0, 8),
                fullHash: c.hash,
                author: c.author,
                timestamp: c.timestamp,
                message: c.message,
            }));
            return ok({ repo, commits });
        }
        catch (err) {
            return fail(err);
        }
    });
    const listRecentDeletionsSchema = z.object({
        repo: z.string().describe('Folder name'),
        within_days: z.number().optional().describe('Lookback window (default 7, max 90)'),
    });
    server.registerTool('list_recent_deletions', { description: 'List entries deleted within the last N days. Use before restore_file to find a target.', inputSchema: listRecentDeletionsSchema }, async (args) => {
        const { repo, within_days } = listRecentDeletionsSchema.parse(args);
        try {
            if (!hasRepoAccess(user, repo, wm, authService))
                return fail(`Folder "${repo}" not found`);
            const gitService = wm.getGraph(repo).getGitService();
            if (!gitService)
                return fail('Folder is not git-backed');
            const days = Math.min(90, Math.max(1, within_days ?? 7));
            const raw = gitService.listRecentDeletions(days);
            // Naive coords: use filename basename for entity_id; entity_type
            // sniffed from frontmatter at lastVersionCommit.
            const deletions = raw.map((d) => {
                const idMatch = d.filePath.match(/([^/]+)\.md$/i);
                const entityId = idMatch ? idMatch[1] : null;
                const content = entityId ? gitService.getFileAtCommit(d.filePath, d.lastVersionCommit) : null;
                let entityType = null;
                if (content) {
                    const fm = content.match(/^---\s*\n([\s\S]*?)\n---/);
                    const tm = fm?.[1].match(/^entity_type:\s*['"]?([\w-]+)['"]?\s*$/m);
                    if (tm)
                        entityType = tm[1];
                }
                if (!entityId || !entityType)
                    return null;
                return {
                    entityType,
                    entityId,
                    deletedAt: d.deletedAt,
                    deletedBy: d.deletedBy.name,
                    deletedInCommit: d.deletedAtCommit.slice(0, 8),
                    lastVersionCommit: d.lastVersionCommit,
                    commitMessage: d.commitMessage,
                    path: d.filePath,
                };
            }).filter((x) => x !== null);
            return ok({ repo, withinDays: days, deletions });
        }
        catch (err) {
            return fail(err);
        }
    });
    const readFileAtCommitSchema = z.object({
        repo: z.string().describe('Folder name'),
        entity_type: z.string().describe('Entity type'),
        entity_id: z.string().describe('Entry ID'),
        commit_hash: z.string().describe('Commit hash (short or full)'),
    });
    server.registerTool('read_file_at_commit', { description: 'Read an entry\'s full content at a specific commit.', inputSchema: readFileAtCommitSchema }, async (args) => {
        const { repo, entity_type, entity_id, commit_hash } = readFileAtCommitSchema.parse(args);
        try {
            if (!hasRepoAccess(user, repo, wm, authService))
                return fail(`Folder "${repo}" not found`);
            const graph = wm.getGraph(repo);
            const found = graph.getEntityAtCommit(entity_type, entity_id, commit_hash);
            if (!found)
                return fail(`Entry ${entity_type}/${entity_id} not found at commit ${commit_hash}`);
            // Surface the image/asset embeds present in the body at this commit so
            // a caller can verify whether images existed here without diffing the
            // raw markdown by eye. Asset *binaries* aren't in git — these are the
            // body references, the only image evidence git retains.
            const embeds = extractAssetEmbeds(stripFrontmatter(found.content));
            return ok({ repo, entityType: entity_type, entityId: entity_id, commitHash: commit_hash, path: found.path, content: found.content, embeds, embedCount: embeds.length });
        }
        catch (err) {
            return fail(err);
        }
    });
    const restoreFileSchema = z.object({
        repo: z.string().describe('Folder name'),
        entity_type: z.string().describe('Entity type'),
        entity_id: z.string().describe('Entry ID'),
        commit_hash: z.string().describe('Commit hash to restore from'),
        confirm: z.boolean().optional().describe('Set to true ONLY after the user has approved the preview. Defaults to false.'),
    });
    server.registerTool('restore_file', { description: 'Restore an entry to its content at a specific commit. Preview-then-confirm: first call returns the preview payload, re-invoke with confirm: true to execute.', inputSchema: restoreFileSchema }, async (args) => {
        const { repo, entity_type, entity_id, commit_hash, confirm } = restoreFileSchema.parse(args);
        try {
            if (!hasRepoAccess(user, repo, wm, authService))
                return fail(`Folder "${repo}" not found`);
            if (!canWriteRepo(user, repo, wm, authService))
                return fail(`You don't have permission to write to "${repo}". Editor or admin role required.`);
            const graph = wm.getGraph(repo);
            if (confirm !== true) {
                const found = graph.getEntityAtCommit(entity_type, entity_id, commit_hash);
                if (!found)
                    return fail(`Entry ${entity_type}/${entity_id} not found at commit ${commit_hash}`);
                let action = 'recreate';
                try {
                    graph.get(entity_type, entity_id);
                    action = 'overwrite';
                }
                catch { }
                const sizeBytes = Buffer.byteLength(found.content, 'utf-8');
                const shortHash = commit_hash.length > 8 ? commit_hash.slice(0, 8) : commit_hash;
                // Embeds the restored body will re-link. Restore rewrites the .md
                // body only; asset binaries live outside git and are NOT touched by
                // restore — they remain wherever they are (intact unless separately
                // deleted), so restoring the body re-links any that still exist.
                const embeds = extractAssetEmbeds(stripFrontmatter(found.content));
                const assetNote = embeds.length
                    ? `Restores the body and re-links ${embeds.length} embedded asset reference(s): ${embeds.join(', ')}. ` +
                        `Asset binaries are stored outside git and are not modified by restore (they remain intact unless separately deleted). `
                    : '';
                return ok({
                    success: false,
                    code: 'PREVIEW_REQUIRED',
                    preview: { action, targetPath: found.path, sizeBytes, commitHash: shortHash, embeds, embedCount: embeds.length },
                    message: `Will ${action} ${entity_type}/${entity_id} from ${shortHash} (${sizeBytes} bytes). ${assetNote}Re-invoke with confirm: true after approval.`,
                });
            }
            const result = await withAgentActivity(repo, 'editing', async () => {
                const { PathConflictError } = await import('../core/graph.js');
                try {
                    return await graph.restoreFile(entity_type, entity_id, commit_hash);
                }
                catch (err) {
                    if (err instanceof PathConflictError) {
                        return { conflict: { path: err.path, occupiedBy: err.occupiedBy } };
                    }
                    throw err;
                }
            });
            if ('conflict' in result) {
                return ok({
                    success: false,
                    code: 'PATH_CONFLICT',
                    conflict: result.conflict,
                    message: `Cannot restore — ${result.conflict.path} is occupied by ${result.conflict.occupiedBy.entityType}/${result.conflict.occupiedBy.entityId}.`,
                });
            }
            emitEntityChange(result.action === 'recreate' ? 'created' : 'updated', repo, entity_type, entity_id);
            const shortHash = commit_hash.length > 8 ? commit_hash.slice(0, 8) : commit_hash;
            return ok({
                success: true,
                repo,
                entityType: entity_type,
                entityId: entity_id,
                action: result.action,
                path: result.path,
                commitHash: shortHash,
                message: `Restored ${entity_type}/${entity_id} from ${shortHash} (${result.action}).`,
            });
        }
        catch (err) {
            return fail(err);
        }
    });
}
//# sourceMappingURL=tools.js.map