pi-ultracode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Custom agent types for workflow subagents.
3
+ *
4
+ * An agent type gives a subagent a distinct role: a system prompt, an optional
5
+ * tool allowlist, and optional model / thinking overrides. Types come from two
6
+ * places:
7
+ * - Built-ins defined below (claude, Explore, Plan, general-purpose, code-reviewer).
8
+ * - Markdown files with YAML-ish frontmatter discovered under:
9
+ * <cwd>/.pi/ultracode/agents/*.md (project)
10
+ * ~/.pi/ultracode/agents/*.md (user)
11
+ * ~/.pi/agent/agents/*.md (shared with other tools)
12
+ */
13
+
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import type { ThinkingLevel } from "./agent-runner.ts";
18
+
19
+ export interface AgentTypeDef {
20
+ name: string;
21
+ description: string;
22
+ /** Extra system guidance for this subagent. */
23
+ systemPrompt: string;
24
+ /** "append" (default) merges with the base coding prompt; "replace" is advisory only. */
25
+ systemPromptMode: "append" | "replace";
26
+ /** Optional allowlist of built-in tool names (read, bash, edit, write, grep, find, ls). */
27
+ tools?: string[];
28
+ /** Optional model pattern override. */
29
+ model?: string;
30
+ /** Optional thinking level override. */
31
+ thinking?: ThinkingLevel;
32
+ source: "builtin" | "user" | "project";
33
+ }
34
+
35
+ const BUILTIN_AGENT_TYPES: AgentTypeDef[] = [
36
+ {
37
+ name: "claude",
38
+ description: "General-purpose subagent with the full coding toolset.",
39
+ systemPrompt: "You are a capable, autonomous coding subagent. Complete the task end-to-end and report concrete results.",
40
+ systemPromptMode: "append",
41
+ source: "builtin",
42
+ },
43
+ {
44
+ name: "general-purpose",
45
+ description: "Research and multi-step execution with all tools.",
46
+ systemPrompt:
47
+ "You are a general-purpose research-and-execution subagent. Search broadly, follow leads across files, and return a thorough, well-organized answer with file:line references where relevant.",
48
+ systemPromptMode: "append",
49
+ source: "builtin",
50
+ },
51
+ {
52
+ name: "Explore",
53
+ description: "Read-only fan-out search. Locates code; does not modify it.",
54
+ systemPrompt:
55
+ "You are a read-only exploration subagent. Sweep many files/directories and report the conclusion with precise file:line references. Read excerpts rather than whole files. Do NOT modify anything.",
56
+ systemPromptMode: "append",
57
+ tools: ["read", "grep", "find", "ls", "bash"],
58
+ source: "builtin",
59
+ },
60
+ {
61
+ name: "Plan",
62
+ description: "Software architect. Designs an implementation plan; does not edit.",
63
+ systemPrompt:
64
+ "You are a software-architect subagent. Produce a concrete, step-by-step implementation plan: critical files, sequence, trade-offs, and risks. Do NOT modify files.",
65
+ systemPromptMode: "append",
66
+ tools: ["read", "grep", "find", "ls", "bash"],
67
+ source: "builtin",
68
+ },
69
+ {
70
+ name: "code-reviewer",
71
+ description: "Adversarial reviewer that hunts for correctness, security, and reliability defects.",
72
+ systemPrompt:
73
+ "You are an adversarial code-review subagent. Default to skepticism: try to REFUTE the claim or find the bug. Cite exact file:line evidence. If you cannot find a concrete defect, say so plainly rather than inventing one.",
74
+ systemPromptMode: "append",
75
+ source: "builtin",
76
+ },
77
+ ];
78
+
79
+ const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]);
80
+
81
+ export function discoverAgentTypes(cwd: string): Map<string, AgentTypeDef> {
82
+ const map = new Map<string, AgentTypeDef>();
83
+ for (const def of BUILTIN_AGENT_TYPES) map.set(def.name, def);
84
+
85
+ const dirs: Array<{ dir: string; source: AgentTypeDef["source"] }> = [
86
+ { dir: path.join(os.homedir(), ".pi", "agent", "agents"), source: "user" },
87
+ { dir: path.join(os.homedir(), ".pi", "ultracode", "agents"), source: "user" },
88
+ { dir: path.join(cwd, ".pi", "ultracode", "agents"), source: "project" },
89
+ ];
90
+
91
+ for (const { dir, source } of dirs) {
92
+ let entries: string[];
93
+ try {
94
+ entries = fs.readdirSync(dir);
95
+ } catch {
96
+ continue;
97
+ }
98
+ for (const entry of entries) {
99
+ if (!entry.endsWith(".md")) continue;
100
+ try {
101
+ const content = fs.readFileSync(path.join(dir, entry), "utf8");
102
+ const def = parseAgentTypeFile(content, entry.replace(/\.md$/, ""), source);
103
+ if (def) map.set(def.name, def); // project overrides user overrides builtin
104
+ } catch {
105
+ // ignore unreadable / malformed agent files
106
+ }
107
+ }
108
+ }
109
+ return map;
110
+ }
111
+
112
+ export function resolveAgentType(
113
+ agentType: string | undefined,
114
+ types: Map<string, AgentTypeDef>,
115
+ ): AgentTypeDef | undefined {
116
+ if (!agentType) return undefined;
117
+ const found = types.get(agentType);
118
+ if (found) return found;
119
+ // Case-insensitive fallback.
120
+ const lower = agentType.toLowerCase();
121
+ for (const def of types.values()) {
122
+ if (def.name.toLowerCase() === lower) return def;
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ export function parseAgentTypeFile(
128
+ content: string,
129
+ fallbackName: string,
130
+ source: AgentTypeDef["source"],
131
+ ): AgentTypeDef | undefined {
132
+ const { frontmatter, body } = parseFrontmatter(content);
133
+ const name = (frontmatter.name ?? fallbackName).trim();
134
+ if (!name) return undefined;
135
+ const tools = frontmatter.tools
136
+ ? frontmatter.tools
137
+ .split(",")
138
+ .map((t) => t.trim())
139
+ .filter(Boolean)
140
+ : undefined;
141
+ const thinking = frontmatter.thinking && VALID_THINKING.has(frontmatter.thinking.trim())
142
+ ? (frontmatter.thinking.trim() as ThinkingLevel)
143
+ : undefined;
144
+ const systemPromptMode = frontmatter.systemPromptMode === "replace" ? "replace" : "append";
145
+ const systemPrompt = (frontmatter.systemPrompt ?? body ?? "").trim();
146
+ return {
147
+ name,
148
+ description: (frontmatter.description ?? "").trim(),
149
+ systemPrompt,
150
+ systemPromptMode,
151
+ tools,
152
+ model: frontmatter.model?.trim() || undefined,
153
+ thinking,
154
+ source,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Minimal YAML-ish frontmatter parser: a leading `---` ... `---` block of
160
+ * `key: value` pairs, supporting block scalars (`key: |`).
161
+ */
162
+ export function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
163
+ const normalized = content.replace(/\r\n/g, "\n");
164
+ const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
165
+ if (!match) return { frontmatter: {}, body: normalized };
166
+
167
+ const frontmatter: Record<string, string> = {};
168
+ const lines = match[1].split("\n");
169
+ let i = 0;
170
+ while (i < lines.length) {
171
+ const line = lines[i];
172
+ const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
173
+ if (!kv) {
174
+ i++;
175
+ continue;
176
+ }
177
+ const key = kv[1];
178
+ let value = kv[2];
179
+ if (value === "|" || value === ">" || value === "|-" || value === ">-") {
180
+ // Block scalar: consume indented following lines.
181
+ const block: string[] = [];
182
+ i++;
183
+ while (i < lines.length && (lines[i].startsWith(" ") || lines[i].trim() === "")) {
184
+ block.push(lines[i].replace(/^ {2}/, ""));
185
+ i++;
186
+ }
187
+ frontmatter[key] = block.join("\n").trim();
188
+ continue;
189
+ }
190
+ value = value.trim().replace(/^["']|["']$/g, "");
191
+ frontmatter[key] = value;
192
+ i++;
193
+ }
194
+ return { frontmatter, body: match[2] ?? "" };
195
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Live progress snapshots for a running workflow, plus compact text renderers used
3
+ * for streamed tool updates and the final tool result.
4
+ */
5
+
6
+ import type { WorkflowMeta } from "./parser.ts";
7
+
8
+ export type WorkflowAgentStatus = "running" | "done" | "error" | "skipped" | "cached";
9
+
10
+ export interface WorkflowAgentSnapshot {
11
+ id: number;
12
+ label: string;
13
+ phase?: string;
14
+ status: WorkflowAgentStatus;
15
+ resultPreview?: string;
16
+ error?: string;
17
+ /** Wall-clock ms when the agent started (host layer; set by the tool). */
18
+ startedAt?: number;
19
+ /** Wall-clock ms when the agent finished. */
20
+ endedAt?: number;
21
+ /** Runtime duration in ms (endedAt - startedAt). */
22
+ durationMs?: number;
23
+ /** Wall-clock ms of the last observed activity inside the subagent. */
24
+ lastActivityAt?: number;
25
+ /** Short activity label while running, e.g. "text", "bash", "thinking". */
26
+ activity?: string;
27
+ /** Ring buffer of recent text output while running (live, not persisted). */
28
+ streamTail?: string;
29
+ }
30
+
31
+ export interface WorkflowSnapshot {
32
+ runId?: string;
33
+ name: string;
34
+ description?: string;
35
+ phases: string[];
36
+ currentPhase?: string;
37
+ logs: string[];
38
+ agents: WorkflowAgentSnapshot[];
39
+ agentCount: number;
40
+ runningCount: number;
41
+ doneCount: number;
42
+ errorCount: number;
43
+ cachedCount: number;
44
+ spentTokens: number;
45
+ budgetTotal: number | null;
46
+ durationMs?: number;
47
+ result?: unknown;
48
+ status: "running" | "completed" | "aborted" | "failed";
49
+ }
50
+
51
+ export interface RenderOptions {
52
+ maxAgents?: number;
53
+ maxLogs?: number;
54
+ showResultPreviews?: boolean;
55
+ /** Show the live streaming tail under each running agent (inspect view). */
56
+ showStream?: boolean;
57
+ /** Override `Date.now()` for deterministic elapsed/idle rendering in tests. */
58
+ now?: number;
59
+ }
60
+
61
+ export function createSnapshot(meta: WorkflowMeta, runId: string, budgetTotal: number | null): WorkflowSnapshot {
62
+ return {
63
+ runId,
64
+ name: meta.name,
65
+ description: meta.description,
66
+ phases: meta.phases?.map((p) => p.title) ?? [],
67
+ logs: [],
68
+ agents: [],
69
+ agentCount: 0,
70
+ runningCount: 0,
71
+ doneCount: 0,
72
+ errorCount: 0,
73
+ cachedCount: 0,
74
+ spentTokens: 0,
75
+ budgetTotal,
76
+ status: "running",
77
+ };
78
+ }
79
+
80
+ export function recompute(snapshot: WorkflowSnapshot): WorkflowSnapshot {
81
+ const runningCount = snapshot.agents.filter((a) => a.status === "running").length;
82
+ const doneCount = snapshot.agents.filter((a) => a.status === "done" || a.status === "cached").length;
83
+ const errorCount = snapshot.agents.filter((a) => a.status === "error").length;
84
+ const cachedCount = snapshot.agents.filter((a) => a.status === "cached").length;
85
+ return { ...snapshot, agentCount: snapshot.agents.length, runningCount, doneCount, errorCount, cachedCount };
86
+ }
87
+
88
+ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderOptions = {}): string[] {
89
+ const maxAgents = options.maxAgents ?? 6;
90
+ const maxLogs = options.maxLogs ?? 2;
91
+ const showResultPreviews = options.showResultPreviews ?? false;
92
+ const showStream = options.showStream ?? false;
93
+ const now = options.now ?? Date.now();
94
+
95
+ const tokens = snapshot.spentTokens
96
+ ? ` · ${formatTokens(snapshot.spentTokens)}${snapshot.budgetTotal ? `/${formatTokens(snapshot.budgetTotal)}` : ""} tok`
97
+ : "";
98
+ const state =
99
+ snapshot.errorCount > 0
100
+ ? `, ${snapshot.errorCount} errors`
101
+ : snapshot.runningCount > 0
102
+ ? `, ${snapshot.runningCount} running`
103
+ : "";
104
+ const cached = snapshot.cachedCount ? ` · ${snapshot.cachedCount} cached` : "";
105
+ const header = `◆ ${statusMark(snapshot.status)} ${snapshot.name} (${snapshot.doneCount}/${snapshot.agentCount} done${state})${cached}${tokens}`;
106
+ const lines = [header];
107
+
108
+ const phaseNames = unique([
109
+ ...snapshot.phases,
110
+ ...(snapshot.currentPhase ? [snapshot.currentPhase] : []),
111
+ ...snapshot.agents.map((a) => a.phase).filter((p): p is string => Boolean(p)),
112
+ ]);
113
+ const rendered = new Set<WorkflowAgentSnapshot>();
114
+
115
+ for (const phase of phaseNames) {
116
+ const agents = snapshot.agents.filter((a) => a.phase === phase);
117
+ if (agents.length === 0 && snapshot.currentPhase !== phase) continue;
118
+ for (const a of agents) rendered.add(a);
119
+ const done = agents.filter((a) => a.status === "done" || a.status === "cached").length;
120
+ const running = agents.filter((a) => a.status === "running").length;
121
+ const errors = agents.filter((a) => a.status === "error").length;
122
+ const complete = agents.length > 0 && done + errors === agents.length;
123
+ const marker = running > 0 || (!complete && snapshot.currentPhase === phase) ? "▶" : complete ? "✓" : " ";
124
+ lines.push(
125
+ ` ${marker} ${phase} ${done}/${agents.length}${running ? ` · ${running} running` : ""}${errors ? ` · ${errors} errors` : ""}`,
126
+ );
127
+ for (const agent of agents.slice(-maxAgents)) {
128
+ lines.push(renderAgentLine(agent, { showResultPreviews, showStream, now }));
129
+ }
130
+ if (agents.length > maxAgents) lines.push(` … ${agents.length - maxAgents} earlier agents`);
131
+ }
132
+
133
+ const unphased = snapshot.agents.filter((a) => !rendered.has(a));
134
+ if (unphased.length) {
135
+ lines.push(" (unphased)");
136
+ for (const agent of unphased.slice(-maxAgents)) {
137
+ lines.push(renderAgentLine(agent, { showResultPreviews, showStream, now }));
138
+ }
139
+ }
140
+
141
+ for (const log of snapshot.logs.slice(-maxLogs)) lines.push(` log: ${shorten(log, 100)}`);
142
+ return lines;
143
+ }
144
+
145
+ export function renderWorkflowText(snapshot: WorkflowSnapshot, options: RenderOptions = {}): string {
146
+ return renderWorkflowLines(snapshot, options).join("\n");
147
+ }
148
+
149
+ export function preview(value: unknown, max = 80): string {
150
+ const text = typeof value === "string" ? value : safeJson(value);
151
+ if (!text) return "";
152
+ const oneLine = text.replace(/\s+/g, " ").trim();
153
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
154
+ }
155
+
156
+ function statusMark(status: WorkflowSnapshot["status"]): string {
157
+ switch (status) {
158
+ case "completed":
159
+ return "✓";
160
+ case "aborted":
161
+ return "■";
162
+ case "failed":
163
+ return "✗";
164
+ default:
165
+ return "▶";
166
+ }
167
+ }
168
+
169
+ function statusIcon(status: WorkflowAgentStatus): string {
170
+ switch (status) {
171
+ case "running":
172
+ return "●";
173
+ case "done":
174
+ return "✓";
175
+ case "cached":
176
+ return "⟲";
177
+ case "error":
178
+ return "✗";
179
+ case "skipped":
180
+ return "-";
181
+ }
182
+ }
183
+
184
+ function unique(values: string[]): string[] {
185
+ return [...new Set(values)];
186
+ }
187
+
188
+ function shorten(value: string, max: number): string {
189
+ const text = value.replace(/\s+/g, " ").trim();
190
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
191
+ }
192
+
193
+ function formatTokens(n: number): string {
194
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
195
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
196
+ return String(n);
197
+ }
198
+
199
+ function safeJson(value: unknown): string {
200
+ try {
201
+ return JSON.stringify(value) ?? "";
202
+ } catch {
203
+ return String(value);
204
+ }
205
+ }
206
+
207
+ /** Seconds of silence after which a running agent is flagged as idle. */
208
+ export const IDLE_THRESHOLD_S = 30;
209
+
210
+ function renderAgentLine(
211
+ agent: WorkflowAgentSnapshot,
212
+ opts: { showResultPreviews: boolean; showStream: boolean; now: number },
213
+ ): string {
214
+ const meta = agentMeta(agent, opts.now);
215
+ const result = opts.showResultPreviews && agent.resultPreview ? ` — ${agent.resultPreview}` : "";
216
+ const stream =
217
+ opts.showStream && agent.status === "running" && agent.streamTail
218
+ ? `\n ┊ ${shorten(agent.streamTail, 120)}`
219
+ : "";
220
+ return ` #${agent.id} ${statusIcon(agent.status)} ${shorten(agent.label, 48)}${meta}${result}${stream}`;
221
+ }
222
+
223
+ /** Compact per-agent timing/activity suffix for the live snapshot. */
224
+ function agentMeta(agent: WorkflowAgentSnapshot, now: number): string {
225
+ if (agent.status === "running") {
226
+ const startedAt = agent.startedAt ?? now;
227
+ const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000));
228
+ const lastAct = agent.lastActivityAt ?? startedAt;
229
+ const idle = Math.max(0, Math.floor((now - lastAct) / 1000));
230
+ if (idle >= IDLE_THRESHOLD_S) return ` · ${elapsed}s · ⚠ idle ${idle}s`;
231
+ const act = agent.activity ? ` · ${shorten(agent.activity, 20)}` : "";
232
+ return ` · ${elapsed}s${act}`;
233
+ }
234
+ if (agent.durationMs != null && agent.durationMs > 0) return ` · ${formatDuration(agent.durationMs)}`;
235
+ return "";
236
+ }
237
+
238
+ function formatDuration(ms: number): string {
239
+ const s = ms / 1000;
240
+ if (s < 1) return "<1s";
241
+ if (s < 60) return `${Math.round(s)}s`;
242
+ const m = Math.floor(s / 60);
243
+ const rem = Math.round(s % 60);
244
+ return `${m}m${rem ? ` ${rem}s` : ""}`;
245
+ }
Binary file
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Convert a plain JSON Schema (as written inside a workflow script's
3
+ * `agent(prompt, { schema })`) into a TypeBox `TSchema` so Pi can both validate
4
+ * the subagent's structured output and serialize a faithful tool schema to the model.
5
+ *
6
+ * Covers the common JSON Schema subset; anything unrecognized falls back to
7
+ * `Type.Unsafe`, which preserves the raw schema for the model without crashing.
8
+ */
9
+
10
+ import { Type, type TSchema } from "typebox";
11
+
12
+ type Json = Record<string, any>;
13
+
14
+ export function jsonSchemaToTypeBox(schema: unknown): TSchema {
15
+ if (!schema || typeof schema !== "object") {
16
+ // No constraints -> accept anything.
17
+ return Type.Unsafe<unknown>({});
18
+ }
19
+ const node = schema as Json;
20
+ const annotations = pickAnnotations(node);
21
+
22
+ // Composite keywords first.
23
+ if (Array.isArray(node.enum)) {
24
+ const literals = node.enum.map((value: unknown) => Type.Literal(value as any));
25
+ return withAnnotations(literals.length === 1 ? literals[0] : Type.Union(literals), annotations);
26
+ }
27
+ if ("const" in node) {
28
+ return withAnnotations(Type.Literal(node.const), annotations);
29
+ }
30
+ if (Array.isArray(node.anyOf)) {
31
+ return withAnnotations(Type.Union(node.anyOf.map(jsonSchemaToTypeBox)), annotations);
32
+ }
33
+ if (Array.isArray(node.oneOf)) {
34
+ return withAnnotations(Type.Union(node.oneOf.map(jsonSchemaToTypeBox)), annotations);
35
+ }
36
+ if (Array.isArray(node.allOf)) {
37
+ return withAnnotations(Type.Intersect(node.allOf.map(jsonSchemaToTypeBox)), annotations);
38
+ }
39
+
40
+ const type = node.type;
41
+ if (Array.isArray(type)) {
42
+ // e.g. ["string", "null"]
43
+ return withAnnotations(
44
+ Type.Union(type.map((t: string) => jsonSchemaToTypeBox({ ...node, type: t, enum: undefined }))),
45
+ annotations,
46
+ );
47
+ }
48
+
49
+ switch (type) {
50
+ case "object":
51
+ return withAnnotations(objectSchema(node), annotations);
52
+ case "array":
53
+ return withAnnotations(arraySchema(node), annotations);
54
+ case "string":
55
+ return withAnnotations(Type.String(numericAndStringConstraints(node)), annotations);
56
+ case "number":
57
+ return withAnnotations(Type.Number(numericAndStringConstraints(node)), annotations);
58
+ case "integer":
59
+ return withAnnotations(Type.Integer(numericAndStringConstraints(node)), annotations);
60
+ case "boolean":
61
+ return withAnnotations(Type.Boolean(), annotations);
62
+ case "null":
63
+ return withAnnotations(Type.Null(), annotations);
64
+ default:
65
+ // Untyped object with properties is still an object.
66
+ if (node.properties) return withAnnotations(objectSchema(node), annotations);
67
+ if (node.items) return withAnnotations(arraySchema(node), annotations);
68
+ return Type.Unsafe<unknown>({ ...node });
69
+ }
70
+ }
71
+
72
+ function objectSchema(node: Json): TSchema {
73
+ const properties: Record<string, TSchema> = {};
74
+ const required: string[] = Array.isArray(node.required) ? node.required : [];
75
+ const props = (node.properties ?? {}) as Json;
76
+ for (const [key, value] of Object.entries(props)) {
77
+ const child = jsonSchemaToTypeBox(value);
78
+ properties[key] = required.includes(key) ? child : Type.Optional(child);
79
+ }
80
+ const options: Json = {};
81
+ if (node.additionalProperties === false) options.additionalProperties = false;
82
+ else if (node.additionalProperties && typeof node.additionalProperties === "object") {
83
+ options.additionalProperties = jsonSchemaToTypeBox(node.additionalProperties);
84
+ }
85
+ return Type.Object(properties, options);
86
+ }
87
+
88
+ function arraySchema(node: Json): TSchema {
89
+ const items = node.items ? jsonSchemaToTypeBox(Array.isArray(node.items) ? node.items[0] : node.items) : Type.Unknown();
90
+ const options: Json = {};
91
+ if (typeof node.minItems === "number") options.minItems = node.minItems;
92
+ if (typeof node.maxItems === "number") options.maxItems = node.maxItems;
93
+ if (node.uniqueItems === true) options.uniqueItems = true;
94
+ return Type.Array(items, options);
95
+ }
96
+
97
+ function numericAndStringConstraints(node: Json): Json {
98
+ const out: Json = {};
99
+ for (const key of ["minimum", "maximum", "minLength", "maxLength", "pattern", "format"]) {
100
+ if (node[key] !== undefined) out[key] = node[key];
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function pickAnnotations(node: Json): Json {
106
+ const out: Json = {};
107
+ if (typeof node.description === "string") out.description = node.description;
108
+ if (typeof node.title === "string") out.title = node.title;
109
+ if (node.default !== undefined) out.default = node.default;
110
+ return out;
111
+ }
112
+
113
+ function withAnnotations(schema: TSchema, annotations: Json): TSchema {
114
+ if (Object.keys(annotations).length === 0) return schema;
115
+ return { ...schema, ...annotations } as TSchema;
116
+ }