Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
9cf9e38
fix(tooling): pyrightconfig stops hardcoding what the venv already kn…
AIOSAI Aug 24, 2026
e56d5de
fix(memory): plan-ID matching anchors to a boundary, and the fourth s…
AIOSAI Aug 24, 2026
660ab69
fix(ai_mail+memory): the purge seam stops eating mail - exit 0 was ne…
AIOSAI Aug 24, 2026
d0ce2ce
feat(spawn): the passport number becomes real - citizenship.citizen_i…
AIOSAI Aug 25, 2026
90d6723
fix(ai_mail): registry rows leave the reader absolute - a projects/* …
AIOSAI Aug 25, 2026
78180e6
feat(spawn): a new external project is born WITH its credential - loa…
AIOSAI Aug 25, 2026
2b7e6bc
fix(drone): owner-tier git refusals carry their species, and the warn…
AIOSAI Aug 25, 2026
6cf01d6
fix(devpulse)+docs(changelog): feedback's cross-store writes speak th…
AIOSAI Aug 25, 2026
ac721b2
docs(fleet): the README truth campaign - 18 branch READMEs verified b…
AIOSAI Aug 25, 2026
2db9956
fix(devpulse): README test-count line speaks both truths and the CI g…
AIOSAI Aug 25, 2026
25f2e13
feat(memory): trinity gold-source templates carry the agreed standard…
AIOSAI Aug 26, 2026
b8d3d78
fix(memory)+feat(memory): the measurement bugs that let memory-file d…
AIOSAI Aug 26, 2026
0d0c8f0
docs(changelog): record @memory's trinity machinery build in the in-p…
AIOSAI Aug 26, 2026
8d31a64
fix(hooks): B3 dead on the Edit/Write lane - edit_gate refuses a miss…
AIOSAI Aug 26, 2026
3d0478f
feat(seedgo): the trinity standard is LIVE - trinity_check.py joins t…
AIOSAI Aug 26, 2026
88cfc2f
docs(changelog): hooks B3 + seedgo trinity checker entries in the tra…
AIOSAI Aug 26, 2026
7ee6a63
fix(memory)+fix(hooks)+docs(seedgo): the morning-after round - B1's s…
AIOSAI Aug 27, 2026
77194eb
feat(memory): the trinity PUSH is built and LIVE-PROVEN on canary - o…
AIOSAI Aug 27, 2026
c5abe8f
feat(seedgo)+feat(spawn): the File set ruling executed on both sides …
AIOSAI Aug 27, 2026
c33072f
feat(fleet): THE FLEET PUSH EXECUTED - five months of memory-file dri…
AIOSAI Aug 27, 2026
975eb7c
fix(memory): todos are NEVER archived - the push's one real defect de…
AIOSAI Aug 27, 2026
0984c27
fix(devpulse): watchdog pair relocated out of .trinity - the File set…
AIOSAI Aug 27, 2026
292ae64
feat(api): host-api survives reboots - a systemd user unit, deliberat…
AIOSAI Aug 27, 2026
0ae74ce
feat(seedgo): marker 7 seedgo lane - the gate that was already a gate…
AIOSAI Aug 27, 2026
19157ab
feat(memory): marker 7 memory lane - the self-healing triggers land, …
AIOSAI Aug 27, 2026
1ff5d01
feat(ai_mail): verified-admin @all reaches the residents, and the amb…
AIOSAI Aug 27, 2026
450702e
feat(drone): timeouts stop killing legitimate work - the hang guard r…
AIOSAI Aug 27, 2026
329f540
feat(hooks): edit_gate grandfather narrowed to todos only - the migra…
AIOSAI Aug 27, 2026
7c0f116
style: ruff format the three committed test files the train's builder…
AIOSAI Aug 27, 2026
986ed37
fix(seedgo): the audit learns what a clean checkout is, and the cache…
AIOSAI Aug 27, 2026
6cadf2f
feat(spawn): newborns arrive with a receipt, retirees leave a named a…
AIOSAI Aug 27, 2026
7210605
fix(memory): CI guard for live-fleet tests - 8 guarded not 5, because…
AIOSAI Aug 27, 2026
4c065f4
fix(aipass): the last drifted citizen reaches trinity 100 - and the s…
AIOSAI Aug 28, 2026
ae16696
docs(changelog): memory CI guard + aipass profile relocation entries …
AIOSAI Aug 28, 2026
818e55f
fix(memory): three CI reds closed - the 3.10 bus flake was a MagicMoc…
AIOSAI Aug 28, 2026
896c190
fix(api): the Windows red was an unreachable mock - and one failure w…
AIOSAI Aug 28, 2026
eecbedd
fix(aipass): the mkdir flag was two violations wearing one line, and …
AIOSAI Aug 28, 2026
447d0f7
refactor(seedgo): trinity_check split at the I/O seam - 1557 lines to…
AIOSAI Aug 28, 2026
5325b2e
docs(changelog): CI round 2 - four root-cause fixes in one evening, P…
AIOSAI Aug 28, 2026
70f86bb
style(api): ruff format test_host_lifetime.py - the one unformatted f…
AIOSAI Aug 28, 2026
ab9a172
release: v2.7.20 - version bump both files + CHANGELOG section retitl…
AIOSAI Aug 28, 2026
24c7ff4
fix(memory): the poisoning was two defects that only bite together - …
AIOSAI Aug 28, 2026
5c7ab1c
test(seedgo): the split's three roster pins - missed from 447d0f7b by…
AIOSAI Aug 28, 2026
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
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"teammateMode": "in-process"
"teammateMode": "in-process",
"outputStyle": "Concise"
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ build/
AIPASS_REGISTRY.json
.trinity/
!src/aipass/spawn/templates/*/.trinity/
.watchdog/
.ai_mail.local/
ai_mail.local/
.feedback.local/
Expand Down
414 changes: 413 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "aipass"
version = "2.7.19"
version = "2.7.20"
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
readme = "README.md"
license = "MIT"
Expand Down
6 changes: 3 additions & 3 deletions pyrightconfig.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"extraPaths": [
"src",
".venv/lib/python3.12/site-packages",
"src/aipass/memory/.venv/lib/python3.12/site-packages"
"src"
],
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.10",
"reportMissingImports": "error",
"reportAttributeAccessIssue": "error",
Expand Down
2 changes: 1 addition & 1 deletion src/aipass/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
"""

__version__ = "2.7.19"
__version__ = "2.7.20"
230 changes: 209 additions & 21 deletions src/aipass/ai_mail/README.md

Large diffs are not rendered by default.

38 changes: 30 additions & 8 deletions src/aipass/ai_mail/apps/handlers/central_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@
_REPO_ROOT = find_repo_root()
AI_CENTRAL_DIR = _REPO_ROOT / ".ai_central"
CENTRAL_FILE = AI_CENTRAL_DIR / "AI_MAIL.central.json"

# Directory NAMES whose subtrees hold archived copies of live mailboxes. They
# are pruned from the walk, never merely filtered out of its results.
#
# Matched as exact path components, not substrings. The substring form hid a
# real branch whose directory merely CONTAINED the word: `my.backup.tools/`
# read as an archive and vanished from central stats with no error anywhere.
# It was also the only reason `/backups/` behaved differently on Windows —
# that check was a literal forward-slash match, so the same tree counted on
# POSIX and did not on Windows. Names have no separators, so both agree now.
EXCLUDED_DIR_NAMES = frozenset({".backup", ".archive", "backups"})
BRANCH_REGISTRY = _REPO_ROOT / "AIPASS_REGISTRY.json"


Expand All @@ -69,7 +80,18 @@ def find_all_inbox_files() -> List[Path]:
Find all inbox.json files in .ai_mail.local directories.

Scans the repo root directory for .ai_mail.local/inbox.json files.
Excludes backup directories to avoid counting archived data.
Excludes backup/archive subtrees to avoid counting archived data.

PRUNES rather than filters. The previous implementation walked the whole
tree with rglob() and then discarded results whose path contained an
excluded word — but discarding a RESULT does not save the WALK. On the live
repo that meant traversing 57GB of .backup and 68GB of projects/ to return
26 inboxes: 2.9s per call, against 0.13s for the same 26 once the excluded
subtrees are pruned at the branch instead of at the leaf.

That cost was not paid once. update_central() calls this on every delivery,
so an 18-recipient broadcast spent ~55 of its ~60 seconds here and was
killed by the router's timeout after all the mail had already landed.

Returns:
List of Path objects to inbox.json files
Expand All @@ -79,15 +101,15 @@ def find_all_inbox_files() -> List[Path]:
"""
inbox_files = []

# Search pattern: any directory ending in .ai_mail.local containing inbox.json
for ai_mail_dir in _REPO_ROOT.rglob(".ai_mail.local"):
# Skip backup/archive directories (but NOT backup branch itself)
path_str = str(ai_mail_dir)
if ".backup" in path_str or ".archive" in path_str or "/backups/" in path_str:
for dirpath, dirnames, _filenames in os.walk(_REPO_ROOT):
# Mutating dirnames in place is what stops os.walk descending.
dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIR_NAMES]

if ".ai_mail.local" not in dirnames:
continue

inbox_path = ai_mail_dir / "inbox.json"
if inbox_path.exists() and inbox_path.is_file():
inbox_path = Path(dirpath) / ".ai_mail.local" / "inbox.json"
if inbox_path.is_file():
inbox_files.append(inbox_path)

return inbox_files
Expand Down
35 changes: 35 additions & 0 deletions src/aipass/ai_mail/apps/handlers/email/purge.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,19 @@ def _vectorize_emails(emails: List[Dict[str, Any]], folder_type: str) -> Dict[st
)

# Call @memory vectorization via subprocess (handler independence)
# THE GAP IS CLOSED — this operation is real now. It was NOT, for about
# four months: @memory's handler had no `vectorize_and_store`, answered
# this call with success:false at exit 0, and purge read that refusal as
# a store and deleted the originals. @memory shipped the operation in
# chroma_subprocess 1.4.0 (2026-08-24) and the seam is verified live from
# this side: a probe returns {'success': True} and both
# ai_mail_email_sent and ai_mail_email_deleted now exist.
#
# Kept as a TEXT-IN call deliberately. `store_vectors` wants `embeddings`
# plus `documents`; sending raw `texts` and letting @memory encode them
# means the branch that OWNS the store picks the embedding model. If each
# caller encoded its own, two callers could put vectors from two models
# in one collection, which is silently unsearchable rather than an error.
input_data = {
"operation": "vectorize_and_store",
"branch": "AI_MAIL",
Expand All @@ -245,6 +258,28 @@ def _vectorize_emails(emails: List[Dict[str, Any]], folder_type: str) -> Dict[st
if result.returncode != 0:
return {"success": False, "error": result.stderr or "Storage failed"}

# EXIT 0 MEANS THE PROCESS RAN, NOT THAT THE REQUEST SUCCEEDED. @memory's
# handler answers a bad request on stdout — {"success": false, "error":
# ...} — and exits 0, correctly: the subprocess did its job, it was the
# ASK that was wrong. Testing only returncode read that refusal as a
# store, and _purge_files then unlinked the originals under the comment
# "data is safely in @memory". It was not. Found by @memory (9da1ba52,
# 2026-08-23) from their side of the wire; the database agrees — not one
# email collection exists among 37.
try:
reply = json.loads(result.stdout)
except (json.JSONDecodeError, TypeError, ValueError) as e:
# Unreadable output is not evidence of success. Guessing "probably
# fine" here is the original defect wearing a different hat, and the
# cost of guessing wrong is deleted mail.
logger.warning("[purge] Unreadable vectorization reply for %s: %s", folder_type, e)
return {"success": False, "error": f"Unreadable reply from @memory: {result.stdout[:200]!r}"}

if not isinstance(reply, dict) or not reply.get("success"):
error = (reply or {}).get("error") if isinstance(reply, dict) else None
logger.warning("[purge] @memory refused vectorization for %s: %s", folder_type, error)
return {"success": False, "error": str(error or "Vectorization refused without a reason")}

return {"success": True, "count": len(texts)}

except subprocess.TimeoutExpired as e:
Expand Down
19 changes: 14 additions & 5 deletions src/aipass/ai_mail/apps/handlers/email/send.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,24 @@ def send_to_broadcast(
create_email_file_fn,
load_email_file_fn,
deliver_email_to_branch_fn,
on_delivered_callback,
log_operation_fn,
update_central_fn,
) -> Tuple[bool, int, int, Any]:
"""
Execute broadcast send to all branches.

Takes no per-recipient on_delivered callback, deliberately. The only thing
that callback ever did was update_central(), which takes no arguments and
rescans every branch inbox from the repo root — so running it per recipient
recomputes one global answer N times and the last run is the only one any
reader sees. An 18-recipient @all did 19 of those scans, ~55s of its ~60s,
and drone killed it at the 60s cap with every message already delivered.
The single call after the loop leaves exactly the same central file.

A broadcast that ever needs genuine per-recipient side effects should be
given a broadcast-aware callback on purpose, not inherit the single-send
one by accident.

Returns:
Tuple of (success, success_count, total_count, results_or_error).
On failure: 4th element is an error string.
Expand All @@ -154,9 +165,7 @@ def send_to_broadcast(
if no_memory_save:
delivery_data["no_memory_save"] = True

success, error_msg = deliver_email_to_branch_fn(
branch["email"], delivery_data, on_delivered=on_delivered_callback
)
success, error_msg = deliver_email_to_branch_fn(branch["email"], delivery_data)
results.append((branch.get("name", branch["email"]), success, error_msg))

success_count = sum(1 for _, s, _ in results if s)
Expand All @@ -176,7 +185,7 @@ def send_to_broadcast(
except ImportError as e:
logger.warning("[send] trigger import unavailable for broadcast event: %s", e)

# Update central (best-effort)
# The one central aggregation for the whole broadcast (best-effort).
try:
if update_central_fn:
update_central_fn()
Expand Down
46 changes: 46 additions & 0 deletions src/aipass/ai_mail/apps/handlers/registry/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,52 @@ def _branches_from_registry(reg_file: Path) -> Dict[str, str]:
return result


# The DPLAN-0318 resident projects, named one by one on purpose. This MIRRORS
# @memory's registry_scope.RESIDENT_REGISTRIES -- the single fleet definition --
# and is deliberately a copy rather than an import: reaching into another
# branch's handlers is an encapsulation violation, and @all must not acquire a
# runtime dependency on @memory to know who it is talking to. A test compares
# the two and fails on drift, so the copy cannot rot quietly.
#
# NAMED, NEVER GLOBBED. projects/ also holds `marketstand(on _hold)` and
# `speakeasy(on_hold)`. marketstand's registry still marks its branch active
# while the directory name says the project is parked, so a glob would broadcast
# into a held project on the strength of a stale status field. Adding a resident
# is a deliberate edit here -- the correct amount of friction for "this project
# now receives fleet announcements".
RESIDENT_REGISTRIES = (
"projects/baud/BAUD_REGISTRY.json",
"projects/earmark/EARMARK_REGISTRY.json",
"projects/finch/FINCH_REGISTRY.json",
"projects/aipass-site/AIPASS-SITE_REGISTRY.json",
)


def get_resident_branches() -> Dict[str, str]:
"""Load email->path for the named resident projects only.

Distinct from get_project_tree_branches(), which globs every registry under
projects/ and therefore also answers for held projects. Resolution may
legitimately be that wide -- a held project's citizen still has an address --
but BROADCAST may not: reaching a parked project is a delivery nobody asked
for. Same tree, two different questions.

Returns:
Dict mapping email address to absolute path string. A resident whose
registry is missing or unreadable contributes nothing and is logged by
_branches_from_registry; it never becomes an empty-but-present entry.
"""
repo_root = find_repo_root()
result: Dict[str, str] = {}
for rel in RESIDENT_REGISTRIES:
reg_file = repo_root / rel
if reg_file.is_file():
result.update(_branches_from_registry(reg_file))
else:
logger.warning("[registry] resident registry absent: %s", rel)
return result


def get_project_tree_branches(repo_root: Path) -> Dict[str, str]:
"""Load branch email->path mappings from every sealed project under repo_root.

Expand Down
72 changes: 67 additions & 5 deletions src/aipass/ai_mail/apps/handlers/users/branch_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@
# how a dispatch from the repo root sent as @aipass (@devpulse, 0bb77ec2).
_CREDENTIAL_SOURCES = frozenset({"assigned", "passport"})

# Strategies where AIPASS_CALLER_BRANCH did not merely exist but actually RESOLVED
# against a catalog. `caller_branch:synthesized` is deliberately absent: it invents
# a citizen from env vars alone, so nothing vouched for the name.
_ENV_VAR_RESOLVED_STRATEGIES = frozenset({"caller_branch:registry", "caller_branch:contact"})


def _get_contact_info(branch_name: str) -> Optional[Dict]:
"""Look up branch info from the contacts address book.
Expand Down Expand Up @@ -235,10 +240,34 @@ def _record_resolution(branch_info: Optional[Dict], strategy: str, confidence: s
# pick AIPASS_CALLER_BRANCH. Legitimate when the caller stood outside any branch
# (drone falls back to the env var), so this warns rather than raises — but it is
# the one disagreement visible from inside this process, so it gets recorded.
#
# UNLESS THE ENV VAR CARRIES A CREDENTIAL, in which case there is no disagreement
# to name. A credential travels and a location does not: an agent that cds into
# another branch is still itself (S102), so `assigned`/`passport` naming one
# branch while the cwd sits in another is the DESIGNED precedence working, not a
# conflict. 104 lifetime warnings said AMBIGUOUS about correctly-resolved sweeps
# — every one CALLER_BRANCH='ai_mail' with the cwd walking the whole fleet — and
# a warning that fires on the known-good case buries the one it exists for.
#
# Two things deliberately keep warning, because neither is proven good:
# • provenance `project` — a registry-derived DIRECTORY name, which answers
# "which project am I in", never "who am I". @aipass the directory and
# @aipass the citizen spell the same; that is the $1.41 wake, and it must
# stay loud forever.
# • provenance missing or `unknown` (an older drone, or no drone) — unprovable
# is not proven, and this lane fails toward noise rather than toward silence.
# A credential naming a citizen the registry has never heard of also still warns:
# resolution falls through to synthesis, and the provenance only says who STAMPED
# the name, never that anything vouched for it.
if caller_branch and caller_cwd:
cwd_root = find_branch_root(Path(caller_cwd))
if cwd_root and cwd_root.name.lower() != caller_branch.lstrip("@").lower():
logger.warning(
vouched = (
os.environ.get("AIPASS_CALLER_IDENTITY_SOURCE") in _CREDENTIAL_SOURCES
and strategy in _ENV_VAR_RESOLVED_STRATEGIES
)
log_disagreement = logger.debug if vouched else logger.warning
log_disagreement(
"[identity] AMBIGUOUS: AIPASS_CALLER_BRANCH=%r but AIPASS_CALLER_CWD sits in branch %r "
"— resolved as %s from the env var. If this message is misattributed, the caller's cwd is why.",
caller_branch,
Expand Down Expand Up @@ -346,6 +375,39 @@ def detect_branch_from_pwd() -> Optional[Dict]:
return None


def _rooted(branch: Dict, registry_path: Path) -> Dict:
"""Return *branch* with its ``path`` made absolute against its OWN registry.

A registry row's ``path`` is relative to THE REGISTRY THAT HOLDS IT, and a
row handed back raw carries no memory of which registry answered. Every
consumer then joins it to the AIPass repo root — right for AIPass citizens
by coincidence, wrong for every project citizen.

Found live 2026-08-24 (@devpulse 10400b9b, measured by @baud): a projects/*
citizen read "Inbox is empty" with four unread messages in the file under
his feet, and `reply <id>` answered "Message not found" for an id read out
of that same file. ``projects/baud`` + ``src/baud/baud`` had been resolved
as ``<aipass>/src/baud/baud``.

IT FABRICATED RATHER THAN FAILING. The wrong path sits inside the AIPass
tree, so the mail lane CREATED it — a phantom .ai_mail.local/sent/ holding
the reply he believed he had sent, in a directory belonging to no citizen.
A refusal would have been loud; a confident wrong address was not.

Absolutising HERE, at the one place a registry is read, rather than at the
nine call sites that join a registry path: a consumer cannot re-derive a
root it was never given, and nine copies of that join is how they drift.
Rows that are already absolute are returned untouched.
"""
path = str(branch.get("path", ""))
if not path or Path(path).is_absolute():
return branch

rooted = dict(branch)
rooted["path"] = str((registry_path.parent / path).resolve())
return rooted


def _lookup_branch_by_name(branch_name: str) -> Optional[Dict]:
"""
Look up branch in the registry by name (case-insensitive).
Expand All @@ -368,7 +430,7 @@ def _lookup_branch_by_name(branch_name: str) -> Optional[Dict]:
registry = json.load(f)
for branch in _get_branches_list(registry):
if branch.get("name", "").lower() == name_lower:
return branch
return _rooted(branch, BRANCH_REGISTRY_PATH)
except Exception as e:
logger.warning("[identity] _lookup_branch_by_name(%s) failed: %s", branch_name, e)

Expand All @@ -380,7 +442,7 @@ def _lookup_branch_by_name(branch_name: str) -> Optional[Dict]:
registry = json.load(f)
for branch in _get_branches_list(registry):
if branch.get("name", "").lower() == name_lower:
return branch
return _rooted(branch, caller_registry)
except Exception as e:
logger.warning(
"[identity] _lookup_branch_by_name(%s) caller registry %s failed: %s", branch_name, caller_registry, e
Expand Down Expand Up @@ -443,7 +505,7 @@ def get_branch_info_from_registry(branch_path: Path) -> Optional[Dict]:
else:
reg_path = reg_path.resolve()
if reg_path == branch_path_resolved:
return branch
return _rooted(branch, BRANCH_REGISTRY_PATH)
except Exception as e:
logger.warning("[identity] get_branch_info_from_registry(%s) failed: %s", branch_path, e)

Expand All @@ -461,7 +523,7 @@ def get_branch_info_from_registry(branch_path: Path) -> Optional[Dict]:
else:
reg_path = reg_path.resolve()
if reg_path == branch_path_resolved:
return branch
return _rooted(branch, caller_registry)
except Exception as e:
logger.warning("[identity] get_branch_info_from_registry(%s) caller registry failed: %s", branch_path, e)

Expand Down
Loading