Skip to content

Commit ba5fbe4

Browse files
committed
Merge upstream/develop into dev/2281-hub-mine-file
CHANGELOG.md conflicted again, same four lines as the previous merge. develop has since added its own [Unreleased] / Bug Fixes section, matching the structure this branch introduced, so both sides now prepend an entry to the same list. Kept both, develop's newer entry first, matching the newest-first order the 3.8.0 section uses. mcp_server.py merged cleanly and still carries the fix. Full suite: 4493 passed, 32 skipped. ruff check and ruff format --check both clean.
2 parents 47fdb87 + fae7de0 commit ba5fbe4

13 files changed

Lines changed: 1059 additions & 67 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1010

1111
### Bug Fixes
1212

13+
- **A palace with no database is no longer reported as one that passed its integrity check.** `sqlite_integrity_errors` answers `[]` when `chroma.sqlite3` is absent, and the MCP gate published that as `checked: true, ok: true`. Absence is now decided by `ENOENT` alone, which proves that nothing resolves under the path, and reported as the not-applicable shape #1931 introduced, `checked: false`/`ok: null` plus a reason. Every state that is not proven absent reaches the probe, and a probe that cannot open the file reports `PRAGMA quick_check failed`, which trips the existing `-32002` refusal: a dangling symlink, a database under an unreadable directory, a symlink loop, a name the filesystem rejects, an embedded NUL in the path, and, on POSIX, a palace path whose parent is a file. A palace directory named with a byte that is not valid UTF-8 reached the probe and, up to Python 3.12, raised out of it, which `mempalace mine` and `mempalace repair` never guarded against; it is now reported like every other unreadable path. `/statusz` reads an absent verdict as healthy, so the new `ok: null` does not turn a fresh install red, and non-chroma backends stop reporting themselves unhealthy, which they had done since the #1931 fix. The size-limited startup skip still publishes a clean verdict; the only change there is that it no longer inherits the previous probe's absence reason. (#2290)
1314
- **`mempalace_mine` accepts a single conversation file again, so hook transcript ingest survives a running hub.** `cli.py` has always documented the mine source as "Directory to mine, or one conversation file with `--mode convos`", and `hooks_cli._ingest_transcript` submits exactly one `.jsonl`. The MCP tool validated `os.path.isdir` regardless of mode, and `cmd_mine` forwards to the hub whenever one is registered and healthy, so the documented single-file form was unreachable in the configuration most users run: every Stop and PreCompact transcript ingest failed with `source directory not found`. Nothing surfaced it, because `hook_precompact` returns the same empty object on the success path, leaving a compaction that captured nothing indistinguishable from one that captured everything. `convos` now accepts a file or a directory; the tree-walking modes still require a directory. (#2281)
1415

1516
---
@@ -20,7 +21,7 @@ Large palaces get fast and stay small: both storage backends lost their palace-w
2021

2122
### Features
2223

23-
- **`mempalace logstream watch` — a background watcher an agent can be woken by.** `logstream wait` is a primitive, not a watcher: it caps at five minutes and reports a timeout, so every caller ended up writing the same re-arm loop and each one had to remember to carry its cursor forward. Most did not, and coordinated tasks stalled on nobody listening rather than on the work. `watch` owns the loop and the cursor, and exits on a match so any harness that can background a process and react to its exit gets woken — `0` when it printed a match, `2` on `--idle-exit-ms`, `130` when interrupted; only `0` means "you have mail". Two filters a watcher needs are things `list_events` cannot express, because its SQL is single-valued and positive-only: repeating a flag means "or" (`--type task.request --type patch.ready` wakes for either and stays quiet for everything else), and `--agent <id>` expands to `--to-agent <id> --exclude-from-agent <id>`. That exclusion is not cosmetic — `to_agent=<you>` deliberately also matches `*` broadcasts, and your own broadcasts are broadcasts, so a watcher without it wakes itself every time it posts a status. `--state-file` persists the cursor so a restart resumes exactly, advancing past events that were examined and rejected rather than only matches; a cursorless first run starts at the tip like the SSE live-tail rather than replaying weeks of fleet history it cannot tell is stale, and says so on stderr. `--follow` keeps the process alive past the first match and emits NDJSON, since repeated indented documents on one stream are not parseable JSON. The invariant throughout is that a restart may cost a duplicate, never a missed delegation. (#2315)
24+
- **`mempalace logstream watch` — a background watcher an agent can be woken by.** `logstream wait` is a primitive, not a watcher: it caps at five minutes and reports a timeout, so every caller ended up writing the same re-arm loop and each one had to remember to carry its cursor forward. Most did not, and coordinated tasks stalled on nobody listening rather than on the work. `watch` owns the loop and the cursor, and exits on a match so any harness that can background a process and react to its exit gets woken — `0` when it printed a match, `2` on `--idle-exit-ms`, `130` when interrupted; only `0` means "you have mail". Two filters a watcher needs are things `list_events` cannot express, because its SQL is single-valued and positive-only: repeating a flag means "or" (`--type task.request --type task.reply --type patch.ready` wakes for any of them and stays quiet for everything else), and `--agent <id>` expands to `--to-agent <id> --exclude-from-agent <id>`. That exclusion is not cosmetic — `to_agent=<you>` deliberately also matches `*` broadcasts, and your own broadcasts are broadcasts, so a watcher without it wakes itself every time it posts a status. `--state-file` persists the cursor so a restart resumes exactly, advancing past events that were examined and rejected rather than only matches; a cursorless first run starts at the tip like the SSE live-tail rather than replaying weeks of fleet history it cannot tell is stale, and says so on stderr. `--follow` keeps the process alive past the first match and emits NDJSON, since repeated indented documents on one stream are not parseable JSON. The invariant throughout is that a restart may cost a duplicate, never a missed delegation. (#2315)
2425
- **The monitoring protocol is documented, including the cursor rule that costs real events.** Events are ordered by append order (`ORDER BY rowid`), not wall clock, so a peer's event is appended whenever it syncs and can already be older than a timestamp high-water mark — resuming with `since_created_at` therefore drops late-arriving cross-replica events permanently. Measured on a live fleet: a windows-origin event created `09:10:48Z` was ingested *after* a mac-origin event created `09:13:21Z`, in a single fifty-event window. `list_events`' docstring already said `since_event_id` was "the precise cursor … regardless of timestamp ties"; it just never reached an agent. That rule now appears in the `mempalace_event_list` and `mempalace_event_wait` tool descriptions at the point of use, both `since_created_at` parameters are marked "NOT a resume cursor", and `coordination-protocol.md` gained a monitoring section covering the modes, the announce-your-watch convention, and declaring when you are *not* watching — a false watcher is worse than a declared-absent one, because the requester stops looking for a human to nudge. The system-prompt snippet every agent copies was updated with it. (#2315)
2526

2627
### Bug Fixes

CONTRIBUTING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ assets/ ← logo + brand
8888
- `bench: add LoCoMo turn-level metrics`
8989
6. Push to your fork and open a PR against `develop`
9090

91+
## Branching Model
92+
93+
- **Feature and fix PRs should target `develop`.** This is the integration branch where all day-to-day work lands.
94+
- **`main` is reserved for tagged, stable releases.** Releases are promoted by merging `develop` into `main` (e.g., `v3.7.0`, `v3.6.0`).
95+
- Only emergency hotfixes should branch directly off — and target — `main`.
96+
9197
## Code Style
9298

9399
- **Formatting**: [Ruff](https://docs.astral.sh/ruff/) with 100-char line limit (configured in `pyproject.toml`)

integrations/shared/coordination-protocol.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ can run a background process and react to its exit gets woken:
105105
```bash
106106
mempalace logstream watch \
107107
--agent mac-claude \
108-
--type task.request --type patch.ready \
108+
--type task.request --type task.reply --type patch.ready \
109109
--state-file ~/.mempalace/watch/mac-claude.json --json
110110
```
111111

@@ -114,10 +114,14 @@ mempalace logstream watch \
114114
`to_agent=<you>` deliberately matches `*` broadcasts, and your own
115115
broadcasts are broadcasts, so a watcher without it wakes itself every time
116116
it posts a status.
117-
- **Repeat a filter to mean "or"**`--type task.request --type patch.ready`
118-
wakes for either and stays silent for everything else. This is how you get
119-
"or nothing": narrow to the event types that actually require you, and
120-
routine status traffic stops waking you.
117+
- **Repeat a filter to mean "or"** — `--type task.request --type task.reply
118+
--type patch.ready` wakes for any of them and stays silent for everything
119+
else. This is how you get "or nothing": narrow to the event types that
120+
actually require you, and routine status traffic stops waking you. If you
121+
ever delegate, `task.reply` belongs in the filter: a worker reporting
122+
`blocked` or `failed` sends exactly that, and a watcher that rejects it
123+
advances its durable cursor past it silently — the delegation then sits
124+
unanswered until a manual sweep.
121125
- **`--state-file`** persists the cursor, so a restart resumes exactly where
122126
it stopped rather than replaying or skipping. It advances past events that
123127
were examined and rejected, not only matches. When the cursor cannot be
@@ -164,7 +168,7 @@ by timeout:
164168
type: status room: status to_agent: * correlation_id: <the task>
165169
166170
<AGENT_ID> is MONITORING this correlation for coordination replies
167-
(task.request / task.reply / patch.ready / status).
171+
(task.request / task.reply / patch.ready).
168172
169173
Watching: to_agent=<AGENT_ID> and correlation_id=<id> on stream project/<name>.
170174
Cursor after: evt_20260811T112013_19320fbd7541
@@ -178,6 +182,18 @@ reaches you), **the cursor** (so others know what you have already seen),
178182
**the overlap warning** (so others do not duplicate), and **the fact that a
179183
watcher exists at all**.
180184

185+
Two hygiene rules keep announcements from becoming noise. Announce in a
186+
`status` type — which the recommended inbox filter above (`task.request` /
187+
`task.reply` / `patch.ready`) sleeps through — so the announcement lands in
188+
everyone's next sweep without burning a wake-up. Keep `status` out of your
189+
advertised wake filter for the same reason: a fleet whose watchers wake on
190+
`status` wakes on every announcement;
191+
an announcement typed as `task.reply` wakes every watching window, and
192+
self-exclusion only protects an agent from its own events, not from six
193+
peers announcing back. And announce once per session or when the filter
194+
changes — never on every re-arm, or a fleet of re-arming watchers wakes
195+
itself in a loop.
196+
181197
### Declare when you are *not* watching
182198

183199
A turn-based agent — most chat-driven harnesses — has no background loop. It
@@ -250,7 +266,8 @@ Coordination (logstream):
250266
and treat its exit as "you have mail" (exit 0 = match, 2 = idle). Use
251267
--agent, not --to-agent: it also excludes your own events, which
252268
otherwise wake you via the '*' broadcast match. Repeat --type to wake
253-
only for what needs you. In-turn, waiting on one known correlation,
269+
only for what needs you; if you delegate, include task.reply — blocked
270+
and failed arrive as replies. In-turn, waiting on one known correlation,
254271
mempalace_event_wait is enough. Before a coordinated task, post a
255272
status event to to_agent=* naming your filter and your cursor so others
256273
know you are listening. If you are turn-based and cannot watch between

mempalace/backends/sqlite_exact.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -464,8 +464,10 @@ def __init__(
464464
# collection_id -> (ids, float32 matrix, mini-metadata). Filled lazily
465465
# by query() so a long-lived hub does not re-read every embedding blob
466466
# on the next search. Mini-metadata is wing/room/source_file for
467-
# in-memory equality filters. Cleared on any write.
467+
# in-memory equality filters. Cleared on any write through this handle;
468+
# ``_vector_cache_data_version`` detects commits from other handles.
468469
self._vector_cache: dict[int, tuple[list[str], np.ndarray, list[dict]]] = {}
470+
self._vector_cache_data_version: Optional[int] = None
469471

470472

471473
class SQLiteExactCollection(BaseCollection):
@@ -958,6 +960,10 @@ def _rank_vectors(
958960
_validate_where(where_document)
959961
expected = self._collection_dimension(cur, collection_id)
960962
empty = np.zeros((0, expected or 0), dtype=np.float32)
963+
data_version = int(cur.execute("PRAGMA data_version").fetchone()[0])
964+
if self._handle._vector_cache_data_version != data_version:
965+
self._handle._vector_cache.clear()
966+
self._handle._vector_cache_data_version = data_version
961967
cached = self._handle._vector_cache.get(collection_id)
962968
if cached is None:
963969
cached = self._load_all_vectors(cur, collection_id, expected)
@@ -1054,11 +1060,25 @@ def get(
10541060
) -> GetResult:
10551061
spec = _IncludeSpec.resolve(include, default_distances=False)
10561062
# get(ids=...) must not scan the collection: look up by primary key.
1057-
if ids is not None and where is None and where_document is None:
1063+
if ids is not None:
1064+
_validate_where(where)
1065+
_validate_where(where_document)
1066+
lookup_spec = _IncludeSpec(
1067+
documents=spec.documents or bool(where_document),
1068+
metadatas=spec.metadatas or bool(where),
1069+
distances=False,
1070+
embeddings=spec.embeddings,
1071+
)
10581072
with self._cursor() as cur:
10591073
collection_id = self._collection_id(cur)
1060-
by_id = self._rows_by_ids(cur, collection_id, list(ids), spec)
1061-
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
1074+
by_id = self._rows_by_ids(cur, collection_id, list(ids), lookup_spec)
1075+
rows = [
1076+
by_id[doc_id]
1077+
for doc_id in ids
1078+
if doc_id in by_id
1079+
and _matches_where(by_id[doc_id]["metadata"], where)
1080+
and _matches_where_document(by_id[doc_id]["document"], where_document)
1081+
]
10621082
if offset:
10631083
rows = rows[offset:]
10641084
if limit is not None:

0 commit comments

Comments
 (0)