]> git.mdlowis.com Git - proto/nlshell.git/commitdiff
added tools concept
authorMike Lowis <mike@mdlowis.com>
Thu, 1 Oct 2026 03:07:09 +0000 (23:07 -0400)
committerMike Lowis <mike@mdlowis.com>
Thu, 1 Oct 2026 03:07:09 +0000 (23:07 -0400)
nlshell.rb

index 8cf64f256996240d709512bb3644f89a1e350cc4..9dff9946fdafe034fa60d6f167adca5f5393504e 100755 (executable)
@@ -194,18 +194,20 @@ end
 # word-for-word against a clause (same length, no fuzzing - that's Tier 2).
 module Tier1
   class << self
-    attr_accessor :rules
+    attr_accessor :tools
   end
 
   def self.configure(ruleset)
-    self.rules = ruleset.rule_patterns
+    self.tools = ruleset.tool_patterns
   end
 
   # Returns an action node (Hash) on an exact match, or nil on a miss.
   def self.lookup(clause)
-    rules.each do |pattern, action_name|
-      args = match(pattern.split, clause)
-      return { type: "action", name: action_name, args: args } if args
+    tools.each do |tool|
+      tool.rules.each do |pattern|
+        args = match(pattern.split, clause)
+        return { type: "action", name: tool.name, args: args, tool: tool } if args
+      end
     end
     nil
   end
@@ -263,7 +265,72 @@ module Utterance
 end
 
 module Ruleset
+  # One tool: the rule patterns that trigger it, the keyword-arg action that
+  # runs it, and whether running it needs user confirmation first.
+  Tool = Struct.new(:name, :rules, :action, :permission)
+
+  # Collects rule()/action()/permission() calls inside a tool(...) do...end
+  # block, then validates them into a Tool on build.
+  class ToolBuilder
+    PERMISSIONS = %i[yolo ask].freeze
+
+    def initialize(name)
+      @name = name
+      @patterns = []
+      @permission = :yolo
+      @action = nil
+    end
+
+    def rule(pattern)
+      # The leading word anchors verb detection (SS verb_patterns below); a
+      # rule can't open on a :slot.
+      raise ArgumentError, "tool #{@name.inspect}: rule #{pattern.inspect} must start with a literal word" if pattern.split.first.start_with?(":")
+
+      @patterns << pattern
+    end
+
+    def permission(level)
+      raise ArgumentError, "tool #{@name.inspect}: unknown permission #{level.inspect}" unless PERMISSIONS.include?(level)
+
+      @permission = level
+    end
+
+    def action(&block)
+      @action = block
+    end
+
+    def build
+      raise ArgumentError, "tool #{@name.inspect} has no rule" if @patterns.empty?
+      raise ArgumentError, "tool #{@name.inspect} has no action" unless @action
+
+      slots = slot_sets
+      raise ArgumentError, "tool #{@name.inspect}: rules disagree on slot names" unless slots.uniq.length == 1
+      raise ArgumentError, "tool #{@name.inspect}: action params don't match rule slots" unless slots.first == required_keywords
+
+      Tool.new(@name, @patterns, @action, @permission)
+    end
+
+    private
+
+    # Each rule's slot names (the ":target" in "copy :target to :destination"),
+    # so build() can confirm every rule in this tool agrees on them.
+    def slot_sets
+      @patterns.map do |pattern|
+        pattern.split.each_with_object(Set.new) { |w, s| s << w.delete_prefix(":").to_sym if w.start_with?(":") }
+      end
+    end
+
+    def required_keywords
+      @action.parameters.select { |type, _| type == :keyreq }.map { |_, name| name }.to_set
+    end
+  end
+
   class RulesModule
+    def initialize
+      @verbs = Set.new
+      @tools = []
+    end
+
     def types(hash)
       @types = hash
     end
@@ -276,8 +343,10 @@ module Ruleset
       @classifiers = hash
     end
 
-    def rules(hash)
-      @rules = hash
+    def tool(name, &block)
+      builder = ToolBuilder.new(name)
+      builder.instance_eval(&block)
+      @tools << builder.build
     end
 
     def verbs(list)
@@ -286,12 +355,18 @@ module Ruleset
 
     def literal_patterns    = @types
     def connective_patterns = @connectives
-    def rule_patterns       = @rules
-    def verb_patterns       = @verbs
+    def tool_patterns       = @tools
+
+    # Every rule's leading word is a verb by construction (ToolBuilder
+    # rejects a rule that opens on a :slot), plus any verbs() declared for
+    # words not yet backed by a tool - so chaining still recognizes them.
+    def verb_patterns
+      @verbs | @tools.flat_map { |t| t.rules.map { |r| r.split.first } }
+    end
 
     # :action phrases come from verbs, not the classifiers() call, so the
     # two lists can't drift apart.
-    def classifier_patterns = @classifiers.merge(action: @verbs.to_a.map { |v| [v] })
+    def classifier_patterns = @classifiers.merge(action: verb_patterns.to_a.map { |v| [v] })
   end
 
   def self.create(name, &block)
@@ -338,16 +413,45 @@ builtins = Ruleset.create("builtins") do
     # action: derived from verbs, below.
   )
 
-  rules(
-    "delete :target"  => "delete",
-    "remove :target"  => "delete",
-    "archive :target" => "archive",
-    "copy :target to :destination"    => "copy",
-    "move :target"    => "move",
-  )
+  tool("delete") do
+    rule "delete :target"
+    rule "remove :target"
+
+    permission :ask
+
+    action do |target:|
+      puts "Deleting #{target}"
+    end
+  end
+
+  tool("archive") do
+    rule "archive :target"
+
+    action do |target:|
+      puts "Archiving #{target}"
+    end
+  end
+
+  tool("copy-from-to") do
+    rule "copy :target to :destination"
 
-  # Verbs recognized by connective_length's bare "and <verb>" check.
-  verbs %w[run delete remove copy move create rename archive search find locate pick transfer]
+    action do |target:, destination:|
+      puts "Copying from #{target} to #{destination}"
+    end
+  end
+
+  tool("move") do
+    rule "move :target"
+
+    permission :ask
+
+    action do |target:|
+      puts "Moving #{target}"
+    end
+  end
+
+  # Verbs not yet backed by a tool; "and <verb>" chaining still recognizes them.
+  verbs %w[run create rename search find locate pick transfer]
 end
 
 Lexer.configure(builtins)
@@ -379,6 +483,16 @@ loop do
 
     node = Tier1.lookup(clause.tokens)
     puts "   #{node ? "tier1 hit: #{node}" : 'tier1 miss'}"
+    next unless node
+
+    tool = node[:tool]
+    # :ask tools must be confirmed before running; :yolo tools run immediately.
+    if tool.permission == :ask
+      print "   run #{tool.name}? [y/N] "
+      next unless gets&.chomp&.downcase == "y"
+    end
+
+    tool.action.call(**node[:args].transform_values(&:text))
   end
   puts "match: #{Lexer.match_text(utterance.clauses.flat_map(&:tokens))}"