Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ standards register names the rows that ask for them.
| 020 | Tool protocol + registry | 2 Tools | M | 012 | approved |
| 021 | Permission gate + approval UI (M2 fingerprint-bound, M7) | 2 Tools | M | 020, 013 | approved |
| 022 | Core tools: filesystem, web fetch/search, shell (MuonTrap) | 2 Tools | L | 021 | approved |
| 023 | Context compaction + session lineage | 2 Tools | M | 012 | planned |
| 023 | Context compaction + session lineage | 2 Tools | M | 012 | done |
| 024 | Effect catalog, authority selection (`TRINITY_AUTHORITY`), local receipts | 2 Tools | L | 021, 022 | planned |
| 025 | Encryption at rest, and the key-custody seam | 2 Tools | M | 010, 024 | planned |
| 026 | Store-and-forward receipts for disconnected operation | 2 Tools | L | 024 | planned |
Expand Down
5 changes: 5 additions & 0 deletions assets/js/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export const Shortcuts = {
// The message list follows new content unless the reader has scrolled up to read.
export const ScrollToBottom = {
mounted() {
// A compaction card's "view the original" dispatches trinity:scroll-to with a seq (slice 023).
window.addEventListener("trinity:scroll-to", (e) => {
const target = this.el.querySelector(`[data-seq="${e.detail.seq}"]`)
if (target) { this.pinned = false; target.scrollIntoView({behavior: "smooth", block: "center"}) }
})
this.pinned = true
this.el.addEventListener("scroll", () => {
const gap = this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight
Expand Down
5 changes: 4 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ config :trinity, :llm,
provider: :fake,
model: "chat",
caps: [:stream, :tools, :json],
price: %{input: 1.0, output: 2.0}
price: %{input: 1.0, output: 2.0},
# Slice 023: a small window so a long fake conversation crosses the thresholds (the fourteen
# tools' schemas alone are about 1,140 estimated tokens of every request).
context_tokens: 6_000
},
%{
id: "fake:embed",
Expand Down
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ slice_id percent sha date
020 67.18 8a5b7ae 2026-09-20
021 72.45 1f3727f 2026-09-20
022 74.85 1fb1372 2026-09-20
023 75.39 5989b73 2026-09-20
9 changes: 9 additions & 0 deletions docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,15 @@ the assistant row records it (`provider_meta.tool_surface`); `Trinity.Tools.surf
the calls a turn made outside it. Sessions depends on Tools; Tools depends on Permissions and never on Sessions
(the runner implements the seam's functions without naming the behaviour, which would close a cycle).

**Compaction (Slice 023):** before a model call the Session estimates the request (`Trinity.Memory.Tokens`,
bytes over three plus four per message, calibrated high) against the model's window (`context_tokens` on the
registry entry, 32,768 when absent); over the soft threshold (70 %) it enters `compacting`, runs
`Trinity.Memory.Compactor` in a Task (the structured call, with a plain-text fallback when the provider answers
no object) and writes the compaction row itself; over the hard threshold (90 %) after that it forks: a child
session with `parent_id`, the compaction first, the user's message second, the child's turn started, the parent
closed with a row naming the child and `{:forked, child_id}` broadcast. Memory depends on LLM and the core,
never on Sessions.

**Effect path (Slice 024):** `Session → Permissions.decide → Effects.execute → Authority → tool.execute/2 (local) or a proposal (external adapter) → Receipts.append`. `Effects` is the only caller of `execute/2` for effectful tools; a census test enforces it. Reads emit query receipts.

**The page (Slice 013):** `TrinityWeb.SessionLive.Show` subscribes to `session:<id>` on mount, calls
Expand Down
1 change: 1 addition & 0 deletions docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ with adapter-specific `execute/1` guarded by `repo().__adapter__()`.
| content | text | plain text or JSON for tool payloads |
| parts | map | structured content parts (text, tool_call, tool_result, image ref). Every part carries `origin`, `source_ref`, `digest` and `taint ∈ {trusted, untrusted, blocked}`; summaries inherit the maximum taint of their inputs (Slice 022) |
| tool_call_id | string, nullable | the assistant row's call id a `tool` row answers (Slice 012); its `parts` carry `tool`, `ok`, `tool_result` (`content`, `truncated`, `meta`, `artifacts`, or `error`) and `tool_definition_digest`, and the assistant row's `provider_meta.tool_surface` maps every declared tool name to its digest (Slice 020) |
| (compaction rows, Slice 023) | | a `system` row whose `parts.compaction` carries `from_seq`, `to_seq`, `rows`, `digests` (of the summarised rows' content parts), `summary`, `open_threads`, `decisions`, `facts` and `previous` (the earlier compaction's id); `parts.taint` the maximum of the inputs'. Nothing it covers is edited or deleted: the prompt builder renders the newest compaction into the system prompt and drops the rows it covers from the list. A fork (past the hard threshold) is a child session with `parent_id` whose first row is the compaction and whose second is the user's message; the parent's last row carries `parts.forked_to` |
| usage | map, nullable | prompt/completion tokens, cost |
| provider_meta | map | model, finish reason, latency |
Append-only. Editing is a new message with `parts.supersedes`. One edit is allowed and named (slice 012): an
Expand Down
3 changes: 3 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ digest, stored on the `tool` row (`parts.content_parts`, `parts.taint`); the pro
`<untrusted source= ref= digest=>` and the system prompt states that instructions inside such blocks are data;
a turn's assistant row carries the maximum taint of everything the model read (its history and the turn's tool
results), so a summary of an untrusted page is itself untrusted, and every later turn in that session is too.
A compaction (slice 023) is a summary the model wrote over rows that may have been untrusted, so its row carries
the maximum taint of its inputs and the digests of the parts it summarised, and an untrusted compaction is
rendered into the system prompt inside an `<untrusted>` block like any other outside content.
`blocked` parts are rendered as a placeholder; nothing writes one yet (024's receipts and the sentinel are where
a block comes from). `web_fetch` refuses no page by content, runs no JavaScript, and escalates a URL whose host
is not public (loopback, private, link-local) to `:ask`.
Expand Down
3 changes: 3 additions & 0 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ defmodule Trinity do
Sessions,
Sessions.Message,
Sessions.SessionRow,
Sessions.Prompt,
LLM,
Memory,
Memory.Tokens,
Tools,
Permissions,
Permissions.Approval,
Expand Down
34 changes: 25 additions & 9 deletions lib/trinity/llm/providers/fake.ex
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ defmodule Trinity.LLM.Providers.Fake do
@doc "Forgets scripts, pending failures, the call count and the last request."
@spec clear() :: :ok
def clear do
for key <- [:scripts, :fail, :calls, :last_request],
for key <- [:scripts, :fail, :calls, :last_request, :object_delay, :object],
do: :persistent_term.erase({__MODULE__, key})

:ok
Expand Down Expand Up @@ -132,23 +132,39 @@ defmodule Trinity.LLM.Providers.Fake do
end
end

@doc "Makes every following `generate_object/3` call sleep `ms` first (slice 023's crash test lands its kill in `compacting`)."
@spec object_delay(non_neg_integer()) :: :ok
def object_delay(ms), do: :persistent_term.put({__MODULE__, :object_delay}, ms)

@doc "The object every following `generate_object/3` call answers (a demo's compaction, say); `clear/0` forgets it."
@spec object(map()) :: :ok
def object(map) when is_map(map), do: :persistent_term.put({__MODULE__, :object}, map)

@impl true
def generate_object(_request, schema, _opts) do
Process.sleep(:persistent_term.get({__MODULE__, :object_delay}, 0))

with :ok <- maybe_fail() do
object =
schema
|> Map.get("properties", %{})
|> Map.new(fn
{k, %{"type" => "integer"}} -> {k, 42}
{k, %{"type" => "number"}} -> {k, 4.2}
{k, %{"type" => "boolean"}} -> {k, true}
{k, _} -> {k, "fake"}
end)
:persistent_term.get({__MODULE__, :object}, nil) ||
fake_object(schema)

{:ok, object, %{input_tokens: 8, output_tokens: 4}}
end
end

defp fake_object(schema) do
schema
|> Map.get("properties", %{})
|> Map.new(fn
{k, %{"type" => "integer"}} -> {k, 42}
{k, %{"type" => "number"}} -> {k, 4.2}
{k, %{"type" => "boolean"}} -> {k, true}
{k, %{"type" => "array"}} -> {k, ["fake"]}
{k, _} -> {k, "fake"}
end)
end

@impl true
def embed(texts, opts) do
with :ok <- maybe_fail() do
Expand Down
9 changes: 7 additions & 2 deletions lib/trinity/llm/providers/req_llm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,13 @@ defmodule Trinity.LLM.Providers.ReqLLM do
with {:ok, spec, call_opts} <- prepare(request, opts),
{:ok, response} <-
Mapping.wrap(ReqLLM.generate_object(spec, context(request), schema, call_opts)) do
{:ok, ReqLLM.Response.object(response),
Mapping.normalise_usage(ReqLLM.Response.usage(response))}
# Slice 023: a response with no object is a transient failure, not an object. Measured
# against openrouter:ling on one transcript: three identical calls, one object and two
# answers of thinking and text with no tool call; the retry around this takes the next.
case ReqLLM.Response.object(response) do
nil -> {:error, Error.transient(:no_object)}
object -> {:ok, object, Mapping.normalise_usage(ReqLLM.Response.usage(response))}
end
end
rescue
e -> {:error, Mapping.classify(e)}
Expand Down
1 change: 1 addition & 0 deletions lib/trinity/llm/registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ defmodule Trinity.LLM.Registry do
required(:caps) => [atom() | {atom(), term()}],
required(:price) => %{input: number(), output: number()},
optional(:base_url) => String.t(),
optional(:context_tokens) => pos_integer(),
optional(:api_key_env) => String.t()
}

Expand Down
10 changes: 10 additions & 0 deletions lib/trinity/memory.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Memory do
@moduledoc """
The memory context. Slice 023 opens it with token estimation and compaction; slices 030
to 032 add the tiers, the search and the semantic recall. It depends on the LLM and on the
core, never on Sessions: the Session calls it and writes what it returns.
"""
use Boundary, deps: [Trinity, Trinity.LLM], exports: [Tokens, Compactor]
end
Loading
Loading