From 37ba8303133077a63fd5b2dcdb9ceed6e98eaf5d Mon Sep 17 00:00:00 2001 From: Mike Lowis Date: Wed, 30 Sep 2026 23:07:09 -0400 Subject: [PATCH] added tools concept --- nlshell.rb | 152 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 133 insertions(+), 19 deletions(-) diff --git a/nlshell.rb b/nlshell.rb index 8cf64f2..9dff994 100755 --- a/nlshell.rb +++ b/nlshell.rb @@ -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 " 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 " 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))}" -- 2.55.0