Files
PaperClipAI/packages/shared/src/frontmatter.ts
T
b13eb5b2b5 Skill Studio: three-pane skill IDE with sandboxed test runs (#9241)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Skills Manager gives operators a reusable skill layer, but
iteration still required manual edits, ad hoc prompts, and indirect run
inspection.
> - Skill authors need a focused workflow for editing skill files,
saving representative test inputs, and running those inputs through an
agent without exposing harness tasks as normal company work.
> - The backend therefore needs durable test inputs, reusable run
templates, hidden harness issues, scoped run execution, retention
metadata, and read-containment rules around hidden work.
> - The frontend needs a three-pane Studio that keeps skill files, saved
inputs/templates, and run output/history visible together while
preserving the existing design system and token rules.
> - This pull request ships that Skill Studio surface end to end:
database migrations, shared contracts, server APIs/services, hidden
harness execution behavior, UI routes/components, and focused tests.
> - The benefit is faster and safer skill iteration, with inspectable
outputs and fewer ways for internal harness work to leak into normal
task lists, costs, or adjacent read APIs.

## Linked Issues or Issue Description

No public GitHub issue exists for this feature. Feature request summary:

- Problem: Skill authors need to edit and test company skills in one
place instead of switching between the skill detail page, task creation,
run output, and manual prompt history.
- Proposed solution: Add a Skill Studio workbench with saved inputs,
reusable templates, hidden sandboxed test runs, live run status, output
inspection, run history, rerun/delete controls, and frontmatter-aware
editing.
- Expected users: Paperclip operators and agent-company maintainers who
create, fork, import, and tune skills.
- Related public PRs: Supersedes #9205, which was replaced so the public
PR branch name follows contributor policy.
- Duplicate search: searched public GitHub issues and PRs for "Skill
Studio"; no other active public issue or PR directly covers this
feature.

## What Changed

- Added database migrations for Skill Studio test inputs, test runs,
test run retention, and reusable run templates.
- Added shared Skill Studio types, validators, route helpers,
frontmatter utilities, and status handling.
- Added server services and routes for saved inputs, test runs,
templates, reruns, terminal-run deletion, hidden harness issue
execution, and run-detail hydration.
- Strengthened hidden-issue read containment across issue-adjacent
routes and cost rollups used by skill test harness work.
- Added the Skill Studio UI with skill file editing, frontmatter
editing, saved inputs, templates, run creation/cancel/rerun/delete
flows, output rendering, history, route support, and responsive pane
behavior.
- Added focused backend, shared, and UI tests for the new APIs, routing
logic, editor/run behavior, hidden-issue containment, and migration
safety.
- Rebased onto current `master`, removed the generated lockfile diff
from the PR, and verified no workflow files are changed.

## Verification

- [x] `pnpm --filter @paperclipai/db check:migrations`
- [x] `pnpm check:token-gates`
- [x] `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/company-skills-routes.test.ts
server/src/__tests__/company-skill-test-runs-service.test.ts
ui/src/lib/skill-studio.test.ts ui/src/pages/SkillStudio.test.tsx` — 5
files, 132 tests passed
- [x] Greptile review on the latest PR head
- [x] GitHub PR checks on the latest PR head

## Risks

- Medium risk because this is a broad feature touching database schema,
server orchestration, issue visibility, and a large UI surface.
- Hidden harness issue containment is security-sensitive; this PR
includes regression coverage for adjacent read paths and cost rollups.
- The new migrations are additive and use idempotent guards where
applicable, but deployed databases that previously tested draft
migration numbers should still be checked carefully.
- The UI depends on a new resizable panels package in `ui/package.json`;
the lockfile is intentionally left to repository automation.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent with shell, git, and GitHub CLI tool
use. Earlier feature commits include assistance from other Paperclip
coding agents; this PR preparation, rebase, cleanup commit, and PR body
were completed by OpenAI Codex in a Paperclip worktree.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:08:56 -05:00

649 lines
20 KiB
TypeScript

import { z } from "zod";
export interface MarkdownDoc {
frontmatter: Record<string, unknown>;
body: string;
hasFrontmatter: boolean;
}
export interface FrontmatterBlock {
frontmatterText: string;
body: string;
hasFrontmatter: boolean;
}
export type FrontmatterRoundTripIssueKind =
| "anchor"
| "alias"
| "comment"
| "quoted_key"
| "tag";
export interface FrontmatterRoundTripIssue {
kind: FrontmatterRoundTripIssueKind;
line: number;
column: number;
message: string;
}
const SKILL_FRONTMATTER_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const SUPPORTED_FRONTMATTER_KEY_RE = /^[A-Za-z0-9_. -]+$/;
type SerializableFrontmatterValue =
| null
| string
| number
| boolean
| SerializableFrontmatterValue[]
| { [key: string]: SerializableFrontmatterValue };
const skillMetadataValueSchema: z.ZodType<unknown> = z.lazy(() =>
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(skillMetadataValueSchema),
z.record(skillMetadataValueSchema),
])
);
export const skillFrontmatterSchema = z.object({
name: z.string().regex(SKILL_FRONTMATTER_SLUG_RE, "Expected a lowercase URL slug."),
description: z.string().min(1),
"allowed-tools": z.array(z.string()).optional(),
metadata: z.record(skillMetadataValueSchema).optional(),
}).passthrough();
export const skillFrontmatterKnownKeys = [
"name",
"description",
"allowed-tools",
"metadata",
] as const;
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function asBoolean(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
export function asStringArray(value: unknown): string[] | null {
if (value === undefined) return [];
if (!Array.isArray(value)) return null;
const out: string[] = [];
for (const item of value) {
const text = asString(item);
if (!text) return null;
out.push(text);
}
return out;
}
export function splitFrontmatterBlock(raw: string): FrontmatterBlock {
if (!raw.startsWith("---\n")) {
return { frontmatterText: "", body: raw, hasFrontmatter: false };
}
const closing = raw.indexOf("\n---\n", 3);
if (closing < 0) {
return { frontmatterText: "", body: raw, hasFrontmatter: false };
}
return {
frontmatterText: raw.slice(4, closing),
body: raw.slice(closing + 5),
hasFrontmatter: true,
};
}
export function stringifyFrontmatter(value: Record<string, unknown>): string {
return stringifyYamlRecord(assertSerializableRecord(value), 0).join("\n");
}
export function getSkillFrontmatterUnknownKeys(value: Record<string, unknown>) {
const known = new Set<string>(skillFrontmatterKnownKeys);
return Object.keys(value).filter((key) => !known.has(key));
}
export function detectFrontmatterRoundTripIssues(rawYaml: string): FrontmatterRoundTripIssue[] {
const issues: FrontmatterRoundTripIssue[] = [];
const lines = rawYaml.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!;
const contentStart = line.search(/\S/u);
if (contentStart < 0) continue;
const content = line.slice(contentStart);
if (content.startsWith("#")) {
issues.push({
kind: "comment",
line: index + 1,
column: contentStart + 1,
message: "Comments are not preserved by the frontmatter field serializer.",
});
continue;
}
const inlineComment = findRoundTripPattern(line, /(^|\s)#/u);
if (inlineComment >= 0) {
issues.push({
kind: "comment",
line: index + 1,
column: inlineComment + 1,
message: "Inline comments are not preserved by the frontmatter field serializer.",
});
}
const quotedKey = /^\s*(?:-\s*)?(["']).+?\1\s*:/u.exec(line);
if (quotedKey) {
issues.push({
kind: "quoted_key",
line: index + 1,
column: line.indexOf(quotedKey[1]!) + 1,
message: "Quoted YAML keys cannot be round-tripped by the frontmatter parser.",
});
}
const anchor = findRoundTripPattern(line, /(^|[\s,[{])&[A-Za-z0-9_-]+/u);
if (anchor >= 0) {
issues.push({
kind: "anchor",
line: index + 1,
column: anchor + 1,
message: "YAML anchors cannot be round-tripped by the frontmatter parser.",
});
}
const alias = findRoundTripPattern(line, /(^|[\s,[{])\*[A-Za-z0-9_-]+/u);
if (alias >= 0) {
issues.push({
kind: "alias",
line: index + 1,
column: alias + 1,
message: "YAML aliases cannot be round-tripped by the frontmatter parser.",
});
}
const tag = findRoundTripPattern(line, /(^|\s)![A-Za-z!][^\s]*/u);
if (tag >= 0) {
issues.push({
kind: "tag",
line: index + 1,
column: tag + 1,
message: "YAML tags cannot be round-tripped by the frontmatter parser.",
});
}
}
return issues;
}
/**
* Recombine a split block into a full markdown document. This is the exact
* inverse of {@link splitFrontmatterBlock}: `join(split(x)) === x` for every
* input, so opening a file and saving it untouched is byte-identical. The body
* is passed through verbatim — never re-parsed or re-serialized.
*/
export function joinFrontmatterBlock(block: FrontmatterBlock): string {
if (!block.hasFrontmatter) return block.body;
return `---\n${block.frontmatterText}\n---\n${block.body}`;
}
/**
* Parse the raw YAML of a frontmatter block (the text between the `---` fences,
* as returned by {@link splitFrontmatterBlock}) into a plain object. Lenient:
* unparseable input yields `{}` rather than throwing.
*/
export function parseFrontmatterFields(frontmatterText: string): Record<string, unknown> {
return parseYamlFrontmatter(frontmatterText);
}
export interface FrontmatterAnalysis {
/** The parsed field object (best-effort; `{}` when nothing parses). */
parsed: Record<string, unknown>;
/**
* True when the field editor is safe to use: the raw YAML both parses and
* re-serializes byte-for-byte. When false, editing must stay in raw-YAML mode
* so bytes the serializer can't reproduce (comments, anchors, folded scalars,
* custom ordering, quoting) are never silently rewritten.
*/
canRoundTrip: boolean;
/** Structural features that make the block non-round-trippable, if any. */
issues: FrontmatterRoundTripIssue[];
}
/**
* Decide whether a frontmatter block can be edited through the structured
* field form. Fields mode is only offered when re-serializing the parsed object
* reproduces the original block exactly — this is the load-bearing round-trip
* safety gate for the Skill Studio FrontmatterPanel (PAP-13145 Option B).
*/
export function analyzeFrontmatterBlock(frontmatterText: string): FrontmatterAnalysis {
const issues = detectFrontmatterRoundTripIssues(frontmatterText);
const parsed = parseYamlFrontmatter(frontmatterText);
let canRoundTrip = issues.length === 0;
if (canRoundTrip) {
try {
canRoundTrip = stringifyFrontmatter(parsed) === frontmatterText;
} catch {
canRoundTrip = false;
}
}
return { parsed, canRoundTrip, issues };
}
export function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
const normalized = raw.replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n")) {
return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false };
}
const closing = normalized.indexOf("\n---\n", 4);
if (closing < 0) {
return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false };
}
const frontmatterRaw = normalized.slice(4, closing);
const body = normalized.slice(closing + 5).trim();
return {
frontmatter: parseYamlFrontmatter(frontmatterRaw),
body,
hasFrontmatter: true,
};
}
function assertSerializableRecord(value: Record<string, unknown>) {
const out: Record<string, SerializableFrontmatterValue> = {};
for (const [key, entryValue] of Object.entries(value)) {
if (entryValue === undefined) continue;
out[key] = assertSerializableValue(entryValue);
}
return out;
}
function assertSerializableValue(value: unknown): SerializableFrontmatterValue {
if (
value === null
|| typeof value === "string"
|| typeof value === "boolean"
) {
return value;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new TypeError("Frontmatter numbers must be finite.");
}
return value;
}
if (Array.isArray(value)) {
return value
.filter((entry) => entry !== undefined)
.map((entry) => assertSerializableValue(entry));
}
if (isPlainRecord(value)) {
return assertSerializableRecord(value);
}
throw new TypeError(`Unsupported frontmatter value type: ${typeof value}`);
}
function stringifyYamlRecord(record: Record<string, SerializableFrontmatterValue>, indentLevel: number): string[] {
const lines: string[] = [];
for (const [key, value] of Object.entries(record)) {
assertYamlKey(key);
lines.push(...stringifyYamlProperty(key, value, indentLevel));
}
return lines;
}
function stringifyYamlProperty(key: string, value: SerializableFrontmatterValue, indentLevel: number): string[] {
const indent = " ".repeat(indentLevel);
if (Array.isArray(value)) {
if (value.length === 0) return [`${indent}${key}: []`];
return [`${indent}${key}:`, ...stringifyYamlArray(value, indentLevel + 2)];
}
if (isSerializableRecord(value)) {
const entries = Object.entries(value);
if (entries.length === 0) return [`${indent}${key}: {}`];
return [`${indent}${key}:`, ...stringifyYamlRecord(value, indentLevel + 2)];
}
if (typeof value === "string" && value.includes("\n")) {
return stringifyBlockScalarProperty(key, value, indentLevel);
}
return [`${indent}${key}: ${stringifyYamlScalar(value)}`];
}
function stringifyYamlArray(values: SerializableFrontmatterValue[], indentLevel: number): string[] {
const indent = " ".repeat(indentLevel);
const lines: string[] = [];
for (const value of values) {
if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${indent}- []`);
} else {
lines.push(`${indent}-`);
lines.push(...stringifyYamlArray(value, indentLevel + 2));
}
continue;
}
if (isSerializableRecord(value)) {
const entries = Object.entries(value);
if (entries.length === 0) {
lines.push(`${indent}- {}`);
} else {
lines.push(`${indent}-`);
lines.push(...stringifyYamlRecord(value, indentLevel + 2));
}
continue;
}
if (typeof value === "string" && value.includes("\n")) {
lines.push(...stringifyBlockScalarArrayItem(value, indentLevel));
continue;
}
lines.push(`${indent}- ${stringifyYamlScalar(value)}`);
}
return lines;
}
function stringifyBlockScalarProperty(key: string, value: string, indentLevel: number) {
const indent = " ".repeat(indentLevel);
return [
`${indent}${key}: ${blockScalarIndicator(value)}`,
...indentBlockScalarValue(value, indentLevel + 2),
];
}
function stringifyBlockScalarArrayItem(value: string, indentLevel: number) {
const indent = " ".repeat(indentLevel);
return [
`${indent}- ${blockScalarIndicator(value)}`,
...indentBlockScalarValue(value, indentLevel + 2),
];
}
function blockScalarIndicator(value: string) {
if (!value.endsWith("\n")) return "|-";
if (value.endsWith("\n\n")) return "|+";
return "|";
}
function indentBlockScalarValue(value: string, indentLevel: number) {
const indent = " ".repeat(indentLevel);
return value.split("\n").map((line) => `${indent}${line}`);
}
function stringifyYamlScalar(value: Exclude<SerializableFrontmatterValue, SerializableFrontmatterValue[] | Record<string, SerializableFrontmatterValue>>) {
if (value === null) return "null";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") return String(value);
if (isPlainYamlScalar(value)) return value;
return JSON.stringify(value);
}
function isPlainYamlScalar(value: string) {
if (value.length === 0) return false;
if (value.trim() !== value) return false;
if (value === "null" || value === "~" || value === "true" || value === "false") return false;
if (value === "[]" || value === "{}") return false;
if (/^-?\d+(\.\d+)?$/u.test(value)) return false;
if (/["'[\]{}#,>&*!|@`]/u.test(value)) return false;
if (value.includes(":")) return false;
return true;
}
function assertYamlKey(key: string) {
if (!SUPPORTED_FRONTMATTER_KEY_RE.test(key) || key.includes(":")) {
throw new TypeError(`Unsupported frontmatter key: ${key}`);
}
}
function isSerializableRecord(value: SerializableFrontmatterValue): value is Record<string, SerializableFrontmatterValue> {
return isPlainRecord(value);
}
function findRoundTripPattern(line: string, pattern: RegExp) {
const match = pattern.exec(line);
if (!match) return -1;
return match.index + (match[1]?.length ?? 0);
}
function parseYamlFrontmatter(raw: string): Record<string, unknown> {
const prepared = prepareYamlLines(raw);
const firstContentIndex = prepared.findIndex((line) => !line.isBlank && !line.isComment);
if (firstContentIndex < 0) return {};
const parsed = parseYamlBlock(prepared, firstContentIndex, prepared[firstContentIndex]!.indent);
return isPlainRecord(parsed.value) ? parsed.value : {};
}
function prepareYamlLines(raw: string) {
return raw
.split("\n")
.map((line) => ({
indent: line.match(/^ */)?.[0].length ?? 0,
raw: line,
content: line.trim(),
isBlank: line.trim().length === 0,
isComment: line.trim().startsWith("#"),
}));
}
function parseYamlBlock(
lines: Array<{ indent: number; raw: string; content: string; isBlank: boolean; isComment: boolean }>,
startIndex: number,
indentLevel: number,
): { value: unknown; nextIndex: number } {
let index = startIndex;
while (index < lines.length && (lines[index]!.isBlank || lines[index]!.isComment)) {
index += 1;
}
if (index >= lines.length || lines[index]!.indent < indentLevel) {
return { value: {}, nextIndex: index };
}
const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-");
if (isArray) {
const values: unknown[] = [];
while (index < lines.length) {
const line = lines[index]!;
if (line.isBlank || line.isComment) {
index += 1;
continue;
}
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
const remainder = line.content.slice(1).trim();
index += 1;
if (!remainder) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
values.push(nested.value);
index = nested.nextIndex;
continue;
}
if (isYamlBlockScalarIndicator(remainder)) {
const block = parseYamlBlockScalar(lines, index, indentLevel, remainder);
values.push(block.value);
index = block.nextIndex;
continue;
}
const inlineObjectSeparator = remainder.indexOf(":");
if (
inlineObjectSeparator > 0
&& !remainder.startsWith("\"")
&& !remainder.startsWith("{")
&& !remainder.startsWith("[")
) {
const key = remainder.slice(0, inlineObjectSeparator).trim();
const rawValue = remainder.slice(inlineObjectSeparator + 1).trim();
const nextObject: Record<string, unknown> = {
[key]: parseYamlScalar(rawValue),
};
if (index < lines.length && lines[index]!.indent > indentLevel) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
if (isPlainRecord(nested.value)) {
Object.assign(nextObject, nested.value);
}
index = nested.nextIndex;
}
values.push(nextObject);
continue;
}
values.push(parseYamlScalar(remainder));
}
return { value: values, nextIndex: index };
}
const record: Record<string, unknown> = {};
while (index < lines.length) {
const line = lines[index]!;
if (line.isBlank || line.isComment) {
index += 1;
continue;
}
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel) {
index += 1;
continue;
}
const separatorIndex = line.content.indexOf(":");
if (separatorIndex <= 0) {
index += 1;
continue;
}
const key = line.content.slice(0, separatorIndex).trim();
const remainder = line.content.slice(separatorIndex + 1).trim();
index += 1;
if (!remainder) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
record[key] = nested.value;
index = nested.nextIndex;
continue;
}
if (isYamlBlockScalarIndicator(remainder)) {
const block = parseYamlBlockScalar(lines, index, indentLevel, remainder);
record[key] = block.value;
index = block.nextIndex;
continue;
}
record[key] = parseYamlScalar(remainder);
}
return { value: record, nextIndex: index };
}
function isYamlBlockScalarIndicator(rawValue: string) {
return /^[>|][+-]?$/.test(rawValue.trim());
}
function parseYamlBlockScalar(
lines: Array<{ indent: number; raw: string; content: string; isBlank: boolean; isComment: boolean }>,
startIndex: number,
parentIndent: number,
indicator: string,
): { value: string; nextIndex: number } {
const trimmedIndicator = indicator.trim();
const style = trimmedIndicator[0];
const chomp = trimmedIndicator.endsWith("+")
? "+"
: trimmedIndicator.endsWith("-")
? "-"
: "";
let index = startIndex;
const collected: Array<{ indent: number; raw: string; isBlank: boolean }> = [];
while (index < lines.length) {
const line = lines[index]!;
if (!line.isBlank && line.indent <= parentIndent) break;
collected.push({ indent: line.indent, raw: line.raw, isBlank: line.isBlank });
index += 1;
}
const contentLines = collected.filter((line) => !line.isBlank);
if (contentLines.length === 0) return { value: "", nextIndex: index };
const blockIndent = Math.min(...contentLines.map((line) => line.indent));
const normalizedLines = collected.map((line) => (
line.isBlank ? "" : line.raw.slice(Math.min(blockIndent, line.raw.length))
));
const baseValue = style === "|"
? normalizedLines.join("\n")
: foldYamlBlockScalarLines(normalizedLines);
return {
value: applyYamlBlockChomp(baseValue, chomp),
nextIndex: index,
};
}
function foldYamlBlockScalarLines(lines: string[]) {
let value = "";
let pendingBlankLines = 0;
for (const line of lines) {
if (line === "") {
pendingBlankLines += 1;
continue;
}
if (value.length === 0) {
value = `${"\n".repeat(pendingBlankLines)}${line}`;
} else if (pendingBlankLines > 0) {
value += `${"\n".repeat(pendingBlankLines + 1)}${line}`;
} else {
value += ` ${line}`;
}
pendingBlankLines = 0;
}
if (pendingBlankLines > 0 && value.length > 0) {
value += "\n".repeat(pendingBlankLines);
}
return value;
}
function applyYamlBlockChomp(value: string, chomp: "" | "+" | "-") {
if (chomp === "+") return value;
if (chomp === "-") return value.replace(/\n+$/u, "");
if (value.length === 0) return value;
return value.replace(/\n+$/u, "") + "\n";
}
function parseYamlScalar(rawValue: string): unknown {
const trimmed = rawValue.trim();
if (trimmed === "") return "";
if (trimmed === "null" || trimmed === "~") return null;
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (trimmed === "[]") return [];
if (trimmed === "{}") return {};
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
if (
trimmed.startsWith("\"") ||
trimmed.startsWith("[") ||
trimmed.startsWith("{")
) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed;
}