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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (779) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (799) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (236: 44 daemon_client + 19 conn-manager + 22 app-commands + 114 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
38 changes: 38 additions & 0 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,13 @@ async def serve(self) -> None:
self._scheduler = TaskScheduler(self.identity)
self._scheduler.load_and_start()

# Global cross-project session index (rant 2026-08-13T16:42:22):
# backfill the index from every on-disk session (registered projects +
# unregistered ones under ~/.emrg) so sessions created before this
# feature are discoverable by other projects. Best-effort — never
# crashes startup.
self._rebuild_sessions_index()

# Background deterministic skill update check (rant 2026-08-08T10:14:29):
# runs at startup + every 24h — refreshes managed skills to their
# latest GitHub releases. Never installs a CLI silently, never touches
Expand Down Expand Up @@ -361,6 +368,34 @@ def _assert_port_file(self, port: int) -> None:
mode=0o600,
)

def _rebuild_sessions_index(self) -> None:
"""Backfill the global cross-project session index at startup.

Rant 2026-08-13T16:42:22: sessions created before this feature (or in
projects not registered in projects.yml) would otherwise be invisible
to other projects. Best-effort — failures are logged at debug level
and never crash the daemon.
"""
from emrg.sessions_index import rebuild_sessions_index

try:
project_paths: list[str] = []
if self._projects_log.exists():
try:
data = yaml.safe_load(self._projects_log.read_text(encoding="utf-8"))
if isinstance(data, list):
project_paths = [
e.get("path", "")
for e in data
if isinstance(e, dict) and e.get("path")
]
except (yaml.YAMLError, OSError):
pass
count = rebuild_sessions_index(config_dir(), project_paths)
logger.info("sessions index rebuilt: %d sessions indexed", count)
except Exception:
logger.debug("sessions index rebuild failed", exc_info=True)

async def _skills_ttl_loop(self) -> None:
"""Background deterministic skill update check (startup + every 24h).

Expand Down Expand Up @@ -1042,6 +1077,9 @@ def _build_system_prompt(self, session: Session | None = None) -> str:
ctx["current_time"] = datetime.now().astimezone().isoformat(timespec="seconds")
ctx["os_name"] = platform.system()
ctx["platform_detail"] = platform.platform()
# Global config dir (~/.emrg) — injected so system.j2 can reference the
# cross-project sessions index and other global data files by path.
ctx["config_dir"] = str(config_dir())

# ── Working Directory ──
if session:
Expand Down
15 changes: 15 additions & 0 deletions emrg/server/prompts/system.j2
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,21 @@ Each line is a JSON record with `type`, `role`, `content`, `timestamp` fields.
Message records: `type=message`, tool calls: `type=tool_call`/`tool_result`, compacted summaries: `type=summary`.
{% endif %}

{% if config_dir %}
## Cross-Session Discovery (read other projects' sessions)

A global index of all sessions across all projects lives at `{{ config_dir }}/sessions_index.json`.
It maps `session_id` → absolute session directory path (one JSON object).

To learn what another project's session has been discussing:
1. read the index file to find the session's directory path
2. read `<session_dir>/meta.json` for basics (title, message_count, updated_at)
3. read `<session_dir>/history.jsonl` (or history_YYMMDD.jsonl) for the actual conversation
4. read `<session_dir>/memory/MEMORY.md` for that session's memory summary

Use this whenever the host asks you to read or understand another session's (or another project's) conversation.
{% endif %}

## Memory Management

After each response, briefly consider whether anything from this exchange should be remembered. If so, create or update a memory file in the appropriate memory directory.
Expand Down
8 changes: 8 additions & 0 deletions emrg/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from pathlib import Path

from emrg.memory import SessionMemoryStore
from emrg.sessions_index import remove_session_index, upsert_session_index

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -490,6 +491,11 @@ def _save_meta_with_title(self, title: str | None) -> None:
except (json.JSONDecodeError, OSError):
pass
self._meta_path.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
# Global cross-project index (rant 2026-08-13T16:42:22): record this
# session so other projects can locate it. Idempotent (no-op when the
# path is unchanged) and never raises — a failed index write must not
# break session creation or message persistence.
upsert_session_index(self.session_id, self._dir)

# ── Clear ──────────────────────────────────────────────────

Expand Down Expand Up @@ -526,6 +532,8 @@ def delete(session_id: str, cwd: Path) -> bool:
if session_dir.exists():
shutil.rmtree(session_dir)
logger.info("session deleted: %s", session_id)
# Global index (rant 2026-08-13T16:42:22): drop the deleted session.
remove_session_index(session_id)
return True
logger.warning("session not found for deletion: %s", session_id)
return False
Expand Down
181 changes: 181 additions & 0 deletions emrg/sessions_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Global cross-project session index (rant 2026-08-13T16:42:22).

Maintains a single JSON map ``session_id -> absolute session directory`` at
``~/.emrg/sessions_index.json`` so that any session (or the agent in any
session) can locate and read another project's conversation records.

Design (host-finalized, minimal index):
- The index stores ONLY the session_id → directory mapping. Everything else
(title, message_count, updated_at, history, memory) is read on demand from
the target session's meta.json / history.jsonl / memory/MEMORY.md.
- Write hooks live in ``Session._save_meta_with_title`` (create/append/compact/
rename/clear all funnel through it) and ``Session.delete``.
- A startup scan in the daemon (``rebuild_sessions_index``) backfills sessions
that predate this feature, including unregistered projects under ~/.emrg.
"""

from __future__ import annotations

import json
import logging
import os
import tempfile
from pathlib import Path

from emrg.config import config_dir

logger = logging.getLogger(__name__)

_INDEX_FILENAME = "sessions_index.json"

# Subtrees that never contain session dirs but are large/irrelevant — pruning
# them keeps the recursive ~/.emrg scan fast (a full Python dist under
# install/, git history, node_modules, etc. would dominate the walk).
_PRUNE_DIRS = {
"install", "updates", "logs", ".git", "node_modules", ".venv",
"__pycache__", "dist", "build", ".cache", "Cache",
}


def sessions_index_path() -> Path:
"""Return the global index file path (~/.emrg/sessions_index.json)."""
return config_dir() / _INDEX_FILENAME


def _load(index_path: Path) -> dict[str, str]:
"""Read the index; corrupt/missing file yields an empty dict (never raises)."""
if not index_path.exists():
return {}
try:
data = json.loads(index_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
logger.warning("corrupt sessions index %s — resetting", index_path)
return {}
if not isinstance(data, dict):
return {}
return {str(k): str(v) for k, v in data.items()}


def _write(data: dict[str, str], index_path: Path) -> None:
"""Atomically write the index (tmp file + os.replace); never raises."""
index_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
dir=str(index_path.parent), prefix=".sessions_index_", suffix=".tmp"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp_path, index_path)
except OSError:
logger.warning("failed to write sessions index %s", index_path, exc_info=True)
try:
os.unlink(tmp_path)
except OSError:
pass


def _upsert(session_id: str, session_dir: str, index_path: Path) -> None:
"""Idempotently set index[session_id] = session_dir (skip if unchanged)."""
data = _load(index_path)
if data.get(session_id) == session_dir:
return # already correct — avoid a redundant rewrite on every meta save
data[session_id] = session_dir
_write(data, index_path)


def upsert_session_index(session_id: str, session_dir: Path) -> None:
"""Record a session in the global index (write hook for Session meta saves)."""
_upsert(str(session_id), str(session_dir), sessions_index_path())


def _remove(session_id: str, index_path: Path) -> None:
data = _load(index_path)
if session_id in data:
del data[session_id]
_write(data, index_path)


def remove_session_index(session_id: str) -> None:
"""Remove a session from the global index (delete hook for Session.delete)."""
_remove(str(session_id), sessions_index_path())


def _read_meta_session_id(meta_path: Path) -> str | None:
"""Return the session_id from a meta.json, or None if missing/corrupt."""
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
sid = meta.get("session_id")
return str(sid) if sid else None


def _iter_project_sessions(project_path: Path):
"""Yield (session_id, session_dir) from <project>/.emrg/sessions/*/meta.json."""
sessions_dir = project_path / ".emrg" / "sessions"
if not sessions_dir.is_dir():
return
for entry in sorted(sessions_dir.iterdir()):
if not entry.is_dir():
continue
meta_path = entry / "meta.json"
if not meta_path.exists():
continue
sid = _read_meta_session_id(meta_path)
if sid:
yield sid, str(entry)


def _iter_nested_sessions(root: Path):
"""Yield (session_id, session_dir) for every <x>/.emrg/sessions under root.

Recursively walks ``root`` (pruning heavy subtrees) so unregistered
projects under ~/.emrg (e.g. ~/.emrg itself, ~/.emrg/source) are covered,
not just paths listed in projects.yml.
"""
for dirpath, dirnames, _ in os.walk(root, followlinks=False):
dirnames[:] = [d for d in dirnames if d not in _PRUNE_DIRS]
if os.path.basename(dirpath) == "sessions" and os.path.basename(
os.path.dirname(dirpath)
) == ".emrg":
sessions_dir = Path(dirpath)
for entry in sorted(sessions_dir.iterdir()):
if not entry.is_dir():
continue
meta_path = entry / "meta.json"
if not meta_path.exists():
continue
sid = _read_meta_session_id(meta_path)
if sid:
yield sid, str(entry)


def rebuild_sessions_index(
config_root: Path, project_paths: list[str] | None = None
) -> int:
"""Backfill the index from on-disk sessions (daemon startup scan).

Scans ``config_root`` recursively (covers ~/.emrg and anything nested under
it) plus each explicit project path (covers projects outside ~/.emrg), and
upserts every discovered session into ``config_root/sessions_index.json``.
Returns the number of distinct sessions indexed.
"""
index_path = config_root / _INDEX_FILENAME
data = _load(index_path)

found: dict[str, str] = {}
for sid, sdir in _iter_nested_sessions(config_root):
found[sid] = sdir
for p in project_paths or []:
if not p:
continue
try:
for sid, sdir in _iter_project_sessions(Path(p)):
found[sid] = sdir
except OSError:
continue

for sid, sdir in found.items():
data[sid] = sdir
_write(data, index_path)
return len(data)
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,21 @@ def guarded(data, path, **kwargs):

monkeypatch.setattr(sched_mod, "atomic_write_yaml", guarded)
monkeypatch.setattr(daemon_mod, "atomic_write_yaml", guarded)


@pytest.fixture(autouse=True)
def _redirect_sessions_index(monkeypatch, tmp_path):
"""Redirect the global session index to a per-test tmp file.

Rant 2026-08-13T16:42:22 added a global ~/.emrg/sessions_index.json that
Session._save_meta_with_title / Session.delete write to on every session
create/append/delete. Without redirection, the whole session test suite
would pollute the host's real index with pytest temp paths (same class as
the projects.yml leak guarded above).
"""
import emrg.sessions_index as sidx

monkeypatch.setattr(
sidx, "sessions_index_path",
lambda: tmp_path / "sessions_index.json",
)
Loading
Loading