@nghyane/arcane 0.1.7 → 0.1.10

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.
package/src/sdk.ts CHANGED
@@ -72,6 +72,7 @@ import {
72
72
  loadSshTool,
73
73
  PythonTool,
74
74
  ReadTool,
75
+ type SubagentContext,
75
76
  setPreferredImageProvider,
76
77
  setPreferredSearchProvider,
77
78
  type Tool,
@@ -157,12 +158,8 @@ export interface CreateAgentSessionOptions {
157
158
  /** Tool names explicitly requested (enables disabled-by-default tools) */
158
159
  toolNames?: string[];
159
160
 
160
- /** Output schema for structured completion (subagents) */
161
- outputSchema?: unknown;
162
- /** Whether to include the submit_result tool by default */
163
- requireSubmitResultTool?: boolean;
164
- /** Task recursion depth (for subagent sessions). Default: 0 */
165
- taskDepth?: number;
161
+ /** Whether this is a subagent session. Default: false */
162
+ isSubagent?: boolean;
166
163
  /** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
167
164
  parentTaskPrefix?: string;
168
165
 
@@ -223,6 +220,7 @@ export {
223
220
  PythonTool,
224
221
  ReadTool,
225
222
  WriteTool,
223
+ type SubagentContext,
226
224
  type ToolSession,
227
225
  };
228
226
 
@@ -629,8 +627,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
629
627
 
630
628
  // For subagent sessions using GitHub Copilot, add X-Initiator header
631
629
  // to ensure proper billing (agent-initiated vs user-initiated)
632
- const taskDepth = options.taskDepth ?? 0;
633
- const forceCopilotAgentInitiator = taskDepth > 0;
630
+ const isSubagent = options.isSubagent ?? false;
631
+ const forceCopilotAgentInitiator = isSubagent;
634
632
  if (forceCopilotAgentInitiator && model?.provider === "github-copilot") {
635
633
  model = {
636
634
  ...model,
@@ -722,9 +720,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
722
720
  contextFiles,
723
721
  skills,
724
722
  eventBus,
725
- outputSchema: options.outputSchema,
726
- requireSubmitResultTool: options.requireSubmitResultTool,
727
- taskDepth: options.taskDepth ?? 0,
723
+ isSubagent: options.isSubagent ?? false,
728
724
  getSessionFile: () => sessionManager.getSessionFile() ?? null,
729
725
  getSessionId: () => sessionManager.getSessionId?.() ?? null,
730
726
  getSessionSpawns: () => options.spawns ?? "*",
@@ -736,10 +732,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
736
732
  if (model) return formatModelString(model);
737
733
  return undefined;
738
734
  },
739
- getCompactContext: () => session.formatCompactContext(),
735
+ subagentContext: {
736
+ getCompactContext: () => session.formatCompactContext(),
737
+ authStorage,
738
+ modelRegistry,
739
+ },
740
740
  settings,
741
- authStorage,
742
- modelRegistry,
743
741
  };
744
742
 
745
743
  // Initialize internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://)
@@ -798,7 +796,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
798
796
  });
799
797
  time("discoverAndLoadMCPTools");
800
798
  mcpManager = mcpResult.manager;
801
- toolSession.mcpManager = mcpManager;
799
+ if (!toolSession.subagentContext) toolSession.subagentContext = {};
800
+ toolSession.subagentContext.mcpManager = mcpManager;
802
801
 
803
802
  // If we extracted Exa API keys from MCP configs and EXA_AARCANE_KEY isn't set, use the first one
804
803
  if (mcpResult.exaApiKeys.length > 0 && !$env.EXA_AARCANE_KEY) {
@@ -1273,7 +1272,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1273
1272
  settings,
1274
1273
  modelRegistry,
1275
1274
  agentDir,
1276
- taskDepth,
1275
+ isSubagent,
1277
1276
  });
1278
1277
 
1279
1278
  return {
@@ -124,8 +124,6 @@ export interface SessionInitEntry extends SessionEntryBase {
124
124
  task: string;
125
125
  /** Tools available to the agent */
126
126
  tools: string[];
127
- /** Output schema if structured output was requested */
128
- outputSchema?: unknown;
129
127
  }
130
128
 
131
129
  /** Mode change entry - tracks agent mode transitions. */
@@ -1685,7 +1683,7 @@ export class SessionManager {
1685
1683
  }
1686
1684
 
1687
1685
  /** Append session init metadata (for subagent debugging/replay). Returns entry id. */
1688
- appendSessionInit(init: { systemPrompt: string; task: string; tools: string[]; outputSchema?: unknown }): string {
1686
+ appendSessionInit(init: { systemPrompt: string; task: string; tools: string[] }): string {
1689
1687
  const entry: SessionInitEntry = {
1690
1688
  type: "session_init",
1691
1689
  id: generateId(this.#byId),
@@ -107,7 +107,7 @@ export interface ExecutorOptions {
107
107
  id: string;
108
108
  modelOverride?: string | string[];
109
109
  thinkingLevel?: ThinkingLevel;
110
- taskDepth?: number;
110
+ isSubagent?: boolean;
111
111
  enableLsp?: boolean;
112
112
  signal?: AbortSignal;
113
113
  onProgress?: (progress: AgentProgress) => void;
@@ -682,7 +682,7 @@ export async function runAgent(options: ExecutorOptions): Promise<SingleResult>
682
682
  sessionManager,
683
683
  hasUI: false,
684
684
  spawns: "",
685
- taskDepth: (options.taskDepth ?? 0) + 1,
685
+ isSubagent: true,
686
686
  parentTaskPrefix: id,
687
687
  enableLsp: lspEnabled,
688
688
  skipPythonPreflight,
package/src/task/index.ts CHANGED
@@ -93,7 +93,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
93
93
 
94
94
  try {
95
95
  await fs.mkdir(effectiveArtifactsDir, { recursive: true });
96
- const compactContext = this.session.getCompactContext?.();
96
+ const compactContext = this.session.subagentContext?.getCompactContext?.();
97
97
  let contextFilePath: string | undefined;
98
98
  if (compactContext) {
99
99
  contextFilePath = path.join(effectiveArtifactsDir, "context.md");
@@ -164,7 +164,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
164
164
  description: rendered.description,
165
165
  index: 0,
166
166
  id: uniqueId,
167
- taskDepth: this.session.taskDepth ?? 0,
167
+ isSubagent: true,
168
168
  modelOverride,
169
169
  sessionFile,
170
170
  persistArtifacts: !!artifactsDir,
@@ -177,10 +177,10 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
177
177
  progressMap.set(0, { ...structuredClone(progress) });
178
178
  emitProgress();
179
179
  },
180
- authStorage: this.session.authStorage,
181
- modelRegistry: this.session.modelRegistry,
180
+ authStorage: this.session.subagentContext?.authStorage,
181
+ modelRegistry: this.session.subagentContext?.modelRegistry,
182
182
  settings: this.session.settings,
183
- mcpManager: this.session.mcpManager,
183
+ mcpManager: this.session.subagentContext?.mcpManager,
184
184
  contextFiles,
185
185
  skills: resolvedSkills,
186
186
  preloadedSkills,
package/src/task/types.ts CHANGED
@@ -24,26 +24,13 @@ export const TASK_SUBAGENT_EVENT_CHANNEL = "task:subagent:event";
24
24
  /** EventBus channel for aggregated subagent progress */
25
25
  export const TASK_SUBAGENT_PROGRESS_CHANNEL = "task:subagent:progress";
26
26
 
27
- /** Single task item for parallel execution */
28
- export const taskItemSchema = Type.Object({
29
- id: Type.String({
30
- description: "CamelCase identifier, max 32 chars",
31
- maxLength: 32,
32
- }),
33
- description: Type.String({
34
- description: "Short one-liner for UI display only — not seen by the subagent",
35
- }),
36
- assignment: Type.String({
37
- description:
38
- "Complete per-task instructions the subagent executes. Must follow the Target/Change/Edge Cases/Acceptance structure. Only include per-task deltas — shared background belongs in `context`.",
39
- }),
40
- skills: Type.Optional(
41
- Type.Array(Type.String(), {
42
- description: "Skill names to preload into the subagent. Use only where it changes correctness.",
43
- }),
44
- ),
45
- });
46
- export type TaskItem = Static<typeof taskItemSchema>;
27
+ /** Single task item for execution */
28
+ export interface TaskItem {
29
+ id: string;
30
+ description: string;
31
+ assignment: string;
32
+ skills?: string[];
33
+ }
47
34
 
48
35
  /** Task schema — single task with optional context */
49
36
  export const taskSchema = Type.Object({
@@ -31,7 +31,6 @@ import { PythonTool } from "./python";
31
31
  import { ReadTool } from "./read";
32
32
  import { ReviewerTool } from "./reviewer-tool";
33
33
  import { loadSshTool } from "./ssh";
34
- import { SubmitResultTool } from "./submit-result";
35
34
  import { TodoWriteTool } from "./todo-write";
36
35
  import { UndoEditTool } from "./undo-edit";
37
36
  import { WriteTool } from "./write";
@@ -94,7 +93,6 @@ export { OracleTool } from "./oracle";
94
93
  export { PythonTool, type PythonToolDetails, type PythonToolOptions } from "./python";
95
94
  export { ReadTool, type ReadToolDetails, type ReadToolInput } from "./read";
96
95
  export { loadSshTool, type SSHToolDetails, SshTool } from "./ssh";
97
- export { SubmitResultTool } from "./submit-result";
98
96
  export { type TodoItem, TodoWriteTool, type TodoWriteToolDetails } from "./todo-write";
99
97
  export { UndoEditTool, type UndoEditToolDetails } from "./undo-edit";
100
98
  export { WriteTool, type WriteToolDetails, type WriteToolInput } from "./write";
@@ -108,6 +106,14 @@ export type ContextFileEntry = {
108
106
  depth?: number;
109
107
  };
110
108
 
109
+ /** Forwarded context for spawning subagent processes */
110
+ export interface SubagentContext {
111
+ authStorage?: import("../session/auth-storage").AuthStorage;
112
+ modelRegistry?: import("../config/model-registry").ModelRegistry;
113
+ mcpManager?: import("../mcp/manager").MCPManager;
114
+ getCompactContext?: () => string;
115
+ }
116
+
111
117
  /** Session context for tool factories */
112
118
  export interface ToolSession {
113
119
  /** Current working directory */
@@ -128,12 +134,8 @@ export interface ToolSession {
128
134
  hasEditTool?: boolean;
129
135
  /** Event bus for tool/extension communication */
130
136
  eventBus?: EventBus;
131
- /** Output schema for structured completion (subagents) */
132
- outputSchema?: unknown;
133
- /** Whether to include the submit_result tool by default */
134
- requireSubmitResultTool?: boolean;
135
- /** Task recursion depth (0 = top-level, 1 = first child, etc.) */
136
- taskDepth?: number;
137
+ /** Whether this session is a subagent (spawned by task tool) */
138
+ isSubagent?: boolean;
137
139
  /** Get session file */
138
140
  getSessionFile: () => string | null;
139
141
  /** Get session ID */
@@ -148,20 +150,14 @@ export interface ToolSession {
148
150
  getModelString?: () => string | undefined;
149
151
  /** Get the current session model string, regardless of how it was chosen */
150
152
  getActiveModelString?: () => string | undefined;
151
- /** Auth storage for passing to subagents (avoids re-discovery) */
152
- authStorage?: import("../session/auth-storage").AuthStorage;
153
- /** Model registry for passing to subagents (avoids re-discovery) */
154
- modelRegistry?: import("../config/model-registry").ModelRegistry;
155
- /** MCP manager for proxying MCP calls through parent */
156
- mcpManager?: import("../mcp/manager").MCPManager;
153
+ /** Context for spawning subagent processes (only used by task/subagent tools) */
154
+ subagentContext?: SubagentContext;
157
155
  /** Internal URL router for agent:// and skill:// URLs */
158
156
  internalRouter?: InternalUrlRouter;
159
157
  /** Agent output manager for unique agent:// IDs across task invocations */
160
158
  agentOutputManager?: AgentOutputManager;
161
159
  /** Settings instance for passing to subagents */
162
160
  settings: Settings;
163
- /** Get compact conversation context for subagents (excludes tool results, system prompts) */
164
- getCompactContext?: () => string;
165
161
  }
166
162
 
167
163
  type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
@@ -191,10 +187,6 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
191
187
  write: s => new WriteTool(s),
192
188
  };
193
189
 
194
- export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
195
- submit_result: s => new SubmitResultTool(s),
196
- };
197
-
198
190
  export type ToolName = keyof typeof BUILTIN_TOOLS;
199
191
 
200
192
  export type PythonToolMode = "ipy-only" | "bash-only" | "both";
@@ -232,7 +224,6 @@ function getPythonModeFromEnv(): PythonToolMode | null {
232
224
  */
233
225
  export async function createTools(session: ToolSession, toolNames?: string[]): Promise<Tool[]> {
234
226
  time("createTools:start");
235
- const includeSubmitResult = session.requireSubmitResultTool === true;
236
227
  const enableLsp = session.enableLsp ?? true;
237
228
  const requestedTools = toolNames && toolNames.length > 0 ? [...new Set(toolNames)] : undefined;
238
229
  const pythonMode = getPythonModeFromEnv() ?? session.settings.get("python.toolMode");
@@ -278,12 +269,12 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
278
269
  ) {
279
270
  requestedTools.push("bash");
280
271
  }
281
- const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
272
+ const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS };
282
273
  const isToolAllowed = (name: string) => {
283
274
  if (name === "lsp") return enableLsp;
284
275
  if (name === "bash") return allowBash;
285
276
  if (name === "python") return allowPython;
286
- if (name === "todo_write") return !includeSubmitResult && session.settings.get("todo.enabled");
277
+ if (name === "todo_write") return session.settings.get("todo.enabled");
287
278
  if (name === "find") return session.settings.get("find.enabled");
288
279
  if (name === "grep") return session.settings.get("grep.enabled");
289
280
  if (name === "notebook") return session.settings.get("notebook.enabled");
@@ -295,25 +286,17 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
295
286
  if (name === "librarian") return session.settings.get("librarian.enabled");
296
287
  if (name === "oracle") return session.settings.get("oracle.enabled");
297
288
  if (name === "task") {
298
- const maxDepth = session.settings.get("task.maxRecursionDepth") ?? 2;
299
- const currentDepth = session.taskDepth ?? 0;
300
- return maxDepth < 0 || currentDepth < maxDepth;
289
+ return !session.isSubagent;
301
290
  }
302
291
  return true;
303
292
  };
304
- if (includeSubmitResult && requestedTools && !requestedTools.includes("submit_result")) {
305
- requestedTools.push("submit_result");
306
- }
307
293
 
308
294
  const filteredRequestedTools = requestedTools?.filter(name => name in allTools && isToolAllowed(name));
309
295
 
310
296
  const entries =
311
297
  filteredRequestedTools !== undefined
312
298
  ? filteredRequestedTools.map(name => [name, allTools[name]] as const)
313
- : [
314
- ...Object.entries(BUILTIN_TOOLS).filter(([name]) => isToolAllowed(name)),
315
- ...(includeSubmitResult ? ([["submit_result", HIDDEN_TOOLS.submit_result]] as const) : []),
316
- ];
299
+ : [...Object.entries(BUILTIN_TOOLS).filter(([name]) => isToolAllowed(name))];
317
300
 
318
301
  const results = await Promise.all(
319
302
  entries.map(async ([name, factory]) => {
@@ -111,7 +111,7 @@ export function createSubagentTool<T extends TProperties>(
111
111
 
112
112
  let contextFilePath: string | undefined;
113
113
  if (passContext) {
114
- const compactContext = this.session.getCompactContext?.();
114
+ const compactContext = this.session.subagentContext?.getCompactContext?.();
115
115
  if (compactContext) {
116
116
  contextFilePath = path.join(effectiveArtifactsDir, "context.md");
117
117
  await Bun.write(contextFilePath, compactContext);
@@ -125,7 +125,7 @@ export function createSubagentTool<T extends TProperties>(
125
125
  description: buildDescription(params),
126
126
  index: 0,
127
127
  id,
128
- taskDepth: this.session.taskDepth ?? 0,
128
+ isSubagent: true,
129
129
  modelOverride,
130
130
  sessionFile,
131
131
  persistArtifacts: !!artifactsDir,
@@ -134,13 +134,13 @@ export function createSubagentTool<T extends TProperties>(
134
134
  enableLsp: false,
135
135
  signal,
136
136
  onProgress: emitProgress,
137
- authStorage: this.session.authStorage,
138
- modelRegistry: this.session.modelRegistry,
137
+ authStorage: this.session.subagentContext?.authStorage,
138
+ modelRegistry: this.session.subagentContext?.modelRegistry,
139
139
  settings: this.session.settings,
140
140
  contextFiles: this.session.contextFiles,
141
141
  skills: this.session.skills,
142
142
  promptTemplates: this.session.promptTemplates,
143
- mcpManager: this.session.mcpManager,
143
+ mcpManager: this.session.subagentContext?.mcpManager,
144
144
  });
145
145
 
146
146
  if (tempArtifactsDir) {
@@ -78,39 +78,6 @@ function normalizeTodos(items: Array<{ id?: string; content?: string; status?: s
78
78
  });
79
79
  }
80
80
 
81
- function validateSequentialTodos(todos: TodoItem[]): { valid: boolean; error?: string } {
82
- if (todos.length === 0) return { valid: true };
83
-
84
- const firstIncompleteIndex = todos.findIndex(todo => todo.status !== "completed");
85
- if (firstIncompleteIndex >= 0) {
86
- for (let i = firstIncompleteIndex + 1; i < todos.length; i++) {
87
- if (todos[i].status === "completed") {
88
- return {
89
- valid: false,
90
- error: `Error: Cannot complete "${todos[i].content}" before completing "${todos[firstIncompleteIndex].content}". Todos must be completed sequentially.`,
91
- };
92
- }
93
- }
94
- }
95
-
96
- const inProgressIndices = todos.reduce<number[]>((acc, todo, index) => {
97
- if (todo.status === "in_progress") acc.push(index);
98
- return acc;
99
- }, []);
100
-
101
- for (const idx of inProgressIndices) {
102
- const hasPriorIncomplete = todos.slice(0, idx).some(t => t.status === "pending");
103
- if (hasPriorIncomplete) {
104
- return {
105
- valid: false,
106
- error: `Cannot start "${todos[idx].content}" while earlier tasks are still pending.`,
107
- };
108
- }
109
- }
110
-
111
- return { valid: true };
112
- }
113
-
114
81
  async function loadTodoFile(filePath: string): Promise<TodoFile | null> {
115
82
  const file = Bun.file(filePath);
116
83
  if (!(await file.exists())) return null;
@@ -168,10 +135,6 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
168
135
  _context?: AgentToolContext,
169
136
  ): Promise<AgentToolResult<TodoWriteToolDetails>> {
170
137
  const todos = normalizeTodos(params.todos ?? []);
171
- const validation = validateSequentialTodos(todos);
172
- if (!validation.valid) {
173
- throw new Error(validation.error ?? "Todos must be completed sequentially.");
174
- }
175
138
  const updatedAt = Date.now();
176
139
 
177
140
  const sessionFile = this.session.getSessionFile();
package/src/version.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { version } from "../package.json" with { type: "json" };
2
+
3
+ export const VERSION: string = version;
@@ -1,11 +0,0 @@
1
- <system-reminder>
2
- You stopped without calling submit_result. This is reminder {{retryCount}} of {{maxRetries}}.
3
-
4
- Your only available action now is to call submit_result. Choose one:
5
- - If task is complete: call submit_result with your result data
6
- - If task failed or was interrupted: call submit_result with status="aborted" and describe what happened
7
-
8
- Do NOT choose aborted if you can still complete the task through exploration (using available tools or repo context). If you must abort, include what you tried and the exact blocker.
9
-
10
- Do NOT output text without a tool call. You must call submit_result to finish.
11
- </system-reminder>
@@ -1,247 +0,0 @@
1
- /**
2
- * Convert JSON Type Definition (JTD) to JSON Schema.
3
- *
4
- * JTD (RFC 8927) is a simpler schema format. This converter allows users to
5
- * write schemas in JTD and have them converted to JSON Schema for model APIs.
6
- *
7
- * @see https://jsontypedef.com/
8
- * @see https://datatracker.ietf.org/doc/html/rfc8927
9
- */
10
-
11
- type JTDPrimitive =
12
- | "boolean"
13
- | "string"
14
- | "timestamp"
15
- | "float32"
16
- | "float64"
17
- | "int8"
18
- | "uint8"
19
- | "int16"
20
- | "uint16"
21
- | "int32"
22
- | "uint32";
23
-
24
- interface JTDType {
25
- type: JTDPrimitive;
26
- }
27
-
28
- interface JTDEnum {
29
- enum: string[];
30
- }
31
-
32
- interface JTDElements {
33
- elements: JTDSchema;
34
- }
35
-
36
- interface JTDValues {
37
- values: JTDSchema;
38
- }
39
-
40
- interface JTDProperties {
41
- properties?: Record<string, JTDSchema>;
42
- optionalProperties?: Record<string, JTDSchema>;
43
- }
44
-
45
- interface JTDDiscriminator {
46
- discriminator: string;
47
- mapping: Record<string, JTDProperties>;
48
- }
49
-
50
- interface JTDRef {
51
- ref: string;
52
- }
53
-
54
- interface JTDEmpty {}
55
-
56
- type JTDSchema = JTDType | JTDEnum | JTDElements | JTDValues | JTDProperties | JTDDiscriminator | JTDRef | JTDEmpty;
57
-
58
- const primitiveMap: Record<JTDPrimitive, string> = {
59
- boolean: "boolean",
60
- string: "string",
61
- timestamp: "string", // ISO 8601
62
- float32: "number",
63
- float64: "number",
64
- int8: "integer",
65
- uint8: "integer",
66
- int16: "integer",
67
- uint16: "integer",
68
- int32: "integer",
69
- uint32: "integer",
70
- };
71
-
72
- function isJTDType(schema: unknown): schema is JTDType {
73
- return typeof schema === "object" && schema !== null && "type" in schema;
74
- }
75
-
76
- function isJTDEnum(schema: unknown): schema is JTDEnum {
77
- return typeof schema === "object" && schema !== null && "enum" in schema;
78
- }
79
-
80
- function isJTDElements(schema: unknown): schema is JTDElements {
81
- return typeof schema === "object" && schema !== null && "elements" in schema;
82
- }
83
-
84
- function isJTDValues(schema: unknown): schema is JTDValues {
85
- return typeof schema === "object" && schema !== null && "values" in schema;
86
- }
87
-
88
- function isJTDProperties(schema: unknown): schema is JTDProperties {
89
- return typeof schema === "object" && schema !== null && ("properties" in schema || "optionalProperties" in schema);
90
- }
91
-
92
- function isJTDDiscriminator(schema: unknown): schema is JTDDiscriminator {
93
- return typeof schema === "object" && schema !== null && "discriminator" in schema;
94
- }
95
-
96
- function isJTDRef(schema: unknown): schema is JTDRef {
97
- return typeof schema === "object" && schema !== null && "ref" in schema;
98
- }
99
-
100
- function convertSchema(schema: unknown): unknown {
101
- if (schema === null || typeof schema !== "object") {
102
- return {};
103
- }
104
-
105
- // Type form: { type: "string" } → { type: "string" }
106
- if (isJTDType(schema)) {
107
- const jsonType = primitiveMap[schema.type as JTDPrimitive];
108
- if (!jsonType) {
109
- return { type: schema.type };
110
- }
111
- return { type: jsonType };
112
- }
113
-
114
- // Enum form: { enum: ["a", "b"] } → { enum: ["a", "b"] }
115
- if (isJTDEnum(schema)) {
116
- return { enum: schema.enum };
117
- }
118
-
119
- // Elements form: { elements: { type: "string" } } → { type: "array", items: ... }
120
- if (isJTDElements(schema)) {
121
- return {
122
- type: "array",
123
- items: convertSchema(schema.elements),
124
- };
125
- }
126
-
127
- // Values form: { values: { type: "string" } } → { type: "object", additionalProperties: ... }
128
- if (isJTDValues(schema)) {
129
- return {
130
- type: "object",
131
- additionalProperties: convertSchema(schema.values),
132
- };
133
- }
134
-
135
- // Properties form: { properties: {...}, optionalProperties: {...} }
136
- if (isJTDProperties(schema)) {
137
- const properties: Record<string, unknown> = {};
138
- const required: string[] = [];
139
-
140
- // Required properties
141
- if (schema.properties) {
142
- for (const [key, value] of Object.entries(schema.properties)) {
143
- properties[key] = convertSchema(value);
144
- required.push(key);
145
- }
146
- }
147
-
148
- // Optional properties
149
- if (schema.optionalProperties) {
150
- for (const [key, value] of Object.entries(schema.optionalProperties)) {
151
- properties[key] = convertSchema(value);
152
- }
153
- }
154
-
155
- const result: Record<string, unknown> = {
156
- type: "object",
157
- properties,
158
- additionalProperties: false,
159
- };
160
-
161
- if (required.length > 0) {
162
- result.required = required;
163
- }
164
-
165
- return result;
166
- }
167
-
168
- // Discriminator form: { discriminator: "type", mapping: { ... } }
169
- if (isJTDDiscriminator(schema)) {
170
- const oneOf: unknown[] = [];
171
-
172
- for (const [tag, props] of Object.entries(schema.mapping)) {
173
- const converted = convertSchema(props) as Record<string, unknown>;
174
- // Add the discriminator property
175
- const properties = (converted.properties || {}) as Record<string, unknown>;
176
- properties[schema.discriminator] = { const: tag };
177
-
178
- const required = ((converted.required as string[]) || []).slice();
179
- if (!required.includes(schema.discriminator)) {
180
- required.push(schema.discriminator);
181
- }
182
-
183
- oneOf.push({
184
- ...converted,
185
- properties,
186
- required,
187
- });
188
- }
189
-
190
- return { oneOf };
191
- }
192
-
193
- // Ref form: { ref: "MyType" } → { $ref: "#/$defs/MyType" }
194
- if (isJTDRef(schema)) {
195
- return { $ref: `#/$defs/${schema.ref}` };
196
- }
197
-
198
- // Empty form: {} → {} (accepts anything)
199
- return {};
200
- }
201
-
202
- /**
203
- * Detect if a schema is JTD format (vs JSON Schema).
204
- *
205
- * JTD schemas use: type (primitives), properties, optionalProperties, elements, values, enum, discriminator, ref
206
- * JSON Schema uses: type: "object", type: "array", items, additionalProperties, etc.
207
- */
208
- export function isJTDSchema(schema: unknown): boolean {
209
- if (schema === null || typeof schema !== "object") {
210
- return false;
211
- }
212
-
213
- const obj = schema as Record<string, unknown>;
214
-
215
- // JTD-specific keywords
216
- if ("elements" in obj) return true;
217
- if ("values" in obj) return true;
218
- if ("optionalProperties" in obj) return true;
219
- if ("discriminator" in obj) return true;
220
- if ("ref" in obj) return true;
221
-
222
- // JTD type primitives (JSON Schema doesn't have int32, float64, etc.)
223
- if ("type" in obj) {
224
- const jtdPrimitives = ["timestamp", "float32", "float64", "int8", "uint8", "int16", "uint16", "int32", "uint32"];
225
- if (jtdPrimitives.includes(obj.type as string)) {
226
- return true;
227
- }
228
- }
229
-
230
- // JTD properties form without type: "object" (JSON Schema requires it)
231
- if ("properties" in obj && !("type" in obj)) {
232
- return true;
233
- }
234
-
235
- return false;
236
- }
237
-
238
- /**
239
- * Convert JTD schema to JSON Schema.
240
- * If already JSON Schema, returns as-is.
241
- */
242
- export function jtdToJsonSchema(schema: unknown): unknown {
243
- if (!isJTDSchema(schema)) {
244
- return schema;
245
- }
246
- return convertSchema(schema);
247
- }