Feral-ai

Latest version: v2026.9.5

Safety actively analyzes 1006753 Python packages for vulnerabilities to keep your Python projects secure.

Scan your dependencies

Page 1 of 19

2026.9.5

Coverage
- pytest (feral-core): 11,436 passed, 39 skipped, 73.96% coverage.
Run with `-p no:randomly`; see issue 292 for 49 order-dependent
failures in one file, all of which pass in isolation.
- vitest (feral-client-v2): 166 files, 1,335 passed.


Fixed

- **The list of folders FERAL may touch is reviewable again.**
`~/.feral/workspace_grants.json` never lost a row. On a live install it
held 876 grants in 174,897 bytes: 870 were pytest sandboxes under
`/private/var/.../T/pytest-of-<user>/`, 4 were other temp directories,
and exactly 2 were folders the operator had granted. The "Folders FERAL
can use" page rendered 2,665 lines, so the one surface that says where
the brain may read and write could not be read. The leak was
`tests/test_api_jobs_background_bash.py`, which opts out of the autouse
`FERAL_HOME` isolation with `no_auto_feral_home` and then granted a
`tmp_path` directory, writing six permanent rows into the real home on
every run. That module now isolates `FERAL_HOME` itself, a session-wide
guard fails any run that modifies the real grants file,
`SandboxPolicy._save_grants` prunes on every grant and revoke, and a
migration cleans an existing file at boot. The pruning rule is narrow
by design: a grant is removed only when it is both missing from disk
and under a directory the OS reclaims, so an unplugged drive or an
unmounted share keeps its grant. Past 25 rows the page now offers a
filter box and a count, with the folders a person chose listed above
scratch directories.
- **Voice replies no longer fragment into several assistant rows.** When
the assistant's own audio tripped OpenAI's server VAD on the phone
mic, OpenAI cancelled the response (`response.done status=cancelled
reason=turn_detected`), whisper transcribed the echo as a stock closer
the phantom filter dropped, and the model started over. The proxy
persisted every `response.output_audio_transcript.done` on arrival, so
"Hey Omar," and "How's it going?" landed as two rows 2 seconds apart.
`RealtimeProxy` now buffers assistant finals per session and writes
them (durable `voice:<sid>` thread, working memory,
`note_voice_assistant_turn`) only on `response.done status=completed`,
merging a `turn_detected` fragment into the reply that follows unless a
real user transcript arrived in between, in which case the fragment is
its own row because the operator really interrupted. Pending text is
flushed on `stop_session`. Live wire frames to the client are unchanged.
- **About-Me extractor no longer files voice disfluencies as
preferences.** The negative pattern `I don't/do not/never X` captured
to the end of the utterance, so "I don't know been working on the demo
so we'll see how it's gonna go" became a stored preference. The
capture now stops at a clause boundary and is dropped when its first
word is a disfluency or verb of thinking (know, think, really, mean,
remember, get, understand, see, guess) or when it is a single word.
"I don't eat pork, it makes me sick" still yields "Does not: eat pork".
- **Tool traces survive a reload.** The web client flattened every
message to `{id, role, content}` before the 450 ms autosave, so the
`tools`, `reasoning`, `timeline`, `notice`, `attachments`, `model` and
`usage` fields were discarded at write time. A turn that ran seven
tools was stored on the operator's brain with three fields, and the
loss was already permanent by the time anyone reloaded. The renderer
had been fixed for exactly this a release earlier and its docstring
said so; the save path was never changed to match. It now spreads the
row and overrides only the three fields it owns.
- **Image generation works again.** Every request failed with `OpenAI
images error 400: Unknown parameter: 'response_format'`. The skill
sent `model: dall-e-3` with `response_format: b64_json` and `style`,
which was the documented shape for that model; OpenAI now rejects the
parameter, and the GPT image models never accepted it or `style`. The
body is `model`, `prompt`, `n`, `size`, the default is `gpt-image-1.5`
(override with `FERAL_IMAGE_MODEL`), and the old landscape and
portrait sizes map onto their GPT-image equivalents so a caller
written against the previous manifest keeps its orientation. A later
catalog refresh confirmed the diagnosis independently: `dall-e-2` and
`dall-e-3` are no longer served at all.
- **Responses-only models stop being sent to /chat/completions.** When
OpenAI was a failover candidate, or when the primary fell through from
the Responses API, a `gpt-5.6-sol` request carrying tools was posted
to `/chat/completions` with `reasoning_effort` set, which OpenAI
refuses: "Function tools with reasoning_effort are not supported". The
endpoint guard existed but only on the primary non-streaming path.
Both `_call_provider` branches and both fall-throughs now consult it.
Present in the logs on eleven separate days.
- **Provider failures are delivered as errors, not as the assistant's
answer.** `extract_response` returned the error string in the text
slot, so `HTTP 400 - invalid_request_error, param=reasoning_effort...`
was rendered as a chat bubble, written to the transcript, and fed back
as context on later turns. Eleven callers were updated; the failure
now travels as an error frame, which the client already knew how to
draw.
- **A single email lookup could freeze the whole brain.** `email
__get_unread_count` opens `imaplib.IMAP4_SSL` with no timeout and ran
it on the event loop, so one call stalled every request for 181
seconds on the operator's machine, including chat, voice and the
phone. The five IMAP paths now run through `asyncio.to_thread` with a
15 second socket timeout, and `SkillExecutor.execute` wraps every tool
in `asyncio.wait_for` with a 30 second default that manifests may
raise, returning a 504 envelope rather than hanging.
- **Credentials no longer reach the log file.** The root logger was set
to INFO, and httpx logs every request URL at INFO, so the Telegram bot
token and the Gemini API key were written in clear text on every call:
5,983 and 1,857 lines respectively in one 44 MB log, world readable at
0644. httpx, httpcore and the websockets loggers are now at WARNING, a
redaction filter covers `/bot<token>`, `key=`, `token=`,
`access_token=`, `api_key=` and `Authorization: Bearer`, and the log
directory and files are created 0700 and 0600. Existing logs are not
rewritten: rotate any credential that has already been recorded.
- **Peer sync stops dialling a dead peer forever.** The backoff computed
`initial * 2 ** (failures - 1)`, which raises `OverflowError` past
about 1,024 consecutive failures. The exception escaped the failure
bookkeeping, so `backoff_until` never advanced and the peer was dialled
every 30 seconds indefinitely. The exponent is clamped, the bookkeeping
cannot be aborted by a metrics error, fan-out is bounded by a
semaphore, a peer is evicted after 20 consecutive failures, and the
`/sync` handshake read has a 10 second timeout so an idle client cannot
hold a handler open.
- **Dollar amounts render as text, not as mathematics.** `remark-math`
ran with single-dollar parsing on, so "$5 billion ... $550 million"
became one italic KaTeX expression, as did the cost banner's
"$9.99 / $10.00".
- **Opening a thread lands on the newest message.** The chat log left
itself at the top of a 6,480 pixel transcript, so the page opened on a
conversation from days earlier and a reply just sent was six screens
down, which reads as "my message did not send". The follow-the-tail
effect could not do this: it runs after paint and asks for a smooth
scroll, which loses a race against the markdown and tool cards below
it growing the scroll height. A layout effect now sets the position
before paint, once per conversation.
- **"Running now" no longer reports 0 active above five rows.** The
header counted only `running` and `connected`, while routines are
`scheduled` and specialists are `ready`. It now says what it lists,
and a routine shows its next run rather than its age, which had been
rendering as "scheduled, 1722h 29m".
- **The cost readout is the real one.** The header read
`budget.daily_spend_usd` from a static config default that nothing
ever wrote, so it showed $0.00 while $9.99 of a $10 hourly cap had
been spent. It now reads the ledger the enforcer itself uses.
- **A cost cap reads like a cost cap.** Hitting the hourly limit
produced an assistant message reading `budget exceeded for chat:
$9.992715 / $10.000000 (hour, resets at 1788541200)`, stored in the
transcript. It is now a structured frame the client already knew how
to draw, naming the cap, the spend, the minutes until reset and where
to change it, and it is not persisted as something the assistant said.
- **The default chat path knows who it is and what day it is.** Every
ordinary turn routes through the multi-agent worker, which returned
before the system prompt was built, so the model never saw the agent
name, the IDENTITY rules, SOUL, MEMORY, About-Me or the current time,
and no prior turn was replayed. It answered a scheduling request with
a date in 2023 and could not remember the previous message. The worker
now receives the identity header and a bounded history window, with
static text first and volatile text last so the prompt cache has a
stable prefix.
- **Tools that cannot run are no longer offered.** 79 of 266 tool
schemas failed deterministically at call time because a key, an OAuth
connection, Docker or a USB device was absent. The model tried them,
failed, and sometimes told the operator the brain was incapable. A
network-free availability gate now withholds them and names what is
off and why in the system prompt, so "Email is not connected" is
sayable instead of "I cannot do email". `FERAL_OFFER_UNAVAILABLE_TOOLS=1`
restores the old behaviour.
- **The Responses API request is capped like the chat one.** The
128-tool cap existed only on `/chat/completions`, so the model that
actually serves chat received all 266 schemas, measured at 32,391
tokens, 97 percent of every request before a word of conversation.
Measured after: 19,893 input tokens per turn.
- **A routine that fails the same way forever now stops.** The
no-progress guard reset its streak whenever the arguments changed, and
its warning told the model to change them, so a nightly routine called
`cutebot__set_lights` 46 times against a disconnected robot, walking
the colour down one value at a time. A per-tool streak of precondition
failures now withdraws the tool for the rest of the turn regardless of
arguments.
- **Restarting the brain is not an event.** The morning briefing was
gated on an in-memory flag, so every restart re-delivered it; the
delivered date now lives in `~/.feral/proactive_state.json`. Boot
catch-up ran any routine missed within a day, so "spin the CuteBot at
9 PM" ran at 08:41 the next morning; a routine that names a time of
day is now only caught up inside a one hour grace
(`FERAL_CATCHUP_GRACE_SECONDS`) and is otherwise re-armed and
reported, while interval routines are unchanged.
- **`feral doctor` checks what wrote the stored vectors.** It reported
the live embedding provider and never asked what had written the
vectors already in the store. On the operator's brain, 312 of 334
entities held 1536d vectors while the provider was 384d, so 93 percent
of the knowledge graph was unreachable by semantic search and the
graph had degraded to keyword-only, under a green tick. The only other
symptom was one line naming a failed numpy reshape. Doctor now scans
the store and names the count, the widths, the tier that lost semantic
search, and the remedy.
- **Vision stops hammering an unreachable provider.** With Ollama down,
the scene loop logged an error every 16 seconds and still recorded a
token reservation for every failed call. It now backs off after three
consecutive connection failures, warns once per step, and records no
cost when no description was produced.
- **Skills that only read are no longer held for confirmation.** Fifteen
read-only endpoints across five manifests lacked `read_only_hint`, so
the resolver's legacy default asked for approval before answering a
question.
- **Notion and Home Assistant fail honestly.** Notion sent
`Authorization: Bearer ` with an empty token and raised "Illegal
header value"; Home Assistant spent five seconds resolving a default
hostname that does not exist. Both now say they are not configured.
- **Settings and the keychain are read once, not on every turn.**
`load_settings` built a fresh loader on each call, re-read both
settings files and unlocked the vault through the macOS keychain,
295 times in one day. It is cached on file mtime and no longer loads
credentials it never used.

Changed

- **`websockets` floor raised to 14.0 for feral-core.** The range
allowed 13.x, where `websockets.connect` resolves to the legacy client
and the awaited result is a `WebSocketClientProtocol` with no
`__aenter__`. `memory/sync.py` awaits the connect and then enters it,
so on 13.x every brain-to-brain sync attempt failed with "does not
support the asynchronous context manager protocol" while chat, which
never touches that path, looked healthy. Measured across 13.1, 14.0,
14.2 and 15.0.1. The node SDKs keep their lower floors: they enter the
connect object directly, which works on both implementations.
- **The bundled model catalog was refreshed** for the first time since
2026-07-30, moving 137 ids. OpenAI retired `dall-e-2`, `dall-e-3`,
`o3-pro`, `o3-deep-research` and the `gpt-4o` audio and realtime
preview line, and added the `gpt-realtime-2` family. Three ids new to
the account classified as unknown and were therefore eligible to be
sent to `/chat/completions`: `gpt-transcribe` and `gpt-live-transcribe`
are audio, `chatgpt-image-latest` is an image model.
- **Modal sheets no longer black out the page behind them.** The
backdrop was `rgba(0,0,0,.45)` with a 24 pixel blur, which on the dark
theme rendered everything behind a skill sheet as one flat slab.
- **Per-turn token counts say what they are.** A turn's footer showed
the sum across every LLM round in that turn, so an eight round turn
read "241,240 tokens" beside a 12.5k context indicator. It now says
the total is across all rounds.
- **A brain restart no longer toasts its own startup.** Six client calls
raced the brain coming back and surfaced "Failed to fetch" and a bare
503 as global errors, though each already had a local fallback.

Added

- **`gpt-6-astra` is known before it reaches an account.** Released
2026-09-03 and not yet visible on this operator's key. Classified as a
reasoning model and routed to `/v1/responses`, priced from the model
page ($10 in, $1 cached, $50 out per 1M; 1,050,000 context). It is
recommended but ranked below `gpt-5.6-sol` on purpose, because
`default_model_for` takes the top of that list and astra costs twice
as much per token. Not verified against a live call.
- **OpenAI cached tokens are billed at the cached rate.** The usage
parser read only Anthropic's cache fields, so cached input was charged
at the full rate, roughly ten times its real cost on `gpt-5.6-sol`.
Responses requests now also carry a `prompt_cache_key`.

Packaging

- **Four packages that the runtime imports were missing from the
wheel.** `migrations`, `process`, `system` and `bridges` are in the
source tree and imported by `api/server.py`, `cli/main.py`,
`skills/impl/coding_tools.py` and the memory store, but were absent
from the setuptools include list, so every pip install logged
"migration pass failed; continuing boot" and never ran a single
`~/.feral` migration. A test now fails if any top-level package that
product code imports is not shipped, or if one is neither included nor
deliberately excluded.

2026.9.2

Added

- **Scoped sharing for brain-to-brain memory sync.** Replication was
all-or-nothing. `SyncEngine._SYNC_ALLOWED_TABLES` covers notes,
episodes, conversations, knowledge, wiki pages, execution log,
entities and relations, and every row of all eight went to any peer
that got past the handshake. Two operators who wanted to pool robot
events had to pool their entire personal memory. That was the blocker
in front of federation between separately-owned brains.

**`sync_wal` now carries a `scope`, and the WAL is the whole
enforcement surface.** Nothing reaches a peer except through a row of
that table, so one column there is complete; the eight source tables
are untouched. Scope is supplied by the CALLER
(`save_note(scope=...)`, `episode_save(scope=...)`, threaded through
`MemoryStore._log_sync` / `_log_sync_async` and
`SyncEngine.log_operation` / `log_operation_async`), not derived from
the table. Deriving it would make the sharing boundary a property of
the schema, so the next table anyone adds inherits some other table's
posture silently and the operator has no record of an intent that was
never expressed. Deletes are the one exception: they pass
`SCOPE_INHERIT` and take the scope of the row's newest logged write,
so a removal reaches exactly the peers the write reached.

**Fail closed, everywhere.** `security/sync_scopes.py` is the single
place a scope name is judged, and it answers `private` (the reserved
scope that never replicates and cannot be granted) for an unscoped
write, a WAL row that predates the column, a name that fails the
grammar, a wrong-typed field, and an operation from a peer running an
older build. The column's `DEFAULT 'private'` is the migration: every
pre-existing row is private permanently, with no backfill that could
guess wrong. Nothing is retroactively classified, and upgrading never
turns an operator's existing history into something poolable.

**Both sides enforce, from the local roster.**
`PeerRoster.grant_scope` / `revoke_scope` / `granted_scopes` store
per-peer grants in a new `peer_scope_grants` table keyed on `node_id`,
the only identifier present at both enforcement points.
`SyncWAL.get_changes_for_peer` filters what is sent and takes
`allowed_scopes` as a keyword argument with NO DEFAULT, so a peer path
cannot reach the unfiltered form by omission.
`SyncEngine.apply_remote_changes_from_peer` re-checks every arriving
operation against the same grant set, before it touches the local WAL,
so a refused operation is not recorded either and this brain never
becomes a relay for memory it has no permission to hold. The receive
check is not redundant: the send filter runs on the brain that is
sending, and in federation that brain belongs to somebody else.
Disabling it alone fails 12 of the new tests.

**Pooling takes two grants.** Each brain's roster is that brain's
whole policy toward a peer and governs both directions. A one-sided
grant moves nothing, because the alternative would let somebody
else's roster decide what lands in your store.

**Revocation is not recall, and nothing says otherwise.**
`revoke_scope` stops future replication in that scope from the next
exchange onward. Operations that already crossed are on a disk this
brain does not control. The code, the CLI output, the API response
and the docs all state that, and
`test_revoking_a_scope_stops_later_writes_but_not_earlier_ones`
asserts the half an operator might hope was false.

**No aggregate caps across peers.** Kleppmann and Howard's
I-confluence result makes a cross-runtime aggregate limit
unenforceable without coordination, and a limit that silently does not
hold is worse than none. Any limit here is local to one enforcement
point and says so where it appears.

Operator surface: `feral sync peer scope grant|revoke|list`,
`GET/POST /api/sync/scopes`, `DELETE
/api/sync/scopes/{node_id}/{scope}`, and `feral sync peer list` now
prints each peer's scopes, including `none (receives nothing)`,
because "enrolled" and "receives something" are different states.

`tests/test_sync_scoped_sharing.py` (77 tests) drives the headline
case through the real `/sync` endpoint over a real websocket and
asserts on MATERIALISED TABLE CONTENTS, never the op log: two brains
pool `robot-events`, both write personal notes the ordinary way, and
the personal notes must not cross. 20 of the 77 fail with the two
gates removed.

- **`feral setup`'s lists are clickable.** Every list prompt in the
wizard was arrow-key only. `cli/ui_kit.pick` (the direct single-pick
behind the provider list, the network profile, phone pairing, the
post-model checkpoint and `ask_choice`'s 19 call sites) sat on
`InquirerPy.inquirer.select`, which exposes no mouse parameter at all,
so there was no argument to pass and no hook to reach through. The
`prompt_toolkit.Application` one layer down does accept
`mouse_support`, so `pick` is now built directly on prompt_toolkit
(`_MousePicker`) and the other prompts (`select`, `multi_select`,
`fuzzy_select`, `fuzzy_pick`, `password`, `confirm`, `text`) stay on
InquirerPy. Clicking a row picks it; the wheel scrolls.

**The keyboard did not change and is still the primary input.** ↑/↓
(and ctrl-p / ctrl-n) move without wrapping, exactly as InquirerPy's
`cycle=False` did; enter takes the row under the cursor; ctrl-c raises
`KeyboardInterrupt`, which is what `helpers.ask_choice` turns into
`QuitNavigation`. A picker that needed a mouse would be worse than the
one it replaced.

**Mouse capture costs drag-to-select, so it is opt-out.** While a
capturing prompt is on screen the terminal routes drags to the
application, so selecting a provider name or a model id with the mouse
selects nothing. `FERAL_SETUP_MOUSE=0` turns capture off for the run
and changes nothing else; the rendered instruction only says "click to
pick" when clicking will actually work. Documented in
`docs/manual/first-run.md` and `docs/manual/troubleshooting.md`.

The rows are built from `_fallback_pairs`, the same helper the typed
non-TTY fallback renders from, so the clickable list and the typed
list cannot show different labels or a different order, which is the failure
mode the navigation hint already hit once. Every degradation path is
unchanged: no TTY, no prompt_toolkit, or any failure to build or run
the application still falls through to `_fallback_select`.

`tests/test_cli_ui_kit_mouse_picker.py` (60 tests) drives the real
`Application` over a `create_pipe_input()` pipe with real vt100 bytes,
including SGR mouse press/release sequences, so the click is executed
rather than asserted about: a click on screen row 3 returns the second
choice, and the same bytes are inert when capture is off.

- **Per-device capability grants — the screen HUP_SPEC named for two
years.** `feral-nodes/HUP_SPEC.md` section 6 has said since v1.0 that
gating happens at **Settings > Devices > <device> > Capabilities**,
that brains **MUST NOT** issue `hup_action_request` for a capability
outside `granted_capabilities`, and that they **MUST** drop
`camera_frame` / `microphone_chunk` from a node whose tier is
disabled. Nothing implemented any of it. `api/server.py:2781` answered
every `node_register` with `"granted_capabilities":
list(payload.capabilities)` and `"denied_capabilities": []` — the
node's own self-declaration echoed straight back, no store, no
operator input, no filtering. `feral-client-v2/src/pages/Devices.jsx`
drew the list as read-only `<span>` chips and called no grant endpoint
because none existed. Both node SDKs then read that ack as `granted or
self.capabilities` (Python) / `granted.length ? granted : capabilities`
(TypeScript), so an empty grant list — a brain saying "you may do
nothing" — was falsy and got replaced with everything the node had
declared. The single answer the operator most needs to give was the
one answer the protocol could not carry.

`security/capability_grants.py` is the store, keyed by `node_id`, with
the section 5.1 tier map. `GET`/`POST
/api/devices/{node_id}/capabilities` read and write it, the Devices
detail modal's chips are toggles that post to it, and both SDKs now
consult the grant before dispatching an action.

**The default is granted, not denied**, and that is a deliberate
departure from what the section 5.1 tier table used to claim. That
table said `camera` and `audio` were "requires user opt-in"; the
shipped policy has `hardware.cameras.allowed: true` and no key gating
the microphone, so the claim was false when it was written.
Implementing it literally would have made it true by breaking every
already-paired phone at upgrade: vision-context-attach and ambient
transcription both live on frames this gate can drop, and they would
have gone dark with no operator action and no error. The table now
says what ships, and section 6 says in words that the per-device layer
is a deny control layered on the global operator policy.

Not a duplicate of `security/hardware_policy`. That module answers
"may this capability run at all, and unattended?" from
`hardware.sensors.allowed` / `hardware.actuators.allowed` /
`hardware.cameras.allowed`, which are global lists of capability ids
with no device in them. An operator with a work phone and a personal
phone paired to the same brain had no way to say "no camera on the
work one". The two layers compose; a capability clears both.

Fixed

- **Brain-to-node frames now carry the envelope the spec requires.**
HUP_SPEC section 5: every HUP frame is a JSON object with
`hup_version`, `type`, `ts`, `payload`. Twelve brain-to-node sends
carried only `type` and `payload`, including **every**
`hup_action_request` builder — the actuator command frame —
(`hardware/mesh.py` twice, `hardware/protocol.py`,
`agents/tool_runner.py` twice) plus `api/state._send_dict_to_node`,
which is a bare passthrough and therefore the choke point for the
whole voice pipeline, `somatic_state`, and ambient digest pushes.
Impact to date is nil because no shipping SDK validates envelopes on
inbound — the Swift decoder documents the omission and tolerates it by
name — so this is a bill payable by the first third-party daemon
written against the published spec, which is the audience the spec
exists for. `models/protocol.py` gains `hup_frame()` and
`stamp_hup_envelope()`; `hardware/action_frames.build_action_request`
is the single builder every action sender now calls, and it applies
the section 6 capability gate in the same step.
`tests/test_hup_version_unified.py` gains an AST gate over every
node-bound `send_json`, so a new sender that bypasses all three fails
the build. That file previously checked only that five surfaces agreed
on the version *literal*; it never looked at a frame.

- **The gateway's `node.invoke` fallback dispatched into silence.**
`gateway/protocol.py:497` sent `{"type": "command", "request_id",
"command", "args"}` when `state.hardware_mesh` was absent. That is not
a HUP frame in any version: `command` is an alias removed in 2026.7.0
(HUP_SPEC section 5.5's alias table), and the shape put the fields at
the top level with no `payload`, no `hup_version` and no `ts`. No
current SDK has a branch for it, so the frame was ignored and the
caller was told `{"dispatched": true}`. `tests/test_hup_protocol.py`
asserted the alias was gone from `hardware/mesh.py` and
`agents/tool_runner.py` and never looked at the gateway.

- **Node SDKs failed open on a full deny.** Both SDKs treated an empty
`granted_capabilities` as absent and substituted the node's own
declaration, and neither read the grant before dispatching an action:
`_granted` appeared at exactly three lines in the Python SDK —
declared, assigned, logged. Presence of the key now decides, `None`
distinguishes "no ack yet" from "granted nothing", and both
dispatchers refuse an ungranted action with
`success=false, error="capability_denied"` per section 6.

- **Federated sync peers have identities.** `/sync` authenticated every
peer with one shared plaintext passphrase, read at
`api/server.py` as `raw.get("passphrase", "")` and compared with a
plain `!=`. Three things followed. No peer was distinguishable from
another, so "stop syncing with that one brain" could only be expressed
by rotating the secret, which re-paired every peer at once (the
docstring of `ensure_sync_passphrase` already admitted this). The
comparison short-circuited on the first differing byte, on a
credential that is an operator-visible string in the common case.
And there was no membership list at all, which is the reason
`MemoryStore.prune_tombstones` states verbatim that it cannot prune by
"every peer has acknowledged this delete".

`security/peer_roster.py` is `security/device_pairing.py` applied to
the other side of the brain, not a new mechanism: the same
argon2id-primary / bcrypt-fallback backend (imported from that module,
so the algorithm choice is still made and logged in one place), the
same SHA-256 `token_lookup` O(1) index, the same "plaintext returned
exactly once at issue time", and the same rotation ledger used as the
migration path off a legacy plaintext secret.

A peer brain is a fuller principal than a device, since it replicates
deletes as well as inserts, so the defaults are tighter than device
pairing's. An invite must be redeemed within an hour on a hard,
non-sliding deadline. A redeemed grant lives in a sliding 7-day window
renewed by each successful handshake, so a peer that stops talking
lapses without anyone having to revoke it. The grant binds to the
`node_id` that first redeems it, so the same secret presented by a
second brain is refused, and the binding check is `hmac.compare_digest`
rather than `==`.

A presented grant is judged on the grant alone. It never falls through
to the shared passphrase, because a fall-through would let anyone
holding the passphrase strip the per-peer layer at will, and would
also render a genuinely lapsed grant as a working sync.

- `PeerListener.remove_service` has a body. It was `pass`, so a peer that
left the network stayed in `SyncEngine._peers` forever, its per-peer
`asyncio.Lock` leaked, and its last-seen was written nowhere that
survives a restart. Departure is now persisted in the roster and is
explicitly not revocation: the grant stays valid and the peer rejoins
on its next advertisement. `PeerListener` and `AsyncPeerListener` moved
out of the `start_discovery` closure to module level, which is what
makes arrival and departure drivable without standing up zeroconf on a
real network.

- `feral sync peer invite|accept|list|revoke`, plus `/api/sync/roster`,
`/api/sync/roster/invite`, `/api/sync/roster/accept` and
`DELETE /api/sync/roster/{peer_row_id}`. `feral sync peers add
<host:port>` also works unquoted now; argparse had nowhere to put the
second word, so only the quoted form ever parsed.

Changed

- `/api/sync/status` reports `identity_mode`, `enrolled_peers`,
`shared_secret_peers` and an `identity_note`. The mode is
`shared_passphrase` until a peer is enrolled, then `mixed`, and only
`per_peer` once `FERAL_SYNC_REQUIRE_PEER_IDENTITY=1` refuses the
shared secret outright. It is deliberately never reported as
`per_peer` on the strength of enrolments alone: while the passphrase
would still be accepted, calling the setup identity-authenticated
would be a lie in the reassuring direction. Promotion is operator-
driven, never automatic, because auto-promotion is exactly how a
working two-brain setup would break in silence.

Migration

- Nothing breaks on upgrade. `FERAL_SYNC_PASSPHRASE` keeps working; each
use is recorded in the roster's `shared_secret_log` and logged at
WARNING, and `feral sync status` names the brains still relying on it.
Enrol each of them, confirm the list is empty, then set
`FERAL_SYNC_REQUIRE_PEER_IDENTITY=1`.

Known limits

- Revocation stops future exchanges. It cannot recall memory a peer has
already replicated, and cannot delete their copy. The API response and
the docs say so rather than letting the word imply otherwise; prefer
the lapsing window over the revocation list.
- Tombstone pruning by acknowledgement is still not implemented.
`PeerRoster.active_peer_ids()` supplies the roster half that
`prune_tombstones` named as missing, but `synced_to` lives in
`sync_wal.db` while tombstones live in `memory.db`, and WAL pruning can
drop the very operation a tombstone would be matched against. That
join is a separate change; age-based retention remains the default.

2026.9.1

Added

- **Undo now covers three things that are not files, and earned autonomy
widens with it.** `skills/checkpoints.py` was byte-oriented: `capture`
stashed a file's prior bytes and `revert_turn` put them back. That
shape cannot express undo for something that did not exist before, so
the store now holds a second kind of record. A calendar event, a
reminder and a routine are reverted by a **compensating call** rather
than a snapshot, saga-style:

| Created by | Undone by | Id read from |
|---|---|---|
| `calendar_google__create_event` | `calendar_google__delete_event(event_id)` | `data.id` |
| `feral_reminders__create` | `feral_reminders__delete(id)` | `data.reminder.id` |
| `feral_routines__create` | `feral_routines__delete(routine_id)` | `data.routine.id` |

The compensation is derived from the tool's **result**, never from its
arguments, and it is recorded at `SkillExecutor.execute` rather than in
`ToolRunner`. Both choices are load-bearing. The id does not exist
until the call has succeeded, and a call that failed created nothing to
compensate. Five of the executor's seven production callers never touch
`ToolRunner` (`mcp/server.py`, `api/routes/tools.py`, both voice
realtime proxies, `agents/multi_agent.py`), so recording anywhere else
would leave undo working on some dispatch lanes and not others while
trust widened on all of them.

Because `security/trust_ledger.py` bounds earned autonomy by exactly
what checkpoints can revert, those three tools join `UNDOABLE_TOOLS`
and become eligible to stop asking for approval under `hybrid` after
five clean runs. `tests/test_earned_autonomy.py` now asserts
`UNDOABLE_TOOLS == CHECKPOINTED_FILE_TOOLS | REVERSIBLE_ACTIONS`
structurally rather than grepping the checkpoint module for endpoint
names, so the two cannot drift apart in either direction. The two lists
are still written out separately: deriving one from the other would let
a change to a skills module silently widen what runs without asking,
with nothing in the security module to review.

**What is deliberately still not undoable, and why.** Email has no
unsend, and a provider-side "undo send" is a delay before sending
rather than a reversal of one. Retraction on Slack, Telegram, WhatsApp
and iMessage is time-bounded, provider-specific, and does not un-notify
or un-read; that is a weaker guarantee than restoring bytes and must
not be sold as undo. `buy_groceries` moves money and has no inverse
call. `browser_use__start_recording` / `stop_recording` is a lifecycle
pair, not an inverse: stopping a recording does not unmake it. `bash`
remains uncheckpointed for the reason it always was.

**A revert can now be partial, and says so.** One compensating call can
fail while the file restores succeed. `success` is true only when every
item was handled; the new `partial` is true when some came back and
some did not; `error_code` is `revert_incomplete`. A caller reading
`success` alone would report a half-done revert as done, which is the
failure that key exists to prevent. The whole-turn drift refusal is
unchanged and now also suppresses the compensating calls, so a refused
revert leaves nothing half-undone.

**An object the user already deleted is not a failure.** The inverse
call comes back 404 or 410, the entry reports `already_reverted`, and
the row is marked done so a second revert does not call again. This is
strict about what counts as gone (an explicit `status_code` of 404/410,
or an error string in the `HTTP 404: ...` shape
`integrations/_http_errors` produces). A timeout, a 401 or a 500 is a
failure, because reading an unreachable provider as "already gone"
would report a revert that never happened.

Two smaller consequences worth knowing. `feral checkpoints revert`
restores files and **cannot** undo actions: it deliberately runs
without the brain, and deleting a calendar event needs the user's OAuth
token. Those entries are reported as `unrecoverable` naming that
reason, never dropped. And a compensating call placed by `revert_turn`
is exempt from the executor's approval and plan-mode gates for the
duration of that one call, because all three inverses resolve to
CONFIRM: without the exemption every action revert under strict or
hybrid returned `pending_approval` from a call that never passed
through `ToolRunner` and so had no resume path. The exemption widens
nothing. The tool and the id both come from a `reversals` row FERAL
wrote itself, no model-supplied argument reaches the call, and the
operator has already approved the confirm-tier revert that is making
it.

Finally, a create that succeeds but yields no id records no undo, logs
a warning, and **drops that tool's trust streak on the spot**.
`UNDOABLE_TOOLS` is a promise that the record exists; if the result
shape ever drifts, the tool goes back to asking rather than keep
auto-approving on a promise it is no longer keeping.

2026.8.30

Fixed

- **The knowledge-graph extractor read the first 2000 characters of
everything it was given, and that literal was wrong in both
directions.** `memory/knowledge_graph.py` built its prompt with
`f"Text: {text[:2000]}"`. Every caller passes more:
`memory/context_builder.py` segments a transcript at
`CHUNK_CHARS = 12000` and hands over one segment at a time (17%
survived), `api/server.py` passes `outcome.detail[:8000]` (25%), and
`agents/learner.py` is unbounded. `CHUNK_CHARS` is 12000 because that
size was measured to halve the number of generation waves while still
showing the model every message, so six sevenths of each carefully
sized segment went into a prompt that read the front of it. An entity
named late in a segment could not enter the graph at all, the same
defect `tests/test_consolidation_redesign.py` already records for the
old `[:3000]` cap one layer up.

Raising the number would have fixed only half of it. Characters are
not tokens and the ratio is not close to constant: measured with
`agents/token_estimate.estimate_tokens` over
`tests/fixtures/token_estimate_corpus.json`, 2000 characters is 698
estimated tokens of English prose, 2601 of Chinese, and 6000 of emoji.
With the template and the 1024-token reply reserve on top, the emoji
case came to 7150 tokens against the 4096-token window
`LlamaCppEngine` pins, measured end to end through
`extract_and_store`. The cap that looked like the safety margin was
already overflowing small local models by 74%, and moving it to 8000
or 12000 would have moved Chinese, Japanese, Korean, Arabic, Greek,
Hebrew, Hindi and Thai into that same column.

The bound is a token budget now, `window - reply reserve - prompt
overhead`, with each term measured: the overhead is computed at import
from the real template plus the `format_chat` role wrapper (126
tokens), the reserve is `LLMProvider.chat`'s actual 1024 default, and
the window comes from the serving model. `LlamaCppEngine` publishes
the `n_ctx` it loads instead of burying it in a closure, a new
`LLMProvider.context_window_tokens` reports it (preferring the local
engine even in hybrid mode, because under-filling a large window costs
context while overflowing a small one fails the request), and
`agents/context_manager.configured_context_window_tokens` is public as
the `FERAL_CONTEXT_WINDOW_TOKENS` fallback. On a 4096-token window
that yields 2946 tokens of text: English prose goes from 2000 to 8427
characters (4.2x), Russian to 4459, Chinese to 2279, and emoji DOWN to
1001. Every script now lands under the window. Text over budget keeps
its head and its tail rather than a leading prefix, for the reason
`context_builder.PER_MESSAGE_HARD_CAP` already cites. A character
bound of 12000 remains as a cost bound, pinned equal to `CHUNK_CHARS`
by a test so raising one cannot silently reintroduce truncation.
- **Deleted memories came back.** `SyncEngine._apply_to_memory` gates
every arriving sync op against the target row's `hlc_string`, but the
delete branch ran a bare `DELETE FROM {table} WHERE id = ?` and
recorded nothing. The hard delete takes that column with it, so on the
next arrival for the same id the gate read `existing_row is None`,
`existing_tuple` fell back to `(0, 0, "")`, and the whole
last-writer-wins comparison was skipped: any insert re-materialised
the row, however old its HLC. Peers hold the original insert forever
(`get_changes_since` selects on HLC alone and nothing prunes the sync
WAL), so a note deleted on one brain came back on the next handshake
with a peer that had not seen the delete. Both directions were
affected: a remotely applied delete and a locally originated one
(`notes_legacy.delete_note`, `store.delete_conversation`, the decay
sweep's hard delete), because the local paths announced the delete to
peers without recording anything on the node that issued it.

Deletes now write a tombstone to `sync_tombstones` in `memory.db`,
holding the row id and the HLC of the delete, in the same transaction
as the DELETE. The tombstone stands in for the row's `hlc_string`
after the row is gone, so the same strictly-greater rule applies; an
insert newer than the tombstone is a legitimate re-creation and
retires it. Tombstones are pruned after 90 days by the sync
scheduler's cadence tick (rate-limited to every 6 hours), which bounds
the table and reopens exactly one window: a peer offline since before
the horizon can still resurrect a row.

The CRDT fuzz suite could not see any of this. Every convergence
assertion in `tests/test_sync_fuzz.py` compares `_final_state`, which
is derived from the sync WAL op log and never from the materialised
`notes` / `knowledge` tables. Two nodes converge on the same set of
WAL ops regardless of what happened to the rows, so drops, flaps and
the 3-node topology all passed over a store that was resurrecting
deleted rows. The new `tests/test_sync_tombstone.py` asserts on row
counts in the tables instead; 6 of its 7 tests fail against the
unfixed code.

2026.8.29

Four defects, three of them found by trying to answer a competitive
question rather than by looking for bugs. Every fix carries a test first
demonstrated to fail against the unfixed code.

Fixed

- **Clicks landed at 57% of where the model aimed, on every Retina Mac.**
Three coordinate spaces are involved and the click path used the wrong
ratio between two of them: `screencapture` writes native pixels (3360
on a 1680-point display), `encode_for_vlm` resizes anything wider than
1920 and discards the ratio, and pyautogui clicks in logical points.
The model's coordinates live in the resized image, so the divisor is
1920/1680 = 1.143; the code divided by the display's backing factor,
2.0. Measured before the fix: a target at x=1900 was clicked at 950
instead of 1662, an error of 712 pixels that grows toward the screen
edge, which is where menus and buttons are. That made
`agentic_computer_use`, the screenshot-understand-act loop, unable to
reliably click anything on Retina hardware, while its manifest claimed
it "supports Retina/HiDPI scaling automatically". Non-Retina displays
were unaffected, because no resize happens and the two numbers
coincide, which is why it survived. Both halves were unit-tested and
both were individually correct; nothing tested the composition, which
is the only place the defect existed.

- **An MCP tool earned auto-approval from its own name.** No `mcp_*`
name is in the danger map, so resolution fell to a substring heuristic
that awards AUTO to anything containing read, get, list, status, query
or search. A tool called `clipboard_read` therefore executed with no
approval in every autonomy mode including strict, returning whatever
the operator last copied. For a native skill those substrings sit
behind a reviewed manifest; for an MCP tool there is none, and the
name is chosen by whoever wrote the server. `plan_mode` already
refused `mcp_*` by name for exactly this reason, and the safety
resolver now agrees with it. Only the AUTO shortcut is withdrawn:
nothing that was refused becomes permitted.

- **FERAL's MCP client silently discovered zero tools from any server
with a large tool list.** `create_subprocess_exec` was called with no
`limit=`, so the reader took asyncio's 64 KiB default, while MCP
frames one JSON-RPC message per line. A 141,876-byte `tools/list`
response overran it, `readline` raised into a generic handler, and
`connect()` still returned true with an empty toolset. The reader also
clears its buffer on that path while the rest of the line is still
arriving, so the stream desynchronised and the following request
failed with an unrelated JSON error. Verified against a live server:
0 tools before, 56 after.

- **One MCP server evicted 56 native tools from the model's tool list.**
`skill_id_from_tool_name` splits on `__`, but MCP wire names use
single underscores, so every MCP tool became its own "skill" and each
claimed a per-skill coverage slot ahead of native depth. Measured
under the 128 cap: native tools dropped from 128 to 72. MCP tools are
now bucketed per server.

- **`macos_ax__click` moved the cursor when a cursor-free route
existed.** Of Finder's 261 interactive elements only 44 publish
AXPress; the rest are AXCell rows publishing AXOpen, so "click
Applications" became a real mouse click in a module whose premise is
that it does not touch the cursor. It now names the element's
cursor-free actions and returns them as structured data rather than
guessing: a coordinate click on a row selects it and AXOpen opens it,
and the function cannot know which was meant.

Added

- **`macos_ax__activate`.** The capable counterpart to `click`: one
call, cursor-free, for any element publishing AXPress, AXOpen,
AXConfirm or AXPick. `click` means the click gesture; `activate` asks
for the outcome, which is why AXOpen is a correct answer there rather
than a substitution. No coordinate fallback, because an element with
no activating action has no primary thing to do.

- **The agentic loop's inner actions are gated.** Its dispatcher called
the raw skill instance, so plan mode, approval and the hourly rate
limit never ran for any action inside the loop: one approved task
bought up to fifteen ungated iterations of clicking and typing. They
now route through `SkillExecutor`, so the operator's autonomy tier
applies as written.

- **CI for `feral-nodes/python-node-sdk` and `feral-registry`.** Both
shipped committed test suites that no job ran. 138 tests were sitting
there unexecuted, which is why three files kept asserting HUP 1.3.0
for weeks after the bump, red on every run, in a repository where
every pull request looked green.

- **cua-driver is a known MCP server.** New `KNOWN_SERVERS` entry in
`mcp/registry.py` for [cua-driver](https://cua.ai/driver), a native
binary that exposes GUI computer-use over MCP stdio: accessibility
tree reads, click, type, app control, and a browser suite (56 tools on
0.22.2). Command and args are the ones `cua-driver mcp-config` prints
for itself, so the registration cannot drift from the tool.

It is the first catalog entry that is not an npx package. Nothing in
the registry or the client needed changing for that: the launcher
execs `command` directly, and `_resolve_install_state` already had a
non-npx branch that resolves through `shutil.which`. The npx-specific
helpers (`_npx_package_name`, `_npx_package_roots`) are reached only
from the npx branch. The entry is stored as the bare binary name, not
the absolute path `mcp-config` prints, because the catalog ships to
every machine.

It is **opt-in**, like every other entry. `KNOWN_SERVERS` is a catalog
the Settings UI renders and `connect_server` reads on an explicit user
action; nothing in the boot path iterates it. An operator who has
never heard of cua-driver pays no startup cost and sees no error, and
a test now asserts that statically so a future startup hook cannot
quietly change it.

- **`feral doctor` reports cua-driver.** Three rows when it is
installed: the binary and version, whether the daemon is answering,
and the macOS TCC grant state. Both of the things that silently stop
it working (a dead daemon, a missing Accessibility or Screen Recording
grant) were previously invisible until a tool call failed mid-turn.

Every row is `_pass` or `_info`, never `_warn` and never `_fail`. It
is an optional feature, so its absence is the expected state and must
not turn a clean install yellow. The severity allowlist in
`tests/test_doctor_severity.py` is unchanged.

Permissions are read via `cua-driver permissions status --json`, which
routes the question through the running daemon so the answer carries
CuaDriver's own TCC identity (`com.trycua.driver`). The MCP-level
`check_permissions` tool, called with no daemon up, reports the
caller's grants instead, which for doctor would be the terminal's. The
probe runs read-only subcommands only, on a 2.5s budget each, and
never raises.

Fixed

- **An MCP stdio server with more than 64 KiB of tools was unreadable.**
MCP frames one JSON-RPC message per line, and
`asyncio.create_subprocess_exec` builds its `StreamReader` with the
library default limit of 64 KiB. `mcp/client.py` passed no `limit=`,
so `readline()` raised `ValueError: Separator is not found, and chunk
exceed the limit` on any server whose `tools/list` response exceeded
that.

The symptom was not an error. The generic `except Exception` logged it
at WARNING as "MCP request error", `_discover_tools` left `_tools`
empty, and `connect()` returned True, so the server rendered as
connected with zero tools. Worse, `readline` clears its buffer on that
path while the rest of the oversized line is still arriving, so the
stream desynchronised and the next request read the tail of the
previous message: `resources/list` (issued immediately after
`tools/list` during connect) died on `Expecting value: line 1
column 1`.

Measured against cua-driver 0.22.2: its `tools/list` response for 56
tools is a single 141,876-byte line, 2.2x the default. This is not an
exotic server; a few dozen tools with real JSON Schemas gets any
server there, and a `tools/call` returning a screenshot is larger
still.

The reader limit is now 16 MiB, overridable with
`FERAL_MCP_STDIO_LINE_LIMIT`. It is a high-water mark rather than a
preallocation, so a well-behaved server costs nothing while a runaway
child still hits a ceiling. A frame that overruns even the configured
limit is now reported as its own condition: the connection is marked
disconnected instead of serving garbage from a desynchronised stream,
and `last_error` names the server, the limit, and the env var.
`json.JSONDecodeError` is also a `ValueError`, so the new handling is
scoped to the read itself and a malformed message is still treated as
the different fault it is.

2026.8.28

Three independent audits of this codebase found forty defects. This
release is all forty, plus the consolidation redesign that came out of
the research pass behind them. Every fix carries a test that was first
demonstrated to fail against the unfixed code; the suite moved from
10185 to 10331.

The audits were prompted by two questions from a developer on Reddit
about consolidation quality. Both of his questions found real bugs, and
looking for more of the same shape found the rest.

Things that had never worked once

Four features shared one mistake: an `async def` called without `await`,
whose `TypeError` landed in a broad handler that logged at debug or
returned a 500. Each reported "unavailable" or "success" rather than
failure.

- `MemoryRetriever` returned zero records for every query, every tier,
always. Its tests all used a synchronous fake store, so the fakes
matched the broken caller instead of the real store and CI stayed
green.
- Multi-hop graph traversal 500'd on every call. `traverse` itself was
correct; only its two callers were not.
- Ambient knowledge-graph extraction raised on every ambient
conversation, so the graph learned nothing from anything overheard.
- Geofences had never fired over REST. The route returned
`success: true` with a coroutine object in the body.

Retrieval

- A nonsense query returned secrets. The entity tier thresholded on a
raw cosine at a hardcoded 0.3, the one tier the corpus-centring fix
never reached, and `"asdfgh zxcvbn qwerty"` returned a note reading
"the wifi password is stored in 1password" through `search_all` and
into the LLM context builder.
- Below 200 chunks the indexed path applied no relevance floor at all,
so every new install and every demo scored nonsense at the maximum
possible value. The replacement floor was derived by measurement on
five small corpora rather than picked.
- The FTS5 BM25 sign bug, already found and fixed for episodes, was
still live in the knowledge graph and reversed its ordering exactly.
- Note search returned different results depending on the interpreter:
a floor documented as cosine was applied to `1 - L2` on the indexed
branch and to a real cosine on the numpy branch, which is
`cos >= 0.719` against `cos >= 0.25` for the same note.
- `INSERT OR REPLACE` orphaned FTS rows forever across episodes, notes
and knowledge, retaining text the decay path had deliberately purged.
`REPLACE` fires neither the delete nor the update trigger.

Memory durability

- Live voice wrote no episode at all. Realtime audio never enters
`handle_command_stream`, so the change that made transcripts outlive
compaction never reached it and a voice-only session was invisible to
search and the timeline.
- Gemini truncated every stored transcript to 300 characters where the
OpenAI path stored the full text through the identical call.
- Conversations silently ate their own history past 500 messages while
reporting the true count.
- 19 of 20 concurrent `conversation_append` calls were lost to a
read-modify-write across two awaits.
- Vector search switched itself off process-wide whenever one
connection failed to load the extension, which on a threaded app is
one cross-thread touch during one request.

Liveness and safety

- A single raised job killed the scheduler thread permanently. Every
routine stopped while the UI kept rendering them as enabled with a
`next_run` receding into the past.
- An emergency `halt` queued behind a one-second telemetry read and
behind the command it existed to abort. Preemption never interrupts
the thread holding the port, because a half-parsed frame on a robot
is worse than a slow one; a closed-loop move still blocks a halt, and
that wait is now logged rather than silent.
- A health question waited out an in-flight background sync and then
ran a second full one, because the throttle read its timestamp
outside the lock and wrote it after the vendor calls.
- Cron turns ran on a throwaway event loop that killed compaction
mid-flight, leaving the in-flight flag stuck true so that session
never compacted again for the life of the process.

Delivery

- `send_to_session` returned `None` on every path, so a frame nobody
could receive was indistinguishable from one that arrived. Channels
additionally dropped any frame without `payload["text"]`, which is
exactly what a confirmation card is.
- A permission request that reached nobody leaked its pending entry
forever, holding a grant open for a question the operator never saw.
It now fails closed.
- The CLI had no branch for `permission_request` at all and sat out a
30 second timeout; its `sdui` branch printed `[UI Component: ?]` and
ended the turn before a card could be answered.

Configuration

- Re-running the setup wizard replaced the whole settings file, erasing
`brain_id`, `relay_id`, Tailscale config, LLM fallbacks and every
paired Telegram, Discord and Slack sender. Merge semantics are now
RFC 7386 JSON Merge Patch, so the wizard can still clear a value by
saying so while silence leaves it alone.
- Session eviction removed the newest session rather than the oldest,
because it sorted by transcript length. It now ranks by real
activity, stamped on the voice path too since a live-voice session
never runs `_finalize_turn`.
- Snapshot debouncing dropped turns instead of deferring them, and the
shutdown force-save its own docstring promised did not exist.

Documentation and process

- `WORK-ORDER.md` and `RESUME-HERE.md` are removed from the repository.
The former listed four P0 security findings with `file:line`
citations under a line reading "Nothing here is started", in a public
repository, months after all four were fixed.
- The documentation leakage checker now scans every root-level markdown
file by default, with exemptions named and justified rather than
inclusions listed, and rejects severity-triage headings. A published
triage list reads as current, maps the gaps for anybody hostile, and
goes stale silently the moment the work lands.

Changed

- **Session consolidation redesigned end to end (F1-F7).** The pipeline
in `memory/context_builder.py` was seven separate defects wearing one
function.

- **The map stage was sequential.** `for chunk in chunks: await
llm.chat(...)` over chunks that are independent by construction.
Now gathered under a semaphore bounded at 3 (`MAP_CONCURRENCY`,
overridable via `memory.compaction.map_concurrency`). Bounded small
on purpose: a local model behind Ollama or llama.cpp serves 1-4
slots, and ten concurrent generations at a one-slot server queue
while each one's KV cache share shrinks.
- **The knowledge graph saw `conversation_text[:3000]`.** At the
default 20-turn threshold that is the first 3-6 messages of each
window, and every entity after it was invisible to the graph
PERMANENTLY, because the raw turns are replaced by the summary
moments later and nothing re-reads them. Measured on a mixed
20-turn window: the graph saw 6.5% of the conversation, 2.2% at 60
turns. It now sees all of it, extracted per segment.
- **Every message was truncated to `content[:500]`.** Leading-N
truncation is the worst available choice: arXiv:2210.16732 measures
~80% of the information needed to reconstruct a summary lost at a
1K-token leading cut, with salience anti-correlated with position
near the cut. The per-message cap is gone; the only remaining one
is a 20000-char safety valve that keeps head AND tail. On the same
20-turn window, 18 of 45 messages used to reach the model intact.
Now 45 of 45 do.
- **Chunks were raw 6000-character slices** of the concatenated
transcript, cutting mid-message, mid-word and mid-tool-result.
Chunking is now on message boundaries, and the target segment is
12000 chars rather than 6000, chosen by measurement: at 6000 the
honest pipeline needs 4 waves where the old lossy one needed 3, at
12000 it needs 2. Net wall clock against the old pipeline is 1.50x
faster at 20 turns and 1.80x at 60, while showing the model every
message instead of a third of them.
- **There was no reduce step and summaries were re-summarised.**
`"\n\n".join(summaries)[:16000]` silently deleted the TAIL segment
summaries, which on a long session are the most recent part of the
conversation. There is now a real reduce, and when it is
unavailable or still overflows, every segment is abridged to an
equal share rather than the last ones being dropped. Separately,
the injected `[Session Summary]` message was fed straight back into
the next compaction: arXiv:2608.22752 measures that exact pattern
at 53% retention after ONE round and 10% after five. Summary
messages are now watermarked, never re-summarised, and when too
many accumulate the oldest are collapsed by RE-DERIVING from the
raw turn rows the watermark points at.
- **The only trigger was a turn counter.** `_last_turn_at` already
existed and was read only for status reporting. Consolidation now
runs on a ladder: backlog-soft (`turns_threshold`, unchanged and
still 20, so existing configs behave as before), idle-debounce
(`idle_seconds`), or deadline-hard (`max_pending_seconds`),
whichever fires first. The deadline is load-bearing, not padding:
idle alone starves, since a session that is never quiet never
consolidates and busy sessions are the ones that need it most. The
W3C requestIdleCallback spec states this failure mode normatively
and races idle against a timeout; RocksDB write stalls, Postgres
autovacuum freeze age, Linux writeback and Go's GC all use the same
free-background to forced-inline ladder. Backlog and deadline are
evaluated on the turn path, so they cannot starve; idle runs on a
background cadence shaped after `MemoryDecayService._loop`.
- **Provenance pointed at nothing.** `time_range` was always
`[0.0, 0.0]`, because ordinary chat history carries no
`meta.created_at`, and `source_turn_ids` was a list of positional
indices into a list the compaction discards two lines later,
serialised into an HTML comment that nothing read. PR 224 made
per-turn `user_command` / `assistant_reply` episodes durable, so
real row ids now exist: a new `compaction_sources` table records
the edge list, `store.compaction_sources()` resolves a
consolidation to its real source rows and
`store.consolidations_for_turn()` answers the reverse, and
`time_range` falls back to those rows' real `created_at`.

SeCom-style topic segmentation over the existing 384-dim embeddings
was evaluated and NOT shipped. Embedding one 20-turn window costs
5.82s on this machine (129ms per message), which is the same order as
the entire map stage, and there is no ground truth in this repo
against which to show it buys anything for that. Message-boundary
chunking is what shipped.

Page 1 of 19

© 2026 Safety CLI Cybersecurity Inc. All Rights Reserved.