Polylogue uses a query-first grammar over archive units. Bare tokens are
full-text terms, field clauses narrow the selected unit, explicit
sessions/messages/actions/blocks/files/assertions/runs/observed-events/context-snapshots/delegations where ...
forms opt into Boolean predicates,
and trailing CLI verbs render or mutate the selected session set. The same
query semantics — filters, retrieval lanes, ranking policy, and typed response
payloads — apply across the CLI, MCP, Python API, and daemon HTTP surfaces.
Quick links:
- Retrieval Lanes —
dialogue,actions,hybrid,semantic, and the default lexicalautolane. - Terminal Unit Queries —
messages/actions/blocks/ files/assertions/runs/observed-events/context-snapshots/delegations where ...row results. - Ranking Policy — current
mixed-bm25-rrf-vectorpolicy and version contract. - SearchEnvelope Contract — the typed response shape shared across surfaces (#1266).
- FTS5 Syntax — boolean, phrase, and prefix queries.
- Searchable Content Coverage — exactly
which block fields FTS indexes, and which (e.g.
Write/Edittool file bodies) it does not. - Facets (Scoped vs Global) — aggregate counts with both views (#1269).
The canonical parser is the Lark grammar in
polylogue/archive/query/expression.py. It has two entry shapes that lower
through the same typed AST and query planner:
compact-query ::= compact-clause*
boolean-query ::= ["sessions" "where"] predicate
projection-query ::= (compact-query | boolean-query) "with" projection-list
projection-list ::= projection-item ("," projection-item)*
projection-item ::= unit ["(" field ("," field)* ")"] ["[" bracket-clause ("," bracket-clause)* "]"]
bracket-clause ::= field ":" value | ("first" | "last") ":" integer
unit-query ::= ("messages" | "actions" | "blocks" | "files" | "assertions" | "runs" | "observed-events" | "context-snapshots" | "delegations") "where" predicate
pipeline-query ::= unit-query ("|" pipeline-stage)+
| "sessions" "where" predicate "|" unit-query ("|" pipeline-stage)*
compact-clause ::= field-clause
| quoted-text | bare-text
| "-" quoted-text | "-" bare-text
| count-comparison | count-range
| date-comparison | date-range
predicate ::= predicate "OR" predicate
| predicate "AND" predicate
| "NOT" predicate
| "(" predicate ")"
| field-clause
| count-comparison | count-range
| date-comparison | date-range
| fts-leaf | semantic-leaf
| "exists" structural-unit "(" predicate ")"
| "seq" "(" sequence-step "->" sequence-step+ ")"
structural-unit ::= "message" | "action" | "block" | "assertion" | "file"
| "run" | "observed-event" | "context-snapshot" | "delegation"
sequence-step ::= action-field-clause ("AND" action-field-clause)*
action-field-clause ::= "action:" value | "tool:" value | "command:" value
| "path:" value | "output:" value | "text:" value
pipeline-stage ::= "sort" "by" "time" ["asc" | "desc"]
| "group" "by" field ["," field]*
| "count"
| "agg" agg-metric ["," agg-metric]*
| "sort" "by" ("count" | "key") ["asc" | "desc"]
| "limit" integer
| "offset" integer
agg-metric ::= "count"
| ("sum" | "avg" | "min" | "max" | "p" digit+) ":" field
Generated by devtools render query-discovery from polylogue/archive/query/discovery.py. The corpus contains 106 positive and 18 negative declarations. Every positive expression is parsed by the production Lark/compiler route in tests; every negative declaration pins the production diagnostic and a parser-valid correction.
Executable forms projected from the declarations:
| Form | Syntax |
|---|---|
compact_form |
<field>:<value> [AND|OR|NOT ...] |
session_form |
sessions where <session-predicate> |
terminal_form |
<terminal-source> where <predicate> |
pipeline_form |
<terminal-source> where <predicate> | group by <field> | count |
scope_form |
sessions where <predicate> | <terminal-source> where <predicate> |
projection_form |
<session-expression> with <unit>(<columns>) |
Terminal sources: action / actions / assertion / assertions / block / blocks / context-snapshot / context-snapshots / delegation / delegations / file / files / message / messages / observed-event / observed-events / run / runs.
Structural exists units: action / assertion / block / context-snapshot / delegation / file / message / observed-event / run.
Result coverage and total must be read with the declared semantics:
| Class | Total | Continuation | Discovery wording |
|---|---|---|---|
exhaustive |
qualified |
cursor-or-offset |
Exhaustive relation is paged: total is page-local; follow continuation until absent. |
top-k |
qualified |
ranked-frontier |
Top-k by relevance, not exhaustive; total is qualified and must not be read as all matching archive rows. |
sample |
qualified |
none |
Sample, not exhaustive; total is qualified and does not establish archive-wide coverage. |
aggregate |
aggregate |
cursor-or-offset |
Aggregate over the declared input relation; totals describe aggregate buckets, while input coverage is reported separately. |
bounded-context |
qualified |
none |
Bounded context for orientation, not exhaustive; omitted evidence is expected and total is qualified. |
recursive-page |
qualified |
recursive-cursor |
Recursive relation, physically paged; node and edge totals remain qualified until every continuation ends. |
Featured parser-gated examples:
| Semantics | Source | Expression | What it answers | Projection | Cost |
|---|---|---|---|---|---|
exhaustive |
sessions |
repo:example-repo |
Finds sessions associated with one repository. | id, origin, title, title_source, title_ref, title_confidence, target_ref, anchor, actions, created_at, updated_at, message_count, tags, summary, words, repo, cwd_display, flags, parent_refs, child_refs, continuation |
selective |
exhaustive |
sessions |
sessions where (repo:example-repo OR repo:example-library) AND NOT tag:stale |
Finds non-stale sessions from either of two repositories. | id, origin, title, title_source, title_ref, title_confidence, target_ref, anchor, actions, created_at, updated_at, message_count, tags, summary, words, repo, cwd_display, flags, parent_refs, child_refs, continuation |
selective |
exhaustive |
sessions |
sessions where exists message(role:assistant AND text:timeout) |
Finds sessions with an assistant message mentioning a timeout. | id, origin, title, title_source, title_ref, title_confidence, target_ref, anchor, actions, created_at, updated_at, message_count, tags, summary, words, repo, cwd_display, flags, parent_refs, child_refs, continuation |
corpus-scale |
exhaustive |
sessions |
sessions where seq(action:file_edit -> action:shell) |
Finds sessions where a file edit precedes a shell action. | id, origin, title, title_source, title_ref, title_confidence, target_ref, anchor, actions, created_at, updated_at, message_count, tags, summary, words, repo, cwd_display, flags, parent_refs, child_refs, continuation |
corpus-scale |
top-k |
sessions |
near:"semantic search" |
Ranks the sessions most relevant to a semantic-search phrase. | session.id, session.origin, session.title, session.message_count, session.created_at, session.updated_at, match.rank, match.retrieval_lane, match.match_surface, match.target_ref, match.message_id, match.snippet, match.score, match.score_kind, match.matched_terms |
corpus-scale |
sample |
sessions |
repo:example-repo since:30d |
Samples recent sessions from one repository. | id, origin, title, title_source, title_ref, title_confidence, target_ref, anchor, actions, created_at, updated_at, message_count, tags, summary, words, repo, cwd_display, flags, parent_refs, child_refs, continuation |
selective |
exhaustive |
messages |
messages where role:assistant AND text:timeout |
Returns assistant messages mentioning a timeout. | unit, message_id, session_id, origin, title, role, message_type, material_origin, occurred_at_ms, position, word_count, text |
corpus-scale |
exhaustive |
actions |
actions where session.repo:example-repo AND action:file_edit AND path:src/query |
Returns file-edit actions under the query source path for one repository. | unit, session_id, message_id, origin, title, tool_use_block_id, tool_result_block_id, tool_name, semantic_type, tool_command, tool_path, occurred_at_ms, output_text, is_error, exit_code, followup_class, followup_message_ref |
selective |
exhaustive |
actions |
actions where tool:shell AND command:pytest |
Returns shell actions whose command mentions pytest. | unit, session_id, message_id, origin, title, tool_use_block_id, tool_result_block_id, tool_name, semantic_type, tool_command, tool_path, occurred_at_ms, output_text, is_error, exit_code, followup_class, followup_message_ref |
corpus-scale |
exhaustive |
actions |
actions where session.repo:example-repo AND session.since:7d AND output:failed |
Returns recent failed action evidence for one repository. | unit, session_id, message_id, origin, title, tool_use_block_id, tool_result_block_id, tool_name, semantic_type, tool_command, tool_path, occurred_at_ms, output_text, is_error, exit_code, followup_class, followup_message_ref |
corpus-scale |
exhaustive |
files |
files where path:src/query/parser.py |
Returns file evidence for the query parser path. | unit, session_id, origin, title, path, action_count, first_message_id, first_tool_use_block_id, last_tool_use_block_id, first_seen_ms, last_seen_ms |
selective |
exhaustive |
files |
files where session.repo:example-repo AND path:src/mcp/server.py |
Returns file evidence for one path within one repository. | unit, session_id, origin, title, path, action_count, first_message_id, first_tool_use_block_id, last_tool_use_block_id, first_seen_ms, last_seen_ms |
selective |
exhaustive |
assertions |
assertions where kind:decision AND text:"schema migration" |
Returns decision assertions about a named topic. | unit, assertion_id, target_ref, scope_ref, kind, key, body_text, value, author_ref, author_kind, status, visibility, evidence_refs, staleness, context_policy, created_at_ms, updated_at_ms |
corpus-scale |
exhaustive |
messages |
sessions where repo:example-repo AND origin:unknown-export | messages where role:assistant |
Returns assistant messages scoped to repository sessions from one origin. | unit, message_id, session_id, origin, title, role, message_type, material_origin, occurred_at_ms, position, word_count, text |
selective |
aggregate |
messages |
messages where text:timeout | group by role | count |
Counts timeout-matching messages by role. | unit, group_by, group_key, count, metrics |
corpus-scale |
aggregate |
actions |
actions where is_error:true | group by tool | count |
Counts error-marked actions by tool. | unit, group_by, group_key, count, metrics |
corpus-scale |
bounded-context |
sessions |
repo:example-repo with messages(message_id,role,text) |
Builds bounded session orientation with selected message columns. | session_id, message_id, role, text |
selective |
bounded-context |
sessions |
sessions where title:"query compiler" with messages(message_id,role), actions(tool_name,semantic_type), files(path) |
Builds bounded mixed evidence for sessions whose title mentions the query compiler. | session_id, message_id, role, tool_name, semantic_type, path |
selective |
recursive-page |
sessions |
lineage:id:example-origin:session-child |
Seeds a recursive lineage walk from one session. | session_id, parent_refs, child_refs, continuation |
selective |
Invalid examples found in shipped teaching surfaces at the snapshot boundary:
| Shipped at | Rejected expression | Actual diagnostic | Corrected form |
|---|---|---|---|
polylogue/mcp/server_prompts.py:509 |
actions where session.repo:example-repo since:7d AND output:failed |
invalid query expression near column 27 | actions where session.repo:example-repo AND session.since:7d AND output:failed |
polylogue/mcp/server_prompts.py:524 |
files where repo:example-repo AND path:src/mcp/server.py |
field 'repo' is not supported for file predicates | files where session.repo:example-repo AND path:src/mcp/server.py |
docs/search.md:924 |
text:css {session_id example}: refactor |
unknown query field 'text'; recognized fields: action, assistant_messages, assistant_words, authored_user_messages, authored_user_words, contains, cwd, duration_ms, has, id, lane, lineage, messages, near, origin, paste_messages, path, project, repo, root, session, since, system_messages, tag, thinking_messages, title, tool, tool_messages, tool_use_messages, until, user_messages, user_words, words | contains:"css refactor" |
Machine clients can request parser-gated positives with MCP/CLI query_completions(kind="example") and real diagnostics/corrections with query_completions(kind="error"). The query capability resource carries corpus counts and the six shared semantics contracts.
When a pipeline starts with sessions where ..., the left stage is lowered into
predicates on the terminal unit query, so it uses the same row executor as direct
messages/actions/files/... pipelines. Field/count/date predicates become
session.<field> row predicates. FTS (~"..."), exists ..., sequence
(seq(...)), and lineage:id:<session-id> stages lower through the existing
session Boolean lowerers against the terminal row's owning session. Semantic
vector predicates still reject in a session pipeline stage until terminal row
queries have explicit ranked-result semantics. Terminal pipeline stages
currently support sort by time [asc|desc],
group by FIELD[,FIELD...] | count, group by FIELD[,FIELD...] | agg METRIC[,METRIC...],
aggregate sort by count|key [asc|desc], limit N,
and offset N for SQL-backed terminal rows (messages, actions, blocks,
files, assertions). Query-string limits narrow the surface limit instead of expanding
caller/API caps, and query-string offsets are added to the caller offset.
| agg ... extends the count-only aggregate rollup with named,
per-group reducer metrics (polylogue-fnm.1). It follows an optional
group by stage (or reduces the whole matched row set with no group by)
and precedes limit/offset:
polylogue --format json messages where session.repo:polylogue | group by role | agg count, avg:word_count, p90:word_count
polylogue --format json actions where session.repo:polylogue | group by tool | agg count, avg:is_error, sum:is_errorEach metric is count (field-less) or FN:FIELD, where FN is sum,
avg, min, max, or a nearest-rank percentile pNN (p1..p99), and
FIELD is one of the unit's declared numeric metric fields (message:
word_count; action: is_error, exit_code). An unsupported function or
field raises a typed error naming the unit, the metric, and the supported
field set. Metric labels are stable output keys: count for the count
metric, otherwise f"{fn}_{field}" (e.g. avg_word_count).
avg/sum/min/max/percentile reducers are not pushed down to SQL
today — the count-only aggregate lowerer (ArchiveStore.query_unit_counts)
computes exact grouped counts directly in SQL, but named-metric reduction
fetches up to 50,000 predicate-matching rows through the unit's existing
row query and reduces them in Python. The pipeline result payload reports
this explicitly: result.exact is true when every matching row was
fetched (the aggregate is exact), or false plus result.sampled_rows when
the match set was larger than the cap (the aggregate is a bounded sample of
the first 50,000 rows in time order, not the full population). Narrow the
query with session/time/repo filters before trusting an agg result on a
large archive; a count-only | group by ... | count stage stays exact at
any scale because it is fully SQL-pushed.
runs, observed-events, context-snapshots, and delegations are SQL-backed terminal rows
over source-derived archive relations (polylogue-dab): main runs,
session_started events, tool-finished events, and session-start context
snapshots are computed directly from sessions and blocks, with no
separate materialized cache table.
These units are usable as
exists run(...) / exists observed-event(...) /
exists context-snapshot(...) session selectors. runs and
context-snapshots support time sorting and paging but have no aggregate
lowerer. observed-events supports time sorting, paging, and declared
group by | count fields. delegations supports paging and declared
aggregation fields but deliberately does not advertise sort by time.
Pipeline syntax is terminal-row syntax: session-selector surfaces reject piped
queries, including aggregate stages like group by role | count, instead of
dropping those stages and widening the query to exists message(...).
--explain --format json reports terminal pipelines as a unit_source AST with
ordered pipeline_stages. Session-scoped pipelines include a session_scope
stage carrying the original session predicate, followed by any terminal
sort/limit/offset stages. Consumers should use that stage list rather than
inferring pipeline behavior from incidental limit, offset, or sort
fields.
Unsupported forms raise typed ExpressionCompileErrors and must not broaden
into looser full-text search. In particular, reserved unit prefixes such as
messages where are errors when malformed; they are not treated as ordinary
text terms.
The with <units> projection clause (session-selecting queries, not
terminal pipelines) accepts an optional bracket after each unit item,
narrowing what gets attached to every selected session (polylogue-fnm.2):
polylogue --format json 'repo:polylogue with messages[role:user, last:20]'
polylogue --format json 'repo:polylogue with actions[tool:Bash, first:5]'
polylogue --format json 'repo:polylogue with messages(message_id,role,text)[role:user, last:10]'A bracket is a comma-separated list of field:value equality predicates
and/or a single first:N/last:N window, combinable with the existing
unit(field, field) payload-field selector in either order. Bracket
predicate fields are a small, explicit vocabulary per unit — the fields
already present on that unit's attached row payload, not the full
<unit> where ... structural predicate field set:
| Unit | Bracket predicate fields |
|---|---|
message |
role, type |
action |
tool, action, type, is_error, exit_code, followup_class |
file |
path |
assertion |
kind, status, visibility, author_kind |
An unsupported bracket field or malformed clause raises a typed error naming
the unit, the field, and the supported set. first:N/last:N trims each
session's attached rows to the first/last N after predicates are applied,
by fetch order — last:N fetches that unit in descending time order (then
restores ascending order) precisely so the per-session/per-page row cap
lands on the session's actual tail instead of silently landing on its head
whenever a session has more matching rows than the cap.
Bracket predicates/windows apply to the already-fetched, capped attached-row
set (attached_units.py's existing _MAX_ROWS_PER_SESSION/
_MAX_ROWS_PER_PAGE caps) — they are not pushed down to the SQL fetch
itself. A predicate combined with last:N on a session whose matching
rows are sparser than the fetch cap can still under-fetch; this is the same
bounded-fetch tradeoff every other post-fetch filter on this path already
has, not a window-specific gap.
| Field | Meaning | Example |
|---|---|---|
repo |
Repository substring | repo:polylogue |
origin |
Source origin | origin:claude-code-session |
tag |
User/session tag | tag:review |
path |
Referenced file path substring | path:polylogue/cli |
cwd |
Working-directory prefix | cwd:/realm/project |
tool |
Tool name used in the session | tool:bash |
action |
Semantic action category | action:file_edit |
has |
Content/evidence presence (paste evidence, tools, thinking, or stored type) |
has:paste |
id |
Session id or prefix | id:codex-session:abc |
session |
Exact session ref alias for id |
session:codex-session:abc |
title |
Session title substring | title:refactor |
since / until |
Session time bounds, ISO or relative | since:7d |
contains |
Exact content substring filter | contains:sqlite |
near |
Vector similarity from text or a session id | near:"semantic search" / near:id:<session> |
lane |
Retrieval lane | lane:dialogue |
lineage |
Sessions sharing topology with a seed | lineage:id:<session> |
Field values support quoted strings and in-field alternatives:
polylogue 'origin:(codex-session|claude-code-session) title:"query DSL"'
polylogue 'tool:bash AND NOT tag:stale'Negation is supported for fields that are semantically safe to negate, such as
origin, tag, tool, and action.
Readable comparisons are supported for message counts, word counts, numeric durations/tokens, and dates:
polylogue 'messages >= 5 AND messages <= 20'
polylogue 'words between 100 and 500'
polylogue 'sessions where duration_ms >= 60000'
polylogue 'messages where input_tokens >= 1000 AND duration_ms between 2000 and 3000'
polylogue 'date between 2026-06-01 and 2026-06-17'Explicit Boolean queries can also compare the session aggregate columns already maintained by the archive:
polylogue 'sessions where user_messages >= 2 AND assistant_words between 500 and 2000'
polylogue 'sessions where authored_user_messages >= 2 AND authored_user_words >= 100'
polylogue 'sessions where system_messages = 0 AND tool_messages = 0'
polylogue 'sessions where tool_use_messages >= 1 AND paste_messages = 0'Supported aggregate count fields are messages, words, user_messages,
authored_user_messages, assistant_messages, system_messages,
tool_messages, user_words, authored_user_words, assistant_words,
tool_use_messages, thinking_messages, and paste_messages.
user_messages and user_words are provider-role counts: they preserve source
envelope truth and may include runtime protocol/context rows from providers
such as Claude Code. Use authored_user_messages and authored_user_words
when the question is human-authored prompt material. The tool_use_messages,
thinking_messages, and paste_messages fields count messages carrying those
signals, not raw block rows. These fields are SQL-backed session predicates; use
them in
sessions where ... Boolean queries or as session.<field> predicates inside
terminal unit queries.
Supported non-count numeric fields are duration_ms on sessions and messages,
plus message-scoped input_tokens, output_tokens, cache_read_tokens, and
cache_write_tokens. Session duration_ms uses the archive's reported session
duration; message duration_ms uses the stored per-message duration. Message
numeric fields are accepted in messages where ... terminal queries and
exists message(...) structural predicates.
Compact count syntax is equivalent where available:
polylogue messages:>=5 words:<=500exists <unit>(...) keeps the selected unit as sessions but requires at least
one child row matching the nested predicate.
| Unit | Accepted fields |
|---|---|
message |
action, cache_read_tokens, cache_write_tokens, command, duration_ms, input_tokens, output, output_tokens, path, role, text, time, tool, type, words |
action |
action, command, output, path, text, time, tool, type |
block |
action, command, path, text, time, tool, type |
file |
action, command, path, text, time, tool, type |
assertion |
author, author_kind, author_ref, body, context, evidence, key, kind, scope, scope_ref, status, target, target_ref, text, value, visibility |
run |
agent, branch, confidence, context_snapshot, cwd, evidence, harness, lineage, native_session_id, native_parent_session_id, origin, parent, provider_origin, role, run, status, text, title, transcript |
observed-event |
delivery_state, evidence, kind, object, subject, summary, text |
context-snapshot |
boundary, evidence, inheritance_mode, metadata, run, segment, text |
Structural units also accept session.<field> predicates for the owning
session fields that their lowerer can evaluate. SQL-backed units can use the
full session filter surface, including action/tool/path/feature predicates.
runs, observed-events, context-snapshots, and delegations are SQL-backed over the
materialized run-projection tables, so they too accept the full session filter
surface (e.g. session.repo, session.tool, session.path) alongside their
own fields above. Count and date session
fields accept compact comparison prefixes such as session.messages:>=2,
session.words:<=500, and session.date:>=2026-01-02. This lets a unit query
carry its session scope inline instead of splitting selection between the
query string and parallel parameters.
Boolean and terminal predicates preserve comparison semantics: >, >=, <,
<=, and = are distinct for count and numeric fields, while date and
terminal time accept the four ordered comparisons plus between. The legacy
flat root filters (messages > 10, words < 500) still compile into inclusive
SessionQuerySpec min/max bounds because that older request object has no
exclusive-bound fields.
Examples:
polylogue sessions where 'exists action(session.repo:polylogue AND tool:bash AND text:pytest)'
polylogue sessions where 'exists file(session.repo:polylogue AND action:file_edit AND path:archive/query)'
polylogue sessions where 'exists block(type:code AND text:timeout)'Inside Boolean predicates, ~ marks an explicit FTS leaf and semantic: /
near:text: mark semantic-vector leaves:
polylogue sessions where '~"null pointer" AND repo:polylogue'
polylogue sessions where 'semantic:"query compiler failure"'
polylogue sessions where 'near:text:timeout'Semantic leaves require embeddings to be configured and available. When vectors are unavailable, the query fails with a typed semantic/vector availability error instead of falling back to broad lexical search.
Most query expressions select sessions. Explicit unit sources select terminal rows instead:
polylogue --format json messages where role:assistant AND text:timeout
polylogue --format ndjson actions where session.repo:polylogue AND action:file_edit AND path:polylogue/archive
polylogue --format yaml blocks where type:code AND text:sqlite
polylogue --format json files where action:file_edit AND path:polylogue/archive/query
polylogue --format json assertions where kind:decision AND status:active AND text:review
polylogue --format json runs where role:subagent AND status:completed AND agent:Explore
polylogue --format json observed-events where delivery_state:acted_on AND text:#2100
polylogue --format json context-snapshots where boundary:session_start AND session.repo:polylogue
polylogue --format json delegations where mapping_state:resolved AND instruction:review
polylogue --format json messages where text:timeout | group by role | countThe row shape is the shared QueryUnitEnvelope used by CLI JSON/NDJSON/YAML,
Python Polylogue.query_units(), MCP query_units, and daemon
GET /api/query-units?expression=.... Plain and CSV CLI output are
transport-specific renderings of the same message/action/block/file/assertion/run/
observed-event/context-snapshot row payloads.
Aggregate pipelines over SQL-backed terminal rows use the sibling
QueryUnitAggregateEnvelope and currently support group by FIELD | count
over closed unit fields such as message role, action tool/action, block type,
file path,
assertion kind/status, and owning-session origin/repo.
Aggregate rows can be ordered with sort by count [asc|desc] or
sort by key [asc|desc] before limit/offset; the default order is count
descending, then key.
Those surfaces share the same session-scoping filters for the row source
where applicable, such as origin, tag, repo, title, date bounds, message-type
and tool/paste/thinking feature filters.
Use session.<field> inside the expression when the unit rows should be scoped
by their owning session:
polylogue messages where session.origin:claude-code-session AND role:assistant
polylogue actions where session.repo:polylogue AND action:file_edit
polylogue blocks where session.since:7d AND session.words:<=500 AND type:code
polylogue files where session.repo:polylogue AND action:file_edit AND path:archive/query
polylogue assertions where session.repo:polylogue AND kind:caveat
polylogue runs where session.repo:polylogue AND role:subagent AND status:completed
polylogue observed-events where session.origin:codex-session AND object_ref:github-review
polylogue context-snapshots where session.messages:>=2 AND session.date:>=2026-01-02 AND boundary:subagent_startfiles where ... is an affected-path evidence projection over action rows with
non-empty tool_path. It returns one row per owning session and normalized path,
with action counts and first/last tool-use refs. It is not a global filesystem
inventory and does not claim that a file still exists on disk.
runs, observed-events, context-snapshots, and delegations are SQL-backed row sources
over source-derived archive relations, with materialized projection tables used
only for richer non-duplicate rows that are not cheap local projections of
sessions and blocks. They are both terminal unit sources (runs where ...)
and exists run(...) / exists observed-event(...) /
exists context-snapshot(...) session selectors, and they accept the full
SQL-backed session filter surface (session.action, session.tool,
session.path, session.has, …). runs and context-snapshots support
sort by time, limit, and offset but reject aggregate stages because no
aggregate lowerer exists. observed-events supports time sorting, paging, and
aggregation. delegations supports paging and aggregation but rejects time
sorting because no stable event-time column is declared.
Session filters such as --origin, --tag, --repo, --since, and --until
still narrow the owning sessions before rows are returned. Session-only actions
and result-shaping modes that do not have row semantics yet, such as
delete, open, stats, count, --cursor, and custom sort/reverse modes,
are rejected instead of silently coercing row queries back to session queries.
Any non-user-authored assertion write (agent, transform, detector, blackboard
post, or any future automated writer) lands with status:candidate by
default -- enforced once, inside upsert_assertion itself, not per-writer
(37t.15). Two narrow exceptions: an assertion_id that already carries a
terminal judgment outcome (accepted/rejected/deferred/superseded/deleted)
keeps that outcome instead of being resurrected to candidate, and session
tags (AssertionKind.TAG) opt out entirely via require_promotion=False
since tags are categorization, not epistemic claims, and have no
judgment-queue path today. Candidate rows are private, carry
context_policy.inject=false, and keep promotion_required=true until an
operator makes an explicit judgment. They
can be inspected like any other assertion row, or through the review list that
keeps pending candidates separate from already judged lifecycle rows:
polylogue assertions where 'status:candidate AND target:session:codex-session:abc123' --format json
polylogue judge --target-ref session:codex-session:abc123 --list --format json
polylogue judge --target-ref session:codex-session:abc123 --review --format json
polylogue judge --status --format jsonThe sole public judgment workflow is root polylogue judge; it writes through
the existing lifecycle authority into the same user.db assertion substrate:
polylogue judge --accept assertion:candidate-id --reason "confirmed by transcript" --format json
polylogue judge --reject assertion:candidate-id --reason "unsupported by evidence" --format json
polylogue judge --defer assertion:candidate-id --reason "needs another source" --format json
polylogue judge --supersede assertion:candidate-id --replacement-kind summary --body "replacement claim" --format jsonThe closed assertion status vocabulary is active, candidate, accepted,
rejected, deferred, superseded, deleted, and inactive; unknown values
fail at the typed boundary instead of being rendered as generic strings.
accept and supersede create an active assertion whose evidence includes the
candidate assertion ref and whose supersedes lineage points at the candidate.
reject writes status:rejected, and defer writes status:deferred; both
preserve a judgment assertion with the reason and leave a durable lifecycle
record. Judged candidates remain in polylogue judge --review with disabled
action reasons such as candidate_already_accepted, candidate_already_rejected,
candidate_deferred, or candidate_superseded, while polylogue judge --list
only shows candidates still awaiting judgment. Review rows disclose the claim
summary, source identity, candidate age, total evidence count, and up to five
bounded evidence previews; unresolved or failed references are represented as
explicit per-reference states rather than failing the whole review. No candidate
assertion is injected into compiled context unless a later surface asks for
candidates explicitly.
polylogue judge --status is a non-destructive queue-health query. It reports
pending counts and age, lifecycle/kind/source breakdowns, producer-run state,
scheduler heartbeat, convergence debt, and retention outcome. An empty queue is
healthy only when a recent successful producer run and fresh heartbeat are both
observable; otherwise it remains empty-unverified. Candidates older than 60
days remain durable and visible as retained backlog.
Pending candidate judgments also appear in the operator debt cockpit:
polylogue ops debt list --kind assertion-candidate --only-actionable --format jsonQuery/read payloads carry public object and evidence refs so agents and the web shell can jump from a row, context image, or assertion back to the exact archive object it cites. Resolve refs through the shared resolver rather than turning them into broad text search:
polylogue read session:codex-session:abc123 --format json
polylogue read message:codex-session:abc123:m1 --format json
polylogue read block:codex-session:abc123:m1:0 --format json
polylogue read assertion:assertion-id --format json
polylogue read delegation:claude-code-session:parent:message:0 --format jsonThe same PublicRefResolutionPayload is exposed by
Polylogue.resolve_ref(), MCP resolve_ref, and daemon
GET /api/refs/resolve?ref=.... The resolver supports session, message,
block, assertion, delegation, and runtime projection refs (run, observed-event,
context-snapshot) when the addressed object exists. Unsupported or missing
refs return a bounded unresolved payload with caveats; they never widen into a
session search.
Delegation refs resolve to an explicit evidence card. The card preserves the
complete recorded instruction while separately bounding the parent-side
dispatch result, actual child-session excerpt, parent context, and parent
follow-up with per-window/per-excerpt truncation markers. Ordinary
delegations where ... rows expose only previews, SHA-256 hashes, structural
mapping/result states, and evidence refs; they do not claim child success,
utility, or parent use.
Polylogue exposes an outbound OTel projection that exports archive evidence as an observability-shaped view.
Outbound projection is not archive authority. Trace/span ids are stable export ids, while Polylogue refs remain the navigation surface back to canonical sessions, messages, runs, context snapshots, observed events, assertions, and evidence refs.
The Python API exposes the first bounded projection surface:
payload = await archive.export_otel(
source_ref="session:codex-session:abc123",
expressions=(
"runs where session.id:codex-session:abc123",
"observed-events where session.id:codex-session:abc123",
"context-snapshots where session.id:codex-session:abc123",
),
)OtelProjectionPayload currently emits OTLP-like JSON (format="otlp-json")
over existing query-unit row payloads. Runs and actions become spans;
messages, observed events, and context snapshots become log/event records.
Tool outputs and absolute local paths are omitted by default; the payload
instead carries output length/presence, redaction flags, and refs that clients
can resolve deliberately when the operator wants to inspect source evidence.
| Flag | Description |
|---|---|
--id, -i |
Session ID (exact or prefix match) |
--contains, -c |
FTS term (repeatable = AND) |
--exclude-text |
Exclude sessions matching this term |
--title |
Title contains substring |
--origin, -o |
Include origins (comma = OR) |
--exclude-origin |
Exclude origins |
--repo, -r |
Filter by repository name |
--referenced-path |
File path contains substring (repeatable = AND) |
--cwd-prefix |
Working directory starts with this prefix |
| Flag | Description |
|---|---|
--has-tool-use |
Only sessions with tool calls |
--has-thinking |
Only sessions with reasoning/thinking blocks |
--has-paste |
Only sessions with paste evidence |
--typed-only |
Only sessions without paste evidence |
--has, --has-type |
Filter by content type: thinking, tools, summary, attachments |
| Flag | Description |
|---|---|
--min-messages |
Minimum message count |
--max-messages |
Maximum message count |
--min-words |
Minimum word count |
| Flag | Description |
|---|---|
--action |
Require semantic action: file_read, file_write, file_edit, shell, search, web, agent, subagent, git (repeatable = AND) |
--exclude-action |
Exclude semantic action (repeatable = AND) |
--action-sequence |
Require ordered action subsequence (comma-separated) |
--action-text |
Text match within action evidence (repeatable = AND) |
--tool |
Require normalized tool name (repeatable = AND) |
--exclude-tool |
Exclude normalized tool name (repeatable = AND) |
| Flag | Description |
|---|---|
--since |
Only sessions on or after this date/time |
--until |
Only sessions on or before this date/time |
--limit, -n |
Maximum results |
--offset |
Start offset |
--latest |
Newest-first sort |
--sort |
Sort order |
--reverse |
Reverse sort direction |
--sample |
Random sample of N sessions |
| Flag | Description |
|---|---|
--tag, -t |
Include tags (comma = OR, supports key:value) |
--exclude-tag |
Exclude tags |
| Flag | Description |
|---|---|
--retrieval-lane |
Query lane: auto, dialogue, actions, hybrid |
--similar |
Semantic similarity query (requires embeddings) |
Verbs determine the action applied to the matched session set.
| Verb | Description |
|---|---|
read --all |
Read every matched session with metadata |
analyze --count |
Print count of matched sessions |
analyze --by ... |
Grouped statistics (origin, month, year, day, action, tool, repo, work-kind) |
read |
Display session content through read views |
read --to browser |
Open session in browser |
read --all --format ... |
Render every matched session in the selected format |
read --view messages |
Show individual messages |
read --view raw |
Show raw (unparsed) session data |
select |
Select and print a single field |
delete |
Delete matched sessions (requires --dry-run confirmation) |
Lane selection lives on the query as retrieval_lane. The resolved value
appears in the SearchEnvelope.retrieval_lane field on every response so
consumers can tell what actually ran (which matters because auto resolves to
a concrete lane — see below).
| Lane | Description | Score kind |
|---|---|---|
auto |
Default lexical planner lane. Resolves to dialogue for ordinary text queries; vector work is explicit through --semantic, --similar, or --retrieval-lane hybrid. |
bm25 |
dialogue |
FTS5 over message text (messages_fts virtual table, unicode61 tokenizer). Default lexical lane. |
bm25 |
actions |
FTS5 over tool-use/tool-result block text in messages_fts. Targets tool/file/shell evidence rather than prose. Public ranked-hit payloads currently carry action rank/evidence without a numeric action BM25 score. |
null |
hybrid |
Reciprocal Rank Fusion combining FTS5 and vector similarity (requires embeddings). | rrf |
semantic |
Pure vector similarity over Voyage-4 embeddings via sqlite-vec. Triggered by --similar or --semantic. |
vector_distance |
Implementation: polylogue/storage/search/query_builders.py,
polylogue/storage/search_providers/hybrid.py,
polylogue/storage/search_providers/sqlite_vec_support.py.
- Backed by SQLite FTS5's BM25 implementation against
messages_fts. - Tokenizer is
unicode61 remove_diacritics 2— case-insensitive for ASCII, Unicode-aware tokenization, and folds ordinary combining-mark diacritics (ó->o,ż->z,ą->a, ...) so a plain-ASCII query finds accented content and vice versa. Porter stemming is not available in this SQLite build, sorefactorandrefactoringare distinct tokens. Use prefix queries (refactor*) when you want morphological breadth. ł/Ł(Latin L with stroke) has no Unicode decomposition, soremove_diacriticscannot reach it.pl_fold(polylogue/storage/fts/pl_fold.py) closes that specific gap: it foldsł/Łinto indexed text before FTS insertion and intoMATCHquery text inescape_fts5_query, solatwo/zrobilemfinds seededłatwo/zrobiłem(polylogue-9jsi). The same tokenizer and fold apply to thethreads_ftsandsession_work_events_ftsinsight-search surfaces, including full rebuild, missing-row repair, and dangling-row repair paths. A trigram fallback lane for further recall (beyond word-boundary tokens) is deliberately not part of this fold — see polylogue-xul7 (tracked follow-up) for a measured, benchmarked trigram lane before any such lane ships or is defaulted on.- Raw score is BM25: lower is better in SQLite FTS5, values are typically negative, and they are not comparable across queries.
- Match evidence:
matched_terms,snippet,match_surface="message",message_id, andtarget_refpoint at the hit message.
- Same FTS5 mechanics as
dialogue, but the query is restricted totool_useandtool_resultblocks insidemessages_fts. The normalizedactionsview remains the structured action surface for filters and analytics. - Current public action-lane hits preserve rank and action match surface
but do not expose the underlying action FTS BM25 score in the shared
SearchEnvelope; consumers should treatscore_kind=nullas the contract for action-only hits until the action evidence path is widened. - Useful when you remember an action ("the session where I edited
connection_profile.py") rather than its prose.
- Runs both
dialogue(FTS5) andsemantic(vector) lanes, then fuses with Reciprocal Rank Fusion atk=60:fused_score = Σ 1 / (k + rank_in_lane). - Tie-breaking is deterministic: descending fused score, then ascending
session_id. This makes cursor and offset pagination stable across runs even when scores tie. - Reported
score_kindis"rrf". Higher fused scores indicate stronger cross-lane consensus. - Lane contributions (per-lane rank and per-lane RRF contribution)
are preserved end-to-end on each hit's
score_components(#1267): every lane that contributed adds a<lane>_rank(1-based rank within that lane) and a matching<lane>_rrf(the1 / (k + rank)contribution that was summed into the fused score). Lane names aretext(FTS5 dialogue),action(FTS5 action blocks), andvector(semantic). A hit that appeared only in the lexical lane carries{text_rank, text_rrf}and nothing else; a hit that survived both lanes carries the full(text|action|vector)_(rank|rrf)set, so consumers can show "ranked high in both lexical and semantic lanes" without re-running the search.
- Pure k-nearest-neighbor over Voyage-4 1024-dim embeddings via
sqlite-vec's
vec0virtual table. - Triggered by
--similar <text>or--semantic(which promotes the positional query string intosimilar_text; no FTS leg runs). - Score kind is
"vector_distance"— lower means closer in embedding space. Like BM25, distances are not directly comparable across different query embeddings. - Requires embeddings to be enabled and populated; see docs/architecture.md § Embedding Pipeline.
- Use
polylogue ops embed statusto check whether vector retrieval is disabled, missing an API key, pending backlog catch-up, partially usable, or complete.polylogue ops embed status --detailperforms exact pending-message and retrieval-band accounting; the default status path stays cheap and reports the latest persisted catch-up run.
Semantic search stays unavailable until embeddings are both enabled and materialized. The activation path is deliberately bounded:
polylogue ops embed statusshows config state, key presence, coverage, configured model/dimension, monthly cost cap, backlog, latest catch-up progress, andnext_action(code,reason,command) for automation.polylogue ops embed preflight --max-sessions 10estimates the next bounded window without contacting Voyage. Use--format jsonfor the scriptable form: it reports the exact window, pricing assumptions, effective cost cap, and a ready-to-runbackfill_argslist for the same bounded catch-up slice.polylogue ops embed enable --yesenables the daemon stage when a Voyage key is already configured, orpolylogue ops embed enable --voyage-api-key ...records the key and enables the stage.polylogue ops embed backfill --max-sessions 10runs an explicit bounded catch-up batch; after enablement,polyloguedalso processes bounded daemon batches for new or stale sessions.
--max-messages is a hard message-count window and uses live message counts
rather than potentially stale session_stats. --max-sessions may
use materialized session stats so small first batches remain fast on large
archives.
MCP clients should use embedding_status for readiness/next-action state and
embedding_preflight for the same no-provider-call cost window. Both tools are
read-only and return the canonical JSON payloads used by the CLI, so agents do
not need to scrape terminal output before deciding whether semantic search is
actually usable.
Python API clients use the same contracts through
Polylogue.embedding_status(detail=False) and
Polylogue.embedding_preflight(...).
When retrieval_lane=auto (the default), ordinary text queries run on the
lexical dialogue lane. The planner does not inspect embeddings.db before a
default search, so keyword lookup stays fast and predictable on large archives.
| Condition | Resolved lane |
|---|---|
--lexical flag set |
dialogue (forced FTS-only) |
--semantic flag set, or --similar <text> given |
semantic (vector-only) |
--retrieval-lane hybrid with an FTS query |
hybrid |
| Otherwise | dialogue |
The resolved lane is always echoed back in SearchEnvelope.retrieval_lane so
callers do not have to re-derive what ran.
Two ergonomic overrides on the root CLI surface (#1217):
--lexical— force the FTS-only lane; useful when you want deterministic keyword matches regardless of embedding state.--semantic— promote the query string into a vector-only similarity probe; no FTS leg, no boolean operators applied.
Every SearchEnvelope declares its ranking_policy and
ranking_policy_version. The current policy identifier is
mixed-bm25-rrf-vector (version 1):
dialogueorders hits by FTS5 BM25 (lower is better; raw scores are usually negative).actionsorders through the action FTS read model, but the public action-lane hit payload does not currently expose a numeric action score.hybridfuses dialogue + action + semantic lanes with RRF atk=60and orders by fused score, breaking ties on(−fused_score, session_id).semanticorders by ascending vector distance.
Consumers should pin the ranking_policy_version they validate against
and treat any change as a contract event. The version is intentionally
exposed so external pipelines can detect ordering shifts without diffing
raw scores. See docs/openapi/search.yaml (x-polylogue-ranking-policy)
for the machine-readable declaration.
All ranked surfaces (CLI --format json, MCP search/list_sessions,
daemon GET /api/sessions?query=…, Python API) return the typed
SearchEnvelope defined in polylogue/surfaces/payloads.py and emitted
via the daemon under
docs/openapi/search.yaml (#1266).
| Field | Meaning |
|---|---|
hits |
Ordered list of SessionSearchHitPayload. Each hit carries a session summary plus a match evidence block. |
total |
Total matching sessions, or null when the lane cannot compute it cheaply. |
limit / offset |
Applied page size and row offset. Offset-based pagination is best-effort for ranked results. |
next_offset |
Convenience offset pointer; only set when more results are likely. |
next_cursor |
Opaque keyset cursor encoding rank, score, session id, and resolved retrieval lane. Preferred for stable rank-first pagination across pages — pass it back unchanged in the next request. |
query |
The FTS query text actually applied after CLI/MCP/HTTP coercion. Empty when no FTS query was given. |
sort |
Applied explicit sort field ("date", "messages", "words", etc.) or null to preserve the lane's natural rank order. Ranked search will not silently fall back to date sort. |
retrieval_lane |
Resolved lane that actually ran (dialogue / actions / hybrid / semantic / auto). |
ranking_policy / ranking_policy_version |
Declared ordering semantics; see above. |
diagnostics |
Optional QueryMissDiagnosticsPayload when the query produced zero hits but filters were applied. |
Each hit's match (a SessionSearchMatchPayload) carries:
| Field | Meaning |
|---|---|
rank |
1-based position in the result list. |
retrieval_lane |
Lane that produced this hit (matches envelope, unless future per-hit attribution differs). |
match_surface |
Indexed surface that matched — e.g. message, action, hybrid, semantic, or attachment. |
score |
Raw lane score (semantics depend on score_kind). |
score_kind |
One of "bm25", "rrf", "vector_distance", or null for identity-only lanes. Always check this before comparing or ordering by score directly. |
score_components |
Map of per-component contributions explaining the rank. Dialogue (FTS5) hits carry {"bm25_raw": <relevance>}; hybrid hits carry per-lane <lane>_rank and <lane>_rrf entries summed into score (#1267). Identity-only lanes (e.g. attachment) carry {}. |
lane_rank / lane_contribution |
Primary lane rank and contribution when the backend can identify one. For hybrid, this is the strongest contributing RRF lane; all lane details still live in score_components. |
raw_score |
Backend-native score before public interpretation. For FTS this is BM25 relevance; for hybrid it is the fused RRF score. |
matched_terms |
FTS terms that triggered the match. |
snippet |
Highlighted excerpt around the match (FTS5 snippet). |
message_id / target_ref / anchor |
Stable identifiers pointing the reader at the matching message or sub-block. |
actions |
Per-target reader action availability (open, copy-link, etc.). |
bm25— lower = better match in SQLite FTS5. Values are typically negative. Never display raw BM25 as a percent or compare across queries; rank position is the durable signal.rrf— higher = better; bounded byΣ 1/(k+1)over contributing lanes. Per-lane decomposition lives inscore_componentsas<lane>_rank/<lane>_rrfpairs, so consumers can explain "this hit appeared in both lexical and semantic lanes" without re-running the query (#1267).vector_distance— lower = closer in embedding space. Not comparable across different query embeddings.null— identity-bearing match (e.g. attachment identity lane); no numeric score, only rank.
Per-hit explanations (#1267)
Every ranked hit carries deterministic why-this-matched evidence on its
match payload. The exact field set depends on the lane that produced
the hit:
| Lane | matched_terms |
score_kind |
score_components |
|---|---|---|---|
dialogue (FTS5 over messages) |
tokenized query terms (lowercased, FTS5 operators stripped) | bm25 |
{"bm25_raw": <relevance>} |
actions (FTS5 over action blocks) |
tokenized query terms | null today |
{} today; action rank is preserved, but action BM25 is not part of the public hit evidence contract yet |
hybrid (RRF fusion) |
tokenized query terms | rrf |
<lane>_rank and <lane>_rrf for every contributing lane (text / action / vector); score equals the sum of *_rrf |
semantic (vector-only) |
the query string passed to --similar / --semantic (single term) |
vector_distance |
{} (raw distance lives in score) |
attachment (identity lookup) |
the matched identifier (single term) | null |
{} (identity hits have no numeric rank) |
Tokenization for matched_terms strips FTS5 boolean operators
(AND / OR / NOT / NEAR), quote/colon/paren punctuation, and
trailing * prefix markers, then deduplicates case-insensitively. The
result is the literal set of tokens a reader can expect to see
highlighted in the snippet.
Hybrid score_components are the load-bearing surface: each
contributing lane adds two entries that explain its share of the fused
score. For example, a hit at lexical rank 1 and vector rank 2 carries:
"score_components": {
"text_rank": 1.0,
"text_rrf": 0.0163934426,
"vector_rank": 2.0,
"vector_rrf": 0.0161290323
},
"score": 0.0325224749Consumers can read score_components directly to render a "matched in
both lanes" badge or to debug ranking drift without re-running the
search.
For ranked queries, prefer next_cursor over offset. Cursor
pagination encodes the rank tie-breaker
((rank, score, session_id, retrieval_lane)) and is stable under
archive growth between page fetches. Offset pagination is supported for
non-ranked list paths and as a best-effort fallback for ranked paths.
The cursor is an opaque URL-safe base64 token (a versioned JSON
envelope; see :class:polylogue.surfaces.payloads.SearchCursor).
Consumers MUST treat it as opaque and pass it back unchanged.
# Page 1
polylogue "sqlite" read --all --format json --limit 25
# Read .next_cursor from the response, then ask for page 2:
polylogue "sqlite" read --all --format json --limit 25 \
--cursor "$NEXT_CURSOR"MCP search and the daemon /api/sessions endpoint accept the same
cursor parameter; the Python API exposes Polylogue.search_envelope( query, cursor=...). The cursor carries the retrieval lane it was
minted in, so a dialogue cursor passed back to a hybrid request is
rejected up-front rather than silently changing ranking policy
mid-walk.
Stability guarantees (#1268):
- No duplicates: any hit returned on page N is filtered out on page N+1 even when new rows were ingested between requests.
- No gaps: any hit that sorts strictly after the anchor (under the lane's natural ordering: BM25 lower-is-better, RRF higher-is-better, vector distance lower-is-better) survives the cursor trim.
- Restart-stable: cursors are self-contained tokens with no server-side state; they survive daemon restart.
The messages_fts virtual table uses SQLite's FTS5 with the unicode61
tokenizer. Prefix queries use *:
polylogue "refactor*"Phrase queries use quotes:
polylogue '"null pointer exception"'Boolean operators combine terms:
polylogue "refactor AND schema NOT test"The strict Polylogue command floor does not expose raw FTS5 column-filter
syntax. A token such as text:css is parsed as a query-DSL field and rejected.
Use a parser-valid content predicate instead:
polylogue 'contains:"css refactor"'blocks.search_text — the generated column FTS5 indexes — concatenates a
fixed subset of block fields, not the whole block. Anything outside that
subset is invisible to the ranked dialogue/actions/hybrid lanes,
--contains, contains:, and bare-text queries, even though the underlying
data is stored and readable through other surfaces (read, select, direct
block/action reads). The live definition lives in
polylogue/storage/sqlite/archive_tiers/index.py (blocks.search_text GENERATED ALWAYS AS (...)); this table is drift-checked against that
expression by
tests/unit/pipeline/test_search_text_coverage_contract.py.
| Source | In search_text (FTS-searchable) |
Notes |
|---|---|---|
blocks.text — message prose, thinking/reasoning block content, tool_result output |
Yes | Every text-bearing block type stores its content in this column, so thinking/reasoning text and tool-result stdout/stderr/output are all searchable today. |
blocks.tool_name |
Yes | Tool identifier string (Write, Bash, Edit, ...). |
tool_input.$.command |
Yes | Shell/exec command lines (Bash, exec_command-style tools). |
tool_input.$.file_path |
Yes | Primary path argument for file-oriented tools. |
tool_input.$.path |
Yes | Alternate path key used by some tools (Grep, Glob). |
tool_input.$.content — file bodies a Write tool call authored |
No | The full text an agent wrote into a new/overwritten file is excluded from search_text. A distinctive string that only appears inside a Write body returns zero FTS hits. |
tool_input.$.old_string / tool_input.$.new_string — Edit tool bodies |
No | Same gap as Write: edited code is excluded unless it also happens to appear in prose, a tool_result echo, or one of the indexed fields above. |
Any other tool_input key (pattern, description, url, ...) |
No | Only the three json_extract paths above are concatenated into search_text; every other key is excluded regardless of tool. |
Dedicated action-evidence lane: when you know an agent wrote or edited a
specific string into a file body, use the query DSL's action text predicate.
It searches the action's raw tool_input JSON (as well as normalized action
fields and output), so it covers Write's content and Edit's
old_string/new_string without putting those bodies in FTS:
polylogue 'actions where tool:write AND text:"needle"'
polylogue 'sessions where exists action(tool:edit AND text:"needle")'This is an unindexed LIKE filter, not FTS; constrain it by action, path,
session scope, or time on large archives. For direct index.db inspection,
the equivalent JSON-aware SQL probe is:
SELECT block_id, session_id, tool_name,
json_extract(tool_input, '$.file_path') AS file_path
FROM blocks
WHERE tool_name IN ('Write', 'Edit')
AND (
json_extract(tool_input, '$.content') LIKE '%needle%'
OR json_extract(tool_input, '$.old_string') LIKE '%needle%'
OR json_extract(tool_input, '$.new_string') LIKE '%needle%'
);Extending search_text itself to cover $.content was considered and
deliberately deferred (see polylogue-013x) because Write bodies can be large
enough to meaningfully bloat the FTS index, and that tradeoff needs a size
probe against a live archive plus a derived-tier index rebuild before it is
decided, not a silent schema bump.
| Format | Description |
|---|---|
markdown |
Default -- formatted markdown with syntax-highlighted code blocks |
json |
Full session as JSON |
jsonl |
One JSON object per line (used by read --all --format ndjson) |
yaml |
YAML representation |
plaintext |
Plain text, no formatting |
html |
HTML with Pygments syntax highlighting |
obsidian |
YAML frontmatter + markdown body |
org |
Org-mode format |
csv |
Messages as rows |
Set format with -f / --format on a verb:
polylogue "sqlite locking" read --all --format json
polylogue --since yesterday read --all --format ndjsonFacets summarize the archive as aggregate counts. Polylogue exposes the
same shape across daemon HTTP (GET /api/facets), MCP (facets), CLI
(polylogue facets), and the Python API (Polylogue.facets); see
#1269 (slice D of
#873).
A facets response carries both views explicitly:
scoped— counts rolled from the current query/filter set. Empty buckets if the filter chain narrows away every value.global— counts over the unfiltered archive. Always populated.scoped_to_query—truewhenever any filter narrowed the view.complete_families/deferred_families/family_status— route-state metadata for each facet family. HTTP readers use this to distinguish an actually-empty bucket from a family intentionally not materialized in the current response.generated_at,stale,stale_age_s,budget_exceeded— freshness and budget metadata surfaced to the web workbench.idf— inverse-document-frequency per facet value, computed against the global universe. Higher = rarer = stronger signal; near zero = value appears in almost every session. Disable with--no-idfon the CLI.
Top-level fields (origins, tags, total_sessions etc.)
mirror the active view (scoped when filtered, global otherwise) for
backward compatibility with surfaces written before #1269. Consumers
that need both views should read scoped and global directly.
Daemon HTTP keeps expensive archive-wide families lazy for workbench
first paint. GET /api/facets includes origin/tag/count buckets and marks
repos plus action_types as deferred_by_default. Clients that need the
full set can call GET /api/facets?include_deferred=1&budget_ms=5000 or
GET /api/facets?families=repos,action_types; a budget overrun returns
the existing bucket fields plus budget_exceeded=true and per-family
deferred reasons instead of blocking the page indefinitely. HTTP workbench
requests attach request IDs and bounded AbortController timeouts; the daemon
observes closed client sockets inside facet SQLite progress handlers so
cancelled/timed-out browser requests can interrupt already-started scans.
polylogue facets # global only (no filters)
polylogue facets -o chatgpt-export # scoped to ChatGPT exports + global side-by-side
polylogue facets -q "vector store" # scoped to FTS hits
polylogue facets -f json --no-idf # FacetsResponse, no IDF weightingWhen a query returns no results:
- Check origin spelling:
polylogue --origin claude-code-session read --all(notclaude_code) - Expand the time window:
--since 2024-01instead of--since yesterday - Verify the archive has data:
polylogue analyze --count(no filters) - Check FTS index health:
polylogued statusshowsfts_readiness - Run
polylogue ops doctorfor schema and index integrity - If using
--similar, ensure embeddings are built (checkpolylogue ops embed status --detailfor embedding readiness/coverage) - If you know an agent wrote or edited a specific string into a file (a
Write/Edittool body), FTS will not find it. Use the action-evidence query path documented in Searchable Content Coverage, such asactions where tool:write AND text:"needle".