From e7b0c283bc1e4c0aad8bcf9655a3adbdc3cf4c12 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Mon, 21 Sep 2026 07:56:53 -0400 Subject: [PATCH 01/24] docs(s032): the owner's decisions at G0, the embedders and the vector store measured, the G1 plan, and the slice opens ROADMAP row 032 to in_progress. NOTES.md records the decisions (brute force and pgvector, no sqlite_vec; local first, hosted only by configuration and never a fallback; the tier off when local cannot run; the embedder recorded per vector; no text to a hosted embed API without opt-in), the measurements (local 86 ms and 0.858/0.062 on the thresholds, 463 MB of XLA, no Windows target; hosted 208 ms and 0.71/0.752; pure Elixir 23 s), the deps, the eight-line plan, the two-item manual queue, four deviations. mix.exs and mix.lock carry nx, exla, bumblebee and pgvector; scripts/embed_bench.exs is the measurement. Signed-off-by: Ayla Croft --- ROADMAP.md | 2 +- mix.exs | 8 ++ mix.lock | 15 ++++ scripts/embed_bench.exs | 34 ++++++++ slices/032-semantic-memory/NOTES.md | 124 ++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 scripts/embed_bench.exs create mode 100644 slices/032-semantic-memory/NOTES.md diff --git a/ROADMAP.md b/ROADMAP.md index 700364a..659d098 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,7 @@ standards register names the rows that ask for them. | 026 | Store-and-forward receipts for disconnected operation | 2 Tools | L | 024 | planned | | 030 | Persona (SOUL) + always-on memory tier | 3 Memory | M | 012 | approved | | 031 | Session search (SQLite FTS5) | 3 Memory | S | 010 | approved | -| 032 | Embeddings + semantic memory + hybrid retrieval | 3 Memory | L | 031 | planned | +| 032 | Embeddings + semantic memory + hybrid retrieval | 3 Memory | L | 031 | in_progress | | 033 | Project context: AGENTS.md | 3 Memory | S | 030, 022 | approved | | 034 | Export, import, restore | 3 Memory | S | 030, 031 | approved | | 040 | Skills registry + agentskills.io format + progressive disclosure | 4 Skills | M | 020 | planned | diff --git a/mix.exs b/mix.exs index 50d5f9d..d0fdcb9 100644 --- a/mix.exs +++ b/mix.exs @@ -113,6 +113,14 @@ defmodule Trinity.MixProject do {:jcs, "~> 0.2"}, # Slice 022: HTML to text for web_fetch (Trinity.Tools.Web.Fetch). {:floki, "~> 0.38"}, + # Slice 032: local embeddings (Trinity.Memory.Embedders.Bumblebee). The 0.13 line of nx + # and exla is what bumblebee 0.7.1 accepts (nx 1.0.0 shipped 2026-09-10 and bumblebee has + # no release for it at G1); measured for bundle size and latency in the slice's NOTES.md. + {:nx, "~> 0.13.1"}, + {:exla, "~> 0.13.1"}, + {:bumblebee, "~> 0.7.1"}, + # Slice 032: vectors on the Postgres job (Trinity.Memory.VectorStores.Pgvector). + {:pgvector, "~> 0.4.1"}, # Slice 013 (owner decision, 2026-09-20): the linux package builds mdex's NIF from # source for musl (MDEX_NATIVE_BUILD=1 and TRINITY_NIF_TARGET in config/config.exs), # because neither precompiled artifact loads in Burrito's musl ERTS (NOTES finding 13). diff --git a/mix.lock b/mix.lock index 2fd9186..554974b 100644 --- a/mix.lock +++ b/mix.lock @@ -1,10 +1,14 @@ %{ "abnf_parsec": {:hex, :abnf_parsec, "2.1.0", "c4e88d5d089f1698297c0daced12be1fb404e6e577ecf261313ebba5477941f9", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e0ed6290c7cc7e5020c006d1003520390c9bdd20f7c3f776bd49bfe3c5cd362a"}, + "axon": {:hex, :axon, "0.8.1", "c4a975e62a14ab6c374997b77367ec3a4c2740952ac474d3b0f202c91b7f75c4", [:mix], [{:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}, {:kino_vega_lite, "~> 0.1.7", [hex: :kino_vega_lite, repo: "hexpm", optional: true]}, {:nx, "~> 0.10", [hex: :nx, repo: "hexpm", optional: false]}, {:polaris, "~> 0.1", [hex: :polaris, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1 or ~> 4.1", [hex: :table_rex, repo: "hexpm", optional: true]}], "hexpm", "682a3517489300507ac9345f28341e7fa95bc5b4960d645816074ce551795d37"}, "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, "boundary": {:hex, :boundary, "0.10.4", "5fec5d2736c12f9bfe1720c3a2bd8c48c3547c24d6002ebf8e087570afd5bd2f", [:mix], [], "hexpm", "8baf6f23987afdb1483033ed0bde75c9c703613c22ed58d5f23bf948f203247c"}, + "bumblebee": {:hex, :bumblebee, "0.7.1", "0d6a757f955671a379ebcf4ed07565513fa767b1869169b989f65ba7958a80f4", [:mix], [{:axon, "~> 0.8.0", [hex: :axon, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.12.0 or ~> 0.13.0", [hex: :nx, repo: "hexpm", optional: false]}, {:nx_image, "~> 0.1.0", [hex: :nx_image, repo: "hexpm", optional: false]}, {:nx_signal, "~> 0.2.0", [hex: :nx_signal, repo: "hexpm", optional: false]}, {:safetensors, "~> 0.1.3", [hex: :safetensors, repo: "hexpm", optional: false]}, {:tokenizers, "~> 0.4", [hex: :tokenizers, repo: "hexpm", optional: false]}, {:unpickler, "~> 0.1.0", [hex: :unpickler, repo: "hexpm", optional: false]}, {:unzip, "~> 0.12.0 or ~> 0.13.0", [hex: :unzip, repo: "hexpm", optional: false]}], "hexpm", "f26b3355700e3c7580014d41ce477d87d9fd71a60b76d5b64701dacb07830a58"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "burrito": {:hex, :burrito, "1.6.0", "7af0a75f11680e8a6e9c01370c9af51cb9d0e15b3226eddf4f438dbc68570520", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "e636a00b032c45a69ff755d9fc53fa5fdc9e1d21bdbd229075fe4a15b05355fe"}, + "castore": {:hex, :castore, "1.0.21", "0a0e8330dc267a40a3b7ad86d39302764bb71758172904e6a59d5ad6443ce307", [:mix], [], "hexpm", "e42e22723e25dbd46876d056a03f685513d6e98f6b5e555dc551321decd76c5c"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "complex": {:hex, :complex, "0.7.0", "695632ef9487517aa5d57edd1697801079d622414cb2e1a7cf538b1f9a50f205", [:mix], [], "hexpm", "0ee39c0803129f546e7f3f640da8f021c9e659402bf59da6f7f2c4848f068f8d"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "daisyui": {:git, "https://github.com/saadeghi/daisyui.git", "22ecff57f2c391b80a75617325748cf4d13fdf47", [tag: "v5.5.20", sparse: "packages/bundle", depth: 1]}, "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, @@ -20,6 +24,7 @@ "ex_ast": {:hex, :ex_ast, "0.13.1", "b3d80ec163733176f63662ac44d2511445c224f6b5e4e3ce01f5eff83c4a5993", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "bd15f68cde5ec945b859bd67416f26cf5499f1aef9b067eb2163ed244c7e703a"}, "ex_doc": {:hex, :ex_doc, "0.40.4", "66f2e42bf588594d5a8aab31cad87f2ddad09d0da1b1a2f379340ec2c2e497cb", [:mix], [{:earmark_parser, "~> 1.4.46", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "6222b9e423d76584ee34df2c82a5ed72c2d53dc153f7f483ad28b378694186cc"}, "ex_tauri": {:hex, :ex_tauri, "0.2.0", "33a3bae4c1b73b77cd656ba1b2236046b85a1ecc839a4eeabb727d7604287bc8", [:mix], [{:burrito, "~> 1.5", [hex: :burrito, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "58ad0cf7173c1d0a5ac2399daeec6bd77f9b3c1a19a7391532ea35ba3ec8b473"}, + "exla": {:hex, :exla, "0.13.1", "e6239dd51283ad5bb858f5627a17284c18cf20c27dfe3f6abaf8a9c5d7f14db9", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.13", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.10.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "6249be89b4f9c9a544af4ca90353a5d4f1a63c6f1b84ab2e22327c9dc473fbf3"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "exqlite": {:hex, :exqlite, "0.40.0", "d63cb394a588ecbe73f19c56fbbcf397bf6488b5872fcddc9383fb375cf6131d", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f83350f2d29a38be1fd38f39081dd36f41fc51bbacf0f4c2927d01308ae331d0"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, @@ -51,7 +56,11 @@ "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "nx": {:hex, :nx, "0.13.1", "f96311a573dcd57d11ff889958ee518f52f7c16dbf6d22191020d322887856fc", [:mix], [{:complex, "~> 0.7", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "20556ea36c1e9732c1d87eebbf7c0773517e876e92af4ff592b974f80db0b9d9"}, + "nx_image": {:hex, :nx_image, "0.1.2", "0c6e3453c1dc30fc80c723a54861204304cebc8a89ed3b806b972c73ee5d119d", [:mix], [{:nx, "~> 0.4", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "9161863c42405ddccb6dbbbeae078ad23e30201509cc804b3b3a7c9e98764b81"}, + "nx_signal": {:hex, :nx_signal, "0.2.0", "e1ca0318877b17c81ce8906329f5125f1e2361e4c4235a5baac8a95ee88ea98e", [:mix], [{:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "7247e5e18a177a59c4cb5355952900c62fdeadeb2bad02a9a34237b68744e2bb"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, + "pgvector": {:hex, :pgvector, "0.4.1", "f45b3be1ad37efa944b418757da5414eb59085997c6de85ea83ac5b5b82701af", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:nx, "~> 0.5 or ~> 1.0", [hex: :nx, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "2bb5c468ff6c435941ca326b408a0a01a1526dcd8f2c3c478be44be091ad88e4"}, "phoenix": {:hex, :phoenix, "1.8.14", "9279cbbcd755ac8d5f42d206915e7df13b9ab49096b98114f1c99ff908e5b42e", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "2782ff375824b2b5e41561fbae4764ee7b875af6898483bca49f24a9d1e37816"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, @@ -62,12 +71,14 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, + "polaris": {:hex, :polaris, "0.1.0", "dca61b18e3e801ecdae6ac9f0eca5f19792b44a5cb4b8d63db50fc40fc038d22", [:mix], [{:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "13ef2b166650e533cb24b10e2f3b8ab4f2f449ba4d63156e8c569527f206e2c2"}, "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, "req_llm": {:hex, :req_llm, "1.24.0", "cdc5c5cd7f38c0e17cf7a949ced99b6760dcba0d860cd4d066fea1aeba422929", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:ex_aws_auth, "~> 1.4", [hex: :ex_aws_auth, repo: "hexpm", optional: true]}, {:goth, "~> 1.4", [hex: :goth, repo: "hexpm", optional: true]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jsv, "~> 0.11", [hex: :jsv, repo: "hexpm", optional: false]}, {:llm_db, ">= 2026.9.3 and < 2027.0.0", [hex: :llm_db, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:server_sent_events, "~> 1.1.0", [hex: :server_sent_events, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:websockex, "~> 0.5.1", [hex: :websockex, repo: "hexpm", optional: false]}, {:zoi, "~> 0.14", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "8ab6bda68e28afc3adf070cc6616d51831b42b8ff144116a9f104999af4c763f"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, + "safetensors": {:hex, :safetensors, "0.1.3", "7ff3c22391e213289c713898481d492c9c28a49ab1d0705b72630fb8360426b2", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "fe50b53ea59fde4e723dd1a2e31cfdc6013e69343afac84c6be86d6d7c562c14"}, "server_sent_events": {:hex, :server_sent_events, "1.1.0", "54606238b9182ba673a10ce90bd95f81a0939c1baa91cdf86b66acc04edd1832", [:mix], [], "hexpm", "8e164db8e295a2d869a8faafbf4a1eeaa749b62fe93f66271adff6394d93ce15"}, "sobelow": {:hex, :sobelow, "0.15.0", "b067d7f8522a9d758fa89cb2bfcbab7ad72c45a0993cb958c989c6fd956fdd56", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24a800e2d7fa8c3bd21561b6ad8ad4745ed726a09fd606598981d9048708da98"}, "sourceror": {:hex, :sourceror, "1.12.3", "f58eebef0765c7a369a49a755ab2a5ee88d92777f403ea5a08b722cefcc37f51", [:mix], [], "hexpm", "d5f2f37099de794840f08c54ae546d7f6e4ea015e397be64aebe4996fa9f7da7"}, @@ -80,11 +91,15 @@ "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "texture": {:hex, :texture, "1.2.1", "ae8d875fb099bcc1ccb0269296bf6c4590116eff9a44895049379359a14d5fca", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}], "hexpm", "925b1938891ce5c1d589df408faa7ed57dd872e4aac9521b32495a29b265cb17"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "tokenizers": {:hex, :tokenizers, "0.5.1", "b0975d92b4ee5b18e8f47b5d65b9d5f1e583d9130189b1a2620401af4e7d4b35", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "5f08d97cc7f2ed3d71d370d68120da6d3de010948ccf676c9c0eb591ba4bacc9"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, + "unpickler": {:hex, :unpickler, "0.1.0", "c2262c0819e6985b761e7107546cef96a485f401816be5304a65fdd200d5bd6a", [:mix], [], "hexpm", "e2b3f61e62406187ac52afead8a63bfb4e49394028993f3c4c42712743cab79e"}, + "unzip": {:hex, :unzip, "0.13.0", "bf5ec6ac6063c69e6ec54c8b4a3b8dcd7a2719d28d10d7025776ab107957cde9", [:mix], [], "hexpm", "4bcb9892ecbf2042606b43ab685a1bffe03c14003e6246f5453db2c829237fd9"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"}, + "xla": {:hex, :xla, "0.10.0", "41121e9f011456242d3a79b9289910ce43419be0b0e7ebe67cc1292c6b3f232f", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f57d91aea6e661b52bf12239316c598679e9170628122bbd941235f040122bc6"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, "zoi": {:hex, :zoi, "0.18.7", "0d6b09d19fd1feff4340b7c5660bab04fbc80c1642ee1e5c75f06d527ac326db", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "5fedddd755dec84a5e78b3671070a5e595026aa3479f7fa566a42b8c4e4e5ff2"}, diff --git a/scripts/embed_bench.exs b/scripts/embed_bench.exs new file mode 100644 index 0000000..2316944 --- /dev/null +++ b/scripts/embed_bench.exs @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Slice 032 G1: the local embedder measured on this machine. Run with a cache directory: +# BUMBLEBEE_CACHE_DIR=/path/to/cache MIX_ENV=test mix run --no-start scripts/embed_bench.exs +# The first run downloads all-MiniLM-L6-v2 (91 MB) into that directory. +{:ok, _} = Application.ensure_all_started(:inets) +{:ok, _} = Application.ensure_all_started(:ssl) +{:ok, _} = Application.ensure_all_started(:bumblebee) +{:ok, _} = Application.ensure_all_started(:exla) +Nx.global_default_backend(EXLA.Backend) +cache = System.get_env("BUMBLEBEE_CACHE_DIR") +repo = {:hf, "sentence-transformers/all-MiniLM-L6-v2", cache_dir: cache} +{t_load, {:ok, model_info}} = :timer.tc(fn -> Bumblebee.load_model(repo) end) +{:ok, tokenizer} = Bumblebee.load_tokenizer(repo) +serving = Bumblebee.Text.text_embedding(model_info, tokenizer, compile: [batch_size: 32, sequence_length: 128], defn_options: [compiler: EXLA], output_pool: :mean_pooling, output_attribute: :hidden_state, embedding_processor: :l2_norm) +{t_first, r} = :timer.tc(fn -> Nx.Serving.run(serving, "the cat sat") end) +dim = Nx.size(r.embedding) +IO.puts("load_ms=#{div(t_load, 1000)} first_embed_ms=#{div(t_first, 1000)} dim=#{dim}") +single = for _ <- 1..20, do: elem(:timer.tc(fn -> Nx.Serving.run(serving, "a sentence to embed for timing") end), 0) +IO.puts("single_ms_p50=#{Enum.at(Enum.sort(single), 9) / 1000} single_ms_max=#{Enum.max(single) / 1000}") +batch = for i <- 1..32, do: "sentence number #{i} about something different each time" +{t_batch, _} = :timer.tc(fn -> Nx.Serving.run(serving, batch) end) +IO.puts("batch32_ms=#{div(t_batch, 1000)} per_item_ms=#{Float.round(t_batch / 32000, 2)}") +cos = fn a, b -> Nx.dot(a, b) |> Nx.to_number() end +e = fn t -> Nx.Serving.run(serving, t).embedding end +c1 = cos.(e.("the cat sat"), e.("a cat was sitting")) +c2 = cos.(e.("the cat sat"), e.("quarterly tax filing")) +IO.puts("cosine(cat sat, cat sitting)=#{Float.round(c1, 3)} cosine(cat sat, tax filing)=#{Float.round(c2, 3)}") +{:ok, files} = File.ls(Path.join([cache, "huggingface"])) +size = Path.wildcard(Path.join(cache, "**/*")) |> Enum.filter(&File.regular?/1) |> Enum.map(&File.stat!(&1).size) |> Enum.sum() +IO.puts("model_cache_bytes=#{size} (#{length(files)} entries)") + +IO.puts("vm_total_mb=#{div(:erlang.memory(:total), 1_000_000)} rss_mb=#{String.trim(File.read!("/proc/self/status") |> then(fn s -> Regex.run(~r/VmRSS:\s+(\d+)/, s) |> List.last() end)) |> String.to_integer() |> div(1000)}") diff --git a/slices/032-semantic-memory/NOTES.md b/slices/032-semantic-memory/NOTES.md new file mode 100644 index 0000000..0dd824b --- /dev/null +++ b/slices/032-semantic-memory/NOTES.md @@ -0,0 +1,124 @@ +# Slice 032: NOTES + +## Decisions before code (the owner, 2026-09-21, at G0) + +SLICE.md leaves R3 and R4 to this slice and VERSIONS.md says to decide the vector store's fallback before it +starts. Measured facts (below) were put to the owner; the decisions, verbatim in substance: + +1. **Vector store: brute force in Elixir, and pgvector on the Postgres job.** `sqlite_vec` is out: its only + release (0.1.0, 2024-11-19, 7,325 downloads) requires `nx ~> 0.9` and cannot sit in a tree with a current + Nx. Vectors are a column on `memories`, cosine search runs in Elixir over the persona's semantic rows + (docs/02's own statement: fine to about 10^5), `Pgvector` serves the Postgres job, and `hnswlib` (0.1.10, + 2026-09-17, active) is the scale option when a slice needs it. AC7's clause "or R4 fallback implemented and + documented" is the one this slice meets; there is no loadable extension for Burrito to break. +2. **Embedder: local first, hosted never automatic.** The default is all-MiniLM-L6-v2 through Bumblebee and + EXLA on Linux and macOS. The hosted embedder (`nvidia:embed`) is a configured alternative only + (`config :trinity, :memory, embedder: :hosted`) and is never the silent fallback when the model is missing, + the download fails, or the OS is Windows. +3. **When local cannot run, semantic memory is off**, not "send the memories to the cloud": full-text search + (031) still works, the observer and the retriever stand down, and the UI says semantic recall is + unavailable, not broken. Windows and machines whose download failed get that mode until a local Windows + backend exists (ONNX or similar, a later slice; 032 is not blocked on it). +4. **Every vector records which embedder produced it** (`embedding_model`, the dimension), and the store never + mixes models: a search runs only over vectors of the embedder in force, and a change of embedder is a + re-embed, never a silent blend. +5. **No memory text, session text or persona text goes to a hosted embed API unless the operator opted in.** + That is the DoD/healthcare posture, and it holds for the observer's extraction call too (which goes to the + session's own model, the one the operator already chose for the conversation). + +## Measured 2026-09-21 before any code + +**The dependency line.** bumblebee 0.7.1 (2026-07-22) accepts `nx ~> 0.12 or ~> 0.13`; nx and exla 1.0.0 +shipped 2026-09-10 and bumblebee has no release for them, so the tree takes nx 0.13.1, exla 0.13.1, xla 0.10.0, +axon 0.8.1, tokenizers 0.5.1 (a Rust NIF, precompiled), pgvector 0.4.1. `fine` (exla's NIF helper) bakes its +include path at compile time and had been compiled at the repository's old location; `mix deps.compile fine +--force` fixed the first build. + +**Local embeddings** (this machine: AMD Ryzen AI MAX+ 395, CPU only through EXLA's host client), all-MiniLM-L6-v2, +sequence length 128, batch size 32, mean pooling, L2-normalised: + +| measure | value | +|---|---| +| model download | 91,359,935 bytes (one entry in the cache) | +| load | 650 ms; first embed (compilation) 318 ms | +| single embed | 86.4 ms p50, 90.9 ms max over 20 | +| batch of 32 | 86 ms, 2.71 ms per item | +| dimension | 384 | +| cosine("the cat sat", "a cat was sitting") | 0.858 (AC2 asks > 0.7) | +| cosine("the cat sat", "quarterly tax filing") | 0.062 (AC2 asks < 0.3) | +| VM total / process RSS while embedding | 101 MB / 587 MB | +| XLA shared library on disk | 463,283,344 bytes (the bundle's cost); 106,037,350 gzip-compressed; the archive 144,319,267 | +| EXLA NIF / tokenizer NIF | 659,856 bytes / 6.9 MB | +| precompiled XLA targets | x86_64 and aarch64 linux, x86_64 and aarch64 darwin; none for Windows | +| without EXLA (Nx.BinaryBackend) | 15 s first, 23 s per sentence: unusable | + +**Hosted embeddings** (`nvidia:embed`, nemotron-3-embed-1b through req_llm, the owner's key, live): + +| measure | value | +|---|---| +| dimension | 2048 | +| single embed | 208 ms p50, 314 ms max over 5 (network) | +| batch of 32 | 627 ms | +| cosine("the cat sat", "a cat was sitting") | 0.71 | +| cosine("the cat sat", "quarterly tax filing") | 0.752: the unrelated pair scores as the related one; raw vectors from this model do not separate at the slice's thresholds, and a dedupe at 0.92 would be meaningless; the model's own query/passage prompting would have to be measured before it could serve | + +**Archive size** (slice 034's open line): a 384-float vector is 1,536 bytes as float32; 10,000 memories add +15 MB before compression. Measured once vectors exist, at G3. + +## G1 plan, 2026-09-21 + +Tree at `190d206` on `main` (033 and 034 approved); branch `slice/032-semantic-memory`; ROADMAP row 032 to +`in_progress` in this commit. Each line names its test. + +1. Deps (above) in mix.exs and VERSIONS.md rows; the EXLA/XLA compile on the gate's three legs (the FIPS leg + compiles EXLA from source against its own OpenSSL-free toolchain: the C++ compile takes minutes; measured on + the first push). Test: `Nx.Serving` starts under `Trinity.Memory.Supervisor` when the embedder is local and + the model is present; otherwise the supervisor starts with the tier off and says why. +2. `Trinity.Memory.Embedder` behaviour (`embed/1`, `dim/0`, `model_id/0`, `available?/0`) with + `Embedders.Bumblebee` (the serving; model cache under the data directory, `BUMBLEBEE_CACHE_DIR` honoured; + `offline: true` unless `config :trinity, :memory, model_download: true`, the first-run download an explicit + action on the memory page with progress), `Embedders.Hosted` (`Trinity.LLM.embed/2` with the configured + model; only when `embedder: :hosted`), `Embedders.Fake` (deterministic vectors from a text's digest, for the + suite). `Trinity.Memory.Semantic.status/0`: `:on | {:off, reason}`. Tests: the fake's determinism; the + status by configuration; the hosted embedder is never selected without the configuration (a test with the + model absent and `embedder: :local` gets `{:off, :model_missing}`, never hosted). +3. Migration: `memories` gains `embedding` (binary, float32 little-endian, nullable), `embedding_model` + (string), `embedding_dim` (integer); on Postgres `embedding_vector vector(384)` through pgvector with a + partial index on `tier = 'semantic'`. `Trinity.Memory.VectorStore` behaviour (`upsert/2`, `search/3`, + `delete/1`, `count/1`; the scope filter a required argument, M6) with `VectorStores.Brute` (SQLite: the + persona's semantic rows of the embedder in force loaded and scored in Elixir) and `VectorStores.Pgvector`. + Test AC1: 1,000 fake vectors, the known nearest in order, on both adapters (the postgres job). +4. `Trinity.Memory.Observer`: after a completed turn the Session hands the turn's rows to a supervised + `Task` (`Trinity.Memory.TaskSupervisor`) that asks the session's model for 0 to 3 durable facts, + preferences or decisions (`generate_object/3`, the compactor's shape), embeds them, drops one whose cosine + to an existing memory of the scope is over 0.92, and inserts the rest as `tier: "semantic"` rows with + `source_message_id`, `confidence`, the scope chain's persona scope, and the change log. Off when the tier + is off. Test AC3 with the fake provider's scripted facts and a planted near-duplicate. +5. `Trinity.Memory.Retriever.relevant(persona_id, session_id, query, k)`: FTS hits (031's search over the + persona's sessions) and vector hits fused by reciprocal rank (k = 60), recency decay (half-life 30 days on + `last_used_at` or the row's insert), scoped to the session's chain; the block "## Relevant memories" rendered + into the volatile tier under its own cap (`config :trinity, :memory, recall_tokens: 600`, measured against + the volatile budget of 030). Tests AC4 (an FTS-only and a vector-only hit both fused; an old identical memory + demoted) and AC5 (the block in the prompt, cut at the cap with the 030 receipt). +6. The `recall` tool (risk `:read`, effect `:none`, `query`, `k`): the retriever's hits as text; the fake + test through the runner; the manual GIF for AC6 with the real model on this machine. +7. UI: the memory page gains a "Semantic" tab: search, provenance link to the source message, delete, pin + (promote to `always_on`, logged), the model download action and the tier's status line. LiveView tests. +8. `docs/perf.md` (new) with the tables above and the gate legs' EXLA compile times; docs/05 (the columns), + docs/01 (the supervisor as built), VERSIONS.md (the rows, βœ…). AC7: the Burrito package run on this branch + (`package` workflow dispatched) proves the bundle starts with EXLA in it and the vec smoke (a fake-vector + search inside the binary) passes, or records the failure by name (R4's fallback is the design here, so a + failure is about EXLA in Burrito, not about an extension). + +Manual verification queue (two items, for the owner at G4): +- **AC2**: the real embedder's numbers are in the table above, measured today; the owner may re-run + `scripts/embed_bench.exs` (added this slice) to see them on their machine. +- **AC6**: the `recall` tool end to end: a fact taught in one session, recalled in a new one, on the real model. + A GIF under `proof/` from a run on this machine; the owner watches it. + +Deviations stated before any code: (a) no `sqlite_vec`, brute force and pgvector instead (decision 1); (b) the +hosted embedder is opt-in only and never a fallback (decision 2), so SLICE.md's "offline fallback = hosted +embedder or disabled semantic tier" is read as "disabled semantic tier"; (c) the model download is an explicit +action, not an automatic first-run download, because a download is a network egress the operator should see +happen (the design note's "first-run download with UI progress" is kept as that action); (d) the embedder that +produced each vector is recorded on the row (decision 4). From 6a6d5cf41d8b43777f8e8584ae975dd348ad17e0 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Mon, 21 Sep 2026 08:29:53 -0400 Subject: [PATCH 02/24] feat(s032): semantic memory: embedder, vector stores, observer, hybrid retriever, recall tool, memory page tab The embedder behaviour with the local Bumblebee serving (explicit download, never a hosted fallback), the hosted alternative behind configuration, and the fake for the suite; the vector on the memories row with the model that produced it, searched brute force on SQLite and through pgvector on Postgres (Postgrex types defined for that build); the observer after each turn under the memory task supervisor with the 0.92 dedupe; the retriever fusing vector and full-text hits by reciprocal rank with recency decay and a cosine floor; the recall block in the volatile tier under its own cap with the 030 receipt; the recall tool; the memory page's Semantic tab with status, search, provenance, delete, pin and the download action. VERSIONS rows for nx, exla, bumblebee, pgvector, hnswlib; sqlite_vec recorded as not used. docs/01, 02, 05 amended. Signed-off-by: Ayla Croft --- .github/workflows/gate.yml | 4 +- VERSIONS.md | 11 +- config/config.exs | 4 +- config/runtime.exs | 4 +- config/test.exs | 12 +- docs/01-architecture.md | 12 +- docs/02-tech-stack.md | 1 + docs/05-data-model.md | 16 + lib/trinity.ex | 6 +- lib/trinity/application.ex | 2 + lib/trinity/memory.ex | 20 +- lib/trinity/memory/embedder.ex | 63 ++++ lib/trinity/memory/embedders/bumblebee.ex | 120 +++++++ lib/trinity/memory/embedders/fake.ex | 45 +++ lib/trinity/memory/embedders/hosted.ex | 54 +++ lib/trinity/memory/entry.ex | 42 ++- lib/trinity/memory/observer.ex | 213 ++++++++++++ lib/trinity/memory/retriever.ex | 157 +++++++++ lib/trinity/memory/semantic.ex | 201 ++++++++++++ lib/trinity/memory/supervisor.ex | 76 +++++ lib/trinity/memory/vector_store.ex | 54 +++ lib/trinity/memory/vector_stores/brute.ex | 67 ++++ lib/trinity/memory/vector_stores/pgvector.ex | 78 +++++ lib/trinity/repo/postgrex_types.ex | 14 + lib/trinity/sessions.ex | 4 + lib/trinity/sessions/prompt.ex | 39 ++- lib/trinity/sessions/session.ex | 39 ++- lib/trinity/tools/recall.ex | 95 ++++++ lib/trinity/versions.ex | 35 +- lib/trinity_web/live/memory_live.ex | 309 +++++++++++++++++- ...60921120000_add_embeddings_to_memories.exs | 46 +++ test/trinity/memory/embedder_test.exs | 87 +++++ test/trinity/memory/observer_test.exs | 209 ++++++++++++ test/trinity/memory/retriever_test.exs | 239 ++++++++++++++ test/trinity/memory/vector_store_test.exs | 143 ++++++++ test/trinity/tools/recall_test.exs | 113 +++++++ test/trinity/tools/registry_test.exs | 2 + test/trinity_web/live/semantic_tab_test.exs | 139 ++++++++ 38 files changed, 2731 insertions(+), 44 deletions(-) create mode 100644 lib/trinity/memory/embedder.ex create mode 100644 lib/trinity/memory/embedders/bumblebee.ex create mode 100644 lib/trinity/memory/embedders/fake.ex create mode 100644 lib/trinity/memory/embedders/hosted.ex create mode 100644 lib/trinity/memory/observer.ex create mode 100644 lib/trinity/memory/retriever.ex create mode 100644 lib/trinity/memory/semantic.ex create mode 100644 lib/trinity/memory/supervisor.ex create mode 100644 lib/trinity/memory/vector_store.ex create mode 100644 lib/trinity/memory/vector_stores/brute.ex create mode 100644 lib/trinity/memory/vector_stores/pgvector.ex create mode 100644 lib/trinity/repo/postgrex_types.ex create mode 100644 lib/trinity/tools/recall.ex create mode 100644 priv/repo/migrations/20260921120000_add_embeddings_to_memories.exs create mode 100644 test/trinity/memory/embedder_test.exs create mode 100644 test/trinity/memory/observer_test.exs create mode 100644 test/trinity/memory/retriever_test.exs create mode 100644 test/trinity/memory/vector_store_test.exs create mode 100644 test/trinity/tools/recall_test.exs create mode 100644 test/trinity_web/live/semantic_tab_test.exs diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 8dcadab..c0b1b0c 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -60,7 +60,9 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:17 + # Slice 032: pgvector's image of Postgres 17, so the vector extension exists for the + # semantic memory's Postgres store. + image: pgvector/pgvector:pg17 env: POSTGRES_USER: trinity POSTGRES_PASSWORD: trinity diff --git a/VERSIONS.md b/VERSIONS.md index 3182990..52682c6 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -87,7 +87,7 @@ never pin a version hex marks as retired or vulnerable. | `ecto_sql` | ~> 3.13 | βœ… in `mix.lock` | | | `ecto_sqlite3` | >= 0.0.0 | βœ… in `mix.lock` | Primary DB. FTS5 available. | | `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. | -| `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. | +| `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. | | `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. | | `req` | ~> 0.5 | βœ… in `mix.lock` | HTTP client. | | `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. | Name | Pin | Verified | Note | |---|---|---|---| -| `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. | -| `bumblebee` | ~> 0.7 | πŸ” not yet a dependency | `all-MiniLM-L6-v2` embeddings; Whisper later. Added at Slice 032. | -| `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. | -| `hnswlib` | ~> 0.1.7 | πŸ” not yet a dependency | ⚠️ Pre-1.0. Optional accelerator; not on the critical path. | +| `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. | +| `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 (the tier is off there). The XLA shared library is 463 MB on disk: docs/perf.md. Pulls `xla` 0.10.0 and `fine`. | +| `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). | +| `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. | +| `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. | ### Tools, sandbox and desktop diff --git a/config/config.exs b/config/config.exs index bc1c9ea..51fdeea 100644 --- a/config/config.exs +++ b/config/config.exs @@ -73,6 +73,8 @@ config :trinity, :tools, Trinity.Tools.Web.Search, # Slice 031: full-text search over past messages. Trinity.Tools.SessionSearch, + # Slice 032: hybrid recall over semantic memories and past messages. + Trinity.Tools.Recall, # Slice 030: the always-on memory tiers. Trinity.Tools.Memory, Trinity.Tools.Shell.Run @@ -82,7 +84,7 @@ config :trinity, :tools, web: ["web_fetch", "web_search"], shell: ["shell"], # Slice 031: search over past conversations. - memory: ["session_search", "memory"] + memory: ["session_search", "recall", "memory"] } # Slice 022: the filesystem roots beside the data directory (always a root) and the session's diff --git a/config/runtime.exs b/config/runtime.exs index 8f1c15f..04eb710 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -119,7 +119,9 @@ if config_env() == :prod do if System.get_env("TRINITY_DB") == "postgres" do config :trinity, Trinity.Repo, url: System.get_env("DATABASE_URL") || raise("TRINITY_DB=postgres needs DATABASE_URL"), - pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + # Slice 032: pgvector's `vector` type. + types: Trinity.Repo.PostgrexTypes config :trinity, Trinity.Repo.Receipts, url: System.get_env("DATABASE_URL"), diff --git a/config/test.exs b/config/test.exs index 6299861..707f30f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -27,6 +27,8 @@ config :trinity, :tools, Trinity.Tools.Web.Search, # Slice 031: full-text search over past messages. Trinity.Tools.SessionSearch, + # Slice 032: hybrid recall. + Trinity.Tools.Recall, # Slice 030: the always-on memory tiers. Trinity.Tools.Memory, Trinity.Tools.Shell.Run @@ -37,7 +39,7 @@ config :trinity, :tools, web: ["web_fetch", "web_search"], shell: ["shell"], # Slice 031: search over past conversations. - memory: ["session_search", "memory"] + memory: ["session_search", "recall", "memory"] }, timeout_ms: 2_000 @@ -97,7 +99,9 @@ if System.get_env("TRINITY_DB") == "postgres" do config :trinity, Trinity.Repo, url: System.get_env("DATABASE_URL") || raise("TRINITY_DB=postgres needs DATABASE_URL"), pool_size: 10, - pool: Ecto.Adapters.SQL.Sandbox + pool: Ecto.Adapters.SQL.Sandbox, + # Slice 032: pgvector's `vector` type. + types: Trinity.Repo.PostgrexTypes # Slice 024: the receipts Repo shares the Postgres database (its own migrations table). config :trinity, Trinity.Repo.Receipts, @@ -150,3 +154,7 @@ config :phoenix, # Slice 024: the receipt signing key and registry for the suite live under the project's # ignored tmp/, never in the data directory of the machine running the tests. config :trinity, :receipts, keys_dir: Path.expand("../tmp/test_keys", __DIR__) + +# Slice 032: the suite embeds with the deterministic fake; nothing leaves the machine and no +# model is needed. +config :trinity, :memory, embedder: :fake, observer: false diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 5a923bf..777ec89 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -42,7 +42,12 @@ Trinity.Application β”‚ # INSERT but does not guarantee each row read the correct predecessor. β”œβ”€β”€ Trinity.Effects.Boot # Slice 024, as built: a transient Task writing the boot receipt β”‚ # once the signer and the authority are known -β”œβ”€β”€ Trinity.Memory.Supervisor # Nx.Serving for embeddings, retrieval. Slice 032 +β”œβ”€β”€ Trinity.Memory.Supervisor # Slice 032, as built: Trinity.Memory.TaskSupervisor (the +β”‚ β”‚ # observer's task after each turn, the page's model download) +β”‚ └── Nx.Serving (Trinity.Memory.Embedding) # the local embedder, started only when the embedder is +β”‚ # :local and the model is in the cache; absent otherwise, the +β”‚ # tier off and the reason logged once. ensure_embedding/0 +β”‚ # starts it after a download. β”œβ”€β”€ Trinity.Skills.Registry # hot-loaded skills index. Slice 040 β”œβ”€β”€ Oban # cron + durable jobs. Slice 050 β”œβ”€β”€ 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. | `Trinity.LLM.Provider` | `stream/3`, `generate/3`, `embed/2`, `models/0`, `capabilities/1` | config `:providers` list | | `Trinity.Tools.Tool` | `name/0`, `description/0`, `schema/0`, `risk/0`, `execute/2` | config `:tools` list + MCP dynamic registration | | `Trinity.Gateways.Adapter` | `child_spec/1`, `deliver/2`, `capabilities/0` | config `:gateways` list | -| `Trinity.Memory.VectorStore` | `upsert/2`, `search/3`, `delete/1` | config `:vector_store` (`SqliteVec` \| `Pgvector` \| `Hnswlib`) | +| `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) | +| `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) | | `Trinity.Skills.Loader` | `load/1`, `validate/1` | config `:skill_loaders` | | `Trinity.Permissions.Policy` | `decide/3` β†’ `:allow | :deny | {:ask, prompt}` | config | @@ -120,7 +126,7 @@ UI/Gateway ──user_message──▢ Session(gen_statem) Session: append message β†’ build prompt (persona + always-on memory + skills index + history[compacted]) β†’ LLM.stream/3 ──chunks──▢ PubSub "session:" ──▢ LiveView + gateways β†’ on tool_call: Permissions.decide β†’ (ask β†’ approval_wait) β†’ Tools.execute in Task β†’ result appended - β†’ loop until final text β†’ persist β†’ Memory.observe(turn) (async) β†’ idle + β†’ loop until final text β†’ persist β†’ Memory.Observer.observe(turn) (a Task under Trinity.Memory.TaskSupervisor, as built at 032) β†’ idle ``` Every state transition is persisted before it is broadcast. A crash between persist and broadcast is safe diff --git a/docs/02-tech-stack.md b/docs/02-tech-stack.md index eda331f..46c0ab8 100644 --- a/docs/02-tech-stack.md +++ b/docs/02-tech-stack.md @@ -14,6 +14,7 @@ Versions live in `VERSIONS.md`. This file explains *why* each choice was made an | 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 | | 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) | | 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 | +| 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 | | Shell tool | MuonTrap | Guaranteed child kill on process death; cgroups on Linux | none | | Sandbox | Luerl (`sandbox` pkg) | In-VM, reduction-limited, no OS access | Untrusted native code β†’ container/microVM (out of scope) | | Modularity | `boundary` + behaviours + `Registry` | Compile-time enforcement of context deps | none | diff --git a/docs/05-data-model.md b/docs/05-data-model.md index 31efa25..17c2b45 100644 --- a/docs/05-data-model.md +++ b/docs/05-data-model.md @@ -75,6 +75,22 @@ vocabulary is `global | persona: | project: | session:` (a session `persona`, `global`), `key` is required for the two always-on tiers (`^[a-z0-9][a-z0-9_.-]{0,63}$`, unique with tier and scope), and the semantic tier's columns wait for 032. Two tables beside it: +As built at slice 032 (the semantic tier): no `memories_vec` virtual table and no `sqlite_vec` (its only release +needs `nx ~> 0.9`; NOTES decision 1). The vector is three columns on the row, on both databases: +`embedding` (binary: float32, little-endian, `dim * 4` bytes), `embedding_model` (the embedder that produced it, +e.g. `bumblebee:sentence-transformers/all-MiniLM-L6-v2`, `fake:sha256-384` in the suite) and `embedding_dim` +(integer), all nullable, under an index on (`persona_id`, `tier`, `embedding_model`). On Postgres the migration +also creates the `vector` extension, a fourth column `embedding_vector vector(384)` and an HNSW cosine index on +it where `tier = 'semantic'`; `Trinity.Memory.VectorStores.Pgvector` keeps it in step with the bytes column and +searches it with `<=>`, and `VectorStores.Brute` (SQLite) loads the filter's rows and scores them in Elixir. +A search takes the scope list as a required argument (M6) and runs only over rows whose `embedding_model` is the +embedder in force's: models are never mixed, a change of embedder is a re-embed. Semantic rows are written by +`Trinity.Memory.Semantic.add/2` (the observer after a turn, `by: "observer"`; the page, `by: "ui"`), keyed +`-<6 hex of its SHA-256>`, and every write is a `memory_changes` row (`add`, `remove`, `pin`). +The change log's `action` vocabulary grows by `pin` (a semantic memory promoted to `always_on` through +`AlwaysOn.add/2`, so the budget applies) and `by` by `observer`. Recall marks its memory hits' `last_used_at`, +which the retriever's recency decay reads. + ### memory_changes (Slice 030) `persona_id`, `action` (add | replace | remove | promote | consolidate), `tier`, `scope`, `key`, `before`, `after`, `by` (tool | ui | consolidator), `session_id`, `proposal_id`, `inserted_at`. Every write to the diff --git a/lib/trinity.ex b/lib/trinity.ex index d1e0354..03386f1 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -11,7 +11,8 @@ defmodule Trinity do # Tools and Permissions sub-boundaries and, until 024, `Effects.Catalog` (now # `Tools.Catalog`, exported by Tools). Slice 024 exports Repo.Receipts (the Receipts # boundary writes it), CorePolicy (the boot receipt reads it) and the Receipts, Authority - # and Effects sub-boundaries. + # and Effects sub-boundaries. Slice 032 exports Memory.Semantic, Memory.Retriever and + # Memory.Embedders.Bumblebee (the memory page's semantic tab and its download action). use Boundary, deps: [], exports: @@ -41,6 +42,9 @@ defmodule Trinity do Memory.Entry, Memory.Proposal, Memory.Change, + Memory.Semantic, + Memory.Retriever, + Memory.Embedders.Bumblebee, Tools, Permissions, Permissions.Approval, diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index eb2917c..aba15b5 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -44,6 +44,8 @@ defmodule Trinity.Application do {Registry, keys: :unique, name: Trinity.Registry}, # Slice 024: the signer and its key, the chain writers, the boot receipt. Before the # tools and the sessions, which receipt through it. + # Slice 032: the observer's task supervisor and the local embedding serving. + Trinity.Memory.Supervisor, Trinity.Receipts.Supervisor, # Slice 024: the boot receipt, once the signer and the authority are known. Trinity.Effects.Boot, diff --git a/lib/trinity/memory.ex b/lib/trinity/memory.ex index 2655735..331fe47 100644 --- a/lib/trinity/memory.ex +++ b/lib/trinity/memory.ex @@ -8,5 +8,23 @@ defmodule Trinity.Memory do """ use Boundary, deps: [Trinity, Trinity.LLM], - exports: [Tokens, Compactor, Search, AlwaysOn, Budget, Consolidator, Entry, Proposal, Change] + exports: [ + Tokens, + Compactor, + Search, + AlwaysOn, + Budget, + Consolidator, + Entry, + Proposal, + Change, + Embedder, + Embedders.Bumblebee, + Embedders.Fake, + Observer, + Retriever, + Semantic, + Supervisor, + VectorStore + ] end diff --git a/lib/trinity/memory/embedder.ex b/lib/trinity/memory/embedder.ex new file mode 100644 index 0000000..0b77e3f --- /dev/null +++ b/lib/trinity/memory/embedder.ex @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Embedder do + @moduledoc """ + Text to vectors (slice 032). One implementation is in force, chosen by + `config :trinity, :memory, embedder:`: `:local` (`Embedders.Bumblebee`, the default: + all-MiniLM-L6-v2 through Bumblebee and EXLA, on this machine), `:hosted` + (`Embedders.Hosted`, a configured alternative only: it sends text to a provider and is + never chosen for the operator), `:fake` (the suite). Every vector records the embedder's + `model_id/0` and `dim/0` on its row, and a store never mixes models (NOTES decision 4). + """ + + @type vector :: [float()] + + @doc "The vectors for texts, in order; `{:error, reason}` when the embedder cannot run." + @callback embed([String.t()]) :: {:ok, [vector()]} | {:error, term()} + + @doc "The dimension of this embedder's vectors." + @callback dim() :: pos_integer() + + @doc "The model's identifier as rows record it, e.g. `\"bumblebee:sentence-transformers/all-MiniLM-L6-v2\"`." + @callback model_id() :: String.t() + + @doc "`:ok`, or the reason this embedder cannot serve here (no model, no backend on this OS, not configured)." + @callback availability() :: :ok | {:off, term()} + + @doc "The configured choice: `:local` unless configuration says otherwise." + @spec configured() :: :local | :hosted | :fake + def configured, do: Application.get_env(:trinity, :memory, []) |> Keyword.get(:embedder, :local) + + @doc "The implementation the configuration names." + @spec impl() :: module() + def impl do + case configured() do + :local -> Trinity.Memory.Embedders.Bumblebee + :hosted -> Trinity.Memory.Embedders.Hosted + :fake -> Trinity.Memory.Embedders.Fake + end + end + + @doc "Embeds through the implementation in force." + @spec embed([String.t()]) :: {:ok, [vector()]} | {:error, term()} + def embed(texts) when is_list(texts), do: impl().embed(texts) + + @doc "Cosine similarity of two vectors of the same length (both L2-normalised: their dot)." + @spec cosine(vector(), vector()) :: float() + def cosine(a, b) when length(a) == length(b) do + {dot, na, nb} = + Enum.zip_reduce(a, b, {0.0, 0.0, 0.0}, fn x, y, {d, p, q} -> + {d + x * y, p + x * x, q + y * y} + end) + + if na == 0.0 or nb == 0.0, do: 0.0, else: dot / (:math.sqrt(na) * :math.sqrt(nb)) + end + + @doc "A vector as the row stores it: float32, little-endian." + @spec to_binary(vector()) :: binary() + def to_binary(vector), do: for(f <- vector, into: <<>>, do: <>) + + @doc "A stored vector back to floats." + @spec from_binary(binary()) :: vector() + def from_binary(bin), do: for(<>, do: f) +end diff --git a/lib/trinity/memory/embedders/bumblebee.ex b/lib/trinity/memory/embedders/bumblebee.ex new file mode 100644 index 0000000..ef6eb43 --- /dev/null +++ b/lib/trinity/memory/embedders/bumblebee.ex @@ -0,0 +1,120 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Embedders.Bumblebee do + @moduledoc """ + all-MiniLM-L6-v2 on this machine through Bumblebee and EXLA (slice 032): 384 dimensions, + mean pooling, L2-normalised; measured at G1 at 86 ms a sentence and 2.7 ms an item in a + batch of 32, with cosines 0.858 and 0.062 on the slice's two pairs. The serving runs under + `Trinity.Memory.Supervisor` as `Trinity.Memory.Embedding` when the model is present. + + **The model is not downloaded on its own.** `availability/0` answers `{:off, :model_missing}` + until `download/0` (an explicit action, on the memory page or by `mix trinity.embeddings.download`) + has put the 91 MB of weights and tokenizer files under the model cache + (`config :trinity, :memory, model_cache_dir:`, the data directory's `models/` by default; + `BUMBLEBEE_CACHE_DIR` overrides). On Windows, where XLA has no precompiled library, + `{:off, :no_local_backend}`; a missing or broken EXLA NIF, `{:off, {:exla, reason}}`. None of + these turns into a hosted call (NOTES decision 2). + """ + @behaviour Trinity.Memory.Embedder + + @repo "sentence-transformers/all-MiniLM-L6-v2" + @dim 384 + @serving Trinity.Memory.Embedding + + @impl true + def dim, do: @dim + + @impl true + def model_id, do: "bumblebee:" <> @repo + + @doc "The Hugging Face repository the weights come from." + @spec repo() :: String.t() + def repo, do: @repo + + @doc "The serving's registered name." + @spec serving_name() :: atom() + def serving_name, do: @serving + + @doc "The model cache directory in force." + @spec cache_dir() :: Path.t() + def cache_dir do + System.get_env("BUMBLEBEE_CACHE_DIR") || + Application.get_env(:trinity, :memory, [])[:model_cache_dir] || + Path.join(Trinity.Paths.data_dir(), "models") + end + + @impl true + def availability do + cond do + match?({:win32, _}, :os.type()) -> {:off, :no_local_backend} + not Code.ensure_loaded?(EXLA) -> {:off, {:exla, :not_compiled}} + not model_present?() -> {:off, :model_missing} + true -> :ok + end + end + + @doc "True when the weights and tokenizer are in the cache (checked offline, no request made)." + @spec model_present?() :: boolean() + def model_present? do + case load(offline: true) do + {:ok, _, _} -> true + _ -> false + end + end + + @impl true + def embed(texts) do + case Process.whereis(@serving) do + nil -> + {:error, {:embedder_off, availability()}} + + _pid -> + try do + results = Nx.Serving.batched_run(@serving, texts) + {:ok, Enum.map(results, &Nx.to_flat_list(&1.embedding))} + catch + kind, reason -> {:error, {kind, reason}} + end + end + end + + @doc """ + The `Nx.Serving` for the supervisor, or `{:error, reason}` when the model is not present. + Loading takes about 650 ms; the first embed compiles the graph (318 ms measured). + """ + @spec serving() :: {:ok, Nx.Serving.t()} | {:error, term()} + def serving do + with {:ok, model_info, tokenizer} <- load(offline: true) do + {:ok, + Bumblebee.Text.text_embedding(model_info, tokenizer, + compile: [batch_size: 32, sequence_length: 128], + defn_options: [compiler: EXLA], + output_pool: :mean_pooling, + output_attribute: :hidden_state, + embedding_processor: :l2_norm + )} + end + end + + @doc """ + Downloads the model into the cache (a network egress the operator asked for), then asks + the supervisor to start the serving. Returns `:ok` or the download's error. + """ + @spec download() :: :ok | {:error, term()} + def download do + with {:ok, _, _} <- load(offline: false) do + Trinity.Memory.Supervisor.ensure_embedding() + end + end + + defp load(opts) do + repo = {:hf, @repo, cache_dir: cache_dir(), offline: Keyword.get(opts, :offline, true)} + + with {:ok, model_info} <- Bumblebee.load_model(repo), + {:ok, tokenizer} <- Bumblebee.load_tokenizer(repo) do + {:ok, model_info, tokenizer} + end + rescue + e -> {:error, {:load, Exception.message(e)}} + end +end diff --git a/lib/trinity/memory/embedders/fake.ex b/lib/trinity/memory/embedders/fake.ex new file mode 100644 index 0000000..7663352 --- /dev/null +++ b/lib/trinity/memory/embedders/fake.ex @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Embedders.Fake do + @moduledoc """ + Deterministic vectors for the suite (slice 032): a text's SHA-256, taken twelve times + with a counter, expanded to 384 floats (the local model's width, so the Postgres job's + `vector(384)` column and index are the ones exercised) and L2-normalised. The same text + embeds the same way in every run, and two different texts land near orthogonal (their + cosine is a random walk over 384 signed bytes: within a few hundredths of zero). Texts that + share a `#near:` prefix map to nearby vectors (the key is what is hashed; the rest of + the text only nudges one component), so a dedupe test has something to find. + """ + @behaviour Trinity.Memory.Embedder + + @dim 384 + + @impl true + def dim, do: @dim + + @impl true + def model_id, do: "fake:sha256-#{@dim}" + + @impl true + def availability, do: :ok + + @impl true + def embed(texts), do: {:ok, Enum.map(texts, &vector/1)} + + @doc "The vector for one text." + @spec vector(String.t()) :: [float()] + def vector(text) do + {base, jitter} = + case Regex.run(~r/^#near:(\S+)\s*(.*)$/s, text) do + [_, key, rest] -> {key, byte_size(rest) * 0.01} + _ -> {text, 0.0} + end + + floats = + for i <- 0..11, <> <> base)>>, do: b / 255.0 - 0.5 + + floats = Enum.with_index(floats, fn f, i -> if i == 0, do: f + jitter, else: f end) + norm = :math.sqrt(Enum.sum(Enum.map(floats, &(&1 * &1)))) + Enum.map(floats, &(&1 / norm)) + end +end diff --git a/lib/trinity/memory/embedders/hosted.ex b/lib/trinity/memory/embedders/hosted.ex new file mode 100644 index 0000000..6b40520 --- /dev/null +++ b/lib/trinity/memory/embedders/hosted.ex @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Embedders.Hosted do + @moduledoc """ + A provider's embedding endpoint through `Trinity.LLM.embed/2` (slice 032). A configured + alternative only: `config :trinity, :memory, embedder: :hosted, hosted_model: "nvidia:embed"`. + Text sent here leaves the machine, so this module is never selected on the operator's + behalf (NOTES decisions 2 and 5), and `availability/0` says `{:off, :not_configured}` unless + the configuration names it. The dimension is the model's, read from the first vector and + kept for the run; `dim/0` before any call answers the configured `hosted_dim` (2048 for + nemotron-3-embed-1b, measured at G1). + """ + @behaviour Trinity.Memory.Embedder + + @dim_key {__MODULE__, :dim} + + @impl true + def dim do + :persistent_term.get( + @dim_key, + Application.get_env(:trinity, :memory, []) |> Keyword.get(:hosted_dim, 2048) + ) + end + + @impl true + def model_id, do: "hosted:" <> model() + + @impl true + def availability do + with :hosted <- Trinity.Memory.Embedder.configured(), + {:ok, %{caps: caps}} when is_list(caps) <- Trinity.LLM.Registry.lookup(model()) do + if :embed in caps, do: :ok, else: {:off, {:no_embed_capability, model()}} + else + {:error, reason} -> {:off, reason} + _ -> {:off, :not_configured} + end + end + + @impl true + def embed(texts) do + with :ok <- availability(), + {:ok, vectors} <- Trinity.LLM.embed(texts, model: model()) do + case vectors do + [v | _] -> :persistent_term.put(@dim_key, length(v)) + _ -> :ok + end + + {:ok, vectors} + end + end + + defp model, + do: Application.get_env(:trinity, :memory, []) |> Keyword.get(:hosted_model, "nvidia:embed") +end diff --git a/lib/trinity/memory/entry.ex b/lib/trinity/memory/entry.ex index 621cb46..f782a20 100644 --- a/lib/trinity/memory/entry.ex +++ b/lib/trinity/memory/entry.ex @@ -26,6 +26,11 @@ defmodule Trinity.Memory.Entry do field :source_message_id, Trinity.UUID field :confidence, :float field :last_used_at, :utc_datetime_usec + # Slice 032: the vector, the model that produced it (never mixed: docs/05 Β§ semantic) and + # its width. `nil` until the observer embeds the row. + field :embedding, :binary + field :embedding_model, :string + field :embedding_dim, :integer timestamps(type: :utc_datetime_usec) end @@ -48,7 +53,10 @@ defmodule Trinity.Memory.Entry do :body, :source_message_id, :confidence, - :last_used_at + :last_used_at, + :embedding, + :embedding_model, + :embedding_dim ]) |> validate_required([:persona_id, :tier, :scope, :key, :body]) |> validate_inclusion(:tier, @always_on_tiers) @@ -60,6 +68,38 @@ defmodule Trinity.Memory.Entry do |> unique_constraint([:tier, :scope, :key]) end + @doc """ + The changeset of a semantic memory (slice 032): the same fields, the tier fixed to + `semantic`, and `confidence` between 0 and 1. The always-on changeset refuses the tier so + the `memory` tool and the consolidator cannot write into it; pinning one (`Semantic.pin/2`) + goes through `changeset/2` with the always-on tier. + """ + @spec semantic_changeset(t(), map()) :: Ecto.Changeset.t() + def semantic_changeset(entry, attrs) do + entry + |> cast(attrs, [ + :persona_id, + :scope, + :key, + :body, + :source_message_id, + :confidence, + :last_used_at, + :embedding, + :embedding_model, + :embedding_dim + ]) + |> put_change(:tier, "semantic") + |> validate_required([:persona_id, :scope, :key, :body]) + |> validate_format(:key, ~r/^[a-z0-9][a-z0-9_.-]{0,63}$/, + message: "a short stable key: lowercase, digits, _ . -" + ) + |> validate_length(:body, min: 1, max: 4_000) + |> validate_format(:scope, ~r/^(global|persona:[^\s]+|project:[^\s]+|session:[^\s]+)$/) + |> validate_number(:confidence, greater_than_or_equal_to: 0, less_than_or_equal_to: 1) + |> unique_constraint([:tier, :scope, :key]) + end + @doc "Bytes an entry costs the budget: its key and body." @spec bytes(t()) :: non_neg_integer() def bytes(%__MODULE__{key: k, body: b}), do: byte_size(k) + byte_size(b) diff --git a/lib/trinity/memory/observer.ex b/lib/trinity/memory/observer.ex new file mode 100644 index 0000000..6b61b20 --- /dev/null +++ b/lib/trinity/memory/observer.ex @@ -0,0 +1,213 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Observer do + @moduledoc """ + Fills the semantic tier after a turn (slice 032). The session hands the completed turn's + messages to `observe/2`, which runs `run/2` under `Trinity.Memory.TaskSupervisor` so the + session is idle at once and a crash here is this task's alone. `run/2` asks the session's + own model (`Trinity.LLM.generate_object/3`, the one the operator chose for the conversation: + no text goes anywhere new, NOTES decision 5) for 0 to 3 durable facts, preferences or + decisions, embeds them in one batch, drops each whose cosine to a memory already in the + session's scope chain is at or over the dedupe threshold (0.92, `dedupe_cosine:`) or to + one earlier in the same batch, and inserts the rest as `semantic` rows in the persona's + scope with the source message, the model's confidence and a row in the change log + (`by: "observer"`). + + Off, and nothing runs, when the tier is off (`Trinity.Memory.Semantic.status/0`), when the + session has no persona, or when `config :trinity, :memory, observer: false` (the test + suite's default: the AC3 test calls `run/2` itself). + """ + + alias Trinity.LLM + alias Trinity.LLM.Request + alias Trinity.Memory.{AlwaysOn, Embedder, Entry, Semantic} + + require Logger + + @default_dedupe 0.92 + @max_memories 3 + + @schema %{ + "type" => "object", + "properties" => %{ + "memories" => %{ + "type" => "array", + "maxItems" => @max_memories, + "items" => %{ + "type" => "object", + "properties" => %{ + "kind" => %{"type" => "string", "enum" => ["fact", "preference", "decision"]}, + "body" => %{"type" => "string"}, + "confidence" => %{"type" => "number", "minimum" => 0, "maximum" => 1} + }, + "required" => ["kind", "body", "confidence"] + } + } + }, + "required" => ["memories"] + } + + @typedoc "What the session hands over: its id, its persona and its model." + @type turn :: %{session_id: String.t(), persona_id: String.t() | nil, model: String.t() | nil} + + @typedoc "A message of the turn, as the session's history carries it." + @type message :: %{id: String.t(), role: String.t(), content: String.t()} + + @doc "The JSON Schema the model answers with." + @spec schema() :: map() + def schema, do: @schema + + @doc "True when the observer will run for this turn." + @spec on?(turn()) :: boolean() + def on?(%{persona_id: persona_id}) do + is_binary(persona_id) and + Keyword.get(Application.get_env(:trinity, :memory, []), :observer, true) == true and + Semantic.on?() + end + + @doc "Runs `run/2` under the memory task supervisor; `:off` when the observer is off." + @spec observe(turn(), [message()]) :: {:ok, pid()} | :off + def observe(turn, messages) do + if on?(turn) do + Task.Supervisor.start_child(Trinity.Memory.TaskSupervisor, fn -> run(turn, messages) end) + else + :off + end + end + + @doc """ + Extracts, dedupes and stores; synchronous. Returns the entries inserted, `:off`, or the + model's error. The dedupe threshold is `dedupe_cosine:` in `config :trinity, :memory`. + """ + @spec run(turn(), [message()]) :: {:ok, [Entry.t()]} | :off | {:error, term()} + def run(%{persona_id: persona_id, session_id: session_id} = turn, messages) do + with true <- on?(turn) || :off, + {:ok, proposed} <- extract(turn, messages), + {:ok, vectors} <- Embedder.embed(Enum.map(proposed, & &1.body)) do + source = source_id(messages) + + inserted = + proposed + |> Enum.zip(vectors) + |> dedupe(persona_id, AlwaysOn.chain(persona_id, session_id)) + |> Enum.flat_map(fn {m, v} -> insert(m, v, persona_id, session_id, source) end) + + {:ok, inserted} + else + :off -> :off + {:error, reason} -> {:error, reason} + end + end + + # Drops a proposal whose vector is within the threshold of a memory in the chain or of one + # kept earlier in this batch; order preserved. + defp dedupe(pairs, persona_id, chain) do + threshold = + Keyword.get(Application.get_env(:trinity, :memory, []), :dedupe_cosine, @default_dedupe) + + {kept, _} = + Enum.reduce(pairs, {[], []}, fn {m, v}, {kept, seen} -> + if duplicate?(persona_id, chain, v, seen, threshold), + do: {kept, seen}, + else: {[{m, v} | kept], [v | seen]} + end) + + Enum.reverse(kept) + end + + defp insert(m, vector, persona_id, session_id, source) do + attrs = %{ + persona_id: persona_id, + scope: AlwaysOn.persona_scope(persona_id), + key: key(m.body), + body: m.body, + source_message_id: source, + confidence: m.confidence + } + + case Semantic.add(attrs, by: "observer", session_id: session_id, vector: vector) do + {:ok, entry} -> + [entry] + + {:error, reason} -> + Logger.debug("memory: observer skipped #{inspect(m.body)}: #{inspect(reason)}") + [] + end + end + + @doc "The key a body gets: its first words as a slug and six hex digits of its digest, so two wordings never collide and the same wording always meets `:exists`." + @spec key(String.t()) :: String.t() + def key(body) do + slug = + body + |> String.downcase() + |> String.replace(~r/[^a-z0-9]+/, "-") + |> String.trim("-") + |> String.slice(0, 40) + |> String.trim("-") + + digest = :crypto.hash(:sha256, body) |> Base.encode16(case: :lower) |> binary_part(0, 6) + if slug == "", do: "m-" <> digest, else: slug <> "-" <> digest + end + + # The turn, with roles, as the model reads it. Tool rows are long and rarely durable: + # named, not quoted. + defp extract(%{model: model, session_id: session_id}, messages) do + transcript = + Enum.map_join(messages, "\n\n", fn + %{role: "tool"} = m -> "[tool result: #{String.slice(m.content, 0, 200)}]" + m -> "#{m.role}: #{String.slice(m.content, 0, 4_000)}" + end) + + request = + Request.new!(%{ + model: model, + system: + "You watch a conversation between a person and their personal agent and note what is " <> + "worth remembering weeks from now: durable facts about the person or their work, their " <> + "preferences, and decisions they made. List 0 to #{@max_memories}. Skip anything transient, " <> + "anything about the agent itself, and anything a stranger would already know. Each memory " <> + "is one sentence in the third person, and confidence is how sure you are it is durable " <> + "and true (0 to 1). Invent nothing; an empty list is a fine answer.", + messages: [%{role: "user", content: transcript}] + }) + + case LLM.generate_object(request, @schema, session_id: session_id) do + {:ok, %{"memories" => memories}} when is_list(memories) -> + {:ok, memories |> Enum.flat_map(&clean/1) |> Enum.take(@max_memories)} + + {:ok, other} -> + {:error, {:no_memories, other}} + + {:error, reason} -> + {:error, reason} + end + end + + defp clean(%{"body" => body} = m) when is_binary(body) do + body = body |> String.trim() |> String.slice(0, 4_000) + + confidence = + case m["confidence"] do + n when is_number(n) -> n |> max(0) |> min(1) |> Kernel.*(1.0) + _ -> 0.5 + end + + if body == "", do: [], else: [%{body: body, confidence: confidence, kind: m["kind"]}] + end + + defp clean(_), do: [] + + defp duplicate?(persona_id, chain, vector, seen, threshold) do + Enum.any?(seen, &(Embedder.cosine(&1, vector) >= threshold)) or + Semantic.near(persona_id, chain, vector, threshold) != nil + end + + # Provenance: the assistant's last message of the turn, else the user's. + defp source_id(messages) do + case Enum.reverse(messages) do + [] -> nil + rev -> (Enum.find(rev, &(&1.role == "assistant")) || hd(rev)).id + end + end +end diff --git a/lib/trinity/memory/retriever.ex b/lib/trinity/memory/retriever.ex new file mode 100644 index 0000000..f339e64 --- /dev/null +++ b/lib/trinity/memory/retriever.ex @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Retriever do + @moduledoc """ + Hybrid recall (slice 032): what in the past is relevant to a query. Two ranked lists are + fused by reciprocal rank (`1 / (60 + rank)`, summed for an item in both): the vector + search over the persona's semantic memories in the session's scope chain (M6: never + wider) and the full-text search of 031 over the persona's past messages (the current + session's own rows are the history already and are left out). A recency decay then + scales each fused score by `0.5 + 0.5 * 2^(-age / 30 days)`, age being the time since a + memory was last used (or inserted) or a message was written: an old hit keeps at least + half its score, so it is demoted below a recent one of the same rank, never lost. + + A vector search always has `k` nearest rows, however far; a memory enters the vector list + only at a cosine of `recall_min_cosine:` (0.3) or more, the slice's own "unrelated" line + (AC2: the unrelated pair measures 0.062 on the local model, the related one 0.858), so a + question about nothing the person ever said recalls nothing. + + When the semantic tier is off, the vector list is empty and recall is the full-text list + alone: 031 keeps working (NOTES decision 3). Memories that come back are marked used. + """ + + alias Trinity.Memory.{AlwaysOn, Embedder, Search, Semantic, VectorStore} + + @rrf_k 60 + @half_life_days 30 + @default_k 8 + @default_min_cosine 0.3 + + @typedoc "A fused hit: a semantic memory or a past message, with the fused score and which lists found it." + @type hit :: %{ + kind: :memory | :message, + id: String.t(), + text: String.t(), + at: DateTime.t(), + score: float(), + found_by: [:vector | :fts], + ref: map() + } + + @doc """ + The `k` (#{@default_k}) most relevant hits for a query, best first. `opts`: `k:`, + `now:` (the decay's reference), `touch:` (mark memory hits used; true). + """ + @spec relevant(String.t(), String.t() | nil, String.t(), keyword()) :: [hit()] + def relevant(persona_id, session_id, query, opts \\ []) do + k = Keyword.get(opts, :k, @default_k) + now = Keyword.get(opts, :now) || DateTime.utc_now() + depth = max(k * 3, 20) + + vector = vector_hits(persona_id, session_id, query, depth) + fts = fts_hits(persona_id, session_id, query, depth) + + hits = + [{:vector, vector}, {:fts, fts}] + |> Enum.reduce(%{}, fn {source, list}, acc -> fuse(acc, source, list) end) + |> Map.values() + |> Enum.map(&%{&1 | score: &1.score * decay(&1.at, now)}) + |> Enum.sort_by(&{-&1.score, &1.id}) + |> Enum.take(k) + + if Keyword.get(opts, :touch, true) do + Semantic.touch(for %{kind: :memory, id: id} <- hits, do: id) + end + + hits + end + + @doc "The block the prompt carries: `## Relevant memories`, one line a hit; `\"\"` for none." + @spec render([hit()]) :: String.t() + def render([]), do: "" + + def render(hits) do + "## Relevant memories\n" <> + Enum.map_join(hits, "\n", fn + %{kind: :memory, at: at, text: text} -> + "- (remembered #{Calendar.strftime(at, "%Y-%m-%d")}) #{one_line(text)}" + + %{kind: :message, at: at, text: text, ref: ref} -> + "- (#{ref.role} in \"#{ref.session_title || "untitled"}\", #{Calendar.strftime(at, "%Y-%m-%d")}) #{one_line(text)}" + end) + end + + @doc "The reciprocal-rank contribution of a rank (1-based)." + @spec rrf(pos_integer()) :: float() + def rrf(rank), do: 1 / (@rrf_k + rank) + + @doc "The recency factor: 1 now, 0.75 at one half-life, never under 0.5." + @spec decay(DateTime.t(), DateTime.t()) :: float() + def decay(at, now) do + age_days = max(DateTime.diff(now, at, :second), 0) / 86_400 + 0.5 + 0.5 * :math.pow(2, -age_days / @half_life_days) + end + + # One list into the fused map: a first sighting takes its rank's share, a second adds it. + defp fuse(acc, source, list) do + list + |> Enum.with_index(1) + |> Enum.reduce(acc, fn {hit, rank}, acc -> + first = %{hit | score: rrf(rank), found_by: [source]} + again = &%{&1 | score: &1.score + rrf(rank), found_by: &1.found_by ++ [source]} + Map.update(acc, {hit.kind, hit.id}, first, again) + end) + end + + defp vector_hits(persona_id, session_id, query, depth) do + with true <- Semantic.on?(), + {:ok, [vector]} <- Embedder.embed([query]) do + chain = AlwaysOn.chain(persona_id, session_id) + + floor = + Keyword.get( + Application.get_env(:trinity, :memory, []), + :recall_min_cosine, + @default_min_cosine + ) + + vector + |> VectorStore.search(depth, Semantic.filter(persona_id, chain)) + |> Enum.filter(&(&1.score >= floor)) + |> Enum.map(fn %{entry: e, score: s} -> + %{ + kind: :memory, + id: e.id, + text: e.body, + at: e.last_used_at || e.inserted_at, + score: s, + found_by: [], + ref: %{key: e.key, scope: e.scope, source_message_id: e.source_message_id, cosine: s} + } + end) + else + _ -> [] + end + end + + defp fts_hits(persona_id, session_id, query, depth) do + query + |> Search.messages(limit: min(depth + 20, 100), persona_id: persona_id) + |> Enum.reject(&(&1.session_id == session_id)) + |> Enum.take(depth) + |> Enum.map(fn h -> + %{ + kind: :message, + id: h.message_id, + text: h.snippet, + at: h.inserted_at, + score: h.rank, + found_by: [], + ref: %{session_id: h.session_id, session_title: h.session_title, role: h.role, seq: h.seq} + } + end) + end + + defp one_line(text), + do: text |> String.replace(~r/\s+/, " ") |> String.trim() |> String.slice(0, 300) +end diff --git a/lib/trinity/memory/semantic.ex b/lib/trinity/memory/semantic.ex new file mode 100644 index 0000000..de5cdcd --- /dev/null +++ b/lib/trinity/memory/semantic.ex @@ -0,0 +1,201 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Semantic do + @moduledoc """ + The semantic tier (slice 032): unbounded rows of `memories` with `tier: "semantic"`, each + carrying the vector its embedder produced, written by the observer after a turn and read by + the retriever and the `recall` tool. Every write is a row in the change log, as the + always-on tiers' are (030). + + Whether the tier is on is `status/0`: the embedder in force can serve. When it cannot, the + tier is off (the observer and the retriever stand down, the recall tool says so, the memory + page says "unavailable"), never rerouted to a hosted embedder (NOTES decision 3). The status + is computed from the embedder's `availability/0` and the serving's presence every time it + is asked; nothing is cached, so a download changes the answer. + """ + + import Ecto.Query + + alias Trinity.Memory.{AlwaysOn, Embedder, Entry, VectorStore} + alias Trinity.Repo + + @type status :: :on | {:off, term()} + + @doc "The tier's status now." + @spec status() :: status() + def status do + impl = Embedder.impl() + + case impl.availability() do + :ok -> if serving?(impl), do: :on, else: {:off, :serving_not_started} + {:off, reason} -> {:off, reason} + end + end + + @doc "True when the tier is on." + @spec on?() :: boolean() + def on?, do: status() == :on + + @doc "The status as a sentence for a page or a tool." + @spec describe(status()) :: String.t() + def describe(:on), do: "semantic recall is on (#{Embedder.impl().model_id()})" + + def describe({:off, :model_missing}), + do: "semantic recall is unavailable: the local model is not downloaded" + + def describe({:off, :no_local_backend}), + do: "semantic recall is unavailable on this platform: no local embedding backend yet" + + def describe({:off, :not_configured}), + do: "semantic recall is unavailable: the hosted embedder is not configured" + + def describe({:off, :serving_not_started}), + do: "semantic recall is unavailable: the embedding serving is not running" + + def describe({:off, reason}), do: "semantic recall is unavailable: #{inspect(reason)}" + + @doc "The model id rows written now carry; the filter every search runs under." + @spec model() :: String.t() + def model, do: Embedder.impl().model_id() + + @doc "The search filter for a persona and the scopes it may see (M6: never wider)." + @spec filter(String.t(), [String.t()]) :: VectorStore.filter() + def filter(persona_id, scopes), do: %{persona_id: persona_id, scopes: scopes, model: model()} + + @doc """ + Adds a semantic memory: embeds the body (or takes `vector:` when the caller embedded it + already, the observer's batch), inserts the row with its vector and the model that + produced it, logs the change (`by:`, `session_id:`). `{:error, {:embedder_off, _}}` when the + tier is off; `{:error, :exists}` when the key is taken in that scope. + """ + @spec add(map(), keyword()) :: {:ok, Entry.t()} | {:error, term()} + def add(attrs, opts) do + attrs = Map.new(attrs, fn {k, v} -> {to_atom(k), v} end) + + with nil <- AlwaysOn.get("semantic", attrs[:scope], attrs[:key]), + {:ok, [vector]} <- vector_for(attrs[:body], Keyword.get(opts, :vector)), + {:ok, entry} <- insert(attrs, vector) do + # The bytes column is on the row already (the changeset); the Postgres store also fills + # its vector column. `upsert/3` is the one place that knows about both. + :ok = VectorStore.upsert(entry.id, vector, model()) + AlwaysOn.log(entry, "add", nil, entry.body, Keyword.put_new(opts, :by, "observer")) + {:ok, Repo.reload!(entry)} + else + %Entry{} -> {:error, :exists} + {:error, reason} -> {:error, reason} + end + end + + @doc "The nearest existing memory to a vector within the scopes, when its cosine reaches `threshold`." + @spec near(String.t(), [String.t()], Embedder.vector(), float()) :: VectorStore.hit() | nil + def near(persona_id, scopes, vector, threshold) do + case VectorStore.search(vector, 1, filter(persona_id, scopes)) do + [%{score: score} = hit] when score >= threshold -> hit + _ -> nil + end + end + + @doc "Removes a semantic memory; logged." + @spec remove(Entry.t(), keyword()) :: {:ok, Entry.t()} | {:error, term()} + def remove(%Entry{tier: "semantic"} = entry, opts) do + with {:ok, deleted} <- Repo.delete(entry) do + AlwaysOn.log(entry, "remove", entry.body, nil, Keyword.put_new(opts, :by, "owner")) + {:ok, deleted} + end + end + + @doc """ + Pins a semantic memory: it becomes an `always_on` entry of the same scope and key through + `AlwaysOn.add/2` (the budget check and the consolidator apply), and the semantic row goes, + logged as `pin`. `{:error, :exists}` when the always-on tier has the key already. + """ + @spec pin(Entry.t(), keyword()) :: {:ok, Entry.t()} | {:error, term()} + def pin(%Entry{tier: "semantic"} = entry, opts) do + opts = Keyword.put_new(opts, :by, "owner") + + attrs = %{ + persona_id: entry.persona_id, + tier: "always_on", + scope: entry.scope, + key: entry.key, + body: entry.body, + source_message_id: entry.source_message_id, + confidence: entry.confidence + } + + with {:ok, pinned} <- AlwaysOn.add(attrs, opts), + {:ok, _} <- Repo.delete(entry) do + AlwaysOn.log(entry, "pin", entry.body, "always_on", opts) + {:ok, pinned} + end + end + + @doc "Marks memories as used now (the retriever's hits), so recency decay sees them." + @spec touch([String.t()]) :: :ok + def touch([]), do: :ok + + def touch(ids) do + Repo.update_all(from(e in Entry, where: e.id in ^ids), + set: [last_used_at: DateTime.utc_now()] + ) + + :ok + end + + @doc "A persona's semantic memories within the scopes, newest first (`limit:`)." + @spec entries(String.t(), [String.t()], keyword()) :: [Entry.t()] + def entries(persona_id, scopes, opts \\ []) do + from(e in Entry, + where: e.persona_id == ^persona_id and e.tier == "semantic" and e.scope in ^scopes, + order_by: [desc: e.inserted_at], + limit: ^Keyword.get(opts, :limit, 200) + ) + |> Repo.all() + end + + @doc "Every semantic memory of a persona, in all scopes, newest first (`limit:`; the memory page)." + @spec all(String.t(), keyword()) :: [Entry.t()] + def all(persona_id, opts \\ []) do + from(e in Entry, + where: e.persona_id == ^persona_id and e.tier == "semantic", + order_by: [desc: e.inserted_at], + limit: ^Keyword.get(opts, :limit, 200) + ) + |> Repo.all() + end + + @doc "How many semantic memories a persona has, in all scopes." + @spec count(String.t()) :: non_neg_integer() + def count(persona_id), + do: + Repo.aggregate( + from(e in Entry, where: e.persona_id == ^persona_id and e.tier == "semantic"), + :count + ) + + @doc "A semantic memory by id." + @spec get(String.t()) :: Entry.t() | nil + def get(id), do: Repo.get_by(Entry, id: id, tier: "semantic") + + defp vector_for(body, nil), do: Embedder.embed([body]) + defp vector_for(_body, vector) when is_list(vector), do: {:ok, [vector]} + + defp insert(attrs, vector) do + attrs = + Map.merge(attrs, %{ + embedding: Embedder.to_binary(vector), + embedding_model: model(), + embedding_dim: length(vector) + }) + + %Entry{} |> Entry.semantic_changeset(attrs) |> Repo.insert() + end + + defp serving?(Trinity.Memory.Embedders.Bumblebee), + do: is_pid(Process.whereis(Trinity.Memory.Embedders.Bumblebee.serving_name())) + + defp serving?(_), do: true + + defp to_atom(k) when is_atom(k), do: k + defp to_atom(k) when is_binary(k), do: String.to_existing_atom(k) +end diff --git a/lib/trinity/memory/supervisor.ex b/lib/trinity/memory/supervisor.ex new file mode 100644 index 0000000..c328527 --- /dev/null +++ b/lib/trinity/memory/supervisor.ex @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Supervisor do + @moduledoc """ + The memory side's processes (slice 032): the task supervisor the observer runs under, and + the embedding serving when the local embedder can serve. The serving is started at boot + when the model is present and on demand after a download (`ensure_embedding/0`); when it + cannot start, the tier is off and the reason is logged once, and the tree boots. + """ + use Supervisor + + require Logger + + alias Trinity.Memory.{Embedder, Embedders, Semantic} + + def start_link(opts), do: Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts) do + children = [{Task.Supervisor, name: Trinity.Memory.TaskSupervisor}] ++ embedding_children() + Supervisor.init(children, strategy: :one_for_one) + end + + @doc "Starts the local serving if the model is present and it is not running; reports the tier's status." + @spec ensure_embedding() :: :ok | {:error, term()} + def ensure_embedding do + case {Embedder.impl(), Process.whereis(Embedders.Bumblebee.serving_name())} do + {Embedders.Bumblebee, nil} -> + with {:ok, spec} <- embedding_child(), do: start_child(spec) + + _ -> + :ok + end + end + + defp start_child(spec) do + case Supervisor.start_child(__MODULE__, spec) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp embedding_children do + if Embedder.impl() == Embedders.Bumblebee do + case embedding_child() do + {:ok, spec} -> + [spec] + + {:error, reason} -> + Logger.info("memory: #{Semantic.describe({:off, reason})}") + [] + end + else + [] + end + end + + defp embedding_child do + with :ok <- Embedders.Bumblebee.availability(), + {:ok, serving} <- Embedders.Bumblebee.serving() do + {:ok, + Supervisor.child_spec( + {Nx.Serving, + serving: serving, + name: Embedders.Bumblebee.serving_name(), + batch_size: 32, + batch_timeout: 50}, + id: Trinity.Memory.Embedding + )} + else + {:off, reason} -> {:error, reason} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/trinity/memory/vector_store.ex b/lib/trinity/memory/vector_store.ex new file mode 100644 index 0000000..b3d9c40 --- /dev/null +++ b/lib/trinity/memory/vector_store.ex @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.VectorStore do + @moduledoc """ + Where semantic memories' vectors live and how they are searched (slice 032). The scope + filter is a required argument of `search/3` (M6): a search never sees a scope the caller + did not name. The store in force is the adapter's: `Brute` on SQLite (the persona's rows + of the embedder in force loaded and scored in Elixir; docs/02: fine to about 10^5) and + `Pgvector` on Postgres. Both search only rows whose `embedding_model` is the embedder in + force's (NOTES decision 4). + """ + + @type hit :: %{id: String.t(), score: float(), entry: Trinity.Memory.Entry.t()} + @type filter :: %{persona_id: String.t(), scopes: [String.t()], model: String.t()} + + @doc "Stores a vector on an existing semantic memory row." + @callback upsert(entry_id :: String.t(), vector :: [float()], model :: String.t()) :: + :ok | {:error, term()} + + @doc "The `k` nearest rows by cosine within the filter, best first." + @callback search(query :: [float()], k :: pos_integer(), filter()) :: [hit()] + + @doc "Removes a row's vector (the row itself is `AlwaysOn.remove/2`'s to remove)." + @callback delete(entry_id :: String.t()) :: :ok + + @doc "How many vectors the filter holds." + @callback count(filter()) :: non_neg_integer() + + @adapter Application.compile_env(:trinity, :db_adapter, Ecto.Adapters.SQLite3) + + @doc "The store for this build's adapter." + @spec impl() :: module() + if @adapter == Ecto.Adapters.Postgres do + def impl, do: Trinity.Memory.VectorStores.Pgvector + else + def impl, do: Trinity.Memory.VectorStores.Brute + end + + @doc "Delegates to the store in force." + @spec upsert(String.t(), [float()], String.t()) :: :ok | {:error, term()} + def upsert(id, vector, model), do: impl().upsert(id, vector, model) + + @doc "Delegates to the store in force." + @spec search([float()], pos_integer(), filter()) :: [hit()] + def search(query, k, filter), do: impl().search(query, k, filter) + + @doc "Delegates to the store in force." + @spec delete(String.t()) :: :ok + def delete(id), do: impl().delete(id) + + @doc "Delegates to the store in force." + @spec count(filter()) :: non_neg_integer() + def count(filter), do: impl().count(filter) +end diff --git a/lib/trinity/memory/vector_stores/brute.ex b/lib/trinity/memory/vector_stores/brute.ex new file mode 100644 index 0000000..39cfe96 --- /dev/null +++ b/lib/trinity/memory/vector_stores/brute.ex @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.VectorStores.Brute do + @moduledoc """ + The SQLite vector store (slice 032): vectors as float32 bytes on `memories.embedding`, + a search loads the filter's rows (a persona's semantic tier, the named scopes, the + embedder in force's model) and scores them in Elixir by cosine. Measured at G3 for 10,000 + rows; docs/02 puts brute force at fine to about 10^5, and hnswlib is the step after. + """ + @behaviour Trinity.Memory.VectorStore + + import Ecto.Query + + alias Trinity.Memory.{Embedder, Entry} + alias Trinity.Repo + + @impl true + def upsert(id, vector, model) when is_list(vector) do + {n, _} = + Repo.update_all(from(e in Entry, where: e.id == ^id), + set: [ + embedding: Embedder.to_binary(vector), + embedding_model: model, + embedding_dim: length(vector) + ] + ) + + if n == 1, do: :ok, else: {:error, :no_such_entry} + end + + @impl true + def search(query, k, %{persona_id: persona_id, scopes: scopes, model: model}) + when is_list(query) do + from(e in Entry, + where: + e.persona_id == ^persona_id and e.tier == "semantic" and e.scope in ^scopes and + e.embedding_model == ^model and not is_nil(e.embedding) + ) + |> Repo.all() + |> Enum.map(fn e -> + %{id: e.id, score: Embedder.cosine(query, Embedder.from_binary(e.embedding)), entry: e} + end) + |> Enum.sort_by(& &1.score, :desc) + |> Enum.take(k) + end + + @impl true + def delete(id) do + Repo.update_all(from(e in Entry, where: e.id == ^id), + set: [embedding: nil, embedding_model: nil, embedding_dim: nil] + ) + + :ok + end + + @impl true + def count(%{persona_id: persona_id, scopes: scopes, model: model}) do + Repo.aggregate( + from(e in Entry, + where: + e.persona_id == ^persona_id and e.tier == "semantic" and e.scope in ^scopes and + e.embedding_model == ^model and not is_nil(e.embedding) + ), + :count + ) + end +end diff --git a/lib/trinity/memory/vector_stores/pgvector.ex b/lib/trinity/memory/vector_stores/pgvector.ex new file mode 100644 index 0000000..b324c6e --- /dev/null +++ b/lib/trinity/memory/vector_stores/pgvector.ex @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.VectorStores.Pgvector do + @moduledoc """ + The Postgres vector store (slice 032): the same rows, with the vector also in + `memories.embedding_vector` (pgvector, `vector(384)`), searched by cosine distance + (`<=>`) under the HNSW index over the semantic tier. Keeps `embedding`, `embedding_model` + and `embedding_dim` in step with the SQLite store so an export reads the same columns on + both. Only vectors of 384 dimensions fit the column; another embedder's go to the bytes + column alone and are searched brute force here too. + """ + @behaviour Trinity.Memory.VectorStore + + import Ecto.Query + + alias Trinity.Memory.{Embedder, Entry} + alias Trinity.Repo + + @column_dim 384 + + @impl true + def upsert(id, vector, model) when is_list(vector) do + bytes = Embedder.to_binary(vector) + + {n, _} = + Repo.update_all(from(e in Entry, where: e.id == ^id), + set: [embedding: bytes, embedding_model: model, embedding_dim: length(vector)] + ) + + if n == 1 and length(vector) == @column_dim do + Repo.query!("UPDATE memories SET embedding_vector = $1 WHERE id = $2", [ + Pgvector.new(vector), + Ecto.UUID.dump!(id) + ]) + end + + if n == 1, do: :ok, else: {:error, :no_such_entry} + end + + @impl true + def search(query, k, %{persona_id: persona_id, scopes: scopes, model: model}) + when is_list(query) do + if length(query) == @column_dim do + vec = Pgvector.new(query) + + from(e in Entry, + where: + e.persona_id == ^persona_id and e.tier == "semantic" and e.scope in ^scopes and + e.embedding_model == ^model and not is_nil(e.embedding), + order_by: fragment("embedding_vector <=> ?", ^vec), + limit: ^k, + select: {e, fragment("1 - (embedding_vector <=> ?)", ^vec)} + ) + |> Repo.all() + |> Enum.map(fn {e, score} -> %{id: e.id, score: score * 1.0, entry: e} end) + else + Trinity.Memory.VectorStores.Brute.search(query, k, %{ + persona_id: persona_id, + scopes: scopes, + model: model + }) + end + end + + @impl true + def delete(id) do + Repo.update_all(from(e in Entry, where: e.id == ^id), + set: [embedding: nil, embedding_model: nil, embedding_dim: nil] + ) + + Repo.query!("UPDATE memories SET embedding_vector = NULL WHERE id = $1", [Ecto.UUID.dump!(id)]) + + :ok + end + + @impl true + def count(filter), do: Trinity.Memory.VectorStores.Brute.count(filter) +end diff --git a/lib/trinity/repo/postgrex_types.ex b/lib/trinity/repo/postgrex_types.ex new file mode 100644 index 0000000..96a0b3a --- /dev/null +++ b/lib/trinity/repo/postgrex_types.ex @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# Slice 032: the Postgres build's Postgrex types with pgvector's `vector` beside the +# defaults, so `memories.embedding_vector` can be bound and read. Defined only when the +# Postgres adapter is compiled in (`TRINITY_DB=postgres`; postgrex is optional and absent +# from the desktop build); `config :trinity, Trinity.Repo, types: Trinity.Repo.PostgrexTypes` +# names it in the same branch of config/runtime.exs and config/test.exs. +if Application.compile_env(:trinity, :db_adapter, Ecto.Adapters.SQLite3) == Ecto.Adapters.Postgres do + Postgrex.Types.define( + Trinity.Repo.PostgrexTypes, + Pgvector.extensions() ++ Ecto.Adapters.Postgres.extensions(), + [] + ) +end diff --git a/lib/trinity/sessions.ex b/lib/trinity/sessions.ex index 37e6ab6..8f90c3b 100644 --- a/lib/trinity/sessions.ex +++ b/lib/trinity/sessions.ex @@ -157,6 +157,10 @@ defmodule Trinity.Sessions do do: Store.update_session(session, %{project_root: expanded}) end + @doc "A message by id (slice 032: the memory page's provenance link)." + @spec get_message(String.t()) :: Message.t() | nil + def get_message(id), do: Store.get_message(id) + @doc "The number of messages in a session." @spec message_count(session_id()) :: non_neg_integer() def message_count(session_id), do: Store.message_count(session_id) diff --git a/lib/trinity/sessions/prompt.ex b/lib/trinity/sessions/prompt.ex index d84cffd..644714f 100644 --- a/lib/trinity/sessions/prompt.ex +++ b/lib/trinity/sessions/prompt.ex @@ -29,7 +29,15 @@ defmodule Trinity.Sessions.Prompt do # plus 300 for the skills index 040 adds). @default_budgets [stable: 800, context: 5_800, volatile: 2_800] - @type truncation :: %{tier: :stable | :context | :volatile, dropped_tokens: pos_integer()} + @type truncation :: %{ + tier: :stable | :context | :volatile | :recall, + dropped_tokens: pos_integer() + } + + # Slice 032: the recall block's own cap inside the volatile tier, measured against the + # volatile budget of 030 (2,800): eight one-line hits of 300 characters are about 800 tokens + # under the estimator; 600 keeps the always-in-mind block and the facts whole. + @default_recall_tokens 600 @doc "The request for the next model call; `tools` is the declared surface (slice 020), none by default." @spec build(SessionRow.t(), Persona.t() | nil, [Message.t()], [Request.tool()], keyword()) :: @@ -43,7 +51,9 @@ defmodule Trinity.Sessions.Prompt do The request and the truncations the tier budgets forced. `opts`: `memory:` (the snapshot block, `""` when none), `now:` (the time the volatile tier states; `DateTime.utc_now/0` by default), `context:` (the context tier's text: the AGENTS.md block from slice 033, the - skills index from 040; `""` when none). + skills index from 040; `""` when none), `recall:` (slice 032's "Relevant memories" block, + cut at `config :trinity, :memory, recall_tokens:` before it joins the volatile tier, the + cut reported as the `:recall` tier; `""` when none). """ @spec build_with_report( SessionRow.t(), @@ -57,6 +67,12 @@ defmodule Trinity.Sessions.Prompt do {compaction, rows} = fold_compaction(history) budgets = Keyword.merge(@default_budgets, Application.get_env(:trinity, :prompt_budgets, [])) + {recall, recall_truncations} = + case cut(Keyword.get(opts, :recall, ""), recall_tokens()) do + {kept, 0} -> {kept, []} + {kept, dropped} -> {kept, [%{tier: :recall, dropped_tokens: dropped}]} + end + tiers = [ {:stable, system(persona) <> "\n\n" <> @untrusted_rule}, {:context, Keyword.get(opts, :context, "")}, @@ -64,12 +80,13 @@ defmodule Trinity.Sessions.Prompt do volatile( session, Keyword.get(opts, :memory, ""), + recall, Keyword.get(opts, :now) || DateTime.utc_now() )} ] {texts, truncations} = - Enum.map_reduce(tiers, [], fn {tier, text}, acc -> + Enum.map_reduce(tiers, recall_truncations, fn {tier, text}, acc -> case cut(text, Keyword.fetch!(budgets, tier)) do {kept, 0} -> {kept, acc} {kept, dropped} -> {kept, acc ++ [%{tier: tier, dropped_tokens: dropped}]} @@ -95,13 +112,23 @@ defmodule Trinity.Sessions.Prompt do def budgets, do: Keyword.merge(@default_budgets, Application.get_env(:trinity, :prompt_budgets, [])) - # The volatile tier: the memory block, the time, the session's facts. - defp volatile(session, memory, now) do + @doc "The recall block's cap in tokens." + @spec recall_tokens() :: pos_integer() + def recall_tokens, + do: + Keyword.get( + Application.get_env(:trinity, :memory, []), + :recall_tokens, + @default_recall_tokens + ) + + # The volatile tier: the memory block, the recall block, the time, the session's facts. + defp volatile(session, memory, recall, now) do facts = "The time now is #{DateTime.to_iso8601(DateTime.truncate(now, :second))} (UTC)." <> title(session) - if memory == "", do: facts, else: memory <> "\n\n" <> facts + [memory, recall, facts] |> Enum.reject(&(&1 == "")) |> Enum.join("\n\n") end defp title(%SessionRow{title: t}) when is_binary(t) and t != "", diff --git a/lib/trinity/sessions/session.ex b/lib/trinity/sessions/session.ex index 45f1100..9bca437 100644 --- a/lib/trinity/sessions/session.ex +++ b/lib/trinity/sessions/session.ex @@ -357,8 +357,16 @@ defmodule Trinity.Sessions.Session do # Slice 033: the project's AGENTS.md, read now, so a change is in this turn (live reload). context = Trinity.Context.AgentsMd.render(session.project_root, session.project_root) + # Slice 032: what the semantic tier and past conversations hold about the latest user + # message, fused and capped; "" when there is no persona or nothing relevant. + recall = recall_block(session, history) + {request, truncations} = - Prompt.build_with_report(session, persona, history, tools, memory: memory, context: context) + Prompt.build_with_report(session, persona, history, tools, + memory: memory, + context: context, + recall: recall + ) Enum.each(truncations, &truncation_receipt(id, &1)) {session, persona, history, request} @@ -379,6 +387,20 @@ defmodule Trinity.Sessions.Session do }) end + # Slice 032: the retriever runs on the latest user message, over the session's scope chain. + defp recall_block(%{persona_id: persona_id, id: id}, history) when is_binary(persona_id) do + case Enum.reverse(history) |> Enum.find(&(&1.role == "user")) do + nil -> + "" + + %{content: query} -> + Trinity.Memory.Retriever.relevant(persona_id, id, query) + |> Trinity.Memory.Retriever.render() + end + end + + defp recall_block(_, _), do: "" + # Slice 030: the always-on block for this session's chain, frozen in state. defp memory_snapshot(%{id: id, persona_id: persona_id}) when is_binary(persona_id), do: Trinity.Memory.AlwaysOn.snapshot(persona_id, id) @@ -634,10 +656,25 @@ defmodule Trinity.Sessions.Session do _ -> data = persist_final(data, %{}) + observe_turn(data) {:next_state, :idle, %{data | turn: nil}} end end + # Slice 032: the completed turn (from its user message on) goes to the memory observer, + # which runs under its own supervisor; the session is idle at once and never waits on it. + defp observe_turn(%State{id: id, session: session}) do + row = Store.get_session(id) || session + history = Trinity.Sessions.history(id, limit: 60) + last_user = history |> Enum.reverse() |> Enum.find_index(&(&1.role == "user")) + turn = if last_user, do: Enum.take(history, -(last_user + 1)), else: history + + Trinity.Memory.Observer.observe( + %{session_id: id, persona_id: row.persona_id, model: row.model}, + Enum.map(turn, &%{id: &1.id, role: &1.role, content: &1.content}) + ) + end + # After the tool rows: the next model call, or the cap. defp next_turn(%State{turn: turn} = data) do turn = %{turn | pending: [], held: [], turns: turn.turns + 1} diff --git a/lib/trinity/tools/recall.ex b/lib/trinity/tools/recall.ex new file mode 100644 index 0000000..886eda9 --- /dev/null +++ b/lib/trinity/tools/recall.ex @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Tools.Recall do + @moduledoc """ + `recall`: hybrid recall over the semantic tier and past conversations (slice 032, + `Trinity.Memory.Retriever`). A read: risk `:read`, effect `:none`, so it runs without + asking and leaves a query receipt. The hits are the person's own memories and history and + come back as text the model reads; they are wrapped as untrusted all the same, as + `session_search`'s are (docs/07: a tool result re-entering the prompt is data). When the + semantic tier is off the answer says so and carries the full-text half alone. + """ + @behaviour Trinity.Tools.Tool + + alias Trinity.Memory.{Retriever, Semantic} + alias Trinity.Tools.{Context, Untrusted} + + @default_k 8 + @max_k 20 + + @impl true + def name, do: "recall" + + @impl true + def description, + do: + "Recalls what is relevant to a question from long-term memory and past conversations: memories by meaning, messages by their words, fused and recent first. Returns at most `k` hits, each as `memory Β· when Β· text` or `session Β· when Β· role: …snippet…`. Use it before answering anything that depends on what this person told you in the past." + + @impl true + def schema, + do: %{ + "type" => "object", + "properties" => %{ + "query" => %{ + "type" => "string", + "description" => "What to recall, as a question or a phrase" + }, + "k" => %{ + "type" => "integer", + "minimum" => 1, + "maximum" => @max_k, + "description" => "At most this many hits (default #{@default_k})" + } + }, + "required" => ["query"], + "additionalProperties" => false + } + + @impl true + def risk, do: :read + + @impl true + def effect, do: :none + + @impl true + def execute(%{"query" => query} = args, %Context{session_id: session_id, persona: persona}) do + k = args |> Map.get("k", @default_k) |> min(@max_k) + status = Semantic.status() + + hits = + case persona do + %{id: persona_id} -> Retriever.relevant(persona_id, session_id, query, k: k) + _ -> [] + end + + text = + case {hits, status} do + {[], _} -> "Nothing recalled for #{inspect(query)}." + {hits, _} -> Enum.map_join(hits, "\n", &line/1) + end + + text = + if status == :on, + do: text, + else: "(#{Semantic.describe(status)}; full-text hits only)\n" <> text + + meta = %{ + "query" => query, + "hits" => length(hits), + "k" => k, + "semantic" => status == :on, + "memory_hits" => Enum.count(hits, &(&1.kind == :memory)) + } + + {:ok, Untrusted.result(text, tool: name(), source_ref: "recall:" <> query, meta: meta)} + end + + defp line(%{kind: :memory, at: at, text: text, ref: ref}), + do: "memory #{ref.key} Β· #{Calendar.strftime(at, "%Y-%m-%d")} Β· #{text}" + + defp line(%{kind: :message, at: at, text: text, ref: ref}) do + title = ref.session_title || "untitled" + + "#{title} (#{ref.session_id}) Β· #{Calendar.strftime(at, "%Y-%m-%d %H:%M")} Β· #{ref.role}: #{text}" + end +end diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index a950ba5..b74f796 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -141,10 +141,10 @@ defmodule Trinity.Versions do }, %{ name: "pgvector", - pin: "optional, ~> 0.3", + pin: "~> 0.4.1", lock: "pgvector", note: - "Vectors on the Postgres path. Not yet a dependency; Slice 032 decides. Split from the postgrex row at Slice 010." + "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." }, %{ name: "oban", @@ -193,30 +193,39 @@ defmodule Trinity.Versions do @memory [ %{ - name: "nx, exla", - pin: "latest stable", - lock: nil, + name: "nx", + pin: "~> 0.13.1", + lock: "nx", + note: + "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." + }, + %{ + name: "exla", + pin: "~> 0.13.1", + lock: "exla", note: - "Local embeddings. EXLA binary size matters for desktop: measure in 032. Two packages, so no single lock key." + "The XLA backend the serving compiles to (Slice 032); precompiled for x86_64 and aarch64 Linux and macOS, none for Windows (the tier is off there). The XLA shared library is 463 MB on disk: docs/perf.md. Pulls `xla` 0.10.0 and `fine`." }, %{ name: "bumblebee", - pin: "~> 0.7", + pin: "~> 0.7.1", lock: "bumblebee", - note: "`all-MiniLM-L6-v2` embeddings; Whisper later. Added at Slice 032." + note: + "`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)." }, %{ name: "sqlite_vec", - pin: "~> 0.1", - lock: "sqlite_vec", + pin: "not used (decided at Slice 032, 2026-09-21)", + lock: nil, note: - "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." + "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." }, %{ name: "hnswlib", - pin: "~> 0.1.7", + pin: "~> 0.1.10", lock: "hnswlib", - note: "⚠️ Pre-1.0. Optional accelerator; not on the critical path." + note: + "⚠️ 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." } ] diff --git a/lib/trinity_web/live/memory_live.ex b/lib/trinity_web/live/memory_live.ex index ae1fe5d..7f28c5d 100644 --- a/lib/trinity_web/live/memory_live.ex +++ b/lib/trinity_web/live/memory_live.ex @@ -8,12 +8,21 @@ defmodule TrinityWeb.MemoryLive do with `by: "ui"`, so it is in the change log like the tool's; a proposal is applied or rejected through `Trinity.Memory.Consolidator`. Running sessions keep their snapshot until their next start or refresh. + + `/memory?tab=semantic` (slice 032): the semantic tier. The tier's status line (on, or why + it is unavailable), the model download as an explicit action with progress when the local + model is missing, a search box over `Trinity.Memory.Retriever` (fused hits with their + scores and which list found them), and every semantic memory with its provenance link to + the message it came from, deletable or pinnable (pin: promoted to `always_on`, logged). """ use TrinityWeb, :live_view - alias Trinity.Memory.{AlwaysOn, Budget, Consolidator} + alias Trinity.Memory.{AlwaysOn, Budget, Consolidator, Embedders, Retriever, Semantic} alias Trinity.Personas + @tabs ~w(always_on semantic) + @model_bytes 91_359_935 + @impl true def mount(params, _session, socket) do personas = Personas.list() @@ -21,8 +30,23 @@ defmodule TrinityWeb.MemoryLive do {:ok, socket - |> assign(page_title: gettext("Memory"), personas: personas, persona: persona, editing: nil) - |> load()} + |> assign( + page_title: gettext("Memory"), + personas: personas, + persona: persona, + editing: nil, + tab: "always_on", + query: "", + hits: nil, + download: nil + )} + end + + # The tab is the URL's; every patch reloads, so a tab shows what the other's writes left. + @impl true + def handle_params(params, _uri, socket) do + tab = if params["tab"] in @tabs, do: params["tab"], else: "always_on" + {:noreply, socket |> assign(tab: tab) |> load()} end defp load(%{assigns: %{persona: persona}} = socket) do @@ -30,16 +54,74 @@ defmodule TrinityWeb.MemoryLive do entries: AlwaysOn.all(persona.id), budget: Budget.status(persona.id), proposals: Consolidator.pending(persona.id), - changes: AlwaysOn.changes(persona.id, limit: 20) + changes: AlwaysOn.changes(persona.id, limit: 20), + semantic: Semantic.all(persona.id), + semantic_count: Semantic.count(persona.id), + status: Semantic.status() ) end @impl true def handle_event("pick_persona", %{"persona_id" => id}, socket) do persona = Enum.find(socket.assigns.personas, &(&1.id == id)) || socket.assigns.persona - {:noreply, socket |> assign(persona: persona, editing: nil) |> load()} + {:noreply, socket |> assign(persona: persona, editing: nil, hits: nil) |> load()} end + def handle_event( + "semantic_search", + %{"query" => query}, + %{assigns: %{persona: persona}} = socket + ) do + query = String.trim(query) + + hits = + if query == "", + do: nil, + else: + Retriever.relevant(persona.id, nil, query, k: 20, touch: false) |> Enum.map(&decorate/1) + + {:noreply, assign(socket, query: query, hits: hits)} + end + + def handle_event("semantic_delete", %{"id" => id}, socket) do + with %{} = entry <- Enum.find(socket.assigns.semantic, &(&1.id == id)), + {:ok, _} <- Semantic.remove(entry, by: "ui") do + {:noreply, socket |> assign(hits: nil) |> load()} + else + _ -> {:noreply, put_flash(socket, :error, gettext("Not deleted."))} + end + end + + def handle_event("semantic_pin", %{"id" => id}, socket) do + with %{} = entry <- Enum.find(socket.assigns.semantic, &(&1.id == id)), + {:ok, _} <- Semantic.pin(entry, by: "ui") do + {:noreply, socket |> assign(hits: nil) |> load()} + else + {:error, :exists} -> + {:noreply, put_flash(socket, :error, gettext("That key is already always in mind."))} + + _ -> + {:noreply, put_flash(socket, :error, gettext("Not pinned."))} + end + end + + # The one network egress on this page, started by a click and nothing else: the weights + # and tokenizer into the model cache, under the memory task supervisor; the page ticks the + # cache's size against the model's while it runs. + def handle_event("download_model", _params, %{assigns: %{download: nil}} = socket) do + me = self() + + {:ok, _} = + Task.Supervisor.start_child(Trinity.Memory.TaskSupervisor, fn -> + send(me, {:download_done, Embedders.Bumblebee.download()}) + end) + + Process.send_after(self(), :download_tick, 1_000) + {:noreply, assign(socket, download: %{bytes: cache_bytes(), of: @model_bytes})} + end + + def handle_event("download_model", _params, socket), do: {:noreply, socket} + def handle_event( "add", %{"tier" => tier, "key" => key, "body" => body}, @@ -109,6 +191,69 @@ defmodule TrinityWeb.MemoryLive do def handle_event("cancel", _params, socket), do: {:noreply, socket} + @impl true + def handle_info(:download_tick, %{assigns: %{download: %{} = d}} = socket) do + Process.send_after(self(), :download_tick, 1_000) + {:noreply, assign(socket, download: %{d | bytes: cache_bytes()})} + end + + def handle_info(:download_tick, socket), do: {:noreply, socket} + + def handle_info({:download_done, :ok}, socket) do + {:noreply, + socket + |> assign(download: nil) + |> put_flash(:info, gettext("The model is downloaded; semantic recall is on.")) + |> load()} + end + + def handle_info({:download_done, {:error, reason}}, socket) do + {:noreply, + socket + |> assign(download: nil) + |> put_flash(:error, gettext("The download failed: %{r}", r: inspect(reason))) + |> load()} + end + + defp cache_bytes do + dir = Embedders.Bumblebee.cache_dir() + + if File.dir?(dir) do + dir + |> Path.join("**") + |> Path.wildcard(match_dot: true) + |> Enum.reduce(0, &(file_size(&1) + &2)) + else + 0 + end + end + + defp file_size(path) do + case File.stat(path) do + {:ok, %{type: :regular, size: size}} -> size + _ -> 0 + end + end + + # A hit with what the page shows: the memory's source message's session, for the link. + defp decorate(%{kind: :memory, ref: %{source_message_id: mid}} = hit) when is_binary(mid) do + case Trinity.Sessions.get_message(mid) do + %{session_id: sid} -> Map.put(hit, :source_session_id, sid) + _ -> hit + end + end + + defp decorate(hit), do: hit + + defp source_session(%{source_message_id: nil}), do: nil + + defp source_session(%{source_message_id: mid}) do + case Trinity.Sessions.get_message(mid) do + %{session_id: sid} -> sid + _ -> nil + end + end + @impl true def render(assigns) do ~H""" @@ -141,7 +286,146 @@ defmodule TrinityWeb.MemoryLive do phx-hook="Shortcuts" class="mx-auto flex h-full max-w-4xl flex-col gap-6 overflow-y-auto px-4 py-6" > -
+ + +
+

+ {Semantic.describe(@status)} + {gettext("Full-text search still works.")} +

+
+ + + {gettext("downloading: %{got} of %{of} MB", + got: div(@download.bytes, 1_000_000), + of: div(@download.of, 1_000_000) + )} + + +
+ + +
    +
  • {gettext("Nothing recalled.")}
  • +
  • + {Float.round(h.score, 4)} + {Enum.map_join( + h.found_by, + "+", + &Atom.to_string/1 + )} + {h.text} + <.link + :if={h.kind == :message} + navigate={~p"/s/#{h.ref.session_id}"} + class="link text-meta" + > + {h.ref.session_title || gettext("untitled")} + + <.link + :if={h[:source_session_id]} + navigate={~p"/s/#{h.source_session_id}"} + class="link text-meta" + > + {gettext("source")} + +
  • +
+ +

{gettext("Remembered")}

+

+ {gettext("Nothing in the semantic tier yet; the observer adds to it after each turn.")} +

+
    +
  • + {short_scope(e.scope)} + {Calendar.strftime( + e.inserted_at, + "%Y-%m-%d" + )} + {e.body} + {e.confidence} + <.link + :if={source_session(e)} + navigate={~p"/s/#{source_session(e)}"} + class="link text-meta" + > + {gettext("source")} + + + +
  • +
+
+ +

{gettext("Consolidation waiting for you")}

-
+

{if tier == "profile", do: gettext("About the person"), else: gettext("Always in mind")}

@@ -218,7 +506,12 @@ defmodule TrinityWeb.MemoryLive do
-
+