MindQL

MindQL is a small declarative query language for the engrava thought-graph. It provides a human-readable syntax for retrieving and counting rows, plus a read-only SQL passthrough.

Syntax

[EXPLAIN] FIND  <table> [WHERE <bool-expr>] [ORDER BY <sort> ...] [LIMIT <n>] [OFFSET <n>]
[EXPLAIN] COUNT <table> [WHERE <bool-expr>]
[EXPLAIN] SELECT <raw read-only SQL>
  • The command verb (FIND, COUNT, SELECT) is case-insensitive.
  • FIND and COUNT require a table name as the second token.
  • A simple WHERE condition is field operator value. Quoting decides how the value is typed, not whether it is allowed: a single-quoted value is kept verbatim as a string, so a zero-padded identifier like source = '007' matches the stored string '007', whereas an unquoted value becomes an int or float when it parses as one — created_cycle = 7 is coerced to the integer 7 — and otherwise stays a string. An unquoted value ends at the first whitespace, so a value containing spaces must be quoted. A single-quoted value may contain a literal quote by doubling it, SQL-style: source = 'O''Brien' matches the stored string O'Brien, in a condition or inside an IN (...) list.
  • A boolean keyword (AND, OR) is recognised only as a whole word outside a quoted value, so an unquoted value that happens to end or start with one (source = vendor, source = ORACLE) is never mistaken for the operator.
  • Operators: =, !=, >, <, >=, <=, and IN (...).
  • Conditions combine with AND, OR, and parentheses (see Boolean expressions).
  • FIND additionally supports ORDER BY and OFFSET; any read query may be prefixed with EXPLAIN to see the compiled SQL without running it.
  • COUNT also parses a trailing LIMIT clause, but ignores it — see COUNT for the full behavior.

Queryable tables

Token(s) Table
thoughts, thought thought
edges, edge edge
embeddings, embedding embedding
actions, action action

Commands

FIND

Retrieve rows from a table.

FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 10
FIND thoughts WHERE lifecycle_status = 'ACTIVE' AND priority = 'P1'
FIND thoughts WHERE priority IN ('P1', 'P2') ORDER BY created_cycle DESC LIMIT 20
FIND thoughts ORDER BY created_cycle DESC LIMIT 10 OFFSET 20
FIND edges WHERE edge_type = 'ASSOCIATED' LIMIT 5

Filterable thought columns include thought_type, lifecycle_status, priority, essence, content, source, confidence, visibility, confirmation_count, created_cycle, updated_cycle, and thought_id. A column outside the per-table allowlist raises MindQLParseError.

Default row cap. A FIND with no LIMIT clause is capped at 100 rows when it runs, so an unqualified FIND thoughts can never trigger an unbounded scan. The cap is applied at execution, not at parse time — parse("FIND thoughts") leaves query.limit as None, and the executor substitutes the default only if no explicit LIMIT is present. An explicit LIMIT always overrides the default; COUNT queries are unaffected (they aggregate and never materialise the rows).

Returns: matching rows as dicts.

ORDER BY

FIND accepts an ORDER BY clause with one or more sort keys, each with an optional direction (ASC default, or DESC, case-insensitive):

FIND thoughts ORDER BY created_cycle DESC
FIND thoughts WHERE priority = 'P1' ORDER BY priority ASC, created_cycle DESC LIMIT 10

Every sort field must be in the per-table column allowlist. Sort fields are column identifiers, not values — they are validated against the allowlist and never bound as parameters, so an unknown field raises MindQLParseError. ORDER BY is emitted before LIMIT/OFFSET. It is rejected on COUNT (which returns a scalar) and is not used with the raw SELECT passthrough.

OFFSET / pagination

FIND accepts an OFFSET <n> clause (n >= 0) for pagination, combined with LIMIT and ORDER BY:

FIND thoughts ORDER BY created_cycle DESC LIMIT 20 OFFSET 40

SQLite requires a LIMIT for OFFSET, so an OFFSET with no explicit LIMIT applies the default row cap as the limit. OFFSET raises a parse error on COUNT; on the SELECT passthrough it is not rejected, because the statement is handed to SQLite unchanged. For stable pages, pair OFFSET with an ORDER BY so the row order is deterministic.

WHERE operators

IN

The IN operator matches a column against a set of values:

FIND thoughts WHERE thought_type IN ('BELIEF', 'OBSERVATION')
FIND thoughts WHERE created_cycle IN (1, 2, 3)

Each value follows the same quoting rule as a comparison value: single-quoted stays a verbatim string, and an unquoted value becomes an int or float when it parses as one, otherwise it stays a string. An empty IN () is a parse error. Every value is bound as a ? placeholder (never interpolated), and the field must be in the per-table allowlist.

Boolean expressions: AND, OR, parentheses

Conditions (simple comparisons, IN, and valid-time predicates) combine with AND, OR, and parenthesised grouping, using SQLite-standard precedence: AND binds tighter than OR:

FIND thoughts WHERE priority = 'P1' OR lifecycle_status = 'ACTIVE'
FIND thoughts WHERE priority = 'P1' OR priority = 'P2' AND source = 'x'
FIND thoughts WHERE (priority = 'P1' OR priority = 'P2') AND lifecycle_status = 'ACTIVE'

Because AND binds tighter, the second example groups as priority = 'P1' OR (priority = 'P2' AND source = 'x'). Use parentheses (as in the third example) to group an OR before an AND. A WHERE that uses only simple comparisons and/or valid-time predicates joined by AND (no OR, no parentheses, no IN) is read as a flat list of conditions joined by AND; a leading, trailing, or doubled AND raises MindQLParseError.

Parenthesised grouping may nest up to 256 levels deep; nesting deeper than that raises MindQLParseError.

COUNT

Count rows matching the filters. COUNT rejects ORDER BY and OFFSET with a parse error; a LIMIT clause parses but has no effect — the executor drops it and returns the count over all matching rows.

COUNT thoughts WHERE lifecycle_status = 'ACTIVE'
COUNT thoughts WHERE thought_type = 'OBSERVATION'
COUNT edges

Returns: the count is exposed on MindQLResult.count.

SELECT

SELECT is a read-only SQL passthrough — it runs the statement verbatim, so it needs a full FROM clause and standard SQL syntax. The underlying tables are thought, edge, embedding, and action.

SELECT thought_id, essence FROM thought WHERE lifecycle_status = 'ACTIVE'
SELECT thought_id, priority, essence FROM thought WHERE thought_type = 'BELIEF' LIMIT 20

Only statements that begin with SELECT are permitted; anything else is rejected. The passthrough is also restricted to a single statement: once a statement-ending ; appears, only whitespace and comments may follow it, and anything else is rejected (MindQLParseError: Only a single SELECT statement is allowed). A ; inside a comment, a string literal (as in SELECT ';' AS s) or a quoted identifier is not a separator.

Bound parameters. The passthrough can carry bound parameters, set programmatically on the query object (never parsed from the MQL text). When MindQLQuery.select_params is set, the statement runs with those values bound to its ? placeholders — the preferred way to pass untrusted values into a passthrough:

from engrava import MindQLQuery, MindQLCommand, MindQLExecutor

query = MindQLQuery(
    command=MindQLCommand.SELECT,
    raw_sql="SELECT thought_id FROM thought WHERE priority = ?",
    select_params=("P1",),
)
result = await MindQLExecutor(conn).execute(query)

When select_params is None (the default, and what parse() produces) the statement runs with no bound parameters, exactly as before.

The executor re-validates a hand-built query. A MindQLQuery is public API and can be constructed directly, as above, so validation is not left to parse(): every identifier and keyword that has to be interpolated is checked again where the query runs. The executor validates the target table against its allowlist (defaulting to thought), every column named in a WHERE comparison, an IN list or an ORDER BY key against that table’s column allowlist, the sort direction against ASC/DESC and the boolean joiner against AND/OR (either case accepted), and limit/offset as non-negative, non-boolean integers. Anything outside those sets raises MindQLParseError before a statement is built. Each check also emits the module’s own string — or, for the row bounds, a rebuilt plain int — rather than the object you passed, so a str or int subclass cannot pass the membership check and then render different text through __format__.

Valid-time predicates

FIND and COUNT against the thoughts and edges tables accept four opt-in valid-time predicates in the WHERE clause, for querying when a fact was true in the world (the second time axis — see The Bi-temporal Model for the full semantics):

FIND thoughts WHERE valid_now
FIND edges WHERE valid_at '2026-01-01T00:00:00+00:00'
FIND thoughts WHERE priority = 'P1' AND valid_within '2026-01-01T00:00:00+00:00' '2026-02-01T00:00:00+00:00'
FIND thoughts WHERE valid_between '2026-01-01T00:00:00+00:00' '2026-12-31T00:00:00+00:00'
  • valid_now takes no argument; valid_at takes one ISO-8601 timestamp; valid_within and valid_between take two.
  • They combine with ordinary conditions via AND.
  • valid_now / valid_at / valid_within are NULL-tolerant (a record with an open valid_from/valid_until bound stays in the result); valid_between requires real bounds on both ends and therefore excludes open-bound rows.
  • A query that uses no temporal predicate behaves exactly as before.

Valid time is predicate-only, not a filterable column. Query valid time only through the four predicates above. valid_from and valid_until are not in the per-table column allowlist, so an ordinary comparison such as WHERE valid_from = '2026-01-01T00:00:00+00:00' is rejected when the query runs (MindQLParseError: Column 'valid_from' not allowed for table 'thought') — use valid_at / valid_within / valid_between instead.

The semantics, the open-interval (NULL = ±∞) rule, and invalidate are documented in full on The Bi-temporal Model.

Python API

Use store.execute_mindql() to run a parsed query on the store’s own connection and read the result:

from engrava import parse

result = await store.execute_mindql(
    parse("FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 10")
)
for row in result.rows:
    print(row["essence"])

CLI Usage

# Find observations
engrava --db my.db query "FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 5"

# Count active thoughts
engrava --db my.db query "COUNT thoughts WHERE lifecycle_status = 'ACTIVE'"

# Select specific fields (raw SQL passthrough)
engrava --db my.db query "SELECT thought_id, essence FROM thought LIMIT 5"

Output formats: --format table (default), --format json, --format csv.

For the exhaustive package reference — including EXPLAIN, extension commands, and the full Python API — see the OSS mindql.md docs.