deepclause-sdk 0.1.0 → 0.1.2

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.
Files changed (142) hide show
  1. package/README.md +83 -12
  2. package/dist/agent.d.ts +14 -1
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +117 -9
  5. package/dist/agent.js.map +1 -1
  6. package/dist/cli/config.d.ts +86 -26
  7. package/dist/cli/config.d.ts.map +1 -1
  8. package/dist/cli/config.js +15 -0
  9. package/dist/cli/config.js.map +1 -1
  10. package/dist/cli/index.js +38 -0
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/cli/repl.d.ts +27 -0
  13. package/dist/cli/repl.d.ts.map +1 -1
  14. package/dist/cli/repl.js +191 -5
  15. package/dist/cli/repl.js.map +1 -1
  16. package/dist/cli/run.d.ts.map +1 -1
  17. package/dist/cli/run.js +16 -0
  18. package/dist/cli/run.js.map +1 -1
  19. package/dist/cli/visualize.d.ts +4 -0
  20. package/dist/cli/visualize.d.ts.map +1 -1
  21. package/dist/cli/visualize.js +29 -4
  22. package/dist/cli/visualize.js.map +1 -1
  23. package/dist/prolog/bridge.d.ts +3 -1
  24. package/dist/prolog/bridge.d.ts.map +1 -1
  25. package/dist/prolog/bridge.js +2 -0
  26. package/dist/prolog/bridge.js.map +1 -1
  27. package/dist/prolog/loader.d.ts +32 -0
  28. package/dist/prolog/loader.d.ts.map +1 -1
  29. package/dist/prolog/loader.js +87 -0
  30. package/dist/prolog/loader.js.map +1 -1
  31. package/dist/prolog-src/deepclause_mi.pl +1217 -120
  32. package/dist/repl/agent.d.ts +33 -1
  33. package/dist/repl/agent.d.ts.map +1 -1
  34. package/dist/repl/agent.js +91 -8
  35. package/dist/repl/agent.js.map +1 -1
  36. package/dist/repl/backend.d.ts +31 -0
  37. package/dist/repl/backend.d.ts.map +1 -1
  38. package/dist/repl/backend.js +122 -77
  39. package/dist/repl/backend.js.map +1 -1
  40. package/dist/repl/harness-tools.d.ts +49 -2
  41. package/dist/repl/harness-tools.d.ts.map +1 -1
  42. package/dist/repl/harness-tools.js +87 -7
  43. package/dist/repl/harness-tools.js.map +1 -1
  44. package/dist/repl/imagegen.d.ts +29 -0
  45. package/dist/repl/imagegen.d.ts.map +1 -0
  46. package/dist/repl/imagegen.js +84 -0
  47. package/dist/repl/imagegen.js.map +1 -0
  48. package/dist/repl/index.d.ts +20 -2
  49. package/dist/repl/index.d.ts.map +1 -1
  50. package/dist/repl/index.js +6 -0
  51. package/dist/repl/index.js.map +1 -1
  52. package/dist/repl/model-resolver.d.ts +59 -0
  53. package/dist/repl/model-resolver.d.ts.map +1 -0
  54. package/dist/repl/model-resolver.js +156 -0
  55. package/dist/repl/model-resolver.js.map +1 -0
  56. package/dist/repl/render.d.ts +36 -2
  57. package/dist/repl/render.d.ts.map +1 -1
  58. package/dist/repl/render.js +175 -17
  59. package/dist/repl/render.js.map +1 -1
  60. package/dist/repl/session.d.ts +129 -4
  61. package/dist/repl/session.d.ts.map +1 -1
  62. package/dist/repl/session.js +380 -17
  63. package/dist/repl/session.js.map +1 -1
  64. package/dist/repl/tool-arguments.d.ts +36 -0
  65. package/dist/repl/tool-arguments.d.ts.map +1 -0
  66. package/dist/repl/tool-arguments.js +123 -0
  67. package/dist/repl/tool-arguments.js.map +1 -0
  68. package/dist/repl/toolset.d.ts +28 -1
  69. package/dist/repl/toolset.d.ts.map +1 -1
  70. package/dist/repl/toolset.js +52 -4
  71. package/dist/repl/toolset.js.map +1 -1
  72. package/dist/repl/tui/transcript.d.ts +2 -0
  73. package/dist/repl/tui/transcript.d.ts.map +1 -1
  74. package/dist/repl/tui/transcript.js +16 -0
  75. package/dist/repl/tui/transcript.js.map +1 -1
  76. package/dist/runner.d.ts +44 -2
  77. package/dist/runner.d.ts.map +1 -1
  78. package/dist/runner.js +293 -33
  79. package/dist/runner.js.map +1 -1
  80. package/dist/system/assets/docs/AGENT_PROMPT.md +1000 -44
  81. package/dist/system/assets/docs/DML_AUTHORING.md +96 -17
  82. package/dist/system/assets/docs/DML_REFERENCE.md +652 -39
  83. package/dist/system/assets/docs/PROLOG_SNIPPETS.md +45 -1
  84. package/dist/system/assets/docs/TASK_PROMPT.md +2 -0
  85. package/dist/system/environment.d.ts +36 -0
  86. package/dist/system/environment.d.ts.map +1 -0
  87. package/dist/system/environment.js +81 -0
  88. package/dist/system/environment.js.map +1 -0
  89. package/dist/system/runtime/shell-manager.d.ts +40 -2
  90. package/dist/system/runtime/shell-manager.d.ts.map +1 -1
  91. package/dist/system/runtime/shell-manager.js +82 -16
  92. package/dist/system/runtime/shell-manager.js.map +1 -1
  93. package/dist/system/runtime/shell-tool-events.js +25 -1
  94. package/dist/system/runtime/shell-tool-events.js.map +1 -1
  95. package/dist/types.d.ts +76 -2
  96. package/dist/types.d.ts.map +1 -1
  97. package/dist/visualize/ascii.d.ts +55 -0
  98. package/dist/visualize/ascii.d.ts.map +1 -0
  99. package/dist/visualize/ascii.js +242 -0
  100. package/dist/visualize/ascii.js.map +1 -0
  101. package/dist/visualize/display.d.ts +24 -0
  102. package/dist/visualize/display.d.ts.map +1 -0
  103. package/dist/visualize/display.js +29 -0
  104. package/dist/visualize/display.js.map +1 -0
  105. package/dist/visualize/graphics.d.ts +35 -0
  106. package/dist/visualize/graphics.d.ts.map +1 -0
  107. package/dist/visualize/graphics.js +96 -0
  108. package/dist/visualize/graphics.js.map +1 -0
  109. package/dist/visualize/image.d.ts +38 -0
  110. package/dist/visualize/image.d.ts.map +1 -0
  111. package/dist/visualize/image.js +95 -0
  112. package/dist/visualize/image.js.map +1 -0
  113. package/dist/visualize/index.d.ts +7 -0
  114. package/dist/visualize/index.d.ts.map +1 -1
  115. package/dist/visualize/index.js +9 -0
  116. package/dist/visualize/index.js.map +1 -1
  117. package/dist/visualize/raster.d.ts +23 -0
  118. package/dist/visualize/raster.d.ts.map +1 -0
  119. package/dist/visualize/raster.js +47 -0
  120. package/dist/visualize/raster.js.map +1 -0
  121. package/dist/visualize/svg.d.ts +39 -0
  122. package/dist/visualize/svg.d.ts.map +1 -0
  123. package/dist/visualize/svg.js +148 -0
  124. package/dist/visualize/svg.js.map +1 -0
  125. package/dist/visualize/transcode.d.ts +13 -0
  126. package/dist/visualize/transcode.d.ts.map +1 -0
  127. package/dist/visualize/transcode.js +40 -0
  128. package/dist/visualize/transcode.js.map +1 -0
  129. package/docs/images/repl.png +0 -0
  130. package/package.json +10 -6
  131. package/src/prolog-src/deepclause_mi.pl +1217 -120
  132. package/dist/compaction.d.ts +0 -98
  133. package/dist/compaction.d.ts.map +0 -1
  134. package/dist/compaction.js +0 -471
  135. package/dist/compaction.js.map +0 -1
  136. package/dist/repl/tools.d.ts +0 -53
  137. package/dist/repl/tools.d.ts.map +0 -1
  138. package/dist/repl/tools.js +0 -307
  139. package/dist/repl/tools.js.map +0 -1
  140. package/dist/system/assets/compactors/default-loop-compactor.dml +0 -43
  141. package/dist/system/assets/compactors/default-session-compactor.dml +0 -30
  142. package/dist/system/assets/docs/META_PROMPT.md +0 -68
@@ -61,6 +61,13 @@
61
61
  repl_edit_touched/2,
62
62
  repl_edit_touched_text/2,
63
63
  repl_edit_touched_clear/1,
64
+ repl_validate_tools/2,
65
+ repl_validate_docs/2,
66
+ schema_fill_fields/3,
67
+ schema_fields_of/3,
68
+ repl_unhandled_directives/2,
69
+ repl_inject_workspace_predicates/1,
70
+ workspace_predicate_name/1,
64
71
  repl_set_dml_reference/2,
65
72
  repl_set_authoring_examples/2,
66
73
  repl_manifest/2,
@@ -71,6 +78,7 @@
71
78
  repl_user_predicates/2,
72
79
  repl_user_predicates_text/2,
73
80
  repl_predicate_arities/3,
81
+ repl_probe_nondeterminism/6,
74
82
  repl_set_meta_catalog/2,
75
83
  repl_set_meta_template/2,
76
84
  repl_set_harness_examples/2,
@@ -115,6 +123,7 @@
115
123
  :- dynamic session_repl_memory/2. % session_repl_memory(SessionId, HarnessMemory) - object/entry memory
116
124
  :- dynamic session_meta_memory/2. % session_meta_memory(SessionId, MetaMemory) - code-writing task memory
117
125
  :- dynamic session_repl_mode/2. % session_repl_mode(SessionId, true/false) - gates format/writeln output shims
126
+ :- dynamic session_unhandled_directive/2. % session_unhandled_directive(SessionId, Directive)
118
127
  :- dynamic session_frozen/2. % session_frozen(SessionId, true/false) - read-only lock
119
128
  :- dynamic session_locked/2. % session_locked(SessionId, true/false) - irreversibly frozen (--locked)
120
129
  :- dynamic session_directive/2. % session_directive(SessionId, Directive) - recorded :- directives for export
@@ -152,7 +161,8 @@
152
161
  parse_dml(FilePath, SessionId, _MemoryId, Params, Error) :-
153
162
  catch(
154
163
  (
155
- read_file_to_string(FilePath, Code, []),
164
+ % FilePath is an absolute host temp path, not workspace-relative.
165
+ system:read_file_to_string(FilePath, Code, []),
156
166
  assert_params(SessionId, Params),
157
167
  parse_dml_string(Code, SessionId),
158
168
  Error = none
@@ -309,6 +319,11 @@ extract_var_info(Bindings, Term, BareVar, Info) :-
309
319
  BareVar = Term,
310
320
  var_to_name(Bindings, Term, Name),
311
321
  Info = typed_var{name: Name, type: string}
322
+ ; compound(Term), Term =.. [schema, SchemaName, Inner], var(Inner)
323
+ -> % A declared shape: schema(name, Var)
324
+ BareVar = Inner,
325
+ var_to_name(Bindings, Inner, Name),
326
+ Info = typed_var{name: Name, type: object, schema: SchemaName}
312
327
  ; compound(Term), Term =.. [Wrapper, Inner]
313
328
  -> % Wrapped variable
314
329
  ( wrapper_type(Wrapper, Type)
@@ -316,6 +331,11 @@ extract_var_info(Bindings, Term, BareVar, Info) :-
316
331
  -> BareVar = Inner,
317
332
  var_to_name(Bindings, Inner, Name),
318
333
  Info = typed_var{name: Name, type: Type}
334
+ ; compound(Inner), Inner =.. [schema, SchemaName, InnerVar], var(InnerVar), Wrapper == list
335
+ -> % List of declared shapes: list(schema(name, X))
336
+ BareVar = InnerVar,
337
+ var_to_name(Bindings, InnerVar, Name),
338
+ Info = typed_var{name: Name, type: array, itemType: object, schema: SchemaName}
319
339
  ; compound(Inner), Inner =.. [InnerWrapper, InnerVar], var(InnerVar), Wrapper == list
320
340
  -> % List of typed items: list(string(X))
321
341
  BareVar = InnerVar,
@@ -336,6 +356,7 @@ extract_var_info(Bindings, Term, BareVar, Info) :-
336
356
  ).
337
357
 
338
358
  %% wrapper_type(?Wrapper, ?Type)
359
+ wrapper_type(media, media).
339
360
  wrapper_type(string, string).
340
361
  wrapper_type(integer, integer).
341
362
  wrapper_type(float, number).
@@ -429,6 +450,9 @@ clean_atomic(with_tools(Tools, Goal), with_tools(Tools, CleanGoal)) :-
429
450
  clean_atomic(without_tools(Tools, Goal), without_tools(Tools, CleanGoal)) :-
430
451
  !,
431
452
  clean_clause_body(Goal, CleanGoal).
453
+ clean_atomic(with_fresh_memory(Goal), with_fresh_memory(CleanGoal)) :-
454
+ !,
455
+ clean_clause_body(Goal, CleanGoal).
432
456
  clean_atomic(with_judgment(Backend, Goal), with_judgment(Backend, CleanGoal)) :-
433
457
  !,
434
458
  clean_clause_body(Goal, CleanGoal).
@@ -477,6 +501,7 @@ typed_arg(Var, Info, Wrapped) :-
477
501
  typed_arg_type(Type, Var, Info, Wrapped).
478
502
  typed_arg(Var, _, Var).
479
503
 
504
+ typed_arg_type(media, Var, _, media(Var)) :- !.
480
505
  typed_arg_type(string, Var, _, Var) :- !.
481
506
  typed_arg_type(integer, Var, _, integer(Var)) :- !.
482
507
  typed_arg_type(number, Var, _, number(Var)) :- !.
@@ -739,8 +764,7 @@ process_directive(consult(File), SessionId) :-
739
764
  !,
740
765
  % Set session ID for get_session_id to find, then call our custom consult
741
766
  nb_setval(current_session_id, SessionId),
742
- resolve_workspace_path(File, FullPath),
743
- readutil:read_file_to_string(FullPath, Code, []),
767
+ system:read_file_to_string(File, Code, []),
744
768
  consult_string(Code, SessionId).
745
769
  %% Handle use_module directive
746
770
  process_directive(use_module(Module), SessionId) :-
@@ -763,7 +787,80 @@ process_directive([File|Files], SessionId) :-
763
787
  nb_setval(current_session_id, SessionId),
764
788
  process_directive(consult(File), SessionId),
765
789
  (Files == [] -> true ; process_directive(Files, SessionId)).
766
- process_directive(_, _).
790
+ %% `:- repl_delete(Name/Arity).` — the directive form of the fact handled by
791
+ %% process_clause/2 below. It used to fall through to the catch-all and be
792
+ %% discarded, so a predicate the author asked to remove stayed in the module.
793
+ process_directive(repl_delete(Name/Arity), SessionId) :-
794
+ !,
795
+ atom(Name),
796
+ integer(Arity),
797
+ Arity >= 0,
798
+ catch(abolish(SessionId:Name/Arity), _, true).
799
+
800
+ %% process_directive(require_sandbox, +SessionId)
801
+ %% The harness declares that it must run in an execution sandbox, and the load
802
+ %% fails if it cannot. Checked against the **effective** backend rather than
803
+ %% mere availability: bwrap being installed while the session resolves to
804
+ %% clean-room means the code would run unsandboxed, which is what the directive
805
+ %% exists to prevent. Only the user sets this (see repl_commit_directive/2).
806
+ process_directive(require_sandbox, SessionId) :-
807
+ ( session_sandbox_backend(SessionId, Kind)
808
+ -> true
809
+ ; Kind = 'clean-room'
810
+ ),
811
+ ( sandbox_backend_acceptable(Kind)
812
+ -> true
813
+ ; format(atom(Message),
814
+ 'harness requires an execution sandbox, but the effective backend is ~w; bwrap, AgentVM or sandbox-exec is required. Install bubblewrap, run with --sandbox (AgentVM), or remove the :- require_sandbox. directive.',
815
+ [Kind]),
816
+ throw(error(permission_error(load, harness, require_sandbox),
817
+ context(require_sandbox/0, Message)))
818
+ ).
819
+
820
+ %% Anything else is recorded rather than discarded. An unrecognised directive
821
+ %% (`:- dynamic(f/1).`, a `:- op(...)` without use_module, a typo) does nothing,
822
+ %% and silently doing nothing is how an edit that never took effect goes
823
+ %% unnoticed. The host reports these after a load.
824
+ process_directive(Directive, SessionId) :-
825
+ ( repl_benign_directive(Directive)
826
+ -> true
827
+ ; assertz(session_unhandled_directive(SessionId, Directive))
828
+ ).
829
+
830
+ %% Directives DML deliberately accepts and ignores (SWI state it manages itself).
831
+ repl_benign_directive(dynamic(_)).
832
+ repl_benign_directive(dynamic(_, _)).
833
+ repl_benign_directive(discontiguous(_)).
834
+ repl_benign_directive(discontiguous(_, _)).
835
+ repl_benign_directive(multifile(_)).
836
+ repl_benign_directive(multifile(_, _)).
837
+
838
+ %% sandbox_kind_atom(+Raw, -Atom)
839
+ %% A JS string or an atom in, an atom out.
840
+ %%
841
+ %% Uses term_string/3, not atom_string/2: in this WASM build `atom_string/2`
842
+ %% returns the string unchanged, so an "atom" produced by it still fails `atom/1`
843
+ %% and compares unequal to every atom literal. That silently made every backend
844
+ %% look unacceptable. `term_string(Atom, "bwrap", [])` does produce the atom.
845
+ sandbox_kind_atom(Raw, Atom) :-
846
+ ( atom(Raw)
847
+ -> Atom = Raw
848
+ ; catch(term_string(Atom, Raw, []), _, Atom = Raw)
849
+ ).
850
+
851
+ %% sandbox_backend_acceptable(+Kind)
852
+ %% The backends that provide filesystem isolation for `exec(bash(...))`.
853
+ %% `clean-room` is the absence of a wrapper and does not qualify. The host
854
+ %% normalizes `sandbox-exec` to `sandbox_exec` (see DMLSession.open) because a
855
+ %% hyphen makes the token a subtraction expression to a Prolog parser.
856
+ sandbox_backend_acceptable(agentvm).
857
+ sandbox_backend_acceptable(bwrap).
858
+ sandbox_backend_acceptable(sandbox_exec).
859
+
860
+
861
+ %% repl_unhandled_directives(+SessionId, -Directives)
862
+ repl_unhandled_directives(SessionId, Directives) :-
863
+ findall(D, session_unhandled_directive(SessionId, D), Directives).
767
864
 
768
865
  %% ============================================================
769
866
  %% Engine Management
@@ -916,7 +1013,17 @@ create_session(SessionId, Params, HarnessMemory, MetaMemory) :-
916
1013
  retractall(session_frozen(SessionId, _)),
917
1014
  assertz(session_frozen(SessionId, false)),
918
1015
  retractall(session_locked(SessionId, _)),
919
- assertz(session_locked(SessionId, false)).
1016
+ assertz(session_locked(SessionId, false)),
1017
+ % Stored as an atom. The host sends a JS string, and an atom and a string
1018
+ % that print identically are not `==`, so normalize once here rather than at
1019
+ % every comparison.
1020
+ ( get_dict(sandbox, Params, SandboxRaw)
1021
+ -> sandbox_kind_atom(SandboxRaw, SandboxKind)
1022
+ ; SandboxKind = 'clean-room'
1023
+ ),
1024
+ retractall(session_sandbox_backend(SessionId, _)),
1025
+ assertz(session_sandbox_backend(SessionId, SandboxKind)),
1026
+ repl_inject_workspace_predicates(SessionId).
920
1027
 
921
1028
  %% repl_abolish_user_predicates(+SessionId)
922
1029
  %% Removes only the harness's own predicates; imported builtins are untouched.
@@ -1235,6 +1342,7 @@ internal_session_predicate(param).
1235
1342
  internal_session_predicate(param_decl).
1236
1343
  internal_session_predicate(tool_source).
1237
1344
  internal_session_predicate('__session__').
1345
+ internal_session_predicate(Name) :- workspace_predicate_name(Name).
1238
1346
 
1239
1347
  %% history_predicate(+Name)
1240
1348
  %% Runtime-owned harness-history facts. Excluded from the user predicate set (so
@@ -1264,6 +1372,7 @@ repl_reserved_name(repl_reserved_name).
1264
1372
  repl_reserved_name(repl_tool_catalog).
1265
1373
  repl_reserved_name(repl_listing).
1266
1374
  repl_reserved_name(repl_assert).
1375
+ repl_reserved_name(require_sandbox).
1267
1376
  repl_reserved_name(repl_retract).
1268
1377
  repl_reserved_name(repl_delete).
1269
1378
  repl_reserved_name(repl_freeze).
@@ -1499,12 +1608,11 @@ repl_script_files(SessionId, Paths) :-
1499
1608
  repl_materialize_embedded(SessionId) :-
1500
1609
  ( current_predicate(SessionId:embedded_file/2)
1501
1610
  -> forall(SessionId:embedded_file(Path, Content),
1502
- ( resolve_workspace_path(Path, Full),
1503
- catch(
1504
- ( file_directory_name(Full, Dir),
1611
+ ( catch(
1612
+ ( file_directory_name(Path, Dir),
1505
1613
  make_directory_path(Dir),
1506
1614
  setup_call_cleanup(
1507
- open(Full, write, Stream),
1615
+ open(Path, write, Stream),
1508
1616
  format(Stream, "~s", [Content]),
1509
1617
  close(Stream))
1510
1618
  ),
@@ -1601,6 +1709,8 @@ repl_assert(SessionId, ClauseText, Result) :-
1601
1709
  catch(
1602
1710
  ( guard_not_frozen(SessionId),
1603
1711
  repl_prepare_single(SessionId, ClauseText, PreparedTerms),
1712
+ forall(member(PreparedTerm, PreparedTerms),
1713
+ repl_require_new_predicate(SessionId, PreparedTerm)),
1604
1714
  forall(member(PreparedTerm, PreparedTerms),
1605
1715
  repl_assert_clause(PreparedTerm, SessionId)),
1606
1716
  Result = ok
@@ -1660,11 +1770,25 @@ repl_read_prepared_terms(Stream, SessionId, Terms) :-
1660
1770
  % their operators are visible to the terms that follow; keep them out of
1661
1771
  % the returned list so repl_commit_terms does not process them twice.
1662
1772
  partition_directives(Prepared, Rules, Directives),
1663
- forall(member(Directive, Directives), process_clause(Directive, SessionId)),
1773
+ forall(member(Directive, Directives), repl_commit_directive(Directive, SessionId)),
1664
1774
  append(Rules, Rest, Terms),
1665
1775
  repl_read_prepared_terms(Stream, SessionId, Rest)
1666
1776
  ).
1667
1777
 
1778
+ %% repl_commit_directive(+Directive, +SessionId)
1779
+ %% Directives written by the *agent* (assert_many/define) run here. `require_sandbox`
1780
+ %% is the user's to set — through `/set-require-sandbox` — so an agent cannot
1781
+ %% declare a requirement it was not asked for, nor lift one by re-asserting the
1782
+ %% harness. The file-load path (parse_clauses/2) is deliberately not guarded:
1783
+ %% that is what the user's own harness file goes through.
1784
+ repl_commit_directive((:- require_sandbox), _SessionId) :-
1785
+ !,
1786
+ throw(error(permission_error(modify, reserved_name, require_sandbox),
1787
+ context(repl_commit_directive/2,
1788
+ 'only the user can set the sandbox requirement'))).
1789
+ repl_commit_directive(Directive, SessionId) :-
1790
+ process_clause(Directive, SessionId).
1791
+
1668
1792
  partition_directives([], [], []).
1669
1793
  partition_directives([Term|Terms], Rules, Directives) :-
1670
1794
  ( Term = (:- _)
@@ -1693,6 +1817,9 @@ repl_assert_many(SessionId, ClausesText, Result) :-
1693
1817
  catch(
1694
1818
  ( guard_not_frozen(SessionId),
1695
1819
  repl_parse_clauses(SessionId, ClausesText, Terms),
1820
+ % assert appends; against an existing predicate that leaves both
1821
+ % versions live with their clauses interleaved.
1822
+ forall(member(Term, Terms), repl_require_new_predicate(SessionId, Term)),
1696
1823
  repl_commit_terms(SessionId, Terms),
1697
1824
  repl_note_touched(SessionId, Terms),
1698
1825
  Result = ok
@@ -1716,11 +1843,105 @@ repl_term_indicator(Term, Name/Arity) :-
1716
1843
  %% Record the predicate indicators a term list defines, for history recording.
1717
1844
  repl_note_touched(SessionId, Terms) :-
1718
1845
  forall(( member(Term, Terms), repl_term_indicator(Term, Name/Arity) ),
1719
- ( session_edit_touched(SessionId, Name/Arity)
1720
- -> true
1721
- ; assertz(session_edit_touched(SessionId, Name/Arity))
1846
+ repl_note_indicator(SessionId, Name/Arity)).
1847
+
1848
+ repl_note_indicator(SessionId, Name/Arity) :-
1849
+ ( session_edit_touched(SessionId, Name/Arity)
1850
+ -> true
1851
+ ; assertz(session_edit_touched(SessionId, Name/Arity))
1852
+ ).
1853
+
1854
+ %% repl_require_new_predicate(+SessionId, +Term)
1855
+ %% `assert`/`assert_many` append. Against a predicate that already has clauses
1856
+ %% that mixes the old and the new version — both stay live, clause order
1857
+ %% interleaves them, and nothing says so. Refuse instead, and name the remedy.
1858
+ repl_require_new_predicate(SessionId, Term) :-
1859
+ repl_term_indicator(Term, Name/Arity),
1860
+ findall(x,
1861
+ ( functor(Head, Name, Arity),
1862
+ clause(SessionId:Head, _)
1863
+ ),
1864
+ Existing),
1865
+ Existing \== [],
1866
+ !,
1867
+ length(Existing, Count),
1868
+ format(atom(Message),
1869
+ '~w/~w already has ~w clause(s). assert appends, so the old and the new clauses would both be present and clause order would interleave them. Use define("~w/~w", "...") to replace the predicate, or delete("~w/~w") first.',
1870
+ [Name, Arity, Count, Name, Arity, Name, Arity]),
1871
+ throw(error(permission_error(modify, existing_predicate, Name/Arity),
1872
+ context(repl_require_new_predicate/2, Message))).
1873
+ repl_require_new_predicate(_SessionId, _Term).
1874
+
1875
+ %% repl_forget_tool(+SessionId, +Name)
1876
+ %% Drop the tool annotation that belonged to a predicate being removed.
1877
+ repl_forget_tool(SessionId, Name) :-
1878
+ ( session_user_tools(SessionId, Name)
1879
+ -> retractall(session_user_tools(SessionId, Name)),
1880
+ retractall(session_user_tool_schema(SessionId, Name, _)),
1881
+ catch(retractall(SessionId:tool_source(Name, _)), _, true)
1882
+ ; true
1883
+ ).
1884
+
1885
+ %% repl_capture_predicates(+SessionId, +Indicators, -Captured)
1886
+ %% Copy the clauses and tool bookkeeping a replacement is about to destroy, so
1887
+ %% a failure part-way through can put them back.
1888
+ repl_capture_predicates(SessionId, Indicators, Captured) :-
1889
+ findall(indicator(Name, Arity, Clauses, tool(IsTool, Schema, Source)),
1890
+ ( member(Name/Arity, Indicators),
1891
+ findall(Stored,
1892
+ ( functor(Head, Name, Arity),
1893
+ clause(SessionId:Head, Body),
1894
+ ( Body == true
1895
+ -> Stored = Head
1896
+ ; Stored = (Head :- Body)
1897
+ )
1898
+ ),
1899
+ Clauses),
1900
+ ( session_user_tools(SessionId, Name)
1901
+ -> IsTool = true
1902
+ ; IsTool = false
1903
+ ),
1904
+ ( session_user_tool_schema(SessionId, Name, Found)
1905
+ -> Schema = Found
1906
+ ; Schema = none
1907
+ ),
1908
+ ( current_predicate(SessionId:tool_source/2),
1909
+ SessionId:tool_source(Name, FoundSource)
1910
+ -> Source = FoundSource
1911
+ ; Source = none
1912
+ )
1913
+ ),
1914
+ Captured).
1915
+
1916
+ %% repl_restore_captured(+SessionId, +Captured)
1917
+ repl_restore_captured(SessionId, Captured) :-
1918
+ forall(member(indicator(Name, Arity, Clauses, tool(IsTool, Schema, Source)), Captured),
1919
+ ( catch(abolish(SessionId:Name/Arity), _, true),
1920
+ repl_forget_tool(SessionId, Name),
1921
+ forall(member(Clause, Clauses), assertz(SessionId:Clause)),
1922
+ ( IsTool == true
1923
+ -> assertz(session_user_tools(SessionId, Name))
1924
+ ; true
1925
+ ),
1926
+ ( Schema == none
1927
+ -> true
1928
+ ; assertz(session_user_tool_schema(SessionId, Name, Schema))
1929
+ ),
1930
+ ( Source == none
1931
+ -> true
1932
+ ; assertz(SessionId:tool_source(Name, Source))
1933
+ )
1722
1934
  )).
1723
1935
 
1936
+ %% repl_replace_predicates(+SessionId, +Indicators, +Terms)
1937
+ %% The destructive half of define: abolish the targets, then assert the block.
1938
+ repl_replace_predicates(SessionId, Indicators, Terms) :-
1939
+ forall(member(Name/Arity, Indicators),
1940
+ ( catch(abolish(SessionId:Name/Arity), _, true),
1941
+ repl_forget_tool(SessionId, Name)
1942
+ )),
1943
+ repl_commit_terms(SessionId, Terms).
1944
+
1724
1945
  %% repl_edit_touched(+SessionId, -Indicators)
1725
1946
  %% Predicates touched by edit tools since the last clear (sorted, unique).
1726
1947
  repl_edit_touched(SessionId, Indicators) :-
@@ -1751,7 +1972,13 @@ repl_system_functor(Name/_) :-
1751
1972
  member(Name, ['.', ',', ';', ':-', '->', ':', '|', '!', '\\+', '=', 'is']).
1752
1973
 
1753
1974
  %% repl_retract(+SessionId, +ClausePattern, -Result)
1754
- %% Retracts matching user clauses. Reserved names are protected.
1975
+ %% Removes the clauses that match the pattern, and says how many.
1976
+ %%
1977
+ %% This used to build a fresh head with `functor/3` and call `retractall/1`, so
1978
+ %% the pattern's *arguments were discarded*: `retract("g(1)")` removed every
1979
+ %% g/1 clause and reported `ok`. It now retracts what was asked for, and fails
1980
+ %% loudly when nothing matched — a silent no-op is how an agent concludes a
1981
+ %% clause is gone when it never was.
1755
1982
  repl_retract(SessionId, ClauseText, Result) :-
1756
1983
  catch(
1757
1984
  ( guard_not_frozen(SessionId),
@@ -1763,19 +1990,29 @@ repl_retract(SessionId, ClauseText, Result) :-
1763
1990
  context(repl_retract/3, 'cannot retract a reserved predicate')))
1764
1991
  ; true
1765
1992
  ),
1766
- functor(Head, Name, Arity),
1767
- retractall(SessionId:Head),
1768
- ( session_user_tools(SessionId, Name)
1769
- -> retractall(session_user_tools(SessionId, Name)),
1770
- retractall(session_user_tool_schema(SessionId, Name, _)),
1771
- retractall(SessionId:tool_source(Name, _))
1993
+ findall(x, retract(SessionId:Pattern), Removed),
1994
+ length(Removed, RemovedCount),
1995
+ ( RemovedCount =:= 0
1996
+ -> format(atom(Message),
1997
+ 'no clause of ~w/~w matched ~w, so nothing was removed',
1998
+ [Name, Arity, Clause]),
1999
+ throw(error(existence_error(clause, Clause),
2000
+ context(repl_retract/3, Message)))
1772
2001
  ; true
1773
2002
  ),
1774
- ( session_edit_touched(SessionId, Name/Arity)
2003
+ % Only drop the tool annotation when the predicate is really gone;
2004
+ % other clauses of it may still be live.
2005
+ ( functor(Head, Name, Arity),
2006
+ clause(SessionId:Head, _)
1775
2007
  -> true
1776
- ; assertz(session_edit_touched(SessionId, Name/Arity))
2008
+ ; repl_forget_tool(SessionId, Name)
1777
2009
  ),
1778
- Result = ok
2010
+ repl_note_indicator(SessionId, Name/Arity),
2011
+ ( RemovedCount =:= 1
2012
+ -> Suffix = ''
2013
+ ; Suffix = 's'
2014
+ ),
2015
+ format(atom(Result), 'ok(removed ~w clause~w)', [RemovedCount, Suffix])
1779
2016
  ),
1780
2017
  RetractError,
1781
2018
  format(atom(Result), 'error(~w)', [RetractError])
@@ -1811,34 +2048,45 @@ repl_delete_predicate(SessionId, Name, Arity, Result) :-
1811
2048
  ).
1812
2049
 
1813
2050
  %% repl_define(+SessionId, +HeadPatternText, +ClausesText, -Result)
1814
- %% Replace every predicate the clause block defines. It parses the block,
1815
- %% computes its indicators (plus the head pattern's), abolishes each, then
1816
- %% asserts the block. This makes `define` safe no matter how many predicates a
1817
- %% block spans, so a multi-predicate block cannot append duplicate clauses.
2051
+ %% Replace every predicate the clause block defines, all or nothing.
2052
+ %%
2053
+ %% The order matters and used to be wrong. Abolishing first and asserting after
2054
+ %% means any failure in between leaves the target *destroyed*: a block with one
2055
+ %% reserved name in it removed the predicate it was replacing and reported an
2056
+ %% error, and a following `end_turn` then committed the deletion as a version.
2057
+ %% So everything that can fail is checked before anything is touched, and the
2058
+ %% clauses being replaced are copied first so a failure can still put them back.
1818
2059
  repl_define(SessionId, HeadPatternText, ClausesText, Result) :-
1819
2060
  catch(
1820
2061
  ( guard_not_frozen(SessionId),
1821
- repl_read_clause(SessionId, HeadPatternText, Pattern),
1822
- repl_head_of(Pattern, HeadClause),
1823
- repl_callable_indicator(HeadClause, HeadName, HeadArity),
2062
+ repl_define_target(SessionId, HeadPatternText, HeadName, HeadArity),
1824
2063
  repl_parse_clauses(SessionId, ClausesText, Terms),
1825
2064
  findall(Name/Arity,
1826
2065
  ( member(Term, Terms), repl_term_indicator(Term, Name/Arity) ),
1827
2066
  BlockIndicators0),
1828
- sort([HeadName/HeadArity|BlockIndicators0], Indicators),
1829
- forall(member(Name/Arity, Indicators),
1830
- ( repl_protected_name(Name)
1831
- -> throw(error(permission_error(modify, reserved_name, Name/Arity),
1832
- context(repl_define/4, 'cannot define a reserved predicate')))
1833
- ; catch(abolish(SessionId:Name/Arity), _, true),
1834
- ( session_user_tools(SessionId, Name)
1835
- -> retractall(session_user_tools(SessionId, Name)),
1836
- retractall(session_user_tool_schema(SessionId, Name, _)),
1837
- retractall(SessionId:tool_source(Name, _))
1838
- ; true
1839
- )
1840
- )),
1841
- repl_commit_terms(SessionId, Terms),
2067
+ sort(BlockIndicators0, BlockIndicators),
2068
+ % The head names the predicate being replaced. If it is not one of
2069
+ % them the target is almost certainly a typo, and silently replacing
2070
+ % a *different* predicate is worse than refusing.
2071
+ ( BlockIndicators \== [],
2072
+ \+ member(HeadName/HeadArity, BlockIndicators)
2073
+ -> format(atom(TargetMessage),
2074
+ 'define was told to replace ~w/~w, but the clause block defines ~w. Pass the predicate you are replacing as the head.',
2075
+ [HeadName, HeadArity, BlockIndicators]),
2076
+ throw(error(domain_error(define_target, HeadName/HeadArity),
2077
+ context(repl_define/4, TargetMessage)))
2078
+ ; true
2079
+ ),
2080
+ sort([HeadName/HeadArity|BlockIndicators], Indicators),
2081
+ % Validate everything that can fail, before anything is removed.
2082
+ forall(member(Term, Terms), repl_require_committable(SessionId, Term)),
2083
+ forall(member(Name/Arity, Indicators), repl_require_not_reserved(SessionId, Name/Arity)),
2084
+ repl_capture_predicates(SessionId, Indicators, Captured),
2085
+ ( catch(repl_replace_predicates(SessionId, Indicators, Terms), ReplaceError,
2086
+ ( repl_restore_captured(SessionId, Captured),
2087
+ throw(ReplaceError)
2088
+ ))
2089
+ ),
1842
2090
  repl_note_touched(SessionId, Terms),
1843
2091
  Result = ok
1844
2092
  ),
@@ -1846,6 +2094,42 @@ repl_define(SessionId, HeadPatternText, ClausesText, Result) :-
1846
2094
  format(atom(Result), 'error(~w)', [DefineError])
1847
2095
  ).
1848
2096
 
2097
+ %% repl_define_target(+SessionId, +HeadText, -Name, -Arity)
2098
+ %% The first argument of `define` names the predicate being replaced, written as
2099
+ %% an indicator (`f/1`) or as a clause head (`f(_)`).
2100
+ %%
2101
+ %% It has to be decomposed explicitly: `f/1` *reads* as the operator term
2102
+ %% `/(f, 1)`, so treating it as a clause head yields the functor `//2`. That is
2103
+ %% what used to happen — the target was silently ignored and the clause block
2104
+ %% alone decided what got replaced, so `define` accepted any name at all.
2105
+ repl_define_target(SessionId, HeadText, Name, Arity) :-
2106
+ repl_read_clause(SessionId, HeadText, Pattern),
2107
+ ( Pattern = IndicatorName/IndicatorArity,
2108
+ atom(IndicatorName),
2109
+ integer(IndicatorArity),
2110
+ IndicatorArity >= 0
2111
+ -> Name = IndicatorName,
2112
+ Arity = IndicatorArity
2113
+ ; repl_head_of(Pattern, HeadClause),
2114
+ repl_callable_indicator(HeadClause, Name, Arity)
2115
+ ).
2116
+
2117
+ %% repl_require_committable(+SessionId, +Term)
2118
+ repl_require_committable(_SessionId, Term) :-
2119
+ ( repl_protected_clause(Term)
2120
+ -> throw(error(permission_error(modify, reserved_name, Term),
2121
+ context(repl_define/4, 'cannot define a reserved predicate')))
2122
+ ; true
2123
+ ).
2124
+
2125
+ %% repl_require_not_reserved(+SessionId, +Indicator)
2126
+ repl_require_not_reserved(_SessionId, Name/Arity) :-
2127
+ ( repl_protected_name(Name)
2128
+ -> throw(error(permission_error(modify, reserved_name, Name/Arity),
2129
+ context(repl_define/4, 'cannot define a reserved predicate')))
2130
+ ; true
2131
+ ).
2132
+
1849
2133
  %% repl_extract_code(+Text, -Source)
1850
2134
  %% Extracts a ```dml fenced block; falls back to the whole answer.
1851
2135
  repl_extract_code(Text, Source) :-
@@ -2052,6 +2336,161 @@ repl_predicate_arities(SessionId, Name, Arities) :-
2052
2336
  Arities0),
2053
2337
  sort(Arities0, Arities).
2054
2338
 
2339
+ %% ============================================================
2340
+ %% Nondeterminism probe (end_turn advisory)
2341
+ %%
2342
+ %% After a commit, the host may probe each changed pure predicate: if calling it
2343
+ %% with fresh variables yields more than a threshold of solutions, the predicate
2344
+ %% is probably missing a commit (`once/1` / `!`) and would flood an `execute`.
2345
+ %% Side-effecting predicates are never run (see repl_impure_name/1), fact tables
2346
+ %% are skipped, and the search is bounded by an inference limit.
2347
+ %% ============================================================
2348
+
2349
+ %% Functors with side effects: model calls, host tools, file IO, DB mutation.
2350
+ repl_impure_name(task). repl_impure_name(prompt). repl_impure_name(exec).
2351
+ repl_impure_name(system). repl_impure_name(user). repl_impure_name(answer).
2352
+ repl_impure_name(input). repl_impure_name(output). repl_impure_name(read_media).
2353
+ repl_impure_name(attach_media). repl_impure_name(save_image). repl_impure_name(gen_image).
2354
+ repl_impure_name(judge). repl_impure_name(choose). repl_impure_name(rate).
2355
+ repl_impure_name(verify). repl_impure_name(probability).
2356
+ repl_impure_name(write_file). repl_impure_name(append_file). repl_impure_name(delete_file).
2357
+ repl_impure_name(make_directory_path). repl_impure_name(open). repl_impure_name(close).
2358
+ repl_impure_name(assert). repl_impure_name(assertz). repl_impure_name(asserta).
2359
+ repl_impure_name(retract). repl_impure_name(retractall).
2360
+ repl_impure_name(consult). repl_impure_name(use_module). repl_impure_name(load_files).
2361
+
2362
+ %% repl_body_impure(+Goal) — true when the goal tree calls a side-effecting name.
2363
+ repl_body_impure(Goal) :-
2364
+ callable(Goal),
2365
+ functor(Goal, Name, _),
2366
+ repl_impure_name(Name),
2367
+ !.
2368
+ repl_body_impure((A, _)) :- repl_body_impure(A).
2369
+ repl_body_impure((_, B)) :- repl_body_impure(B).
2370
+ repl_body_impure((A ; B)) :- ( repl_body_impure(A) ; repl_body_impure(B) ).
2371
+ repl_body_impure((A -> B)) :- ( repl_body_impure(A) ; repl_body_impure(B) ).
2372
+ repl_body_impure(\+ Goal) :- repl_body_impure(Goal).
2373
+ repl_body_impure(once(Goal)) :- repl_body_impure(Goal).
2374
+ repl_body_impure(ignore(Goal)) :- repl_body_impure(Goal).
2375
+ repl_body_impure(forall(Gen, Action)) :- ( repl_body_impure(Gen) ; repl_body_impure(Action) ).
2376
+ repl_body_impure(catch(Goal, _, Recovery)) :- ( repl_body_impure(Goal) ; repl_body_impure(Recovery) ).
2377
+ repl_body_impure(findall(_, Goal, _)) :- repl_body_impure(Goal).
2378
+ repl_body_impure(findall(_, Goal, _, _)) :- repl_body_impure(Goal).
2379
+ repl_body_impure(call_with_inference_limit(Goal, _, _)) :- repl_body_impure(Goal).
2380
+ repl_body_impure(with_output_to(_, Goal)) :- repl_body_impure(Goal).
2381
+ repl_body_impure(_:Goal) :- repl_body_impure(Goal).
2382
+
2383
+ repl_predicate_pure(SessionId, Name, Arity) :-
2384
+ functor(Head, Name, Arity),
2385
+ \+ ( clause(SessionId:Head, Body), repl_body_impure(Body) ).
2386
+
2387
+ %% ============================================================
2388
+ %% Tool contract validation (end_turn advisory)
2389
+ %% ============================================================
2390
+ %%
2391
+ %% A tool head is what the nested agent calls; the tool body is what runs. So a
2392
+ %% well-formed tool is `tool(foo(A,B), "…") :- foo(A,B)` with foo/2 defined
2393
+ %% separately. The declared head arrives structurally through `clause/2` (its
2394
+ %% body is NOT executed), so a declared-but-undefined tool is caught here rather
2395
+ %% than at call time deep inside a nested agent.
2396
+ %%
2397
+ %% Known limitation: a module-qualified skill delegate (e.g. `other:solve(A,B)`)
2398
+ %% is not a session predicate and is reported; use `with_tools/2` for those.
2399
+
2400
+ %% repl_validate_tools(+SessionId, -Warnings)
2401
+ repl_validate_tools(SessionId, Warnings) :-
2402
+ findall(W, tool_contract_violation(SessionId, W), Raw),
2403
+ list_to_set(Raw, Warnings).
2404
+
2405
+ %% repl_validate_docs(+SessionId, -Warnings)
2406
+ %%
2407
+ %% `doc(Name/Arity, Text)` is an annotation: it documents a predicate, it does not
2408
+ %% create one. A `doc/2` whose predicate has no clauses therefore names a goal that
2409
+ %% fails silently when called — the failure surfaces far away as an empty result,
2410
+ %% and the name still appears in the manifest, so the listing looks healthy.
2411
+ %% Reported at `end_turn`, where the author can still see what they just wrote.
2412
+ repl_validate_docs(SessionId, Warnings) :-
2413
+ findall(W, doc_without_clauses(SessionId, W), Raw),
2414
+ list_to_set(Raw, Warnings).
2415
+
2416
+ doc_without_clauses(SessionId, Warning) :-
2417
+ SessionId:doc(Name/Arity, _),
2418
+ ( \+ current_predicate(SessionId:Name/Arity)
2419
+ ; functor(Head, Name, Arity),
2420
+ \+ clause(SessionId:Head, _)
2421
+ ),
2422
+ !,
2423
+ format(string(Warning),
2424
+ 'doc(~w/~w) documents a predicate with no clauses, so calling it fails silently and yields an empty result. Define ~w/~w, or remove the doc/2.',
2425
+ [Name, Arity, Name, Arity]).
2426
+
2427
+ tool_contract_violation(SessionId, Warning) :-
2428
+ clause(SessionId:tool(Head), Body),
2429
+ compound(Head),
2430
+ Head =.. [Name|HeadArgs],
2431
+ length(HeadArgs, Arity),
2432
+ tool_body_goal(Body, Call),
2433
+ Call =.. [Callee|Args],
2434
+ length(Args, CalleeArity),
2435
+ ( Callee == Name, CalleeArity == Arity, \+ repl_user_predicate(SessionId, Name, Arity)
2436
+ -> format(string(Warning),
2437
+ 'tool ~w/~w calls the tool itself, so a nested agent recurses forever. Define ~w/~w separately and delegate to it: tool(~w(A,B), "…") :- ~w(A,B).',
2438
+ [Name, Arity, Name, Name, Name, Name])
2439
+ ; Callee == Name, CalleeArity \== Arity
2440
+ -> format(string(Warning),
2441
+ 'tool ~w/~w declares a different arity than its body calls (~w/~w). A tool head and its delegate must match, with the output argument last.',
2442
+ [Name, Arity, Callee, CalleeArity])
2443
+ ; \+ ( SessionId:current_predicate(Callee/CalleeArity) )
2444
+ -> format(string(Warning),
2445
+ 'tool ~w/~w calls ~w/~w, which is not defined, so a nested agent gets "Tool not found". Define ~w/~w and delegate to it.',
2446
+ [Name, Arity, Callee, CalleeArity, Callee, CalleeArity])
2447
+ ).
2448
+
2449
+ %% tool_body_goal(+Body, -FirstCall): a tool body must reach a call, not be empty.
2450
+ tool_body_goal(Body, Call) :-
2451
+ Body \== true,
2452
+ tool_first_goal(Body, Call),
2453
+ compound(Call),
2454
+ functor(Call, F, _),
2455
+ F \== (',').
2456
+
2457
+ %% tool_first_goal(+Body, -Call): the first call in a conjunction body.
2458
+ tool_first_goal((A, _), Call) :- !, tool_first_goal(A, Call).
2459
+ tool_first_goal(Call, Call).
2460
+
2461
+ %% True when the predicate has at least one clause with a real body (not a fact).
2462
+ repl_predicate_has_rule(SessionId, Name, Arity) :-
2463
+ functor(Head, Name, Arity),
2464
+ clause(SessionId:Head, Body),
2465
+ Body \== true,
2466
+ !.
2467
+
2468
+ %% repl_probe_nondeterminism(+SessionId, +Name, +Arity, +Threshold, +Bound, -Result)
2469
+ %% Result: one | many | timeout | data | unsafe | removed | error.
2470
+ repl_probe_nondeterminism(SessionId, Name, Arity, Threshold, Bound, Result) :-
2471
+ ( \+ current_predicate(SessionId:Name/Arity)
2472
+ -> Result = removed
2473
+ ; \+ repl_predicate_has_rule(SessionId, Name, Arity)
2474
+ -> Result = data
2475
+ ; \+ repl_predicate_pure(SessionId, Name, Arity)
2476
+ -> Result = unsafe
2477
+ ; functor(Head, Name, Arity),
2478
+ N is Threshold + 1,
2479
+ catch(
2480
+ ( with_output_to(string(_),
2481
+ call_with_inference_limit(call_nth(SessionId:Head, N), Bound, R)),
2482
+ ( R == false -> Result = one
2483
+ ; R == inference_limit_exceeded -> Result = timeout
2484
+ ; R == true -> Result = many
2485
+ ; R == (!) -> Result = many % success is returned as `!` in this build
2486
+ ; Result = error
2487
+ )
2488
+ ),
2489
+ _,
2490
+ Result = error
2491
+ )
2492
+ ).
2493
+
2055
2494
  %% repl_history_listing(+SessionId)
2056
2495
  %% Human-readable tail for `listing(history)`.
2057
2496
  repl_history_listing(SessionId) :-
@@ -2463,8 +2902,15 @@ step_engine(SessionId, Status, Content, Payload) :-
2463
2902
 
2464
2903
  %% process_engine_result(+Result, -Status, -Content, -Payload)
2465
2904
  process_engine_result(output(Text), output, Text, none) :- !.
2905
+ process_engine_result(media(Payload), media, '', Payload) :- is_dict(Payload), !.
2466
2906
  process_engine_result(log(Text), log, Text, none) :- !.
2467
2907
  process_engine_result(answer(Text), answer, Text, none) :- !.
2908
+ %% Current 8-arg format (with model spec from with_model/2)
2909
+ process_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ReasoningEffort, RecipeContext, ModelSpec), request_agent_loop, '',
2910
+ payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: ReasoningEffort, recipeContext: RecipeContext, modelSpec: ModelSpec}) :- !.
2911
+ %% Prompt-family 6-arg format (tool scope + model spec; no reasoning/recipe)
2912
+ process_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ModelSpec), request_agent_loop, '',
2913
+ payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: none, recipeContext: "", modelSpec: ModelSpec}) :- !.
2468
2914
  %% Note: Memory and tool scope are now passed in the payload for the agent loop
2469
2915
  process_engine_result(request_agent_loop(Desc, Vars, Tools, Memory, ToolScope, ReasoningEffort, RecipeContext), request_agent_loop, '',
2470
2916
  payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: ToolScope, reasoningEffort: ReasoningEffort, recipeContext: RecipeContext}) :- !.
@@ -2476,10 +2922,14 @@ process_engine_result(request_agent_loop(Desc, Vars, Tools, Memory), request_age
2476
2922
  payload{taskDescription: Desc, outputVars: Vars, userTools: Tools, memory: Memory, toolScope: none, reasoningEffort: none, recipeContext: ""}) :- !.
2477
2923
  process_engine_result(request_exec(Tool, Args), request_exec, '',
2478
2924
  payload{toolName: Tool, args: Args}) :- !.
2925
+ process_engine_result(request_sample_token(Prompt, Allowed, ModelSpec), request_sample_token, '',
2926
+ payload{prompt: Prompt, allowedTokens: Allowed, modelSpec: ModelSpec}) :- !.
2479
2927
  process_engine_result(request_sample_token(Prompt, Allowed), request_sample_token, '',
2480
- payload{prompt: Prompt, allowedTokens: Allowed}) :- !.
2928
+ payload{prompt: Prompt, allowedTokens: Allowed, modelSpec: none}) :- !.
2929
+ process_engine_result(request_llm(Messages, ModelSpec), request_llm, '',
2930
+ payload{messages: Messages, modelSpec: ModelSpec}) :- !.
2481
2931
  process_engine_result(request_llm(Messages), request_llm, '',
2482
- payload{messages: Messages}) :- !.
2932
+ payload{messages: Messages, modelSpec: none}) :- !.
2483
2933
  process_engine_result(request_judgment(Payload), request_judgment, '', Payload) :- !.
2484
2934
  %% Tool result from inline tool execution
2485
2935
  process_engine_result(tool_result(Result), tool_result, '',
@@ -2714,6 +3164,25 @@ get_reasoning_effort(State, Effort) :-
2714
3164
  ; Effort = none
2715
3165
  ).
2716
3166
 
3167
+ %% set_model_state(+StateIn, +Spec, -StateOut)
3168
+ set_model_state(StateIn, Spec, StateOut) :-
3169
+ StateOut = StateIn.put(model_spec, Spec).
3170
+
3171
+ %% clear_model_state(+StateIn, -StateOut)
3172
+ clear_model_state(StateIn, StateOut) :-
3173
+ ( get_dict(model_spec, StateIn, _)
3174
+ -> del_dict(model_spec, StateIn, _, StateOut)
3175
+ ; StateOut = StateIn
3176
+ ).
3177
+
3178
+ %% get_model_spec(+State, -Spec)
3179
+ %% `none` means "no override" (use the session model).
3180
+ get_model_spec(State, Spec) :-
3181
+ ( get_dict(model_spec, State, Spec)
3182
+ -> true
3183
+ ; Spec = none
3184
+ ).
3185
+
2717
3186
  %% ============================================================
2718
3187
  %% Recipe Context Helpers
2719
3188
  %% ============================================================
@@ -3090,8 +3559,9 @@ mi_call(task(Desc), StateIn, StateOut) :-
3090
3559
  get_tool_scope(StateIn, ToolScope),
3091
3560
  get_reasoning_effort(StateIn, ReasoningEffort),
3092
3561
  get_recipe_context(StateIn, RecipeContext),
3562
+ get_model_spec(StateIn, ModelSpec),
3093
3563
  % Yield request with current memory and tool scope
3094
- engine_yield(request_agent_loop(InterpDesc, [], UserTools, Memory, ToolScope, ReasoningEffort, RecipeContext)),
3564
+ engine_yield(request_agent_loop(InterpDesc, [], UserTools, Memory, ToolScope, ReasoningEffort, RecipeContext, ModelSpec)),
3095
3565
  % Handle signals (tool calls) until agent_done
3096
3566
  handle_agent_signals(SessionId, StateIn, StateAfterAgent),
3097
3567
  session_agent_result(SessionId, Result),
@@ -3161,7 +3631,8 @@ mi_call(prompt(Desc), StateIn, StateOut) :-
3161
3631
  get_depth(StateIn, Depth),
3162
3632
  add_trace_entry(SessionId, llm_call, prompt, [InterpDesc], Depth),
3163
3633
  % prompt/N is a simple LLM call - no tools, no tool scope
3164
- engine_yield(request_agent_loop(InterpDesc, [], [], [], none)),
3634
+ get_model_spec(StateIn, ModelSpec),
3635
+ engine_yield(request_agent_loop(InterpDesc, [], [], [], none, ModelSpec)),
3165
3636
  % Wait for agent_done (no tool calls expected since no tools provided)
3166
3637
  handle_agent_signals(SessionId, StateIn, StateOut),
3167
3638
  session_agent_result(SessionId, Result),
@@ -3196,7 +3667,9 @@ mi_call_prompt_n(Desc, Vars, VarNames, StateIn, StateOut) :-
3196
3667
  get_depth(StateIn, Depth),
3197
3668
  add_trace_entry(SessionId, llm_call, prompt, [InterpDesc], Depth),
3198
3669
  % prompt/N is a simple LLM call - no tools, no tool scope
3199
- engine_yield(request_agent_loop(InterpDesc, VarNames, [], [], none)),
3670
+ get_model_spec(StateIn, ModelSpec),
3671
+ resolve_schema_fields(SessionId, VarNames, ResolvedVarNames),
3672
+ engine_yield(request_agent_loop(InterpDesc, ResolvedVarNames, [], [], none, ModelSpec)),
3200
3673
  % Wait for agent_done (no tool calls expected since no tools provided)
3201
3674
  handle_agent_signals(SessionId, StateIn, StateOut),
3202
3675
  session_agent_result(SessionId, Result),
@@ -3214,7 +3687,7 @@ mi_call_prompt_n(Desc, Vars, VarNames, StateIn, StateOut) :-
3214
3687
  engine_yield(output(WarnMsg)),
3215
3688
  fail
3216
3689
  ),
3217
- bind_task_variables(Result.variables, VarNames, Vars).
3690
+ bind_task_variables(SessionId, Result.variables, VarNames, Vars).
3218
3691
  % Don't modify memory - but state may have changed from tool calls
3219
3692
 
3220
3693
  mi_call_sample_token(Desc, Allowed, Token, StateIn, StateOut) :-
@@ -3223,7 +3696,8 @@ mi_call_sample_token(Desc, Allowed, Token, StateIn, StateOut) :-
3223
3696
  get_session_id(SessionId),
3224
3697
  get_depth(StateIn, Depth),
3225
3698
  add_trace_entry(SessionId, llm_call, sample_token, [InterpDesc, Allowed], Depth),
3226
- engine_yield(request_sample_token(InterpDesc, Allowed)),
3699
+ get_model_spec(StateIn, ModelSpec),
3700
+ engine_yield(request_sample_token(InterpDesc, Allowed, ModelSpec)),
3227
3701
  wait_sample_token_signal(SessionId),
3228
3702
  ( session_sample_token_result(SessionId, Result)
3229
3703
  -> retract(session_sample_token_result(SessionId, _)),
@@ -3431,7 +3905,8 @@ mi_call_llm(Messages, Reply, StateIn, StateOut) :-
3431
3905
  get_session_id(SessionId),
3432
3906
  get_depth(StateIn, Depth),
3433
3907
  add_trace_entry(SessionId, llm_call, llm, [Messages], Depth),
3434
- engine_yield(request_llm(Messages)),
3908
+ get_model_spec(StateIn, ModelSpec),
3909
+ engine_yield(request_llm(Messages, ModelSpec)),
3435
3910
  wait_llm_signal(SessionId),
3436
3911
  ( session_llm_result(SessionId, Result)
3437
3912
  -> retract(session_llm_result(SessionId, _)),
@@ -3474,7 +3949,9 @@ mi_call_task_n(Desc, Vars, VarNames, StateIn, StateOut) :-
3474
3949
  get_tool_scope(StateIn, ToolScope),
3475
3950
  get_reasoning_effort(StateIn, ReasoningEffort),
3476
3951
  get_recipe_context(StateIn, RecipeContext),
3477
- engine_yield(request_agent_loop(InterpDesc, VarNames, UserTools, Memory, ToolScope, ReasoningEffort, RecipeContext)),
3952
+ get_model_spec(StateIn, ModelSpec),
3953
+ resolve_schema_fields(SessionId, VarNames, ResolvedVarNames),
3954
+ engine_yield(request_agent_loop(InterpDesc, ResolvedVarNames, UserTools, Memory, ToolScope, ReasoningEffort, RecipeContext, ModelSpec)),
3478
3955
  % Handle signals (tool calls) until agent_done
3479
3956
  handle_agent_signals(SessionId, StateIn, StateAfterAgent),
3480
3957
  session_agent_result(SessionId, Result),
@@ -3491,7 +3968,7 @@ mi_call_task_n(Desc, Vars, VarNames, StateIn, StateOut) :-
3491
3968
  engine_yield(output(WarnMsg)),
3492
3969
  fail
3493
3970
  ),
3494
- bind_task_variables(Result.variables, VarNames, Vars),
3971
+ bind_task_variables(SessionId, Result.variables, VarNames, Vars),
3495
3972
  % Use full memory from agent loop result when available
3496
3973
  ( get_dict(full_memory, Result, FullMemory), is_list(FullMemory)
3497
3974
  -> set_memory(StateAfterAgent, FullMemory, StateOut),
@@ -3513,18 +3990,134 @@ mi_call_task_n(Desc, Vars, VarNames, StateIn, StateOut) :-
3513
3990
  )
3514
3991
  ).
3515
3992
 
3993
+ %% ---------------------------------------------------------------
3994
+ %% Output schemas: schema(Name, [field(Field, Type, Option), ...])
3995
+ %%
3996
+ %% `list(object(B))` says "a list of dicts" but not what is in them, so every
3997
+ %% harness re-derives defaults and tolerates missing fields by hand. A schema
3998
+ %% declares the shape once, and `schema_fill/3` makes the value match it: absent
3999
+ %% fields with a default are filled, unknown fields are kept (so a model mistake
4000
+ %% stays visible rather than disappearing), and a `required` field that is absent
4001
+ %% or a field of the wrong type **fails the step** rather than yielding an empty
4002
+ %% result.
4003
+ %%
4004
+ %% schema(author, [
4005
+ %% field(name, string, required),
4006
+ %% field(affiliation, string, default("not stated")),
4007
+ %% field(email, string, default(""))
4008
+ %% ]).
4009
+ %%
4010
+ %% task("From the front matter extract every author: {Text}",
4011
+ %% list(schema(author, Authors))).
4012
+ %% schema_fill(+Fields, +Value, -Filled): the pure part, so it is testable and
4013
+ %% usable without a session. The session predicate schema_fill/3 looks the fields
4014
+ %% up from `schema/2`.
4015
+ schema_fill_fields(Fields, Value, Filled) :-
4016
+ is_dict(Value),
4017
+ ( member(field(Field, Type, required), Fields),
4018
+ \+ dict_field(Value, Field, _),
4019
+ !,
4020
+ format(atom(Msg), 'schema: required field "~w" is missing', [Field]),
4021
+ throw(error(schema_error(Msg), schema_fill/3))
4022
+ ; true
4023
+ ),
4024
+ ( member(field(Field, Type, _), Fields),
4025
+ dict_field(Value, Field, Found),
4026
+ \+ schema_value_ok(Type, Found),
4027
+ !,
4028
+ format(atom(Msg), 'schema: field "~w" is not ~w (~w)', [Field, Type, Found]),
4029
+ throw(error(schema_error(Msg), schema_fill/3))
4030
+ ; true
4031
+ ),
4032
+ dict_pairs(Value, _, Pairs),
4033
+ findall(Key-NewValue,
4034
+ ( member(Key-Old, Pairs),
4035
+ ( member(field(Key, _, Option), Fields)
4036
+ -> ( Option = default(Default), Old == "" -> NewValue = Default
4037
+ ; NewValue = Old
4038
+ )
4039
+ ; NewValue = Old % undeclared: kept, so it stays visible
4040
+ )
4041
+ ),
4042
+ Updated),
4043
+ findall(Field-Default,
4044
+ ( member(field(Field, _, default(Default)), Fields),
4045
+ \+ dict_field(Value, Field, _)
4046
+ ),
4047
+ Missing),
4048
+ append(Updated, Missing, All),
4049
+ dict_pairs(Filled, _, All).
4050
+
4051
+ %% A declared type accepts its own shape; anything coarser (a number for a string
4052
+ %% field, for instance) fails, because silently coercing hides the model's error.
4053
+ schema_value_ok(string, V) :- ( string(V) ; atom(V) ), !.
4054
+ schema_value_ok(integer, V) :- integer(V), !.
4055
+ schema_value_ok(number, V) :- number(V), !.
4056
+ schema_value_ok(boolean, V) :- ( V == true ; V == false ; V == yes ; V == no ), !.
4057
+ schema_value_ok(object, V) :- is_dict(V), !.
4058
+ schema_value_ok(array, V) :- is_list(V), !.
4059
+ schema_value_ok(_, _) :- fail.
4060
+
3516
4061
  %% bind_task_variables(+VarsDict, +Names, -Values)
3517
- bind_task_variables(_, [], []) :- !.
3518
- bind_task_variables(VarsDict, [N|Names], [Value|Values]) :-
4062
+ bind_task_variables(_, _, [], []) :- !.
4063
+ bind_task_variables(SessionId, VarsDict, [N|Names], [Value|Values]) :-
3519
4064
  ( is_dict(N)
3520
4065
  -> Key = N.name
3521
4066
  ; Key = N
3522
4067
  ),
3523
- ( get_dict(Key, VarsDict, Value)
4068
+ ( get_dict(Key, VarsDict, Raw)
3524
4069
  -> true
3525
- ; true
4070
+ ; Raw = _Missing
3526
4071
  ),
3527
- bind_task_variables(VarsDict, Names, Values).
4072
+ ( is_dict(N), get_dict(schema, N, SchemaName), nonvar(Raw)
4073
+ -> schema_fields_of(SessionId, SchemaName, Fields),
4074
+ ( is_list(Raw)
4075
+ -> maplist(schema_filled(Fields), Raw, Value)
4076
+ ; schema_fill_fields(Fields, Raw, Value)
4077
+ )
4078
+ ; Value = Raw
4079
+ ),
4080
+ bind_task_variables(SessionId, VarsDict, Names, Values).
4081
+
4082
+ schema_fields_of(SessionId, Name, Fields) :-
4083
+ ( catch(clause(SessionId:schema(Name, Fields), true), _, fail)
4084
+ -> true
4085
+ ; format(atom(Msg), 'schema "~w" is not declared', [Name]),
4086
+ throw(error(schema_error(Msg), schema_fields_of/3))
4087
+ ).
4088
+
4089
+ %% resolve_schema_fields(+SessionId, +VarNames, -Resolved)
4090
+ %%
4091
+ %% Attach a schema's field list to the descriptor for each output variable that
4092
+ %% names one, so the agent building the prompt can tell the model the shape to
4093
+ %% produce. Done here rather than in extract_var_info/4 (which runs at load time)
4094
+ %% because the `schema/2` fact is only certainly present once everything is loaded.
4095
+ resolve_schema_fields(SessionId, VarNames, Resolved) :-
4096
+ maplist(resolve_schema_field(SessionId), VarNames, Resolved).
4097
+
4098
+ resolve_schema_field(SessionId, VarName, Resolved) :-
4099
+ ( is_dict(VarName), get_dict(schema, VarName, SchemaName)
4100
+ -> ( schema_field_dicts(SessionId, SchemaName, Fields)
4101
+ -> put_dict(fields, VarName, Fields, Resolved)
4102
+ ; Resolved = VarName
4103
+ )
4104
+ ; Resolved = VarName
4105
+ ).
4106
+
4107
+ %% Field descriptors as dicts, so they cross the bridge without a decoder.
4108
+ %% `required` is 1/0 rather than an atom: the atom `false` arrives as the string
4109
+ %% "false", which is truthy in JavaScript.
4110
+ schema_field_dicts(SessionId, SchemaName, Dicts) :-
4111
+ catch(clause(SessionId:schema(SchemaName, Fields), true), _, fail),
4112
+ findall(x{name: Field, type: Type, required: Required, default: Default},
4113
+ ( member(field(Field, Type, Option), Fields),
4114
+ ( Option == required -> Required = 1 ; Required = 0 ),
4115
+ ( Option = default(D) -> Default = D ; Default = no_default )
4116
+ ),
4117
+ Dicts).
4118
+
4119
+ schema_filled(Fields, Raw, Filled) :-
4120
+ ( is_dict(Raw) -> schema_fill_fields(Fields, Raw, Filled) ; Filled = Raw ).
3528
4121
 
3529
4122
  %% ============================================================
3530
4123
  %% Exec Handling
@@ -3572,6 +4165,19 @@ mi_call(with_tools(ToolList, Goal), StateIn, StateOut) :-
3572
4165
  mi_call(Goal, ScopedState, StateWithScope),
3573
4166
  clear_tool_scope(StateWithScope, StateOut).
3574
4167
 
4168
+ %% mi_call(with_fresh_memory(Goal), +StateIn, -StateOut)
4169
+ %% Run Goal with an empty memory, and discard whatever it accumulates. The steps
4170
+ %% inside see nothing from the caller, and the caller sees nothing from them.
4171
+ %% Use it to scope a group of task/N calls that must not be influenced by the
4172
+ %% conversation so far, or to keep a long history from growing through a
4173
+ %% sub-computation. `prompt/N` is the single-call equivalent.
4174
+ mi_call(with_fresh_memory(Goal), StateIn, StateOut) :-
4175
+ !,
4176
+ get_memory(StateIn, SavedMemory),
4177
+ set_memory(StateIn, [], FreshState),
4178
+ mi_call(Goal, FreshState, AfterGoal),
4179
+ set_memory(AfterGoal, SavedMemory, StateOut).
4180
+
3575
4181
  %% mi_call(without_tools(ToolList, Goal), +StateIn, -StateOut)
3576
4182
  %% Run Goal with the specified tools excluded from nested tasks
3577
4183
  mi_call(without_tools(ToolList, Goal), StateIn, StateOut) :-
@@ -3589,6 +4195,17 @@ mi_call(with_reasoning(Goal, Effort), StateIn, StateOut) :-
3589
4195
  mi_call(Goal, ScopedState, StateWithScope),
3590
4196
  clear_reasoning_effort_state(StateWithScope, StateOut).
3591
4197
 
4198
+ %% mi_call(with_model(Spec, Goal), +StateIn, -StateOut)
4199
+ %% Run Goal with a specific model, and optionally a reasoning level. `Spec` is a
4200
+ %% string — "provider:model", "provider:model#reasoning", "model",
4201
+ %% "model#reasoning" — or a model{provider, id, temperature, maxTokens, reasoning}
4202
+ %% dict. It applies to task/N, prompt/N and sample_token/N (not judgments).
4203
+ mi_call(with_model(Spec, Goal), StateIn, StateOut) :-
4204
+ !,
4205
+ set_model_state(StateIn, Spec, ScopedState),
4206
+ mi_call(Goal, ScopedState, StateWithScope),
4207
+ clear_model_state(StateWithScope, StateOut).
4208
+
3592
4209
  %% mi_call(with_recipe(Goal, Query), +StateIn, -StateOut)
3593
4210
  %% Run Goal with a recipe loaded as transient context (not persisted to memory).
3594
4211
  mi_call(with_recipe(Goal, Query), StateIn, StateOut) :-
@@ -3647,9 +4264,36 @@ mi_call(attach_media(Media), StateIn, StateOut) :-
3647
4264
  append(Memory, [Message], NewMemory),
3648
4265
  set_memory(StateIn, NewMemory, StateOut).
3649
4266
 
4267
+ %% mi_call(save_image(+Media, +OutPath), +StateIn, -StateOut)
4268
+ %% Write a media reference to a workspace file: `save_image(media(Path, Mime), "out.png")`,
4269
+ %% `save_image(media{svg: Svg}, "out.svg")`. Parent directories must exist.
4270
+ mi_call(save_image(Media, Out), StateIn, StateIn) :-
4271
+ !,
4272
+ media_dict(Media, Dict),
4273
+ ( get_dict(kind, Dict, inline_svg),
4274
+ get_dict(data, Dict, Svg)
4275
+ -> open(Out, write, Stream),
4276
+ write(Stream, Svg),
4277
+ close(Stream)
4278
+ ; get_dict(path, Dict, Path),
4279
+ Path \== ""
4280
+ -> copy_file(Path, Out)
4281
+ ; throw(error(domain_error(save_image, Media), save_image/2))
4282
+ ).
4283
+
3650
4284
  %% media_dict(+Media, -Dict)
3651
- %% Accepts media(Path, Mime) or a bare path; normalizes to the storage dict.
4285
+ %% Normalizes an image reference for display/attachment:
4286
+ %% media(Path, Mime) | media(Path) | a bare Path string
4287
+ %% media{path: Path} | media{path: Path, mime: Mime}
4288
+ %% media{svg: SvgString} (inline SVG)
4289
+ %% media{data: Base64} | media{data: Base64, mime: Mime} (inline bytes)
3652
4290
  media_dict(media(Path, Mime), media{path: Path, mime: Mime}) :- !.
4291
+ media_dict(media(Path), media{path: Path, mime: Mime}) :- !, media_mime(Path, Mime).
4292
+ media_dict(media{path: Path, mime: Mime}, media{path: Path, mime: Mime}) :- !.
4293
+ media_dict(media{path: Path}, media{path: Path, mime: Mime}) :- !, media_mime(Path, Mime).
4294
+ media_dict(media{svg: Svg}, media{path: "", mime: "image/svg+xml", kind: inline_svg, data: Svg}) :- !.
4295
+ media_dict(media{data: Data, mime: Mime}, media{path: "", mime: Mime, kind: inline_data, data: Data}) :- !.
4296
+ media_dict(media{data: Data}, media{path: "", mime: "image/png", kind: inline_data, data: Data}) :- !.
3653
4297
  media_dict(Path, media{path: Path, mime: Mime}) :- media_mime(Path, Mime).
3654
4298
 
3655
4299
  %% media_mime(+Path, -Mime)
@@ -3657,6 +4301,7 @@ media_dict(Path, media{path: Path, mime: Mime}) :- media_mime(Path, Mime).
3657
4301
  media_mime(Path, Mime) :-
3658
4302
  ( string(Path) -> atom_string(Atom, Path) ; Atom = Path ),
3659
4303
  ( sub_atom(Atom, _, _, 0, '.png') -> Mime = "image/png"
4304
+ ; sub_atom(Atom, _, _, 0, '.svg') -> Mime = "image/svg+xml"
3660
4305
  ; sub_atom(Atom, _, _, 0, '.jpg') -> Mime = "image/jpeg"
3661
4306
  ; sub_atom(Atom, _, _, 0, '.jpeg') -> Mime = "image/jpeg"
3662
4307
  ; sub_atom(Atom, _, _, 0, '.gif') -> Mime = "image/gif"
@@ -3676,6 +4321,29 @@ mi_call(answer(Text), StateIn, StateIn) :-
3676
4321
  engine_yield(answer(InterpText)),
3677
4322
  throw('$answer_commit').
3678
4323
 
4324
+ %% mi_call(output(Media), +StateIn, -StateOut)
4325
+ %% Present an image instead of text: `output(media(Path, Mime))`, `output(media(Path))`,
4326
+ %% or a dict form (`media{svg: Svg}`, `media{data: Base64, mime: Mime}`). The host
4327
+ %% renders it inline (kitty/iTerm2) or reports the path.
4328
+ %% This clause must come before the generic `output(Text)` clause; it fails
4329
+ %% through for anything that is not a media reference.
4330
+ mi_call(output(Media), StateIn, StateIn) :-
4331
+ nonvar(Media),
4332
+ ( Media = media(_, _)
4333
+ ; Media = media(_)
4334
+ ; is_dict(Media)
4335
+ ),
4336
+ media_dict(Media, Dict),
4337
+ !,
4338
+ get_dict(mime, Dict, Mime),
4339
+ get_dict(path, Dict, Path),
4340
+ ( get_dict(data, Dict, Data) -> true ; Data = "" ),
4341
+ ( get_dict(kind, Dict, Kind) -> true ; Kind = file ),
4342
+ get_session_id(SessionId),
4343
+ get_depth(StateIn, Depth),
4344
+ add_trace_entry(SessionId, media, media, [Path], Depth),
4345
+ engine_yield(media(media{path: Path, mimeType: Mime, kind: Kind, data: Data})).
4346
+
3679
4347
  %% mi_call(output(Text), +StateIn, -StateOut)
3680
4348
  mi_call(output(Text), StateIn, StateIn) :-
3681
4349
  get_params(StateIn, Params),
@@ -4099,28 +4767,428 @@ mi_foldl(Goal, [H|T], V0, V, StateIn, StateOut) :-
4099
4767
  mi_foldl(Goal, T, V1, V, State1, StateOut).
4100
4768
 
4101
4769
  %% ============================================================
4102
- %% File I/O - All paths relative to /workspace
4770
+ %% Workspace predicates (one definition, every surface)
4103
4771
  %% ============================================================
4772
+ %%
4773
+ %% These are **real clauses in the session module**, not only meta-interpreter
4774
+ %% handlers. `read_file/2` in particular has no other definition in this build:
4775
+ %% without these clauses it exists only as an `mi_call/3` body, so it works from
4776
+ %% DML but fails from a host query. Installing them makes a host query, `execute`,
4777
+ %% `/goal` and a nested `task/N` resolve the same predicate.
4778
+ %%
4779
+ %% The file-stream primitives live in `system:` (NOT `readutil`, which has no
4780
+ %% `read_file_to_string/3`). They are module-qualified so a wrapper cannot
4781
+ %% recurse into itself.
4782
+ %%
4783
+ %% Deliberately not here: `open/3`, `close/1`, `read_string/3`, `write/2`. They
4784
+ %% are imported builtins in the session module and SWI refuses to shadow an
4785
+ %% imported private predicate, so a workspace-resolving version cannot be
4786
+ %% installed. `read_file/2` and `write_file/2` are the whole-file API instead.
4787
+ %%
4788
+ %% `workspace_predicate_name/1` is the single source of truth; it also keeps the
4789
+ %% set out of `listing` / `export` / history.
4790
+
4791
+ workspace_predicate_name(read_file).
4792
+ workspace_predicate_name(read_file_to_string).
4793
+ workspace_predicate_name(write_file).
4794
+ workspace_predicate_name(append_file).
4795
+ workspace_predicate_name(exists_file).
4796
+ workspace_predicate_name(exists_directory).
4797
+ workspace_predicate_name(directory_files).
4798
+ workspace_predicate_name(make_directory).
4799
+ workspace_predicate_name(make_directory_path).
4800
+ workspace_predicate_name(delete_file).
4801
+ workspace_predicate_name(delete_directory).
4802
+ workspace_predicate_name(size_file).
4803
+ workspace_predicate_name(time_file).
4804
+ workspace_predicate_name(dict_field).
4805
+ workspace_predicate_name(dict_field_key).
4806
+ workspace_predicate_name(json_get).
4807
+ workspace_predicate_name(json_gets).
4808
+ workspace_predicate_name(json_put).
4809
+ workspace_predicate_name(json_text).
4810
+ workspace_predicate_name(schema_fill).
4811
+ workspace_predicate_name(pattern).
4812
+
4813
+ %% dict_field(+Dict, +Key, -Value) and dict_field(+Dict, +Key, +Default, -Value)
4814
+ %%
4815
+ %% Look up a field of a model-produced dict. SWI dicts have *atom* keys and
4816
+ %% `get_dict/3` is strict: `get_dict("affiliation", D)` raises
4817
+ %% `type_error(dict-key, affiliation)` rather than failing, and the model varies
4818
+ %% the spelling (`Name` / `name` / `full_name`). This matches on a normalised
4819
+ %% atom — any of atom/string, any case, `_`/`-`/space ignored — so
4820
+ %% `dict_field(D, "affiliation", A)` finds the `affiliation` key.
4821
+ dict_field(Dict, Key, Value) :-
4822
+ is_dict(Dict),
4823
+ dict_field_key(Key, Wanted),
4824
+ dict_pairs(Dict, _, Pairs),
4825
+ member(K-V, Pairs),
4826
+ dict_field_key(K, Wanted),
4827
+ Value = V.
4828
+
4829
+ dict_field(Dict, Key, Default, Value) :-
4830
+ ( dict_field(Dict, Key, Found)
4831
+ -> Value = Found
4832
+ ; Value = Default
4833
+ ).
4834
+
4835
+ %% dict_field_key(+Key, -Normalised): lowercase atom with separators removed.
4836
+ dict_field_key(Key, Normalised) :-
4837
+ ( atom(Key) -> atom_string(Key, Text) ; Text = Key ),
4838
+ string_lower(Text, Lower),
4839
+ split_string(Lower, " _-", "", Parts),
4840
+ atomic_list_concat(Parts, '', Normalised).
4841
+
4842
+ %% json_get/3,4, json_gets/3, json_put/4, json_text/2
4843
+ %%
4844
+ %% Path access into a model-produced structure, so a nested lookup is one call
4845
+ %% rather than a chain of dict_field/3. A path is a list of keys and indices, or a
4846
+ %% string/atom with `.` separators (`"results.0.authors.1.name"`), which is how a
4847
+ %% JSON path is normally written.
4848
+ %%
4849
+ %% A missing path **fails** — it does not throw and does not yield "" — so absence
4850
+ %% is visible at the call site; json_get/4 supplies a default instead. Key matching
4851
+ %% is the same tolerant match as dict_field/3 (atom or string, any case,
4852
+ %% `_`/`-`/space ignored).
4853
+ json_path_segments(Path, Segments) :-
4854
+ ( is_list(Path)
4855
+ -> Segments = Path
4856
+ ; ( atom(Path) -> atom_string(Path, Text) ; Text = Path ),
4857
+ split_string(Text, ".", "", RawParts),
4858
+ exclude(==(""), RawParts, Parts),
4859
+ maplist(json_path_segment, Parts, Segments)
4860
+ ).
4861
+
4862
+ json_path_segment("*", star) :- !.
4863
+ json_path_segment(Part, Index) :-
4864
+ catch(number_string(N, Part), _, fail),
4865
+ integer(N),
4866
+ !,
4867
+ Index = N.
4868
+ json_path_segment(Part, Part).
4869
+
4870
+ %% json_walk(+Value, +Segments, -Found)
4871
+ json_walk(Value, [], Value) :- !.
4872
+ json_walk(Doc, [Index|Rest], Found) :-
4873
+ integer(Index),
4874
+ is_list(Doc),
4875
+ !,
4876
+ nth0(Index, Doc, Next),
4877
+ json_walk(Next, Rest, Found).
4878
+ json_walk(Doc, [Key|Rest], Found) :-
4879
+ is_dict(Doc),
4880
+ dict_field_key(Key, Wanted),
4881
+ dict_pairs(Doc, _, Pairs),
4882
+ member(K-V, Pairs),
4883
+ dict_field_key(K, Wanted),
4884
+ json_walk(V, Rest, Found).
4885
+
4886
+ json_get(Doc, Path, Value) :-
4887
+ json_path_segments(Path, Segments),
4888
+ \+ member(star, Segments),
4889
+ json_walk(Doc, Segments, Value).
4890
+
4891
+ json_get(Doc, Path, Default, Value) :-
4892
+ ( json_get(Doc, Path, Found)
4893
+ -> Value = Found
4894
+ ; Value = Default
4895
+ ).
4896
+
4897
+ %% json_gets/3: `*` maps over a list, collecting the remainder for every element.
4898
+ %% Nested `*`s flatten, so "results.*.authors.*.name" is a flat list of names.
4899
+ json_gets(Doc, Path, Values) :-
4900
+ json_path_segments(Path, Segments),
4901
+ json_collect(Doc, Segments, Values).
4902
+
4903
+ json_collect(Value, [], [Value]) :- !.
4904
+ json_collect(Doc, [star|Rest], Values) :-
4905
+ is_list(Doc),
4906
+ findall(Collected,
4907
+ ( member(Item, Doc), json_collect(Item, Rest, Collected) ),
4908
+ Nested),
4909
+ append(Nested, Values).
4910
+ json_collect(Doc, [Seg|Rest], Values) :-
4911
+ json_walk_1(Doc, Seg, Next),
4912
+ json_collect(Next, Rest, Values).
4913
+
4914
+ %% One step of a walk, for the collector (where `star` is handled by the caller).
4915
+ json_walk_1(Doc, Index, Value) :- integer(Index), is_list(Doc), !, nth0(Index, Doc, Value).
4916
+ json_walk_1(Doc, Key, Value) :-
4917
+ is_dict(Doc),
4918
+ dict_field_key(Key, Wanted),
4919
+ dict_pairs(Doc, _, Pairs),
4920
+ member(K-V, Pairs),
4921
+ dict_field_key(K, Wanted),
4922
+ Value = V.
4923
+
4924
+ %% json_put/4: set a key path. Lists are read-only here — an index path is
4925
+ %% rejected, because building output is about setting fields, not splicing lists.
4926
+ json_put(Doc, Path, Value, Updated) :-
4927
+ json_path_segments(Path, Segments),
4928
+ Segments \== [],
4929
+ \+ member(star, Segments),
4930
+ % NB: `\+ ( member(S, Segments), integer(S) )`. Writing this as
4931
+ % `\+ member(S, Segments), integer(S)` binds S inside the negation, so the
4932
+ % whole guard fails for any non-empty path.
4933
+ \+ ( member(Seg, Segments), integer(Seg) ),
4934
+ json_put_walk(Doc, Segments, Value, Updated).
4935
+
4936
+ json_put_walk(_Doc, [], Value, Value) :- !.
4937
+ json_put_walk(Doc, [Key|Rest], Value, Updated) :-
4938
+ is_dict(Doc),
4939
+ ( json_walk_1(Doc, Key, Old)
4940
+ -> json_put_walk(Old, Rest, Value, NewValue)
4941
+ ; json_put_walk(_{}, Rest, Value, NewValue)
4942
+ ),
4943
+ dict_field_key(Key, Key0),
4944
+ put_dict(Key0, Doc, NewValue, Updated).
4945
+
4946
+ %% json_text(+Dict, -Text): compact JSON. For layout or streaming use
4947
+ %% json_write_dict/3 from library(http/json).
4948
+ json_text(Dict, Text) :- atom_json_dict(Text, Dict, []).
4949
+
4950
+ %% pattern(+Template, ?Text, ?Bindings)
4951
+ %%
4952
+ %% One syntax for building a string and for parsing it. Literal text matches
4953
+ %% exactly, and `$Name` marks a slot; the direction follows from what is bound:
4954
+ %%
4955
+ %% pattern("Name: $Name, age: $Age", Text, [name="Ada", age=36]) % build
4956
+ %% pattern("Name: $Name, age: $Age", "Name: Ada, age: 36", B) % parse
4957
+ %% pattern("DOI: $Doi:doi", Text, [doi=D]) % typed slot
4958
+ %%
4959
+ %% `$` is the marker rather than `{…}` so a template can contain JSON or any other
4960
+ %% brace-bearing text verbatim, and so it never collides with the `{Var}`
4961
+ %% interpolation used in task/N descriptions. `$$` is a literal dollar.
4962
+ %%
4963
+ %% A template must be anchored by literal text: two slots separated only by nothing
4964
+ %% (`"$A$B"`) is rejected, rather than resolved arbitrarily. On parse a slot
4965
+ %% consumes up to the next literal, so the literals decide the split.
4966
+ pattern(Template, Text, Bindings) :-
4967
+ pattern_tokens(Template, Tokens),
4968
+ ( var(Text)
4969
+ -> pattern_build(Tokens, Bindings, Text)
4970
+ ; pattern_parse(Tokens, Text, Bindings)
4971
+ ).
4972
+
4973
+ %% pattern_tokens(+Template, -Tokens): [lit(CharList)|slot(NameChars, TypeChars)]
4974
+ pattern_tokens(Template, Tokens) :-
4975
+ ( atom(Template) -> atom_string(Template, Text) ; Text = Template ),
4976
+ string_chars(Text, Chars),
4977
+ pattern_scan(Chars, [], Tokens),
4978
+ pattern_check_anchored(Tokens).
4979
+
4980
+ pattern_scan([], Acc, [lit(Text)]) :-
4981
+ !,
4982
+ reverse(Acc, Text).
4983
+ pattern_scan(['$', '$' | Rest], Acc, Tokens) :-
4984
+ !,
4985
+ pattern_scan(Rest, ['$' | Acc], Tokens).
4986
+ pattern_scan(['$' | Rest], Acc, Tokens) :-
4987
+ !,
4988
+ pattern_take_name(Rest, NameChars, Rest1),
4989
+ NameChars \== [],
4990
+ atom_chars(Name, NameChars),
4991
+ ( Rest1 = [':' | Rest2],
4992
+ pattern_take_name(Rest2, TypeChars, Rest3),
4993
+ TypeChars \== []
4994
+ -> atom_chars(Type, TypeChars)
4995
+ ; Rest3 = Rest1, Type = none
4996
+ ),
4997
+ reverse(Acc, Text),
4998
+ Tokens = [lit(Text), slot(Name, Type) | More],
4999
+ pattern_scan(Rest3, [], More).
5000
+ pattern_scan([C | Rest], Acc, Tokens) :-
5001
+ pattern_scan(Rest, [C | Acc], Tokens).
5002
+
5003
+ %% A slot name is a run of characters that are not a template delimiter. Scanning by
5004
+ %% delimiter rather than by `char_type(C, alnum)` avoids depending on a ctypes
5005
+ %% predicate that is not guaranteed in this build.
5006
+ pattern_take_name([C | Rest], [C | More], Left) :-
5007
+ \+ pattern_delimiter(C),
5008
+ !,
5009
+ pattern_take_name(Rest, More, Left).
5010
+ pattern_take_name(Rest, [], Rest).
5011
+
5012
+ pattern_delimiter(' '). pattern_delimiter('\t'). pattern_delimiter('\n').
5013
+ pattern_delimiter(':'). pattern_delimiter(','). pattern_delimiter('.').
5014
+ pattern_delimiter(')'). pattern_delimiter(']'). pattern_delimiter('}').
5015
+ pattern_delimiter('$'). pattern_delimiter('-'). pattern_delimiter('/').
5016
+ pattern_delimiter('='). pattern_delimiter('\'').
5017
+
5018
+ %% Two slots with nothing between them cannot be told apart on parse.
5019
+ pattern_check_anchored(Tokens) :-
5020
+ pattern_adjacent_slots(Tokens, A, B),
5021
+ !,
5022
+ format(atom(Msg),
5023
+ 'pattern: slots "~w" and "~w" are adjacent with no literal between them, so the split is ambiguous',
5024
+ [A, B]),
5025
+ throw(error(domain_error(pattern_unanchored, Msg), pattern_tokens/2)).
5026
+ pattern_check_anchored(_).
5027
+
5028
+ pattern_adjacent_slots([slot(A, _), lit([]), slot(B, _) | _], A, B) :- !.
5029
+ pattern_adjacent_slots([_ | Rest], A, B) :- pattern_adjacent_slots(Rest, A, B).
5030
+
5031
+ %% pattern_build(+Tokens, +Bindings, -Text)
5032
+ pattern_build(Tokens, Bindings, Text) :-
5033
+ findall(Chars,
5034
+ ( member(Token, Tokens),
5035
+ pattern_build_token(Token, Bindings, Chars)
5036
+ ),
5037
+ Parts),
5038
+ append(Parts, All),
5039
+ string_chars(Text, All).
4104
5040
 
4105
- %% Helper: resolve path relative to /workspace
4106
- %% Prevents directory traversal outside workspace
4107
- resolve_workspace_path(RelPath, FullPath) :-
4108
- ( atom(RelPath) -> atom_string(RelPath, RelPathStr)
4109
- ; RelPathStr = RelPath
5041
+ pattern_build_token(lit(Chars), _, Chars) :- !.
5042
+ pattern_build_token(slot(Name, Type), Bindings, Chars) :-
5043
+ ( member(Name=Value, Bindings)
5044
+ -> true
5045
+ ; member(Key=Value, Bindings), pattern_name(Key, Name)
5046
+ -> true
5047
+ ; format(atom(Msg), 'pattern: no value bound for slot "~w"', [Name]),
5048
+ throw(error(uninstantiation_error(Msg), pattern/3))
4110
5049
  ),
4111
- % Remove leading slashes to make relative
4112
- ( sub_string(RelPathStr, 0, 1, _, "/")
4113
- -> sub_string(RelPathStr, 1, _, 0, CleanPath)
4114
- ; CleanPath = RelPathStr
5050
+ pattern_render(Type, Value, Chars).
5051
+
5052
+ pattern_render(none, Value, Chars) :- !, pattern_text_chars(Value, Chars).
5053
+ pattern_render(Type, Value, Chars) :-
5054
+ ( memberchk(Type, [string, text])
5055
+ -> pattern_text_chars(Value, Chars)
5056
+ ; memberchk(Type, [int, integer, number])
5057
+ -> ( number(Value) -> format(string(S), "~w", [Value]) ; S = Value ),
5058
+ string_chars(S, Chars)
5059
+ ; format(atom(Msg), 'pattern: unknown slot type "~w"', [Type]),
5060
+ throw(error(domain_error(pattern_type, Msg), pattern/3))
5061
+ ).
5062
+
5063
+ %% Always a char LIST. Returning the string itself here silently corrupted the
5064
+ %% build: the parts list then held a string among char lists, and the final
5065
+ %% string_chars/2 failed on a mixed list.
5066
+ pattern_text_chars(Value, Chars) :-
5067
+ ( string(Value) -> string_chars(Value, Chars)
5068
+ ; atom(Value) -> atom_chars(Value, Chars)
5069
+ ; number(Value) -> format(string(S), "~w", [Value]), string_chars(S, Chars)
5070
+ ; format(atom(Msg), 'pattern: cannot render ~w as text', [Value]),
5071
+ throw(error(type_error(text, Msg), pattern/3))
5072
+ ).
5073
+
5074
+ %% pattern_parse(+Tokens, +Text, -Bindings)
5075
+ pattern_parse(Tokens, Text, Bindings) :-
5076
+ % Always a char list: a string here would be one element and the literal
5077
+ % matching below would never line up.
5078
+ ( string(Text) -> string_chars(Text, Chars)
5079
+ ; atom(Text) -> atom_string(Text, S), string_chars(S, Chars)
5080
+ ; Chars = Text
4115
5081
  ),
4116
- % Prevent directory traversal
4117
- ( sub_string(CleanPath, _, _, _, "..")
4118
- -> throw(error(permission_error(access, directory, RelPath),
4119
- context(resolve_workspace_path/2, 'Directory traversal not allowed')))
4120
- ; true
5082
+ is_list(Chars),
5083
+ pattern_match(Tokens, Chars, Bindings).
5084
+
5085
+ pattern_match([], [], []) :- !.
5086
+ pattern_match([lit(Lit)], Rest, []) :-
5087
+ !,
5088
+ append(Lit, [], Rest).
5089
+ pattern_match([lit(Lit), slot(Name, Type) | More], Chars, [Name=Value | Bindings]) :-
5090
+ append(Lit, Rest0, Chars),
5091
+ !,
5092
+ ( More = [lit(Next) | _], Next \== []
5093
+ -> append(Taken, After, Rest0),
5094
+ append(Next, _, After)
5095
+ ; Taken = Rest0,
5096
+ After = []
4121
5097
  ),
4122
- % Build full path
4123
- atom_concat('/workspace/', CleanPath, FullPath).
5098
+ pattern_capture(Type, Taken, Value),
5099
+ pattern_match(More, After, Bindings).
5100
+
5101
+ pattern_capture(none, Chars, Value) :- !, string_chars(Value, Chars).
5102
+ pattern_capture(Type, Chars, Value) :-
5103
+ ( memberchk(Type, [string, text])
5104
+ -> string_chars(Value, Chars)
5105
+ ; memberchk(Type, [int, integer])
5106
+ -> ( catch(number_codes(N, Chars), _, fail), integer(N)
5107
+ -> Value = N
5108
+ ; format(atom(Msg), 'pattern: "~s" is not an integer', [Chars]),
5109
+ throw(error(syntax_error(Msg), pattern/3))
5110
+ )
5111
+ ; memberchk(Type, [number])
5112
+ -> ( catch(number_codes(N, Chars), _, fail)
5113
+ -> Value = N
5114
+ ; format(atom(Msg), 'pattern: "~s" is not a number', [Chars]),
5115
+ throw(error(syntax_error(Msg), pattern/3))
5116
+ )
5117
+ ; format(atom(Msg), 'pattern: unknown slot type "~w"', [Type]),
5118
+ throw(error(domain_error(pattern_type, Msg), pattern/3))
5119
+ ).
5120
+
5121
+ %% pattern_name(+Key, +Name): compare a template slot name with a binding key the
5122
+ %% way dict_field does — atom or string, any case, separators ignored.
5123
+ pattern_name(Key, Name) :-
5124
+ pattern_normalise(Key, A),
5125
+ pattern_normalise(Name, B),
5126
+ A == B.
5127
+
5128
+ pattern_normalise(Value, Normalised) :-
5129
+ ( atom(Value) -> atom_string(Value, Text) ; Text = Value ),
5130
+ string_lower(Text, Lower),
5131
+ split_string(Lower, " _-", "", Parts),
5132
+ atomic_list_concat(Parts, '', Normalised).
5133
+
5134
+ %% repl_inject_workspace_predicates(+SessionId)
5135
+ %% Idempotent: assertz/1 the same clauses twice is harmless (a duplicate clause
5136
+ %% yields the same solution once? no - it yields twice), so clear first.
5137
+ repl_inject_workspace_predicates(SessionId) :-
5138
+ forall(( workspace_predicate_name(Name), between(0, 5, Arity) ),
5139
+ catch(abolish(SessionId:Name/Arity), _, true)),
5140
+ forall(workspace_predicate_clause(SessionId, Clause),
5141
+ catch(assertz(Clause), _, true)).
5142
+
5143
+ workspace_predicate_clause(S, (S:read_file(F, C) :-
5144
+ system:read_file_to_string(F, C, []))).
5145
+ workspace_predicate_clause(S, (S:read_file_to_string(F, C, O) :-
5146
+ system:read_file_to_string(F, C, O))).
5147
+ workspace_predicate_clause(S, (S:write_file(F, C) :-
5148
+ system:open(F, write, St), system:write(St, C), system:close(St))).
5149
+ workspace_predicate_clause(S, (S:append_file(F, C) :-
5150
+ system:open(F, append, St), system:write(St, C), system:close(St))).
5151
+ workspace_predicate_clause(S, (S:exists_file(F) :-
5152
+ system:exists_file(F))).
5153
+ workspace_predicate_clause(S, (S:exists_directory(D) :-
5154
+ system:exists_directory(D))).
5155
+ workspace_predicate_clause(S, (S:directory_files(D, L) :-
5156
+ system:directory_files(D, L))).
5157
+ workspace_predicate_clause(S, (S:make_directory(D) :-
5158
+ system:make_directory(D))).
5159
+ workspace_predicate_clause(S, (S:make_directory_path(D) :-
5160
+ system:make_directory_path(D))).
5161
+ workspace_predicate_clause(S, (S:delete_file(F) :-
5162
+ system:delete_file(F))).
5163
+ workspace_predicate_clause(S, (S:delete_directory(D) :-
5164
+ system:delete_directory(D))).
5165
+ workspace_predicate_clause(S, (S:size_file(F, Sz) :-
5166
+ system:size_file(F, Sz))).
5167
+ workspace_predicate_clause(S, (S:dict_field(D, K, V) :- deepclause_mi:dict_field(D, K, V))).
5168
+ workspace_predicate_clause(S, (S:dict_field(D, K, Def, V) :- deepclause_mi:dict_field(D, K, Def, V))).
5169
+ workspace_predicate_clause(S, (S:dict_field_key(K, N) :- deepclause_mi:dict_field_key(K, N))).
5170
+ workspace_predicate_clause(S, (S:json_get(D, P, V) :- deepclause_mi:json_get(D, P, V))).
5171
+ workspace_predicate_clause(S, (S:json_get(D, P, Def, V) :- deepclause_mi:json_get(D, P, Def, V))).
5172
+ workspace_predicate_clause(S, (S:json_gets(D, P, Vs) :- deepclause_mi:json_gets(D, P, Vs))).
5173
+ workspace_predicate_clause(S, (S:json_put(D, P, V, D2) :- deepclause_mi:json_put(D, P, V, D2))).
5174
+ workspace_predicate_clause(S, (S:json_text(D, T) :- deepclause_mi:json_text(D, T))).
5175
+ workspace_predicate_clause(S, (S:schema_fill(N, V, F) :-
5176
+ deepclause_mi:schema_fields_of(S, N, Fields), deepclause_mi:schema_fill_fields(Fields, V, F))).
5177
+ workspace_predicate_clause(S, (S:pattern(T, Text, B) :- deepclause_mi:pattern(T, Text, B))).
5178
+
5179
+ workspace_predicate_clause(S, (S:time_file(F, T) :-
5180
+ system:time_file(F, T))).
5181
+
5182
+ %% ============================================================
5183
+ %% File I/O - paths resolve against /workspace (see the note above)
5184
+ %% ============================================================
5185
+
5186
+ %% File I/O paths
5187
+ %% `/workspace` is the host project, mounted by the WASM filesystem (NODEFS);
5188
+ %% its working directory is set to /workspace, so a relative path and a
5189
+ %% `/workspace/...` path name the same file. Every other absolute path is the
5190
+ %% ephemeral in-memory VM filesystem, not the host. There is no rewriting
5191
+ %% layer any more: the mount is the boundary.
4124
5192
 
4125
5193
  %% mi_call(consult(File), +StateIn, -StateOut)
4126
5194
  %% Load Prolog clauses from a file into the current session module
@@ -4129,8 +5197,7 @@ mi_call(consult(File), StateIn, StateIn) :-
4129
5197
  !,
4130
5198
  get_session_id(SessionId),
4131
5199
  guard_not_frozen(SessionId),
4132
- resolve_workspace_path(File, FullPath),
4133
- readutil:read_file_to_string(FullPath, Code, []),
5200
+ system:read_file_to_string(File, Code, []),
4134
5201
  consult_string(Code, SessionId).
4135
5202
 
4136
5203
  mi_call(use_module(Module), StateIn, StateIn) :-
@@ -4228,22 +5295,19 @@ consult_process_term(Head, SessionId) :-
4228
5295
  %% mi_call(read_file_to_string(File, Content, Options), +StateIn, -StateOut)
4229
5296
  mi_call(read_file_to_string(File, Content, Options), StateIn, StateIn) :-
4230
5297
  !,
4231
- resolve_workspace_path(File, FullPath),
4232
- readutil:read_file_to_string(FullPath, Content, Options).
5298
+ system:read_file_to_string(File, Content, Options).
4233
5299
 
4234
5300
  %% mi_call(read_file(File, Content), +StateIn, -StateOut)
4235
5301
  %% Simplified version without options
4236
5302
  mi_call(read_file(File, Content), StateIn, StateIn) :-
4237
5303
  !,
4238
- resolve_workspace_path(File, FullPath),
4239
- readutil:read_file_to_string(FullPath, Content, []).
5304
+ system:read_file_to_string(File, Content, []).
4240
5305
 
4241
5306
  %% mi_call(write_file(File, Content), +StateIn, -StateOut)
4242
5307
  %% Write string content to a file
4243
5308
  mi_call(write_file(File, Content), StateIn, StateIn) :-
4244
5309
  !,
4245
- resolve_workspace_path(File, FullPath),
4246
- open(FullPath, write, Stream),
5310
+ open(File, write, Stream),
4247
5311
  write(Stream, Content),
4248
5312
  close(Stream).
4249
5313
 
@@ -4251,8 +5315,7 @@ mi_call(write_file(File, Content), StateIn, StateIn) :-
4251
5315
  %% Append string content to a file
4252
5316
  mi_call(append_file(File, Content), StateIn, StateIn) :-
4253
5317
  !,
4254
- resolve_workspace_path(File, FullPath),
4255
- open(FullPath, append, Stream),
5318
+ open(File, append, Stream),
4256
5319
  write(Stream, Content),
4257
5320
  close(Stream).
4258
5321
 
@@ -4260,19 +5323,13 @@ mi_call(append_file(File, Content), StateIn, StateIn) :-
4260
5323
  %% Open a file for reading/writing
4261
5324
  mi_call(open(File, Mode, Stream), StateIn, StateIn) :-
4262
5325
  !,
4263
- resolve_workspace_path(File, FullPath),
4264
- open(FullPath, Mode, Stream).
5326
+ open(File, Mode, Stream).
4265
5327
 
4266
5328
  %% mi_call(close(Stream), +StateIn, -StateOut)
4267
5329
  mi_call(close(Stream), StateIn, StateIn) :-
4268
5330
  !,
4269
5331
  close(Stream).
4270
5332
 
4271
- %% mi_call(read_string(Stream, Length, Content), +StateIn, -StateOut)
4272
- mi_call(read_string(Stream, Length, Content), StateIn, StateIn) :-
4273
- !,
4274
- read_string(Stream, Length, Content).
4275
-
4276
5333
  %% mi_call(write(Stream, Content), +StateIn, -StateOut)
4277
5334
  mi_call(write(Stream, Content), StateIn, StateIn) :-
4278
5335
  !,
@@ -4281,60 +5338,51 @@ mi_call(write(Stream, Content), StateIn, StateIn) :-
4281
5338
  %% mi_call(exists_file(File), +StateIn, -StateOut)
4282
5339
  mi_call(exists_file(File), StateIn, StateIn) :-
4283
5340
  !,
4284
- resolve_workspace_path(File, FullPath),
4285
- exists_file(FullPath).
5341
+ exists_file(File).
4286
5342
 
4287
5343
  %% mi_call(exists_directory(Dir), +StateIn, -StateOut)
4288
5344
  mi_call(exists_directory(Dir), StateIn, StateIn) :-
4289
5345
  !,
4290
- resolve_workspace_path(Dir, FullPath),
4291
- exists_directory(FullPath).
5346
+ exists_directory(Dir).
4292
5347
 
4293
5348
  %% mi_call(directory_files(Dir, Files), +StateIn, -StateOut)
4294
5349
  mi_call(directory_files(Dir, Files), StateIn, StateIn) :-
4295
5350
  !,
4296
- resolve_workspace_path(Dir, FullPath),
4297
- directory_files(FullPath, Files).
5351
+ directory_files(Dir, Files).
4298
5352
 
4299
5353
  %% mi_call(make_directory(Dir), +StateIn, -StateOut)
4300
5354
  mi_call(make_directory(Dir), StateIn, StateIn) :-
4301
5355
  !,
4302
- resolve_workspace_path(Dir, FullPath),
4303
- make_directory(FullPath).
5356
+ make_directory(Dir).
4304
5357
 
4305
5358
  %% mi_call(make_directory_path(Dir), +StateIn, -StateOut)
4306
5359
  mi_call(make_directory_path(Dir), StateIn, StateIn) :-
4307
5360
  !,
4308
- resolve_workspace_path(Dir, FullPath),
4309
- make_directory_path(FullPath).
5361
+ make_directory_path(Dir).
4310
5362
 
4311
5363
  %% mi_call(delete_file(File), +StateIn, -StateOut)
4312
5364
  mi_call(delete_file(File), StateIn, StateIn) :-
4313
5365
  !,
4314
- resolve_workspace_path(File, FullPath),
4315
- delete_file(FullPath).
5366
+ delete_file(File).
4316
5367
 
4317
5368
  %% mi_call(delete_directory(Dir), +StateIn, -StateOut)
4318
5369
  mi_call(delete_directory(Dir), StateIn, StateIn) :-
4319
5370
  !,
4320
- resolve_workspace_path(Dir, FullPath),
4321
- delete_directory(FullPath).
5371
+ delete_directory(Dir).
4322
5372
 
4323
5373
  %% mi_call(size_file(File, Size), +StateIn, -StateOut)
4324
5374
  %% size_file/2 is a plain SWI builtin, so it would otherwise resolve `File`
4325
5375
  %% against the WASM process cwd (not the workspace) and demand `/workspace/...`.
4326
5376
  mi_call(size_file(File, Size), StateIn, StateIn) :-
4327
5377
  !,
4328
- resolve_workspace_path(File, FullPath),
4329
- size_file(FullPath, Size).
5378
+ size_file(File, Size).
4330
5379
 
4331
5380
  %% mi_call(time_file(File, Time), +StateIn, -StateOut)
4332
5381
  %% Modifications time (seconds since epoch); same workspace-relative rule as
4333
5382
  %% size_file/2.
4334
5383
  mi_call(time_file(File, Time), StateIn, StateIn) :-
4335
5384
  !,
4336
- resolve_workspace_path(File, FullPath),
4337
- time_file(FullPath, Time).
5385
+ time_file(File, Time).
4338
5386
 
4339
5387
  %% ============================================================
4340
5388
  %% Catch/Throw - STATE THREADED
@@ -4419,6 +5467,7 @@ is_mi_special_predicate(system(_)).
4419
5467
  is_mi_special_predicate(user(_)). %% Add user message to context
4420
5468
  is_mi_special_predicate(read_media(_,_)).
4421
5469
  is_mi_special_predicate(attach_media(_)).
5470
+ is_mi_special_predicate(save_image(_,_)).
4422
5471
  is_mi_special_predicate(task(_)).
4423
5472
  is_mi_special_predicate(task(_,_)).
4424
5473
  is_mi_special_predicate(task(_,_,_)).
@@ -4439,9 +5488,11 @@ is_mi_special_predicate(prompt_named(_,_,_)). %% Transformed form of prompt/N
4439
5488
  is_mi_special_predicate(llm(_,_)).
4440
5489
  %% Tool scoping predicates
4441
5490
  is_mi_special_predicate(with_tools(_,_)).
5491
+ is_mi_special_predicate(with_fresh_memory(_)).
4442
5492
  is_mi_special_predicate(without_tools(_,_)).
4443
5493
  %% Reasoning effort scoping
4444
5494
  is_mi_special_predicate(with_reasoning(_,_)).
5495
+ is_mi_special_predicate(with_model(_,_)).
4445
5496
  %% Recipe context scoping
4446
5497
  is_mi_special_predicate(with_recipe(_,_)).
4447
5498
  %% Semantic judgment predicates
@@ -4669,6 +5720,14 @@ consume_gas(StateIn, StateOut) :-
4669
5720
  %% mi_call_dispatch(+Goal, +StateIn, -StateOut)
4670
5721
  %% Dispatch user-defined vs built-in predicates
4671
5722
  %% Uses separate clauses instead of if-then-else to preserve backtracking
5723
+ mi_call_dispatch(Goal, StateIn, StateIn) :-
5724
+ get_session_id(SessionId),
5725
+ callable(Goal),
5726
+ predicate_property(SessionId:Goal, defined),
5727
+ predicate_property(Goal, built_in),
5728
+ !,
5729
+ call(SessionId:Goal).
5730
+
4672
5731
  mi_call_dispatch(Goal, StateIn, StateIn) :-
4673
5732
  predicate_property(Goal, built_in),
4674
5733
  !, % Commit: it's built-in, no backtracking needed
@@ -4750,8 +5809,14 @@ var_to_name(Bindings, Var, Name) :-
4750
5809
  %% expand_interpolations(+Term, +Bindings, -ExpandedTerm)
4751
5810
  %% Walk a term and expand strings containing {VarName} patterns
4752
5811
  %% Bindings is a list of 'VarName'=Variable pairs from read_term
5812
+ %% Interpolation happens in goal position only: `task/N`/`prompt/N`/`output/1`/… and
5813
+ %% the arguments of a user goal, never inside a list, a dict, or another compound
5814
+ %% passed as data. See the note on expand_term_interp/3.
5815
+ expand_interpolations((Head :- Body), Bindings, (Head :- ExpandedBody)) :-
5816
+ !,
5817
+ expand_body_interp(Body, Bindings, ExpandedBody).
4753
5818
  expand_interpolations(Term, Bindings, ExpandedTerm) :-
4754
- expand_term_interp(Term, Bindings, ExpandedTerm).
5819
+ expand_body_interp(Term, Bindings, ExpandedTerm).
4755
5820
 
4756
5821
  %% expand_term_interp(+Term, +Bindings, -Expanded)
4757
5822
  %% Main term expansion predicate
@@ -4760,13 +5825,16 @@ expand_interpolations(Term, Bindings, ExpandedTerm) :-
4760
5825
  expand_term_interp(Var, _, Var) :-
4761
5826
  var(Var), !.
4762
5827
 
4763
- % Strings - check if they need interpolation
4764
- expand_term_interp(String, Bindings, Expanded) :-
4765
- string(String), !,
4766
- ( string_needs_interpolation(String)
4767
- -> build_format_goal(String, Bindings, Expanded)
4768
- ; Expanded = String
4769
- ).
5828
+ % Strings are LITERAL here. Interpolation is decided by expand_body_interp/3, which
5829
+ % only rewrites a string that is a *direct argument of a goal*. Rewriting every
5830
+ % string, nested ones included, corrupted data: the shell command
5831
+ % exec(bash(argv: [..., "-w", "%{http_code}", Url]), R)
5832
+ % had its `%{http_code}` element replaced by a format goal inside the argv list, so
5833
+ % the list arrived truncated at `-w`. A JSON document, a regex, an `awk` program and
5834
+ % a `jq` filter all contain braces and must survive verbatim.
5835
+ expand_term_interp(String, _, String) :-
5836
+ string(String),
5837
+ !.
4770
5838
 
4771
5839
  % Atoms - check if they need interpolation (for atom strings)
4772
5840
  expand_term_interp(Atom, Bindings, Expanded) :-
@@ -4812,6 +5880,19 @@ expand_term_interp(Term, Bindings, ExpandedTerm) :-
4812
5880
  expand_body_interp(Var, _, Var) :-
4813
5881
  var(Var), !.
4814
5882
 
5883
+ % A list or a dict is DATA, not a sequence of goals. Descending into one treats its
5884
+ % elements as goal arguments and hoists any `{...}` string it contains, which
5885
+ % rewrote data into a goal term:
5886
+ % argv: ["curl","-w","%{http_code}","u"] became
5887
+ % argv: [curl,-w|((param(http_code,_),format(...)),[Temp,u])]
5888
+ % so `exec(bash(argv: [...]))` reached the shell with a truncated argument list.
5889
+ expand_body_interp(Data, _, Data) :-
5890
+ ( Data == []
5891
+ ; Data = [_|_]
5892
+ ; is_dict(Data)
5893
+ ),
5894
+ !.
5895
+
4815
5896
  % Conjunction
4816
5897
  expand_body_interp((A, B), Bindings, ExpandedConj) :- !,
4817
5898
  expand_body_interp(A, Bindings, EA),
@@ -4860,6 +5941,7 @@ expand_body_interp(Goal, Bindings, ExpandedGoal) :-
4860
5941
  expand_body_interp(Goal, Bindings, ExpandedGoal) :-
4861
5942
  compound(Goal),
4862
5943
  Goal =.. [Functor|Args],
5944
+ interp_arg_predicate(Functor),
4863
5945
  expand_args_hoist(Args, Bindings, PreGoals, ExpandedArgs, true),
4864
5946
  !,
4865
5947
  Bare =.. [Functor|ExpandedArgs],
@@ -4875,6 +5957,21 @@ expand_body_interp(Goal, Bindings, ExpandedGoal) :-
4875
5957
  % Atoms/other
4876
5958
  expand_body_interp(Goal, _, Goal).
4877
5959
 
5960
+ %% Predicates whose string arguments interpolate `{Var}`. Interpolation lives in
5961
+ %% the *description* of an LLM step and in the question of a judgment; anywhere else
5962
+ %% a string is data and must survive verbatim. Hoisting for every goal made a string
5963
+ %% of data into a goal argument: `X = "%{code}"` became an interpolation of the
5964
+ %% variable `code`, and a shell argv containing `%{http_code}` was rewritten.
5965
+ %%
5966
+ %% (task/N, prompt/N, answer/1, system/1, user/1, output/1, log/1 and yield/1 are
5967
+ %% handled by goal_with_string_arg/4 above, for their first argument.)
5968
+ interp_arg_predicate(judge).
5969
+ interp_arg_predicate(choose).
5970
+ interp_arg_predicate(rate).
5971
+ interp_arg_predicate(verify).
5972
+ interp_arg_predicate(probability).
5973
+ interp_arg_predicate(holds).
5974
+
4878
5975
  %% goal_with_string_arg(+Goal, -Functor, -StringArg, -RestArgs)
4879
5976
  %% Match goals that take a string as their first argument
4880
5977
  goal_with_string_arg(answer(S), answer, S, []) :- string(S).