LEX --> VAL{Unbalanced quote?}
VAL -->|yes| REJ[Reject, ask to close quote]
VAL -->|no| SEG[Sentences then clauses - SS 5]
- SEG --> CLS[Classifier - regex/keyword, per clause]
- CLS -->|action/chain| AN[ActionRecorder path]
- CLS -->|loop/conditional| LN[Tree node path]
- CLS -->|query| QN[Query path]
- CLS -->|teach| TN[Teach path]
- AN & LN --> T1Try[Tier 1 lookup]
+ SEG --> T1Try[Tier 1 lookup]
T1Try -->|miss| T2Try[Tier 2 fuzzy match]
T2Try -->|miss| T3Call[Tier 3: schema-constrained call]
T3Call --> Validate[JSON Schema validate]
- Splits a sentence into clauses on connective token sequences declared via `connectives([...])` (builtins: `["after", "that"]`, `["and", "then"]`, `["then"]`, `["next"]`).
- A bare `and` **without** a preceding comma is ambiguous ("copy a and b to c" is one command). The code only splits on it when the token right after `and` (or `, and`) is in the ruleset's `verb_patterns` set (a known verb word) **or** is a `:command` token (a recognized `$PATH` executable, e.g. "delete a.txt and ls") — not "post-canonicalization" since Tier 2 canonicalization doesn't exist yet; otherwise it's treated as a list argument within one clause.
-- Each resulting clause is classified independently (§7 node types) via `Classifier.classify`.
+- Each resulting clause is resolved independently via the same Tier 1→Tier 2(→Tier 3) pipeline as any other clause (§6.1) — there's no separate classification step; a `query`/`conditional`/`loop` phrase is just a tool whose rule happens to match it, same as any `action`. §7's node `type` comes from whichever tool resolves it, not from a pre-tagged clause kind.
-**[A] still open, not yet implemented:** the REPL currently classifies and executes each clause as it's reached, rather than resolving the whole Utterance first. The plan-then-execute policy below remains a v-next item:
-
-- For a multi-clause Utterance: resolve every clause first (may hit Tier 3 per clause), assemble the full plan, show it once, then execute. Query and confirmed-teach clauses may run immediately since they don't mutate state; action clauses wait for the full plan to validate. If any clause fails to resolve, execute nothing.
+**Partially implemented:** `Utterance.build` now resolves every clause first and executes nothing if any clause fails to resolve, closing the core gap this note used to describe. Still open: the query/confirmed-teach-clauses-may-run-immediately refinement, and Tier 3 escalation per clause (Tier 3 doesn't exist yet).
### 5.6 Fragments and the context register **[A]**
**Steps**
1. **Normalize:** lowercase, strip filler words ("please", "the", "my"), split into tokens.
+
+ **Proposal (not yet implemented):** derive the filler list from a standard English stop-word list minus whatever words appear literally in the current rule set (`STOP_WORDS - vocabulary`), recomputed whenever rules are reindexed, instead of hand-maintaining a fixed list. This generalizes past the handful of words anyone's thought to add, and self-updates as rules change, but only protects words that collide with *current* rule vocabulary — it does not protect words reserved for future use. The seed `STOP_WORDS` list must still exclude pronouns ("it", "them") and negation ("not", "no") up front, since §5.6's planned pronoun/ellipsis resolution needs those to survive normalization rather than be discarded here.
+
2. **Typo correction:** for each token not in the known vocabulary (rule words + synonym terms), replace it with the nearest vocabulary word if edit distance ≤ 2 (`text` gem, Jaro-Winkler or Levenshtein). Leave the token alone if nothing is close.
3. **Synonym canonicalization:** rewrite each token to its canonical form using the `synonyms` table, e.g. `wipe → delete`. Both the input and the indexed rule patterns are canonicalized the same way, so they meet in the middle.
4. **Score:** compare the canonicalized input against every rule's canonicalized trigger phrase.
---
+### 6.5 Control-flow tools (proposal, not yet implemented)
+
+Collapses the former `Classifier` pre-filter for `conditional`/`loop` into the same `Ruleset` tool + Tier 1/2/3 mechanism every other action uses, instead of a separate node kind with its own bespoke matching. `query` already folds in this way with no further changes needed — a query is just a tool whose action looks something up instead of running a destructive command. Loops/conditionals need actual new capability, scoped below:
+
+1. **DSL:** `ToolBuilder` gains a way to mark a slot as a *body* rather than a leaf value — e.g. `rule "if :condition then :body"` declares `:body` as recursive (a `body_slots :body` call, or a naming convention) — so `build` stops requiring every slot to bind one flat keyword to one leaf token.
+2. **Extraction:** a body slot consumes *all* leftover tokens after the pattern's literal words are matched, not one leftover token per slot like today's `Tier1.match`/`Tier2.extract_slots` — a different extraction rule, specific to body slots.
+3. **Recursive resolution:** the body span's tokens are re-split via `Lexer.clauses` and each resulting clause re-resolved via the same `Utterance.resolve` (Tier1→Tier2→Tier3) used for top-level clauses, producing a nested list of nodes — reusing the one pipeline rather than standing up a parallel one.
+4. **Condition parsing:** `:condition` becomes a predicate struct (§7's `equals|contains|exists|greater_than|matches_pattern`, `left`/`right` as `$ref`/literal), not a leaf token — a parsing step beyond today's slot binding, needed regardless of the DSL change above.
+5. **Executor:** `Executor.call` currently flattens every arg via `.text`; it needs a branch so a `:body`/`:then`/`:else` arg executes its nested nodes recursively instead of being passed as a string.
+6. **Limits:** the depth cap (§7, 3) and loop iteration cap (§10, 20) must be enforced in this new recursive path, not just documented.
+
+Suggested first slice: a single `conditional` tool, a flat `:body` of one action, no `else`, no loops — validates the DSL/extraction/executor extensions before generalizing to full nesting and loops.
+
+---
+
## 7. Node Schemas (JSON Schema, abbreviated)
```jsonc
end
end
-# Stage: CLS. Tags a clause as conditional/loop/query/action by matching its
-# leading words against configured phrases, checked in the configured order
-# (first match wins). teach/remember aren't a kind - they're builtin actions.
-module Classifier
- class << self
- attr_accessor :patterns
- end
-
- def self.configure(ruleset)
- self.patterns = ruleset.classifier_patterns
- end
-
- # Returns the matched kind, or nil if no configured phrase matches.
- # TODO: fall through to Tier 3 (LLM) instead of nil once that tier exists.
- def self.classify(clause)
- patterns.each do |kind, phrases|
- return kind if phrases.any? { |phrase| leading_match?(clause, phrase) }
- end
- nil
- end
-
- def self.leading_match?(clause, phrase)
- return false if clause.length < phrase.length
-
- clause.first(phrase.length).each_with_index.all? { |t, i| t.word? && Fuzzy.close?(t.text, phrase[i]) }
- end
-end
-
# Stage: Tier 1. Exact/template match against configured rule patterns. A
# pattern is a string of literal words and :slot placeholders, matched
# word-for-word against a clause (same length, no fuzzing - that's Tier 2).
+# A trailing :slot the tool declared via body_slots is the one exception:
+# it claims every remaining token as a nested clause, not one token.
module Tier1
class << self
attr_accessor :rules
# Returns an action node (Hash) on an exact match, or nil on a miss.
def self.lookup(clause)
rules.each do |pattern, tool|
- args = match(pattern.split, clause)
+ args = match(pattern.split, clause, tool.body_slots)
return { type: "action", name: tool.name, args: args, tool: tool } if args
end
nil
end
- def self.match(words, clause)
- return nil unless words.length == clause.length
+ def self.match(words, clause, body_slots = Set.new)
+ last = words.last
+ if last&.start_with?(":") && body_slots.include?(last.delete_prefix(":").to_sym)
+ match_with_body(words, clause, last)
+ else
+ return nil unless words.length == clause.length
+
+ match_literals(words, clause)
+ end
+ end
+
+ # Matches the pattern's fixed-length head normally, then hands every
+ # leftover token to the same Tier 1/2 resolution a top-level clause gets -
+ # so a conditional's body is just another resolved node, not a raw token.
+ def self.match_with_body(words, clause, body_word)
+ head = words[0..-2]
+ return nil if clause.length <= head.length # need at least one token for the body
+ args = match_literals(head, clause.first(head.length))
+ return nil unless args
+
+ body = Utterance.resolve(clause[head.length..])
+ return nil unless body.node
+
+ args[body_word.delete_prefix(":").to_sym] = body.node
+ args
+ end
+
+ def self.match_literals(words, clause)
args = {}
words.each_with_index do |word, i|
token = clause[i]
end
end
-# Tier 2 canonicalization store (SS6.4 step 3): term -> canonical word. Tier 2
-# itself isn't built yet; "remember" populates this for when it is.
+# Tier 2 canonicalization store (SS6.4 step 3): term -> canonical word,
+# applied to both the query and rule vocabulary before Tier2 scores them.
+# "remember" populates this at runtime.
module Synonyms
class << self
attr_accessor :terms
end
end
+# Stage: Tier 2 (SS6.4). Bag-of-words + TF-IDF cosine fallback for a Tier 1
+# miss - tolerates reordered/extra/filler words that Tier 1's strict,
+# same-length positional match can't. Runs only after Tier1.lookup misses.
+module Tier2
+ # Carry no matching signal of their own; stripped before scoring.
+ FILLER_WORDS = %w[please the a an my your].freeze
+
+ # SS6.4 decision thresholds, collapsed to accept/reject: there's no Tier 3
+ # yet to escalate a medium-confidence score to.
+ ACCEPT_SCORE = 0.75
+ ACCEPT_LEAD = 0.15
+
+ # Max edit distance for correcting a query word toward a known vocabulary
+ # word before scoring (SS6.4 step 2).
+ TYPO_MAX_DISTANCE = 2
+
+ class << self
+ attr_accessor :rules, :vocabulary, :idf, :vectors
+ end
+
+ def self.configure(ruleset)
+ self.rules = ruleset.rule_index
+ reindex
+ end
+
+ # Rebuilds vocabulary/idf/vectors from the current rule set. Call again
+ # after a rule is added at runtime (teach uses the same shared rules Hash).
+ def self.reindex
+ docs = rules.keys.to_h { |pattern| [pattern, literal_words(pattern)] }
+ self.vocabulary = docs.values.flatten.uniq
+ self.idf = compute_idf(docs.values)
+ self.vectors = docs.transform_values { |words| vectorize(words) }
+ end
+
+ # Returns an action node Hash (same shape as Tier1.lookup), or nil if
+ # nothing scores high enough, or slot extraction can't bind cleanly.
+ def self.lookup(clause)
+ words = normalize(clause)
+ return nil if words.empty?
+
+ query = vectorize(words)
+ scored = vectors.map { |pattern, vector| [pattern, cosine(query, vector)] }.sort_by { |_, score| -score }
+ return nil unless accept?(scored)
+
+ pattern, = scored.first
+ tool = rules[pattern]
+ args = extract_slots(pattern, clause)
+ return nil unless args
+
+ { type: "action", name: tool.name, args: args, tool: tool }
+ end
+
+ # Top score must clear the floor AND lead the runner-up by a margin - a
+ # close second choice means the match is too ambiguous to auto-accept.
+ def self.accept?(scored)
+ return false if scored.empty?
+
+ top = scored[0][1]
+ runner_up = scored[1]&.last || 0
+ top >= ACCEPT_SCORE && (top - runner_up) >= ACCEPT_LEAD
+ end
+
+ # Clause tokens -> normalized bag-of-words: literal tokens contribute their
+ # kind (Token#key, so different filenames score identically), filler words
+ # and punctuation are dropped, everything else is typo-corrected then
+ # synonym-canonicalized.
+ def self.normalize(clause)
+ clause.reject(&:punct?)
+ .map(&:key)
+ .reject { |w| FILLER_WORDS.include?(w) }
+ .map { |w| Synonyms.canonical(correct(w)) }
+ end
+
+ # Nearest known vocabulary word within TYPO_MAX_DISTANCE, or the word
+ # itself if nothing is close enough to count as a typo of it.
+ def self.correct(word)
+ return word if vocabulary.include?(word)
+
+ nearest = vocabulary.min_by { |v| Fuzzy.distance(word, v) }
+ return word unless nearest && Fuzzy.distance(word, nearest) <= TYPO_MAX_DISTANCE
+
+ nearest
+ end
+
+ def self.literal_words(pattern)
+ pattern.split.reject { |w| w.start_with?(":") }
+ end
+
+ # Smoothed IDF (as in scikit-learn): +1 inside the log avoids a zero/div-by-
+ # zero for a term in every document; +1 outside keeps every term's weight
+ # positive even if it appears in all of them.
+ def self.compute_idf(docs)
+ total = docs.length
+ vocabulary.to_h do |term|
+ doc_freq = docs.count { |words| words.include?(term) }
+ [term, Math.log((total + 1).fdiv(doc_freq + 1)) + 1]
+ end
+ end
+
+ def self.vectorize(words)
+ counts = words.each_with_object(Hash.new(0)) { |w, h| h[w] += 1 }
+ counts.each_with_object({}) { |(term, count), v| v[term] = count * idf.fetch(term, 0) }
+ end
+
+ def self.cosine(a, b)
+ dot = a.sum { |term, weight| weight * b.fetch(term, 0) }
+ mag_a = Math.sqrt(a.values.sum { |w| w**2 })
+ mag_b = Math.sqrt(b.values.sum { |w| w**2 })
+ return 0.0 if mag_a.zero? || mag_b.zero?
+
+ dot / (mag_a * mag_b)
+ end
+
+ # SS6.4 step 6: tokens not consumed matching a literal pattern word fill
+ # the pattern's :slots, in the order both appear. Returns nil if the
+ # leftover count doesn't match the slot count - a high-scoring pattern
+ # that can't bind cleanly isn't a usable match.
+ def self.extract_slots(pattern, clause)
+ literals = literal_words(pattern)
+ slots = pattern.split.select { |w| w.start_with?(":") }.map { |w| w.delete_prefix(":").to_sym }
+
+ leftovers = []
+ # Each non-filler clause token either consumes one remaining literal
+ # pattern word (fuzzy match, synonym-aware) or becomes a candidate slot
+ # value; filler words carry no matching signal and are dropped outright.
+ clause.reject(&:punct?).each do |token|
+ next if FILLER_WORDS.include?(token.key)
+
+ index = literals.find_index { |word| token.word? && Fuzzy.close?(Synonyms.canonical(token.text), word) }
+ if index
+ literals.delete_at(index)
+ else
+ leftovers << token
+ end
+ end
+
+ return nil unless leftovers.length == slots.length
+
+ slots.zip(leftovers).to_h
+ end
+end
+
# Stage: EX. Runs a resolved tool's action, isolating the REPL from any
# exception an action raises so one bad command can't crash the shell.
module Executor
+ # Only a Token needs flattening to its raw text - a :body/:then/:else arg
+ # is already a resolved node Hash, meant for the action to execute itself.
def self.call(tool, args)
- tool.action.call(**args.transform_values(&:text))
+ tool.action.call(**args.transform_values { |v| v.is_a?(Lexer::Token) ? v.text : v })
true
rescue StandardError => e
puts " error running #{tool.name}: #{e.message}"
false
end
+
+ # Confirms first for an :ask tool, same policy a top-level clause gets - the
+ # one gate between "resolved" and "actually runs", so a conditional's body
+ # can't bypass permission checking.
+ def self.run(node)
+ tool = node[:tool]
+ if tool.permission == :ask
+ print " run #{tool.name}? [y/N] "
+ return false unless gets&.chomp&.downcase == "y"
+ end
+
+ call(tool, node[:args])
+ end
end
# Stage 0 result (spec SS5.7). Everything downstream (Tier 1/2/3, executor)
-# operates on this, never on raw text or loose token/clause arrays.
+# operates on this, never on raw text or loose token/clause arrays. A clause's
+# kind is no longer tagged up front by a separate classifier - every clause
+# is resolved directly through the same Tier 1/2(/3) pipeline, so node/tier
+# are nil together on a miss.
module Utterance
- Clause = Struct.new(:tokens, :kind)
+ Clause = Struct.new(:tokens, :node, :tier)
Result = Struct.new(:raw, :clauses)
# SS5.8 limits.
clauses = sentences.flat_map { |sentence| Lexer.clauses(sentence) }
raise Rejected, "that's too many clauses in one request, please split it up" if clauses.length > MAX_CLAUSES
- # Classification/Tier 1 tolerate small typos on their own (Fuzzy.close?
- # inside Classifier.leading_match? and Tier1.match), so no separate
- # correction pass is needed here.
- Result.new(raw, clauses.map { |tokens| Clause.new(tokens, Classifier.classify(tokens)) })
+ # Tier 1/2 tolerate small typos on their own (Fuzzy.close? inside
+ # Tier1.match and Tier2's typo correction), so no separate pass is needed.
+ Result.new(raw, clauses.map { |tokens| resolve(tokens) })
+ end
+
+ # Tries Tier 1 first (strict positional match), falling back to Tier 2
+ # (fuzzy bag-of-words) only on a Tier 1 miss - tags which tier resolved it
+ # so the REPL can report that, or nil/nil if neither did.
+ def self.resolve(tokens)
+ if (node = Tier1.lookup(tokens))
+ Clause.new(tokens, node, :tier1)
+ elsif (node = Tier2.lookup(tokens))
+ Clause.new(tokens, node, :tier2)
+ else
+ Clause.new(tokens, nil, nil)
+ end
end
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)
+ # runs it, whether running it needs user confirmation first, and which
+ # slot names (if any) are bodies - see ToolBuilder#body_slots.
+ Tool = Struct.new(:name, :rules, :action, :permission, :body_slots)
# A pattern's :slot names (the "target" in "copy :target to :destination").
def self.slot_names(pattern)
@verb_synonyms = []
@permission = :ask # fail-safe default: a tool must opt into :yolo
@action = nil
+ @body_slots = Set.new
end
def rule(pattern)
@permission = level
end
+ # Declares which of this tool's :slots are bodies: a nested clause
+ # (resolved through the same Tier 1/2 pipeline as any top-level clause),
+ # not a single leaf token. Must be the last slot in any rule that uses it
+ # - Tier1.match can only claim a trailing run of tokens, not a middle one.
+ def body_slots(names)
+ @body_slots.merge(names)
+ end
+
def action(&block)
@action = block
end
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)
+ validate_body_slots(patterns)
+ Tool.new(@name, patterns, @action, @permission, @body_slots)
end
private
def required_keywords
@action.parameters.select { |type, _| type == :keyreq }.map { |_, name| name }.to_set
end
+
+ # A body slot not in trailing position would silently never match -
+ # Tier1.match_with_body only ever claims a trailing run of tokens.
+ def validate_body_slots(patterns)
+ patterns.each do |pattern|
+ words = pattern.split
+ @body_slots.each do |slot|
+ next unless words.include?(":#{slot}")
+
+ raise ArgumentError, "tool #{@name.inspect}: body slot :#{slot} must be last in #{pattern.inspect}" unless words.last == ":#{slot}"
+ end
+ end
+ end
end
class RulesModule
@connectives = list
end
- def classifiers(hash)
- @classifiers = hash
- end
-
def tool(name, &block)
builder = ToolBuilder.new(name)
builder.instance_eval(&block)
Set.new(@rules.keys.map { |pattern| pattern.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: verb_patterns.to_a.map { |v| [v] })
-
private
# Last tool to claim a rule pattern wins; shadowing is never silent
["next"],
]
- # Checked in this order; first matching kind wins. teach/remember are not a
- # kind of their own - they're builtin tools, classified as :action like any
- # other verb (see the "teach"/"remember" tools below).
- classifiers(
- conditional: [
- ["if"], ["unless"], ["when"]
- ],
- loop: [
- ["for", "each"], ["repeat"], ["while"], ["every"]
- ],
- query: [
- ["what"], ["how"], ["why"], ["who"], ["where"], ["is"], ["are"], ["does"], ["list"], ["show"]
- ],
- # action: derived from verbs, below.
- )
-
tool("delete") do
permission :ask
end
end
+ # "then" is already a clause-splitting connective (SS5.5), so it can't also
+ # be a literal word here - "do" joins condition and body instead.
+ tool("conditional") do
+ permission :yolo # never destructive itself; the body's own permission gates that, via Executor.run
+
+ rule "if :target exists do :body"
+ body_slots %i[body]
+
+ action do |target:, body:|
+ if File.exist?(target)
+ Executor.run(body)
+ else
+ puts " condition false, skipping"
+ end
+ end
+ end
+
tool("teach") do
permission :ask
next
end
- Classifier.configure(builtins) # refresh so the new leading word classifies as :action
+ Tier2.reindex # new pattern's literal words join Tier 2's vocabulary
puts " learned: #{trigger.inspect} -> #{tool.name}"
end
end
end
Lexer.configure(builtins)
-Classifier.configure(builtins)
Tier1.configure(builtins)
+Tier2.configure(builtins)
# Only run the REPL when executed directly, so tests can require this file
# for its modules/ruleset without blocking on stdin.
end
next if utterance.nil? # empty/punctuation-only input: no-op
- if utterance.clauses.any? { |clause| clause.kind.nil? }
+ # Every clause must resolve before any of them run - a later clause
+ # failing shouldn't leave an earlier one already executed.
+ if utterance.clauses.any? { |clause| clause.node.nil? }
puts "couldn't tell what kind of request this is, please rephrase"
next
end
utterance.clauses.each_with_index do |clause, j|
- puts "clause #{j} [#{clause.kind}]: #{clause.tokens.map(&:to_s).join(' ')}"
- next unless clause.kind == :action
-
- 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
+ puts "clause #{j}: #{clause.tokens.map(&:to_s).join(' ')}"
+ puts " #{clause.tier} hit: #{clause.node}"
- Executor.call(tool, node[:args])
+ Executor.run(clause.node)
end
puts "match: #{Lexer.match_text(utterance.clauses.flat_map(&:tokens))}"
end
end
end
-class ClassifierTest < Minitest::Test
- def test_classifies_known_verb_as_action
- tokens = Lexer.downcase_words(Lexer.tokenize("delete old.log"))
- assert_equal :action, Classifier.classify(tokens)
+class UtteranceTest < Minitest::Test
+ def test_resolves_a_known_verb_via_tier1
+ clause = Utterance.build("delete old.log").clauses.first
+ assert_equal :tier1, clause.tier
+ assert_equal "delete", clause.node[:name]
end
- def test_classifies_query_keyword
- tokens = Lexer.downcase_words(Lexer.tokenize("what is the time"))
- assert_equal :query, Classifier.classify(tokens)
+ # Regression: query/conditional/loop keywords no longer get a free pass
+ # through a separate classifier - with no tool registered for "what", this
+ # must fail to resolve exactly like any other unmatched verb.
+ def test_unmatched_leading_word_leaves_the_clause_unresolved
+ clause = Utterance.build("what is the time").clauses.first
+ assert_nil clause.node
+ assert_nil clause.tier
end
- def test_unknown_leading_word_is_unclassified
- tokens = Lexer.downcase_words(Lexer.tokenize("bork the garble"))
- assert_nil Classifier.classify(tokens)
+ def test_unknown_verb_leaves_the_clause_unresolved
+ clause = Utterance.build("bork the garble").clauses.first
+ assert_nil clause.node
+ assert_nil clause.tier
end
end
node = Tier1.lookup(tokens)
assert_equal "delete", node[:name]
end
+
+ # A :body slot must claim every remaining token as a nested resolved node,
+ # not bind to exactly one token like an ordinary :slot.
+ def test_lookup_resolves_a_body_slot_as_a_nested_node
+ tokens = Lexer.downcase_words(Lexer.tokenize("if nlshell.rb exists do archive nlshell.rb"))
+ node = Tier1.lookup(tokens)
+ refute_nil node
+ assert_equal "conditional", node[:name]
+ assert_equal "archive", node[:args][:body][:name]
+ end
+
+ # Not enough tokens left for the body slot to bind anything - a miss, not
+ # a crash.
+ def test_lookup_misses_when_body_slot_has_nothing_to_bind
+ tokens = Lexer.downcase_words(Lexer.tokenize("if nlshell.rb exists do"))
+ assert_nil Tier1.lookup(tokens)
+ end
+end
+
+class Tier2Test < Minitest::Test
+ # A filler word breaks Tier 1's same-length positional match, but Tier 2's
+ # bag-of-words score should still resolve it.
+ def test_lookup_resolves_past_a_filler_word_tier1_misses_on
+ tokens = Lexer.downcase_words(Lexer.tokenize("delete the old.log"))
+ assert_nil Tier1.lookup(tokens)
+
+ node = Tier2.lookup(tokens)
+ refute_nil node
+ assert_equal "delete", node[:name]
+ assert_equal "old.log", node[:args][:target].text
+ end
+
+ def test_lookup_rejects_unrelated_input
+ tokens = Lexer.downcase_words(Lexer.tokenize("what time is it"))
+ assert_nil Tier2.lookup(tokens)
+ end
+
+ # Regression: extract_slots must drop filler words from the leftover count,
+ # not just from the score-time bag-of-words, or a real match's slot count
+ # comes out wrong and gets rejected.
+ def test_extract_slots_ignores_filler_words
+ tokens = Lexer.downcase_words(Lexer.tokenize("delete the old.log"))
+ args = Tier2.extract_slots("delete :target", tokens)
+ assert_equal({ target: tokens.last }, args)
+ end
+
+ def test_extract_slots_rejects_a_leftover_count_mismatch
+ tokens = Lexer.downcase_words(Lexer.tokenize("delete old.log now"))
+ assert_nil Tier2.extract_slots("delete :target", tokens)
+ end
end
class RulesetTest < Minitest::Test
end
class ExecutorTest < Minitest::Test
- Tool = Struct.new(:name, :action)
+ Tool = Struct.new(:name, :action, :permission)
def test_returns_true_on_success
tool = Tool.new("noop", ->(target:) { target })
refute Executor.call(tool, { target: token })
end
+
+ # Regression: a :body arg (a resolved node Hash, not a Token) must reach
+ # the action unchanged - only Token args get flattened to their raw text.
+ def test_call_passes_non_token_args_through_unchanged
+ captured = nil
+ tool = Tool.new("wrap", ->(body:) { captured = body })
+ node = { type: "action", name: "noop" }
+
+ Executor.call(tool, { body: node })
+ assert_same node, captured
+ end
+
+ # :yolo tools run immediately - no stdin prompt, so this can't hang a test.
+ def test_run_executes_a_yolo_tool_without_confirmation
+ tool = Tool.new("noop", ->(target:) { target }, :yolo)
+ token = Lexer::Token.new(:literal, "old.log", :file)
+
+ assert Executor.run({ tool: tool, args: { target: token } })
+ end
end
# End-to-end smoke tests driving the actual REPL process over stdin/stdout.
out = run_repl(%(delete old.log\nn\n))
refute_match(/Deleting old\.log/, out)
end
+
+ def test_unresolvable_input_reports_couldnt_tell
+ out = run_repl(%(bork the garble\n))
+ assert_match(/couldn't tell what kind of request this is/, out)
+ end
+
+ def test_conditional_runs_body_when_condition_is_true
+ out = run_repl(%(if nlshell.rb exists do archive nlshell.rb\n))
+ assert_match(/Archiving nlshell\.rb/, out)
+ end
+
+ def test_conditional_skips_body_when_condition_is_false
+ out = run_repl(%(if nope-definitely-missing.log exists do archive nope-definitely-missing.log\n))
+ refute_match(/Archiving/, out)
+ end
+
+ # The body's own permission still gates it - a conditional can't be used to
+ # bypass confirmation on a destructive action.
+ def test_conditional_body_still_requires_its_own_confirmation
+ out = run_repl(%(if nlshell.rb exists do delete nlshell.rb\nn\n))
+ refute_match(/Deleting/, out)
+ end
end