Skip to content

Commit 9c6acff

Browse files
authored
Merge pull request #45 from ScriptKittyOS/slice/032-semantic-memory
feat(s032): complete slice 032 (semantic memory and hybrid retrieval) Signed-off-by: Ayla Croft <aylacroft@proton.me>
2 parents 190d206 + cb0dc62 commit 9c6acff

66 files changed

Lines changed: 4225 additions & 140 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/gate.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ jobs:
6060
runs-on: ubuntu-latest
6161
services:
6262
postgres:
63-
image: postgres:17
63+
# Slice 032: pgvector's image of Postgres 17, so the vector extension exists for the
64+
# semantic memory's Postgres store.
65+
image: pgvector/pgvector:pg17
6466
env:
6567
POSTGRES_USER: trinity
6668
POSTGRES_PASSWORD: trinity

‎.github/workflows/package.yml‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,20 @@ jobs:
192192
ours() { ps -eo pid,ppid,comm | grep -E 'burrito|beam\.smp|erl_child_setup|desktop_' | grep -v grep || true; }
193193
ours > ps-before.txt
194194
chmod +x "burrito_out/${{ matrix.artifact }}"
195-
"./burrito_out/${{ matrix.artifact }}" --no-halt --smoke | tee smoke.log
195+
# Slice 032: the variable, not `--smoke`: Kernel.CLI read the argument as a file
196+
# ("No file named --smoke", exit 1) and won the race against the halting Task on
197+
# macOS in run 35608084951.
198+
TRINITY_SMOKE=1 "./burrito_out/${{ matrix.artifact }}" --no-halt | tee smoke.log
196199
sleep 3
197200
ours > ps-after.txt
198201
grep -q '^TRINITY_SMOKE_PORT=[0-9][0-9]*$' smoke.log
199202
# Slice 013: the renderer's NIF loaded and rendered in this binary (exit 3 otherwise).
200203
grep -q '^TRINITY_SMOKE_MARKDOWN=ok$' smoke.log
204+
# Slice 032, AC7: a fake-vector search ran inside this binary through the vector
205+
# store in force (exit 4 otherwise). The EXLA and SEMANTIC lines are recorded, not
206+
# asserted: whether the XLA library loads in Burrito's ERTS is what they measure.
207+
grep -q '^TRINITY_SMOKE_VEC=ok:' smoke.log
208+
grep -E '^TRINITY_SMOKE_(EXLA|SEMANTIC)=' smoke.log
201209
echo "--- ours, before ---"; cat ps-before.txt
202210
echo "--- ours, after ----"; cat ps-after.txt
203211
diff ps-before.txt ps-after.txt
@@ -207,14 +215,20 @@ jobs:
207215
shell: pwsh
208216
run: |
209217
$ErrorActionPreference = "Stop"
210-
& ".\burrito_out\${{ matrix.artifact }}" --no-halt --smoke | Tee-Object smoke.log
218+
$env:TRINITY_SMOKE = "1"
219+
& ".\burrito_out\${{ matrix.artifact }}" --no-halt | Tee-Object smoke.log
211220
if ($LASTEXITCODE -ne 0) { throw "smoke exited $LASTEXITCODE" }
212221
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_PORT=\d+$')) {
213222
throw "no port line"
214223
}
215224
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_MARKDOWN=ok$')) {
216225
throw "the markdown renderer did not render in this binary (slice 013)"
217226
}
227+
# Slice 032, AC7, as on the other two: the vector search binds, the other two lines are recorded.
228+
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_VEC=ok:')) {
229+
throw "the vector search did not run inside this binary (slice 032)"
230+
}
231+
Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_(EXLA|SEMANTIC)='
218232
219233
# AC1's property on a runner, and AC6's per-OS cold start, which used to be skipped.
220234
# `bash` on every OS, Windows included: the job that never curled was the one gap in

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ standards register names the rows that ask for them.
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 | approved |
4848
| 031 | Session search (SQLite FTS5) | 3 Memory | S | 010 | approved |
49-
| 032 | Embeddings + semantic memory + hybrid retrieval | 3 Memory | L | 031 | planned |
49+
| 032 | Embeddings + semantic memory + hybrid retrieval | 3 Memory | L | 031 | done |
5050
| 033 | Project context: AGENTS.md | 3 Memory | S | 030, 022 | approved |
5151
| 034 | Export, import, restore | 3 Memory | S | 030, 031 | approved |
5252
| 040 | Skills registry + agentskills.io format + progressive disclosure | 4 Skills | M | 020 | planned |

‎VERSIONS.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ never pin a version hex marks as retired or vulnerable.
8787
| `ecto_sql` | ~> 3.13 | ✅ in `mix.lock` | |
8888
| `ecto_sqlite3` | >= 0.0.0 | ✅ in `mix.lock` | Primary DB. FTS5 available. |
8989
| `postgrex` | >= 0.0.0 (optional) | ✅ in `mix.lock` | Secondary DB driver, `optional: true` so the desktop build carries none of it; compiled in only under `TRINITY_DB=postgres`, which the CI job proves. Added at Slice 010. Was one row with pgvector; pgvector keeps its own row below. |
90-
| `pgvector` | optional, ~> 0.3 | 🔍 not yet a dependency | Vectors on the Postgres path. Not yet a dependency; Slice 032 decides. Split from the postgrex row at Slice 010. |
90+
| `pgvector` | ~> 0.4.1 | ✅ in `mix.lock` | Vectors on the Postgres path: `memories.embedding_vector vector(384)` under an HNSW cosine index, searched by `Trinity.Memory.VectorStores.Pgvector` (Slice 032). The postgres job runs on the `pgvector/pgvector:pg17` image. Split from the postgrex row at Slice 010. |
9191
| `oban` | ~> 2.24 | 🔍 not yet a dependency | Uses `Oban.Engines.Lite` on SQLite. ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050. |
9292
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. |
9393
| `req_llm` | ~> 1.22 | ✅ in `mix.lock` | Provider layer (streaming, tools, structured output, usage). ⚠️ The pin was `~> 1.10` against a recorded latest of 1.10.0; the real latest was twelve minors ahead. Check event shapes against the current version at Slice 011, not against this file's prose. Added at Slice 011. |
@@ -118,10 +118,11 @@ never pin a version hex marks as retired or vulnerable.
118118

119119
| Name | Pin | Verified | Note |
120120
|---|---|---|---|
121-
| `nx, exla` | latest stable | 🔍 not a single package | Local embeddings. EXLA binary size matters for desktop: measure in 032. Two packages, so no single lock key. |
122-
| `bumblebee` | ~> 0.7 | 🔍 not yet a dependency | `all-MiniLM-L6-v2` embeddings; Whisper later. Added at Slice 032. |
123-
| `sqlite_vec` | ~> 0.1 | 🔍 not yet a dependency | Vectors in SQLite. Verify the loadable extension works inside the Burrito bundle (Slice 032). ⚠️ Pre-1.0, no release in roughly 22 months, 6,938 downloads all-time. R11's trigger already fires. Decide the fallback before Slice 032 starts. |
124-
| `hnswlib` | ~> 0.1.7 | 🔍 not yet a dependency | ⚠️ Pre-1.0. Optional accelerator; not on the critical path. |
121+
| `nx` | ~> 0.13.1 | ✅ in `mix.lock` | Tensors for the local embedder (Slice 032). The 0.13 line, not 1.0.0 (2026-09-10): bumblebee 0.7.1 accepts `~> 0.12 or ~> 0.13` and has no release for 1.0. Measured at Slice 032: docs/perf.md. |
122+
| `exla` | ~> 0.13.1 | ✅ in `mix.lock` | The XLA backend the serving compiles to (Slice 032); precompiled for x86_64 and aarch64 Linux and macOS, none for Windows (not declared on a Windows host; the tier is off there). Declared `runtime: false` and carried by the release in `:load` mode, started on demand by `Trinity.Memory.Embedders.Bumblebee.exla/0`: its NIF does not load in Burrito's Linux ERTS (NOTES finding 2). The XLA shared library is 463 MB on disk: docs/perf.md. Pulls `xla` 0.10.0 and `fine`. |
123+
| `bumblebee` | ~> 0.7.1 | ✅ in `mix.lock` | `all-MiniLM-L6-v2` embeddings through `Trinity.Memory.Embedders.Bumblebee` (Slice 032); Whisper later. Pulls `axon` 0.8.1 and `tokenizers` 0.5.1 (a precompiled Rust NIF). |
124+
| `sqlite_vec` | not used (decided at Slice 032, 2026-09-21) | 🔍 not a single package | Vectors in SQLite. Its only release (0.1.0, 2024-11-19) requires `nx ~> 0.9` and cannot sit in a tree with nx 0.13; R11's trigger fired and the owner chose brute force in Elixir over the persona's semantic rows (`Trinity.Memory.VectorStores.Brute`, docs/02: fine to about 10^5) with pgvector on the Postgres path. The row stays so the decision is visible where a reader would look for the package. |
125+
| `hnswlib` | ~> 0.1.10 | 🔍 not yet a dependency | ⚠️ Pre-1.0 (0.1.10, 2026-09-17, active). The scale option after brute force; not yet a dependency, the slice that measures brute force past 10^5 rows adds it. |
125126

126127
### Tools, sandbox and desktop
127128

‎config/config.exs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ config :trinity, :tools,
7373
Trinity.Tools.Web.Search,
7474
# Slice 031: full-text search over past messages.
7575
Trinity.Tools.SessionSearch,
76+
# Slice 032: hybrid recall over semantic memories and past messages.
77+
Trinity.Tools.Recall,
7678
# Slice 030: the always-on memory tiers.
7779
Trinity.Tools.Memory,
7880
Trinity.Tools.Shell.Run
@@ -82,7 +84,7 @@ config :trinity, :tools,
8284
web: ["web_fetch", "web_search"],
8385
shell: ["shell"],
8486
# Slice 031: search over past conversations.
85-
memory: ["session_search", "memory"]
87+
memory: ["session_search", "recall", "memory"]
8688
}
8789

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

‎config/runtime.exs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import Config
2424
# which is the same call: this file is evaluated before the application is available under
2525
# Mix, so it may not call project modules. `Burrito.Util.Args.get_arguments/0` reads the same
2626
# thing.
27-
smoke? = "--smoke" in Enum.map(:init.get_plain_arguments(), &to_string/1)
27+
smoke? =
28+
"--smoke" in Enum.map(:init.get_plain_arguments(), &to_string/1) or
29+
System.get_env("TRINITY_SMOKE") == "1"
2830

2931
if System.get_env("PHX_SERVER") || smoke? do
3032
config :trinity, TrinityWeb.Endpoint, server: true
@@ -119,7 +121,9 @@ if config_env() == :prod do
119121
if System.get_env("TRINITY_DB") == "postgres" do
120122
config :trinity, Trinity.Repo,
121123
url: System.get_env("DATABASE_URL") || raise("TRINITY_DB=postgres needs DATABASE_URL"),
122-
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
124+
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
125+
# Slice 032: pgvector's `vector` type.
126+
types: Trinity.Repo.PostgrexTypes
123127

124128
config :trinity, Trinity.Repo.Receipts,
125129
url: System.get_env("DATABASE_URL"),

‎config/test.exs‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ config :trinity, :tools,
2727
Trinity.Tools.Web.Search,
2828
# Slice 031: full-text search over past messages.
2929
Trinity.Tools.SessionSearch,
30+
# Slice 032: hybrid recall.
31+
Trinity.Tools.Recall,
3032
# Slice 030: the always-on memory tiers.
3133
Trinity.Tools.Memory,
3234
Trinity.Tools.Shell.Run
@@ -37,7 +39,7 @@ config :trinity, :tools,
3739
web: ["web_fetch", "web_search"],
3840
shell: ["shell"],
3941
# Slice 031: search over past conversations.
40-
memory: ["session_search", "memory"]
42+
memory: ["session_search", "recall", "memory"]
4143
},
4244
timeout_ms: 2_000
4345

@@ -97,21 +99,36 @@ if System.get_env("TRINITY_DB") == "postgres" do
9799
config :trinity, Trinity.Repo,
98100
url: System.get_env("DATABASE_URL") || raise("TRINITY_DB=postgres needs DATABASE_URL"),
99101
pool_size: 10,
100-
pool: Ecto.Adapters.SQL.Sandbox
102+
pool: Ecto.Adapters.SQL.Sandbox,
103+
# Slice 032: pgvector's `vector` type.
104+
types: Trinity.Repo.PostgrexTypes,
105+
# Slice 032: the sandbox shares one connection with every process a test starts, and
106+
# DBConnection sheds load (refuses checkouts) when the queue stays over `queue_target`
107+
# for `queue_interval`. The 100-session and 20-writer tests queue that hard on the
108+
# postgres job's runner, more so once each turn also runs the retriever (runs 35610164189
109+
# and 35610171809); a queue that is long on purpose should wait, not shed.
110+
queue_target: 5_000,
111+
queue_interval: 30_000
101112

102113
# Slice 024: the receipts Repo shares the Postgres database (its own migrations table).
103114
config :trinity, Trinity.Repo.Receipts,
104115
url: System.get_env("DATABASE_URL"),
105116
pool_size: 10,
106-
pool: Ecto.Adapters.SQL.Sandbox
117+
pool: Ecto.Adapters.SQL.Sandbox,
118+
queue_target: 5_000,
119+
queue_interval: 30_000
107120
else
108121
config :trinity, Trinity.Repo,
109122
database: Path.expand("../trinity_test.db", __DIR__),
110-
pool: Ecto.Adapters.SQL.Sandbox
123+
pool: Ecto.Adapters.SQL.Sandbox,
124+
queue_target: 5_000,
125+
queue_interval: 30_000
111126

112127
config :trinity, Trinity.Repo.Receipts,
113128
database: Path.expand("../trinity_test_receipts.db", __DIR__),
114-
pool: Ecto.Adapters.SQL.Sandbox
129+
pool: Ecto.Adapters.SQL.Sandbox,
130+
queue_target: 5_000,
131+
queue_interval: 30_000
115132
end
116133

117134
# We don't run a server during test. If one is required,
@@ -150,3 +167,7 @@ config :phoenix,
150167
# Slice 024: the receipt signing key and registry for the suite live under the project's
151168
# ignored tmp/, never in the data directory of the machine running the tests.
152169
config :trinity, :receipts, keys_dir: Path.expand("../tmp/test_keys", __DIR__)
170+
171+
# Slice 032: the suite embeds with the deterministic fake; nothing leaves the machine and no
172+
# model is needed.
173+
config :trinity, :memory, embedder: :fake, observer: false

‎coverage.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ slice_id percent sha date
1515
030 78.40 c657da8 2026-09-21
1616
033 78.50 3b5e89e 2026-09-21
1717
034 78.83 3a5785f 2026-09-21
18+
032 78.99 193800e 2026-09-21

‎docs/01-architecture.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ Trinity.Application
4242
│ # INSERT but does not guarantee each row read the correct predecessor.
4343
├── Trinity.Effects.Boot # Slice 024, as built: a transient Task writing the boot receipt
4444
│ # once the signer and the authority are known
45-
├── Trinity.Memory.Supervisor # Nx.Serving for embeddings, retrieval. Slice 032
45+
├── Trinity.Memory.Supervisor # Slice 032, as built: Trinity.Memory.TaskSupervisor (the
46+
│ │ # observer's task after each turn, the page's model download)
47+
│ └── Nx.Serving (Trinity.Memory.Embedding) # the local embedder, started only when the embedder is
48+
│ # :local and the model is in the cache; absent otherwise, the
49+
│ # tier off and the reason logged once. ensure_embedding/0
50+
│ # starts it after a download.
4651
├── Trinity.Skills.Registry # hot-loaded skills index. Slice 040
4752
├── Oban # cron + durable jobs. Slice 050
4853
├── Trinity.MCP.Supervisor # MCP clients (one per server, lib per 059) + server. Slice 059-062
@@ -106,7 +111,8 @@ through those modules only. `boundary` `exports:` lists enforce this.
106111
| `Trinity.LLM.Provider` | `stream/3`, `generate/3`, `embed/2`, `models/0`, `capabilities/1` | config `:providers` list |
107112
| `Trinity.Tools.Tool` | `name/0`, `description/0`, `schema/0`, `risk/0`, `execute/2` | config `:tools` list + MCP dynamic registration |
108113
| `Trinity.Gateways.Adapter` | `child_spec/1`, `deliver/2`, `capabilities/0` | config `:gateways` list |
109-
| `Trinity.Memory.VectorStore` | `upsert/2`, `search/3`, `delete/1` | config `:vector_store` (`SqliteVec` \| `Pgvector` \| `Hnswlib`) |
114+
| `Trinity.Memory.VectorStore` | `upsert/3`, `search/3`, `delete/1`, `count/1` (as built at 032: the scope filter is `search/3`'s required argument) | the database adapter, at compile time (`Brute` on SQLite, `Pgvector` on Postgres; `Hnswlib` when a slice measures past 10^5 rows) |
115+
| `Trinity.Memory.Embedder` | `embed/1`, `dim/0`, `model_id/0`, `availability/0` | `config :trinity, :memory, embedder:` (`:local` default, `:hosted` opt-in only, `:fake` in the suite) |
110116
| `Trinity.Skills.Loader` | `load/1`, `validate/1` | config `:skill_loaders` |
111117
| `Trinity.Permissions.Policy` | `decide/3` → `:allow | :deny | {:ask, prompt}` | config |
112118

@@ -120,7 +126,7 @@ UI/Gateway ──user_message──▶ Session(gen_statem)
120126
Session: append message → build prompt (persona + always-on memory + skills index + history[compacted])
121127
→ LLM.stream/3 ──chunks──▶ PubSub "session:<id>" ──▶ LiveView + gateways
122128
→ on tool_call: Permissions.decide → (ask → approval_wait) → Tools.execute in Task → result appended
123-
→ loop until final text → persist → Memory.observe(turn) (async) → idle
129+
→ loop until final text → persist → Memory.Observer.observe(turn) (a Task under Trinity.Memory.TaskSupervisor, as built at 032) → idle
124130
```
125131

126132
Every state transition is persisted before it is broadcast. A crash between persist and broadcast is safe

‎docs/02-tech-stack.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Versions live in `VERSIONS.md`. This file explains *why* each choice was made an
1414
| MCP | decided by Slice 059 (fastest_mcp / gen_mcp / anubis_mcp / own server) | Target is 2026-07-28 with 2025-11-25 compat; anubis is ≤ 2025-11-25 and LGPL-3.0 | ADR-0007 finalised by measurement |
1515
| Embeddings | Bumblebee + EXLA (all-MiniLM-L6-v2) | Local, private, 384-dim | If EXLA binary size is unacceptable on desktop → hosted embeddings via req_llm, or Ortex (risk: stalled) |
1616
| Vector search | sqlite_vec (brute force) behind `VectorStore` behaviour | Fine to ~10^5 vectors; no extra process | Scale → hnswlib (pre-1.0) or pgvector HNSW |
17+
| Vector search, as built at 032 | brute force in Elixir over the row's `embedding` bytes (SQLite); pgvector HNSW (Postgres) | sqlite_vec's one release needs `nx ~> 0.9` and cannot share a tree with nx 0.13 (VERSIONS.md); measured in docs/perf.md | Scale → hnswlib 0.1.10 |
1718
| Shell tool | MuonTrap | Guaranteed child kill on process death; cgroups on Linux | none |
1819
| Sandbox | Luerl (`sandbox` pkg) | In-VM, reduction-limited, no OS access | Untrusted native code → container/microVM (out of scope) |
1920
| Modularity | `boundary` + behaviours + `Registry` | Compile-time enforcement of context deps | none |

0 commit comments

Comments
 (0)