@gakim-digital/dexter-bridge 0.5.6 → 0.5.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "Local Companion bridge for the Dexter Framer plugin — runs Codex or Claude Code on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -13,8 +13,10 @@ export const BRIDGE_BUILD_FINGERPRINT = crypto
13
13
  '../package.json',
14
14
  './agent.js',
15
15
  './agentOutput.js',
16
+ './api.js',
16
17
  './cli.js',
17
18
  './config.js',
19
+ './protocol.js',
18
20
  ].map((relativePath) => {
19
21
  const url = new URL(relativePath, import.meta.url);
20
22
  return `${relativePath}\u0000${fs.readFileSync(url, 'utf8')}`;
@@ -23,6 +25,7 @@ export const BRIDGE_BUILD_FINGERPRINT = crypto
23
25
  export const BRIDGE_CAPABILITIES = [
24
26
  'model-turn-v1',
25
27
  'build-fingerprint-v1',
28
+ 'tool-schema-parity-v1',
26
29
  ];
27
30
  // Codex is the default local agent: driving Claude Code from a user's Claude.ai
28
31
  // subscription needs prior written approval from Anthropic for commercial use, so
package/src/protocol.js CHANGED
@@ -1,27 +1,73 @@
1
+ import { createHash } from 'node:crypto';
2
+
1
3
  function isRecord(value) {
2
4
  return value && typeof value === 'object' && !Array.isArray(value);
3
5
  }
4
6
 
5
- function shortText(value, max = 360) {
6
- const text = typeof value === 'string' ? value : JSON.stringify(value ?? '');
7
- return text.length > max ? `${text.slice(0, max)}...[truncated ${text.length - max} chars]` : text;
7
+ function toolCatalogDigest(tools) {
8
+ return createHash('sha256')
9
+ .update(JSON.stringify(tools))
10
+ .digest('hex')
11
+ .slice(0, 24);
8
12
  }
9
13
 
10
- function compactSchema(schema, depth = 0) {
11
- if (!isRecord(schema) || depth > 4) return schema;
12
- const result = {};
13
- for (const key of ['type', 'enum', 'required', 'additionalProperties']) {
14
- if (schema[key] !== undefined) result[key] = schema[key];
14
+ export function assertToolCatalogParity(modelTurn = {}) {
15
+ const tools = Array.isArray(modelTurn.tools) ? modelTurn.tools : [];
16
+ const expected = modelTurn?.session?.toolCatalogHash;
17
+ if (!expected) return toolCatalogDigest(tools);
18
+ const actual = toolCatalogDigest(tools);
19
+ if (actual !== expected) {
20
+ throw Object.assign(
21
+ new Error(
22
+ `Dexter tool schema parity failed: expected ${expected}, received ${actual}.`,
23
+ ),
24
+ {
25
+ code: 'TOOL_SCHEMA_PARITY_ERROR',
26
+ expectedToolCatalogHash: expected,
27
+ actualToolCatalogHash: actual,
28
+ },
29
+ );
15
30
  }
16
- if (isRecord(schema.properties)) {
17
- result.properties = Object.fromEntries(
18
- Object.entries(schema.properties)
19
- .slice(0, 40)
20
- .map(([key, value]) => [key, compactSchema(value, depth + 1)]),
31
+ return actual;
32
+ }
33
+
34
+ function toolParameters(tool) {
35
+ return isRecord(tool?.parameters)
36
+ ? tool.parameters
37
+ : {
38
+ type: 'object',
39
+ properties: {},
40
+ additionalProperties: false,
41
+ };
42
+ }
43
+
44
+ function nestedToolParameters(schema, namespace, outputDefinitions) {
45
+ const source = JSON.parse(JSON.stringify(schema));
46
+ delete source.$schema;
47
+ const localDefinitions = {
48
+ ...(isRecord(source.definitions) ? source.definitions : {}),
49
+ ...(isRecord(source.$defs) ? source.$defs : {}),
50
+ };
51
+ delete source.definitions;
52
+ delete source.$defs;
53
+ const prefix = `${namespace}__`;
54
+ const rewrite = (value) => {
55
+ if (Array.isArray(value)) return value.map(rewrite);
56
+ if (!isRecord(value)) return value;
57
+ return Object.fromEntries(
58
+ Object.entries(value).map(([key, nested]) => {
59
+ if (key === '$ref' && typeof nested === 'string') {
60
+ const match = nested.match(/^#\/(?:definitions|\$defs)\/(.+)$/);
61
+ if (match) return [key, `#/$defs/${prefix}${match[1]}`];
62
+ }
63
+ return [key, rewrite(nested)];
64
+ }),
21
65
  );
66
+ };
67
+ for (const [name, definition] of Object.entries(localDefinitions)) {
68
+ outputDefinitions[`${prefix}${name}`] = rewrite(definition);
22
69
  }
23
- if (schema.items !== undefined) result.items = compactSchema(schema.items, depth + 1);
24
- return result;
70
+ return rewrite(source);
25
71
  }
26
72
 
27
73
  function compactModelTurnContent(content) {
@@ -65,8 +111,8 @@ export function compactToolCatalog(tools = []) {
65
111
  .slice(0, 40)
66
112
  .map((tool) => ({
67
113
  name: typeof tool.name === 'string' ? tool.name : '',
68
- description: shortText(tool.description || '', 360),
69
- parameters: compactSchema(tool.parameters),
114
+ description: typeof tool.description === 'string' ? tool.description : '',
115
+ parameters: toolParameters(tool),
70
116
  }))
71
117
  .filter((tool) => tool.name);
72
118
  }
@@ -107,8 +153,8 @@ function modelTurnInstructions(modelTurn = {}) {
107
153
  'You are the model engine for Dexter. The server owns the agent loop and executes all tools.',
108
154
  'Return exactly one JSON object and no markdown.',
109
155
  'Allowed response:',
110
- '{"text":"optional assistant text","toolCalls":[{"id":"stable-id","name":"toolName","arguments":"{\\"key\\":\\"value\\"}"}],"finishReason":"tool_calls|stop|length"}',
111
- 'Each toolCalls[].arguments value must be a JSON-encoded string whose decoded value is an object.',
156
+ '{"text":"optional assistant text","toolCalls":[{"id":"stable-id","name":"toolName","arguments":{"key":"value"}}],"finishReason":"tool_calls|stop|length"}',
157
+ 'Each toolCalls[].arguments value must be an object matching the exact selected tool schema.',
112
158
  'Use only tools listed below. Do not claim a tool executed; only request it.',
113
159
  toolChoice.required
114
160
  ? toolChoice.toolName
@@ -119,6 +165,7 @@ function modelTurnInstructions(modelTurn = {}) {
119
165
  }
120
166
 
121
167
  export function buildModelTurnPrompt(modelTurn = {}) {
168
+ assertToolCatalogParity(modelTurn);
122
169
  const messages = compactMessages(modelTurn.messages, 64);
123
170
  const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
124
171
  return [
@@ -131,6 +178,7 @@ export function buildModelTurnPrompt(modelTurn = {}) {
131
178
  }
132
179
 
133
180
  export function buildModelTurnDeltaPrompt(modelTurn = {}) {
181
+ assertToolCatalogParity(modelTurn);
134
182
  const messages = compactMessages(modelTurn.messages, 24);
135
183
  const includeTools = modelTurn?.session?.toolCatalogChanged === true;
136
184
  const tools = includeTools
@@ -167,37 +215,56 @@ export function buildModelTurnFallbackPrompt(modelTurn = {}) {
167
215
  }
168
216
 
169
217
  export function modelTurnOutputSchema(modelTurn = {}) {
218
+ assertToolCatalogParity(modelTurn);
170
219
  const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
171
220
  const toolChoice = normalizedToolChoice(modelTurn);
172
- const availableToolNames = tools.map((tool) => tool.name);
173
- const toolNames =
174
- toolChoice.toolName && availableToolNames.includes(toolChoice.toolName)
175
- ? [toolChoice.toolName]
176
- : availableToolNames;
177
- return {
221
+ const selectedTools =
222
+ toolChoice.toolName
223
+ ? tools.filter((tool) => tool.name === toolChoice.toolName)
224
+ : tools;
225
+ const definitions = {};
226
+ const toolCallVariants = selectedTools.map((tool, index) => ({
227
+ type: 'object',
228
+ properties: {
229
+ id: { type: 'string' },
230
+ name: { type: 'string', enum: [tool.name] },
231
+ arguments: nestedToolParameters(
232
+ tool.parameters,
233
+ `tool_${index}_${tool.name.replace(/[^A-Za-z0-9_]/g, '_')}`,
234
+ definitions,
235
+ ),
236
+ },
237
+ required: ['id', 'name', 'arguments'],
238
+ additionalProperties: false,
239
+ }));
240
+ const emptyToolCall = {
241
+ type: 'object',
242
+ properties: {
243
+ id: { type: 'string' },
244
+ name: { type: 'string' },
245
+ arguments: {
246
+ type: 'object',
247
+ properties: {},
248
+ additionalProperties: false,
249
+ },
250
+ },
251
+ required: ['id', 'name', 'arguments'],
252
+ additionalProperties: false,
253
+ };
254
+ const output = {
178
255
  type: 'object',
179
256
  properties: {
180
257
  text: { type: 'string' },
181
258
  toolCalls: {
182
259
  type: 'array',
183
260
  ...(toolChoice.required ? { minItems: 1 } : {}),
184
- maxItems: toolNames.length ? 12 : 0,
185
- items: {
186
- type: 'object',
187
- properties: {
188
- id: { type: 'string' },
189
- name: toolNames.length
190
- ? { type: 'string', enum: toolNames }
191
- : { type: 'string' },
192
- // Structured Outputs requires every object schema to declare
193
- // additionalProperties:false. Tool argument shapes differ per
194
- // selected tool, so encode them at this boundary and validate the
195
- // decoded object before returning the completion to Dexter.
196
- arguments: { type: 'string' },
197
- },
198
- required: ['id', 'name', 'arguments'],
199
- additionalProperties: false,
200
- },
261
+ maxItems: selectedTools.length ? 12 : 0,
262
+ items:
263
+ toolCallVariants.length === 1
264
+ ? toolCallVariants[0]
265
+ : toolCallVariants.length > 1
266
+ ? { anyOf: toolCallVariants }
267
+ : emptyToolCall,
201
268
  },
202
269
  finishReason: {
203
270
  type: 'string',
@@ -209,6 +276,8 @@ export function modelTurnOutputSchema(modelTurn = {}) {
209
276
  required: ['text', 'toolCalls', 'finishReason'],
210
277
  additionalProperties: false,
211
278
  };
279
+ if (Object.keys(definitions).length) output.$defs = definitions;
280
+ return output;
212
281
  }
213
282
 
214
283
  export function normalizeModelTurnCompletion(raw, fallbackModel = 'local-companion') {
@@ -221,16 +290,9 @@ export function normalizeModelTurnCompletion(raw, fallbackModel = 'local-compani
221
290
  if (!isRecord(call) || typeof call.name !== 'string' || !call.name.trim()) {
222
291
  throw new Error(`Model turn tool call ${index + 1} is missing a name.`);
223
292
  }
224
- let toolArguments = call.arguments;
225
- if (typeof toolArguments === 'string') {
226
- try {
227
- toolArguments = JSON.parse(toolArguments);
228
- } catch {
229
- throw new Error(`Model turn tool call ${index + 1} arguments must contain valid JSON.`);
230
- }
231
- }
293
+ const toolArguments = call.arguments;
232
294
  if (toolArguments !== undefined && !isRecord(toolArguments)) {
233
- throw new Error(`Model turn tool call ${index + 1} arguments must decode to an object.`);
295
+ throw new Error(`Model turn tool call ${index + 1} arguments must be an object.`);
234
296
  }
235
297
  return {
236
298
  id: typeof call.id === 'string' && call.id.trim() ? call.id.slice(0, 160) : `local_tool_${index + 1}`,