Skip to content

Commit eba63b5

Browse files
authored
Merge slice/031-session-search-fts
Slice 031: session search (FTS5). PROOF.md at slices/031-session-search-fts/PROOF.md; gate, postgres and fips jobs green on the closing tree. Signed-off-by: Ayla Croft <aylacroft@proton.me>
2 parents d9ee12d + c73fc47 commit eba63b5

27 files changed

Lines changed: 976 additions & 6 deletions

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ standards register names the rows that ask for them.
4545
| 025 | Encryption at rest, and the key-custody seam | 2 Tools | M | 010, 024 | planned |
4646
| 026 | Store-and-forward receipts for disconnected operation | 2 Tools | L | 024 | planned |
4747
| 030 | Persona (SOUL) + always-on memory tier | 3 Memory | M | 012 | planned |
48-
| 031 | Session search (SQLite FTS5) | 3 Memory | S | 010 | planned |
48+
| 031 | Session search (SQLite FTS5) | 3 Memory | S | 010 | done |
4949
| 032 | Embeddings + semantic memory + hybrid retrieval | 3 Memory | L | 031 | planned |
5050
| 033 | Project context: AGENTS.md | 3 Memory | S | 030, 022 | planned |
5151
| 034 | Export, import, restore | 3 Memory | S | 030, 031 | planned |

‎config/config.exs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,16 @@ config :trinity, :tools,
7171
Trinity.Tools.FS.Grep,
7272
Trinity.Tools.Web.Fetch,
7373
Trinity.Tools.Web.Search,
74+
# Slice 031: full-text search over past messages.
75+
Trinity.Tools.SessionSearch,
7476
Trinity.Tools.Shell.Run
7577
],
7678
toolsets: %{
7779
fs: ["fs_read", "fs_write", "fs_edit", "fs_list", "fs_glob", "fs_grep"],
7880
web: ["web_fetch", "web_search"],
79-
shell: ["shell"]
81+
shell: ["shell"],
82+
# Slice 031: search over past conversations.
83+
memory: ["session_search"]
8084
}
8185

8286
# Slice 022: the filesystem roots beside the data directory (always a root) and the session's

‎config/test.exs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,17 @@ config :trinity, :tools,
2525
Trinity.Tools.FS.Grep,
2626
Trinity.Tools.Web.Fetch,
2727
Trinity.Tools.Web.Search,
28+
# Slice 031: full-text search over past messages.
29+
Trinity.Tools.SessionSearch,
2830
Trinity.Tools.Shell.Run
2931
],
3032
toolsets: %{
3133
core: ["echo", "sleep", "crash", "big", "write_note"],
3234
fs: ["fs_read", "fs_write", "fs_edit", "fs_list", "fs_glob", "fs_grep"],
3335
web: ["web_fetch", "web_search"],
34-
shell: ["shell"]
36+
shell: ["shell"],
37+
# Slice 031: search over past conversations.
38+
memory: ["session_search"]
3539
},
3640
timeout_ms: 2_000
3741

‎coverage.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ slice_id percent sha date
1111
023 75.39 5989b73 2026-09-20
1212
003 75.39 39518c2 2026-09-20
1313
024 76.55 f977b84 2026-09-20
14+
031 76.96 866dc3f 2026-09-20

‎docs/01-architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ without anything failing.
7474
|---|---|---|
7575
| `Trinity.Sessions` | Session process, turn loop, message log | LLM, Tools, **Effects**, Permissions, Memory, Skills, Repo, PubSub |
7676
| `Trinity.LLM` | Provider behaviour, req_llm adapter, model registry, streaming, usage | Repo (usage), Telemetry |
77-
| `Trinity.Tools` | Tool behaviour, registry, execution runtime, core tools, and (as built at 024) the compile-time effect catalog `Trinity.Tools.Catalog`, because the registry reads it and Effects depends on Tools | Permissions, Sandbox, Repo |
77+
| `Trinity.Tools` | Tool behaviour, registry, execution runtime, core tools, and (as built at 024) the compile-time effect catalog `Trinity.Tools.Catalog`, because the registry reads it and Effects depends on Tools | Permissions, Sandbox, Repo, **Memory** (as built at 031: `session_search` reads the index; Memory never depends on Tools) |
7878
| `Trinity.Permissions` | Policy, tier/1 (name-only), fingerprint-bound approvals, override adjudication | Repo, PubSub |
7979
| `Trinity.Effects` | The membrane; the runner in force (`Effects.Runner`, the executor `Tools.Runner` takes as a function); decision and query receipts; the boot receipt | **Tools**, Permissions, Authority, Receipts, Repo |
8080
| `Trinity.Authority` | Behaviour; `Local` implementation (the one caller of `execute/2` for effectful tools); selection at boot; `Staged` | Receipts, Repo |

‎docs/05-data-model.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ turn ended that way); the role, seq and session never change.
4848
### messages_fts (Slice 031): SQLite `fts5(content, session_id UNINDEXED, message_id UNINDEXED)`; on Postgres a
4949
`tsvector` generated column on `messages`.
5050

51+
As built: SQLite `messages_fts` with `tokenize = 'porter unicode61'`, `rowid` equal to the message row's, kept
52+
by three triggers (`messages_fts_ai`, `_ad`, `_au` on `content`) and backfilled by the migration; rebuildable by
53+
`mix trinity.search.reindex`. Postgres: `messages.content_tsv` generated as `to_tsvector('english', content)` with
54+
the GIN index `messages_content_tsv_idx`; nothing to rebuild. Stemming is suffix-based on both: "running" meets
55+
"runs" at `run` and never "ran". `Trinity.Memory.Search.messages/2` binds the query as a parameter and quotes
56+
every term for FTS5, so operators are text.
57+
5158
### memories (Slice 030/032)
5259
| column | type | notes |
5360
|---|---|---|
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Mix.Tasks.Trinity.Search.Reindex do
4+
@shortdoc "Rebuilds the message search index from the messages table"
5+
6+
@moduledoc """
7+
Slice 031. On SQLite, empties `messages_fts` and refills it from `messages`, then optimises;
8+
on Postgres the index is a generated column and there is nothing to rebuild, which the task
9+
says. Prints the time.
10+
11+
mix trinity.search.reindex
12+
"""
13+
use Boundary, classify_to: Trinity
14+
use Mix.Task
15+
16+
@impl Mix.Task
17+
def run(_argv) do
18+
Mix.Task.run("app.start")
19+
{us, {:ok, what}} = :timer.tc(fn -> Trinity.Memory.Search.reindex() end)
20+
21+
case what do
22+
:rebuilt ->
23+
Mix.shell().info("reindexed messages_fts in #{div(us, 1000)} ms")
24+
25+
:generated_column ->
26+
Mix.shell().info("nothing to rebuild: content_tsv is a generated column")
27+
end
28+
end
29+
end

‎lib/trinity.ex‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ defmodule Trinity do
2828
LLM,
2929
Memory,
3030
Memory.Tokens,
31+
Memory.Search,
3132
Tools,
3233
Permissions,
3334
Permissions.Approval,

‎lib/trinity/memory.ex‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ defmodule Trinity.Memory do
66
to 032 add the tiers, the search and the semantic recall. It depends on the LLM and on the
77
core, never on Sessions: the Session calls it and writes what it returns.
88
"""
9-
use Boundary, deps: [Trinity, Trinity.LLM], exports: [Tokens, Compactor]
9+
use Boundary, deps: [Trinity, Trinity.LLM], exports: [Tokens, Compactor, Search]
1010
end

‎lib/trinity/memory/search.ex‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.Memory.Search do
4+
@moduledoc """
5+
Full-text search over every message (slice 031). SQLite: the `messages_fts` FTS5 table,
6+
porter stemming, `bm25()` order, `snippet()`; Postgres: the generated `content_tsv` column,
7+
`plainto_tsquery('english')`, `ts_rank`, `ts_headline`. Same shape of hit either way, so the
8+
tool and the page do not know which database they run on.
9+
10+
The query text is bound as a parameter and never spliced into SQL. On SQLite every term is
11+
additionally quoted for FTS5, so a user's `"`, `*`, `-` or `OR` is text to find, not an
12+
operator; the search is "all these words", stemmed, in any order. Stemming is a suffix
13+
operation: "running" and "runs" meet at "run", and "ran" does not (NOTES.md, fact 2).
14+
15+
Schemaless queries on purpose: Memory depends on the core and LLM, never on Sessions
16+
(docs/01), so the tables are named here and the ids are cast through `Trinity.UUID`.
17+
"""
18+
19+
import Ecto.Query
20+
21+
alias Trinity.Repo
22+
23+
@default_limit 20
24+
@max_limit 100
25+
26+
# The adapter is fixed at compile time (config/config.exs, slice 010), so the two databases'
27+
# query shapes are compiled in, not chosen at run time; the postgres job proves the other.
28+
@adapter Application.compile_env(:trinity, :db_adapter, Ecto.Adapters.SQLite3)
29+
30+
@type hit :: %{
31+
message_id: String.t(),
32+
session_id: String.t(),
33+
session_title: String.t() | nil,
34+
seq: non_neg_integer(),
35+
role: String.t(),
36+
snippet: String.t(),
37+
inserted_at: DateTime.t(),
38+
rank: float()
39+
}
40+
41+
@doc """
42+
Ranked hits for `query`. Options: `limit:` (#{@default_limit}, at most #{@max_limit}), `role:`
43+
(a message role), `persona_id:`, `since:` and `until:` (`DateTime`, on the message's
44+
`inserted_at`). An empty or all-punctuation query is no hits.
45+
"""
46+
@spec messages(String.t(), keyword()) :: [hit()]
47+
def messages(query, opts \\ []) when is_binary(query) do
48+
case terms(query) do
49+
[] -> []
50+
terms -> terms |> build(opts) |> Repo.all() |> Enum.map(&to_hit/1)
51+
end
52+
end
53+
54+
@doc "The words of a query, punctuation dropped; the population FTS5 and tsquery both receive."
55+
@spec terms(String.t()) :: [String.t()]
56+
def terms(query) do
57+
query
58+
|> String.split(~r/[^\p{L}\p{N}_']+/u, trim: true)
59+
|> Enum.map(&String.replace(&1, "'", ""))
60+
|> Enum.reject(&(&1 == ""))
61+
|> Enum.take(32)
62+
end
63+
64+
@doc "Rebuilds the index from `messages`; on Postgres the column is generated and this reports so."
65+
@spec reindex() :: {:ok, :rebuilt | :generated_column}
66+
if @adapter == Ecto.Adapters.SQLite3 do
67+
def reindex do
68+
Repo.transaction(fn ->
69+
Repo.query!("DELETE FROM messages_fts")
70+
71+
Repo.query!(
72+
"INSERT INTO messages_fts(rowid, content, session_id, message_id) SELECT rowid, content, session_id, id FROM messages"
73+
)
74+
75+
Repo.query!("INSERT INTO messages_fts(messages_fts) VALUES('optimize')")
76+
end)
77+
78+
{:ok, :rebuilt}
79+
end
80+
else
81+
def reindex, do: {:ok, :generated_column}
82+
end
83+
84+
defp build(terms, opts) do
85+
limit = opts |> Keyword.get(:limit, @default_limit) |> min(@max_limit) |> max(1)
86+
87+
base()
88+
|> match(terms)
89+
|> filter(:role, opts[:role])
90+
|> filter(:persona_id, opts[:persona_id])
91+
|> filter(:since, opts[:since])
92+
|> filter(:until, opts[:until])
93+
|> limit(^limit)
94+
end
95+
96+
defp base do
97+
from(m in "messages",
98+
join: s in "sessions",
99+
on: s.id == m.session_id,
100+
select: %{
101+
message_id: type(m.id, Trinity.UUID),
102+
session_id: type(m.session_id, Trinity.UUID),
103+
session_title: s.title,
104+
seq: m.seq,
105+
role: m.role,
106+
inserted_at: type(m.inserted_at, :utc_datetime_usec),
107+
persona_id: type(s.persona_id, Trinity.UUID)
108+
}
109+
)
110+
end
111+
112+
# SQLite: every term quoted for FTS5 (a double quote inside is doubled), joined by spaces,
113+
# which FTS5 reads as AND; the whole string is one bound parameter.
114+
if @adapter == Ecto.Adapters.SQLite3 do
115+
defp match(query, terms) do
116+
needle = Enum.map_join(terms, " ", &("\"" <> String.replace(&1, "\"", "\"\"") <> "\""))
117+
118+
from([m, s] in query,
119+
join: f in "messages_fts",
120+
on: f.rowid == m.rowid,
121+
where: fragment("messages_fts MATCH ?", ^needle),
122+
order_by: fragment("bm25(messages_fts)"),
123+
select_merge: %{
124+
snippet: fragment("snippet(messages_fts, 0, '[', ']', '…', 12)"),
125+
rank: fragment("bm25(messages_fts)")
126+
}
127+
)
128+
end
129+
else
130+
defp match(query, terms) do
131+
needle = Enum.join(terms, " ")
132+
133+
from([m, s] in query,
134+
where: fragment("? @@ plainto_tsquery('english', ?)", m.content_tsv, ^needle),
135+
order_by: [
136+
desc: fragment("ts_rank(?, plainto_tsquery('english', ?))", m.content_tsv, ^needle)
137+
],
138+
select_merge: %{
139+
snippet:
140+
fragment(
141+
"ts_headline('english', ?, plainto_tsquery('english', ?), 'StartSel=[, StopSel=], MaxWords=12, MinWords=6')",
142+
m.content,
143+
^needle
144+
),
145+
rank: fragment("ts_rank(?, plainto_tsquery('english', ?))", m.content_tsv, ^needle)
146+
}
147+
)
148+
end
149+
end
150+
151+
defp filter(query, _key, nil), do: query
152+
defp filter(query, :role, role), do: from([m, s] in query, where: m.role == ^role)
153+
154+
defp filter(query, :persona_id, id),
155+
do: from([m, s] in query, where: s.persona_id == type(^id, Trinity.UUID))
156+
157+
defp filter(query, :since, at), do: from([m, s] in query, where: m.inserted_at >= ^at)
158+
defp filter(query, :until, at), do: from([m, s] in query, where: m.inserted_at <= ^at)
159+
160+
defp to_hit(row) do
161+
row
162+
|> Map.delete(:persona_id)
163+
|> Map.update!(:rank, &(&1 * 1.0))
164+
end
165+
end

0 commit comments

Comments
 (0)