Skip to content

Semantic Resolver (macsemantic)

macsemantic is a Python package that Indigo installs alongside its other bundled packages. Given a natural-language phrase and a list of named things, it returns the ones the phrase most likely refers to — for example, "I can't see what I'm chopping" to Kitchen Lights. It is the engine behind the MCP find_* tools, and any script or plugin can import it.

from macsemantic import SemanticResolver

resolver = SemanticResolver(warmup=False)
candidates = [{"id": dev.id, "name": dev.name} for dev in indigo.devices]
result = resolver.resolve("the porch light", candidates, domain=indigo.utils.SEMANTIC_DOMAIN_DEVICES)
if not result["no_match"]:
    dev = indigo.devices[result["results"][0]["id"]]

For a walkthrough — how it decides, how to use it from a script or a plugin, and more examples — see macsemantic in the Python Packages guide.

Results depend on the Mac

With Apple Intelligence available, phrases are matched by meaning using Apple's on-device model. On every other Mac, including Intel Macs, the same calls work but match on shared words only: "the porch light" still finds Porch Light, but "I can't see what I'm chopping" won't find Kitchen Lights. See Requirements for what "available" takes.

Requirements

macsemantic works on every Mac Indigo runs on, and nothing fails when a requirement below is missing. What changes is how well it understands a phrase. Matching by meaning uses Apple's on-device model, and the model can be used only when all of these are true:

  • The Mac supports Apple Intelligence: any Mac with M1 or later, or a MacBook Neo (A18 Pro). Intel Macs never have the model.
  • It runs macOS 26 or later.
  • Apple Intelligence is turned on. On macOS 26 that is in System Settings → Apple Intelligence & Siri. On macOS 27 there is no separate pane: it's in System Settings → Siri, which says "Powered by Apple Intelligence" once it's on.
  • The language is supported, and the Mac and Siri agree on it. The first language in System Settings → General → Language & Region and Siri's language must both be ones Apple Intelligence supports, and they must match. If they don't, macOS hides the Apple Intelligence settings altogether, so the cause is easy to miss: removing a second English variant (English (Australia) under English (US), for example) can be all it takes.
  • The model has finished downloading. After Apple Intelligence is first turned on, macOS downloads the model in the background. It is several gigabytes, and until it finishes matching is by words only.

When any of these is missing, every lookup matches on shared words. A phrase that uses the words in the name usually still finds it: "the porch light" finds Porch Light. An indirect one doesn't: "it's too warm in here" can't find a thermostat named Ecobee, and returns no match or a wrong answer. Descriptive names help most on these Macs.

To check a Mac, read fm_available: true means phrases are matched by meaning. From the Indigo Script Editor:

from macsemantic import SemanticResolver
indigo.server.log(f"Matching by meaning: {SemanticResolver(warmup=False).fm_available}")

Each result also says how it was answered, in its tier: "lexical" means the model wasn't available. The MCP find_* tools pass the tier on, so a chat assistant can tell you which kind of matching answered a search.

SemanticResolver

Loads the resolver once. Create one and keep it — in a plugin, create it at startup; in a script, once per run. One resolver serves every kind of lookup: the domain goes on each resolve() call, so a plugin that looks up devices, action groups and variables still needs only one.

Constructor

SemanticResolver(dylib_path=None, *, model=None, warmup=True)

Parameter Required Type Description
dylib_path No string Path to a specific libmacsemantic.dylib. Leave it unset to use the copy inside the package.
model No string The default model for this resolver. None means MODEL_SYSTEM. Raises ModelUnavailableError at once if this Mac can't use it.
warmup No boolean If true (the default), preloads the model on a background thread so the first resolve() doesn't pay a ~0.4 second cold start. Pass False in a script that resolves once and exits.

The constructor never raises because the resolver failed to load. It sets available to False and load_error to the reason, and resolve() raises instead, so a plugin can start normally and report the problem when it matters.

Properties

All properties are read-only.

Property Type Description
available boolean true if the resolver loaded. A Mac without Apple Intelligence is still available — it matches on words.
load_error string or None why the resolver didn't load, or None if it did
fm_available boolean true if Apple's on-device model can be used on this Mac, so phrases are matched by meaning
supported_models frozenset the models usable on this Mac right now; empty when fm_available is false
capability_notes dict for a model the resolver knows but can't use here, the reason, keyed by model name
default_model string the model used when resolve() isn't given one

Commands (SemanticResolver.*)

Resolve

Ranks the candidates against the query and returns a result dictionary. Every id in the result is one you supplied.

The call blocks while the model runs, typically about half a second. That is fine in a script or a plugin action; in asyncio code, run it in an executor so it doesn't stall the event loop:

from functools import partial

result = await loop.run_in_executor(
    None, partial(resolver.resolve, query, candidates, domain=indigo.utils.SEMANTIC_DOMAIN_DEVICES))

run_in_executor only passes positional arguments, and domain is keyword-only, hence functools.partial.

Command Syntax Examples

resolver.resolve("make it warmer upstairs", candidates, domain=indigo.utils.SEMANTIC_DOMAIN_DEVICES)

resolver.resolve("time for bed", candidates, top_n=3, domain=indigo.utils.SEMANTIC_DOMAIN_ACTION_GROUPS)

Parameters

Parameter Required Type Description
query Yes string the natural-language phrase to resolve
candidates Yes list a list of candidate dictionaries
top_n No integer the most results to return (defaults to 5)
model No string overrides default_model for this call
domain Yes string or dict what the candidates are — an indigo.utils.SEMANTIC_DOMAIN_* constant, a description, or templates; see Domain. Keyword-only.

Raises ValueError if domain is empty or its templates are incomplete (leaving it out entirely raises TypeError), ResolverUnavailableError if the resolver never loaded, and ModelUnavailableError if model names a model this Mac can't use.

Check no_match before using results

When no_match is true, the query asked for something the list doesn't contain, and you must not act on results[0]. results still carries a ranking, so you can show near misses, but it is not an answer. The model is made to name some candidate, and names one confidently even for "the dishwasher" in a house with no dishwasher, so a result's score alone can't tell you it was a real match.

Warmup

Starts loading the model so the next resolve() isn't a cold start. It returns immediately, is safe to call more than once, and does nothing on a Mac without Apple Intelligence. The constructor calls it for you unless you pass warmup=False.

Command Syntax Examples

resolver.warmup()

Dictionaries

Candidates and results are plain Python dictionaries. The package also exports them as TypedDict types (Candidate, Match, ResolveResult) for type hints.

Candidate

One thing the query might refer to.

Key Required Type Description
id Yes integer or string returned unchanged in the result; use the Indigo object id
name Yes string what the query is matched against
category No string a grouping key; see Categories

Extra keys are allowed and ignored.

Categories

The model is never shown a long list whole. It sees 16 candidates: the ones whose names share the most words with the query. A query that shares no words with its answer can therefore miss it — with 60 lights and one thermostat named "Ecobee", "make it warmer in here" never shows the model the thermostat.

category is an optional label for what kind of thing a candidate is. When the 16 are chosen, the best candidate from every category that would otherwise be left out is added, so a lone item of its kind still reaches the model. The model never reads the label; it only groups the list, so any strings work as long as items of the same kind share one. It is not specific to devices:

List A good category
Devices the device class and subType, e.g. "SensorDevice:Motion"; see With Your Own Devices
Files the file type: "spreadsheet", "photo", "pdf"
Contacts the relationship: "family", "work", "service"
Action groups or variables the folder they're in

A missing or empty category means none. With 16 or fewer candidates, categories change nothing. When the query shares words with some names, the best of those keep up to 8 of the 16 places and categories fill the rest, so with many categories not all of them fit. When it shares words with none, the categories go first.

ResolveResult

Key Type Description
results list match dictionaries, best first
no_match boolean true when nothing in the list matches the query — see the warning under Resolve
tier string which stage answered; see the table below
error string only present when tier is "error": what went wrong
tier Meaning
"lexical-fastpath" the words matched unambiguously, so the model wasn't asked
"lexical" matched on words only, because the model isn't available on this Mac
"fm" the model chose the top result
"fm-no-match" the model decided nothing matches; no_match is true
"lexical-fm-failed" the model ran but gave no usable answer, so the word match was returned
"error" something failed inside the resolver; results is empty

Match

Key Type Description
id integer or string one of the candidate ids you supplied
score float 0.0 to 1.0, higher is better. It only ranks entries within this one result and always goes down with the list. It isn't a confidence and can't be compared across queries, and it can't tell you whether something matched — use no_match for that.
source string "model" for the model's answer and its runner-up, "lexical" for entries ranked by shared words.

Reading the Results

resolve() picks one item. results[0] is the answer; the rest of the list is ranked runners-up, not additional matches. For "the kitchen lights" against a house with two kitchen lights:

{
    "tier": "fm",
    "no_match": False,
    "results": [
        {"id": 1, "score": 0.9,  "source": "model"},     # Kitchen Ceiling Light      - the answer
        {"id": 2, "score": 0.54, "source": "model"},     # Kitchen Under-Cabinet LEDs - the model's second choice
        {"id": 3, "score": 0.0,  "source": "lexical"},   # Living Room Lamp           - word-match filler
        {"id": 4, "score": 0.0,  "source": "lexical"},   # Bedroom Lamp               - word-match filler
        {"id": 5, "score": 0.0,  "source": "lexical"},   # Porch Light                - word-match filler
    ],
}
Position What it is source
first the answer "model" when the model answered (tier "fm"), otherwise "lexical"
second the model's runner-up, when it had one "model"
the rest the remaining candidates ranked by shared words, so the list reaches top_n "lexical"

When tier is a word-matching tier ("lexical", "lexical-fastpath"), every entry is ranked by shared words, and its score is the share of the query's words found in the name. When the model answered, the word-matched entries are scored below the model's choices, so the scores always go down with the list. A score of 0.0 means no shared words — not a match.

Use the order, not the scores. The word-matched entries after the model's choices are there because the model is sometimes wrong, and when it is, the right item is often among them; nothing in the scores can tell you when that has happened. Showing them as "did you mean…?" options is the way to use them.

The list is never a set of matches: "all the lights" still returns one light at the top. To act on a group of things, use a folder or a filtered loop over indigo.devices.

To act on the answer and mention plausible alternatives:

result = resolver.resolve(phrase, candidates, top_n=3, domain=indigo.utils.SEMANTIC_DOMAIN_DEVICES)
if not result["no_match"]:
    best, *others = result["results"]
    alternatives = [m for m in others if m["score"] > 0]   # drop zero-score filler

Domain

domain is required on every resolve() call. It tells the model what the candidates are, and there is no default: a silent default would judge every list by one kind of list's rules.

Indigo's Domains

indigo.utils defines one for each Indigo object type:

Constant Form For a list of
SEMANTIC_DOMAIN_DEVICES templates devices — the prompts the MCP find_device tool is tuned with
SEMANTIC_DOMAIN_ACTION_GROUPS description action groups
SEMANTIC_DOMAIN_TRIGGERS templates triggers — the prompts the MCP find_trigger tool is tuned with
SEMANTIC_DOMAIN_SCHEDULES description schedules
SEMANTIC_DOMAIN_VARIABLES templates variables — the prompts the MCP find_variable tool is tuned with
SEMANTIC_DOMAIN_CONTROL_PAGES description control pages
SEMANTIC_DOMAIN_FOLDERS description folders of any type
SEMANTIC_DOMAIN_AUTOMATIONS templates a mixed list of action groups, triggers and schedules — the prompts the MCP find_automation tool is tuned with

Descriptions

For any other list, pass a short description of what the items are, as you'd say it to a person:

resolver.resolve("something mellow", playlists, domain="a user's music playlists")

The resolver places it into its own generic prompts. Keep it to a phrase; the text is placed in a sentence the model reads.

Templates

A domain can instead be a dict of two complete prompts, sent to the model as written, for callers that have tuned prompts against a test set of their own. SEMANTIC_DOMAIN_DEVICES, SEMANTIC_DOMAIN_VARIABLES, SEMANTIC_DOMAIN_TRIGGERS and SEMANTIC_DOMAIN_AUTOMATIONS are.

Key Must contain Used for
pick {items} (the candidate names, one - name line each) and {query} choosing from the list
verify {item} (the name just chosen) and {query} a follow-up check, asked when the query shares no words with any name: can this item serve the request?

The pick answer is constrained to one of the listed names or "none of these", and the verify answer to true or false, so the prompts should ask for exactly that.

Templates exist because the on-device models react to the wording and order of the whole prompt, not only to the facts in it. The device rules — a temperature request means the thermostat, wanting to see better means a light, sensors are almost never the target — scored measurably worse on macOS 26 when supplied as a description than in the prompt they were tuned in. Rewording SEMANTIC_DOMAIN_DEVICES, even its punctuation, changes results.

Models

A model is named by a string. On an Indigo server only the system model is usable.

Constant Value Description
MODEL_SYSTEM "SystemLanguageModel" Apple's on-device model. The default, and the only model Indigo can use.
MODEL_PRIVATE_CLOUD_COMPUTE "PrivateCloudComputeLanguageModel" Apple's server-side model. Apple allows it only in apps distributed through the App Store, so it is never available to Indigo.
DEFAULT_MODEL "SystemLanguageModel" the model used when none is given

You don't need to pass a model. On a Mac without Apple Intelligence, the default quietly falls back to word matching; naming any other model that isn't usable raises ModelUnavailableError.

Exceptions

Both subclass RuntimeError and are importable from macsemantic.

Exception Raised when
ResolverUnavailableError resolve() is called on a resolver that didn't load (available is false). The message includes load_error.
ModelUnavailableError a model is explicitly requested, at construction or per call, that this Mac can't use. The resolver never silently switches to a different model. The message lists the models that are available.