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:
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. |