gdharness 0.9.0 → 0.9.1

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/build/cli.js CHANGED
@@ -15066,7 +15066,7 @@ function buildToolDefinitions() {
15066
15066
  };
15067
15067
  }
15068
15068
  for (const [name, schema] of Object.entries(spec.parameters)) {
15069
- const { ops: _ops, ...carried } = schema;
15069
+ const { ops: _ops, blank: _blank, ...carried } = schema;
15070
15070
  properties[name] = carried;
15071
15071
  }
15072
15072
  const required = [...spec.requires];
@@ -15258,7 +15258,8 @@ var init_tool_definitions = __esm(() => {
15258
15258
  projectPath: PROJECT_PATH,
15259
15259
  setting: { type: "string", description: 'Setting path, such as "display/window/size/viewport_width".' },
15260
15260
  value: {
15261
- description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}.'
15261
+ blank: true,
15262
+ description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}. "" writes an empty string.'
15262
15263
  },
15263
15264
  name: { type: "string", description: "Autoload name." },
15264
15265
  path: { type: "string", description: "Autoload script or scene inside the project." },
@@ -15763,7 +15764,7 @@ var init_tool_definitions = __esm(() => {
15763
15764
  property: {
15764
15765
  type: "string",
15765
15766
  ops: ["property", "find"],
15766
- description: "property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label."
15767
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through the objects a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about; a step that is not there is named.'
15767
15768
  },
15768
15769
  depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
15769
15770
  includeProperties: {
@@ -15780,7 +15781,7 @@ var init_tool_definitions = __esm(() => {
15780
15781
  namePattern: {
15781
15782
  type: "string",
15782
15783
  ops: ["find"],
15783
- description: 'find: a case-insensitive glob on the node name, such as "Enemy*".'
15784
+ description: 'find: a case-insensitive glob on the node name, such as "Enemy*". Matched against the whole name, so a bare word finds only a node called exactly that; an answer of none says how many names contain it and what glob would have found them.'
15784
15785
  },
15785
15786
  group: { type: "string", ops: ["find"], description: "find: a group the node is in." },
15786
15787
  says: {
@@ -15821,7 +15822,7 @@ var init_tool_definitions = __esm(() => {
15821
15822
  requires: ["nodePath"]
15822
15823
  },
15823
15824
  property: {
15824
- summary: "what one property reads on a node, refusing a property the node does not have",
15825
+ summary: "what one property reads on a node, or through the objects it holds, refusing a name nothing along the way has",
15825
15826
  requires: ["nodePath", "property"]
15826
15827
  },
15827
15828
  metrics: { summary: "frame time, memory, draw calls and the rest", requires: [] }
@@ -15834,8 +15835,16 @@ var init_tool_definitions = __esm(() => {
15834
15835
  parameters: {
15835
15836
  projectPath: RUNNING_PROJECT_PATH,
15836
15837
  nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
15837
- property: { type: "string", ops: ["set"] },
15838
- value: { ops: ["set"], description: "set: the value, fitted to the property's type." },
15838
+ property: {
15839
+ type: "string",
15840
+ ops: ["set"],
15841
+ description: 'set: which one to write. Colons write through the objects a node holds, "_game:run:day", and the answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
15842
+ },
15843
+ value: {
15844
+ ops: ["set"],
15845
+ blank: true,
15846
+ description: `set: the value, fitted to the property's type. "" writes an empty string.`
15847
+ },
15839
15848
  method: { type: "string", ops: ["call"] },
15840
15849
  args: {
15841
15850
  type: "array",
@@ -15845,7 +15854,10 @@ var init_tool_definitions = __esm(() => {
15845
15854
  },
15846
15855
  requires: ["nodePath"],
15847
15856
  operations: {
15848
- set: { summary: "set a property", requires: ["property", "value"] },
15857
+ set: {
15858
+ summary: "set a property, on a node or on an object it holds",
15859
+ requires: ["property", "value"]
15860
+ },
15849
15861
  call: { summary: "call a method and return its result", requires: ["method"] }
15850
15862
  }
15851
15863
  },
@@ -15898,7 +15910,8 @@ var init_tool_definitions = __esm(() => {
15898
15910
  text: {
15899
15911
  type: "string",
15900
15912
  ops: ["text", "choose"],
15901
- description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
15913
+ blank: true,
15914
+ description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says.'
15902
15915
  },
15903
15916
  replace: {
15904
15917
  type: "boolean",
@@ -36360,7 +36373,11 @@ class GodotServer {
36360
36373
  const required = [...spec.requires, ...op !== null ? spec.operations?.[op]?.requires ?? [] : []];
36361
36374
  const missing = required.filter((field) => {
36362
36375
  const value = Object.hasOwn(args, field) ? args[field] : undefined;
36363
- return value === undefined || value === null || typeof value === "string" && value.trim() === "";
36376
+ if (value === undefined || value === null) {
36377
+ return true;
36378
+ }
36379
+ const blank = typeof value === "string" && value.trim() === "";
36380
+ return blank && spec.parameters[field]?.blank !== true;
36364
36381
  });
36365
36382
  if (missing.length > 0) {
36366
36383
  const where = op !== null ? `${spec.name} ${op}` : spec.name;
@@ -38926,6 +38943,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
38926
38943
  | Find a control by the word on it | \`runtime_inspect find\` with \`says\`, rather than listing a screen and reading each one |
38927
38944
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
38928
38945
  | What a property reads right now | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
38946
+ | What a property on an object a node holds reads | the same two, with colons: \`_game:clock:speed\` |
38929
38947
  | Press a button | \`runtime_input click\`, which says what was under the pointer |
38930
38948
  | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
38931
38949
  | Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
@@ -162,8 +162,9 @@ func inject_key(params: Dictionary) -> Dictionary:
162
162
  ## back now rather than echoed from the request, so a call that typed somewhere unhelpful says so.
163
163
  func inject_text(params: Dictionary) -> Dictionary:
164
164
  var text: String = String(params.get("text", ""))
165
- if text.is_empty():
166
- return {"type": "error", "message": "text required"}
165
+ var over: bool = bool(params.get("replace", false))
166
+ if text.is_empty() and not over:
167
+ return {"type": "error", "message": "text needs something to type, or replace to empty a field"}
167
168
 
168
169
  var viewport: Viewport = _host.get_tree().root
169
170
  var focused: Control = viewport.gui_get_focus_owner()
@@ -171,14 +172,13 @@ func inject_text(params: Dictionary) -> Dictionary:
171
172
  if not shut.is_empty():
172
173
  return {"type": "error", "message": shut}
173
174
 
174
- var replaced: bool = bool(params.get("replace", false)) and _select_everything(focused)
175
+ var replaced: bool = over and _select_everything(focused)
176
+ # Emptying a field is a real thing to ask for and the one shape of filling one in that types no
177
+ # characters. The selection is standing, so the key a player presses over one is what clears it.
178
+ if replaced and text.is_empty():
179
+ _press(viewport, _held_down(KEY_DELETE))
175
180
  for index: int in text.length():
176
- var down: InputEventKey = _typed(text.unicode_at(index))
177
- viewport.push_input(down)
178
- # The release as well, so nothing is left held down behind the caller.
179
- var up: InputEventKey = down.duplicate()
180
- up.pressed = false
181
- viewport.push_input(up)
181
+ _press(viewport, _typed(text.unicode_at(index)))
182
182
 
183
183
  # Where it went, which is the one thing a caller cannot see from here. Null is a game reading
184
184
  # keys for itself with nothing focused, which is a real thing to be typing at.
@@ -253,6 +253,24 @@ static func _shut_to_typing(focused: Control) -> String:
253
253
 
254
254
 
255
255
  ## The key press that produces [param glyph], as a keyboard would send it.
256
+ ## A whole press: down, then the release, so nothing is left held down behind the caller.
257
+ static func _press(viewport: Viewport, down: InputEventKey) -> void:
258
+ viewport.push_input(down)
259
+ var up: InputEventKey = down.duplicate()
260
+ up.pressed = false
261
+ viewport.push_input(up)
262
+
263
+
264
+ ## A key held down by its keycode, for the ones that stand for an edit rather than a character.
265
+ static func _held_down(code: Key) -> InputEventKey:
266
+ var event: InputEventKey = InputEventKey.new()
267
+ event.pressed = true
268
+ event.keycode = code
269
+ event.physical_keycode = code
270
+ event.key_label = code
271
+ return event
272
+
273
+
256
274
  func _typed(glyph: int) -> InputEventKey:
257
275
  var event: InputEventKey = InputEventKey.new()
258
276
  event.pressed = true
@@ -71,13 +71,20 @@ func find_nodes(params: Dictionary) -> Dictionary:
71
71
  var found: Array[Dictionary] = []
72
72
  var pending: Array[Node] = [root]
73
73
  var truncated: bool = false
74
+ # Counted while the tree is already being walked, for the answer below: how many nodes this
75
+ # find would have matched if the name had been read the way it was probably meant.
76
+ var literal: bool = _is_literal(wanted["name"])
77
+ var nearly: int = 0
74
78
  while not pending.is_empty():
75
79
  var node: Node = pending.pop_front()
76
- if _matches(node, wanted):
80
+ var rest: bool = _matches_apart_from_name(node, wanted)
81
+ if rest and _named(node, wanted["name"]):
77
82
  if found.size() >= limit:
78
83
  truncated = true
79
84
  break
80
85
  found.append(_found(node, wanted_property))
86
+ elif rest and literal and str(node.name).containsn(wanted["name"]):
87
+ nearly += 1
81
88
  # Internal children included, which they were not. A ConfirmationDialog builds its Yes and
82
89
  # its No as internal nodes, and a ScrollContainer its bars, so a find over a screen for
83
90
  # every Button came back without the two buttons the player is being asked to press:
@@ -88,7 +95,17 @@ func find_nodes(params: Dictionary) -> Dictionary:
88
95
  for index: int in range(children.size() - 1, -1, -1):
89
96
  pending.push_front(children[index])
90
97
 
91
- return {"type": "nodes", "count": found.size(), "truncated": truncated, "nodes": found}
98
+ var answer: Dictionary = {"type": "nodes", "count": found.size(), "truncated": truncated, "nodes": found}
99
+ # Nothing found is the one answer that cannot be told apart from having asked the wrong
100
+ # question, and a name written without a wildcard is the way an agent writes "contains".
101
+ # Said only when it changes the answer, so a genuine nothing stays a plain nothing.
102
+ if found.is_empty() and nearly > 0:
103
+ var holding: String = "names contain" if nearly > 1 else "name contains"
104
+ answer["note"] = (
105
+ 'name is matched as a glob against the whole name; %d node %s "%s", which "*%s*" would find'
106
+ % [nearly, holding, wanted["name"], wanted["name"]]
107
+ )
108
+ return answer
92
109
 
93
110
 
94
111
  ## One match, with the named property on it when one was named.
@@ -102,15 +119,20 @@ func _found(node: Node, wanted_property: String) -> Dictionary:
102
119
  var entry: Dictionary = _serialize_node(node, false)
103
120
  if wanted_property.is_empty():
104
121
  return entry
105
- var has: bool = false
106
- for prop: Dictionary in node.get_property_list():
107
- if str(prop["name"]) == wanted_property:
108
- has = true
109
- break
110
122
  entry["property"] = wanted_property
123
+ # Through a path as well, for the reason [method _reached] gives. A step that is not there on
124
+ # this node reads as not having the property rather than as a refusal, which is the rule this
125
+ # whole function is written to: a find matches nodes of several classes on purpose.
126
+ var reached: Dictionary = _reached(node, str(entry["path"]), wanted_property)
127
+ if reached.has("message"):
128
+ entry["has_property"] = false
129
+ return entry
130
+ var holder: Object = reached["holder"]
131
+ var named: String = reached["property"]
132
+ var has: bool = _has_property(holder, named)
111
133
  entry["has_property"] = has
112
134
  if has:
113
- entry["value"] = _values.serialize(node.get(wanted_property))
135
+ entry["value"] = _values.serialize(holder.get(named))
114
136
  return entry
115
137
 
116
138
 
@@ -123,11 +145,24 @@ static func _anything_asked(wanted: Dictionary[String, String]) -> bool:
123
145
  return false
124
146
 
125
147
 
126
- func _matches(node: Node, wanted: Dictionary[String, String]) -> bool:
148
+ ## Whether [param node] carries the name a find asked for. Empty matches everything, which is what
149
+ ## makes leaving a filter out the same as not having one.
150
+ static func _named(node: Node, pattern: String) -> bool:
151
+ return pattern.is_empty() or str(node.name).matchn(pattern)
152
+
153
+
154
+ ## Whether [param pattern] is a name written as a whole word rather than as a glob, which is how a
155
+ ## caller writes one when they mean "contains". [method String.matchn] answers nothing to it, and
156
+ ## nothing is also what a name that is simply not there answers.
157
+ static func _is_literal(pattern: String) -> bool:
158
+ return not pattern.is_empty() and not pattern.contains("*") and not pattern.contains("?")
159
+
160
+
161
+ ## Every filter but the name, so a find that came back empty can say how many nodes the name was
162
+ ## the only thing standing between it and.
163
+ func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) -> bool:
127
164
  if not wanted["group"].is_empty() and not node.is_in_group(wanted["group"]):
128
165
  return false
129
- if not wanted["name"].is_empty() and not str(node.name).matchn(wanted["name"]):
130
- return false
131
166
  # What this node says, rather than everything said underneath it. A row is then found by the
132
167
  # label in it, and the path answered is that label's, which is where the words a caller is
133
168
  # looking at actually are: matching every container above it would answer with the screen.
@@ -395,24 +430,63 @@ func get_property(params: Dictionary) -> Dictionary:
395
430
  if node == null:
396
431
  return {"type": "error", "message": "Node not found: " + node_path}
397
432
 
433
+ var reached: Dictionary = _reached(node, node_path, property)
434
+ if reached.has("message"):
435
+ return reached
436
+
437
+ var holder: Object = reached["holder"]
438
+ var named: String = reached["property"]
398
439
  # Asked of the property list rather than read and compared to null, because a property the
399
- # node does not have and a property that is null both read as null.
400
- var known: bool = false
401
- for entry: Dictionary in node.get_property_list():
402
- if str(entry["name"]) == property:
403
- known = true
404
- break
405
- if not known:
406
- return {"type": "error", "message": "%s has no property %s" % [node_path, property]}
440
+ # holder does not have and a property that is null both read as null.
441
+ if not _has_property(holder, named):
442
+ return {"type": "error", "message": "%s has no property %s" % [reached["called"], named]}
407
443
 
408
444
  return {
409
445
  "type": "property",
410
446
  "path": node_path,
411
447
  "property": property,
412
- "value": _values.serialize(node.get(property)),
448
+ "value": _values.serialize(holder.get(named)),
413
449
  }
414
450
 
415
451
 
452
+ ## What [param property] is a property of, which is the node itself until the name has a colon in
453
+ ## it, and the last name along that path.
454
+ ##
455
+ ## A game's state does not sit on nodes, it hangs off them: the speed of the clock is a property of
456
+ ## a [RefCounted] held by a [RefCounted] held by the root, and that is what an agent asks about.
457
+ ## Colons, because that is the separator [method Object.get_indexed] already takes. Walked a step
458
+ ## at a time rather than handed to that method, which answers null for a path that goes wrong
459
+ ## halfway along and for one that ends on null.
460
+ ##
461
+ ## Answers with a `message` instead when a step along the way is not there or holds something that
462
+ ## is not an object, naming the step rather than the whole path: "/root/Main:_game has no property
463
+ ## clocks" is a typo found, and "no property _game:clocks:speed" is a puzzle.
464
+ func _reached(node: Node, node_path: String, property: String) -> Dictionary:
465
+ var parts: PackedStringArray = property.split(":")
466
+ var holder: Object = node
467
+ var called: String = node_path
468
+ for step: int in parts.size() - 1:
469
+ var named: String = parts[step]
470
+ if not _has_property(holder, named):
471
+ return {"type": "error", "message": "%s has no property %s" % [called, named]}
472
+ var next: Variant = holder.get(named)
473
+ called = "%s:%s" % [called, named]
474
+ if not next is Object:
475
+ var wanted: String = parts[step + 1]
476
+ return {"type": "error", "message": "%s holds no object to read %s off" % [called, wanted]}
477
+ holder = next
478
+ return {"holder": holder, "property": parts[parts.size() - 1], "called": called}
479
+
480
+
481
+ ## Whether [param holder] declares [param named]. Its own function because three ops ask it and a
482
+ ## property read back as null answers it wrongly.
483
+ static func _has_property(holder: Object, named: String) -> bool:
484
+ for entry: Dictionary in holder.get_property_list():
485
+ if str(entry["name"]) == named:
486
+ return true
487
+ return false
488
+
489
+
416
490
  func set_property(params: Dictionary) -> Dictionary:
417
491
  var node_path: String = str(params.get("path", ""))
418
492
  var property: String = str(params.get("property", ""))
@@ -425,15 +499,24 @@ func set_property(params: Dictionary) -> Dictionary:
425
499
  if node == null:
426
500
  return {"type": "error", "message": "Node not found: " + node_path}
427
501
 
428
- var old_value: Variant = node.get(property)
429
- node.set(property, _values.fitted(value, typeof(old_value)))
502
+ # Through a path as well, for the reason [method _reached] gives, and read back off the same
503
+ # holder afterwards: a set that does not take says so by answering with the old value, which is
504
+ # how a typed container refusing a write is told from one accepting it.
505
+ var reached: Dictionary = _reached(node, node_path, property)
506
+ if reached.has("message"):
507
+ return reached
508
+
509
+ var holder: Object = reached["holder"]
510
+ var named: String = reached["property"]
511
+ var old_value: Variant = holder.get(named)
512
+ holder.set(named, _values.fitted(value, typeof(old_value)))
430
513
 
431
514
  return {
432
515
  "type": "property_set",
433
516
  "path": node_path,
434
517
  "property": property,
435
518
  "old_value": _values.serialize(old_value),
436
- "new_value": _values.serialize(node.get(property))
519
+ "new_value": _values.serialize(holder.get(named))
437
520
  }
438
521
 
439
522
 
package/build/index.js CHANGED
@@ -27811,7 +27811,8 @@ var TOOL_SPECS = [
27811
27811
  projectPath: PROJECT_PATH,
27812
27812
  setting: { type: "string", description: 'Setting path, such as "display/window/size/viewport_width".' },
27813
27813
  value: {
27814
- description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}.'
27814
+ blank: true,
27815
+ description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}. "" writes an empty string.'
27815
27816
  },
27816
27817
  name: { type: "string", description: "Autoload name." },
27817
27818
  path: { type: "string", description: "Autoload script or scene inside the project." },
@@ -28316,7 +28317,7 @@ var TOOL_SPECS = [
28316
28317
  property: {
28317
28318
  type: "string",
28318
28319
  ops: ["property", "find"],
28319
- description: "property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label."
28320
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through the objects a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about; a step that is not there is named.'
28320
28321
  },
28321
28322
  depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
28322
28323
  includeProperties: {
@@ -28333,7 +28334,7 @@ var TOOL_SPECS = [
28333
28334
  namePattern: {
28334
28335
  type: "string",
28335
28336
  ops: ["find"],
28336
- description: 'find: a case-insensitive glob on the node name, such as "Enemy*".'
28337
+ description: 'find: a case-insensitive glob on the node name, such as "Enemy*". Matched against the whole name, so a bare word finds only a node called exactly that; an answer of none says how many names contain it and what glob would have found them.'
28337
28338
  },
28338
28339
  group: { type: "string", ops: ["find"], description: "find: a group the node is in." },
28339
28340
  says: {
@@ -28374,7 +28375,7 @@ var TOOL_SPECS = [
28374
28375
  requires: ["nodePath"]
28375
28376
  },
28376
28377
  property: {
28377
- summary: "what one property reads on a node, refusing a property the node does not have",
28378
+ summary: "what one property reads on a node, or through the objects it holds, refusing a name nothing along the way has",
28378
28379
  requires: ["nodePath", "property"]
28379
28380
  },
28380
28381
  metrics: { summary: "frame time, memory, draw calls and the rest", requires: [] }
@@ -28387,8 +28388,16 @@ var TOOL_SPECS = [
28387
28388
  parameters: {
28388
28389
  projectPath: RUNNING_PROJECT_PATH,
28389
28390
  nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
28390
- property: { type: "string", ops: ["set"] },
28391
- value: { ops: ["set"], description: "set: the value, fitted to the property's type." },
28391
+ property: {
28392
+ type: "string",
28393
+ ops: ["set"],
28394
+ description: 'set: which one to write. Colons write through the objects a node holds, "_game:run:day", and the answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
28395
+ },
28396
+ value: {
28397
+ ops: ["set"],
28398
+ blank: true,
28399
+ description: `set: the value, fitted to the property's type. "" writes an empty string.`
28400
+ },
28392
28401
  method: { type: "string", ops: ["call"] },
28393
28402
  args: {
28394
28403
  type: "array",
@@ -28398,7 +28407,10 @@ var TOOL_SPECS = [
28398
28407
  },
28399
28408
  requires: ["nodePath"],
28400
28409
  operations: {
28401
- set: { summary: "set a property", requires: ["property", "value"] },
28410
+ set: {
28411
+ summary: "set a property, on a node or on an object it holds",
28412
+ requires: ["property", "value"]
28413
+ },
28402
28414
  call: { summary: "call a method and return its result", requires: ["method"] }
28403
28415
  }
28404
28416
  },
@@ -28451,7 +28463,8 @@ var TOOL_SPECS = [
28451
28463
  text: {
28452
28464
  type: "string",
28453
28465
  ops: ["text", "choose"],
28454
- description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
28466
+ blank: true,
28467
+ description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says.'
28455
28468
  },
28456
28469
  replace: {
28457
28470
  type: "boolean",
@@ -28625,7 +28638,7 @@ function buildToolDefinitions() {
28625
28638
  };
28626
28639
  }
28627
28640
  for (const [name, schema] of Object.entries(spec.parameters)) {
28628
- const { ops: _ops, ...carried } = schema;
28641
+ const { ops: _ops, blank: _blank, ...carried } = schema;
28629
28642
  properties[name] = carried;
28630
28643
  }
28631
28644
  const required = [...spec.requires];
@@ -29141,7 +29154,11 @@ class GodotServer {
29141
29154
  const required = [...spec.requires, ...op !== null ? spec.operations?.[op]?.requires ?? [] : []];
29142
29155
  const missing = required.filter((field) => {
29143
29156
  const value = Object.hasOwn(args, field) ? args[field] : undefined;
29144
- return value === undefined || value === null || typeof value === "string" && value.trim() === "";
29157
+ if (value === undefined || value === null) {
29158
+ return true;
29159
+ }
29160
+ const blank = typeof value === "string" && value.trim() === "";
29161
+ return blank && spec.parameters[field]?.blank !== true;
29145
29162
  });
29146
29163
  if (missing.length > 0) {
29147
29164
  const where = op !== null ? `${spec.name} ${op}` : spec.name;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",