feat(parser): emit verb-target-prep on 'with'/'on'/'in'/'to' separators

Enables `light lamp with matches`, `use shears on vines`, and similar
multi-noun forms. Both the target and indirect noun must resolve;
otherwise the command falls back to unknown-noun.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 13:56:19 -05:00
parent 46f851bc3a
commit b318747840
2 changed files with 102 additions and 0 deletions
+45
View File
@@ -62,6 +62,24 @@ const TWO_WORD_VERBS = ['pick up']
/** Leading stop-words stripped from the noun phrase before matching. */
const STOP_WORDS = new Set(['at', 'the', 'a', 'an'])
const PREPOSITIONS = new Set(['with', 'on', 'in', 'to'])
function resolveNoun(rawTokens: string[], ctx: ParserContext): { id: string; alias: string } | null {
const phrase = rawTokens.join(' ')
if (phrase === 'it' && ctx.lastNoun) {
return { id: ctx.lastNoun.canonical, alias: 'it' }
}
for (const noun of ctx.visibleNouns) {
for (const alias of noun.aliases) {
if (alias === phrase) return { id: noun.id, alias }
}
}
for (const itemId of ctx.inventoryItemIds) {
if (itemId === phrase) return { id: itemId, alias: phrase }
}
return null
}
function tokenize(input: string): string[] {
return input.trim().toLowerCase().split(/\s+/).filter(Boolean)
}
@@ -141,6 +159,33 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
return { kind: 'unknown', raw: trimmed, reason: 'malformed' }
}
// Detect a preposition splitting target | indirect.
const prepIdx = rest.findIndex((tok) => PREPOSITIONS.has(tok))
if (prepIdx > 0 && prepIdx < rest.length - 1) {
const targetTokens = rest.slice(0, prepIdx)
const prep = rest[prepIdx]!
let indirectTokens = rest.slice(prepIdx + 1)
// Strip stop-words at the head of the indirect phrase too ("on the table").
while (indirectTokens.length > 0 && STOP_WORDS.has(indirectTokens[0]!)) {
indirectTokens = indirectTokens.slice(1)
}
if (indirectTokens.length > 0) {
const target = resolveNoun(targetTokens, ctx)
const indirect = resolveNoun(indirectTokens, ctx)
if (target && indirect) {
return {
kind: 'verb-target-prep',
verb,
target: { canonical: target.id, raw: target.alias },
preposition: prep,
indirect: { canonical: indirect.id, raw: indirect.alias },
}
}
// Either side failed to resolve → fall through to unknown-noun below.
return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' }
}
}
// Pronoun resolution: "it" maps to lastNoun.
if (rest.length === 1 && rest[0] === 'it') {
if (!ctx.lastNoun) {