@stll/folio-agents
Version:
Framework-neutral LLM tool layer over folio's ai-edits engine: function-calling tools so an agent can read and mutate .docx documents through @stll/folio-core.
468 lines (467 loc) • 25.1 kB
JavaScript
import { getDecodedCommentHandlers } from "./bridge.js";
import { decodeCommentId, decodeMainStoryTextRangeHandle, decodeSectionHandle, decodeStoryHandle } from "./codecs.js";
import { explainTextTooLong, parseAddCommentInput, parseSuggestChangesInput, prepareFolioAgentDocumentOperationBatch } from "./parse.js";
import { FOLIO_AGENT_TOOL_NAMES } from "./types.js";
import { FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, createFolioAITextRangeHandle, getFolioDocumentOperationIssues, getFolioDocumentOutline, hashFolioAIBlockText, normalizeFolioAIBlockText, readFolioDocumentSection } from "@stll/folio-core/server";
//#region src/execute.ts
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
const isNonEmptyString = (value) => typeof value === "string" && value.length > 0;
const ok = (result) => ({
ok: true,
result
});
const fail = (error) => ({
ok: false,
error
});
const VALID_TOOL_NAMES = Object.values(FOLIO_AGENT_TOOL_NAMES);
/**
* The same normalized-text hash the core snapshot/apply machinery uses for
* `precondition.blockTextHash` (see `hashFolioAIBlockText` /
* `normalizeFolioAIBlockText`). Computed straight from a block's `text`
* rather than looked up from `snapshot.anchors` so every read path (main
* document, a section, a find_text match) can attach it without threading
* the anchors map around — it is defined to produce the exact same value.
*/
const blockTextHashOf = (text) => hashFolioAIBlockText(normalizeFolioAIBlockText(text));
/** `find_text` requires a short `query` and caps how much of a large match set it returns in one call. */
const MAX_QUERY_LENGTH = 1e3;
const MAX_FIND_MATCHES = 200;
/**
* Execute one tool call against a {@link FolioAgentBridge}. Every EXPECTED
* failure — bad arguments, an unknown tool name, a capability the bridge does
* not implement, a domain-level skip — comes back as `{ ok: false, error }`
* with a message meant to be fed straight back to the model; it is never
* thrown. This function is the sole boundary layer allowed to catch an
* unexpected throw from the bridge and fold it into that same shape (per
* AGENTS.md, try/catch is acceptable at boundary layers).
*
* Every bridge member used here ({@link FolioDocxReviewer} and the live-editor
* ref) is synchronous, so this stays synchronous too.
*/
const executeFolioToolCallUntyped = (name, args, bridge, options = {}) => {
if (!VALID_TOOL_NAMES.includes(name)) return fail(`Unknown tool "${name}". Valid tools: ${VALID_TOOL_NAMES.join(", ")}.`);
try {
return dispatch(name, args, bridge, options);
} catch (error) {
return fail(error instanceof Error ? error.message : String(error));
}
};
function executeFolioToolCall(name, args, bridge, options = {}) {
return executeFolioToolCallUntyped(name, args, bridge, options);
}
const dispatch = (name, args, bridge, options) => {
if (!isFolioAgentToolName(name)) return fail(`Unknown tool "${name}".`);
return TOOL_HANDLERS[name](args, bridge, options);
};
const readDocument = (bridge) => {
const { blocks } = bridge.snapshot();
return ok(blocks.map((block) => ({
blockId: block.id,
kind: block.kind,
text: block.text,
blockTextHash: blockTextHashOf(block.text)
})));
};
const getDocumentOutline = (args, bridge) => {
const maxDepth = isPlainObject(args) ? args["maxDepth"] : void 0;
if (maxDepth !== void 0 && (typeof maxDepth !== "number" || !Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 9)) return fail("get_document_outline's `maxDepth` must be an integer from 1 to 9.");
const outline = getFolioDocumentOutline(bridge.snapshot());
const depth = maxDepth ?? 3;
const sections = outline.sections.filter(({ level }) => level <= depth).map((entry) => {
const page = bridge.getTargetPage?.({
type: "block",
story: "main",
blockId: entry.headingBlockId
}) ?? void 0;
return page === void 0 ? entry : Object.assign({}, entry, { page });
});
return ok({
sections,
totalSections: outline.sections.length,
truncated: sections.length < outline.sections.length
});
};
const readSection = (args, bridge) => {
if (!isPlainObject(args)) return fail("read_section expects a section `handle` from get_document_outline.");
const handle = decodeSectionHandle(args["handle"]);
if (handle === null) return fail("read_section requires a valid `handle` from get_document_outline.");
const maxBlocks = args["maxBlocks"] ?? 100;
if (typeof maxBlocks !== "number" || !Number.isInteger(maxBlocks) || maxBlocks < 1 || maxBlocks > 200) return fail("read_section's `maxBlocks` must be an integer from 1 to 200.");
const afterBlockId = args["afterBlockId"];
if (afterBlockId !== void 0 && !isNonEmptyString(afterBlockId)) return fail("read_section's `afterBlockId` must be a non-empty string when provided.");
const resolved = readFolioDocumentSection(bridge.snapshot(), handle);
if (resolved.status === "missing") return fail("The section no longer exists; run get_document_outline again.");
if (resolved.status === "stale") return fail("The section heading changed; run get_document_outline again for a fresh handle.");
let startIndex = 0;
if (afterBlockId !== void 0) {
const cursorIndex = resolved.section.blocks.findIndex(({ id }) => id === afterBlockId);
if (cursorIndex === -1) return fail("read_section's `afterBlockId` is not part of this section.");
startIndex = cursorIndex + 1;
}
const selected = resolved.section.blocks.slice(startIndex, startIndex + maxBlocks);
const hasMore = startIndex + selected.length < resolved.section.blocks.length;
const lastBlockId = selected.at(-1)?.id;
return ok({
handle,
heading: resolved.section.heading,
blocks: selected.map(({ id, kind, text }) => ({
blockId: id,
kind,
text,
blockTextHash: blockTextHashOf(text)
})),
totalBlocks: resolved.section.blocks.length,
truncated: hasMore,
...hasMore && lastBlockId !== void 0 && { nextAfterBlockId: lastBlockId }
});
};
const readStory = (args, bridge) => {
if (!bridge.readStory) return fail("This editor surface does not support story reads.");
const parsed = isPlainObject(args) ? decodeStoryHandle(args["handle"]) : null;
if (parsed === null) return fail("read_story requires a valid typed `handle` from list_stories.");
const story = bridge.readStory(parsed);
return story ? ok(story) : fail("The requested story was not found; run list_stories again.");
};
const CONTEXT_RADIUS = 40;
const WORD_CHARACTER_AT_END = /[\p{L}\p{M}\p{N}_]$/u;
const WORD_CHARACTER_AT_START = /^[\p{L}\p{M}\p{N}_]/u;
/**
* Window (UTF-16 code units) sliced on each side of a match for the
* whole-word boundary check. The regexes above only ever test the single
* grapheme adjacent to the match, but a surrogate pair or a base character
* with stacked combining marks can span a few code units — this window is
* generous enough to cover that while staying a constant, not
* `block.text.length`. Without a bound, `.slice(0, at)` / `.slice(at + len)`
* on every match makes a whole-word search O(n^2) in the block's length.
*/
const WORD_BOUNDARY_WINDOW = 8;
const findTextMatches = (blocks, query, matchCase, wholeWord, getTargetPage, pageFilter) => {
const matches = [];
let totalMatches = 0;
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const expression = new RegExp(escapedQuery, matchCase ? "gu" : "giu");
for (const block of blocks) {
let occurrence = 0;
let blockTextHash;
for (const match of block.text.matchAll(expression)) {
const at = match.index;
const matchedText = match[0];
if (wholeWord && (WORD_CHARACTER_AT_END.test(block.text.slice(Math.max(0, at - WORD_BOUNDARY_WINDOW), at)) || WORD_CHARACTER_AT_START.test(block.text.slice(at + matchedText.length, at + matchedText.length + WORD_BOUNDARY_WINDOW)))) continue;
const range = createFolioAITextRangeHandle({
blockId: block.id,
text: block.text,
startOffset: at,
endOffset: at + matchedText.length
});
if (range === null) continue;
const page = getTargetPage?.(range) ?? void 0;
if (pageFilter !== void 0 && page !== pageFilter) continue;
totalMatches += 1;
if (matches.length < MAX_FIND_MATCHES) {
const contextStart = Math.max(0, at - CONTEXT_RADIUS);
const contextEnd = Math.min(block.text.length, at + matchedText.length + CONTEXT_RADIUS);
blockTextHash ??= blockTextHashOf(block.text);
matches.push({
type: "main",
story: { type: "main" },
blockId: block.id,
blockTextHash,
range,
occurrenceInBlock: occurrence,
context: block.text.slice(contextStart, contextEnd),
...page !== void 0 && page !== null && { page }
});
}
occurrence += 1;
}
}
return {
matches,
totalMatches
};
};
const findStoryTextMatches = (text, story, query, matchCase, wholeWord) => {
const matches = [];
let totalMatches = 0;
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const expression = new RegExp(escapedQuery, matchCase ? "gu" : "giu");
for (const match of text.matchAll(expression)) {
const at = match.index;
const matchedText = match[0];
if (wholeWord && (WORD_CHARACTER_AT_END.test(text.slice(Math.max(0, at - WORD_BOUNDARY_WINDOW), at)) || WORD_CHARACTER_AT_START.test(text.slice(at + matchedText.length, at + matchedText.length + WORD_BOUNDARY_WINDOW)))) continue;
if (matches.length < MAX_FIND_MATCHES) matches.push({
type: "story",
story,
startOffset: at,
endOffset: at + matchedText.length,
occurrenceInStory: totalMatches,
context: text.slice(Math.max(0, at - CONTEXT_RADIUS), Math.min(text.length, at + matchedText.length + CONTEXT_RADIUS))
});
totalMatches += 1;
}
return {
matches,
totalMatches
};
};
const findText = (args, bridge) => {
if (!isPlainObject(args)) return fail("find_text expects an object with a non-empty `query` string.");
const query = args["query"];
const matchCase = args["matchCase"];
const wholeWord = args["wholeWord"];
if (!isNonEmptyString(query)) return fail("find_text requires a non-empty string `query`.");
if (query.length > MAX_QUERY_LENGTH) return fail(`find_text's \`query\` is ${query.length.toLocaleString()} characters, over the ${MAX_QUERY_LENGTH.toLocaleString()}-character limit; shorten it.`);
if (matchCase !== void 0 && typeof matchCase !== "boolean") return fail("find_text's `matchCase` must be a boolean when provided.");
if (wholeWord !== void 0 && typeof wholeWord !== "boolean") return fail("find_text's `wholeWord` must be a boolean when provided.");
const scope = args["scope"];
let blocks = bridge.snapshot().blocks;
let getTargetPage;
let pageFilter;
if (scope !== void 0) {
if (!isPlainObject(scope) || !isNonEmptyString(scope["type"])) return fail("find_text's `scope` must be a document, section, page, or story scope.");
if (scope["type"] === "section") {
const handle = decodeSectionHandle(scope["handle"]);
if (handle === null) return fail("find_text's section scope requires a handle from get_document_outline.");
const section = readFolioDocumentSection(bridge.snapshot(), handle);
if (section.status !== "found") return fail("The scoped section is stale or missing; run get_document_outline again.");
blocks = section.section.blocks;
} else if (scope["type"] === "page") {
const page = scope["page"];
if (typeof page !== "number" || !Number.isInteger(page) || page < 1) return fail("find_text's page scope requires an integer `page` >= 1.");
if (!bridge.getTargetPage) return fail("This editor surface cannot search by page without live pagination.");
const pageCount = bridge.getPageCount?.();
if (pageCount !== void 0 && page > pageCount) return fail(`find_text's page (${page}) exceeds the document's page count (${pageCount}).`);
getTargetPage = bridge.getTargetPage;
pageFilter = page;
} else if (scope["type"] === "story") {
const handle = decodeStoryHandle(scope["handle"]);
if (handle === null) return fail("find_text's story scope requires a handle from list_stories.");
if (handle.type !== "main") {
if (!bridge.readStory) return fail("This editor surface does not support story search.");
const story = bridge.readStory(handle);
if (story === null) return fail("The scoped story was not found; run list_stories again.");
const found = findStoryTextMatches(story.text, handle, query, matchCase === true, wholeWord === true);
return ok({
matches: found.matches,
truncated: found.totalMatches > found.matches.length,
totalMatches: found.totalMatches
});
}
} else if (scope["type"] !== "document") return fail("find_text's `scope.type` must be document, section, page, or story.");
}
const { matches, totalMatches } = findTextMatches(blocks, query, matchCase === true, wholeWord === true, getTargetPage, pageFilter);
const result = {
matches,
truncated: totalMatches > matches.length,
totalMatches
};
return ok(result);
};
const isCommentFilter = (value) => value === "all" || value === "open" || value === "resolved";
const readComments = (args, bridge) => {
const filter = isPlainObject(args) ? args["filter"] : void 0;
if (filter !== void 0 && !isCommentFilter(filter)) return fail("read_comments' `filter` must be one of \"all\", \"open\", \"resolved\" when provided.");
const comments = bridge.getComments();
if (filter === void 0 || filter === "all") return ok(comments);
const wantResolved = filter === "resolved";
return ok(comments.filter((comment) => comment.resolved === wantResolved));
};
/**
* Turn a `FolioAIEditSkipReason` into a plain-language reason a model can act
* on — what to change and retry, not just a machine code.
*/
const explainSkipReason = (reason) => {
if (reason === "missingBlock") return "blockId not found; re-read the document (read_document or find_text) and retry with a fresh block id.";
if (reason === "changedBlock") return "the block changed since your snapshot; re-read the document and retry with fresh ids.";
if (reason === "ambiguousFind") return "`find` matches more than once in this block; narrow it so it matches exactly once, or use a replaceBlock operation instead.";
if (reason === "missingFind") return "`find` was not found in this block; re-read the block's current text and retry.";
if (reason === "unsupportedBlock") return "this block kind does not support this operation.";
if (reason === "unsupportedMode") return "this operation does not support the requested mutation mode; inspect document operation capabilities and retry with a supported mode.";
if (reason === "atomicBatchRejected") return "another operation in this atomic batch could not be applied; no operations were committed.";
if (reason === "preconditionFailed") return "the target block changed after the operation was prepared; re-read the document and retry with a fresh operation.";
if (reason === "staleRange") return "the selected range changed or shifted; run find_text again and retry with the fresh range.";
if (reason === "emptyOperation") return "this operation has no effect; nothing to apply.";
if (reason === "noopOperation") return "this operation would not change the document (the text already matches what you asked for).";
if (reason === "documentVersionMismatch") return "the document changed after these edits were proposed; re-read it and regenerate the edits against the current version.";
if (reason === "documentNotEditable") return "the document is not open for editing right now; nothing was applied or queued. Ask the user to open it for editing, then retry.";
return reason;
};
/**
* Turn one apply-time `FolioAIEditNormalization` (an automatic adjustment the
* applier made to an operation's input, e.g. splitting a multi-line
* `insertAfterBlock` / `insertBeforeBlock` `text` into several paragraphs)
* into the same `{ path, message }` shape lenient decoding reports, so the
* model sees every normalization in one list regardless of where it happened.
*/
const explainApplyNormalization = (normalization) => {
if (normalization.code === "splitMultilineText") return {
path: `operations[id=${normalization.id}].text`,
message: `\`text\` contained line breaks; split into ${normalization.paragraphCount} consecutive paragraphs (one per non-blank line) instead of one paragraph with embedded newlines.`
};
return {
path: `operations[id=${normalization.id}]`,
message: `input was normalized (${normalization.code}).`
};
};
const summarizeApplyResult = (result, normalizations) => ({
version: result.version,
applied: result.applied.map((entry) => ({ id: entry.id })),
queued: (result.queued ?? []).map((entry) => ({ id: entry.id })),
skipped: result.skipped.map((entry) => ({
id: entry.id,
reason: explainSkipReason(entry.reason)
})),
issues: result.issues ?? [],
receipts: result.receipts ?? [],
normalizations: [...normalizations, ...(result.normalizations ?? []).map(explainApplyNormalization)]
});
/** Every operation skipped for one batch-wide reason, before anything reached the bridge. */
const rejectBatch = (operations, reason, normalizations) => {
const skipped = operations.map(({ id }) => ({
id,
reason
}));
return summarizeApplyResult({
version: FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION,
applied: [],
skipped,
issues: getFolioDocumentOperationIssues(operations, skipped),
receipts: []
}, normalizations);
};
const applyOperations = (bridge, { operations, precondition, normalizations = [] }) => {
const snapshot = bridge.snapshot();
const guardedOperations = [];
for (const operation of operations) {
if (operation.precondition !== void 0) {
guardedOperations.push(operation);
continue;
}
const blockId = operation.type === "replaceRange" || operation.type === "commentOnRange" || operation.type === "formatRange" ? operation.range.blockId : operation.blockId;
const blockTextHash = snapshot.anchors[blockId]?.textHash;
guardedOperations.push({
...operation,
...blockTextHash !== void 0 && { precondition: { blockTextHash } }
});
}
return summarizeApplyResult(bridge.applyDocumentOperations(prepareFolioAgentDocumentOperationBatch({
operations: guardedOperations,
...bridge.documentOperationMode !== void 0 && { mode: bridge.documentOperationMode },
...precondition !== void 0 && { precondition }
})), normalizations);
};
const addComment = (args, bridge) => {
const parsed = parseAddCommentInput(args);
if (!parsed.ok) return fail(parsed.error);
return ok(applyOperations(bridge, { operations: [parsed.operation] }));
};
/**
* A version-pinned batch is compared against the bridge's document version
* BEFORE any operation is applied or queued: an approval that runs after
* the document moved on skips as a whole instead of landing half of its
* edits on a document the model never saw. A surface without a version
* notion cannot honour the pin and refuses rather than guessing.
*/
const suggestChanges = (args, bridge, options) => {
const parsed = parseSuggestChangesInput(args, options.suggestChanges);
if (!parsed.ok) return fail(parsed.error);
if (parsed.precondition !== void 0) {
if (!bridge.getDocumentVersion) return fail("This editor surface cannot verify `documentVersion`; it has no document version to compare against.");
if (bridge.getDocumentVersion() !== parsed.precondition.documentVersion) return ok(rejectBatch(parsed.operations, "documentVersionMismatch", parsed.normalizations));
}
return ok(applyOperations(bridge, {
operations: parsed.operations,
...parsed.precondition !== void 0 && { precondition: parsed.precondition },
normalizations: parsed.normalizations
}));
};
const replyComment = (args, bridge) => {
if (!isPlainObject(args)) return fail("reply_comment expects an object with `commentId` and `text` strings.");
const commentId = args["commentId"];
const text = args["text"];
if (!isNonEmptyString(commentId)) return fail("reply_comment requires a non-empty string `commentId`.");
if (!isNonEmptyString(text)) return fail("reply_comment requires a non-empty string `text`.");
if (text.length > 1e5) return fail(explainTextTooLong("reply_comment's `text`", text.length));
const decodedHandlers = getDecodedCommentHandlers(bridge);
if (decodedHandlers === void 0) return bridge.replyToComment(commentId, text) ? ok({ replied: true }) : fail(`No comment with id "${commentId}" was found.`);
const decodedCommentId = decodeCommentId(commentId);
if (decodedCommentId === null) return fail("reply_comment's `commentId` must be a comment id from `read_comments`.");
if (!decodedHandlers.replyToComment(decodedCommentId, text)) return fail(`No comment with id "${commentId}" was found.`);
return ok({ replied: true });
};
const resolveComment = (args, bridge) => {
if (!isPlainObject(args)) return fail("resolve_comment expects an object with a `commentId` string.");
const commentId = args["commentId"];
const reopen = args["reopen"];
if (!isNonEmptyString(commentId)) return fail("resolve_comment requires a non-empty string `commentId`.");
if (reopen !== void 0 && typeof reopen !== "boolean") return fail("resolve_comment's `reopen` must be a boolean when provided.");
const resolved = reopen !== true;
const decodedHandlers = getDecodedCommentHandlers(bridge);
if (decodedHandlers === void 0) return bridge.resolveComment(commentId, resolved) ? ok({ resolved }) : fail(`No comment with id "${commentId}" was found.`);
const decodedCommentId = decodeCommentId(commentId);
if (decodedCommentId === null) return fail("resolve_comment's `commentId` must be a comment id from `read_comments`.");
if (!decodedHandlers.resolveComment(decodedCommentId, resolved)) return fail(`No comment with id "${commentId}" was found.`);
return ok({ resolved });
};
const readPage = (args, bridge) => {
if (!bridge.getPageText) return fail("This editor surface does not support read_page (no live paginated view).");
const page = isPlainObject(args) ? args["page"] : void 0;
if (typeof page !== "number" || !Number.isInteger(page) || page < 1) return fail("read_page requires an integer `page` >= 1.");
const pageCount = bridge.getPageCount?.();
if (pageCount !== void 0 && page > pageCount) return fail(`read_page's \`page\` (${page}) exceeds the document's page count (${pageCount}).`);
return ok({
page,
...pageCount !== void 0 && { totalPages: pageCount },
text: bridge.getPageText(page)
});
};
const readSelection = (bridge) => {
if (!bridge.getSelectionText) return fail("This editor surface does not support read_selection (no live selection).");
return ok({ text: bridge.getSelectionText() });
};
const scrollToBlock = (args, bridge) => {
if (!bridge.scrollToBlock) return fail("This editor surface does not support scroll_to_block (no live editor view).");
const blockId = isPlainObject(args) ? args["blockId"] : void 0;
if (!isNonEmptyString(blockId)) return fail("scroll_to_block requires a non-empty string `blockId`.");
return ok({ scrolled: bridge.scrollToBlock(blockId) });
};
const showInDocument = (args, bridge) => {
if (!bridge.showInDocument) return fail("This editor surface does not support show_in_document (no live editor view).");
if (!isPlainObject(args)) return fail("show_in_document expects exactly one of `blockId` or `range`.");
const blockId = args["blockId"];
const rawRange = args["range"];
if (blockId === void 0 === (rawRange === void 0)) return fail("show_in_document requires exactly one of `blockId` or `range`.");
if (blockId !== void 0) {
if (!isNonEmptyString(blockId)) return fail("show_in_document's `blockId` must be a non-empty string.");
return ok({ shown: bridge.showInDocument({
type: "block",
story: "main",
blockId
}) });
}
const range = decodeMainStoryTextRangeHandle(rawRange);
if (range === null) return fail("show_in_document's `range` must be copied from find_text.");
return ok({ shown: bridge.showInDocument(range) });
};
const TOOL_HANDLERS = {
[FOLIO_AGENT_TOOL_NAMES.readDocument]: (_args, bridge) => readDocument(bridge),
[FOLIO_AGENT_TOOL_NAMES.getDocumentOutline]: getDocumentOutline,
[FOLIO_AGENT_TOOL_NAMES.readSection]: readSection,
[FOLIO_AGENT_TOOL_NAMES.listStories]: (_args, bridge) => bridge.listStories ? ok(bridge.listStories()) : fail("This editor surface does not support story discovery."),
[FOLIO_AGENT_TOOL_NAMES.readStory]: readStory,
[FOLIO_AGENT_TOOL_NAMES.findText]: findText,
[FOLIO_AGENT_TOOL_NAMES.readComments]: readComments,
[FOLIO_AGENT_TOOL_NAMES.readChanges]: (_args, bridge) => ok(bridge.getChanges()),
[FOLIO_AGENT_TOOL_NAMES.addComment]: addComment,
[FOLIO_AGENT_TOOL_NAMES.suggestChanges]: suggestChanges,
[FOLIO_AGENT_TOOL_NAMES.replyComment]: replyComment,
[FOLIO_AGENT_TOOL_NAMES.resolveComment]: resolveComment,
[FOLIO_AGENT_TOOL_NAMES.readPage]: readPage,
[FOLIO_AGENT_TOOL_NAMES.readSelection]: (_args, bridge) => readSelection(bridge),
[FOLIO_AGENT_TOOL_NAMES.scrollToBlock]: scrollToBlock,
[FOLIO_AGENT_TOOL_NAMES.showInDocument]: showInDocument
};
const isFolioAgentToolName = (name) => Object.hasOwn(TOOL_HANDLERS, name);
//#endregion
export { executeFolioToolCall, executeFolioToolCallUntyped };