From ba56646277508ebcd5b34780e7c786e3d9909b34 Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Thu, 20 Aug 2026 13:20:55 +0800 Subject: [PATCH 01/21] feat(memory): expose expert memory as an MCP server for external agents Add a memory MCP server (Streamable HTTP at /mcp/memory) so external agents (coding agents, bots) can read/write Octop expert memory directly, aligned with the in-process MemoryService capabilities. Tools (per expert, bound at connect time via X-Octop-Agent-Id header): - memory_recall(query, limit): full recall pipeline (tokenize + FTS + rerank), returns structured memories + rendered markdown - memory_save(content, source, topic?): persist a structured fact directly into the atom/tree (durable, no extraction) - memory_capture(content, source, session_id?): write an L0 raw event (extraction pipeline); visible immediately via memory_search_raw - memory_search_raw(query, limit): FTS-search L0 raw events (capture visible before extraction) - memory_update(atom_id, new_content, source): deprecate old atom + save new Auth: independent token via OCTOP_MEMORY_MCP_TOKEN (fail-closed if unset); authorization via Authorization: Bearer or X-Octop-Memory-Token. Implementation: - Lives in infra/agents/memory_mcp.py (no api-layer dependency; opens the agent Memory instance via open_memory_kwargs) - One FastMCP per agent, routed by X-Octop-Agent-Id header at /mcp/memory - DNS rebinding protection disabled (server runs behind a reverse proxy) - streamable_http task groups wired into the FastAPI lifespan Tests: tests/unit/agents/test_memory_mcp.py (tools, header routing, token middleware, unified mount). --- src/octop/api/app.py | 17 ++ src/octop/infra/agents/memory_mcp.py | 344 +++++++++++++++++++++++++++ tests/unit/agents/test_memory_mcp.py | 229 ++++++++++++++++++ 3 files changed, 590 insertions(+) create mode 100644 src/octop/infra/agents/memory_mcp.py create mode 100644 tests/unit/agents/test_memory_mcp.py diff --git a/src/octop/api/app.py b/src/octop/api/app.py index 14ba5e8f7..486e4c35e 100644 --- a/src/octop/api/app.py +++ b/src/octop/api/app.py @@ -233,6 +233,23 @@ async def acme_http01_challenge(token: str) -> PlainTextResponse: ], ) + # 专家记忆 MCP server(对外暴露,独立 token 鉴权,未配置 OCTOP_MEMORY_MCP_TOKEN 时不挂载) + from octop.infra.agents.memory_mcp import mount_memory_mcp + + memory_mcp_managers = mount_memory_mcp(app, server) + if memory_mcp_managers: + from contextlib import AsyncExitStack, asynccontextmanager + + @asynccontextmanager + async def _memory_mcp_lifespan(application: FastAPI): + # streamable_http_app 的 task group 依赖 lifespan,挂载后须手动并入 + async with AsyncExitStack() as stack: + for mgr in memory_mcp_managers: + await stack.enter_async_context(mgr.run()) + yield + + app.router.lifespan_context = _memory_mcp_lifespan + if enable_api_docs: @app.get("/api/docs", include_in_schema=False) diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py new file mode 100644 index 000000000..2a4a94cc4 --- /dev/null +++ b/src/octop/infra/agents/memory_mcp.py @@ -0,0 +1,344 @@ +"""Expose Octop expert memory as an MCP server for external agents. + +External agents (coding agents, bots) can read/write Octop expert memory over +MCP (Streamable HTTP), aligned with the in-process ``MemoryService`` +capabilities. Every write stamps a ``source`` marker that can be traced back +on recall. + +Expert binding: the endpoint is a single ``/mcp/memory`` mount; the expert is +selected at connect time via the ``X-Octop-Agent-Id`` header (one connection +binds one expert — the caller never passes an agent id per tool call). + +raw vs atom (aligned with ``MemoryService``): + +* ``memory_capture`` -> ``add_raw``: writes an **L0 raw event**, which goes + through the extraction pipeline (extract -> candidate -> promote -> atom). + Use it to record raw conversations / events. The record is visible + immediately via ``memory_search_raw``; ``memory_recall`` returns it only + after extraction promotes it to an atom. +* ``memory_save`` -> ``store``: persists a structured fact directly into the + canonical atom/tree (durable, no extraction). Use it when you already know + the exact fact to remember. + +Auth: independent token via ``OCTOP_MEMORY_MCP_TOKEN`` (fail-closed when +unset), enforced by the ASGI middleware in ``mount_memory_mcp``. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings + +from octop.infra.agents.memory_backend import open_memory_kwargs +from octop.infra.server import OctopServer + +logger = logging.getLogger(__name__) + + +def _open_memory(server: OctopServer, agent_id: str) -> Any: + """Open the agent's ``Memory`` instance (sqlite by default, postgres opt-in). + + Mirrors ``api.common.memory_client._open_memory_for_agent`` but stays in + ``infra/`` (no api dependency). Workspace is resolved from the agent + registry, falling back to the Octop default layout. + """ + from harness_memory.core import Memory # noqa: PLC0415 + + runtime = getattr(server, "app_runtime", None) + registry = getattr(runtime, "agent_registry", None) if runtime is not None else None + if registry is not None and hasattr(registry, "resolve_workspace_dir"): + workspace = registry.resolve_workspace_dir(agent_id) + else: + paths = getattr(server, "paths", None) or server.services.paths + workspace = paths.ensure_agent_workspace(agent_id) + + row = server.services.agent_repo.get(agent_id) + cfg: dict[str, Any] = {} + if row is not None and row.config_json: + import json # noqa: PLC0415 + + try: + parsed = json.loads(row.config_json) + if isinstance(parsed, dict): + cfg = parsed + except json.JSONDecodeError: + cfg = {} + + ns, backend, backend_config = open_memory_kwargs( + agent_id=agent_id, + cfg=cfg, + octop_config=server.services.config, + workspace_dir=workspace, + ) + return Memory(namespace=ns, backend=backend, backend_config=backend_config) + + +def build_memory_mcp(server: OctopServer, agent_id: str) -> FastMCP: + """Build an MCP server bound to one expert (``agent_id`` captured in closure).""" + mcp = FastMCP( + f"octop-memory-{agent_id}", + # Octop runs behind a reverse proxy (Host is the public domain, forwarded + # by nginx), not a localhost dev scenario — the mcp SDK's localhost + # DNS-rebinding protection does not apply and would reject the Host + # with 421 unless the domain is allow-listed. + transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), + ) + # Collapse the streamable-HTTP path to "/" so the endpoint is exactly + # /mcp/memory (the default "/mcp" would make it /mcp/memory/mcp). + mcp.settings.streamable_http_path = "/" + + def _memory(): + return _open_memory(server, agent_id) + + @mcp.tool() + def memory_recall(query: str, limit: int = 5) -> dict[str, Any]: + """Recall memories from this expert (aligned with the in-process recall_inject). + + Runs the full recall pipeline (tokenization -> FTS -> rerank -> dedupe) + and returns structured snippets plus a rendered markdown block ready to + inject into a system prompt. + + Args: + query: free-form question / keywords (pass the whole sentence; the + pipeline tokenizes CJK into n-grams internally). + limit: max number of snippets to return. + """ + from harness_memory.pipeline.recall import recall_for_prompt # noqa: PLC0415 + + memory = _memory() + result = recall_for_prompt(memory, query, limit=limit) + return { + "memories": [ + { + "source_id": s.source_id, + "timestamp": s.timestamp_iso, + "layer": s.layer, + "text": s.text, + } + for s in result.snippets + ], + "count": len(result.snippets), + "rendered": result.rendered, + } + + @mcp.tool() + def memory_save( + content: str, + source: str, + topic: str | None = None, + ) -> dict[str, Any]: + """Persist a structured fact directly (atom/tree, durable, no extraction). + + Use this when you already know the exact fact to remember — it is + immediately recallable via ``memory_recall``. The source marker is + stored in ``metadata.source``. + + Args: + content: the fact to remember. + source: who/what recorded it (e.g. "coding-agent"), for traceability. + topic: optional topic label. + """ + memory = _memory() + node = memory.store(content, topic=topic, metadata={"source": source}) + return {"node_id": node.id, "content": node.content, "source": source} + + @mcp.tool() + def memory_capture(content: str, source: str, session_id: str | None = None) -> dict[str, Any]: + """Record a raw event to L0 (goes through extraction: extract -> candidate -> atom). + + Use this to record raw conversations / events that the extraction + pipeline will later distill into atoms. The record is NOT immediately + recallable via ``memory_recall`` (that reads atoms); query it right + away with ``memory_search_raw``. The source marker is stored in + ``payload.source``. + + Example:: + + memory_capture( + content="user reported: the report panel banner is not rendering", + source="review-bot", + session_id="review-2026-08-20", + ) + # -> {"event_id": "...", "recorded": true, ...} + # later: memory_recall(query="report panel banner not rendering") + + Args: + content: the raw conversation / event text. + source: who/what recorded it, for traceability. + session_id: optional stable session id (e.g. caller name) so the + extraction pipeline can group events by session. + """ + memory = _memory() + raw = memory.add_raw( + content, + event_type="manual", + host="mcp-external", + session_id=session_id, + payload={"source": source}, + ) + return { + "event_id": raw.id, + "source": source, + "recorded": True, + "note": ( + "raw (L0) event recorded; visible now via memory_search_raw, " + "recallable via memory_recall after the extraction pipeline " + "promotes it to an atom" + ), + } + + @mcp.tool() + def memory_search_raw(query: str, limit: int = 10) -> dict[str, Any]: + """FTS-search L0 raw events of this expert (capture visible immediately). + + Unlike ``memory_recall`` (which reads atoms), this searches the raw + event layer, so records written by ``memory_capture`` are visible right + away, before extraction promotes them. + + Args: + query: keywords to match against raw event content. + limit: max number of events to return. + """ + memory = _memory() + events = memory.search_raw(query, limit=limit) + return { + "events": [ + { + "event_id": e.id, + "timestamp": e.timestamp.isoformat(), + "session_id": e.session_id, + "user": e.user, + "source": (e.payload or {}).get("source") if e.payload else None, + "content": e.content, + } + for e in events + ], + "count": len(events), + } + + @mcp.tool() + def memory_update( + atom_id: str, + new_content: str, + source: str, + note: str = "mcp update", + ) -> dict[str, Any]: + """Update a memory: deprecate the old atom and persist the new fact. + + Args: + atom_id: id of the atom to supersede. + new_content: the replacement fact. + source: who/what updated it, for traceability. + note: deprecation note. + """ + memory = _memory() + deprecated = memory.deprecate_atom(atom_id, actor="user", note=note) + node = memory.store(new_content, metadata={"source": source, "supersedes": atom_id}) + return { + "deprecated": deprecated, + "deprecated_atom_id": atom_id, + "new_node_id": node.id, + "source": source, + } + + return mcp + + +def _memory_mcp_token() -> str | None: + """Read the MCP auth token (empty string treated as unconfigured).""" + return (os.environ.get("OCTOP_MEMORY_MCP_TOKEN") or "").strip() or None + + +class _TokenAuthMiddleware: + """ASGI middleware enforcing ``Authorization: Bearer`` or ``X-Octop-Memory-Token``.""" + + def __init__(self, app: Any, token: str) -> None: + self._app = app + self._token = token + + async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None: + if scope.get("type") != "http": + await self._app(scope, receive, send) + return + + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} + auth = headers.get("authorization", "") + provided = auth[7:].strip() if auth.startswith("Bearer ") else "" + if not provided: + provided = headers.get("x-octop-memory-token", "").strip() + + if provided != self._token: + body = b'{"error":"unauthorized"}' + await send({ + "type": "http.response.start", + "status": 401, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + }) + await send({"type": "http.response.body", "body": body}) + return + + await self._app(scope, receive, send) + + +class _AgentRouter: + """ASGI dispatcher routing to the per-expert MCP app by ``X-Octop-Agent-Id`` header.""" + + def __init__(self, mcp_apps: dict[str, Any]) -> None: + self._mcp_apps = mcp_apps + + async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None: + if scope.get("type") != "http": + return # lifespan is wired into the host FastAPI manually; http only here + + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} + agent_id = headers.get("x-octop-agent-id", "").strip() + target = self._mcp_apps.get(agent_id) + if target is None: + body = b'{"error":"missing or unknown agent_id (X-Octop-Agent-Id)"}' + await send({ + "type": "http.response.start", + "status": 404, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + }) + await send({"type": "http.response.body", "body": body}) + return + await target(scope, receive, send) + + +def mount_memory_mcp(app: Any, server: OctopServer) -> list[Any]: + """Mount the memory MCP endpoint at ``/mcp/memory``; the expert is selected + per connection via the ``X-Octop-Agent-Id`` header (one connection binds one + expert; the URL stays uniform and does not leak expert ids). + + Does not mount when ``OCTOP_MEMORY_MCP_TOKEN`` is unset (fail-closed). + Returns the session managers that must be initialized in the host FastAPI + lifespan (``streamable_http_app`` task groups depend on it). + """ + token = _memory_mcp_token() + if token is None: + return [] + + managers: list[Any] = [] + mcp_apps: dict[str, Any] = {} + rows = server.services.agent_repo.list_all(include_disabled=False) + for row in rows: + agent_id = row.agent_id + mcp = build_memory_mcp(server, agent_id) + mcp_apps[agent_id] = mcp.streamable_http_app() + managers.append(mcp._session_manager) + + app.mount("/mcp/memory", _TokenAuthMiddleware(_AgentRouter(mcp_apps), token)) + return managers + + +__all__ = ["build_memory_mcp", "mount_memory_mcp"] diff --git a/tests/unit/agents/test_memory_mcp.py b/tests/unit/agents/test_memory_mcp.py new file mode 100644 index 000000000..1be2924c6 --- /dev/null +++ b/tests/unit/agents/test_memory_mcp.py @@ -0,0 +1,229 @@ +"""Unit tests for the expert memory MCP server (infra/agents/memory_mcp).""" + +from __future__ import annotations + +from unittest import mock + +import pytest + +from octop.infra.agents import memory_mcp as mm + + +@pytest.fixture +def fake_memory(monkeypatch): + mem = mock.MagicMock() + mem.recall.return_value = [] + node = mock.MagicMock() + node.id = "node1" + node.content = "remember X" + mem.store.return_value = node + mem.add_raw.return_value = mock.MagicMock(id="evt1") + mem.deprecate_atom.return_value = True + monkeypatch.setattr(mm, "_open_memory", lambda server, agent_id: mem) + return mem + + +def _tools(mcp): + return mcp._tool_manager._tools + + +def test_build_binds_agent_id(monkeypatch): + """Tools capture agent_id in the closure; callers never pass it.""" + captured = {} + + def fake_open(server, agent_id): + captured["agent_id"] = agent_id + mem = mock.MagicMock() + mem.store.return_value = mock.MagicMock(id="n1", content="x") + return mem + + monkeypatch.setattr(mm, "_open_memory", fake_open) + mcp = mm.build_memory_mcp(mock.MagicMock(), agent_id="EXPERT42") + _tools(mcp)["memory_save"].fn(content="x", source="s") + assert captured["agent_id"] == "EXPERT42" + + +def test_build_registers_five_tools(fake_memory): + mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") + assert set(_tools(mcp)) == { + "memory_recall", + "memory_save", + "memory_capture", + "memory_update", + "memory_search_raw", + } + + +def test_memory_recall_uses_full_pipeline(fake_memory, monkeypatch): + """memory_recall runs the full recall pipeline (recall_for_prompt).""" + import harness_memory.pipeline.recall as _recall + + class _Snippet: + source_id = "atom-1" + timestamp_iso = "2026-08-19T00:00:00+00:00" + layer = "atom" + text = "billing-migration is the local clone" + + fake_result = mock.MagicMock() + fake_result.snippets = [_Snippet()] + fake_result.rendered = "markdown" + monkeypatch.setattr(_recall, "recall_for_prompt", lambda m, q, limit: fake_result) + + mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") + result = _tools(mcp)["memory_recall"].fn(query="billing-migration", limit=3) + assert result["count"] == 1 + assert result["memories"][0]["text"] == "billing-migration is the local clone" + assert result["rendered"] == "markdown" + + +def test_memory_save_goes_store(fake_memory): + mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") + result = _tools(mcp)["memory_save"].fn(content="remember X", source="coding-agent") + kwargs = fake_memory.store.call_args.kwargs + assert kwargs["topic"] is None + assert kwargs["metadata"] == {"source": "coding-agent"} + assert result["source"] == "coding-agent" + + +def test_memory_capture_goes_add_raw(fake_memory): + mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") + result = _tools(mcp)["memory_capture"].fn( + content="raw conversation", source="review-bot", session_id="review-1" + ) + kwargs = fake_memory.add_raw.call_args.kwargs + assert kwargs["event_type"] == "manual" + assert kwargs["host"] == "mcp-external" + assert kwargs["session_id"] == "review-1" + assert kwargs["payload"] == {"source": "review-bot"} + assert result["recorded"] is True + assert "raw (L0)" in result["note"] + + +def test_memory_search_raw_queries_l0(fake_memory): + class _Evt: + id = "evt1" + timestamp = __import__("datetime").datetime(2026, 8, 19) + session_id = "review-1" + user = "u1" + payload = {"source": "review-bot"} + content = "report panel banner hidden" + + fake_memory.search_raw.return_value = [_Evt()] + mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") + result = _tools(mcp)["memory_search_raw"].fn(query="report panel banner", limit=5) + fake_memory.search_raw.assert_called_once_with("report panel banner", limit=5) + assert result["count"] == 1 + assert result["events"][0]["event_id"] == "evt1" + assert result["events"][0]["source"] == "review-bot" + + +def test_memory_update_deprecates_and_saves(fake_memory): + mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") + result = _tools(mcp)["memory_update"].fn( + atom_id="atom1", new_content="new fact", source="review-bot" + ) + fake_memory.deprecate_atom.assert_called_once_with("atom1", actor="user", note="mcp update") + assert fake_memory.store.call_args.kwargs["metadata"] == { + "source": "review-bot", + "supersedes": "atom1", + } + assert result["deprecated"] is True + + +def _asgi_scope(headers: list[tuple[bytes, bytes]] | None = None) -> dict: + return {"type": "http", "headers": headers or []} + + +@pytest.mark.asyncio +async def test_token_middleware_rejects_bad_token(): + inner_called = False + + async def _inner(scope, receive, send): + nonlocal inner_called + inner_called = True + + mw = mm._TokenAuthMiddleware(_inner, "secret") + sent = [] + scope = _asgi_scope([(b"authorization", b"Bearer wrong")]) + + async def _send(msg): + sent.append(msg) + + await mw(scope, lambda: {}, _send) + assert inner_called is False + assert sent[0]["status"] == 401 + + +@pytest.mark.asyncio +async def test_token_middleware_accepts_bearer(): + inner_called = False + + async def _inner(scope, receive, send): + nonlocal inner_called + inner_called = True + + mw = mm._TokenAuthMiddleware(_inner, "secret") + scope = _asgi_scope([(b"authorization", b"Bearer secret")]) + await mw(scope, lambda: {}, lambda msg: None) + assert inner_called is True + + +def test_mount_fail_closed_without_token(monkeypatch): + monkeypatch.delenv("OCTOP_MEMORY_MCP_TOKEN", raising=False) + app = mock.MagicMock() + assert mm.mount_memory_mcp(app, mock.MagicMock()) == [] + app.mount.assert_not_called() + + +def test_mount_unified_path_with_header_router(monkeypatch): + from types import SimpleNamespace + + monkeypatch.setenv("OCTOP_MEMORY_MCP_TOKEN", "secret") + app = mock.MagicMock() + server = SimpleNamespace( + services=SimpleNamespace( + agent_repo=mock.MagicMock( + list_all=lambda include_disabled: [ + SimpleNamespace(agent_id="A1"), + SimpleNamespace(agent_id="A2"), + ] + ) + ) + ) + managers = mm.mount_memory_mcp(app, server) + assert len(managers) == 2 + # unified path mounted exactly once + app.mount.assert_called_once() + assert app.mount.call_args.args[0] == "/mcp/memory" + + +@pytest.mark.asyncio +async def test_agent_router_routes_by_header(): + """_AgentRouter routes to the right app by X-Octop-Agent-Id header.""" + called = {} + + class _FakeApp: + def __init__(self, aid): + self._aid = aid + + async def __call__(self, scope, receive, send): + called["agent"] = self._aid + + router = mm._AgentRouter({"A1": _FakeApp("A1"), "A2": _FakeApp("A2")}) + scope = {"type": "http", "headers": [(b"x-octop-agent-id", b"A2")]} + await router(scope, lambda: {}, lambda msg: None) + assert called["agent"] == "A2" + + +@pytest.mark.asyncio +async def test_agent_router_404_unknown_agent(): + """Unknown agent_id returns 404.""" + router = mm._AgentRouter({"A1": mock.MagicMock()}) + scope = {"type": "http", "headers": [(b"x-octop-agent-id", b"NOPE")]} + sent = [] + + async def _send(msg): + sent.append(msg) + + await router(scope, lambda: {}, _send) + assert sent[0]["status"] == 404 From 004714a30dfa538c2026a63a7eb2fe1766524910 Mon Sep 17 00:00:00 2001 From: jinlongqi Date: Sun, 23 Aug 2026 11:34:43 +0800 Subject: [PATCH 02/21] style: format memory_mcp.py with ruff --- src/octop/infra/agents/memory_mcp.py | 44 ++++++++++++++++------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py index 2a4a94cc4..0cd9152d8 100644 --- a/src/octop/infra/agents/memory_mcp.py +++ b/src/octop/infra/agents/memory_mcp.py @@ -265,7 +265,9 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None await self._app(scope, receive, send) return - headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} + headers = { + k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", []) + } auth = headers.get("authorization", "") provided = auth[7:].strip() if auth.startswith("Bearer ") else "" if not provided: @@ -273,14 +275,16 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None if provided != self._token: body = b'{"error":"unauthorized"}' - await send({ - "type": "http.response.start", - "status": 401, - "headers": [ - (b"content-type", b"application/json"), - (b"content-length", str(len(body)).encode()), - ], - }) + await send( + { + "type": "http.response.start", + "status": 401, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + } + ) await send({"type": "http.response.body", "body": body}) return @@ -297,19 +301,23 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None if scope.get("type") != "http": return # lifespan is wired into the host FastAPI manually; http only here - headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} + headers = { + k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", []) + } agent_id = headers.get("x-octop-agent-id", "").strip() target = self._mcp_apps.get(agent_id) if target is None: body = b'{"error":"missing or unknown agent_id (X-Octop-Agent-Id)"}' - await send({ - "type": "http.response.start", - "status": 404, - "headers": [ - (b"content-type", b"application/json"), - (b"content-length", str(len(body)).encode()), - ], - }) + await send( + { + "type": "http.response.start", + "status": 404, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + } + ) await send({"type": "http.response.body", "body": body}) return await target(scope, receive, send) From fea4c5976d79bdd8696758a8abb99f1f063c3c4e Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Sun, 23 Aug 2026 12:56:29 +0800 Subject: [PATCH 03/21] fix: resolve mypy type errors in memory_mcp.py - Add assert for server.services to satisfy mypy strict mode - Add return type annotation to _memory closure --- PR.md | 113 +++++++++++++++++++++++++++ src/octop/infra/agents/memory_mcp.py | 17 ++-- 2 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 PR.md diff --git a/PR.md b/PR.md new file mode 100644 index 000000000..d843eb444 --- /dev/null +++ b/PR.md @@ -0,0 +1,113 @@ +# PR Title + +feat(memory): expose expert memory as an MCP server for external agents + +--- + +## Summary + +Adds a memory **MCP server** (Streamable HTTP at `/mcp/memory`) so external +agents (coding agents, bots, other AI tools) can directly **read / write / +update Octop expert memory**, aligned 1:1 with the in-process +`MemoryService` capabilities. Every write stamps a `source` marker that is +traceable on recall. + +## Why + +Octop experts accumulate rich memory (facts, conversations, decisions), but +today only the Octop dashboard / in-process agent can access it. External +agents that need to reuse that expertise (e.g. a coding agent asking a +business expert's accumulated knowledge) have no way in. This PR exposes the +same memory surface over the standard MCP protocol so any MCP-capable agent +can join the loop. + +## What + +- **New module** `src/octop/infra/agents/memory_mcp.py` — FastMCP server + bound to one expert per connection, plus token auth and header routing. +- **Mount** in `api/app.py` (`build_app`) at `/mcp/memory`, with + `streamable_http` task groups wired into the FastAPI lifespan. +- **Tests** `tests/unit/agents/test_memory_mcp.py` (13 tests). + +### Tools + +| Tool | Purpose | Backing API | +|------|---------|-------------| +| `memory_recall(query, limit=5)` | Recall memories (full pipeline: tokenize → FTS → rerank → dedupe); returns structured snippets + rendered markdown | `recall_for_prompt` | +| `memory_save(content, source, topic?)` | Persist a structured fact directly into the atom/tree (durable, no extraction) | `Memory.store` | +| `memory_capture(content, source, session_id?)` | Write an **L0 raw event** (goes through extraction); visible immediately via `memory_search_raw` | `Memory.add_raw` | +| `memory_search_raw(query, limit=10)` | FTS-search L0 raw events (capture visible before extraction) | `Memory.search_raw` | +| `memory_update(atom_id, new_content, source)` | Deprecate old atom + persist the new fact | `deprecate_atom` + `store` | + +### Expert binding & auth + +- **One connection binds one expert**: endpoint is a single `/mcp/memory`; + the expert is selected at connect time via the `X-Octop-Agent-Id` header — + callers never pass an agent id per tool call (they don't know the id list). +- **Auth**: independent token via `OCTOP_MEMORY_MCP_TOKEN` (fail-closed when + unset). Authorization via `Authorization: Bearer` or `X-Octop-Memory-Token`. + +### raw vs atom (for callers) + +- `memory_capture` → **L0 raw event** (evidence layer), distilled later by + the extraction pipeline (`extract → candidate → promote → atom`). Use it to + record raw conversations/events; the record is visible immediately via + `memory_search_raw` and recallable via `memory_recall` once promoted. +- `memory_save` → **atom/tree directly** (durable, no extraction). Use it + when the fact is already known. + +## Implementation notes + +- Lives in `infra/agents/` with no api-layer dependency: opens the agent + `Memory` instance via `open_memory_kwargs` + `Memory(...)` (workspace + resolved from the agent registry). +- DNS rebinding protection disabled (`TransportSecuritySettings`) because + Octop runs behind a reverse proxy (Host is the public domain, not localhost). +- `streamable_http_path` collapsed to `/` so the endpoint is exactly + `/mcp/memory` (the SDK default `/mcp` would yield `/mcp/memory/mcp`). +- One `FastMCP` per expert, routed by an ASGI dispatcher on the + `X-Octop-Agent-Id` header; missing/unknown agent → 404. + +## Usage example + +```bash +export OCTOP_MEMORY_MCP_TOKEN="" +``` + +```json +{ + "mcpServers": { + "octop-memory": { + "type": "streamable_http", + "url": "http:///mcp/memory/", + "headers": { + "Authorization": "Bearer ", + "X-Octop-Agent-Id": "" + } + } + } +} +``` + +```text +memory_recall(query="what are the key project decisions?") +memory_save(content="the release window is every Tuesday", source="coding-agent", topic="release") +memory_capture(content="user reported: the report panel banner is not rendering", source="review-bot", session_id="review-2026-08-20") +memory_search_raw(query="report panel banner") +memory_update(atom_id="atom_xxx", new_content="updated fact", source="coding-agent") +``` + +## Testing + +- `tests/unit/agents/test_memory_mcp.py` — 13 tests: tool registration, + recall pipeline, capture (raw) semantics, search_raw, update, token + middleware (401 / accept), header routing, 404 unknown agent, unified mount. +- Verified locally by booting the server and exercising the MCP endpoints: + health, 401 without token, `initialize` (binds expert via header), + `tools/list` (5 tools), `tools/call memory_recall`. + +## Checklist + +- [x] No internal/hard-coded environment-specific values in the diff +- [x] `make lint` clean (ruff) +- [x] Unit tests pass diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py index 0cd9152d8..931208ec4 100644 --- a/src/octop/infra/agents/memory_mcp.py +++ b/src/octop/infra/agents/memory_mcp.py @@ -53,10 +53,14 @@ def _open_memory(server: OctopServer, agent_id: str) -> Any: if registry is not None and hasattr(registry, "resolve_workspace_dir"): workspace = registry.resolve_workspace_dir(agent_id) else: - paths = getattr(server, "paths", None) or server.services.paths + services = server.services + assert services is not None, "server.services required when agent_registry unavailable" + paths = getattr(server, "paths", None) or services.paths workspace = paths.ensure_agent_workspace(agent_id) - row = server.services.agent_repo.get(agent_id) + services = server.services + assert services is not None, "server.services required for memory backend" + row = services.agent_repo.get(agent_id) cfg: dict[str, Any] = {} if row is not None and row.config_json: import json # noqa: PLC0415 @@ -71,7 +75,7 @@ def _open_memory(server: OctopServer, agent_id: str) -> Any: ns, backend, backend_config = open_memory_kwargs( agent_id=agent_id, cfg=cfg, - octop_config=server.services.config, + octop_config=services.config, workspace_dir=workspace, ) return Memory(namespace=ns, backend=backend, backend_config=backend_config) @@ -91,7 +95,7 @@ def build_memory_mcp(server: OctopServer, agent_id: str) -> FastMCP: # /mcp/memory (the default "/mcp" would make it /mcp/memory/mcp). mcp.settings.streamable_http_path = "/" - def _memory(): + def _memory() -> Any: return _open_memory(server, agent_id) @mcp.tool() @@ -336,9 +340,12 @@ def mount_memory_mcp(app: Any, server: OctopServer) -> list[Any]: if token is None: return [] + services = server.services + assert services is not None, "server.services required for memory MCP mount" + managers: list[Any] = [] mcp_apps: dict[str, Any] = {} - rows = server.services.agent_repo.list_all(include_disabled=False) + rows = services.agent_repo.list_all(include_disabled=False) for row in rows: agent_id = row.agent_id mcp = build_memory_mcp(server, agent_id) From 0e7eec8c0054891ed033905047af704846c9ffb6 Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Wed, 26 Aug 2026 13:48:59 +0800 Subject: [PATCH 04/21] =?UTF-8?q?feat(memory-mcp):=20=E5=86=85=E7=BD=91?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E2=80=94=E2=80=94=E8=B0=83=E7=94=A8=E8=80=85?= =?UTF-8?q?=20user=20=E8=BF=BD=E6=BA=AF=20+=20=E8=87=AA=E5=8A=A8=E6=8F=90?= =?UTF-8?q?=E5=8F=96=20+=20=E5=B7=A5=E5=85=B7=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于社区 feat/memory-mcp-server (fea4c59) 的独立扩展分支,仅改 memory_mcp.py: - memory_capture 写 L0 后自动触发提取流水线(extract -> promote -> atom) - 缺省 session 派生 ext:{source}:{user},外部调用无需 octop 原生 session - X-Octop-User-Id header / user 参数,全部工具支持调用者追溯 - 工具描述区分日常(recall/capture)与显式(save/update) - stateless HTTP + mypy strict 修复 --- src/octop/infra/agents/memory_mcp.py | 263 +++++++++++++++++++++------ 1 file changed, 208 insertions(+), 55 deletions(-) diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py index 931208ec4..fb56a508b 100644 --- a/src/octop/infra/agents/memory_mcp.py +++ b/src/octop/infra/agents/memory_mcp.py @@ -28,9 +28,11 @@ import logging import os +from contextvars import ContextVar from typing import Any from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.server import Context from mcp.server.transport_security import TransportSecuritySettings from octop.infra.agents.memory_backend import open_memory_kwargs @@ -38,6 +40,11 @@ logger = logging.getLogger(__name__) +# 当前 MCP HTTP 请求的调用者 user id(由 _AgentRouter 中间件写入,工具读取)。 +# stateless streamable HTTP 下 mcp SDK 不提供 ctx.request_context,故用 contextvar +# 跨 ASGI 中间件 → 工具传递,供 memory_capture/save 做 per-user 追溯。 +_current_caller_user: ContextVar[str] = ContextVar("octop_mcp_caller_user", default="") + def _open_memory(server: OctopServer, agent_id: str) -> Any: """Open the agent's ``Memory`` instance (sqlite by default, postgres opt-in). @@ -48,18 +55,16 @@ def _open_memory(server: OctopServer, agent_id: str) -> Any: """ from harness_memory.core import Memory # noqa: PLC0415 + services = server.services + assert services is not None, "server.services required for memory backend" runtime = getattr(server, "app_runtime", None) registry = getattr(runtime, "agent_registry", None) if runtime is not None else None if registry is not None and hasattr(registry, "resolve_workspace_dir"): workspace = registry.resolve_workspace_dir(agent_id) else: - services = server.services - assert services is not None, "server.services required when agent_registry unavailable" paths = getattr(server, "paths", None) or services.paths workspace = paths.ensure_agent_workspace(agent_id) - services = server.services - assert services is not None, "server.services required for memory backend" row = services.agent_repo.get(agent_id) cfg: dict[str, Any] = {} if row is not None and row.config_json: @@ -85,6 +90,13 @@ def build_memory_mcp(server: OctopServer, agent_id: str) -> FastMCP: """Build an MCP server bound to one expert (``agent_id`` captured in closure).""" mcp = FastMCP( f"octop-memory-{agent_id}", + # Stateless streamable HTTP: every request gets a fresh transport, no + # Mcp-Session-Id tracking. Session state is in-memory per process, so a + # server restart silently orphans every client session id and the next + # tool call fails with -32600 "Session not found". Stateless mode + # eliminates that failure class entirely (clients re-initialize per + # request); the cost is one extra initialize per tool call. + stateless_http=True, # Octop runs behind a reverse proxy (Host is the public domain, forwarded # by nginx), not a localhost dev scenario — the mcp SDK's localhost # DNS-rebinding protection does not apply and would reject the Host @@ -98,21 +110,55 @@ def build_memory_mcp(server: OctopServer, agent_id: str) -> FastMCP: def _memory() -> Any: return _open_memory(server, agent_id) + def _caller_user(ctx: Any | None) -> str: + """读取当前 MCP 请求的调用者 user id。 + + 优先级:显式 ``user`` 参数 → ``X-Octop-User-Id`` header(由 + ``_AgentRouter`` 中间件写入 contextvar)。stateless HTTP 下 mcp SDK + 不提供 ``ctx.request_context``,故不依赖它。 + """ + try: + return _current_caller_user.get() or "" + except Exception: # noqa: BLE001 + return "" + + def _derive_session(source: str, user: str) -> str: + """外部调用缺省 session_id 时派生稳定会话键。 + + 规则 ``ext:{source}:{user}``:同 source 同 user 的多次 capture 落入 + 同一分组,harness 提取管线能聚合蒸馏成 atom;不同 source / 不同 user + 分开分组,避免混入彼此上下文。 + """ + return f"ext:{source or 'mcp'}:{user or 'anon'}" + + @mcp.tool() - def memory_recall(query: str, limit: int = 5) -> dict[str, Any]: + def memory_recall( + query: str, + limit: int = 5, + user: str | None = None, + ctx: Context | None = None, # type: ignore[type-arg] + ) -> dict[str, Any]: """Recall memories from this expert (aligned with the in-process recall_inject). - Runs the full recall pipeline (tokenization -> FTS -> rerank -> dedupe) - and returns structured snippets plus a rendered markdown block ready to - inject into a system prompt. + **日常使用**:每次对话/任务开始前调用,把专家记忆中与 query 相关的 + atom 召回注入上下文。运行完整召回管线(tokenize -> FTS -> rerank -> + dedupe),返回结构化片段 + 可注入 system prompt 的 markdown 块。 + + 调用者身份(``X-Octop-User-Id`` header 或 ``user`` 参数)会记录在 + 返回的 ``caller`` 字段,供按调用者追溯召回来源;记忆本身是专家级 + 共享,不按用户隔离。 Args: query: free-form question / keywords (pass the whole sentence; the pipeline tokenizes CJK into n-grams internally). limit: max number of snippets to return. + user: optional caller id (overrides the ``X-Octop-User-Id`` header). + ctx: injected MCP context (reads ``X-Octop-User-Id`` header). """ from harness_memory.pipeline.recall import recall_for_prompt # noqa: PLC0415 + caller = user or _caller_user(ctx) memory = _memory() result = recall_for_prompt(memory, query, limit=limit) return { @@ -127,6 +173,7 @@ def memory_recall(query: str, limit: int = 5) -> dict[str, Any]: ], "count": len(result.snippets), "rendered": result.rendered, + "caller": caller or None, } @mcp.tool() @@ -134,60 +181,96 @@ def memory_save( content: str, source: str, topic: str | None = None, + user: str | None = None, + ctx: Context | None = None, # type: ignore[type-arg] ) -> dict[str, Any]: """Persist a structured fact directly (atom/tree, durable, no extraction). - Use this when you already know the exact fact to remember — it is - immediately recallable via ``memory_recall``. The source marker is - stored in ``metadata.source``. + **显式记忆**(非日常):仅当你知道一个明确的、需要长期记住的事实 + 时才调用(如用户偏好、项目约定)。立即通过 ``memory_recall`` 可召回, + 不经过提取管线。日常对话内容请用 ``memory_capture`` 交给自动提取。 + The source marker is stored in ``metadata.source``; the caller id (from + ``X-Octop-User-Id`` header or ``user`` arg) is stored in ``metadata.user``. Args: content: the fact to remember. source: who/what recorded it (e.g. "coding-agent"), for traceability. topic: optional topic label. + user: optional caller id (overrides the ``X-Octop-User-Id`` header). + ctx: injected MCP context (reads ``X-Octop-User-Id`` header). """ + caller = user or _caller_user(ctx) memory = _memory() - node = memory.store(content, topic=topic, metadata={"source": source}) - return {"node_id": node.id, "content": node.content, "source": source} + node = memory.store( + content, + topic=topic, + metadata={"source": source, **({"user": caller} if caller else {})}, + ) + return { + "node_id": node.id, + "content": node.content, + "source": source, + "user": caller or None, + } @mcp.tool() - def memory_capture(content: str, source: str, session_id: str | None = None) -> dict[str, Any]: + def memory_capture( + content: str, + source: str, + session_id: str | None = None, + user: str | None = None, + ctx: Context | None = None, # type: ignore[type-arg] + ) -> dict[str, Any]: """Record a raw event to L0 (goes through extraction: extract -> candidate -> atom). - Use this to record raw conversations / events that the extraction - pipeline will later distill into atoms. The record is NOT immediately - recallable via ``memory_recall`` (that reads atoms); query it right - away with ``memory_search_raw``. The source marker is stored in - ``payload.source``. + **日常使用**:把对话/事件原始内容记录下来,交给自动提取流水线 + (extract -> candidate -> promote -> atom),稍后经 ``memory_recall`` + 可召回。记录后立即可用 ``memory_search_raw`` 查询。The source marker + is stored in ``payload.source``; the caller id (from ``X-Octop-User-Id`` + header or ``user`` arg) is stored on the raw event for per-user + traceability. + + If ``session_id`` is omitted it is derived as ``ext:{source}:{user}`` + so external callers without an Octop native session still get their raw + events grouped and distilled into atoms (extraction groups by session). Example:: memory_capture( content="user reported: the report panel banner is not rendering", source="review-bot", - session_id="review-2026-08-20", ) - # -> {"event_id": "...", "recorded": true, ...} + # -> {"event_id": "...", "recorded": true, "extract_scheduled": true, ...} # later: memory_recall(query="report panel banner not rendering") Args: content: the raw conversation / event text. source: who/what recorded it, for traceability. session_id: optional stable session id (e.g. caller name) so the - extraction pipeline can group events by session. + extraction pipeline can group events by session. When omitted, + derived as ``ext:{source}:{user}``. + user: optional caller id (overrides the ``X-Octop-User-Id`` header). + ctx: injected MCP context (reads ``X-Octop-User-Id`` header). """ + caller = user or _caller_user(ctx) + effective_session = session_id or _derive_session(source, caller) memory = _memory() raw = memory.add_raw( content, event_type="manual", host="mcp-external", - session_id=session_id, + session_id=effective_session, + user=caller or None, payload={"source": source}, ) + extract_scheduled = _trigger_extract(server, agent_id, effective_session) return { "event_id": raw.id, "source": source, + "user": caller or None, + "session_id": effective_session, "recorded": True, + "extract_scheduled": extract_scheduled, "note": ( "raw (L0) event recorded; visible now via memory_search_raw, " "recallable via memory_recall after the extraction pipeline " @@ -196,7 +279,12 @@ def memory_capture(content: str, source: str, session_id: str | None = None) -> } @mcp.tool() - def memory_search_raw(query: str, limit: int = 10) -> dict[str, Any]: + def memory_search_raw( + query: str, + limit: int = 10, + user: str | None = None, + ctx: Context | None = None, # type: ignore[type-arg] + ) -> dict[str, Any]: """FTS-search L0 raw events of this expert (capture visible immediately). Unlike ``memory_recall`` (which reads atoms), this searches the raw @@ -206,7 +294,11 @@ def memory_search_raw(query: str, limit: int = 10) -> dict[str, Any]: Args: query: keywords to match against raw event content. limit: max number of events to return. + user: optional caller id (overrides the ``X-Octop-User-Id`` header); + returned in ``caller`` for per-caller traceability. + ctx: injected MCP context (reads ``X-Octop-User-Id`` header). """ + caller = user or _caller_user(ctx) memory = _memory() events = memory.search_raw(query, limit=limit) return { @@ -222,6 +314,7 @@ def memory_search_raw(query: str, limit: int = 10) -> dict[str, Any]: for e in events ], "count": len(events), + "caller": caller or None, } @mcp.tool() @@ -230,28 +323,90 @@ def memory_update( new_content: str, source: str, note: str = "mcp update", + user: str | None = None, + ctx: Context | None = None, # type: ignore[type-arg] ) -> dict[str, Any]: """Update a memory: deprecate the old atom and persist the new fact. + **显式更新**(非日常):仅当已知旧记忆已过时、需要替换时才调用 + (如用户纠正了一个事实)。旧 atom 标记 deprecated,新事实立即经 + ``memory_recall`` 可召回。日常纠错也可以走 ``memory_capture`` 让 + 提取管线处理。 + Args: atom_id: id of the atom to supersede. new_content: the replacement fact. source: who/what updated it, for traceability. note: deprecation note. + user: optional caller id (overrides the ``X-Octop-User-Id`` header). + ctx: injected MCP context (reads ``X-Octop-User-Id`` header). """ + caller = user or _caller_user(ctx) memory = _memory() deprecated = memory.deprecate_atom(atom_id, actor="user", note=note) - node = memory.store(new_content, metadata={"source": source, "supersedes": atom_id}) + node = memory.store( + new_content, + metadata={ + "source": source, + "supersedes": atom_id, + **({"user": caller} if caller else {}), + }, + ) return { "deprecated": deprecated, "deprecated_atom_id": atom_id, "new_node_id": node.id, "source": source, + "user": caller or None, } return mcp +def _trigger_extract(server: OctopServer, agent_id: str, session_id: str | None) -> bool: + """Best-effort: asynchronously trigger the agent's memory extraction. + + Internal-network enhancement (not part of the community PR): raw events + written by MCP capture are not in the harness-agent extractor's tracked + sessions, so they would never be distilled into atoms. Reuse the agent's + in-process ``MemoryService`` (with the agent's configured extraction LLM) + via ``agent._memory_runtime.service`` (no public entrypoint; best-effort). + Returns whether an extract task was scheduled. + """ + import asyncio + + if not session_id: + return False + try: + runtime_server = server.app_runtime + assert runtime_server is not None, "app_runtime required for memory extract" + agent = runtime_server.agent_registry.get_agent(agent_id) + runtime = getattr(agent, "_memory_runtime", None) + service = getattr(runtime, "service", None) if runtime else None + if service is None: + return False + + async def _extract() -> None: + try: + await asyncio.to_thread( + service.extract, + session_id, + incremental=True, + promote=True, + regen_pages=True, + ) + except Exception: + logger.warning( + "memory extract failed for session %s", session_id, exc_info=True + ) + + asyncio.create_task(_extract()) + return True + except Exception: + logger.debug("memory extract trigger skipped for agent %s", agent_id, exc_info=True) + return False + + def _memory_mcp_token() -> str | None: """Read the MCP auth token (empty string treated as unconfigured).""" return (os.environ.get("OCTOP_MEMORY_MCP_TOKEN") or "").strip() or None @@ -269,9 +424,7 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None await self._app(scope, receive, send) return - headers = { - k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", []) - } + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} auth = headers.get("authorization", "") provided = auth[7:].strip() if auth.startswith("Bearer ") else "" if not provided: @@ -279,16 +432,14 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None if provided != self._token: body = b'{"error":"unauthorized"}' - await send( - { - "type": "http.response.start", - "status": 401, - "headers": [ - (b"content-type", b"application/json"), - (b"content-length", str(len(body)).encode()), - ], - } - ) + await send({ + "type": "http.response.start", + "status": 401, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + }) await send({"type": "http.response.body", "body": body}) return @@ -305,26 +456,29 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None if scope.get("type") != "http": return # lifespan is wired into the host FastAPI manually; http only here - headers = { - k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", []) - } + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} agent_id = headers.get("x-octop-agent-id", "").strip() target = self._mcp_apps.get(agent_id) if target is None: body = b'{"error":"missing or unknown agent_id (X-Octop-Agent-Id)"}' - await send( - { - "type": "http.response.start", - "status": 404, - "headers": [ - (b"content-type", b"application/json"), - (b"content-length", str(len(body)).encode()), - ], - } - ) + await send({ + "type": "http.response.start", + "status": 404, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + }) await send({"type": "http.response.body", "body": body}) return - await target(scope, receive, send) + # 把调用者 user id 写入 contextvar,供工具读取(stateless HTTP 下 + # mcp SDK 不提供 ctx.request_context)。 + user = headers.get("x-octop-user-id", "").strip() + token_cv = _current_caller_user.set(user) + try: + await target(scope, receive, send) + finally: + _current_caller_user.reset(token_cv) def mount_memory_mcp(app: Any, server: OctopServer) -> list[Any]: @@ -340,11 +494,10 @@ def mount_memory_mcp(app: Any, server: OctopServer) -> list[Any]: if token is None: return [] - services = server.services - assert services is not None, "server.services required for memory MCP mount" - managers: list[Any] = [] mcp_apps: dict[str, Any] = {} + services = server.services + assert services is not None, "server.services required for memory MCP mount" rows = services.agent_repo.list_all(include_disabled=False) for row in rows: agent_id = row.agent_id From 9160cbed14a459988743d5fd025eb9c21bd01433 Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Wed, 26 Aug 2026 15:12:04 +0800 Subject: [PATCH 05/21] =?UTF-8?q?feat(memory-mcp):=20=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E5=88=86=E5=B1=82=E5=B7=A5=E5=85=B7=E9=9B=86=20+=20=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E6=B5=81=E6=B0=B4=E7=BA=BF=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考 DSH 记忆工具设计,把记忆生产流水线每环节暴露为 MCP 工具: - memory_raws: L0 原始事件结构化列表(session/host/user 过滤) - memory_candidates: L1 候选队列查询(pending/promoted/rejected) - memory_extract: 手动调度提取(L0→L1,promote 可直达 L2) - memory_promote: 审核晋升候选(L1→L2) - memory_reject: 拒绝候选(标记+审计) - memory_atoms: L2 原子记忆结构化查询 全部工具经 MemoryService / Memory 分层 API 实现,与 capture 自动流水线互补, 支持外部按需调度记忆生产。 --- src/octop/infra/agents/memory_mcp.py | 218 +++++++++++++++++++++++++++ tests/unit/agents/test_memory_mcp.py | 46 ++++++ 2 files changed, 264 insertions(+) diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py index fb56a508b..2416e3d71 100644 --- a/src/octop/infra/agents/memory_mcp.py +++ b/src/octop/infra/agents/memory_mcp.py @@ -360,6 +360,224 @@ def memory_update( "user": caller or None, } + # ─── 记忆分层查询 + 流水线调度(L0/L1/L2)───────────────────────── + # 参考 DSH 记忆工具集(memory_raws/candidates/atoms/extract/promote/reject), + # 把记忆生产流水线的每个环节暴露为 MCP 工具,供外部调度。 + + @mcp.tool() + def memory_raws( + session_id: str | None = None, + host: str | None = None, + user: str | None = None, + limit: int = 50, + ctx: Context | None = None, # type: ignore[type-arg] + ) -> dict[str, Any]: + """**L0 原始事件列表**:按 session/host/user 过滤查询原始事件(证据源)。 + + 与 ``memory_search_raw``(全文搜索)互补——本工具做结构化过滤 + (session/host/user),按时间倒序返回。日常调试 / 溯源用。 + + Args: + session_id: filter by session (e.g. ``ext:review-bot:user-alice``). + host: filter by recording host (e.g. ``mcp-external``). + user: filter by caller user id (also read from X-Octop-User-Id). + limit: max events (default 50). + ctx: injected MCP context. + """ + caller = user or _caller_user(ctx) + memory = _memory() + events = memory.list_raw( + session_id=session_id, + host=host, + user=user or (caller or None), + limit=limit, + ) + return { + "events": [ + { + "event_id": e.id, + "timestamp": e.timestamp.isoformat(), + "session_id": e.session_id, + "user": e.user, + "event_type": e.event_type, + "source": (e.payload or {}).get("source") if e.payload else None, + "content": e.content, + } + for e in events + ], + "count": len(events), + "caller": caller or None, + } + + @mcp.tool() + def memory_candidates( + status: str | None = None, + session_id: str | None = None, + limit: int = 50, + ) -> dict[str, Any]: + """**L1 候选记忆列表**:查询待审核/已晋升/已拒绝的候选(提取产物)。 + + 候选由 ``memory_capture`` 触发的提取流水线生成(或手动 + ``memory_extract``)。默认返回 pending 队列;可用 ``status`` 过滤 + (pending / promoted / rejected / needs_review / conflict)。 + + Args: + status: filter by candidate status (default pending). + session_id: filter by source session. + limit: max candidates (default 50). + """ + memory = _memory() + from harness_memory.core import CandidateStatus # noqa: PLC0415 + + status_enum = None + if status: + try: + status_enum = CandidateStatus(status) + except ValueError: + status_enum = None + candidates = memory.list_candidates( + status=status_enum, + session_id=session_id, + limit=limit, + ) + return { + "candidates": [ + { + "candidate_id": c.id, + "status": c.status.value if hasattr(c.status, "value") else str(c.status), + "candidate_type": getattr(c, "candidate_type", None), + "assertion": getattr(c, "assertion", None), + "session_id": getattr(c, "session_id", None), + "confidence": getattr(c, "confidence", None), + } + for c in candidates + ], + "count": len(candidates), + } + + @mcp.tool() + def memory_extract( + session_id: str | None = None, + limit: int = 100, + promote: bool = False, + ) -> dict[str, Any]: + """**手动触发记忆提取**(L0 → L1,可选直达 L2):调度生产流水线。 + + 复用专家进程内 ``MemoryService``(含配置的提取 LLM)同步执行提取: + 取最近 ``limit`` 条 L0 原始事件 → LLM 类型化提取 → 候选(pending); + ``promote=True`` 时对候选执行晋升检查(L1 → L2 atom),跳过人工审核。 + 运行时无 MemoryService 时返回 ``error``(best-effort)。 + + Args: + session_id: only extract events of this session; omit for recent all. + limit: number of recent raw events to extract (default 100). + promote: run promotion on extracted candidates (default False). + """ + runtime_server = server.app_runtime + assert runtime_server is not None, "app_runtime required for memory extract" + agent = runtime_server.agent_registry.get_agent(agent_id) + runtime = getattr(agent, "_memory_runtime", None) + service = getattr(runtime, "service", None) if runtime else None + if service is None: + return {"error": "MemoryService unavailable (agent not running / no memory runtime)"} + + eff_session = session_id or "manual" + result = service.extract(eff_session, incremental=True, promote=promote, regen_pages=False) + if not isinstance(result, dict): + return {"session_id": eff_session, "candidates": 0, "promoted": 0} + return { + "session_id": eff_session, + "events_considered": result.get("events_considered", 0), + "candidates": result.get("candidates", 0), + "promoted": result.get("promoted", 0) if isinstance(result.get("promotion"), dict) else 0, + "error": result.get("failure_reason"), + } + + @mcp.tool() + def memory_promote( + candidate_ids: list[str], + importance: str | None = None, + ) -> dict[str, Any]: + """**审核晋升候选**(L1 → L2):确认候选为原子记忆。 + + 对指定候选执行 5 项晋升检查(规则路径),通过则写入 L2 atom, + 记录 journal。用于人工审核 / 外部调度晋升。 + + Args: + candidate_ids: candidate ids to promote (from memory_candidates). + importance: override importance (low/medium/high); default keep. + """ + memory = _memory() + candidates = memory.list_candidates(limit=1000) + by_id = {c.id: c for c in candidates} + selected = [by_id[cid] for cid in candidate_ids if cid in by_id] + if not selected: + return {"promoted": 0, "skipped": len(candidate_ids)} + result = memory.promote_candidates(selected) + return { + "promoted": result.promoted if hasattr(result, "promoted") else len(selected), + "skipped": len(candidate_ids) - len(selected), + } + + @mcp.tool() + def memory_reject( + candidate_id: str, + reason: str = "rejected by external caller", + ) -> dict[str, Any]: + """**拒绝候选**:标记 rejected + 原因,不进原子层(记录 journal 可审计)。 + + Args: + candidate_id: candidate id to reject. + reason: rejection reason. + """ + memory = _memory() + from harness_memory.core import CandidateStatus # noqa: PLC0415 + + ok = memory.update_candidate_status( + candidate_id, + status=CandidateStatus.REJECTED, + decided_by="mcp-external", + promotion_reason=reason, + ) + return {"ok": ok, "candidate_id": candidate_id, "status": "rejected"} + + @mcp.tool() + def memory_atoms( + importance: str | None = None, + include_deprecated: bool = False, + limit: int = 50, + ) -> dict[str, Any]: + """**L2 原子记忆列表**:结构化查询原子记忆(按 importance 过滤)。 + + 与 ``memory_recall``(语义召回)互补——本工具做结构化枚举 + (importance / deprecated),返回原子断言 + 置信度 + 重要性。 + + Args: + importance: filter by importance (low/medium/high). + include_deprecated: include deprecated atoms (default False). + limit: max atoms (default 50). + """ + memory = _memory() + atoms = memory.list_atoms( + importance=importance, + include_deprecated=include_deprecated, + limit=limit, + ) + return { + "atoms": [ + { + "atom_id": a.id, + "assertion": a.assertion, + "entity_id": getattr(a, "entity_id", None), + "confidence": getattr(a, "confidence", None), + "importance": getattr(a, "importance", None), + "created_at": getattr(a, "created_at", None), + } + for a in atoms + ], + "count": len(atoms), + } + return mcp diff --git a/tests/unit/agents/test_memory_mcp.py b/tests/unit/agents/test_memory_mcp.py index 1be2924c6..a360e98c1 100644 --- a/tests/unit/agents/test_memory_mcp.py +++ b/tests/unit/agents/test_memory_mcp.py @@ -51,6 +51,12 @@ def test_build_registers_five_tools(fake_memory): "memory_capture", "memory_update", "memory_search_raw", + "memory_raws", + "memory_candidates", + "memory_extract", + "memory_promote", + "memory_reject", + "memory_atoms", } @@ -227,3 +233,43 @@ async def _send(msg): await router(scope, lambda: {}, _send) assert sent[0]["status"] == 404 + + +def test_trigger_extract_no_session_returns_false(): + """No session_id -> no extraction trigger.""" + assert mm._trigger_extract(mock.MagicMock(), "A1", None) is False + + +def test_trigger_extract_no_service_returns_false(monkeypatch): + """Agent without memory runtime/service -> silently skipped.""" + agent = mock.MagicMock() + runtime = mock.MagicMock() + runtime.service = None + agent._memory_runtime = runtime + registry = mock.MagicMock(get_agent=lambda aid: agent) + server = mock.MagicMock() + server.app_runtime.agent_registry = registry + assert mm._trigger_extract(server, "A1", "kiro-chat") is False + + +def test_trigger_extract_schedules_service(monkeypatch): + """With a service, asynchronously schedule extract and return True.""" + import asyncio + import time + + agent = mock.MagicMock() + service = mock.MagicMock() + runtime = mock.MagicMock() + runtime.service = service + agent._memory_runtime = runtime + registry = mock.MagicMock(get_agent=lambda aid: agent) + server = mock.MagicMock() + server.app_runtime.agent_registry = registry + + async def _run(): + return mm._trigger_extract(server, "A1", "kiro-chat") + + assert asyncio.run(_run()) is True + time.sleep(0.1) + service.extract.assert_called() + assert service.extract.call_args.args[0] == "kiro-chat" From d629bb154333e51d04884ce32fa797c378dcb237 Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Wed, 26 Aug 2026 22:38:05 +0800 Subject: [PATCH 06/21] =?UTF-8?q?refactor(memory-mcp):=20=E7=B2=BE?= =?UTF-8?q?=E7=AE=80=E5=B7=A5=E5=85=B7=E9=9D=A2=E2=80=94=E2=80=94=E5=90=88?= =?UTF-8?q?=E5=B9=B6=20L0/L2=20=E6=9F=A5=E8=AF=A2=EF=BC=8C11=E2=86=929=20?= =?UTF-8?q?=E4=B8=AA=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 memory_atoms(memory_recall 已覆盖 L2 查询) - 删除 memory_search_raw(memory_raws 增加 query 参数走 FTS,覆盖 L0 搜索) - 保留 9 个:recall/save/capture/update(读写)+ raws/candidates/extract/promote/reject(分层查询+流水线调度) --- src/octop/infra/agents/memory_mcp.py | 93 +++------------------------- tests/unit/agents/test_memory_mcp.py | 7 +-- 2 files changed, 13 insertions(+), 87 deletions(-) diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py index 2416e3d71..59b1697a6 100644 --- a/src/octop/infra/agents/memory_mcp.py +++ b/src/octop/infra/agents/memory_mcp.py @@ -14,7 +14,7 @@ * ``memory_capture`` -> ``add_raw``: writes an **L0 raw event**, which goes through the extraction pipeline (extract -> candidate -> promote -> atom). Use it to record raw conversations / events. The record is visible - immediately via ``memory_search_raw``; ``memory_recall`` returns it only + immediately via ``memory_raws``; ``memory_recall`` returns it only after extraction promotes it to an atom. * ``memory_save`` -> ``store``: persists a structured fact directly into the canonical atom/tree (durable, no extraction). Use it when you already know @@ -225,7 +225,7 @@ def memory_capture( **日常使用**:把对话/事件原始内容记录下来,交给自动提取流水线 (extract -> candidate -> promote -> atom),稍后经 ``memory_recall`` - 可召回。记录后立即可用 ``memory_search_raw`` 查询。The source marker + 可召回。记录后立即可用 ``memory_raws`` 查询。The source marker is stored in ``payload.source``; the caller id (from ``X-Octop-User-Id`` header or ``user`` arg) is stored on the raw event for per-user traceability. @@ -272,51 +272,12 @@ def memory_capture( "recorded": True, "extract_scheduled": extract_scheduled, "note": ( - "raw (L0) event recorded; visible now via memory_search_raw, " + "raw (L0) event recorded; visible now via memory_raws, " "recallable via memory_recall after the extraction pipeline " "promotes it to an atom" ), } - @mcp.tool() - def memory_search_raw( - query: str, - limit: int = 10, - user: str | None = None, - ctx: Context | None = None, # type: ignore[type-arg] - ) -> dict[str, Any]: - """FTS-search L0 raw events of this expert (capture visible immediately). - - Unlike ``memory_recall`` (which reads atoms), this searches the raw - event layer, so records written by ``memory_capture`` are visible right - away, before extraction promotes them. - - Args: - query: keywords to match against raw event content. - limit: max number of events to return. - user: optional caller id (overrides the ``X-Octop-User-Id`` header); - returned in ``caller`` for per-caller traceability. - ctx: injected MCP context (reads ``X-Octop-User-Id`` header). - """ - caller = user or _caller_user(ctx) - memory = _memory() - events = memory.search_raw(query, limit=limit) - return { - "events": [ - { - "event_id": e.id, - "timestamp": e.timestamp.isoformat(), - "session_id": e.session_id, - "user": e.user, - "source": (e.payload or {}).get("source") if e.payload else None, - "content": e.content, - } - for e in events - ], - "count": len(events), - "caller": caller or None, - } - @mcp.tool() def memory_update( atom_id: str, @@ -366,18 +327,21 @@ def memory_update( @mcp.tool() def memory_raws( + query: str | None = None, session_id: str | None = None, host: str | None = None, user: str | None = None, limit: int = 50, ctx: Context | None = None, # type: ignore[type-arg] ) -> dict[str, Any]: - """**L0 原始事件列表**:按 session/host/user 过滤查询原始事件(证据源)。 + """**L0 原始事件查询**:FTS 搜索或结构化过滤原始事件(证据源)。 - 与 ``memory_search_raw``(全文搜索)互补——本工具做结构化过滤 - (session/host/user),按时间倒序返回。日常调试 / 溯源用。 + 合并了原 ``memory_search_raw``(全文搜索)与结构化过滤两种能力: + ``query`` 走 FTS 全文搜索(capture 立即可见,提取前也可查), + ``session_id``/``host``/``user`` 做结构化过滤。按时间倒序返回。 Args: + query: FTS keywords to match raw event content (optional). session_id: filter by session (e.g. ``ext:review-bot:user-alice``). host: filter by recording host (e.g. ``mcp-external``). user: filter by caller user id (also read from X-Octop-User-Id). @@ -386,7 +350,7 @@ def memory_raws( """ caller = user or _caller_user(ctx) memory = _memory() - events = memory.list_raw( + events = memory.search_raw(query, limit=limit) if query else memory.list_raw( session_id=session_id, host=host, user=user or (caller or None), @@ -541,43 +505,6 @@ def memory_reject( ) return {"ok": ok, "candidate_id": candidate_id, "status": "rejected"} - @mcp.tool() - def memory_atoms( - importance: str | None = None, - include_deprecated: bool = False, - limit: int = 50, - ) -> dict[str, Any]: - """**L2 原子记忆列表**:结构化查询原子记忆(按 importance 过滤)。 - - 与 ``memory_recall``(语义召回)互补——本工具做结构化枚举 - (importance / deprecated),返回原子断言 + 置信度 + 重要性。 - - Args: - importance: filter by importance (low/medium/high). - include_deprecated: include deprecated atoms (default False). - limit: max atoms (default 50). - """ - memory = _memory() - atoms = memory.list_atoms( - importance=importance, - include_deprecated=include_deprecated, - limit=limit, - ) - return { - "atoms": [ - { - "atom_id": a.id, - "assertion": a.assertion, - "entity_id": getattr(a, "entity_id", None), - "confidence": getattr(a, "confidence", None), - "importance": getattr(a, "importance", None), - "created_at": getattr(a, "created_at", None), - } - for a in atoms - ], - "count": len(atoms), - } - return mcp diff --git a/tests/unit/agents/test_memory_mcp.py b/tests/unit/agents/test_memory_mcp.py index a360e98c1..9985dc30f 100644 --- a/tests/unit/agents/test_memory_mcp.py +++ b/tests/unit/agents/test_memory_mcp.py @@ -50,13 +50,11 @@ def test_build_registers_five_tools(fake_memory): "memory_save", "memory_capture", "memory_update", - "memory_search_raw", "memory_raws", "memory_candidates", "memory_extract", "memory_promote", "memory_reject", - "memory_atoms", } @@ -105,18 +103,19 @@ def test_memory_capture_goes_add_raw(fake_memory): assert "raw (L0)" in result["note"] -def test_memory_search_raw_queries_l0(fake_memory): +def test_memory_raws_queries_l0_with_query(fake_memory): class _Evt: id = "evt1" timestamp = __import__("datetime").datetime(2026, 8, 19) session_id = "review-1" user = "u1" + event_type = "manual" payload = {"source": "review-bot"} content = "report panel banner hidden" fake_memory.search_raw.return_value = [_Evt()] mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") - result = _tools(mcp)["memory_search_raw"].fn(query="report panel banner", limit=5) + result = _tools(mcp)["memory_raws"].fn(query="report panel banner", limit=5) fake_memory.search_raw.assert_called_once_with("report panel banner", limit=5) assert result["count"] == 1 assert result["events"][0]["event_id"] == "evt1" From a857871bacc7a966b89b5c3d050e0e4d14b872ab Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Wed, 26 Aug 2026 22:48:30 +0800 Subject: [PATCH 07/21] chore: remove upstream PR.md draft (PR description lives on GitHub) --- PR.md | 113 ---------------------------------------------------------- 1 file changed, 113 deletions(-) delete mode 100644 PR.md diff --git a/PR.md b/PR.md deleted file mode 100644 index d843eb444..000000000 --- a/PR.md +++ /dev/null @@ -1,113 +0,0 @@ -# PR Title - -feat(memory): expose expert memory as an MCP server for external agents - ---- - -## Summary - -Adds a memory **MCP server** (Streamable HTTP at `/mcp/memory`) so external -agents (coding agents, bots, other AI tools) can directly **read / write / -update Octop expert memory**, aligned 1:1 with the in-process -`MemoryService` capabilities. Every write stamps a `source` marker that is -traceable on recall. - -## Why - -Octop experts accumulate rich memory (facts, conversations, decisions), but -today only the Octop dashboard / in-process agent can access it. External -agents that need to reuse that expertise (e.g. a coding agent asking a -business expert's accumulated knowledge) have no way in. This PR exposes the -same memory surface over the standard MCP protocol so any MCP-capable agent -can join the loop. - -## What - -- **New module** `src/octop/infra/agents/memory_mcp.py` — FastMCP server - bound to one expert per connection, plus token auth and header routing. -- **Mount** in `api/app.py` (`build_app`) at `/mcp/memory`, with - `streamable_http` task groups wired into the FastAPI lifespan. -- **Tests** `tests/unit/agents/test_memory_mcp.py` (13 tests). - -### Tools - -| Tool | Purpose | Backing API | -|------|---------|-------------| -| `memory_recall(query, limit=5)` | Recall memories (full pipeline: tokenize → FTS → rerank → dedupe); returns structured snippets + rendered markdown | `recall_for_prompt` | -| `memory_save(content, source, topic?)` | Persist a structured fact directly into the atom/tree (durable, no extraction) | `Memory.store` | -| `memory_capture(content, source, session_id?)` | Write an **L0 raw event** (goes through extraction); visible immediately via `memory_search_raw` | `Memory.add_raw` | -| `memory_search_raw(query, limit=10)` | FTS-search L0 raw events (capture visible before extraction) | `Memory.search_raw` | -| `memory_update(atom_id, new_content, source)` | Deprecate old atom + persist the new fact | `deprecate_atom` + `store` | - -### Expert binding & auth - -- **One connection binds one expert**: endpoint is a single `/mcp/memory`; - the expert is selected at connect time via the `X-Octop-Agent-Id` header — - callers never pass an agent id per tool call (they don't know the id list). -- **Auth**: independent token via `OCTOP_MEMORY_MCP_TOKEN` (fail-closed when - unset). Authorization via `Authorization: Bearer` or `X-Octop-Memory-Token`. - -### raw vs atom (for callers) - -- `memory_capture` → **L0 raw event** (evidence layer), distilled later by - the extraction pipeline (`extract → candidate → promote → atom`). Use it to - record raw conversations/events; the record is visible immediately via - `memory_search_raw` and recallable via `memory_recall` once promoted. -- `memory_save` → **atom/tree directly** (durable, no extraction). Use it - when the fact is already known. - -## Implementation notes - -- Lives in `infra/agents/` with no api-layer dependency: opens the agent - `Memory` instance via `open_memory_kwargs` + `Memory(...)` (workspace - resolved from the agent registry). -- DNS rebinding protection disabled (`TransportSecuritySettings`) because - Octop runs behind a reverse proxy (Host is the public domain, not localhost). -- `streamable_http_path` collapsed to `/` so the endpoint is exactly - `/mcp/memory` (the SDK default `/mcp` would yield `/mcp/memory/mcp`). -- One `FastMCP` per expert, routed by an ASGI dispatcher on the - `X-Octop-Agent-Id` header; missing/unknown agent → 404. - -## Usage example - -```bash -export OCTOP_MEMORY_MCP_TOKEN="" -``` - -```json -{ - "mcpServers": { - "octop-memory": { - "type": "streamable_http", - "url": "http:///mcp/memory/", - "headers": { - "Authorization": "Bearer ", - "X-Octop-Agent-Id": "" - } - } - } -} -``` - -```text -memory_recall(query="what are the key project decisions?") -memory_save(content="the release window is every Tuesday", source="coding-agent", topic="release") -memory_capture(content="user reported: the report panel banner is not rendering", source="review-bot", session_id="review-2026-08-20") -memory_search_raw(query="report panel banner") -memory_update(atom_id="atom_xxx", new_content="updated fact", source="coding-agent") -``` - -## Testing - -- `tests/unit/agents/test_memory_mcp.py` — 13 tests: tool registration, - recall pipeline, capture (raw) semantics, search_raw, update, token - middleware (401 / accept), header routing, 404 unknown agent, unified mount. -- Verified locally by booting the server and exercising the MCP endpoints: - health, 401 without token, `initialize` (binds expert via header), - `tools/list` (5 tools), `tools/call memory_recall`. - -## Checklist - -- [x] No internal/hard-coded environment-specific values in the diff -- [x] `make lint` clean (ruff) -- [x] Unit tests pass From 97f1eb9b04692f22c64828b96775887596229f2c Mon Sep 17 00:00:00 2001 From: jinlongqi Date: Fri, 28 Aug 2026 14:50:49 +0800 Subject: [PATCH 08/21] =?UTF-8?q?docs(memory-mcp):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=B8=93=E5=AE=B6=E8=AE=B0=E5=BF=86=20MCP=20Server=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 9 工具面(recall/save/capture/update/raws/candidates/extract/promote/reject): - 连接信息:/mcp/memory endpoint、OCTOP_MEMORY_MCP_TOKEN 鉴权(fail-closed)、X-Octop-Agent-Id 专家绑定、调用者追溯 - 工具参数表 + 与 MemoryService 的层级映射(L0/L1/L2) - 使用示例:curl 握手、capture/save/recall、MCP SDK 客户端 - 部署配置与开启步骤 --- docs/memory-mcp.md | 191 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 docs/memory-mcp.md diff --git a/docs/memory-mcp.md b/docs/memory-mcp.md new file mode 100644 index 000000000..2048c583d --- /dev/null +++ b/docs/memory-mcp.md @@ -0,0 +1,191 @@ +# 专家记忆 MCP Server 使用文档 + +> Octop 将专家记忆通过 **MCP(Streamable HTTP)** 暴露给外部 agent(编码 agent、机器人等), +> 与进程内 `MemoryService` 能力对齐。外部 agent 可读写专家记忆;每次写入都会带上 +> `source` 标记,召回时可追溯来源。 +> +> 适用版本:`feat/memory-mcp-extension` 分支(9 工具面)。 + +--- + +## 1. 连接信息 + +| 项 | 值 | +|---|---| +| Endpoint | `http://:/mcp/memory` | +| 协议 | MCP Streamable HTTP(SSE + JSON) | +| 鉴权 | `Authorization: Bearer ` | +| 专家绑定 | `X-Octop-Agent-Id: ` 请求头(一连接绑定一专家) | + +### 1.1 鉴权(fail-closed) + +- 服务端通过环境变量 `OCTOP_MEMORY_MCP_TOKEN` 配置独立 token;**未配置时不挂载** `/mcp/memory`(安全默认)。 +- 每个请求必须带 `Authorization: Bearer `,否则返回 `401`。 +- Token 建议与 Octop 登录 JWT 完全隔离(独立凭据,仅限记忆通道使用)。 + +### 1.2 专家绑定(一连接一专家) + +- 端点只有一个 `/mcp/memory`,专家在**连接时**通过 `X-Octop-Agent-Id` 头选择(如 `main`、`MRA7KP`)。 +- 一次连接绑定一个专家,工具调用时**不再**传 agent id——调用方只需在建立会话时固定一个专家。 +- 所有读写都落在该专家的 `Memory` 实例(默认 SQLite;PG 控制面可显式开启)。 + +### 1.3 调用者追溯 + +- 内网增强:MCP 请求的调用者 user id 通过 `X-Octop-User-Id` 头(或工具显式 `user` 参数)传递, + `memory_capture` / `memory_save` / `memory_update` 会按调用者做 per-user 追溯。 +- stateless Streamable HTTP 下 mcp SDK 不提供 `ctx.request_context`,实现用 ContextVar 跨 + ASGI 中间件 → 工具传递,因此每次请求头里的调用者身份是可靠的。 + +--- + +## 2. 工具清单(9 个) + +### 2.1 读取 + +| 工具 | 参数 | 说明 | +|---|---|---| +| `memory_recall` | `query: str`, `limit: int = 5`, `user: str | None` | 语义召回专家记忆(原子/树,L2)。日常使用入口 | +| `memory_raws` | `query: str | None`, `session_id: str | None`, `host: str | None`, `user: str | None`, `limit: int = 50` | 查 L0 原始事件(采集即可见,提取前也能查) | + +### 2.2 写入 + +| 工具 | 参数 | 说明 | +|---|---|---| +| `memory_capture` | `content: str`, `source: str`, `session_id: str | None`, `user: str | None` | 记录一条 **L0 原始事件**,走提取流水线(extract → candidate → promote → atom)。适合记录对话/事件原文 | +| `memory_save` | `content: str`, `source: str`, `topic: str | None`, `user: str | None` | 直接持久化一条**结构化事实**到原子层(L2,不经过提取)。适合已知的明确事实 | +| `memory_update` | `atom_id: str`, `new_content: str`, `source: str`, `note: str = "mcp update"`, `user: str | None` | 显式更新一条记忆:旧 atom 标记 deprecated,新事实立即可召回 | + +### 2.3 提取 / 审核流水线 + +| 工具 | 参数 | 说明 | +|---|---|---| +| `memory_extract` | `session_id: str | None`, `limit: int = 100`, `promote: bool = False` | 手动触发提取:取最近 L0 事件 → LLM 类型化提取 → 候选(pending)。`promote=True` 时直接晋升检查 | +| `memory_candidates` | `status: str | None`, `session_id: str | None`, `limit: int = 50` | 列出候选记忆(默认 pending 队列;可用 status 过滤 promoted / rejected / needs_review) | +| `memory_promote` | `candidate_ids: list[str]`, `importance: str | None` | 审核晋升候选(L1 → L2 原子),可覆盖 importance | +| `memory_reject` | `candidate_id: str`, `reason: str = "rejected by external caller"` | 拒绝候选并记录原因(journal 可审计) | + +--- + +## 3. 使用示例 + +### 3.1 直接 HTTP(curl) + +```bash +TOKEN="" +AGENT="main" # 绑定的专家 +BASE="http://127.0.0.1:8088/mcp/memory" + +# 初始化(MCP 握手) +curl -s -X POST "$BASE/" \ + -H "Authorization: Bearer $TOKEN" \ + -H "X-Octop-Agent-Id: $AGENT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"cli","version":"1.0"}}}' +``` + +### 3.2 记录一条原始事件(L0) + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "memory_capture", + "arguments": { + "content": "user reported: the report panel banner is not rendering", + "source": "review-bot", + "user": "alice" + } + } +} +``` + +### 3.3 直接保存一条事实(L2) + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "memory_save", + "arguments": { + "content": "机票记忆体系 M1 采用 Octop 专家记忆平台作为目标服务", + "source": "planning-agent", + "topic": "flight-memory-system" + } + } +} +``` + +### 3.4 召回 + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "memory_recall", + "arguments": { "query": "机票记忆体系", "limit": 5 } + } +} +``` + +### 3.5 用 MCP 客户端 SDK + +```python +# pip install mcp +import asyncio +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +async def main(): + # 注意:endpoint 需以 / 结尾,且须带鉴权与专家绑定头 + async with streamablehttp_client( + "http://127.0.0.1:8088/mcp/memory/", + headers={"Authorization": "Bearer ", + "X-Octop-Agent-Id": "main"}, + ) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + tools = await session.list_tools() + print([t.name for t in tools.tools]) # 9 个 memory_* 工具 + res = await session.call_tool("memory_recall", {"query": "octop 沙箱"}) + print(res) + +asyncio.run(main()) +``` + +--- + +## 4. 部署与配置 + +| 配置 | 说明 | +|---|---| +| `OCTOP_MEMORY_MCP_TOKEN` | 必填;未设置则 `/mcp/memory` 不挂载(fail-closed) | +| `X-Octop-Agent-Id` | 连接时必填;专家 id(`octop agent list` 可查) | +| 记忆后端 | 默认 SQLite(`~/.octop/agents//memory.sqlite`);PG 控制面显式开启 | + +### 4.1 开启步骤 + +1. 设置环境变量 `OCTOP_MEMORY_MCP_TOKEN=`(可写入 `~/.octop/env`,启动自动加载)。 +2. 重启 `octop run`。 +3. 验证:`curl -i http://:/mcp/memory/` 应返回 `401`(未带 token)或 MCP 协议响应(带 token)。 +4. 外部 agent 按 §1 连接信息接入。 + +--- + +## 5. 与进程内 MemoryService 的关系 + +| MCP 工具 | MemoryService 对应 | 层级 | +|---|---|---| +| `memory_capture` | `add_raw` | L0 原始事件 | +| `memory_extract` / `memory_promote` / `memory_reject` | 提取流水线(extract → candidate → promote) | L0 → L1 → L2 | +| `memory_raws` | 查询 raw events | L0 | +| `memory_candidates` | 查询候选队列 | L1 | +| `memory_recall` / `memory_save` / `memory_update` | 原子/树读写 | L2 | + +写入链路:`capture`(L0)→ `extract`(L1 候选)→ `promote`(L2 原子);直接 `save` 则跳过提取直写 L2。 From 02f437febec5d3126beefa58bc9a2bb5ecd46be3 Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Sat, 5 Sep 2026 18:52:58 +0800 Subject: [PATCH 09/21] =?UTF-8?q?feat(memory-mcp):=20=E5=B9=82=E7=AD=89=20?= =?UTF-8?q?capture=20=E5=8E=BB=E9=87=8D=20+=20harness=5Fmemory=20=E5=8F=AC?= =?UTF-8?q?=E5=9B=9E=E8=B4=A8=E9=87=8F=E8=A1=A5=E4=B8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/octop/infra/agents/memory_mcp.py | 20 + src/octop/infra/agents/memory_recall_patch.py | 356 ++++++++++++++++++ src/octop/infra/server.py | 8 + 3 files changed, 384 insertions(+) create mode 100644 src/octop/infra/agents/memory_recall_patch.py diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py index 59b1697a6..089f4ba14 100644 --- a/src/octop/infra/agents/memory_mcp.py +++ b/src/octop/infra/agents/memory_mcp.py @@ -255,6 +255,26 @@ def memory_capture( caller = user or _caller_user(ctx) effective_session = session_id or _derive_session(source, caller) memory = _memory() + + # Idempotent capture: skip if an identical raw event (same session + + # content) already exists, so re-ingesting the same conversation does + # not duplicate L0 events. Keeps downstream extraction re-runnable. + try: + for ev in memory.list_raw(session_id=effective_session, limit=1000): + if getattr(ev, "content", None) == content: + return { + "event_id": ev.id, + "source": source, + "user": caller or None, + "session_id": effective_session, + "recorded": True, + "duplicate": True, + "note": "raw (L0) event already present; skipped (idempotent capture)", + } + except Exception: # noqa: BLE001 + # If duplicate detection fails, fall back to recording (safe). + pass + raw = memory.add_raw( content, event_type="manual", diff --git a/src/octop/infra/agents/memory_recall_patch.py b/src/octop/infra/agents/memory_recall_patch.py new file mode 100644 index 000000000..5e4cf1443 --- /dev/null +++ b/src/octop/infra/agents/memory_recall_patch.py @@ -0,0 +1,356 @@ +"""Persistent recall-quality patches for ``harness_memory``. + +The upstream ``harness-memory`` package (PyPI) ships a recall pipeline whose +retrieval quality is poor for multi-person shared-conversation datasets like +the evaluation corpus. Three defects were found during eval work and are +patched here at startup (idempotent), so the fixes survive +``uv sync`` / redeploys instead of living only in the venv: + + 1. ``router.route`` — entity hints are matched against stored aliases with + an exact string lookup, but natural-language queries attach Chinese + suffixes ("张小明的deadline" → hint "张小明的", stored alias "张小明"). + Result: no entity anchor is resolved, recall degrades to topical FTS + and cross-entity topics (everyone's deadlines) drown the requested + person's. Fix: progressively strip common suffixes before alias lookup. + + 2. ``multi_source._per_token_atom_search`` — merges per-token FTS hits + ordered by token position, so an early generic token ("李小" n-gram of + "李小婉") outranks a later *relevant* token ("recurring"). Result: + recall returns the entity's generic facts (age / company) instead of + the topical memory. Fix: rank by count of matched *strong* tokens + (full Latin words + complete Han runs + resolved anchor names), with + n-grams contributing to recall but not to the relevance count. + + 3. ``multi_source._gather_atoms`` anchor branch — when the router resolves + an entity, its "recent atoms" fill the candidate list before FTS + topical hits, so a query-relevant atom outside the recency window is + truncated before rerank. Fix: run FTS first, then anchor atoms as + fallback, and pass anchor entity names into the strong-token set so + "张小明 + deadline" outranks someone else's "deadline". + +Each patch wraps the original symbol (preserving the upstream signature and +behaviour as the fallback); ``apply_memory_recall_patch()`` is idempotent +and is invoked from ``octop.infra.server._boot_runtime`` before any agent +memory service is created. +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from harness_memory.pipeline.recall.router import RoutingDecision + +logger = logging.getLogger(__name__) + +_PATCHED = False + +# ── shared helpers ──────────────────────────────────────────────────────── + +_LATIN_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{1,}") +_HAN_RUN_RE = re.compile(r"[\u4e00-\u9fff]{2,}") + +# Chinese possessive / copular suffixes that attach to a name in queries. +_SUFFIXES = ("的", "是", "在", "了", "与", "和") + + +def _strong_query_tokens(text: str) -> set[str]: + """Full-word query tokens — Latin words + complete Han runs (no n-grams).""" + out: set[str] = set() + for m in _LATIN_WORD_RE.finditer(text): + out.add(m.group(0).lower()) + for m in _HAN_RUN_RE.finditer(text): + out.add(m.group(0)) + return out + + +def _alias_candidates(alias: str) -> tuple[str, ...]: + """Ordered alias lookup candidates: exact match, then suffix-stripped.""" + out = [alias] + if alias and not alias.isascii(): + stripped = alias + for _ in range(3): + if stripped and stripped[-1] in _SUFFIXES: + stripped = stripped[:-1] + if stripped: + out.append(stripped) + else: + break + return tuple(out) + + +# ── 4. substring entity anchoring (cross-entity / conflict phrasing) ───── +# +# ``parser._entity_hints`` produces *fragmented* hints for compound +# natural-language queries: "在多位同事都提供了age的情况下,周晓东本人的age" +# yields hints like "周晓东本人的" (exact alias lookup misses "周晓东"), +# "和赵晓磊同住…王小明" yields one long blob, and "当吴晓强的…" keeps the +# leading "当". Exact-then-suffix-stripped resolution cannot recover the +# entity, so the router resolves nothing and recall degrades to topical FTS. +# Fix: also match any known entity name/alias as a *substring* of the query +# text, so the target person (王小明/周晓东/吴晓强/赵晓磊) is resolved and flows +# into the anchor branch of ``_gather_atoms`` (fixes cross-entity + conflict +# "以哪个值为准" phrasing misses). + +_ENTITY_INDEX: dict[str, dict[str, str]] = {} + + +def _entity_index_key(memory: Any) -> str: + """Key the entity-index cache by memory identity so daily / dev (two + separate agents) never share one index.""" + return str(getattr(memory, "namespace", None) or id(getattr(memory, "_backend", memory))) + + +def _entity_index_for(memory: Any) -> dict[str, str]: + key = _entity_index_key(memory) + if key not in _ENTITY_INDEX: + _ENTITY_INDEX[key] = _build_entity_index(memory) + return _ENTITY_INDEX[key] + + +def _build_entity_index(memory: Any, *, limit: int = 800) -> dict[str, str]: + """Map every entity canonical name + alias -> entity id.""" + idx: dict[str, str] = {} + try: + for e in memory.list_entities(limit=limit): + names = [e.canonical_name, *(getattr(e, "aliases", None) or [])] + for name in names: + name = str(name).strip() + if len(name) >= 2: + idx.setdefault(name, e.id) + # The alias table may hold names not denormalized onto the entity. + for a in memory.list_aliases(limit=limit * 2): + al = str(getattr(a, "alias", "")).strip() + if len(al) >= 2: + idx.setdefault(al, a.entity_id) + except Exception: # noqa: BLE001 + # A bare/mock backend may lack these; fall back to an empty index. + pass + return idx + + +def _substring_entity_hits(text: str, idx: dict[str, str]) -> list[str]: + """Entity ids whose name/alias appears as a substring of ``text``. + + De-duped by entity id, order-preserving. + """ + hits: dict[str, str] = {} + for name, eid in idx.items(): + if len(name) >= 2 and name in text: + hits.setdefault(eid, name) + return list(dict.fromkeys(hits)) + + +# ── 1. router.route: suffix-tolerant entity resolution ──────────────────── + + +def _patched_route( + memory: Any, + parsed: Any, + *, + thread_id: str | None = None, +) -> RoutingDecision: + from harness_memory.pipeline.recall.router import _orig_route # noqa: PLC0415 + + # Re-resolve hints with suffix stripping: try each candidate alias in + # order, resolve the first hit, and inject it back into the parsed + # query so the original route() picks it up. + resolved: list[str] = [] + for alias in parsed.entity_hints or (): + for candidate in _alias_candidates(alias): + try: + entity = memory.find_entity_by_alias(candidate) + except Exception: # noqa: BLE001 + continue + if entity is not None: + resolved.append(entity.id) + break + + # Substring anchoring: the parser's hint fragments (e.g. "当吴晓强的", + # "周晓东本人的", "和赵晓磊同住…王小明") fail exact alias lookup, so the + # router resolves nothing for these compound queries. Match any known + # entity name/alias as a substring of the whole query text to recover the + # target entity. + try: + for eid in _substring_entity_hits(parsed.text, _entity_index_for(memory)): + if eid not in resolved: + resolved.append(eid) + except Exception: # noqa: BLE001 + pass + + decision = _orig_route(memory, parsed, thread_id=thread_id) + # Union our anchors (suffix + substring) with whatever the original route + # resolved; never drop the original resolution. Only rebuild the decision + # when we actually resolved something extra. + union = list(dict.fromkeys(resolved + list(decision.resolved_entity_ids))) + if union and tuple(union) != tuple(decision.resolved_entity_ids): + from dataclasses import replace # noqa: PLC0415 + + decision = replace(decision, resolved_entity_ids=tuple(union)) + return decision + + +# ── 2. multi_source._per_token_atom_search: strong-token ranking ────────── + + +def _patched_per_token_atom_search( + memory: Any, + parsed: Any, + *, + limit: int, + anchor_names: Sequence[str] = (), +) -> list[Any]: + """Per-token FTS merge ranked by matched *strong* token count. + + Mirrors the upstream function but counts only strong tokens (full words + + anchor entity names), so a hit matching "张小明" + "deadline" outranks + a single-token "deadline" hit on a different entity. + """ + strong = _strong_query_tokens(parsed.text) + for name in anchor_names: + norm = str(name).strip() + if len(norm) >= 2: + strong.add(norm.lower() if norm.isascii() else norm) + seen: dict[str, dict[str, Any]] = {} + tokens = parsed.raw_tokens or (parsed.text,) + for token_idx, token in enumerate(tokens): + if not str(token).strip(): + continue + try: + # Search deep: FTS5 rank is a global corpus score, so the + # relevant hit for a common token (e.g. "recurring" shared by + # many entities) can sit far past the top-N. A shallow pool + # truncates it before our strong-token ranking can promote it. + atoms = memory.search_atoms(token, limit=limit * 8) + except Exception: + continue + is_strong = token in strong + for atom_idx, atom in enumerate(atoms): + entry = seen.get(atom.id) + if entry is None: + seen[atom.id] = { + "count": 1 if is_strong else 0, + "first": (token_idx, atom_idx), + "atom": atom, + } + elif is_strong: + entry["count"] += 1 + ranked = sorted( + seen.values(), + key=lambda e: (-e["count"], e["first"][0], e["first"][1]), + ) + return [e["atom"] for e in ranked[:limit]] + + +# ── 3. multi_source._gather_atoms anchor branch: FTS first + anchor names ── + +# item 3: decision/cause/pitfall/experience intent reorder. The corpus stores +# "技术决策" atoms as e.g. "「X」做过一个技术决策:用 G6 而非 D3.js:因为…" and +# "踩坑/经验复用" atoms as "「X」的pitfalls有更新…" / "可复用经验…". For a query +# asking "技术决策及原因 / 为什么 / 经验复用 / 踩坑", those atoms are added as +# *anchor fillers after* the FTS "技术选型" matches and get truncated by +# `limit`. Reorder them ahead of generic selection atoms when the query shows +# that intent. +_DECISION_INTENT_RE = re.compile( + r"技术决策|决策及原因|原因|为什么|若非|为何|经验|复用|踩|坑|规避|如何解决" +) +_DECISION_MARK_RE = re.compile(r"技术决策|而非|因为|坑|pitfall|经验|可复用|规避|决策|N\+1") + + +def _decision_first(atoms: list[Any]) -> list[Any]: + """Stable-partition: decision/cause/pitfall atoms first, others after.""" + dec = [a for a in atoms if _DECISION_MARK_RE.search(getattr(a, "assertion", "") or "")] + rest = [a for a in atoms if not _DECISION_MARK_RE.search(getattr(a, "assertion", "") or "")] + return dec + rest + + +def _patched_gather_atoms( + memory: Any, + parsed: Any, + *, + limit: int, + anchor_ids: Sequence[str] = (), +) -> list[Any]: + """Anchor-narrowed atom gather: FTS topical hits first, anchor fillers + after, with anchor entity names promoted into the strong-token set.""" + + if anchor_ids: + anchor_names: list[str] = [] + for eid in anchor_ids: + ent = memory.get_entity(eid) + if ent is not None and ent.canonical_name: + anchor_names.append(ent.canonical_name) + out: dict[str, Any] = {} + for atom in _patched_per_token_atom_search( + memory, parsed, limit=limit, anchor_names=anchor_names + ): + out.setdefault(atom.id, atom) + if parsed.time_window is not None: + for eid in anchor_ids: + for atom in memory.search_atoms_by_time_range( + start=parsed.time_window.start, + end=parsed.time_window.end, + entity_id=eid, + limit=limit, + ): + out.setdefault(atom.id, atom) + else: + for eid in anchor_ids: + for atom in memory.list_atoms(entity_id=eid, limit=limit): + out.setdefault(atom.id, atom) + atoms = list(out.values()) + # Reorder the WHOLE gathered set (FTS hits + anchor fillers) so the + # decision/cause/pitfall atom is not truncated by `limit` before it + # can be promoted ahead of generic "技术选型" atoms. + if _DECISION_INTENT_RE.search(parsed.text or ""): + atoms = _decision_first(atoms) + return atoms[:limit] + + if parsed.time_window is not None: + return list( + memory.search_atoms_by_time_range( + start=parsed.time_window.start, + end=parsed.time_window.end, + limit=limit, + ) + ) + return _patched_per_token_atom_search(memory, parsed, limit=limit) + + +# ── apply ───────────────────────────────────────────────────────────────── + + +def apply_memory_recall_patch() -> None: + """Install the recall-quality patches (idempotent).""" + global _PATCHED + if _PATCHED: + return + + try: + import harness_memory.pipeline.recall.multi_source as ms # noqa: PLC0415 + import harness_memory.pipeline.recall.router as router # noqa: PLC0415 + + if not hasattr(router, "_orig_route"): + router._orig_route = router.route + router.route = _patched_route + + if not hasattr(ms, "_orig_per_token_atom_search"): + ms._orig_per_token_atom_search = ms._per_token_atom_search + ms._per_token_atom_search = _patched_per_token_atom_search + + if not hasattr(ms, "_orig_gather_atoms"): + ms._orig_gather_atoms = ms._gather_atoms + ms._gather_atoms = _patched_gather_atoms + + _PATCHED = True + logger.info("memory recall patches applied (router suffix + strong-token ranking)") + except Exception: # noqa: BLE001 + # Never fail startup because a recall patch could not be installed — + # upstream behaviour is the safe fallback. + logger.warning( + "memory recall patch installation failed; using upstream recall", + exc_info=True, + ) diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py index 4d648c37c..c544aa58d 100644 --- a/src/octop/infra/server.py +++ b/src/octop/infra/server.py @@ -227,6 +227,14 @@ async def _boot_runtime(self, config: OctopConfig) -> None: configure_browser_idle_timeout(config.browser_idle_timeout_minutes) + # harness_memory recall 质量补丁(实体后缀解析 + 强 token 排序): + # 在任意 MemoryService / MCP 记忆工具创建前应用,保证评测与生产召回一致。 + from octop.infra.agents.memory_recall_patch import ( + apply_memory_recall_patch, + ) + + apply_memory_recall_patch() + registry = AgentManager( repos=self.services.repos, paths=self.paths, From 226b4582a7595e6232f016228576fecd3eae95fc Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Mon, 14 Sep 2026 21:23:54 +0800 Subject: [PATCH 10/21] =?UTF-8?q?fix(memory-mcp):=20=E4=BF=AE=E5=A4=8D=20m?= =?UTF-8?q?emory=5Fcandidates=20status=20=E4=B8=8E=20memory=5Freject=20?= =?UTF-8?q?=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CandidateStatus 是 typing.Literal 而非 Enum: - memory_candidates 用 CandidateStatus(status) 会抛 TypeError: Cannot instantiate typing.Literal(仅 except ValueError,未捕获) - memory_reject 用 CandidateStatus.REJECTED 会抛 AttributeError,工具 100% 失败 改为校验字面量取值后直接把字符串透传给 list_candidates; reject 直接传字面量 "rejected"。 新增 3 个回归测试:status 传参透传、非法 status 抛 ValueError、reject 字面量。 --- src/octop/infra/agents/memory_mcp.py | 25 ++++++++++++++++--------- tests/unit/agents/test_memory_mcp.py | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py index 089f4ba14..dbfdb3b40 100644 --- a/src/octop/infra/agents/memory_mcp.py +++ b/src/octop/infra/agents/memory_mcp.py @@ -29,7 +29,7 @@ import logging import os from contextvars import ContextVar -from typing import Any +from typing import Any, get_args from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.server import Context @@ -413,14 +413,20 @@ def memory_candidates( memory = _memory() from harness_memory.core import CandidateStatus # noqa: PLC0415 - status_enum = None + # ``CandidateStatus`` is a ``typing.Literal`` (not an Enum), so it cannot + # be instantiated: ``CandidateStatus(status)`` raises ``TypeError``. The + # backend stores status as a plain string, so validate the caller's value + # against the literal and pass the string straight through. + status_value: str | None = None if status: - try: - status_enum = CandidateStatus(status) - except ValueError: - status_enum = None + allowed = set(get_args(CandidateStatus)) + if status not in allowed: + raise ValueError( + f"invalid candidate status {status!r}; expected one of {sorted(allowed)}" + ) + status_value = status candidates = memory.list_candidates( - status=status_enum, + status=status_value, session_id=session_id, limit=limit, ) @@ -515,11 +521,12 @@ def memory_reject( reason: rejection reason. """ memory = _memory() - from harness_memory.core import CandidateStatus # noqa: PLC0415 + # ``CandidateStatus`` is a ``typing.Literal``, not an Enum, so + # ``CandidateStatus.REJECTED`` does not exist. Pass the literal string. ok = memory.update_candidate_status( candidate_id, - status=CandidateStatus.REJECTED, + status="rejected", decided_by="mcp-external", promotion_reason=reason, ) diff --git a/tests/unit/agents/test_memory_mcp.py b/tests/unit/agents/test_memory_mcp.py index 9985dc30f..0e22fa47e 100644 --- a/tests/unit/agents/test_memory_mcp.py +++ b/tests/unit/agents/test_memory_mcp.py @@ -272,3 +272,26 @@ async def _run(): time.sleep(0.1) service.extract.assert_called() assert service.extract.call_args.args[0] == "kiro-chat" + + +def test_memory_candidates_passes_status_string(fake_memory): + """``status`` is a typing.Literal of strings: pass the raw value, don't + instantiate it (previously crashed with "Cannot instantiate typing.Literal").""" + fake_memory.list_candidates.return_value = [] + mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") + result = _tools(mcp)["memory_candidates"].fn(status="pending", limit=5) + assert result["candidates"] == [] + assert fake_memory.list_candidates.call_args.kwargs["status"] == "pending" + + +def test_memory_candidates_not_a_status_raises(fake_memory): + mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") + with pytest.raises(ValueError): + _tools(mcp)["memory_candidates"].fn(status="no-such-status") + + +def test_memory_reject_passes_literal_status(fake_memory): + mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") + result = _tools(mcp)["memory_reject"].fn(candidate_id="c1", reason="dup") + assert result["status"] == "rejected" + assert fake_memory.update_candidate_status.call_args.kwargs["status"] == "rejected" From 6de7c349338cc3d6f914d6a5d9f0b22dc70a3856 Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Mon, 14 Sep 2026 21:23:54 +0800 Subject: [PATCH 11/21] =?UTF-8?q?feat(memory-mcp):=20=E8=AF=BB=E9=9D=A2?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=20+=20=E4=B8=93=E5=AE=B6=E6=8C=89=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E7=BB=91=E5=AE=9A=20+=20=E5=8F=91=E9=80=81=E8=80=85?= =?UTF-8?q?=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 专家绑定改为按请求校验(X-Octop-Agent-Id → agent_repo,须存在且 enabled), 改用单一共享 FastMCP app;新建/停用专家即时生效,无需重启进程 - 读面补齐 memory_search(corpus=all|memory|atom|raw,返回带虚拟 path 的命中) 与 memory_get(path → markdown,支持 start/lines 分页,坏路径返回 error+hint) - 写入按 X-Octop-User-Id 在正文加 `说:` 前缀(随原子落库、可被 FTS 命中、 召回时可见);memory_capture 保留同 session+content 幂等 - 工具描述统一中文,明确 recall / search / get / raws 的分工与覆盖边界 - docs/memory-mcp.md:11 工具面、记忆分层与选工具、Kiro / Claude Code / DSH 接入 (含 hooks 与「共享记忆」预设的自动化配置) - tests/unit/agents/test_memory_mcp.py:31 项(新增 search/get/前缀/幂等用例) --- docs/memory-mcp.md | 506 ++++++++++++++++++++------ src/octop/infra/agents/memory_mcp.py | 521 ++++++++++++++++++--------- tests/unit/agents/test_memory_mcp.py | 343 +++++++++++++++--- 3 files changed, 1048 insertions(+), 322 deletions(-) diff --git a/docs/memory-mcp.md b/docs/memory-mcp.md index 2048c583d..c939eeedf 100644 --- a/docs/memory-mcp.md +++ b/docs/memory-mcp.md @@ -1,10 +1,10 @@ # 专家记忆 MCP Server 使用文档 -> Octop 将专家记忆通过 **MCP(Streamable HTTP)** 暴露给外部 agent(编码 agent、机器人等), -> 与进程内 `MemoryService` 能力对齐。外部 agent 可读写专家记忆;每次写入都会带上 -> `source` 标记,召回时可追溯来源。 +> Octop 把**专家记忆**通过标准 **MCP(Streamable HTTP)** 暴露给外部 agent(编码 agent、机器人、 +> 其他 AI 工具),能力与进程内 `MemoryService` 对齐:外部 agent 可以召回记忆、按路径下钻读全文、 +> 写入新的事件与事实,并能区分"这条记忆是谁说的"。 > -> 适用版本:`feat/memory-mcp-extension` 分支(9 工具面)。 +> 当前工具面:**11 个 `memory_*` 工具**(读取 4 个 / 写入 3 个 / 提取与审核 4 个)。 --- @@ -12,180 +12,474 @@ | 项 | 值 | |---|---| -| Endpoint | `http://:/mcp/memory` | +| Endpoint | `http(s):///mcp/memory`(MCP 侧实际请求路径为 `/mcp/memory/`) | | 协议 | MCP Streamable HTTP(SSE + JSON) | | 鉴权 | `Authorization: Bearer ` | -| 专家绑定 | `X-Octop-Agent-Id: ` 请求头(一连接绑定一专家) | +| 专家绑定 | `X-Octop-Agent-Id: ` 请求头 | +| 调用者标识 | `X-Octop-User-Id: ` 请求头(可选,见 §1.3) | ### 1.1 鉴权(fail-closed) -- 服务端通过环境变量 `OCTOP_MEMORY_MCP_TOKEN` 配置独立 token;**未配置时不挂载** `/mcp/memory`(安全默认)。 -- 每个请求必须带 `Authorization: Bearer `,否则返回 `401`。 -- Token 建议与 Octop 登录 JWT 完全隔离(独立凭据,仅限记忆通道使用)。 +- 服务端通过环境变量 `OCTOP_MEMORY_MCP_TOKEN` 配置这条通道的独立 token;**未配置时整个 + `/mcp/memory` 端点不挂载**(安全默认)。 +- 每个请求都必须带 `Authorization: Bearer `,否则返回 `401`。 +- 建议该 token 与 Octop 登录凭据完全隔离——它是独立凭据,仅用于记忆通道。 -### 1.2 专家绑定(一连接一专家) +### 1.2 专家绑定(按请求校验,无需重启) -- 端点只有一个 `/mcp/memory`,专家在**连接时**通过 `X-Octop-Agent-Id` 头选择(如 `main`、`MRA7KP`)。 -- 一次连接绑定一个专家,工具调用时**不再**传 agent id——调用方只需在建立会话时固定一个专家。 -- 所有读写都落在该专家的 `Memory` 实例(默认 SQLite;PG 控制面可显式开启)。 +- 端点只有一个 `/mcp/memory`,URL 里不含专家 id;专家由请求头 `X-Octop-Agent-Id` 选择。 +- **每个请求都会用该 id 去专家注册表校验**(存在且处于启用状态),校验不通过返回 `404`。 + 因此新建或停用专家**立即生效**,不需要重启服务。 +- 所有读写都落在该专家的 `Memory` 实例上,工具调用时**不再**传 agent id。 +- 记忆是**专家级共享**的:同一专家下的多个外部调用者看到同一份记忆,不做按人硬隔离; + 需要按人定位时依赖 §1.3 的发送者归属。 -### 1.3 调用者追溯 +### 1.3 调用者归属(`X-Octop-User-Id`) -- 内网增强:MCP 请求的调用者 user id 通过 `X-Octop-User-Id` 头(或工具显式 `user` 参数)传递, - `memory_capture` / `memory_save` / `memory_update` 会按调用者做 per-user 追溯。 -- stateless Streamable HTTP 下 mcp SDK 不提供 `ctx.request_context`,实现用 ContextVar 跨 - ASGI 中间件 → 工具传递,因此每次请求头里的调用者身份是可靠的。 +- 调用者身份通过 `X-Octop-User-Id` 头(或工具的显式 `user` 参数,优先级更高)传递。 +- `memory_capture` / `memory_save` / `memory_update` 会把调用者 id **拼进内容前缀** + `说:…` 后再落库。原因:`AtomCard` 没有 user 列,把发送者写进正文才能随原子一起 + 落库、被全文检索命中(查询里带上发送者名字即可命中其记忆)、并在召回时直接可见。 +- 因此**不要把名字重复写进 `content`**;内容已带同名前缀时不会重复拼接;不带该头时保持原文。 +- 未匹配到调用者身份时,写入仍然成功,只是没有前缀。 --- -## 2. 工具清单(9 个) +## 2. 工具清单(11 个) -### 2.1 读取 +### 2.1 读取(4 个) | 工具 | 参数 | 说明 | |---|---|---| -| `memory_recall` | `query: str`, `limit: int = 5`, `user: str | None` | 语义召回专家记忆(原子/树,L2)。日常使用入口 | -| `memory_raws` | `query: str | None`, `session_id: str | None`, `host: str | None`, `user: str | None`, `limit: int = 50` | 查 L0 原始事件(采集即可见,提取前也能查) | +| `memory_recall` | `query: str`, `limit: int = 5`, `user?: str` | **读入口首选**。跑完整召回管线(分词 → 路由 → FTS → 重排 → 去重),返回结构化片段 + 可直接注入 system prompt 的 markdown(`rendered`)。L2 原子优先,主题页标题并入 atom 命中,L0 原始事件仅兜底 | +| `memory_search` | `query: str`, `max_results: int = 5`, `corpus: str = "all"` | 同一套召回管线,但**不渲染 markdown**,而是给每条命中一个虚拟 `path`,交给 `memory_get` 下钻。`corpus`:`all`/`memory`(原子+原始事件同管线)、`atom`(只要 L2 原子)、`raw`(直接走 L0 全文检索,不受"有原子命中就丢 raw"的兜底影响) | +| `memory_get` | `path: str`, `start?: int`, `lines?: int` | 把命中路径解析成完整 markdown。支持 `atom/.md`、`page/.md`、`raw//.md`;长内容用 `start`/`lines` 分页(1-based)。路径非法或过期时返回 `{error, hint}` 而不是抛栈 | +| `memory_raws` | `query?: str`, `session_id?: str`, `host?: str`, `user?: str`, `limit: int = 50` | **原始事件(证据源)**。`query` 走全文检索(写入后立即可见,提取前也能查),其余字段做结构化过滤,按时间倒序返回 | + +返回形状: + +```jsonc +// memory_recall +{ "memories": [{ "source_id", "timestamp", "layer", "text" }], "count", "rendered", "caller" } +// memory_search +{ "hits": [{ "path", "layer", "snippet", "occurred_at", "source_id" }], "total", "corpus", "hint" } +// memory_get +{ "path", "kind", "content", "total_lines", "from_line", "to_line", "truncated", "metadata" } +// memory_raws +{ "events": [{ "event_id", "timestamp", "session_id", "user", "event_type", "source", "content" }], "count", "caller" } +``` -### 2.2 写入 +### 2.2 写入(3 个,两条通道) | 工具 | 参数 | 说明 | |---|---|---| -| `memory_capture` | `content: str`, `source: str`, `session_id: str | None`, `user: str | None` | 记录一条 **L0 原始事件**,走提取流水线(extract → candidate → promote → atom)。适合记录对话/事件原文 | -| `memory_save` | `content: str`, `source: str`, `topic: str | None`, `user: str | None` | 直接持久化一条**结构化事实**到原子层(L2,不经过提取)。适合已知的明确事实 | -| `memory_update` | `atom_id: str`, `new_content: str`, `source: str`, `note: str = "mcp update"`, `user: str | None` | 显式更新一条记忆:旧 atom 标记 deprecated,新事实立即可召回 | +| `memory_capture` | `content: str`, `source: str`, `session_id?: str`, `user?: str` | **记录原始事件(L0)**:走 提取 → 候选 → 晋升 → 原子 的流水线,适合记录对话/事件原文。写入后立即可用 `memory_raws` / `memory_search(corpus="raw")` 查;晋升成原子后才能被 `memory_recall` 召回。`session_id` 缺省派生为 `ext:{source}:{user}`,用于让提取管线按会话分组蒸馏 | +| `memory_save` | `content: str`, `source: str`, `topic?: str`, `user?: str` | **直接保存事实(L2)**:不经过提取,立即可召回。适合已知的明确事实/约定 | +| `memory_update` | `atom_id: str`, `new_content: str`, `source: str`, `note: str = "mcp update"`, `user?: str` | **更新记忆**:旧原子标记 deprecated,新事实立刻可召回,并带 `supersedes` 关联。适合纠正过时事实 | + +`memory_capture` 是**幂等**的:同一 `session_id` + 同一内容重复写入时不会产生重复 L0 事件, +返回里带 `duplicate: true` 并复用已有 `event_id`(下游提取因此可以反复重跑)。 -### 2.3 提取 / 审核流水线 +### 2.3 提取与审核流水线(4 个) | 工具 | 参数 | 说明 | |---|---|---| -| `memory_extract` | `session_id: str | None`, `limit: int = 100`, `promote: bool = False` | 手动触发提取:取最近 L0 事件 → LLM 类型化提取 → 候选(pending)。`promote=True` 时直接晋升检查 | -| `memory_candidates` | `status: str | None`, `session_id: str | None`, `limit: int = 50` | 列出候选记忆(默认 pending 队列;可用 status 过滤 promoted / rejected / needs_review) | -| `memory_promote` | `candidate_ids: list[str]`, `importance: str | None` | 审核晋升候选(L1 → L2 原子),可覆盖 importance | -| `memory_reject` | `candidate_id: str`, `reason: str = "rejected by external caller"` | 拒绝候选并记录原因(journal 可审计) | +| `memory_extract` | `session_id?: str`, `limit: int = 100`, `promote: bool = False` | 手动触发提取:取最近 L0 事件 → LLM 类型化提取 → 候选(pending);`promote=True` 时直接跑晋升检查。复用**该专家进程内**的 `MemoryService`(含其配置的提取模型);专家未运行/无记忆运行时时返回 `error` | +| `memory_candidates` | `status?: str`, `session_id?: str`, `limit: int = 50` | 列出 L1 候选(默认 pending)。`status` 可取 `pending` / `promoted` / `rejected` / `needs_review` / `conflict`,非法值报错 | +| `memory_promote` | `candidate_ids: list[str]`, `importance?: str` | 审核晋升:对指定候选跑 5 项晋升检查,通过则写入 L2 原子并记录 journal | +| `memory_reject` | `candidate_id: str`, `reason: str = "rejected by external caller"` | 拒绝候选并记录原因(不进原子层,journal 可审计) | + +--- + +## 3. 记忆分层与选工具 + +| 层级 | 内容 | 对应工具 | +|---|---|---| +| L0 | 原始事件(原话、证据) | `memory_capture` 写入;`memory_raws` / `memory_search(corpus="raw")` 读取 | +| L1 | 候选(提取产物,待审核) | `memory_extract` 产生;`memory_candidates` 查看;`memory_promote` / `memory_reject` 裁决 | +| L2 | 原子(长期记忆,可被召回) | `memory_save` / `memory_update` 直写;`memory_capture` 经流水线晋升;`memory_recall` / `memory_search` 召回 | +| L3 | 主题页(实体页) | 随晋升重新生成;可用 `memory_get(page/.md)` 读取 | + +按意图选工具: + +- 只想把相关背景拉进上下文 → `memory_recall` +- 要定位某条具体记忆并读全文 → `memory_search` 拿 `path`,再 `memory_get(path)` +- 要原话/证据,或内容刚写入还没晋升 → `memory_raws`(或 `memory_search(corpus="raw")`) +- 记录对话/事件 → `memory_capture`;记录明确事实/规则 → `memory_save`;纠正过时事实 → `memory_update` +- 处理审核队列 → `memory_candidates` → `memory_promote` / `memory_reject` --- -## 3. 使用示例 +## 4. 使用示例 + +> 下面默认客户端**直连** Octop 的 `/mcp/memory`。如果 Octop 前面还挂了一层网关/代理(例如由代理统一校验调用人身份并注入上游 token),只需把 URL 换成代理端点、按代理要求填它需要的凭证(通常是**一个** token 头);此时 `Authorization` / `X-Octop-User-Id` 由代理负责,**不要**在客户端重复配置。 -### 3.1 直接 HTTP(curl) +> 接入任一客户端都是**两步**:① 配好 MCP 服务器(§4.3 Kiro / §4.4 Claude Code / §4.5 DSH 各自的「配置」段)——这一步只让工具**可用**;② 再配**自动化**(Kiro 与 Claude Code 挂 hooks;DSH 新建并使用「共享记忆」预设)——这一步才会**自动召回、自动沉淀**。只做第 ① 步的话,每次都得手动让模型去调工具。§4.1 / §4.2 是最简的手动自测形态。 + +### 4.1 直接 HTTP(curl) ```bash TOKEN="" -AGENT="main" # 绑定的专家 -BASE="http://127.0.0.1:8088/mcp/memory" +AGENT="" +BASE="http://127.0.0.1:/mcp/memory" -# 初始化(MCP 握手) +# 1) 初始化(MCP 握手) curl -s -X POST "$BASE/" \ -H "Authorization: Bearer $TOKEN" \ -H "X-Octop-Agent-Id: $AGENT" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"cli","version":"1.0"}}}' + +# 2) 记录一条原始事件(L0) +curl -s -X POST "$BASE/" \ + -H "Authorization: Bearer $TOKEN" \ + -H "X-Octop-Agent-Id: $AGENT" \ + -H "X-Octop-User-Id: alice" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"memory_capture","arguments":{"content":"报告页横幅没有渲染出来","source":"review-bot"}}}' +``` + +其余工具只是换 `params.name` / `params.arguments`: + +```jsonc +// 直接保存一条事实(L2,立即可召回) +{ "name": "memory_save", + "arguments": { "content": "部署约定:记忆端点挂在 /mcp/memory", "source": "planning-agent", "topic": "octop-deploy" } } + +// 召回 +{ "name": "memory_recall", "arguments": { "query": "部署约定", "limit": 5 } } + +// 检索拿 path +{ "name": "memory_search", "arguments": { "query": "部署约定", "corpus": "atom" } } + +// 读全文(path 来自上一步 hits[].path) +{ "name": "memory_get", "arguments": { "path": "atom/.md", "lines": 40 } } +``` + +### 4.2 MCP 客户端 SDK(Python) + +```python +# pip install mcp +import asyncio + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def main(): + # 注意:endpoint 以 / 结尾;鉴权与专家绑定都放在 headers 里 + async with streamablehttp_client( + "http://127.0.0.1:/mcp/memory/", + headers={ + "Authorization": "Bearer ", + "X-Octop-Agent-Id": "", + "X-Octop-User-Id": "alice", + }, + ) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + tools = await session.list_tools() + print([t.name for t in tools.tools]) # 11 个 memory_* 工具 + + await session.call_tool( + "memory_capture", + {"content": "报告页横幅没有渲染出来", "source": "review-bot"}, + ) + res = await session.call_tool("memory_recall", {"query": "报告页 横幅"}) + print(res) + + +asyncio.run(main()) ``` -### 3.2 记录一条原始事件(L0) +### 4.3 Kiro + +Kiro 原生支持远程 MCP(`url` + `headers`)。配置分两级,同名条目以工作区为准: + +- 工作区:`.kiro/settings/mcp.json` +- 用户级:`~/.kiro/settings/mcp.json` +- 打开方式:命令面板 `Kiro: Open workspace MCP config (JSON)` / `Kiro: Open user MCP config (JSON)`;保存后自动热重载。 -```json +```jsonc { - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { - "name": "memory_capture", - "arguments": { - "content": "user reported: the report panel banner is not rendering", - "source": "review-bot", - "user": "alice" + "mcpServers": { + "octop-memory": { + "type": "streamable_http", + "url": "https:///mcp/memory/", + "headers": { + "Authorization": "Bearer ", + "X-Octop-Agent-Id": "", + "X-Octop-User-Id": "" + }, + "disabled": false } } } ``` -### 3.3 直接保存一条事实(L2) +Kiro 侧注意事项: + +- **远程 `url` 必须是 `https`**(只有本地回环允许 `http`)。 +- 工具名带**服务器名前缀**:服务器名 `octop-memory` → 工具 `mcp_octop_memory_memory_recall`;在 hooks 或提示词里必须写全名。 +- 工具默认不放行:在 `~/.kiro/settings/permissions.yaml` 里找到 `capability: mcp` 的 `match` 列表,按需加 `effect: allow`(只读先放 `memory_recall` / `memory_raws`,需要沉淀再放 `memory_capture` / `memory_save`)。 +- 用 `${VAR}` 引用环境变量时,要先把变量加入 Kiro 的允许列表(设置项 `Mcp Approved Env Vars`),否则不会被展开。 +- 校验:会话里执行 `/mcp`,确认服务器已连接、工具已加载。 + +#### 自动化:hooks(配好 MCP 之后必配) + +**只配 `mcp.json` 只是"工具能用"——不会自动召回、也不会自动沉淀,必须再挂 hooks。** Kiro 的 hooks 放在 `~/.kiro/hooks/*.json`,用 `trigger` + `action.type: agent` 让模型在固定时机去调对应工具。 + +提交提示词时召回 —— `~/.kiro/hooks/memory-recall-on-prompt-submit.json`: + +```jsonc +{ + "version": "v1", + "hooks": [ + { + "name": "Recall Memory on Prompt Submit", + "trigger": "UserPromptSubmit", + "action": { + "type": "agent", + "prompt": "回答前,按需主动召回相关记忆来辅助本次工作。结合用户本次输入与当前项目上下文构造查询,调用 `mcp_octop_memory_memory_recall`。若本次输入与已召回记忆无关可跳过,不要重复召回同一查询。" + }, + "description": "收到消息时自动召回相关记忆,提供上下文连续性。", + "enabled": true + } + ] +} +``` + +会话结束时沉淀 —— `~/.kiro/hooks/memory-save-on-stop.json`: + +```jsonc +{ + "version": "v1", + "hooks": [ + { + "name": "Save Memory on Session End", + "trigger": "Stop", + "action": { + "type": "agent", + "prompt": "会话结束前静默沉淀:1) 调用 `mcp_octop_memory_memory_capture` 记录本次会话(source=\"kiro-session\",session_id 形如 \"session-YYYY-MM-DD-主题\");2) 对值得长期保留的事实(偏好、约定、架构决策、环境配置)调用 `mcp_octop_memory_memory_save`(source=\"kiro-session\")。只写对未来确有价值的信息;成功后一句话告知,不要复述会话内容。" + }, + "description": "会话结束时把可复用事实沉淀到 octop-memory。", + "enabled": true + } + ] +} +``` + +改完 hooks / permissions 后重载或重启 Kiro 生效。 + +### 4.4 Claude Code + +Claude Code 直接支持远程 HTTP + 自定义请求头: + +```bash +claude mcp add --transport http octop-memory https:///mcp/memory/ \ + --header "Authorization: Bearer " \ + --header "X-Octop-Agent-Id: " \ + --header "X-Octop-User-Id: " +``` + +`--header` 可重复(短写 `-H`)。也可以直接写配置文件(`.mcp.json` 项目级 / `~/.claude/settings.json` 用户级): -```json +```jsonc { - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": { - "name": "memory_save", - "arguments": { - "content": "机票记忆体系 M1 采用 Octop 专家记忆平台作为目标服务", - "source": "planning-agent", - "topic": "flight-memory-system" + "mcpServers": { + "octop-memory": { + "type": "http", + "url": "https:///mcp/memory/", + "headers": { + "Authorization": "Bearer ${OCTOP_MEMORY_MCP_TOKEN}", + "X-Octop-Agent-Id": "", + "X-Octop-User-Id": "" + } } } } ``` -### 3.4 召回 +Claude Code 侧注意事项: + +- Streamable HTTP 写 `"type": "http"`(MCP 规范名 `streamable-http` 也接受);**带 `url` 就必须带 `type`**,否则该条目会被当作 stdio 服务器跳过。 +- 工具名带前缀:`mcp__octop-memory__memory_recall`。 +- 排查:`claude mcp list`、`claude mcp get octop-memory`,会话内 `/mcp`。 -```json +#### 自动化:hooks(配好 MCP 之后必配) + +同样,**只加 MCP 服务器不会自动召回/沉淀**。Claude Code 在 `settings.json` 的 `hooks` 字段挂命令脚本(脚本放 `~/.claude/hook-events/`),两个事件各一个: + +| 脚本 | 事件 | 作用 | +|---|---|---| +| `octop-user-prompt-recall.mjs` | `UserPromptSubmit` | 向 stdout 注入「先召回」的指令;真正调用工具的是模型,脚本**不直连 MCP**,任何异常静默放行、不阻断本轮 | +| `octop-stop-capture.mjs` | `Stop` | 输出 `{"decision":"block","reason":"..."}`,让模型在追加的一轮里执行 `memory_capture`;`stop_hook_active` 为真时直接退出,防止召回/沉淀互相触发成死循环 | + +```jsonc +// ~/.claude/settings.json { - "jsonrpc": "2.0", - "id": 4, - "method": "tools/call", - "params": { - "name": "memory_recall", - "arguments": { "query": "机票记忆体系", "limit": 5 } + "hooks": { + "UserPromptSubmit": [ + { "hooks": [ { "type": "command", "command": "node ~/.claude/hook-events/octop-user-prompt-recall.mjs" } ] } + ], + "Stop": [ + { "hooks": [ { "type": "command", "command": "node ~/.claude/hook-events/octop-stop-capture.mjs" } ] } + ] } } ``` -### 3.5 用 MCP 客户端 SDK +`octop-user-prompt-recall.mjs` 最小实现: -```python -# pip install mcp -import asyncio -from mcp import ClientSession -from mcp.client.streamable_http import streamablehttp_client +```javascript +#!/usr/bin/env node +// UserPromptSubmit:把「先召回」指令注入本轮上下文;真正调用 MCP 工具的是模型。 +process.stdout.write( + "[octop-memory] 回答前先按需召回相关记忆:结合用户输入与当前项目上下文构造查询," + + "调用 MCP 工具 mcp__octop-memory__memory_recall;与本轮无关可跳过,不要重复召回同一查询。\n", +); +``` -async def main(): - # 注意:endpoint 需以 / 结尾,且须带鉴权与专家绑定头 - async with streamablehttp_client( - "http://127.0.0.1:8088/mcp/memory/", - headers={"Authorization": "Bearer ", - "X-Octop-Agent-Id": "main"}, - ) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - tools = await session.list_tools() - print([t.name for t in tools.tools]) # 9 个 memory_* 工具 - res = await session.call_tool("memory_recall", {"query": "octop 沙箱"}) - print(res) +`octop-stop-capture.mjs` 最小实现: -asyncio.run(main()) +```javascript +#!/usr/bin/env node +// Stop:让模型多跑一轮,把本轮可复用事实写入 L0。 +import fs from "node:fs"; + +let payload = {}; +try { + const raw = fs.readFileSync(0, "utf8"); + if (raw.trim()) payload = JSON.parse(raw); +} catch { + // 解析失败也继续:沉淀不该因为载荷缺字段而丢掉 +} + +if (payload.stop_hook_active) process.exit(0); // 防召回/沉淀互相触发 + +process.stdout.write( + JSON.stringify({ + decision: "block", + reason: + "会话结束前静默沉淀:调用 mcp__octop-memory__memory_capture 写入本轮可复用事实" + + '(source="claude-code");没有可沉淀内容就不要写入,成功后一句话告知即可。', + }) + "\n", +); +``` + +### 4.5 DSH(DeepSeek Harness) + +DSH 在 **Settings → MCP** 里管理 MCP 服务器(定义持久化在 `~/.dsh/storages/mcp_servers.json`)。设置页新增一条 Streamable HTTP 服务器时,对应的字段如下: + +```jsonc +{ + "serverName": "octop-memory", + "transport": "streamable-http", + "enabled": true, + "url": "https:///mcp/memory/", + "headers": [ + { "name": "Authorization", "value": "Bearer ${OCTOP_MEMORY_MCP_TOKEN}" }, + { "name": "X-Octop-Agent-Id", "value": "" }, + { "name": "X-Octop-User-Id", "value": "" } + ], + "toolCallTimeoutMs": 60000, + "failOnStartupError": true +} +``` + +注意事项: + +- `transport` 取 `streamable-http`(另支持 `stdio`);`enabled: false` 停用整条;`failOnStartupError` 决定连接失败是否阻断启动。 +- 设置页里的 Headers 是每行一个 `名称: 值`,值支持 `${ENV}` 替换;密钥类变量交给 DSH 的全局环境变量/凭据存储,不要写进明文配置。 +- DSH 默认**按需注入** MCP 工具:会话里先检索一次,才会把 `memory_*` 挂进当前对话——所以只加服务器**更不会**自动召回/沉淀。 +- 新增/修改服务器后按提示重连;若工具列表没有刷新,重启 `dsh web`。 + +#### 自动化:共享记忆预设(配好 MCP 之后必配) + +和 Kiro / Claude Code 要挂 hooks 一样,DSH 这边还要**新增一个「共享记忆」模式的 agent preset,并用它开会话**,自动化才会发生: + +- 会话首轮:先 `memory_recall`,把相关原子记忆拉进上下文; +- 每轮结束:若本轮产生了可沉淀的事实,调用一次 `memory_capture`(`source` 标记客户端来源、`session_id` 用工作目录派生);寒暄或已记录内容不重复写。 + +预设是一个目录(`~/.dsh/.agent-presets/<你的预设>/`),含 `preset.yml`(名称与描述)和 `agent.cordis.yml`(该预设的完整组合): + +```text +<你的预设>/ + preset.yml + agent.cordis.yml # 共享记忆策略写在 persona 文本末尾 ``` +`agent.cordis.yml` 的 `persona` 里追加的策略段(等价于 Kiro / Claude Code 的 hooks): + +```text +── 共享记忆 (shared memory) ───────────────────────────── +This preset auto-reads and auto-writes the octop-memory expert store so durable facts carry across sessions and experts: memory_recall at conversation start, memory_capture at turn end. + +会话开始时(本会话首轮、尚无历史):若工具列表里没有 `memory_recall`,先按需注入 octop-memory 工具,再调用 `memory_recall(query=用户当前目标全文)`,把相关原子记忆纳入上下文。 + +每轮结束时(给出最终回复前):若本轮产生了可沉淀的事实(需求、决定、偏好、约定、结论、关键路径),调用一次 `memory_capture`: + content: 1–3 句简洁事实摘要(不写机密、不做原始转储) + source: 'dsh:shared-memory' + session_id: 'ext:dsh:<当前工作目录名>' +寒暄或已记录内容不重复记录;没有可沉淀事实就不调用。 +``` + +- 从自带预设**拷贝到自己目录再改**,不要直接改部署自带的预设(升级会覆盖)。 +- 建好后要在会话里**选择这个预设**才生效;已经在跑的会话不会自动切换。 + +### 4.6 其它 MCP 客户端 + +多数客户端共用同一份 JSON 描述一个 Streamable HTTP server: + +```jsonc +{ + "mcpServers": { + "octop-memory": { + "type": "streamable-http", + "url": "https:///mcp/memory/", + "headers": { + "Authorization": "Bearer ", + "X-Octop-Agent-Id": "", + "X-Octop-User-Id": "" + } + } + } +} +``` + +只支持 stdio 的客户端可以用 `mcp-remote` 这类桥接工具转发到远程端点;`type` 的取值随客户端而异(有的写 `http`,有的写 `streamable-http`),以客户端文档为准。自动化同样取决于该客户端有没有 hooks / 预设机制:只挂服务器通常只解决"工具可用"。 + --- -## 4. 部署与配置 +## 5. 部署与配置 | 配置 | 说明 | |---|---| -| `OCTOP_MEMORY_MCP_TOKEN` | 必填;未设置则 `/mcp/memory` 不挂载(fail-closed) | -| `X-Octop-Agent-Id` | 连接时必填;专家 id(`octop agent list` 可查) | -| 记忆后端 | 默认 SQLite(`~/.octop/agents//memory.sqlite`);PG 控制面显式开启 | +| `OCTOP_MEMORY_MCP_TOKEN` | 必填。未设置时 `/mcp/memory` 不挂载(fail-closed) | +| `X-Octop-Agent-Id` | 每个请求必填,指向一个存在且启用的专家 | +| `X-Octop-User-Id` | 可选;用于把发送者写进记忆正文(§1.3) | +| 记忆后端 | 由专家配置 `memory.backend` 决定:默认 SQLite(`memory.sqlite`);PostgreSQL 控制面下默认复用控制面 DSN(每专家 schema) | -### 4.1 开启步骤 +开启步骤: -1. 设置环境变量 `OCTOP_MEMORY_MCP_TOKEN=`(可写入 `~/.octop/env`,启动自动加载)。 -2. 重启 `octop run`。 -3. 验证:`curl -i http://:/mcp/memory/` 应返回 `401`(未带 token)或 MCP 协议响应(带 token)。 -4. 外部 agent 按 §1 连接信息接入。 +1. 设置环境变量 `OCTOP_MEMORY_MCP_TOKEN=`。 +2. 重启 Octop 服务(挂载发生在启动阶段)。 +3. 验证:不带 token 请求 `/mcp/memory/` 应返回 `401`;带 token 且带合法 + `X-Octop-Agent-Id` 时应返回 MCP 协议响应(未知/停用专家返回 `404`)。 +4. 外部 agent 按 §1 / §4 接入。 --- -## 5. 与进程内 MemoryService 的关系 - -| MCP 工具 | MemoryService 对应 | 层级 | -|---|---|---| -| `memory_capture` | `add_raw` | L0 原始事件 | -| `memory_extract` / `memory_promote` / `memory_reject` | 提取流水线(extract → candidate → promote) | L0 → L1 → L2 | -| `memory_raws` | 查询 raw events | L0 | -| `memory_candidates` | 查询候选队列 | L1 | -| `memory_recall` / `memory_save` / `memory_update` | 原子/树读写 | L2 | - -写入链路:`capture`(L0)→ `extract`(L1 候选)→ `promote`(L2 原子);直接 `save` 则跳过提取直写 L2。 +## 6. 行为契约与边界 + +- **写入分两条通道**:`memory_capture` 走"提取 → 候选 → 晋升"(学习型记忆,质量由流水线把关); + `memory_save` / `memory_update` 是权威直写(规则/明确事实,立即可召回)。 + 晋升治理(提取触发、候选审核)保留在服务端,站内会话与外部写入走**同一套**记忆治理路径。 +- **capture 之后 recall 无结果属预期**:内容还在 L0,需要经提取晋升成原子才会被召回; + 想立刻看到请用 `memory_raws` 或 `memory_search(corpus="raw")`。 +- **capture 幂等**:同 `session_id` + 同内容不重复落库(见 §2.2)。 +- **召回是专家级共享**,不做按人隔离;按发送者定位依赖正文里的 `说:` 前缀 + 全文检索。 +- **错误形态**:`memory_get` 的坏路径返回 `{error, hint}`;`memory_search` 的非法 `corpus`、 + `memory_candidates` 的非法 `status` 直接报错(参数错误不会被静默吞掉)。 diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py index dbfdb3b40..b7d75472a 100644 --- a/src/octop/infra/agents/memory_mcp.py +++ b/src/octop/infra/agents/memory_mcp.py @@ -6,8 +6,9 @@ on recall. Expert binding: the endpoint is a single ``/mcp/memory`` mount; the expert is -selected at connect time via the ``X-Octop-Agent-Id`` header (one connection -binds one expert — the caller never passes an agent id per tool call). +selected per request via the ``X-Octop-Agent-Id`` header, validated against the +agent registry on every call — the caller never passes an agent id per tool +call, and the URL itself does not leak expert ids. raw vs atom (aligned with ``MemoryService``): @@ -20,6 +21,24 @@ canonical atom/tree (durable, no extraction). Use it when you already know the exact fact to remember. +Read surface (three tools, same storage as the in-process ``memory_search`` / +``memory_get`` exposed to Octop's own agents): + +* ``memory_recall`` -> ``recall_for_prompt``: ranked, prompt-injectable text. + L2 atoms first; ``page`` headlines are folded into atom hits; L0 raw is only + a fallback (dropped as soon as any atom matches). +* ``memory_search`` -> ``MemoryRuntime.memory_search`` (``corpus=raw`` uses + ``Memory.search_raw``): the same ranking, returned as hits that carry a + virtual ``path`` instead of rendered markdown. +* ``memory_get`` -> ``MemoryRuntime.memory_get``: resolve that path to the full + markdown (``atom/.md`` / ``page/.md`` / ``raw//.md``). + +Sender attribution: ``X-Octop-User-Id`` identifies the caller, and every write +(``memory_capture`` / ``memory_save`` / ``memory_update``) prefixes the content +with ``说:``. harness-memory's ``AtomCard`` has no user column, so putting +the sender into the text is what makes it reach the atom, stay FTS-searchable +(a query naming the sender matches), and remain visible on recall. + Auth: independent token via ``OCTOP_MEMORY_MCP_TOKEN`` (fail-closed when unset), enforced by the ASGI middleware in ``mount_memory_mcp``. """ @@ -40,11 +59,20 @@ logger = logging.getLogger(__name__) -# 当前 MCP HTTP 请求的调用者 user id(由 _AgentRouter 中间件写入,工具读取)。 +# 当前 MCP HTTP 请求的绑定状态(由 _AgentRouter 中间件写入,工具读取)。 # stateless streamable HTTP 下 mcp SDK 不提供 ctx.request_context,故用 contextvar -# 跨 ASGI 中间件 → 工具传递,供 memory_capture/save 做 per-user 追溯。 +# 跨 ASGI 中间件 → 工具传递: +# - _current_agent_id: 本次请求绑定的 expert(X-Octop-Agent-Id 校验后写入) +# - _current_caller_user: 调用者 user id(供 memory_capture/save 做 per-user 追溯) +_current_agent_id: ContextVar[str] = ContextVar("octop_mcp_agent_id", default="") _current_caller_user: ContextVar[str] = ContextVar("octop_mcp_caller_user", default="") +_SEARCH_CORPORA: frozenset[str] = frozenset({"all", "memory", "atom", "raw"}) +"""Corpora ``memory_search`` accepts. ``atom``/``raw`` are layer filters over the +recall pipeline; ``all``/``memory`` are the same pipeline without a filter.""" + +_SNIPPET_CHARS = 200 + def _open_memory(server: OctopServer, agent_id: str) -> Any: """Open the agent's ``Memory`` instance (sqlite by default, postgres opt-in). @@ -86,10 +114,63 @@ def _open_memory(server: OctopServer, agent_id: str) -> Any: return Memory(namespace=ns, backend=backend, backend_config=backend_config) -def build_memory_mcp(server: OctopServer, agent_id: str) -> FastMCP: - """Build an MCP server bound to one expert (``agent_id`` captured in closure).""" +def _snippet(text: str) -> str: + """Cap a raw event body so ``memory_search`` hits stay small (``memory_get`` reads the rest).""" + body = (text or "").strip() + return body if len(body) <= _SNIPPET_CHARS else body[: _SNIPPET_CHARS - 1].rstrip() + "…" + + +def _attributed(content: str, caller: str) -> str: + """Prefix the sender so the caller id lives inside the text. + + harness-memory's ``AtomCard`` has no user column, so the sender is stamped + by writing ``说:`` into the content itself: it then reaches the atom's + assertion (manual writes) or its raw event (captured events), stays + FTS-searchable, and shows up verbatim on recall. Already-prefixed content is + left untouched so a re-capture cannot double it. + """ + name = (caller or "").strip() + if not name: + return content + prefix = f"{name}说:" + return content if content.startswith(prefix) else prefix + content + + +def _pipeline_hits( + memory: Any, query: str, max_results: int, *, atom_only: bool +) -> list[dict[str, Any]]: + """Run the in-process multi-source search and return its path-carrying hits. + + ``atom_only`` keeps just L2 atoms, so the request is widened first — + otherwise raw hits could crowd the atoms out before the filter runs. + """ + from harness_memory.application.runtime import MemoryRuntime # noqa: PLC0415 + + runtime = MemoryRuntime(memory) + result = runtime.memory_search( + { + "query": query, + "maxResults": max_results * 4 if atom_only else max_results, + "corpus": "memory", + } + ) + hits = list(result.get("hits") or []) + if atom_only: + hits = [hit for hit in hits if hit.get("layer") == "atom"] + return hits[:max_results] + + +def build_memory_mcp(server: OctopServer) -> FastMCP: + """Build the shared memory MCP app (expert bound per request, not per build). + + The expert is selected at request time by ``X-Octop-Agent-Id`` (validated + against the agent repo by ``_AgentRouter``) and carried to the tools via the + ``_current_agent_id`` contextvar. A single app is shared by every expert, so + agents created or disabled after process start are honored immediately — + no process restart is needed to pick up new agents. + """ mcp = FastMCP( - f"octop-memory-{agent_id}", + "octop-memory", # Stateless streamable HTTP: every request gets a fresh transport, no # Mcp-Session-Id tracking. Session state is in-memory per process, so a # server restart silently orphans every client session id and the next @@ -107,8 +188,15 @@ def build_memory_mcp(server: OctopServer, agent_id: str) -> FastMCP: # /mcp/memory (the default "/mcp" would make it /mcp/memory/mcp). mcp.settings.streamable_http_path = "/" + def _agent_id() -> str: + """Agent bound to this request (set by ``_AgentRouter`` from the header).""" + agent_id = _current_agent_id.get() + if not agent_id: + raise RuntimeError("X-Octop-Agent-Id header not bound to this request") + return agent_id + def _memory() -> Any: - return _open_memory(server, agent_id) + return _open_memory(server, _agent_id()) def _caller_user(ctx: Any | None) -> str: """读取当前 MCP 请求的调用者 user id。 @@ -131,7 +219,6 @@ def _derive_session(source: str, user: str) -> str: """ return f"ext:{source or 'mcp'}:{user or 'anon'}" - @mcp.tool() def memory_recall( query: str, @@ -139,22 +226,27 @@ def memory_recall( user: str | None = None, ctx: Context | None = None, # type: ignore[type-arg] ) -> dict[str, Any]: - """Recall memories from this expert (aligned with the in-process recall_inject). + """**召回专家记忆(读入口首选)**:把与 query 相关的记忆召回进上下文。 - **日常使用**:每次对话/任务开始前调用,把专家记忆中与 query 相关的 - atom 召回注入上下文。运行完整召回管线(tokenize -> FTS -> rerank -> - dedupe),返回结构化片段 + 可注入 system prompt 的 markdown 块。 + 每次对话/任务开始前先调一次。运行完整召回管线(分词 → 路由 → FTS → + 重排 → 去重 → token 预算),返回结构化片段 + 可直接注入 system prompt 的 markdown。 - 调用者身份(``X-Octop-User-Id`` header 或 ``user`` 参数)会记录在 - 返回的 ``caller`` 字段,供按调用者追溯召回来源;记忆本身是专家级 - 共享,不按用户隔离。 + 三个读工具怎么选: + - 只想把相关背景拉进上下文 → 用本工具(一次调用,``rendered`` 直接可注入)。 + - 要**定位某条具体记忆并读全文** → ``memory_search`` 拿 ``path``, + 再 ``memory_get(path)`` 读完整 markdown(支持分页)。 + - 要**原话/证据**,或 ``memory_capture`` 刚写入、还没晋升成原子的内容 → + ``memory_raws``(L0 全文检索,capture 后立即可见)。 + + 覆盖范围(与内置 ``memory_search`` 同一套管线):L2 原子优先,``page`` + 主题页标题会并入 atom 命中;L0 原始事件只做兜底——只要有原子命中,raw 就被 + 整层丢弃。L1 候选不在召回范围内,请用 ``memory_candidates``。 Args: - query: free-form question / keywords (pass the whole sentence; the - pipeline tokenizes CJK into n-grams internally). - limit: max number of snippets to return. - user: optional caller id (overrides the ``X-Octop-User-Id`` header). - ctx: injected MCP context (reads ``X-Octop-User-Id`` header). + query: 自然语言问题/关键词,整句传入(内部对中文做 n-gram 分词)。 + limit: 最多返回片段数,默认 5。 + user: 可选调用者 id(覆盖 ``X-Octop-User-Id`` 头)。 + ctx: MCP 注入的上下文(读取 ``X-Octop-User-Id`` 头)。 """ from harness_memory.pipeline.recall import recall_for_prompt # noqa: PLC0415 @@ -176,6 +268,102 @@ def memory_recall( "caller": caller or None, } + @mcp.tool() + def memory_search( + query: str, + max_results: int = 5, + corpus: str = "all", + ) -> dict[str, Any]: + """**检索记忆(返回可下钻的 path)**:全文检索记忆,返回带虚拟路径的命中列表。 + + 与 ``memory_recall`` 走同一套召回/重排管线,区别是本工具不渲染 markdown,而是给 + 每条命中一个 ``path``,交给 ``memory_get`` 读全文。要"引用出处/读全文"用 + search + get,只想"把背景拉进上下文"用 ``memory_recall``,要"原话/证据"用 + ``memory_raws``。 + + Args: + query: 自然语言问题/关键词(中文会做 n-gram 分词)。 + max_results: 最多返回命中数,默认 5。 + corpus: 检索范围: + ``all``(默认)/``memory`` = 原子(L2)+原始事件(L0)同一套管线; + ``atom`` = 只要 L2 原子命中; + ``raw`` = 直接走 L0 全文检索,不受"有原子命中就丢 raw"的兜底策略影响, + 适合找刚 ``memory_capture``、还没晋升成原子的内容。 + """ + corpus_value = (corpus or "all").strip().lower() + if corpus_value not in _SEARCH_CORPORA: + raise ValueError( + f"invalid corpus {corpus!r}; expected one of {sorted(_SEARCH_CORPORA)}" + ) + memory = _memory() + if corpus_value == "raw": + from harness_memory.application.path_projection import raw_to_path # noqa: PLC0415 + + hits = [ + { + "path": raw_to_path(event), + "layer": "raw", + "snippet": _snippet(event.content), + "occurred_at": event.timestamp.isoformat(), + "source_id": event.id, + } + for event in memory.search_raw(query, limit=max_results) + ] + else: + hits = _pipeline_hits(memory, query, max_results, atom_only=corpus_value == "atom") + return { + "hits": hits, + "total": len(hits), + "corpus": corpus_value, + "hint": "每条命中自带 path,可交给 memory_get(path) 读全文", + } + + @mcp.tool() + def memory_get( + path: str, + start: int | None = None, + lines: int | None = None, + ) -> dict[str, Any]: + """**读取记忆全文**:把 ``memory_search`` / ``memory_recall`` 命中的虚拟路径解析成 markdown。 + + 支持的路径形态:``atom/.md``(L2 原子)、``page/.md`` + (L3 主题页)、``raw//.md``(L0 原始事件)。长内容用 + ``start`` / ``lines`` 分页(配合返回的 ``total_lines`` / ``truncated``)。 + + Args: + path: 虚拟路径,取自 ``memory_search`` 的 ``hits[].path``。 + start: 可选起始行号(1-based)。 + lines: 可选返回行数。 + """ + from harness_memory.application.runtime import MemoryRuntime # noqa: PLC0415 + + params: dict[str, Any] = {"path": path} + if start is not None: + params["from"] = start + if lines is not None: + params["lines"] = lines + try: + result = MemoryRuntime(_memory()).memory_get(params) + except Exception as exc: # stale / mistyped path from a previous call + return { + "path": path, + "error": f"{exc.__class__.__name__}: {exc}", + "hint": ( + "path 形如 atom/.md / page/.md / " + "raw//.md,取自 memory_search 的 hits[].path" + ), + } + return { + "path": result.get("path", path), + "kind": result.get("kind"), + "content": result.get("excerpt") or "", + "total_lines": result.get("total_lines"), + "from_line": result.get("from_line"), + "to_line": result.get("to_line"), + "truncated": result.get("truncated"), + "metadata": result.get("metadata") or {}, + } + @mcp.tool() def memory_save( content: str, @@ -184,25 +372,24 @@ def memory_save( user: str | None = None, ctx: Context | None = None, # type: ignore[type-arg] ) -> dict[str, Any]: - """Persist a structured fact directly (atom/tree, durable, no extraction). + """**直接保存事实**:把一条已知事实写入原子层(跳过提取,立即可召回)。 - **显式记忆**(非日常):仅当你知道一个明确的、需要长期记住的事实 - 时才调用(如用户偏好、项目约定)。立即通过 ``memory_recall`` 可召回, - 不经过提取管线。日常对话内容请用 ``memory_capture`` 交给自动提取。 - The source marker is stored in ``metadata.source``; the caller id (from - ``X-Octop-User-Id`` header or ``user`` arg) is stored in ``metadata.user``. + 用于明确、需长期记住的事实(如用户偏好、项目约定)。日常对话内容请用 + ``memory_capture`` 交给提取管线。来源写入 ``metadata.source``,调用者写入 ``metadata.user``; + 调用者 id 还会自动拼进内容前缀(``说:…``),这样它随原子一起落库、可被检索、 + 召回时直接可见——不要把名字重复写进 ``content``。 Args: - content: the fact to remember. - source: who/what recorded it (e.g. "coding-agent"), for traceability. - topic: optional topic label. - user: optional caller id (overrides the ``X-Octop-User-Id`` header). - ctx: injected MCP context (reads ``X-Octop-User-Id`` header). + content: 要记住的事实。 + source: 谁记录的(如 "coding-agent"),用于追溯。 + topic: 可选主题标签。 + user: 可选调用者 id(覆盖 ``X-Octop-User-Id`` 头)。 + ctx: MCP 注入的上下文(读取 ``X-Octop-User-Id`` 头)。 """ caller = user or _caller_user(ctx) memory = _memory() node = memory.store( - content, + _attributed(content, caller), topic=topic, metadata={"source": source, **({"user": caller} if caller else {})}, ) @@ -221,71 +408,58 @@ def memory_capture( user: str | None = None, ctx: Context | None = None, # type: ignore[type-arg] ) -> dict[str, Any]: - """Record a raw event to L0 (goes through extraction: extract -> candidate -> atom). - - **日常使用**:把对话/事件原始内容记录下来,交给自动提取流水线 - (extract -> candidate -> promote -> atom),稍后经 ``memory_recall`` - 可召回。记录后立即可用 ``memory_raws`` 查询。The source marker - is stored in ``payload.source``; the caller id (from ``X-Octop-User-Id`` - header or ``user`` arg) is stored on the raw event for per-user - traceability. + """**记录原始事件**:把一条原始内容写入 L0,交给提取流水线。 - If ``session_id`` is omitted it is derived as ``ext:{source}:{user}`` - so external callers without an Octop native session still get their raw - events grouped and distilled into atoms (extraction groups by session). - - Example:: - - memory_capture( - content="user reported: the report panel banner is not rendering", - source="review-bot", - ) - # -> {"event_id": "...", "recorded": true, "extract_scheduled": true, ...} - # later: memory_recall(query="report panel banner not rendering") + 日常使用入口:记录对话/事件,经 提取 → 候选 → 晋升 → 原子 成为记忆。 + 记录后立即可用 ``memory_raws`` 查询,晋升后才可被 ``memory_recall`` 召回。 + 调用者 id(``X-Octop-User-Id``)会自动拼成内容前缀 ``说:…``:发送者由此进入 + 原子正文,既能被 FTS 直接搜到,也能在召回时一眼看出是谁说的——**不要**自己再 + 写一遍名字。 Args: - content: the raw conversation / event text. - source: who/what recorded it, for traceability. - session_id: optional stable session id (e.g. caller name) so the - extraction pipeline can group events by session. When omitted, - derived as ``ext:{source}:{user}``. - user: optional caller id (overrides the ``X-Octop-User-Id`` header). - ctx: injected MCP context (reads ``X-Octop-User-Id`` header). + content: 原始对话/事件内容(不含发送者前缀)。 + source: 谁记录的,用于追溯。 + session_id: 可选会话 id,用于提取分组;缺省派生为 ``ext:{source}:{user}``。 + user: 可选调用者 id(覆盖 ``X-Octop-User-Id`` 头)。 + ctx: MCP 注入的上下文(读取 ``X-Octop-User-Id`` 头)。 """ caller = user or _caller_user(ctx) effective_session = session_id or _derive_session(source, caller) memory = _memory() + stored_content = _attributed(content, caller) # Idempotent capture: skip if an identical raw event (same session + # content) already exists, so re-ingesting the same conversation does # not duplicate L0 events. Keeps downstream extraction re-runnable. try: for ev in memory.list_raw(session_id=effective_session, limit=1000): - if getattr(ev, "content", None) == content: + if getattr(ev, "content", None) == stored_content: return { "event_id": ev.id, + "content": ev.content, "source": source, "user": caller or None, "session_id": effective_session, "recorded": True, "duplicate": True, - "note": "raw (L0) event already present; skipped (idempotent capture)", + "note": ("raw (L0) event already present; skipped (idempotent capture)"), } except Exception: # noqa: BLE001 # If duplicate detection fails, fall back to recording (safe). pass raw = memory.add_raw( - content, + stored_content, event_type="manual", host="mcp-external", session_id=effective_session, user=caller or None, payload={"source": source}, ) - extract_scheduled = _trigger_extract(server, agent_id, effective_session) + extract_scheduled = _trigger_extract(server, effective_session) return { "event_id": raw.id, + "content": raw.content, "source": source, "user": caller or None, "session_id": effective_session, @@ -307,26 +481,25 @@ def memory_update( user: str | None = None, ctx: Context | None = None, # type: ignore[type-arg] ) -> dict[str, Any]: - """Update a memory: deprecate the old atom and persist the new fact. + """**更新记忆**:废弃旧原子并写入新事实。 - **显式更新**(非日常):仅当已知旧记忆已过时、需要替换时才调用 - (如用户纠正了一个事实)。旧 atom 标记 deprecated,新事实立即经 - ``memory_recall`` 可召回。日常纠错也可以走 ``memory_capture`` 让 - 提取管线处理。 + 用于旧记忆已过时、需替换的场景(如纠正事实)。旧原子标记 deprecated, + 新事实立即可被 ``memory_recall`` 召回,带 ``supersedes`` 关联。 + 与 ``memory_save`` 一样,调用者 id 会自动拼进内容前缀(``说:…``)。 Args: - atom_id: id of the atom to supersede. - new_content: the replacement fact. - source: who/what updated it, for traceability. - note: deprecation note. - user: optional caller id (overrides the ``X-Octop-User-Id`` header). - ctx: injected MCP context (reads ``X-Octop-User-Id`` header). + atom_id: 要废弃的旧原子 id。 + new_content: 替代的新事实(不含发送者前缀)。 + source: 谁更新的,用于追溯。 + note: 废弃说明,默认 "mcp update"。 + user: 可选调用者 id(覆盖 ``X-Octop-User-Id`` 头)。 + ctx: MCP 注入的上下文(读取 ``X-Octop-User-Id`` 头)。 """ caller = user or _caller_user(ctx) memory = _memory() deprecated = memory.deprecate_atom(atom_id, actor="user", note=note) node = memory.store( - new_content, + _attributed(new_content, caller), metadata={ "source": source, "supersedes": atom_id, @@ -354,27 +527,30 @@ def memory_raws( limit: int = 50, ctx: Context | None = None, # type: ignore[type-arg] ) -> dict[str, Any]: - """**L0 原始事件查询**:FTS 搜索或结构化过滤原始事件(证据源)。 + """**查原始事件**:FTS 搜索或结构化过滤 L0 原始事件(证据源)。 - 合并了原 ``memory_search_raw``(全文搜索)与结构化过滤两种能力: - ``query`` 走 FTS 全文搜索(capture 立即可见,提取前也可查), - ``session_id``/``host``/``user`` 做结构化过滤。按时间倒序返回。 + ``query`` 走全文搜索(capture 后立即可见),``session_id``/``host``/``user`` + 做结构化过滤,按时间倒序返回。 Args: - query: FTS keywords to match raw event content (optional). - session_id: filter by session (e.g. ``ext:review-bot:user-alice``). - host: filter by recording host (e.g. ``mcp-external``). - user: filter by caller user id (also read from X-Octop-User-Id). - limit: max events (default 50). - ctx: injected MCP context. + query: FTS 关键词(可选)。 + session_id: 按会话过滤(如 ``ext:review-bot:user-alice``)。 + host: 按记录主机过滤(如 ``mcp-external``)。 + user: 按调用者过滤。 + limit: 最多返回条数,默认 50。 + ctx: MCP 注入的上下文。 """ caller = user or _caller_user(ctx) memory = _memory() - events = memory.search_raw(query, limit=limit) if query else memory.list_raw( - session_id=session_id, - host=host, - user=user or (caller or None), - limit=limit, + events = ( + memory.search_raw(query, limit=limit) + if query + else memory.list_raw( + session_id=session_id, + host=host, + user=user or (caller or None), + limit=limit, + ) ) return { "events": [ @@ -399,16 +575,15 @@ def memory_candidates( session_id: str | None = None, limit: int = 50, ) -> dict[str, Any]: - """**L1 候选记忆列表**:查询待审核/已晋升/已拒绝的候选(提取产物)。 + """**查候选记忆**:列出 L1 候选(默认 pending 队列)。 - 候选由 ``memory_capture`` 触发的提取流水线生成(或手动 - ``memory_extract``)。默认返回 pending 队列;可用 ``status`` 过滤 + 候选由 ``memory_capture``/``memory_extract`` 生成。可用 ``status`` 过滤 (pending / promoted / rejected / needs_review / conflict)。 Args: - status: filter by candidate status (default pending). - session_id: filter by source session. - limit: max candidates (default 50). + status: 候选状态过滤,默认 pending。 + session_id: 按来源会话过滤。 + limit: 最多返回条数,默认 50。 """ memory = _memory() from harness_memory.core import CandidateStatus # noqa: PLC0415 @@ -451,21 +626,19 @@ def memory_extract( limit: int = 100, promote: bool = False, ) -> dict[str, Any]: - """**手动触发记忆提取**(L0 → L1,可选直达 L2):调度生产流水线。 + """**手动触发提取**:把最近 L0 原始事件提取为候选(可选直达原子)。 - 复用专家进程内 ``MemoryService``(含配置的提取 LLM)同步执行提取: - 取最近 ``limit`` 条 L0 原始事件 → LLM 类型化提取 → 候选(pending); - ``promote=True`` 时对候选执行晋升检查(L1 → L2 atom),跳过人工审核。 - 运行时无 MemoryService 时返回 ``error``(best-effort)。 + 取最近 ``limit`` 条 L0 事件 → LLM 类型化提取 → 候选(pending); + ``promote=True`` 时对候选执行晋升检查(L1 → L2),跳过人工审核。 Args: - session_id: only extract events of this session; omit for recent all. - limit: number of recent raw events to extract (default 100). - promote: run promotion on extracted candidates (default False). + session_id: 仅提取该会话的事件;缺省提取最近全部。 + limit: 提取的最近原始事件数,默认 100。 + promote: 是否对候选直接晋升,默认 False。 """ runtime_server = server.app_runtime assert runtime_server is not None, "app_runtime required for memory extract" - agent = runtime_server.agent_registry.get_agent(agent_id) + agent = runtime_server.agent_registry.get_agent(_agent_id()) runtime = getattr(agent, "_memory_runtime", None) service = getattr(runtime, "service", None) if runtime else None if service is None: @@ -479,7 +652,9 @@ def memory_extract( "session_id": eff_session, "events_considered": result.get("events_considered", 0), "candidates": result.get("candidates", 0), - "promoted": result.get("promoted", 0) if isinstance(result.get("promotion"), dict) else 0, + "promoted": result.get("promoted", 0) + if isinstance(result.get("promotion"), dict) + else 0, "error": result.get("failure_reason"), } @@ -488,14 +663,13 @@ def memory_promote( candidate_ids: list[str], importance: str | None = None, ) -> dict[str, Any]: - """**审核晋升候选**(L1 → L2):确认候选为原子记忆。 + """**审核晋升候选**:把 L1 候选晋升为 L2 原子记忆。 - 对指定候选执行 5 项晋升检查(规则路径),通过则写入 L2 atom, - 记录 journal。用于人工审核 / 外部调度晋升。 + 对指定候选执行 5 项晋升检查(规则路径),通过则写入原子并记录 journal。 Args: - candidate_ids: candidate ids to promote (from memory_candidates). - importance: override importance (low/medium/high); default keep. + candidate_ids: 要晋升的候选 id 列表(来自 ``memory_candidates``)。 + importance: 覆盖重要性(low/medium/high),默认保留。 """ memory = _memory() candidates = memory.list_candidates(limit=1000) @@ -514,11 +688,11 @@ def memory_reject( candidate_id: str, reason: str = "rejected by external caller", ) -> dict[str, Any]: - """**拒绝候选**:标记 rejected + 原因,不进原子层(记录 journal 可审计)。 + """**拒绝候选**:标记候选为 rejected 并写入原因(不进原子层,可审计)。 Args: - candidate_id: candidate id to reject. - reason: rejection reason. + candidate_id: 候选 id。 + reason: 拒绝原因,默认 "rejected by external caller"。 """ memory = _memory() @@ -535,19 +709,19 @@ def memory_reject( return mcp -def _trigger_extract(server: OctopServer, agent_id: str, session_id: str | None) -> bool: +def _trigger_extract(server: OctopServer, session_id: str | None) -> bool: """Best-effort: asynchronously trigger the agent's memory extraction. - Internal-network enhancement (not part of the community PR): raw events - written by MCP capture are not in the harness-agent extractor's tracked - sessions, so they would never be distilled into atoms. Reuse the agent's - in-process ``MemoryService`` (with the agent's configured extraction LLM) - via ``agent._memory_runtime.service`` (no public entrypoint; best-effort). - Returns whether an extract task was scheduled. + Raw events written by MCP capture are not in the harness-agent extractor's + tracked sessions, so they would never be distilled into atoms. Reuse the + agent's in-process ``MemoryService`` (with the agent's configured extraction + LLM) via ``agent._memory_runtime.service`` (no public entrypoint; + best-effort). Returns whether an extract task was scheduled. """ import asyncio - if not session_id: + agent_id = _current_agent_id.get() + if not session_id or not agent_id: return False try: runtime_server = server.app_runtime @@ -568,9 +742,7 @@ async def _extract() -> None: regen_pages=True, ) except Exception: - logger.warning( - "memory extract failed for session %s", session_id, exc_info=True - ) + logger.warning("memory extract failed for session %s", session_id, exc_info=True) asyncio.create_task(_extract()) return True @@ -596,7 +768,9 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None await self._app(scope, receive, send) return - headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} + headers = { + k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", []) + } auth = headers.get("authorization", "") provided = auth[7:].strip() if auth.startswith("Bearer ") else "" if not provided: @@ -604,14 +778,16 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None if provided != self._token: body = b'{"error":"unauthorized"}' - await send({ - "type": "http.response.start", - "status": 401, - "headers": [ - (b"content-type", b"application/json"), - (b"content-length", str(len(body)).encode()), - ], - }) + await send( + { + "type": "http.response.start", + "status": 401, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + } + ) await send({"type": "http.response.body", "body": body}) return @@ -619,44 +795,58 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None class _AgentRouter: - """ASGI dispatcher routing to the per-expert MCP app by ``X-Octop-Agent-Id`` header.""" + """ASGI dispatcher validating ``X-Octop-Agent-Id`` against the agent repo and + forwarding to the single shared memory MCP app. - def __init__(self, mcp_apps: dict[str, Any]) -> None: - self._mcp_apps = mcp_apps + The agent set is NOT snapshotted at startup: every request is checked against + the agent repo (existence + ``enabled``), so agents created or disabled after + process start take effect immediately (no restart required). + """ + + def __init__(self, app: Any, server: OctopServer) -> None: + self._app = app + self._server = server async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None: if scope.get("type") != "http": return # lifespan is wired into the host FastAPI manually; http only here - headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} + headers = { + k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", []) + } agent_id = headers.get("x-octop-agent-id", "").strip() - target = self._mcp_apps.get(agent_id) - if target is None: + services = self._server.services + row = services.agent_repo.get(agent_id) if (services is not None and agent_id) else None + if row is None or not row.enabled: body = b'{"error":"missing or unknown agent_id (X-Octop-Agent-Id)"}' - await send({ - "type": "http.response.start", - "status": 404, - "headers": [ - (b"content-type", b"application/json"), - (b"content-length", str(len(body)).encode()), - ], - }) + await send( + { + "type": "http.response.start", + "status": 404, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + } + ) await send({"type": "http.response.body", "body": body}) return - # 把调用者 user id 写入 contextvar,供工具读取(stateless HTTP 下 - # mcp SDK 不提供 ctx.request_context)。 + # 把本次请求绑定的 expert 与调用者 user id 写入 contextvar,供工具读取 + # (stateless HTTP 下 mcp SDK 不提供 ctx.request_context)。 + agent_cv = _current_agent_id.set(agent_id) user = headers.get("x-octop-user-id", "").strip() token_cv = _current_caller_user.set(user) try: - await target(scope, receive, send) + await self._app(scope, receive, send) finally: _current_caller_user.reset(token_cv) + _current_agent_id.reset(agent_cv) def mount_memory_mcp(app: Any, server: OctopServer) -> list[Any]: """Mount the memory MCP endpoint at ``/mcp/memory``; the expert is selected - per connection via the ``X-Octop-Agent-Id`` header (one connection binds one - expert; the URL stays uniform and does not leak expert ids). + per request via the ``X-Octop-Agent-Id`` header (validated at request time; + the URL stays uniform and does not leak expert ids). Does not mount when ``OCTOP_MEMORY_MCP_TOKEN`` is unset (fail-closed). Returns the session managers that must be initialized in the host FastAPI @@ -666,18 +856,21 @@ def mount_memory_mcp(app: Any, server: OctopServer) -> list[Any]: if token is None: return [] - managers: list[Any] = [] - mcp_apps: dict[str, Any] = {} services = server.services assert services is not None, "server.services required for memory MCP mount" - rows = services.agent_repo.list_all(include_disabled=False) - for row in rows: - agent_id = row.agent_id - mcp = build_memory_mcp(server, agent_id) - mcp_apps[agent_id] = mcp.streamable_http_app() - managers.append(mcp._session_manager) - - app.mount("/mcp/memory", _TokenAuthMiddleware(_AgentRouter(mcp_apps), token)) + mcp = build_memory_mcp(server) + # IMPORTANT: _session_manager is created lazily by streamable_http_app(). + # Read it only AFTER building the ASGI app and drop None entries — in + # stateless HTTP mode there is no session manager to keep alive, and reading + # mcp._session_manager before streamable_http_app() yields None, which then + # crashes the host FastAPI lifespan with "NoneType has no attribute 'run'". + streamable_app = mcp.streamable_http_app() + managers = [mgr for mgr in (mcp._session_manager,) if mgr is not None] + + app.mount( + "/mcp/memory", + _TokenAuthMiddleware(_AgentRouter(streamable_app, server), token), + ) return managers diff --git a/tests/unit/agents/test_memory_mcp.py b/tests/unit/agents/test_memory_mcp.py index 0e22fa47e..8bcce3c4a 100644 --- a/tests/unit/agents/test_memory_mcp.py +++ b/tests/unit/agents/test_memory_mcp.py @@ -17,18 +17,40 @@ def fake_memory(monkeypatch): node.id = "node1" node.content = "remember X" mem.store.return_value = node - mem.add_raw.return_value = mock.MagicMock(id="evt1") + + def _add_raw(content, **_kwargs): + raw = mock.MagicMock(id="evt1") + raw.content = content + return raw + + mem.add_raw.side_effect = _add_raw mem.deprecate_atom.return_value = True monkeypatch.setattr(mm, "_open_memory", lambda server, agent_id: mem) return mem +@pytest.fixture +def bind_agent(): + """Bind the request-scoped agent contextvar (main resolves the expert per request).""" + token = mm._current_agent_id.set("A1") + yield "A1" + mm._current_agent_id.reset(token) + + +@pytest.fixture +def bind_user(): + """Bind the ``X-Octop-User-Id`` caller contextvar (sender attribution).""" + token = mm._current_caller_user.set("alice") + yield "alice" + mm._current_caller_user.reset(token) + + def _tools(mcp): return mcp._tool_manager._tools -def test_build_binds_agent_id(monkeypatch): - """Tools capture agent_id in the closure; callers never pass it.""" +def test_build_resolves_agent_per_request(monkeypatch): + """Expert is bound per request (contextvar), not captured at build time.""" captured = {} def fake_open(server, agent_id): @@ -38,15 +60,21 @@ def fake_open(server, agent_id): return mem monkeypatch.setattr(mm, "_open_memory", fake_open) - mcp = mm.build_memory_mcp(mock.MagicMock(), agent_id="EXPERT42") - _tools(mcp)["memory_save"].fn(content="x", source="s") + mcp = mm.build_memory_mcp(mock.MagicMock()) + token = mm._current_agent_id.set("EXPERT42") + try: + _tools(mcp)["memory_save"].fn(content="x", source="s") + finally: + mm._current_agent_id.reset(token) assert captured["agent_id"] == "EXPERT42" -def test_build_registers_five_tools(fake_memory): - mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") +def test_build_registers_eleven_tools(fake_memory): + mcp = mm.build_memory_mcp(mock.MagicMock()) assert set(_tools(mcp)) == { "memory_recall", + "memory_search", + "memory_get", "memory_save", "memory_capture", "memory_update", @@ -58,7 +86,7 @@ def test_build_registers_five_tools(fake_memory): } -def test_memory_recall_uses_full_pipeline(fake_memory, monkeypatch): +def test_memory_recall_uses_full_pipeline(fake_memory, monkeypatch, bind_agent): """memory_recall runs the full recall pipeline (recall_for_prompt).""" import harness_memory.pipeline.recall as _recall @@ -73,15 +101,15 @@ class _Snippet: fake_result.rendered = "markdown" monkeypatch.setattr(_recall, "recall_for_prompt", lambda m, q, limit: fake_result) - mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") + mcp = mm.build_memory_mcp(mock.MagicMock()) result = _tools(mcp)["memory_recall"].fn(query="billing-migration", limit=3) assert result["count"] == 1 assert result["memories"][0]["text"] == "billing-migration is the local clone" assert result["rendered"] == "markdown" -def test_memory_save_goes_store(fake_memory): - mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") +def test_memory_save_goes_store(fake_memory, bind_agent): + mcp = mm.build_memory_mcp(mock.MagicMock()) result = _tools(mcp)["memory_save"].fn(content="remember X", source="coding-agent") kwargs = fake_memory.store.call_args.kwargs assert kwargs["topic"] is None @@ -89,8 +117,8 @@ def test_memory_save_goes_store(fake_memory): assert result["source"] == "coding-agent" -def test_memory_capture_goes_add_raw(fake_memory): - mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") +def test_memory_capture_goes_add_raw(fake_memory, bind_agent): + mcp = mm.build_memory_mcp(mock.MagicMock()) result = _tools(mcp)["memory_capture"].fn( content="raw conversation", source="review-bot", session_id="review-1" ) @@ -103,7 +131,24 @@ def test_memory_capture_goes_add_raw(fake_memory): assert "raw (L0)" in result["note"] -def test_memory_raws_queries_l0_with_query(fake_memory): +def test_memory_capture_is_idempotent(fake_memory, bind_agent): + """Re-capturing the same session + content reuses the existing L0 event.""" + existing = mock.MagicMock(id="evt-existing") + existing.content = "raw conversation" + fake_memory.list_raw.return_value = [existing] + + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_capture"].fn( + content="raw conversation", source="review-bot", session_id="review-1" + ) + + fake_memory.add_raw.assert_not_called() + assert result["event_id"] == "evt-existing" + assert result["duplicate"] is True + assert "idempotent capture" in result["note"] + + +def test_memory_raws_queries_l0_with_query(fake_memory, bind_agent): class _Evt: id = "evt1" timestamp = __import__("datetime").datetime(2026, 8, 19) @@ -114,7 +159,7 @@ class _Evt: content = "report panel banner hidden" fake_memory.search_raw.return_value = [_Evt()] - mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") + mcp = mm.build_memory_mcp(mock.MagicMock()) result = _tools(mcp)["memory_raws"].fn(query="report panel banner", limit=5) fake_memory.search_raw.assert_called_once_with("report panel banner", limit=5) assert result["count"] == 1 @@ -122,8 +167,8 @@ class _Evt: assert result["events"][0]["source"] == "review-bot" -def test_memory_update_deprecates_and_saves(fake_memory): - mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") +def test_memory_update_deprecates_and_saves(fake_memory, bind_agent): + mcp = mm.build_memory_mcp(mock.MagicMock()) result = _tools(mcp)["memory_update"].fn( atom_id="atom1", new_content="new fact", source="review-bot" ) @@ -180,50 +225,52 @@ def test_mount_fail_closed_without_token(monkeypatch): app.mount.assert_not_called() -def test_mount_unified_path_with_header_router(monkeypatch): +def test_mount_unified_path_with_shared_app(monkeypatch): + """A single shared MCP app is mounted once at /mcp/memory.""" from types import SimpleNamespace monkeypatch.setenv("OCTOP_MEMORY_MCP_TOKEN", "secret") app = mock.MagicMock() - server = SimpleNamespace( - services=SimpleNamespace( - agent_repo=mock.MagicMock( - list_all=lambda include_disabled: [ - SimpleNamespace(agent_id="A1"), - SimpleNamespace(agent_id="A2"), - ] - ) - ) - ) + server = SimpleNamespace(services=SimpleNamespace(agent_repo=mock.MagicMock())) managers = mm.mount_memory_mcp(app, server) - assert len(managers) == 2 - # unified path mounted exactly once + assert len(managers) == 1 app.mount.assert_called_once() assert app.mount.call_args.args[0] == "/mcp/memory" @pytest.mark.asyncio async def test_agent_router_routes_by_header(): - """_AgentRouter routes to the right app by X-Octop-Agent-Id header.""" - called = {} + """_AgentRouter validates the agent, binds the contextvar, forwards to the shared app.""" + from types import SimpleNamespace - class _FakeApp: - def __init__(self, aid): - self._aid = aid + seen = {} + class _FakeApp: async def __call__(self, scope, receive, send): - called["agent"] = self._aid + seen["agent"] = mm._current_agent_id.get() - router = mm._AgentRouter({"A1": _FakeApp("A1"), "A2": _FakeApp("A2")}) + rows = { + "A1": SimpleNamespace(agent_id="A1", enabled=True), + "A2": SimpleNamespace(agent_id="A2", enabled=True), + } + server = SimpleNamespace( + services=SimpleNamespace(agent_repo=mock.MagicMock(get=lambda aid: rows.get(aid))) + ) + router = mm._AgentRouter(_FakeApp(), server) scope = {"type": "http", "headers": [(b"x-octop-agent-id", b"A2")]} await router(scope, lambda: {}, lambda msg: None) - assert called["agent"] == "A2" + assert seen["agent"] == "A2" @pytest.mark.asyncio async def test_agent_router_404_unknown_agent(): - """Unknown agent_id returns 404.""" - router = mm._AgentRouter({"A1": mock.MagicMock()}) + """Unknown agent_id returns 404 before dispatching.""" + from types import SimpleNamespace + + server = SimpleNamespace( + services=SimpleNamespace(agent_repo=mock.MagicMock(get=lambda aid: None)) + ) + router = mm._AgentRouter(mock.MagicMock(), server) scope = {"type": "http", "headers": [(b"x-octop-agent-id", b"NOPE")]} sent = [] @@ -236,10 +283,10 @@ async def _send(msg): def test_trigger_extract_no_session_returns_false(): """No session_id -> no extraction trigger.""" - assert mm._trigger_extract(mock.MagicMock(), "A1", None) is False + assert mm._trigger_extract(mock.MagicMock(), None) is False -def test_trigger_extract_no_service_returns_false(monkeypatch): +def test_trigger_extract_no_service_returns_false(): """Agent without memory runtime/service -> silently skipped.""" agent = mock.MagicMock() runtime = mock.MagicMock() @@ -248,7 +295,11 @@ def test_trigger_extract_no_service_returns_false(monkeypatch): registry = mock.MagicMock(get_agent=lambda aid: agent) server = mock.MagicMock() server.app_runtime.agent_registry = registry - assert mm._trigger_extract(server, "A1", "kiro-chat") is False + token = mm._current_agent_id.set("A1") + try: + assert mm._trigger_extract(server, "kiro-chat") is False + finally: + mm._current_agent_id.reset(token) def test_trigger_extract_schedules_service(monkeypatch): @@ -266,32 +317,220 @@ def test_trigger_extract_schedules_service(monkeypatch): server.app_runtime.agent_registry = registry async def _run(): - return mm._trigger_extract(server, "A1", "kiro-chat") + return mm._trigger_extract(server, "kiro-chat") - assert asyncio.run(_run()) is True + token = mm._current_agent_id.set("A1") + try: + assert asyncio.run(_run()) is True + finally: + mm._current_agent_id.reset(token) time.sleep(0.1) service.extract.assert_called() assert service.extract.call_args.args[0] == "kiro-chat" +def _with_agent(agent_id: str): + """Bind the request-scoped agent contextvar for a single tool call.""" + return mm._current_agent_id.set(agent_id) + + def test_memory_candidates_passes_status_string(fake_memory): """``status`` is a typing.Literal of strings: pass the raw value, don't instantiate it (previously crashed with "Cannot instantiate typing.Literal").""" fake_memory.list_candidates.return_value = [] - mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") - result = _tools(mcp)["memory_candidates"].fn(status="pending", limit=5) + mcp = mm.build_memory_mcp(mock.MagicMock()) + token = _with_agent("A1") + try: + result = _tools(mcp)["memory_candidates"].fn(status="pending", limit=5) + finally: + mm._current_agent_id.reset(token) assert result["candidates"] == [] assert fake_memory.list_candidates.call_args.kwargs["status"] == "pending" def test_memory_candidates_not_a_status_raises(fake_memory): - mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") - with pytest.raises(ValueError): - _tools(mcp)["memory_candidates"].fn(status="no-such-status") + mcp = mm.build_memory_mcp(mock.MagicMock()) + token = _with_agent("A1") + try: + with pytest.raises(ValueError): + _tools(mcp)["memory_candidates"].fn(status="no-such-status") + finally: + mm._current_agent_id.reset(token) def test_memory_reject_passes_literal_status(fake_memory): - mcp = mm.build_memory_mcp(mock.MagicMock(), "A1") - result = _tools(mcp)["memory_reject"].fn(candidate_id="c1", reason="dup") + mcp = mm.build_memory_mcp(mock.MagicMock()) + token = _with_agent("A1") + try: + result = _tools(mcp)["memory_reject"].fn(candidate_id="c1", reason="dup") + finally: + mm._current_agent_id.reset(token) assert result["status"] == "rejected" assert fake_memory.update_candidate_status.call_args.kwargs["status"] == "rejected" + + +def _stub_runtime(monkeypatch, *, hits=None, get_result=None, captured=None): + """Patch ``MemoryRuntime`` so search/get run against a fake runtime.""" + + class _FakeRuntime: + def __init__(self, memory): + if captured is not None: + captured["memory"] = memory + + def memory_search(self, params): + if captured is not None: + captured["search_params"] = params + return {"hits": list(hits or []), "total": len(hits or []), "empty_reason": None} + + def memory_get(self, params): + if captured is not None: + captured["get_params"] = params + return dict(get_result or {}) + + import harness_memory.application.runtime as _runtime + + monkeypatch.setattr(_runtime, "MemoryRuntime", _FakeRuntime) + + +def test_memory_search_projects_hit_paths(fake_memory, monkeypatch, bind_agent): + """memory_search runs the shared pipeline and returns path-carrying hits.""" + captured = {} + _stub_runtime( + monkeypatch, + hits=[ + {"path": "atom/a1.md", "layer": "atom", "snippet": "s1", "source_id": "a1"}, + {"path": "raw/2026-08-19/r1.md", "layer": "raw", "snippet": "s2", "source_id": "r1"}, + ], + captured=captured, + ) + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_search"].fn(query="billing", max_results=5) + assert captured["memory"] is fake_memory + assert captured["search_params"] == {"query": "billing", "maxResults": 5, "corpus": "memory"} + assert [hit["path"] for hit in result["hits"]] == ["atom/a1.md", "raw/2026-08-19/r1.md"] + assert result["total"] == 2 + assert result["corpus"] == "all" + + +def test_memory_search_atom_corpus_widens_then_filters(fake_memory, monkeypatch, bind_agent): + """``corpus=atom`` asks for a wider pool, then keeps only L2 atoms.""" + captured = {} + _stub_runtime( + monkeypatch, + hits=[ + {"path": "raw/2026-08-19/r1.md", "layer": "raw", "snippet": "s2", "source_id": "r1"}, + {"path": "atom/a1.md", "layer": "atom", "snippet": "s1", "source_id": "a1"}, + {"path": "atom/a2.md", "layer": "atom", "snippet": "s3", "source_id": "a2"}, + ], + captured=captured, + ) + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_search"].fn(query="billing", max_results=2, corpus="atom") + assert captured["search_params"]["maxResults"] == 8 + assert [hit["path"] for hit in result["hits"]] == ["atom/a1.md", "atom/a2.md"] + assert result["corpus"] == "atom" + + +def test_memory_search_raw_corpus_uses_l0_fts(fake_memory, bind_agent): + """``corpus=raw`` bypasses the atom-first fallback and searches L0 directly.""" + from datetime import datetime + + event = mock.MagicMock() + event.id = "r1" + event.content = "nginx 需要 proxy /api/memory-mcp" + event.timestamp = datetime(2026, 8, 19, 12, 0) + fake_memory.search_raw.return_value = [event] + + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_search"].fn(query="proxy", max_results=3, corpus="raw") + fake_memory.search_raw.assert_called_once_with("proxy", limit=3) + assert result["hits"][0]["path"] == "raw/2026-08-19/r1.md" + assert result["hits"][0]["layer"] == "raw" + + +def test_memory_search_rejects_unknown_corpus(fake_memory, bind_agent): + mcp = mm.build_memory_mcp(mock.MagicMock()) + with pytest.raises(ValueError): + _tools(mcp)["memory_search"].fn(query="billing", corpus="wiki") + + +def test_memory_get_returns_excerpt_as_content(fake_memory, monkeypatch, bind_agent): + """memory_get surfaces the excerpt under ``content`` and forwards paging.""" + captured = {} + _stub_runtime( + monkeypatch, + get_result={ + "path": "atom/a1.md", + "kind": "atom", + "excerpt": "# body", + "metadata": {"id": "a1"}, + "total_lines": 3, + "from_line": 1, + "to_line": 3, + "truncated": False, + }, + captured=captured, + ) + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_get"].fn(path="atom/a1.md", start=1, lines=10) + assert captured["get_params"] == {"path": "atom/a1.md", "from": 1, "lines": 10} + assert result["content"] == "# body" + assert result["kind"] == "atom" + assert result["total_lines"] == 3 + + +def test_memory_get_returns_hint_for_bad_path(fake_memory, monkeypatch, bind_agent): + """A stale/mistyped path degrades to an error payload instead of raising.""" + + class _Boom: + def __init__(self, memory): + pass + + def memory_get(self, params): + raise ValueError("path must be non-empty and unpadded: 'nope'") + + import harness_memory.application.runtime as _runtime + + monkeypatch.setattr(_runtime, "MemoryRuntime", _Boom) + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_get"].fn(path="nope") + assert "path must be non-empty" in result["error"] + assert "atom/.md" in result["hint"] + + +def test_memory_capture_prefixes_sender(fake_memory, bind_agent, bind_user): + """The header user id is folded into the captured text, not just the payload.""" + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_capture"].fn(content="接口先不要动", source="review-bot") + assert fake_memory.add_raw.call_args.args[0] == "alice说:接口先不要动" + assert fake_memory.add_raw.call_args.kwargs["user"] == "alice" + assert result["content"] == "alice说:接口先不要动" + assert result["user"] == "alice" + + +def test_memory_capture_does_not_double_prefix(fake_memory, bind_agent, bind_user): + mcp = mm.build_memory_mcp(mock.MagicMock()) + _tools(mcp)["memory_capture"].fn(content="alice说:接口先不要动", source="review-bot") + assert fake_memory.add_raw.call_args.args[0] == "alice说:接口先不要动" + + +def test_memory_save_prefixes_sender(fake_memory, bind_agent, bind_user): + mcp = mm.build_memory_mcp(mock.MagicMock()) + _tools(mcp)["memory_save"].fn(content="部署约定:端点挂在 /mcp/memory", source="coding-agent") + assert fake_memory.store.call_args.args[0] == "alice说:部署约定:端点挂在 /mcp/memory" + assert fake_memory.store.call_args.kwargs["metadata"]["user"] == "alice" + + +def test_memory_update_prefixes_sender(fake_memory, bind_agent, bind_user): + mcp = mm.build_memory_mcp(mock.MagicMock()) + _tools(mcp)["memory_update"].fn( + atom_id="a1", new_content="端点改到 /api/memory-mcp", source="s" + ) + assert fake_memory.store.call_args.args[0] == "alice说:端点改到 /api/memory-mcp" + + +def test_write_without_caller_keeps_content(fake_memory, bind_agent): + """No ``X-Octop-User-Id`` -> no attribution prefix.""" + mcp = mm.build_memory_mcp(mock.MagicMock()) + _tools(mcp)["memory_save"].fn(content="no sender", source="coding-agent") + assert fake_memory.store.call_args.args[0] == "no sender" From f7ca99a684b2faa806c4004f1434c8c60a1ee86f Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Mon, 14 Sep 2026 21:23:54 +0800 Subject: [PATCH 12/21] =?UTF-8?q?fix(api):=20=E8=AE=B0=E5=BF=86=20MCP=20li?= =?UTF-8?q?fespan=20=E8=A1=A5=E8=BF=94=E5=9B=9E=E7=B1=BB=E5=9E=8B=E6=A0=87?= =?UTF-8?q?=E6=B3=A8=EF=BC=8C=E4=BF=AE=E6=8E=89=20mypy=20no-untyped-def?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_memory_mcp_lifespan`(asynccontextmanager 包装)缺返回类型,让 `make typecheck` 与 CI 在 api/app.py:284 报 no-untyped-def。补 `-> AsyncIterator[None]` 并加对应局部 import。纯类型标注,行为不变。 验证:mypy src/octop(474 文件)0 error; pytest -n auto -m "not live" → 2978 passed / 15 skipped。 --- src/octop/api/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/octop/api/app.py b/src/octop/api/app.py index dd7c98ea9..9f9f0e343 100644 --- a/src/octop/api/app.py +++ b/src/octop/api/app.py @@ -278,10 +278,11 @@ async def acme_http01_challenge(token: str) -> PlainTextResponse: memory_mcp_managers = mount_memory_mcp(app, server) if memory_mcp_managers: + from collections.abc import AsyncIterator from contextlib import AsyncExitStack, asynccontextmanager @asynccontextmanager - async def _memory_mcp_lifespan(application: FastAPI): + async def _memory_mcp_lifespan(application: FastAPI) -> AsyncIterator[None]: # streamable_http_app 的 task group 依赖 lifespan,挂载后须手动并入 async with AsyncExitStack() as stack: for mgr in memory_mcp_managers: From abf140ba797d18376599bf9631f43010ebe461b2 Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Mon, 14 Sep 2026 21:52:39 +0800 Subject: [PATCH 13/21] =?UTF-8?q?fix(mcp):=20recall=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20session/thread=20=E4=BD=9C=E7=94=A8=E5=9F=9F=20+=20capture?= =?UTF-8?q?=20=E4=B8=A2=E5=BC=83=E5=8F=AC=E5=9B=9E=E5=9B=9E=E5=A3=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hook 场景暴露的两个缺口: 1. memory_recall 接受 session_id / thread_id 并透传给 recall_for_prompt: - session_id 把本会话的 raw 从召回里排除,避免"注入 → 被记录 → 下轮再召回"的回声 - thread_id 启用共指消解(active-entity stack)并把命中写回实体栈 2. memory_capture 增加回声保护:内容含召回注入标记("[memory] Earlier in this workspace" / "## Memory Recall")时直接丢弃,返回 skipped=recall_echo。 MCP 写路径走 Memory.add_raw,绕过了 MemoryRuntime.capture 的 skip_memory_echo, 不补这层就会把 hook 注入的召回块当新事件采集,形成放大环。 docs/memory-mcp.md:同步 memory_recall 的参数与 capture 回声保护契约。 验证:pytest -n auto -m "not live" → 2983 passed / 15 skipped; tests/unit/agents/test_memory_mcp.py → 36 passed;mypy / ruff 干净。 --- docs/memory-mcp.md | 10 +++- src/octop/infra/agents/memory_mcp.py | 63 ++++++++++++++++++++++- tests/unit/agents/test_memory_mcp.py | 76 +++++++++++++++++++++++++++- 3 files changed, 145 insertions(+), 4 deletions(-) diff --git a/docs/memory-mcp.md b/docs/memory-mcp.md index c939eeedf..5896adb53 100644 --- a/docs/memory-mcp.md +++ b/docs/memory-mcp.md @@ -51,7 +51,7 @@ | 工具 | 参数 | 说明 | |---|---|---| -| `memory_recall` | `query: str`, `limit: int = 5`, `user?: str` | **读入口首选**。跑完整召回管线(分词 → 路由 → FTS → 重排 → 去重),返回结构化片段 + 可直接注入 system prompt 的 markdown(`rendered`)。L2 原子优先,主题页标题并入 atom 命中,L0 原始事件仅兜底 | +| `memory_recall` | `query: str`, `limit: int = 5`, `session_id?: str`, `thread_id?: str`, `user?: str` | **读入口首选**。跑完整召回管线(分词 → 路由 → FTS → 重排 → 去重),返回结构化片段 + 可直接注入 system prompt 的 markdown(`rendered`)。L2 原子优先,主题页标题并入 atom 命中,L0 原始事件仅兜底。**自动注入(hook)场景建议传 `session_id`**:管线会据此把本会话的 raw 排除,避免"注入 → 被记录 → 下轮又召回"的回声;`thread_id` 则启用共指消解("那个项目" 靠该线程的 active-entity stack) | | `memory_search` | `query: str`, `max_results: int = 5`, `corpus: str = "all"` | 同一套召回管线,但**不渲染 markdown**,而是给每条命中一个虚拟 `path`,交给 `memory_get` 下钻。`corpus`:`all`/`memory`(原子+原始事件同管线)、`atom`(只要 L2 原子)、`raw`(直接走 L0 全文检索,不受"有原子命中就丢 raw"的兜底影响) | | `memory_get` | `path: str`, `start?: int`, `lines?: int` | 把命中路径解析成完整 markdown。支持 `atom/.md`、`page/.md`、`raw//.md`;长内容用 `start`/`lines` 分页(1-based)。路径非法或过期时返回 `{error, hint}` 而不是抛栈 | | `memory_raws` | `query?: str`, `session_id?: str`, `host?: str`, `user?: str`, `limit: int = 50` | **原始事件(证据源)**。`query` 走全文检索(写入后立即可见,提取前也能查),其余字段做结构化过滤,按时间倒序返回 | @@ -80,6 +80,11 @@ `memory_capture` 是**幂等**的:同一 `session_id` + 同一内容重复写入时不会产生重复 L0 事件, 返回里带 `duplicate: true` 并复用已有 `event_id`(下游提取因此可以反复重跑)。 +它还有**回声保护**:内容里带 `memory_recall` 的注入标记(`[memory] Earlier in this workspace` / +`## Memory Recall`)时**不写入**,直接返回 `{recorded: false, skipped: "recall_echo"}`。 +原因:MCP 写路径直接调 `Memory.add_raw`,绕过了 `MemoryRuntime.capture` 的 `skip_memory_echo`; +不补这层,hook 注入的召回块会被当作新事件采集,形成"注入 → 采集 → 再召回"的放大环。 + ### 2.3 提取与审核流水线(4 个) | 工具 | 参数 | 说明 | @@ -480,6 +485,9 @@ This preset auto-reads and auto-writes the octop-memory expert store so durable - **capture 之后 recall 无结果属预期**:内容还在 L0,需要经提取晋升成原子才会被召回; 想立刻看到请用 `memory_raws` 或 `memory_search(corpus="raw")`。 - **capture 幂等**:同 `session_id` + 同内容不重复落库(见 §2.2)。 +- **召回回声有双向保护**:写入侧 `memory_capture` 丢弃带召回标记的内容(`skipped=recall_echo`); + 读取侧建议 hook 给 `memory_recall` 传与 capture 一致的 `session_id`,把本会话的 raw 排除。 + 两侧都做,才不会出现"注入 → 采集 → 再召回"的放大环(§4.3 / §4.4 的自动召回就是这个场景)。 - **召回是专家级共享**,不做按人隔离;按发送者定位依赖正文里的 `说:` 前缀 + 全文检索。 - **错误形态**:`memory_get` 的坏路径返回 `{error, hint}`;`memory_search` 的非法 `corpus`、 `memory_candidates` 的非法 `status` 直接报错(参数错误不会被静默吞掉)。 diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py index b7d75472a..202aa5013 100644 --- a/src/octop/infra/agents/memory_mcp.py +++ b/src/octop/infra/agents/memory_mcp.py @@ -26,13 +26,20 @@ * ``memory_recall`` -> ``recall_for_prompt``: ranked, prompt-injectable text. L2 atoms first; ``page`` headlines are folded into atom hits; L0 raw is only - a fallback (dropped as soon as any atom matches). + a fallback (dropped as soon as any atom matches). Takes ``session_id`` / + ``thread_id`` so an auto-inject hook can exclude the current session's raw and + use the thread's active-entity stack for co-reference. * ``memory_search`` -> ``MemoryRuntime.memory_search`` (``corpus=raw`` uses ``Memory.search_raw``): the same ranking, returned as hits that carry a virtual ``path`` instead of rendered markdown. * ``memory_get`` -> ``MemoryRuntime.memory_get``: resolve that path to the full markdown (``atom/.md`` / ``page/.md`` / ``raw//.md``). +Recall echo guard: ``memory_capture`` drops content carrying a recall marker +(``_RECALL_ECHO_MARKERS``), the same rule ``MemoryRuntime.capture`` applies via +``skip_memory_echo`` — the MCP write path calls ``Memory.add_raw`` directly and +would otherwise capture a hook's own injected recall block as a new event. + Sender attribution: ``X-Octop-User-Id`` identifies the caller, and every write (``memory_capture`` / ``memory_save`` / ``memory_update``) prefixes the content with ``说:``. harness-memory's ``AtomCard`` has no user column, so putting @@ -73,6 +80,17 @@ _SNIPPET_CHARS = 200 +_RECALL_ECHO_MARKERS: tuple[str, ...] = ( + "## Memory Recall", + "[memory] Earlier in this workspace", +) +"""Markers ``recall_for_prompt`` puts into its rendered block (legacy + current). + +``MemoryRuntime.capture`` drops events containing these (``skip_memory_echo``) so the +host's own injection cannot be captured back as a new memory. The MCP write path calls +``Memory.add_raw`` directly and therefore has to apply the same rule itself. +""" + def _open_memory(server: OctopServer, agent_id: str) -> Any: """Open the agent's ``Memory`` instance (sqlite by default, postgres opt-in). @@ -120,6 +138,16 @@ def _snippet(text: str) -> str: return body if len(body) <= _SNIPPET_CHARS else body[: _SNIPPET_CHARS - 1].rstrip() + "…" +def _is_recall_echo(content: str) -> bool: + """True when ``content`` is our own recall block coming back as a new event. + + Mirrors ``MemoryRuntime.capture``'s anti-feedback rule; without it a hook that + injects ``memory_recall`` output and then captures the turn via MCP would feed the + injected block back in, and each round would recall (and re-capture) more of it. + """ + return any(marker in content for marker in _RECALL_ECHO_MARKERS) + + def _attributed(content: str, caller: str) -> str: """Prefix the sender so the caller id lives inside the text. @@ -223,6 +251,8 @@ def _derive_session(source: str, user: str) -> str: def memory_recall( query: str, limit: int = 5, + session_id: str | None = None, + thread_id: str | None = None, user: str | None = None, ctx: Context | None = None, # type: ignore[type-arg] ) -> dict[str, Any]: @@ -230,6 +260,8 @@ def memory_recall( 每次对话/任务开始前先调一次。运行完整召回管线(分词 → 路由 → FTS → 重排 → 去重 → token 预算),返回结构化片段 + 可直接注入 system prompt 的 markdown。 + 自动注入(hook)场景建议传 ``session_id``:管线会据此把**本会话**的 raw 排除, + 避免"注入 → 被记录 → 下轮又召回"的回声。 三个读工具怎么选: - 只想把相关背景拉进上下文 → 用本工具(一次调用,``rendered`` 直接可注入)。 @@ -245,6 +277,10 @@ def memory_recall( Args: query: 自然语言问题/关键词,整句传入(内部对中文做 n-gram 分词)。 limit: 最多返回片段数,默认 5。 + session_id: 可选,当前会话 id。用于把本会话的 raw 从召回里排除(防回声); + 与 ``memory_capture`` 传入的 ``session_id`` 一致才生效。 + thread_id: 可选,会话线程 id。用于共指消解("那个项目" 靠该线程的 + active-entity stack)并把命中写回实体栈;不传则只做普通检索。 user: 可选调用者 id(覆盖 ``X-Octop-User-Id`` 头)。 ctx: MCP 注入的上下文(读取 ``X-Octop-User-Id`` 头)。 """ @@ -252,7 +288,13 @@ def memory_recall( caller = user or _caller_user(ctx) memory = _memory() - result = recall_for_prompt(memory, query, limit=limit) + result = recall_for_prompt( + memory, + query, + thread_id=thread_id, + session_id=session_id, + limit=limit, + ) return { "memories": [ { @@ -416,14 +458,31 @@ def memory_capture( 原子正文,既能被 FTS 直接搜到,也能在召回时一眼看出是谁说的——**不要**自己再 写一遍名字。 + 回声保护:内容里带 ``memory_recall`` 注入标记(``[memory] Earlier in this + workspace`` / ``## Memory Recall``)时**不写入**,返回 ``skipped=recall_echo``。 + 拼进 prompt 的召回块被整轮回采会形成"注入 → 采集 → 再召回"的放大环,故直接丢弃。 + Args: content: 原始对话/事件内容(不含发送者前缀)。 source: 谁记录的,用于追溯。 session_id: 可选会话 id,用于提取分组;缺省派生为 ``ext:{source}:{user}``。 + 传了之后,``memory_recall`` 用同一个 id 就能把本会话的 raw 排除(防回声)。 user: 可选调用者 id(覆盖 ``X-Octop-User-Id`` 头)。 ctx: MCP 注入的上下文(读取 ``X-Octop-User-Id`` 头)。 """ caller = user or _caller_user(ctx) + if _is_recall_echo(content): + logger.info("memory_capture: dropped recall echo for agent %s", _agent_id()) + return { + "recorded": False, + "skipped": "recall_echo", + "reason": ( + "content contains a recall injection marker " + f"({_RECALL_ECHO_MARKERS[1]!r} / {_RECALL_ECHO_MARKERS[0]!r}); " + "dropped so our own recall output is not captured as a new event" + ), + "user": caller or None, + } effective_session = session_id or _derive_session(source, caller) memory = _memory() stored_content = _attributed(content, caller) diff --git a/tests/unit/agents/test_memory_mcp.py b/tests/unit/agents/test_memory_mcp.py index 8bcce3c4a..b9232d37d 100644 --- a/tests/unit/agents/test_memory_mcp.py +++ b/tests/unit/agents/test_memory_mcp.py @@ -99,7 +99,7 @@ class _Snippet: fake_result = mock.MagicMock() fake_result.snippets = [_Snippet()] fake_result.rendered = "markdown" - monkeypatch.setattr(_recall, "recall_for_prompt", lambda m, q, limit: fake_result) + monkeypatch.setattr(_recall, "recall_for_prompt", lambda m, q, **kw: fake_result) mcp = mm.build_memory_mcp(mock.MagicMock()) result = _tools(mcp)["memory_recall"].fn(query="billing-migration", limit=3) @@ -108,6 +108,55 @@ class _Snippet: assert result["rendered"] == "markdown" +def test_memory_recall_forwards_session_and_thread(fake_memory, monkeypatch, bind_agent): + """Hook callers can scope recall to a session/thread (echo guard + co-reference).""" + import harness_memory.pipeline.recall as _recall + + captured = {} + + def _fake(memory, query, **kwargs): + captured["memory"] = memory + captured["query"] = query + captured.update(kwargs) + result = mock.MagicMock() + result.snippets = [] + result.rendered = "" + return result + + monkeypatch.setattr(_recall, "recall_for_prompt", _fake) + mcp = mm.build_memory_mcp(mock.MagicMock()) + _tools(mcp)["memory_recall"].fn( + query="那个项目", + limit=4, + session_id="sess-1", + thread_id="thr-1", + ) + assert captured["memory"] is fake_memory + assert captured["query"] == "那个项目" + assert captured["session_id"] == "sess-1" + assert captured["thread_id"] == "thr-1" + assert captured["limit"] == 4 + + +def test_memory_recall_without_scope_passes_none(fake_memory, monkeypatch, bind_agent): + import harness_memory.pipeline.recall as _recall + + captured = {} + + def _fake(memory, query, **kwargs): + captured.update(kwargs) + result = mock.MagicMock() + result.snippets = [] + result.rendered = "" + return result + + monkeypatch.setattr(_recall, "recall_for_prompt", _fake) + mcp = mm.build_memory_mcp(mock.MagicMock()) + _tools(mcp)["memory_recall"].fn(query="q") + assert captured["session_id"] is None + assert captured["thread_id"] is None + + def test_memory_save_goes_store(fake_memory, bind_agent): mcp = mm.build_memory_mcp(mock.MagicMock()) result = _tools(mcp)["memory_save"].fn(content="remember X", source="coding-agent") @@ -534,3 +583,28 @@ def test_write_without_caller_keeps_content(fake_memory, bind_agent): mcp = mm.build_memory_mcp(mock.MagicMock()) _tools(mcp)["memory_save"].fn(content="no sender", source="coding-agent") assert fake_memory.store.call_args.args[0] == "no sender" + + +@pytest.mark.parametrize( + "content", + [ + "[memory] Earlier in this workspace, related to your question:\n- [atom] x", + "结论见下:\n## Memory Recall\n- [atom] y\n[/memory]", + ], +) +def test_memory_capture_drops_recall_echo(fake_memory, bind_agent, content): + """Injected recall blocks are not captured back (mirrors skip_memory_echo).""" + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_capture"].fn(content=content, source="hook") + assert result["recorded"] is False + assert result["skipped"] == "recall_echo" + assert "recall_echo" in result["skipped"] + fake_memory.add_raw.assert_not_called() + + +def test_memory_capture_still_records_normal_content(fake_memory, bind_agent, bind_user): + """The echo guard must not block ordinary captures.""" + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_capture"].fn(content="接口先不要动", source="hook") + assert result["recorded"] is True + assert fake_memory.add_raw.call_args.args[0] == "alice说:接口先不要动" From 5c27a7ac73636f532f6ecdb7c2b502636c4be740 Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Mon, 14 Sep 2026 22:24:36 +0800 Subject: [PATCH 14/21] =?UTF-8?q?test(memory):=20=E8=A1=A5=20recall=20?= =?UTF-8?q?=E8=B4=A8=E9=87=8F=E8=A1=A5=E4=B8=81=20memory=5Frecall=5Fpatch?= =?UTF-8?q?=20=E7=9A=84=E6=9C=80=E5=B0=8F=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `memory_recall_patch.py` 随本 PR 一起进仓库,但一直没有单测(内网分支同样没有)。 补两个 wiring 断言: - `apply_memory_recall_patch()` 后 `router.route` 与 `multi_source` 的两个 私有函数确实被替换,且上游原始实现保留为兜底路径; - 重复调用幂等:不会把补丁套两层(`_orig_*` 不被覆盖)。 补丁的实际召回效果由 memory eval 语料覆盖,不在单测里重复。 验证:`pytest tests/unit/agents/test_memory_recall_patch.py` → 2 passed。 --- tests/unit/agents/test_memory_recall_patch.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/unit/agents/test_memory_recall_patch.py diff --git a/tests/unit/agents/test_memory_recall_patch.py b/tests/unit/agents/test_memory_recall_patch.py new file mode 100644 index 000000000..e88242e1f --- /dev/null +++ b/tests/unit/agents/test_memory_recall_patch.py @@ -0,0 +1,42 @@ +"""Unit tests for the harness_memory recall-quality patch (infra/agents/memory_recall_patch). + +The patch monkey-patches three symbols inside the upstream ``harness-memory`` +package at startup, so these tests only assert the wiring contract: the three +symbols are replaced, the originals are kept as the fallback, and a second call +is a no-op. The patched behaviour itself is covered by the memory eval corpus, +not here. +""" + +from __future__ import annotations + +import harness_memory.pipeline.recall.multi_source as multi_source +import harness_memory.pipeline.recall.router as router + +from octop.infra.agents import memory_recall_patch as patch + + +def test_apply_patches_the_three_recall_symbols(): + """router.route + the two multi_source helpers are replaced, originals kept.""" + patch.apply_memory_recall_patch() + + assert router.route is patch._patched_route + assert multi_source._per_token_atom_search is patch._patched_per_token_atom_search + assert multi_source._gather_atoms is patch._patched_gather_atoms + + # The upstream implementations stay reachable as the fallback path. + assert router._orig_route is not patch._patched_route + assert multi_source._orig_per_token_atom_search is not patch._patched_per_token_atom_search + assert multi_source._orig_gather_atoms is not patch._patched_gather_atoms + + +def test_apply_is_idempotent(): + """A second call must not re-wrap the originals (that would nest the patch).""" + patch.apply_memory_recall_patch() + original_route = router._orig_route + original_gather = multi_source._orig_gather_atoms + + patch.apply_memory_recall_patch() + + assert router._orig_route is original_route + assert multi_source._orig_gather_atoms is original_gather + assert router.route is patch._patched_route From 855cc3b4f7433db9479d2c583b5eb7bc7194b36e Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Tue, 13 Oct 2026 11:39:57 +0800 Subject: [PATCH 15/21] chore(sync): merge community v1.0.2b7 and migrate memory stack to octop_* packages - merge tag v1.0.2b7 (664c1fb3): 1635 files, +146486/-15034, 0 conflicts - fix import after upstream reorg: agents.memory_backend -> agents.memory.backend - rename harness_memory/harness_agent -> octop_memory/octop_harness (pyproject now requires octop-memory>=1.0.1) - verified: pytest tests/unit/agents/test_memory_{mcp,recall_patch,backend}.py -> 50 passed --- src/octop/infra/agents/memory_mcp.py | 14 +++++++------- src/octop/infra/agents/memory_recall_patch.py | 10 +++++----- src/octop/infra/server.py | 2 +- tests/unit/agents/test_memory_mcp.py | 10 +++++----- tests/unit/agents/test_memory_recall_patch.py | 6 +++--- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py index 202aa5013..6896eccd4 100644 --- a/src/octop/infra/agents/memory_mcp.py +++ b/src/octop/infra/agents/memory_mcp.py @@ -61,7 +61,7 @@ from mcp.server.fastmcp.server import Context from mcp.server.transport_security import TransportSecuritySettings -from octop.infra.agents.memory_backend import open_memory_kwargs +from octop.infra.agents.memory.backend import open_memory_kwargs from octop.infra.server import OctopServer logger = logging.getLogger(__name__) @@ -99,7 +99,7 @@ def _open_memory(server: OctopServer, agent_id: str) -> Any: ``infra/`` (no api dependency). Workspace is resolved from the agent registry, falling back to the Octop default layout. """ - from harness_memory.core import Memory # noqa: PLC0415 + from octop_memory.core import Memory # noqa: PLC0415 services = server.services assert services is not None, "server.services required for memory backend" @@ -172,7 +172,7 @@ def _pipeline_hits( ``atom_only`` keeps just L2 atoms, so the request is widened first — otherwise raw hits could crowd the atoms out before the filter runs. """ - from harness_memory.application.runtime import MemoryRuntime # noqa: PLC0415 + from octop_memory.application.runtime import MemoryRuntime # noqa: PLC0415 runtime = MemoryRuntime(memory) result = runtime.memory_search( @@ -284,7 +284,7 @@ def memory_recall( user: 可选调用者 id(覆盖 ``X-Octop-User-Id`` 头)。 ctx: MCP 注入的上下文(读取 ``X-Octop-User-Id`` 头)。 """ - from harness_memory.pipeline.recall import recall_for_prompt # noqa: PLC0415 + from octop_memory.pipeline.recall import recall_for_prompt # noqa: PLC0415 caller = user or _caller_user(ctx) memory = _memory() @@ -339,7 +339,7 @@ def memory_search( ) memory = _memory() if corpus_value == "raw": - from harness_memory.application.path_projection import raw_to_path # noqa: PLC0415 + from octop_memory.application.path_projection import raw_to_path # noqa: PLC0415 hits = [ { @@ -377,7 +377,7 @@ def memory_get( start: 可选起始行号(1-based)。 lines: 可选返回行数。 """ - from harness_memory.application.runtime import MemoryRuntime # noqa: PLC0415 + from octop_memory.application.runtime import MemoryRuntime # noqa: PLC0415 params: dict[str, Any] = {"path": path} if start is not None: @@ -645,7 +645,7 @@ def memory_candidates( limit: 最多返回条数,默认 50。 """ memory = _memory() - from harness_memory.core import CandidateStatus # noqa: PLC0415 + from octop_memory.core import CandidateStatus # noqa: PLC0415 # ``CandidateStatus`` is a ``typing.Literal`` (not an Enum), so it cannot # be instantiated: ``CandidateStatus(status)`` raises ``TypeError``. The diff --git a/src/octop/infra/agents/memory_recall_patch.py b/src/octop/infra/agents/memory_recall_patch.py index 5e4cf1443..c180ee0c6 100644 --- a/src/octop/infra/agents/memory_recall_patch.py +++ b/src/octop/infra/agents/memory_recall_patch.py @@ -1,4 +1,4 @@ -"""Persistent recall-quality patches for ``harness_memory``. +"""Persistent recall-quality patches for ``octop_memory``. The upstream ``harness-memory`` package (PyPI) ships a recall pipeline whose retrieval quality is poor for multi-person shared-conversation datasets like @@ -42,7 +42,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from harness_memory.pipeline.recall.router import RoutingDecision + from octop_memory.pipeline.recall.router import RoutingDecision logger = logging.getLogger(__name__) @@ -153,7 +153,7 @@ def _patched_route( *, thread_id: str | None = None, ) -> RoutingDecision: - from harness_memory.pipeline.recall.router import _orig_route # noqa: PLC0415 + from octop_memory.pipeline.recall.router import _orig_route # noqa: PLC0415 # Re-resolve hints with suffix stripping: try each candidate alias in # order, resolve the first hit, and inject it back into the parsed @@ -330,8 +330,8 @@ def apply_memory_recall_patch() -> None: return try: - import harness_memory.pipeline.recall.multi_source as ms # noqa: PLC0415 - import harness_memory.pipeline.recall.router as router # noqa: PLC0415 + import octop_memory.pipeline.recall.multi_source as ms # noqa: PLC0415 + import octop_memory.pipeline.recall.router as router # noqa: PLC0415 if not hasattr(router, "_orig_route"): router._orig_route = router.route diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py index b8980640f..311e32bcc 100644 --- a/src/octop/infra/server.py +++ b/src/octop/infra/server.py @@ -415,7 +415,7 @@ async def _boot_runtime(self, config: OctopConfig) -> None: configure_browser_idle_timeout(config.browser_idle_timeout_minutes) - # harness_memory recall 质量补丁(实体后缀解析 + 强 token 排序): + # octop_memory recall 质量补丁(实体后缀解析 + 强 token 排序): # 在任意 MemoryService / MCP 记忆工具创建前应用,保证评测与生产召回一致。 from octop.infra.agents.memory_recall_patch import ( apply_memory_recall_patch, diff --git a/tests/unit/agents/test_memory_mcp.py b/tests/unit/agents/test_memory_mcp.py index b9232d37d..6d7044d2a 100644 --- a/tests/unit/agents/test_memory_mcp.py +++ b/tests/unit/agents/test_memory_mcp.py @@ -88,7 +88,7 @@ def test_build_registers_eleven_tools(fake_memory): def test_memory_recall_uses_full_pipeline(fake_memory, monkeypatch, bind_agent): """memory_recall runs the full recall pipeline (recall_for_prompt).""" - import harness_memory.pipeline.recall as _recall + import octop_memory.pipeline.recall as _recall class _Snippet: source_id = "atom-1" @@ -110,7 +110,7 @@ class _Snippet: def test_memory_recall_forwards_session_and_thread(fake_memory, monkeypatch, bind_agent): """Hook callers can scope recall to a session/thread (echo guard + co-reference).""" - import harness_memory.pipeline.recall as _recall + import octop_memory.pipeline.recall as _recall captured = {} @@ -139,7 +139,7 @@ def _fake(memory, query, **kwargs): def test_memory_recall_without_scope_passes_none(fake_memory, monkeypatch, bind_agent): - import harness_memory.pipeline.recall as _recall + import octop_memory.pipeline.recall as _recall captured = {} @@ -436,7 +436,7 @@ def memory_get(self, params): captured["get_params"] = params return dict(get_result or {}) - import harness_memory.application.runtime as _runtime + import octop_memory.application.runtime as _runtime monkeypatch.setattr(_runtime, "MemoryRuntime", _FakeRuntime) @@ -538,7 +538,7 @@ def __init__(self, memory): def memory_get(self, params): raise ValueError("path must be non-empty and unpadded: 'nope'") - import harness_memory.application.runtime as _runtime + import octop_memory.application.runtime as _runtime monkeypatch.setattr(_runtime, "MemoryRuntime", _Boom) mcp = mm.build_memory_mcp(mock.MagicMock()) diff --git a/tests/unit/agents/test_memory_recall_patch.py b/tests/unit/agents/test_memory_recall_patch.py index e88242e1f..87d51140e 100644 --- a/tests/unit/agents/test_memory_recall_patch.py +++ b/tests/unit/agents/test_memory_recall_patch.py @@ -1,4 +1,4 @@ -"""Unit tests for the harness_memory recall-quality patch (infra/agents/memory_recall_patch). +"""Unit tests for the octop_memory recall-quality patch (infra/agents/memory_recall_patch). The patch monkey-patches three symbols inside the upstream ``harness-memory`` package at startup, so these tests only assert the wiring contract: the three @@ -9,8 +9,8 @@ from __future__ import annotations -import harness_memory.pipeline.recall.multi_source as multi_source -import harness_memory.pipeline.recall.router as router +import octop_memory.pipeline.recall.multi_source as multi_source +import octop_memory.pipeline.recall.router as router from octop.infra.agents import memory_recall_patch as patch From 09b25c3806a37db0b3062a2eca897d4346027690 Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Tue, 13 Oct 2026 11:51:38 +0800 Subject: [PATCH 16/21] feat(memory-mcp): port local terminal integration (installer, hooks, session archive) - /mcp/memory/setup + /uninstall installer for Claude Code / Kiro / DSH / Codex / Cursor (client_setup.py), per-host hook scripts, and the DSH shared-memory preset - transcript import and server-side session archive (claude_transcript.py, memory_session_archive.py) - auth stays a single configurable header: OCTOP_TOKEN_HEADER / OCTOP_ENTRY_TOKEN_HEADER (default Authorization -> Bearer ); the deployment-specific token dialect is gone - verified: pytest tests/unit/agents/test_{client_setup,hook_scripts,memory_session_archive,claude_transcript}.py -> 97 passed --- .../dsh-memory-share/cordis.patch.yml | 164 ++ .../dsh-memory-share/package.json | 12 + src/octop/infra/agents/claude_transcript.py | 1097 +++++++++++++ src/octop/infra/agents/client_setup.py | 1449 +++++++++++++++++ .../hook_scripts/claude-code/stop-capture.mjs | 114 ++ .../claude-code/user-prompt-recall.mjs | 51 + .../hook_scripts/codex/stop-capture.mjs | 102 ++ .../hook_scripts/codex/user-prompt-recall.mjs | 55 + .../hook_scripts/cursor/stop-capture.mjs | 65 + .../cursor/user-prompt-recall.mjs | 56 + .../agents/hook_scripts/dsh/memory-target.mjs | 88 + .../agents/hook_scripts/dsh/stop-capture.mjs | 223 +++ .../hook_scripts/dsh/user-prompt-recall.mjs | 97 ++ .../agents/hook_scripts/kiro/stop-capture.mjs | 74 + .../hook_scripts/lib/octop-memory-client.mjs | 362 ++++ .../infra/agents/memory_session_archive.py | 210 +++ tests/unit/agents/test_claude_transcript.py | 830 ++++++++++ tests/unit/agents/test_client_setup.py | 1257 ++++++++++++++ tests/unit/agents/test_hook_scripts.py | 118 ++ .../agents/test_memory_session_archive.py | 139 ++ 20 files changed, 6563 insertions(+) create mode 100644 src/octop/infra/agents/agent_presets/dsh-memory-share/cordis.patch.yml create mode 100644 src/octop/infra/agents/agent_presets/dsh-memory-share/package.json create mode 100644 src/octop/infra/agents/claude_transcript.py create mode 100644 src/octop/infra/agents/client_setup.py create mode 100644 src/octop/infra/agents/hook_scripts/claude-code/stop-capture.mjs create mode 100644 src/octop/infra/agents/hook_scripts/claude-code/user-prompt-recall.mjs create mode 100644 src/octop/infra/agents/hook_scripts/codex/stop-capture.mjs create mode 100644 src/octop/infra/agents/hook_scripts/codex/user-prompt-recall.mjs create mode 100644 src/octop/infra/agents/hook_scripts/cursor/stop-capture.mjs create mode 100644 src/octop/infra/agents/hook_scripts/cursor/user-prompt-recall.mjs create mode 100644 src/octop/infra/agents/hook_scripts/dsh/memory-target.mjs create mode 100644 src/octop/infra/agents/hook_scripts/dsh/stop-capture.mjs create mode 100644 src/octop/infra/agents/hook_scripts/dsh/user-prompt-recall.mjs create mode 100644 src/octop/infra/agents/hook_scripts/kiro/stop-capture.mjs create mode 100644 src/octop/infra/agents/hook_scripts/lib/octop-memory-client.mjs create mode 100644 src/octop/infra/agents/memory_session_archive.py create mode 100644 tests/unit/agents/test_claude_transcript.py create mode 100644 tests/unit/agents/test_client_setup.py create mode 100644 tests/unit/agents/test_hook_scripts.py create mode 100644 tests/unit/agents/test_memory_session_archive.py diff --git a/src/octop/infra/agents/agent_presets/dsh-memory-share/cordis.patch.yml b/src/octop/infra/agents/agent_presets/dsh-memory-share/cordis.patch.yml new file mode 100644 index 000000000..023f486ad --- /dev/null +++ b/src/octop/infra/agents/agent_presets/dsh-memory-share/cordis.patch.yml @@ -0,0 +1,164 @@ +# DSH agent preset「记忆共享模式」: the shipped `cordis` (创造模式) child plugin +# list, plus the octop memory hooks. Copied from DSH's `@deepseek-ai/dsh-web-app` +# preset `cordis` — refresh this copy when that preset changes (见 client_setup.py +# 的 `_DSH_PRESET_FILES`), and keep `configPath` as the installer's placeholder. +- insert: + - id: preset-octop-shared-memory + name: '@deepseek-ai/dsh-agent-preset' + config: + id: octop-shared-memory + name: 记忆共享模式 + description: 基于创造模式的全能力副本,并接入 octop 共享记忆: + user prompt submit 自动检索相关记忆注入上下文,session stop 自动上报本会话的对话。 + order: 5 + plugins: + # Same persona as `standard`: tool descriptions and the skill catalog carry + # the operating detail, and the skills own the preset-authoring rules. + - id: persona + name: '@deepseek-ai/dsh-persona' + config: + suffix: Your working directory is {{cwd}}. + prefix: >- + You are a coding agent powered by the {{model}} model. + - id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + disabled: !!js process.platform === 'win32' + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: !!js process.platform !== 'win32' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + - id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + - id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + - id: command-goal + name: '@deepseek-ai/dsh-command-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + - id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + - id: compaction + name: cordis:group + group: true + isolate: + compaction: true + toolResultPruner: true + config: + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + - id: tool-result-pruner + name: '@deepseek-ai/dsh-compaction-tool-result-pruner' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + - id: delegation + name: cordis:group + group: true + isolate: + workflowEngine: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + modelSelectionSettings: true + backgroundMode: continuable + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed + - id: workflow-ptc + name: '@deepseek-ai/dsh-workflow-ptc' + config: + provider: spawn + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + disabled: true + config: + subagentProvider: spawn + maxRounds: 64 + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: true + searchTimeoutMs: 60000 + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' + - id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + config: + customSkillDirs: + - !!js process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:path').dirname(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('@deepseek-ai/dsh-agent-preset/package.json')), 'skills') + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + - id: present + name: '@deepseek-ai/dsh-tool-present' + - id: tool-plugin-manager + name: '@deepseek-ai/dsh-plugin-manager/tools' + disabled: !!js "!ctx.get('profileContext')" + + # 共享记忆:UserPromptSubmit 检索注入,Stop 上报(hook 脚本见 ~/.dsh/hooks/dsh)。 + - id: hooks-octop-memory + name: '@deepseek-ai/dsh-hooks-claude-code' + config: + configPath: __OCTOP_HOOKS_JSON__ diff --git a/src/octop/infra/agents/agent_presets/dsh-memory-share/package.json b/src/octop/infra/agents/agent_presets/dsh-memory-share/package.json new file mode 100644 index 000000000..2f113d55f --- /dev/null +++ b/src/octop/infra/agents/agent_presets/dsh-memory-share/package.json @@ -0,0 +1,12 @@ +{ + "name": "octop-dsh-memory-share", + "version": "0.1.0", + "private": true, + "description": "DSH agent preset 记忆共享模式: the shipped cordis toolset plus octop memory hooks (UserPromptSubmit recall, Stop capture).", + "type": "module", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + } +} diff --git a/src/octop/infra/agents/claude_transcript.py b/src/octop/infra/agents/claude_transcript.py new file mode 100644 index 000000000..413251918 --- /dev/null +++ b/src/octop/infra/agents/claude_transcript.py @@ -0,0 +1,1097 @@ +"""Replay terminal session transcripts (Claude Code, Kiro) into an agent's memory (L0). + +Claude Code already writes a complete session transcript to +``~/.claude/projects//.jsonl``: every user prompt, assistant +reply, tool call and tool result, each with its own timestamp. Re-capturing that +session through ``memory_capture`` cannot reproduce it — a real session has +hundreds of turns and dozens of tool calls — so this module replays the +transcript straight into L0 instead, where the normal extraction pipeline +(``extract`` → candidate → promote → atom) picks it up unchanged. + +Record mapping (record types that carry no conversation content — ``progress`` / +``queue-operation`` / ``last-prompt`` / ``system`` — are ignored): + +============================ ===================== ========================== +Claude Code record L0 ``event_type`` payload +============================ ===================== ========================== +user, ``content`` str/text ``user_message`` — +user, ``tool_result`` block ``tool_result`` tool_use_id, is_error +assistant, ``text`` block ``assistant_message`` — +assistant, ``tool_use`` block ``tool_call`` tool_name, input +assistant, ``thinking`` — skipped (scratchpad) +============================ ===================== ========================== + +Every event carries ``payload["src_key"] = ":"``, which +makes the import idempotent: keys already present in the target session are +skipped, so re-running a file (or a transcript that grew with new turns) only +appends what is missing. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import uuid +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from octop_memory.types import RawEventType + +logger = logging.getLogger(__name__) + +DEFAULT_HOST = "claude-code" +"""``RawEvent.host`` stamped on imported events (``memory_raws(host=…)`` filter).""" + +SESSION_PREFIX = "cc:" +"""L0 session id prefix; one Claude Code session maps onto one octop session.""" + +_MAX_CONTENT_CHARS = 4000 +"""Cap on a stored L0 body. + +A ``tool_result`` can be an entire file or build log, and the extractor prompt +receives every event's ``content`` in one call — so bodies have to stay bounded. +""" + +_MAX_PAYLOAD_CHARS = 4000 +"""Cap on a single serialized payload value (tool inputs are stored verbatim).""" + +_MESSAGE_EVENT_TYPES: frozenset[str] = frozenset({"user_message", "assistant_message"}) +"""The two event types a whole-message marker check may drop.""" + +_TOOL_EVENT_POLICIES: dict[str, frozenset[str]] = { + "all": frozenset(), + "calls": frozenset({"tool_result"}), + "none": frozenset({"tool_call", "tool_result"}), +} +"""``tool_events`` value → the tool event types to drop from L0. + +This is a *landing* policy, not an upload filter: the terminal always ships the whole +transcript (byte-delta, gzip+base64) and the parser reads every record — the policy +only decides which records become L0 rows. The product entry points (the +``memory_capture`` tool and ``octop memory import-claude``) default to ``none``: +L0 raw events feed the extractor and act as the recall fallback layer, and tool +traffic is 79% of a transcript's bytes (``tool_result`` alone 61%), so keeping it +dilutes what the session's memories look like. ``calls`` keeps what the agent did +and drops the output; ``all`` keeps the record whole for callers that want it. +""" + + +def _dropped_tool_types(tool_events: str) -> frozenset[str]: + try: + return _TOOL_EVENT_POLICIES[tool_events] + except KeyError: + raise ValueError( + f"unknown tool_events {tool_events!r}; expected one of {sorted(_TOOL_EVENT_POLICIES)}" + ) from None + + +_EXISTING_SCAN_LIMIT = 100_000 +"""How many existing L0 rows to scan for ``cc_key`` when deduplicating.""" + +_OWN_READ_TOOLS: frozenset[str] = frozenset( + { + "memory_recall", + "memory_search", + "memory_get", + "memory_raws", + "memory_candidates", + } +) +"""Our own memory *read* tools. + +Their tool results restate what is already stored, so importing them back into L0 +would close the "inject → capture → recall again" loop. They are dropped at parse +time — before the marker-based check — because a hook may format its own +injection block and lose the marker. +""" + + +def _is_own_read_tool(tool_name: str) -> bool: + """True for our read tools under any client naming. + + The MCP entry may be called ``octop-memory-local`` / ``-prod`` / ``-oauth`` or + anything the user renamed it to, so only the tool name decides: the bare + ``memory_search``, Claude Code's ``mcp____memory_search`` and Kiro's + ``mcp__memory_search``. + """ + tail = tool_name.rsplit("__", 1)[-1] + return tail in _OWN_READ_TOOLS or any(tool_name.endswith(name) for name in _OWN_READ_TOOLS) + + +@dataclass(frozen=True) +class TranscriptEvent: + """One L0 event mapped out of a Claude Code transcript record.""" + + key: str + event_type: RawEventType + content: str + timestamp: datetime + payload: dict[str, Any] + + +@dataclass(frozen=True) +class ParsedSession: + """Result of parsing one ``.jsonl`` transcript.""" + + session_id: str | None + """The Claude Code ``sessionId`` (``None`` when no record carried one).""" + + events: list[TranscriptEvent] + + skipped_own_results: int = 0 + """Tool results dropped because they came from our own memory read tools.""" + skipped_tools: int = 0 + """Tool events dropped by the caller's ``tool_events`` policy.""" + + +@dataclass +class ImportStats: + """Per-file import counters, reported back to the caller.""" + + session_id: str + parsed: int = 0 + imported: int = 0 + skipped_existing: int = 0 + skipped_echo: int = 0 + """Events dropped because their content carried a recall-injection marker.""" + skipped_own_results: int = 0 + """Tool results dropped because they came from our own memory read tools.""" + skipped_tools: int = 0 + """Tool events dropped by the caller's ``tool_events`` policy.""" + sidechain: int = 0 + dry_run: bool = False + warnings: list[str] = field(default_factory=list) + by_type: dict[str, int] = field(default_factory=dict) + """Parsed-event histogram (``event_type`` -> count), for caller verification.""" + + +# --------------------------------------------------------------------------- +# parsing (pure — no writes, no memory instance) +# --------------------------------------------------------------------------- + + +def _iter_records(text: str) -> Iterator[Any]: + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + logger.debug("claude_transcript: skipping malformed transcript line") + + +def _parse_timestamp(value: Any) -> datetime: + if isinstance(value, (int, float)) and value: + # DSH writes epoch milliseconds (``time``), older hosts seconds. + seconds = value / 1000 if value > 1e11 else float(value) + try: + return datetime.fromtimestamp(seconds, tz=UTC) + except (OverflowError, OSError, ValueError): + return datetime.now(UTC) + if isinstance(value, str) and value: + text = value[:-1] + "+00:00" if value.endswith("Z") else value + try: + parsed = datetime.fromisoformat(text) + except ValueError: + parsed = None + if parsed is not None: + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + return datetime.now(UTC) + + +def _block_text(value: Any) -> str: + """Flatten a tool-result body (string, or a list of content blocks) to text.""" + if isinstance(value, str): + return value + if isinstance(value, list): + parts = [ + str(block.get("text") or "") + for block in value + if isinstance(block, dict) and block.get("type") == "text" + ] + return "\n".join(part for part in parts if part) + return "" + + +def _cap(text: str, limit: int = _MAX_CONTENT_CHARS) -> str: + body = (text or "").strip() + return body if len(body) <= limit else body[: limit - 1].rstrip() + "…" + + +def _bounded(value: Any) -> Any: + """Keep a payload value structured but bounded (payload is stored verbatim).""" + if isinstance(value, str): + return _cap(value, _MAX_PAYLOAD_CHARS) + try: + encoded = json.dumps(value, ensure_ascii=False, default=str) + except (TypeError, ValueError): + return str(value)[:_MAX_PAYLOAD_CHARS] + if len(encoded) <= _MAX_PAYLOAD_CHARS: + return value + return {"truncated_json": encoded[:_MAX_PAYLOAD_CHARS]} + + +def _blocks(content: Any) -> list[dict[str, Any]]: + """Normalize a message body to a list of content blocks.""" + if isinstance(content, str): + return [{"type": "text", "text": content}] + if isinstance(content, list): + return [block for block in content if isinstance(block, dict)] + return [] + + +def _map_block( + block: dict[str, Any], *, role: str +) -> tuple[RawEventType, str, dict[str, Any]] | None: + """Map one content block to ``(event_type, content, payload extras)``.""" + block_type = block.get("type") + if block_type == "text": + text = str(block.get("text") or "") + if not text.strip(): + return None + event_type: RawEventType = "user_message" if role == "user" else "assistant_message" + return event_type, _cap(text), {} + if block_type == "tool_use": + name = str(block.get("name") or "tool") + args = block.get("input") + summary = json.dumps(args, ensure_ascii=False, default=str) if args else "" + return ( + "tool_call", + _cap(f"{name} {summary}".strip()), + {"tool_use_id": block.get("id"), "tool_name": name, "input": _bounded(args)}, + ) + if block_type == "tool_result": + body = _cap(_block_text(block.get("content"))) + return ( + "tool_result", + body or "(empty tool result)", + {"tool_use_id": block.get("tool_use_id"), "is_error": bool(block.get("is_error"))}, + ) + # ``thinking`` (model scratchpad), images and unknown blocks are not L0 material. + return None + + +def parse_text(text: str) -> ParsedSession: + """Map transcript JSONL *content* to L0 events (pure, no writes). + + This is the entry point for callers that receive the transcript over the + wire (a cloud deployment cannot read the client's ``~/.claude`` directory). + """ + session_id: str | None = None + events: list[TranscriptEvent] = [] + tool_names: dict[str, str] = {} + skipped_own = 0 + for index, record in enumerate(_iter_records(text)): + if not isinstance(record, dict): + continue + record_type = record.get("type") + if record_type not in ("user", "assistant"): + continue + raw_session = record.get("sessionId") + if session_id is None and raw_session: + session_id = str(raw_session) + message = record.get("message") + if not isinstance(message, dict): + continue + role = str(message.get("role") or record_type) + timestamp = _parse_timestamp(record.get("timestamp")) + record_uuid = str(record.get("uuid") or f"line{index}") + sidechain = bool(record.get("isSidechain")) + for block_index, block in enumerate(_blocks(message.get("content"))): + if block.get("type") == "tool_use": + call_id = block.get("id") + if isinstance(call_id, str): + tool_names[call_id] = str(block.get("name") or "") + elif block.get("type") == "tool_result": + call_id = block.get("tool_use_id") + origin = tool_names.get(str(call_id) or "", "") + if origin and _is_own_read_tool(origin): + skipped_own += 1 + continue + mapped = _map_block(block, role=role) + if mapped is None: + continue + event_type, content, extra = mapped + key = f"{record_uuid}:{block_index}" + events.append( + TranscriptEvent( + key=key, + event_type=event_type, + content=content, + timestamp=timestamp, + payload={ + "source": DEFAULT_HOST, + "src_key": key, + "role": role, + "sidechain": sidechain, + **{name: value for name, value in extra.items() if value is not None}, + }, + ) + ) + return ParsedSession(session_id=session_id, events=events, skipped_own_results=skipped_own) + + +def parse_session(path: Path) -> ParsedSession: + """Map a transcript *file* to L0 events (same-host callers).""" + return parse_text(path.read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# Kiro (``~/.kiro/sessions///messages.jsonl``) +# --------------------------------------------------------------------------- + +_KIRO_KINDS: dict[str, str] = { + "user": "user_message", + "assistant": "assistant_message", + "tool_call": "tool_call", + "tool_result": "tool_result", +} +"""Kiro ``payload.type`` → L0 ``event_type``. + +Everything else Kiro writes (``turn_start`` / ``turn_end`` / ``session_metadata`` / +``usage_summary`` / ``session_event`` / ``pending_interaction`` / +``interaction_resolved``) carries no conversation content. ``ContextualHookInvoked`` +is deliberately absent: that is our own hook invocation — it even carries the hook's +``output`` — i.e. exactly the plumbing that must not become memory. +""" + +_KIRO_SCRATCHPAD: frozenset[str] = frozenset({"Reasoning"}) +"""``assistant.operationType`` that is the model's scratchpad (Kiro's thinking).""" + + +def _map_kiro_payload(payload: dict[str, Any]) -> tuple[RawEventType, str, dict[str, Any]] | None: + kind = payload.get("type") + event_type = _KIRO_KINDS.get(kind) if isinstance(kind, str) else None + if event_type is None: + return None + if event_type in ("user_message", "assistant_message"): + if str(payload.get("operationType") or "") in _KIRO_SCRATCHPAD: + return None + text = str(payload.get("content") or "") + if not text.strip(): + return None + return event_type, _cap(text), {} + if event_type == "tool_call": + name = str(payload.get("toolName") or "tool") + args = payload.get("args") + summary = json.dumps(args, ensure_ascii=False, default=str) if args else "" + return ( + "tool_call", + _cap(f"{name} {summary}".strip()), + { + "tool_use_id": payload.get("toolCallId"), + "tool_name": name, + "input": _bounded(args), + }, + ) + body = _cap(str(payload.get("content") or "")) + return ( + "tool_result", + body or "(empty tool result)", + {"tool_use_id": payload.get("toolCallId"), "success": payload.get("success")}, + ) + + +def parse_kiro_text(text: str) -> ParsedSession: + """Map a Kiro ``messages.jsonl`` to L0 events (pure, no writes). + + The Kiro session id is the **directory** name, not a record field, so callers + pass it explicitly (the hook does: ``session_id="kiro:"``). + """ + events: list[TranscriptEvent] = [] + skipped_own = 0 + for index, record in enumerate(_iter_records(text)): + if not isinstance(record, dict): + continue + payload = record.get("payload") + if not isinstance(payload, dict): + continue + if payload.get("type") == "ContextualHookInvoked": + skipped_own += 1 + continue + mapped = _map_kiro_payload(payload) + if mapped is None: + continue + event_type, content, extra = mapped + record_id = str(record.get("id") or f"line{index}") + key = f"{record_id}:0" + events.append( + TranscriptEvent( + key=key, + event_type=event_type, + content=content, + timestamp=_parse_timestamp(record.get("timestamp")), + payload={ + "source": DEFAULT_HOST, + "src_key": key, + "role": "user" + if event_type in ("user_message", "tool_result") + else "assistant", + "sidechain": False, + **{name: value for name, value in extra.items() if value is not None}, + }, + ) + ) + return ParsedSession(session_id=None, events=events, skipped_own_results=skipped_own) + + +# --------------------------------------------------------------------------- +# DSH (``~/.dsh/sessions///session.jsonl.zstd``) +# --------------------------------------------------------------------------- + +_DSH_TOOL_BLOCK_TYPES: frozenset[str] = frozenset({"tool-call"}) +"""Block types that restate a call already emitted as its own ``tool/call`` record.""" + + +def _dsh_text_blocks(blocks: Any) -> str: + """Join the ``text`` blocks of a DSH content array (images / attachments dropped).""" + if not isinstance(blocks, list): + return "" + parts = [ + str(block.get("text") or "") + for block in blocks + if isinstance(block, dict) and block.get("type") == "text" + ] + return "\n".join(part for part in parts if part) + + +def _dsh_event( + key: str, + event_type: RawEventType, + content: str, + timestamp: datetime, + role: str, + extra: dict[str, Any] | None = None, +) -> TranscriptEvent: + return TranscriptEvent( + key=key, + event_type=event_type, + content=content, + timestamp=timestamp, + payload={ + "source": DEFAULT_HOST, + "src_key": key, + "role": role, + "sidechain": False, + **(extra or {}), + }, + ) + + +def parse_dsh_text(text: str) -> ParsedSession: + """Map a DSH ``session.jsonl`` to L0 events (pure, no writes). + + DSH streams its answer in ``assistant/chunk`` / ``reasoning-chunks`` / + ``text-chunks`` records and then writes the finished message — only the finished + records are mapped, otherwise every token would land in L0 twice. A call also + appears twice (as a ``tool/call`` record and as a ``tool-call`` block inside + ``assistant/message``), so the record wins and the block is skipped by call id. + """ + records = [record for record in _iter_records(text) if isinstance(record, dict)] + canonical_calls = { + str((record.get("data") or {}).get("callId") or "") + for record in records + if record.get("type") == "tool/call" + } + session_id: str | None = None + events: list[TranscriptEvent] = [] + skipped_own = 0 + for index, record in enumerate(records): + record_type = record.get("type") + raw_data = record.get("data") + data: dict[str, Any] = raw_data if isinstance(raw_data, dict) else {} + timestamp = _parse_timestamp(record.get("time")) + seq = record.get("seq", f"line{index}") + if record_type == "session": + session_id = session_id or str(record.get("id") or "") or None + elif record_type == "user/message": + raw_source = data.get("source") + kind = str(raw_source.get("kind") or "") if isinstance(raw_source, dict) else "" + if kind and kind != "user": + skipped_own += 1 + continue + body = _cap(_dsh_text_blocks(data.get("content"))) + if body.strip(): + events.append(_dsh_event(f"{seq}:0", "user_message", body, timestamp, "user")) + elif record_type == "assistant/message": + raw_message = data.get("message") + message: dict[str, Any] = raw_message if isinstance(raw_message, dict) else {} + for block_index, block in enumerate(message.get("content") or []): + if not isinstance(block, dict): + continue + block_type = block.get("type") + if block_type == "text": + body = _cap(str(block.get("text") or "")) + if body.strip(): + events.append( + _dsh_event( + f"{seq}:{block_index}", + "assistant_message", + body, + timestamp, + "assistant", + ) + ) + elif ( + block_type in _DSH_TOOL_BLOCK_TYPES + and str(block.get("id") or "") not in canonical_calls + ): + events.append(_dsh_tool_call(block, f"{seq}:{block_index}", timestamp)) + elif record_type == "tool/call": + events.append( + _dsh_tool_call( + { + "id": data.get("callId"), + "name": data.get("name"), + "arguments": data.get("arguments"), + }, + f"{seq}:0", + timestamp, + ) + ) + elif record_type == "tool/result": + raw_result = data.get("message") + result_message: dict[str, Any] = raw_result if isinstance(raw_result, dict) else {} + for block_index, block in enumerate(result_message.get("content") or []): + if not isinstance(block, dict) or block.get("type") != "tool-result": + continue + body = _cap(_dsh_text_blocks(block.get("content"))) + events.append( + _dsh_event( + f"{seq}:{block_index}", + "tool_result", + body or "(empty tool result)", + timestamp, + "user", + { + "tool_use_id": block.get("toolCallId"), + "is_error": block.get("isError"), + }, + ) + ) + # every other record type is streaming delta / turn-step / metadata noise + return ParsedSession(session_id=session_id, events=events, skipped_own_results=skipped_own) + + +def _dsh_tool_call(block: dict[str, Any], key: str, timestamp: datetime) -> TranscriptEvent: + name = str(block.get("name") or "tool") + raw_args = block.get("arguments") + parsed: Any = raw_args + if isinstance(raw_args, str) and raw_args.strip(): + try: + parsed = json.loads(raw_args) + except json.JSONDecodeError: + parsed = raw_args + summary = json.dumps(parsed, ensure_ascii=False, default=str) if parsed else "" + return _dsh_event( + key, + "tool_call", + _cap(f"{name} {summary}".strip()), + timestamp, + "assistant", + {"tool_use_id": block.get("id"), "tool_name": name, "input": _bounded(parsed)}, + ) + + +# --------------------------------------------------------------------------- +# Codex CLI (``~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl``) +# --------------------------------------------------------------------------- + +_CODEX_TOOL_ITEM_TYPES: frozenset[str] = frozenset( + {"function_call", "local_shell_call", "custom_tool_call", "web_search_call"} +) +_CODEX_TOOL_OUTPUT_TYPES: frozenset[str] = frozenset( + {"function_call_output", "local_shell_call_output", "custom_tool_call_output"} +) +_CODEX_TEXT_ITEM_TYPES: frozenset[str] = frozenset({"input_text", "output_text", "text"}) +"""Codex rollout records: ``event_msg`` carries the conversation, ``response_item`` +the wire-level items (developer instructions, tool calls, and a copy of the +messages). Both are folded here with a text-level dedupe so a turn is never stored +twice.""" + + +def _codex_item_text(content: Any) -> str: + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + parts: list[str] = [] + for block in content: + if isinstance(block, dict) and block.get("type") in _CODEX_TEXT_ITEM_TYPES: + text = block.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text) + elif isinstance(block, str) and block.strip(): + parts.append(block) + return "\n".join(parts) + + +def parse_codex_text(text: str) -> ParsedSession: + """Map a Codex rollout JSONL to L0 events (pure, no writes).""" + events: list[TranscriptEvent] = [] + skipped_own = 0 + seen_messages: set[str] = set() + for index, record in enumerate(_iter_records(text)): + if not isinstance(record, dict): + continue + payload = record.get("payload") + if not isinstance(payload, dict): + continue + record_type = str(record.get("type") or "") + payload_type = str(payload.get("type") or "") + record_id = str(payload.get("id") or record.get("id") or f"line{index}") + key = f"{record_id}:0" + event_type = "" + content = "" + extra: dict[str, Any] = {} + + if record_type == "event_msg": + if payload_type == "user_message": + event_type, content = "user_message", str(payload.get("message") or "") + elif payload_type == "agent_message": + event_type, content = "assistant_message", str(payload.get("message") or "") + else: + continue # task_started / token_count / turn_aborted … plumbing + if not content.strip(): + continue + seen_messages.add(content.strip()) + elif record_type == "response_item": + if payload_type in _CODEX_TOOL_ITEM_TYPES: + event_type = "tool_call" + name = str(payload.get("name") or payload_type) + content = f"{name} {json.dumps(payload.get('arguments') or payload.get('action') or {}, ensure_ascii=False)}" + elif payload_type in _CODEX_TOOL_OUTPUT_TYPES: + event_type = "tool_result" + output = payload.get("output") + content = ( + output + if isinstance(output, str) + else json.dumps(output or "", ensure_ascii=False) + ) + elif payload_type == "message": + role = str(payload.get("role") or "") + if role not in ("user", "assistant"): + skipped_own += 1 # developer/system instructions are host plumbing + continue + body = _codex_item_text(payload.get("content")) + if not body.strip(): + continue + event_type = "user_message" if role == "user" else "assistant_message" + content = body + if content.strip() in seen_messages: + skipped_own += 1 # same turn already taken from event_msg + continue + seen_messages.add(content.strip()) + else: + continue # reasoning / other wire items + else: + continue # session_meta / turn_context / world_state + + events.append( + TranscriptEvent( + key=key, + event_type=event_type, + content=content, + timestamp=_parse_timestamp(record.get("timestamp")), + payload={ + "source": DEFAULT_HOST, + "src_key": key, + "role": "user" + if event_type in ("user_message", "tool_result") + else "assistant", + "sidechain": False, + **{name: value for name, value in extra.items() if value is not None}, + }, + ) + ) + return ParsedSession(session_id=None, events=events, skipped_own_results=skipped_own) + + +# --------------------------------------------------------------------------- +# Cursor (``~/.cursor/hooks.json`` -> ``transcript_path``) +# --------------------------------------------------------------------------- + +_CURSOR_ROLE_KEYS: tuple[str, ...] = ("role", "type", "author", "sender") +_CURSOR_TEXT_KEYS: tuple[str, ...] = ("text", "content", "message", "value") + + +def _cursor_role(record: dict[str, Any]) -> str: + for name in _CURSOR_ROLE_KEYS: + value = record.get(name) + if isinstance(value, str) and value.lower() in ( + "user", + "human", + "assistant", + "ai", + "agent", + "bot", + ): + return "user" if value.lower() in ("user", "human") else "assistant" + return "" + + +def _cursor_text(value: Any) -> str: + if isinstance(value, str): + return value + if isinstance(value, list): + return _codex_item_text(value) + if isinstance(value, dict): + for name in _CURSOR_TEXT_KEYS: + if name in value: + nested = _cursor_text(value[name]) + if nested.strip(): + return nested + return "" + + +def _cursor_stable_key(record: dict[str, Any], *, role: str, body: str) -> str: + """Idempotency key that stays stable across byte-delta uploads. + + Cursor agent-transcript lines usually have no ``id`` / ``uuid``. The old + fallback ``line{index}:0`` renumbered from 0 inside every delta the stop + hook ships, so the user turn at the head of each chunk reused ``line0:0`` + and was dropped as ``skipped_existing`` while later assistant lines + (higher indices) still imported. Prefer a real id when present; otherwise + hash ``role`` + body so re-sends dedupe but distinct turns do not collide. + """ + for name in ("id", "uuid"): + value = record.get(name) + if isinstance(value, str) and value.strip(): + return f"{value.strip()}:0" + if value is not None and not isinstance(value, (dict, list)): + text = str(value).strip() + if text: + return f"{text}:0" + digest = hashlib.sha256(f"{role}\0{body}".encode()).hexdigest()[:16] + return f"c{digest}:0" + + +def parse_cursor_text(text: str) -> ParsedSession: + """Map a Cursor transcript to L0 events (pure, no writes). + + The ``stop`` hook's ``transcript_path`` points at + ``~/.cursor/projects//agent-transcripts//.jsonl``. + Each line is ``{"role","message":{"content":[{"type":"text","text":…}]}}``; + tool blocks in that array are dropped. A JSON array or + ``{"messages":[…]}`` / ``{"turns":[…]}`` is still accepted. + + ``src_key`` is content-stable (see :func:`_cursor_stable_key`) so the hook's + byte-delta uploads stay idempotent without colliding across chunks. + """ + events: list[TranscriptEvent] = [] + skipped_own = 0 + records: list[Any] = [] + stripped = text.lstrip() + if stripped.startswith("["): + loaded = json.loads(text) + records = loaded if isinstance(loaded, list) else [] + elif stripped.startswith("{"): + try: + loaded = json.loads(text) + except json.JSONDecodeError: + loaded = None + if isinstance(loaded, dict): + for name in ("messages", "turns", "items", "conversation"): + if isinstance(loaded.get(name), list): + records = loaded[name] + break + else: + records = [loaded] + else: + records = list(_iter_records(text)) # JSONL fallback + else: + records = list(_iter_records(text)) + + for record in records: + if not isinstance(record, dict): + continue + role = _cursor_role(record) + if not role: + skipped_own += 1 # tool/system entries carry no conversational role + continue + body = "" + for name in _CURSOR_TEXT_KEYS: + if name in record: + body = _cursor_text(record[name]) + if body.strip(): + break + if not body.strip(): + continue + key = _cursor_stable_key(record, role=role, body=body) + events.append( + TranscriptEvent( + key=key, + event_type="user_message" if role == "user" else "assistant_message", + content=body, + timestamp=_parse_timestamp( + record.get("timestamp") or record.get("createdAt") or record.get("created_at") + ), + payload={ + "source": DEFAULT_HOST, + "src_key": key, + "role": role, + "sidechain": False, + }, + ) + ) + return ParsedSession(session_id=None, events=events, skipped_own_results=skipped_own) + + +_PARSERS: dict[str, Callable[[str], ParsedSession]] = { + "claude-code": parse_text, + "kiro": parse_kiro_text, + "dsh": parse_dsh_text, + "codex": parse_codex_text, + "cursor": parse_cursor_text, +} +"""``source`` → transcript parser. A terminal's name picks its parser.""" + +SESSION_PREFIXES: dict[str, str] = { + "claude-code": "cc:", + "kiro": "kiro:", + "dsh": "dsh:", + "codex": "codex:", + "cursor": "cursor:", +} +"""L0 session-id prefix per terminal, so one terminal's sessions never collide.""" + +# --------------------------------------------------------------------------- +# import (writes L0 through the target agent's memory store) +# --------------------------------------------------------------------------- + + +USER_MESSAGE = "user_message" +"""The user side of a transcript; used to tell injected context from a reply.""" + + +def attribute_user(content: str, caller: str | None) -> str: + """Prefix ``说:`` so the sender survives into the extracted atom. + + harness-memory serialises only ``event_id / event_type / content / timestamp / + session_id`` for the extractor prompt (``pipeline/extractor/__init__.py``), and + ``AtomCard`` has no user column — so the raw event's ``user`` field never reaches + the model. Writing the sender into the content is the only way "who said it" + survives extraction, stays FTS-searchable and shows up verbatim on recall. + Already-prefixed content is left alone, so a re-send cannot double it. + """ + name = (caller or "").strip() + if not name: + return content + prefix = f"{name}说:" + return content if content.startswith(prefix) else prefix + content + + +def _is_echo(content: str, event_type: str, markers: tuple[str, ...]) -> bool: + """This event is our own injected text coming back, not the user's words. + + Two shapes, because the two roles differ: + + * assistant side — only a block that *starts* with the marker is ours. A reply + that merely quotes the marker (explaining how the hooks work) is real content + and must survive; + * user side — the marker anywhere means it is the context we injected (Claude + Code also prefixes it, e.g. ``Stop hook feedback:``), so it is dropped. + """ + if any(content.lstrip().startswith(marker) for marker in markers): + return True + return event_type == USER_MESSAGE and any(marker in content for marker in markers) + + +def _persist( + memory: Any, + parsed: ParsedSession, + *, + fallback_session: str, + session_id: str | None, + user: str | None, + host: str, + dry_run: bool, + skip_markers: tuple[str, ...] = (), + skip_message_markers: tuple[str, ...] = (), + tool_events: str = "all", + session_prefix: str = SESSION_PREFIX, +) -> ImportStats: + """Deduplicate a parsed session against L0 and append only the missing events. + + Accounting: ``parsed = imported + skipped_existing + skipped_echo + skipped_tools`` + plus the ``skipped_own_results`` the parser already dropped. + """ + from octop_memory.types import RawEvent # noqa: PLC0415 + + resolved_session = session_id or f"{session_prefix}{parsed.session_id or fallback_session}" + stats = ImportStats( + session_id=resolved_session, + parsed=len(parsed.events), + dry_run=dry_run, + skipped_own_results=parsed.skipped_own_results, + ) + + candidates = parsed.events + if skip_markers or skip_message_markers: + kept = [ + event + for event in candidates + # 我们的注入块以标记**开头**;模型回答里只是引用该标记(例如解释接入方式) + # 时必须保留——否则会把正常对话当回声丢掉。 + if not _is_echo(event.content, event.event_type, skip_markers) + # Injected *messages* are plumbing; the same string inside tool output is + # just data (a file being read may quote it), so that check is narrower. + and not ( + event.event_type in _MESSAGE_EVENT_TYPES + and any(marker in event.content for marker in skip_message_markers) + ) + ] + stats.skipped_echo = len(candidates) - len(kept) + candidates = kept + + drop_types = _dropped_tool_types(tool_events) + if drop_types: + kept = [event for event in candidates if event.event_type not in drop_types] + stats.skipped_tools = len(candidates) - len(kept) + candidates = kept + + stats.sidechain = sum(1 for event in candidates if event.payload.get("sidechain")) + for event in candidates: + stats.by_type[event.event_type] = stats.by_type.get(event.event_type, 0) + 1 + + seen: set[str] = set() + try: + for existing in memory.list_raw(session_id=resolved_session, limit=_EXISTING_SCAN_LIMIT): + # ``cc_key`` is the pre-Kiro name of the same field; old rows keep it. + key = (existing.payload or {}).get("src_key") or (existing.payload or {}).get("cc_key") + if isinstance(key, str): + seen.add(key) + except Exception: # noqa: BLE001 - an audit scan must not lose the import + logger.warning("claude_transcript: existing-event scan failed", exc_info=True) + stats.warnings.append("existing-event scan failed; duplicate check degraded") + + pending = [event for event in candidates if event.key not in seen] + stats.skipped_existing = len(candidates) - len(pending) + if dry_run or not pending: + return stats + + memory.add_raw_batch( + [ + RawEvent( + id=str(uuid.uuid4()), + host=host, + session_id=resolved_session, + thread_id=resolved_session, + user=user, + timestamp=event.timestamp, + event_type=event.event_type, + # Only the human side is attributed (``event_type`` already marks the + # other), and never a sidechain record — those "user" messages are a + # subagent's synthetic prompt, not something the caller said. + content=( + attribute_user(event.content, user) + if event.event_type == USER_MESSAGE and not event.payload.get("sidechain") + else event.content + ), + payload={**event.payload, "source": host}, + ) + for event in pending + ] + ) + stats.imported = len(pending) + return stats + + +def import_content( + memory: Any, + content: str, + *, + session_id: str | None = None, + user: str | None = None, + host: str = DEFAULT_HOST, + dry_run: bool = False, + skip_markers: tuple[str, ...] = (), + skip_message_markers: tuple[str, ...] = (), + tool_events: str = "all", + parser: str = "claude-code", +) -> ImportStats: + """Replay transcript *content* (JSONL text) into L0. + + This is the upload path: a cloud deployment cannot read the client's + ``~/.claude`` directory, so the terminal sends the file body instead. + ``skip_markers`` drops events whose content carries one of those strings + (e.g. this service's own recall-injection markers). + """ + if parser not in _PARSERS: + raise ValueError( + f"unknown transcript parser {parser!r}; expected one of {sorted(_PARSERS)}" + ) + return _persist( + memory, + _PARSERS[parser](content), + fallback_session="upload", + session_id=session_id, + user=user, + host=host, + dry_run=dry_run, + skip_markers=skip_markers, + skip_message_markers=skip_message_markers, + tool_events=tool_events, + session_prefix=SESSION_PREFIXES.get(parser, SESSION_PREFIX), + ) + + +def import_session( + memory: Any, + path: Path, + *, + session_id: str | None = None, + user: str | None = None, + host: str = DEFAULT_HOST, + dry_run: bool = False, + skip_markers: tuple[str, ...] = (), + skip_message_markers: tuple[str, ...] = (), + tool_events: str = "all", + parser: str = "claude-code", +) -> ImportStats: + """Replay one transcript *file* into L0 (same-host callers). + + ``session_id`` defaults to ``cc:`` so one Claude Code + session maps onto one octop session (extraction groups by session). + """ + if parser not in _PARSERS: + raise ValueError( + f"unknown transcript parser {parser!r}; expected one of {sorted(_PARSERS)}" + ) + # Kiro names every file ``messages.jsonl``; the session id is its parent dir. + fallback = path.parent.name if path.name.startswith("messages") else path.stem + return _persist( + memory, + _PARSERS[parser](path.read_text(encoding="utf-8")), + fallback_session=fallback, + session_id=session_id, + user=user, + host=host, + dry_run=dry_run, + skip_markers=skip_markers, + skip_message_markers=skip_message_markers, + tool_events=tool_events, + session_prefix=SESSION_PREFIXES.get(parser, SESSION_PREFIX), + ) + + +__all__ = [ + "DEFAULT_HOST", + "SESSION_PREFIX", + "ImportStats", + "ParsedSession", + "TranscriptEvent", + "SESSION_PREFIXES", + "import_content", + "import_session", + "parse_dsh_text", + "parse_kiro_text", + "parse_session", + "parse_text", +] diff --git a/src/octop/infra/agents/client_setup.py b/src/octop/infra/agents/client_setup.py new file mode 100644 index 000000000..6bc196208 --- /dev/null +++ b/src/octop/infra/agents/client_setup.py @@ -0,0 +1,1449 @@ +"""Bootstrap a local terminal so it talks to this memory service. + +``GET /mcp/memory/setup`` streams the interactive installer this module renders — no +authentication, because it only hands out a script: the credentials are typed by the +person running it, never fetched:: + + curl -fsSL "https:///mcp/memory/setup" | sh + # → 检测到:1) Claude Code 2) Kiro 3) DSH 4) Codex 5) Cursor + # → 要配置哪些? token: ****(不回显) 专家 ID: XXXX 用户标识(建议域账号): xxxx + +``GET /mcp/memory/uninstall`` is the counterpart — removes hooks / MCP entries this +installer wrote (does not delete server-side memory):: + + curl -fsSL "https:///mcp/memory/uninstall" | OCTOP_TERMINALS=cursor sh + +Nothing is assumed about where an installer runs. The script detects which terminals +exist, asks which to configure, prompts for the token and the expert id (reading +``/dev/tty`` — a piped script must not consume its own stdin), then installs a copy of +both hook scripts next to *each* terminal, in the directory that terminal uses, and +registers them in that terminal's own hook config: + +=========================== ========================================== ============================== +Claude Code ``~/.claude/hooks-events/`` ``~/.claude/settings.json`` +Kiro ``~/.kiro/hooks/`` ``~/.kiro/hooks/*.json`` +DSH ``~/.dsh/hooks/`` ``~/.dsh/hooks/octop-hooks.json`` +Codex ``~/.codex/hooks/`` ``~/.codex/config.toml`` +Cursor ``~/.cursor/hooks/`` ``~/.cursor/hooks.json`` +=========================== ========================================== ============================== + +Each copy carries its own ``config.json`` (endpoint, token, expert). The hook roles are +fixed: **UserPromptSubmit / beforeSubmitPrompt → memory_search** (pull background in) +and **Stop / stop → memory_capture** (ship the session transcript). +""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +_HOOKS_SRC = Path(__file__).with_name("hook_scripts") +# Every host gets the host-agnostic transport module next to its own entry scripts. +_LIB_FILES = ("lib/octop-memory-client.mjs",) + +HOST_FILES: dict[str, tuple[str, ...]] = { + "claude-code": ("claude-code/stop-capture.mjs", "claude-code/user-prompt-recall.mjs"), + "kiro": ("kiro/stop-capture.mjs",), + "dsh": ("dsh/stop-capture.mjs", "dsh/user-prompt-recall.mjs", "dsh/memory-target.mjs"), + "codex": ("codex/stop-capture.mjs", "codex/user-prompt-recall.mjs"), + "cursor": ("cursor/stop-capture.mjs", "cursor/user-prompt-recall.mjs"), +} +"""Which hook scripts a terminal gets. Each host has its own entry script — no +runtime host detection — so changing one host cannot affect another; the only +shared file is the host-agnostic transport module.""" + +_DSH_PRESET_SRC = Path(__file__).with_name("agent_presets") / "dsh-memory-share" +_DSH_PRESET_FILES = ("package.json", "cordis.patch.yml") +"""DSH ≥0.1.7 declares presets in a bundle patch, so the installer ships one: +``记忆共享模式`` = the shipped ``cordis`` plugin list + the memory hooks row.""" + +TERMINALS: tuple[str, ...] = ("claude-code", "kiro", "dsh", "codex", "cursor") +"""Terminals this installer knows how to configure.""" + +# Owned by each terminal, so no cross-terminal directory is invented. +HOOKS_DIRS: dict[str, str] = { + "claude-code": "~/.claude/hooks-events", + "kiro": "~/.kiro/hooks", + "dsh": "~/.dsh/hooks", + "codex": "~/.codex/hooks", + "cursor": "~/.cursor/hooks", +} + +_SELECT_PY = r""" +import sys + +# 交互式终端清单:↑/↓ 移动 · 空格 选择 · a 全选 · 回车 确认。 +# 键位与绘制都走 /dev/tty,stdout 只用来回传结果(shell 捕获)。 +# 任何一步不可用(无 tty / 无 termios / 空选择)就返回非 0,让 shell 回退到数字输入。 + +def main() -> int: + items = [item.split("|", 1) for item in sys.argv[1:] if "|" in item] + if not items: + return 3 + try: + import termios + import tty as ttymod + + tty_in = open("/dev/tty", "rb", buffering=0) + tty_out = open("/dev/tty", "w") + fd = tty_in.fileno() + saved = termios.tcgetattr(fd) + except Exception: + return 3 + + chosen = [False] * len(items) + cursor = 0 + + def render(first: bool = False) -> None: + if not first: + tty_out.write("\033[%dA" % (len(items) + 1)) + for index, (_, label) in enumerate(items): + box = "◉" if chosen[index] else "◯" + pointer = "❯" if index == cursor else " " + tty_out.write("\033[2K%s %s %s\r\n" % (pointer, box, label)) + hint = " ↑/↓ 移动 · 空格 选择 · a 全选 · 回车 确认" + if not any(chosen): + hint = " ↑/↓ 移动 · 空格 选择 · a 全选 · 回车 确认(还没选)" + tty_out.write("\033[2K%s\r\n" % hint) + tty_out.flush() + + def read_key() -> str: + key = tty_in.read(1) + if key == b"\x1b": + rest = tty_in.read(2) + return {b"[A": "up", b"[B": "down"}.get(rest, "other") + if key in (b"\r", b"\n"): + return "enter" + if key == b" ": + return "space" + if key in (b"a", b"A"): + return "all" + if key == b"\x03": + return "abort" + if key in (b"k", b"j"): + return "up" if key == b"k" else "down" + return "other" + + try: + ttymod.setraw(fd) + tty_out.write("要配置哪些终端?(可多选)\r\n") + render(first=True) + while True: + key = read_key() + if key == "up": + cursor = (cursor - 1) % len(items) + elif key == "down": + cursor = (cursor + 1) % len(items) + elif key == "space": + chosen[cursor] = not chosen[cursor] + elif key == "all": + turn_on = not all(chosen) + chosen = [turn_on] * len(items) + elif key == "enter": + if not any(chosen): + continue # 一个都没选时不确认,提示留在屏幕上 + break + elif key == "abort": + return 130 + render() + except Exception: + return 3 + finally: + try: + termios.tcsetattr(fd, termios.TCSADRAIN, saved) + except Exception: + pass + tty_out.write("\r\n") + + print(",".join(value for (value, _), on in zip(items, chosen) if on)) + return 0 + + +sys.exit(main()) +""".strip() + +_MERGE_PY = ''' +import base64 +import json +import os +import pathlib +import shlex +import shutil +import subprocess + + +SEARCH_PROMPT = ( + "回答前,先用 memory_search 检索与本轮相关的记忆(query 用用户本次输入,corpus=atom," + "max_results=5),把命中内容当作背景;与本次工作无关就忽略,不要重复检索同一个查询。" +) +"""Kiro 的召回提示词——Kiro 不会把 runCommand 的 stdout 注进上下文,只能让模型自己调工具。 + +上报**没有**对应的提示词:让模型"总结这段会话"只会把复述写进 L0,原始会话要由 hook 脚本 +(整段 ``messages.jsonl``)提交,见 ``do_kiro``。 +""" + + +def _load(path): + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except Exception as exc: # noqa: BLE001 - a broken config must not break setup + print(f" ! {path} 不是合法 JSON({exc});跳过该文件") + return None + + +def _dump(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\\n", encoding="utf-8") + + +def _install_hooks(target, host): + """把共享传输模块 + 该宿主的入口脚本写进这个终端自己的 hooks 目录。""" + target.mkdir(parents=True, exist_ok=True) + for rel, blob in HOOK_FILES[host].items(): + path = target / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(base64.b64decode(blob)) + config = {"url": HOOK_URL, "token": TOKEN, "tokenHeader": TOKEN_HEADER, "agentId": AGENT} + if USER_ID: + config["userId"] = USER_ID # 直连端点时「谁问的」只靠这个头 + _dump(target / "config.json", config) + print(f" ✓ hook 脚本({host} 专用,含共享传输模块)+ config.json → {target}") + print( + f" · 日志/增量状态:{target}/octop-hook.log、{target}/octop-transcript-state.json" + "(该目录不可写时退到 $TMPDIR/octop-hooks 并打印告警)" + ) + + +def _entry(transport=None): + # 两种模式,凭据各带各的: + # 直连(条目落在实例挂载点):带**实例共享 token**;header 名由 ``OCTOP_TOKEN_HEADER`` + # 决定(缺省 ``Authorization`` → ``Bearer ``),身份只能靠 ``X-Octop-User-Id`` + # 头表达。 + # OAuth 前置(条目指向代理):必须带**个人令牌**;header 名由 ``OCTOP_ENTRY_TOKEN_HEADER`` + # 决定(缺省 ``Authorization``),身份由代理按令牌重写,所以条目不写 ``X-Octop-User-Id``。 + headers = {"X-Octop-Agent-Id": AGENT} + if OAUTH_ENTRY: + if ENTRY_TOKEN: + header = ENTRY_TOKEN_HEADER or "Authorization" + headers[header] = f"Bearer {ENTRY_TOKEN}" if header == "Authorization" else ENTRY_TOKEN + else: + headers[TOKEN_HEADER] = f"Bearer {TOKEN}" if TOKEN_HEADER == "Authorization" else TOKEN + if USER_ID: + headers["X-Octop-User-Id"] = USER_ID # 「谁问的」靠这个头,不靠 token 解析 + entry = {"type": transport or "http", "url": ENTRY_URL, "headers": headers} + return entry + + +def _octop_servers(servers): + """配置里已有的 octop memory 服务名——``local`` / ``prod`` / ``oauth`` / ``test`` 都算。""" + return [name for name in servers if str(name).replace("_", "-").startswith("octop-memory")] + + +def _merge_mcp(path, transport=None): + """登记 MCP 服务:端点一致就复用已有条目,已有别的 octop 条目就不重复添加。 + + 服务名由客户端的配置说了算(同一个 ``octop-memory`` 可以带不同环境后缀、也可以被改名), + 这里只按端点判断;召回/上报 hook 调的地址来自 ``config.json``,与名字无关。 + """ + entry = _entry(transport) + cfg = _load(path) + if cfg is None: + return + servers = cfg.setdefault("mcpServers", {}) + ours = _octop_servers(servers) + reused = next( + (name for name in ours if (servers.get(name) or {}).get("url") == entry["url"]), "" + ) + if reused: + print(f" ✓ MCP 服务 {reused}(已存在且端点一致,复用)→ {path}") + return + if ours: + print(f" · {path} 已有 octop 服务:{', '.join(ours)}(端点与本次 {URL} 不同,保持原样)") + print(" · 模型侧用哪个服务由你决定;召回/上报 hook 用 config.json 里的地址") + return + servers[NAME] = entry + _dump(path, cfg) + print(f" ✓ MCP 服务 {NAME} → {path}") + + +def _claude_hooks(hooks_dir): + """Claude Code 的两条 command hook。 + + - ``UserPromptSubmit`` → 召回脚本:**该事件不支持 ``mcp_tool``**(实测 2.1.263 静默跳过), + 所以用 command hook 跑脚本,脚本直接调 MCP ``memory_search``,stdout 被注入上下文。 + - ``Stop`` → 上报脚本:``Stop`` 触发时本轮还没落盘,脚本会**后台脱离**、等几秒再读 + transcript(见脚本头注释),hook 本身立即返回,不给每轮加延迟。 + ``SessionEnd`` 不能用:那里没有 MCP 客户端上下文(``mcp_tool hooks are not available + for the 'SessionEnd' hook event``)。 + """ + search = {"type": "command", "command": hook_command(f"{hooks_dir}/claude-code/user-prompt-recall.mjs")} + stop = {"type": "command", "command": hook_command(f"{hooks_dir}/claude-code/stop-capture.mjs")} + # **简写形态**:事件直接挂 handler 数组,不套 matcher 组那层。我们的 hook 对所有调用都生效, + # 本来就不需要 matcher;而某些终端的 SDK 侧不保证 ``hooks`` 数组按顺序执行, + # 少一层嵌套更稳(老配置里的嵌套形态仍会被识别并替换,见 _handlers_of)。 + return {"UserPromptSubmit": [search], "Stop": [stop]} + + +_OUR_SCRIPTS = ( + "user-prompt-recall.mjs", + "stop-capture.mjs", + # 包装脚本(配置里引用的是 .sh) + "user-prompt-recall.sh", + "stop-capture.sh", + # 早期版本的文件名,重装时一并替换掉 + "octop-user-prompt-recall.mjs", + "octop-stop-capture.mjs", +) + +_OUR_MCP_TOOLS = ("memory_search", "memory_capture") +"""旧版 mcp_tool hook 用的工具名——按工具认,不按 MCP 服务名认。""" + + +def _is_our_handler(handler): + """认我们自己留下的 hook:command 形态按脚本名,旧版 mcp_tool 按工具名。 + + 同一个 ``octop-memory`` 条目可以带不同环境后缀、也可能被用户改名,所以不能拿服务名 + 当判据;工具名才是这段接入的契约。 + """ + handler = handler or {} + if handler.get("type") == "mcp_tool": + return handler.get("tool") in _OUR_MCP_TOOLS + command = handler.get("command") or "" + return any(script in command for script in _OUR_SCRIPTS) + + +def _handlers_of(entry): + """一个条目里的 handler 列表:可能是 matcher 组(``{"hooks": [...]}``)或简写形态的单条。 + + 两种都认 —— 简写是我们自己写的(某些终端的 SDK 侧不保证 ``hooks`` 数组按顺序执行,见 + ``_claude_hooks``),嵌套形态是别人或早期版本写的。 + """ + entry = entry or {} + nested = entry.get("hooks") + if isinstance(nested, list): + return nested + return [entry] + + +def _is_our_group(entry): + """这个 hook 条目是不是我们写的(任一 handler 命中就算)。""" + return any(_is_our_handler(handler) for handler in _handlers_of(entry)) + + +def _upsert_groups(settings, event, groups): + """Drop earlier entries that are ours (either形态), keep every other hook, add ours.""" + hooks = settings.setdefault("hooks", {}) + kept = [entry for entry in (hooks.get(event) or []) if not _is_our_group(entry)] + hooks[event] = kept + list(groups) + + +def do_claude_code(): + # 两条 hook 都是 command 脚本(召回 / 上报),任何模式都要装脚本 + config.json + _install_hooks(CC_DIR, "claude-code") + _merge_mcp(home / ".claude.json") + settings = home / ".claude" / "settings.json" + st = _load(settings) + if st is not None: + for event, groups in _claude_hooks(CC_DIR).items(): + _upsert_groups(st, event, groups) + # 早期版本挂过 SessionEnd(mcp_tool 在那里不可用,会持续报错):清掉我们留下的那条 + _upsert_groups(st, "SessionEnd", []) + if not st["hooks"].get("SessionEnd"): + st["hooks"].pop("SessionEnd", None) + _dump(settings, st) + print(" ✓ Claude Code hooks(UserPromptSubmit → 召回脚本;Stop → 上报脚本,后台延迟等落盘)") + print(f" · → {settings}") + + +def do_kiro(): + """Kiro 的 hook 只有 runCommand / askAgent 两种动作,没有 mcp_tool。 + + 因此: + + * 召回(UserPromptSubmit)用**提示词**(``agent``):Kiro 不会把 runCommand 的 stdout + 注入上下文,只能让模型经 Kiro 的 MCP 客户端调 ``memory_search``; + * 上报(Stop)**始终用 command hook** 跑脚本,把整段 + ``~/.kiro/sessions///messages.jsonl`` 提交进 L0 —— Kiro 侧 + 最有价值的就是**原始的用户/助手消息**,绝不能让模型自己总结(早期版本在共享 token + 下退化为 ``agent`` 提示词,结果是"AI 复述"进 L0)。共享 token 下"谁问的"由脚本带的 + ``X-Octop-User-Id``(安装时的 ``OCTOP_USER_ID``)表达,不再需要额外的个人令牌。 + """ + _merge_mcp(home / ".kiro" / "settings" / "mcp.json", transport="streamable_http") + _install_hooks(KIRO_DIR, "kiro") + + recall = KIRO_DIR / "memory-recall-on-start.json" + _dump( + recall, + { + "version": "v1", + "hooks": [ + { + "name": "octop memory (UserPromptSubmit)", + "trigger": "UserPromptSubmit", + "enabled": True, + "timeout": 30, + "action": {"type": "agent", "prompt": SEARCH_PROMPT}, + } + ], + }, + ) + print(f" ✓ Kiro 召回 hook(agent 提示词)已写入 {recall}") + + save = KIRO_DIR / "memory-save-on-stop.json" + action = {"type": "command", "command": hook_command(f"{KIRO_DIR}/kiro/stop-capture.mjs")} + _dump( + save, + { + "version": "v1", + "hooks": [ + { + "name": "octop memory (Stop)", + "trigger": "Stop", + "enabled": True, + "timeout": 120, + "action": action, + } + ], + }, + ) + print(f" ✓ Kiro 上报 hook(command 脚本提交整段 messages.jsonl)已写入 {save}") + + +_CODEX_HOOKS = (("UserPromptSubmit", "user-prompt-recall"), ("Stop", "stop-capture")) +_CURSOR_HOOKS = (("beforeSubmitPrompt", "user-prompt-recall"), ("stop", "stop-capture")) + + +def _toml_string(value): + """TOML 字面串:hook 命令里有双引号、没有单引号,用单引号串就不必转义。""" + return "'" + value + "'" + + +def _toml_blocks(text): + """按表头行列把 TOML 切成块,保留原始文本(首块是文件头/杂项)。""" + blocks = [[]] + for line in text.splitlines(keepends=True): + if line.lstrip().startswith("[") and not line.lstrip().startswith("#"): + blocks.append([]) + blocks[-1].append(line) + return blocks + + +def _codex_upsert(path): + """把两条 hook 挂进 ``~/.codex/config.toml``(幂等)。 + + 结构是 ``[[hooks.]]`` 容器 + ``[[hooks..hooks]]`` 处理项,先删掉我们自己 + 写过的处理项(连它那个空容器),再把新处理项插到该事件最后一组之后 —— 不动已有 hook 的 + 先后位置,因为 codex 的 hook 信任状态按 ``:::`` 记索引。 + """ + text = path.read_text(encoding="utf-8") if path.exists() else "" + blocks, kept = _toml_blocks(text), [] + for block in blocks: + body = "".join(block) + header = block[0].strip() if block else "" + ours = str(CODEX_DIR) in body + if ours and header.startswith("[[hooks.") and header.endswith(".hooks]]"): + event = header[len("[[hooks.") : -len(".hooks]]")] + # 我们自己写的空容器也一起清掉(重装不堆空表) + if kept and kept[-1][0].strip() == f"[[hooks.{event}]]" and not _codex_container_body( + kept[-1] + ): + kept.pop() + continue + kept.append(block) + + additions = {} + for event, script in _CODEX_HOOKS: + handler = ( + f"[[hooks.{event}.hooks]]\\n" + f"command = {_toml_string(hook_command(f'{CODEX_DIR}/codex/{script}.mjs'))}\\n" + f'type = "command"\\n' + ) + insert_at = None + for index, block in enumerate(kept): + header = block[0].strip() if block else "" + if header in (f"[[hooks.{event}]]", f"[[hooks.{event}.hooks]]"): + insert_at = index + 1 + if insert_at is None: + additions[event] = f"[[hooks.{event}]]\\n\\n{handler}" + else: + kept.insert(insert_at, [handler, "\\n"]) + + body = "".join("".join(block) for block in kept).rstrip("\\n") + tail = "\\n\\n".join(additions[event] for event, _ in _CODEX_HOOKS if event in additions) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text((body + "\\n\\n" if body else "") + tail + "\\n", encoding="utf-8") + + +def _codex_container_body(block): + """容器块是否只有表头(没有处理项)。""" + return not "".join(block[1:]).strip() + + +def _cursor_upsert(path): + """把两条 hook 挂进 ``~/.cursor/hooks.json``(幂等,其它事件与别人的 hook 一律保留)。""" + cfg = _load(path) + if cfg is None: + return + cfg.setdefault("version", 1) + hooks = cfg.setdefault("hooks", {}) + for event, script in _CURSOR_HOOKS: + entries = [ + entry + for entry in hooks.get(event) or [] + if str(CURSOR_DIR) not in json.dumps(entry, ensure_ascii=False) + ] + entries.append({"command": hook_command(f"{CURSOR_DIR}/cursor/{script}.mjs")}) + hooks[event] = entries + _dump(path, cfg) + + +def do_codex(): + """Codex:hook 写在 ``~/.codex/config.toml``(``[[hooks.]]`` + ``type="command"``)。 + + codex 对 hook 有信任机制:新写入的 hook 需要用户信任一次才会执行,所以这里只写配置、 + 不伪造 ``hooks.state`` 里的 ``trusted_hash``。 + """ + _install_hooks(CODEX_DIR, "codex") + config = home / ".codex" / "config.toml" + _codex_upsert(config) + print(f" ✓ Codex hooks(UserPromptSubmit → 召回;Stop → 上报)→ {config}") + print(" · 首次需在 codex 里信任该 hook(否则不执行);自动化可加 --dangerously-bypass-hook-trust") + print(" · Codex 的 MCP 服务由它自己的配置管理,这里不动") + + +def do_cursor(): + """Cursor:hook 写在 ``~/.cursor/hooks.json``(``{"version":1,"hooks":{:[{command}]}}``)。""" + _install_hooks(CURSOR_DIR, "cursor") + hooks = home / ".cursor" / "hooks.json" + _cursor_upsert(hooks) + print(f" ✓ Cursor hooks(beforeSubmitPrompt → 召回;stop → 上报)→ {hooks}") + print(" · Cursor 的 MCP 服务由它自己的配置管理,这里不动") + + +def _dsh_bridge(hooks_dir): + """DSH 的 hooks 桥接只执行 command 钩子(mcp_tool / prompt 会被跳过),故写死 command。""" + return { + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + {"type": "command", "command": hook_command(f"{hooks_dir}/dsh/user-prompt-recall.mjs")} + ] + } + ], + "Stop": [ + {"hooks": [{"type": "command", "command": hook_command(f"{hooks_dir}/dsh/stop-capture.mjs")}]} + ], + } + } + + +def _dsh_profile(): + """装哪个 profile:OCTOP_DSH_PROFILE,否则按 web / desktop / default 取第一个存在的。""" + profiles = home / ".dsh" / "profiles" + wanted = os.environ.get("OCTOP_DSH_PROFILE", "") + for name in ([wanted] if wanted else ["web", "desktop", "default"]): + if name and (profiles / name / "package.json").exists(): + return name, profiles / name + return "", pathlib.Path() + + +def _install_dsh_preset(bridge): + """把「记忆共享模式」bundle 写到 ~/.dsh/bundles,configPath 换成刚写好的桥接文件。""" + bundle = DSH_DIR.parent / "bundles" / "dsh-memory-share" + for name, blob in PRESET_FILES.items(): + target = bundle / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + base64.b64decode(blob).decode("utf-8").replace("__OCTOP_HOOKS_JSON__", str(bridge)), + encoding="utf-8", + ) + return bundle + + +def _select_dsh_bundle(profile_dir, name): + """把 bundle 加进 profile 的 dsh.profile.bundles(幂等)——DSH 只按那里的名字加载 bundle。""" + package = profile_dir / "package.json" + config = _load(package) + if config is None: + return False + bundles = config.setdefault("dsh", {}).setdefault("profile", {}).setdefault("bundles", []) + if name in bundles: + return True + bundles.append(name) + package.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\\n", encoding="utf-8") + return True + + +def _run_dsh_install(bundle, profile, profile_dir): + """把 bundle 装进 profile:优先 dsh CLI,其次直接 pnpm(源码 checkout 常没有 dsh 命令)。 + + 两条路都在 profile 目录里跑包管理器——DSH 的 CLI 也只是把参数转给 profile 里的 pnpm。 + """ + if shutil.which("dsh"): + command = ["dsh", "plugin", "--profile", profile, "add", str(bundle)] + elif shutil.which("pnpm"): + command = ["pnpm", "add", str(bundle)] + else: + print(" · 没找到 dsh / pnpm 命令,无法自动装依赖") + return None + done = subprocess.run(command, cwd=str(profile_dir), capture_output=True, text=True) + if done.returncode != 0: + tail = (done.stderr or done.stdout or "").strip().splitlines() + print(f" ! 装依赖失败({command[0]} 退出码 {done.returncode})" + (f":{tail[-1]}" if tail else "")) + return False + return True + + +def do_dsh(): + """装 hook 脚本 + 桥接文件(只有 command 钩子),并把 preset bundle 装进 profile。 + + DSH 0.1.7 起 preset 由 bundle patch 声明,旧版「在 agent preset 文件里加一行」已经 + 不再生效,所以这里直接生成 bundle 并装进 profile 的 dsh.profile.bundles。 + + 脚本直传不再挑 token 方言:共享 token 下"谁问的"由脚本带的 ``X-Octop-User-Id`` + (安装时的 ``OCTOP_USER_ID``)表达,所以任何模式都装 hooks。 + """ + _install_hooks(DSH_DIR, "dsh") + bridge = DSH_DIR / "octop-hooks.json" + _dump(bridge, _dsh_bridge(DSH_DIR)) + print(f" ✓ DSH hooks 桥接(UserPromptSubmit → 召回;Stop → 上报)→ {bridge}") + bundle = _install_dsh_preset(bridge) + name = json.loads((bundle / "package.json").read_text(encoding="utf-8"))["name"] + print(f" ✓ DSH 模式 bundle「记忆共享模式」→ {bundle}") + profile, profile_dir = _dsh_profile() + if not profile: + print(" ! 没找到 DSH profile(~/.dsh/profiles/),跳过自动安装") + elif _run_dsh_install(bundle, profile, profile_dir) and _select_dsh_bundle(profile_dir, name): + print(f" ✓ 已装进 profile「{profile}」:重启 DSH 后,新会话的模式里选「记忆共享模式」") + else: + print(" ! 没自动装进 profile,手动两步:") + print(f" 1) 在 {profile_dir} 执行:pnpm add {bundle}") + print(f" 2) 把 {name} 加进该目录 package.json 的 dsh.profile.bundles,然后重启 DSH") + if OAUTH_ENTRY: + print(" · DSH 侧这条目带个人令牌(OAuth 前置),静态凭据由代理校验") + print(" · DSH 的 MCP 服务请在它的 MCP 面板/CLI 里添加(该条目由 DSH 自己管理):") + print(f" url: {URL}") + print(f" headers: {json.dumps(_entry()['headers'], ensure_ascii=False)}") + + +home = pathlib.Path(os.path.expanduser("~")) +URL = os.environ["OCTOP_URL"] +HOOK_URL = os.environ.get("OCTOP_HOOK_URL") or URL # hooks 实际 POST 的地址(直连) +TOKEN = os.environ["OCTOP_TOKEN"] +TOKEN_HEADER = os.environ["OCTOP_TOKEN_HEADER"] +# 条目专属凭据(只有 OAuth 前置用得到):hooks 打实例、客户端条目打代理,两个凭据可以不同。 +ENTRY_TOKEN = os.environ.get("OCTOP_ENTRY_TOKEN", "").strip() +ENTRY_TOKEN_HEADER = os.environ.get("OCTOP_ENTRY_TOKEN_HEADER", "").strip() +NODE = os.environ.get("OCTOP_NODE") or "node" # 安装时解析出的绝对路径(非 login shell 也能跑) +# 用户标识:token 的唯一作用是让服务端知道「谁问的」,直连端点时只能靠这个头。 +# 建议填域账号:HTTP header 只能是 ASCII,Node 的 fetch 对非 ASCII 直接抛错(hook 会崩)。 +USER_ID = os.environ.get("OCTOP_USER_ID", "").strip() +if USER_ID and not USER_ID.isascii(): + print(f" ! 用户标识「{USER_ID}」含非 ASCII 字符:header 传不了(hook 会直接报错),已忽略") + print(" · 请填域账号(如 alice);要中文显示名需服务端支持 percent 解码") + USER_ID = "" +# MCP 条目只有两种模式:**OAuth(默认)** 或 直连 MCP token(明确不要 OAuth 时)。 +# OCTOP_DISABLE_OAUTH=true(1/yes)→ 直连:条目写 X-Octop-Agent-Id + 实例共享 token +# 不传 / false(0/no) → OAuth:条目只留 X-Octop-Agent-Id,Authorization 由客户端临时注入 +_DISABLE_OAUTH = (os.environ.get("OCTOP_DISABLE_OAUTH") or "").strip().lower() +if _DISABLE_OAUTH in ("", "0", "false", "no"): + OAUTH_ENTRY = True +elif _DISABLE_OAUTH in ("1", "true", "yes"): + OAUTH_ENTRY = False +else: + raise SystemExit( + f"OCTOP_DISABLE_OAUTH 只能填 true/false(1/0),收到 {_DISABLE_OAUTH!r}" + ) + +# 模式与地址是配对的:OAuth 只能打代理(静态 token 在那儿会被 401),直连必须打 octop 实例 +# 自己的挂载点(代理不做静态 token)。所以由模式决定条目地址,而不是由地址猜模式。 +if OAUTH_ENTRY and URL == HOOK_URL: + # 部署没声明 OAuth 前置地址 → OAuth 条目点的是实例挂载点、又没凭据,必然 401。 + # 这时按直连模式生成(实例 token 走 Authorization,身份靠 X-Octop-User-Id)。 + print(" · 本实例未声明 OAuth 前置地址(memory_public_mcp_url / ?url=)→ 按直连模式生成条目") + OAUTH_ENTRY = False + +if OAUTH_ENTRY: + ENTRY_URL = URL # 部署声明的代理地址(memory_public_mcp_url / ?url=) + NAME = os.environ["OCTOP_SERVER_NAME"] + if not ENTRY_TOKEN: + print(" ! OAuth 前置必须带个人令牌,但没拿到:条目调用会 401") + print(" · 重跑并设 OCTOP_ENTRY_TOKEN=") +else: + ENTRY_URL = HOOK_URL # 直连不认识代理,声明了也不用 + NAME = os.environ["OCTOP_SERVER_NAME_DIRECT"] + if URL != HOOK_URL: + print(f" · 直连模式忽略声明的代理地址 {URL},条目指向实例 {HOOK_URL}") + + +def hook_command(script_path): + """一条 command hook 的 ``command``:**独立包装脚本**的路径(写它、返回它)。 + + 不把 node 绝对路径 / ``NODE_OPTIONS`` / 失败兜底那一长串直接塞进 JSON-TOML 字符串 —— + 里面的引号与 ``$`` 很容易把配置写坏,也难读(用户反馈过这一点)。包装脚本负责: + + * 用安装时解析出的 node **绝对路径**跑 ``.mjs``(hook 由终端派生,非 login shell 里 + 没有 nvm 的 node,裸 ``node`` 会找不到); + * ``OCTOP_USE_SYSTEM_CA=1`` 时导出 ``NODE_OPTIONS=--use-system-ca``(Node 22.15+): + Node 自带的证书库不认企业根(macOS 钥匙串 / Windows 证书库里有、Node 没有), + 不加这行 hook 里的 fetch 会失败;已有 ``NODE_OPTIONS`` 时追加,不覆盖; + * 失败时把原因打到 stderr —— command hook 的非 0 退出是**静默**的。 + """ + mjs = pathlib.Path(str(script_path)) + wrapper = mjs.with_suffix(".sh") + lines = [ + "#!/bin/sh", + "# octop memory hook(安装器生成,勿手改):配置里只引用本文件路径", + ] + if os.environ.get("OCTOP_USE_SYSTEM_CA") == "1": + lines.append('export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--use-system-ca"') + lines.append( + f"{shlex.quote(str(NODE))} {shlex.quote(str(mjs))} " + '|| echo "octop-memory: hook 执行失败(node 路径不对或脚本报错)" >&2' + ) + wrapper.parent.mkdir(parents=True, exist_ok=True) + wrapper.write_text("\\n".join(lines) + "\\n", encoding="utf-8") + wrapper.chmod(0o755) + return shlex.quote(str(wrapper)) +AGENT = os.environ["OCTOP_AGENT"] +CC_DIR = pathlib.Path(os.environ["OCTOP_CC_DIR"]).expanduser() +KIRO_DIR = pathlib.Path(os.environ["OCTOP_KIRO_DIR"]).expanduser() +DSH_DIR = pathlib.Path(os.environ["OCTOP_DSH_DIR"]).expanduser() +CODEX_DIR = pathlib.Path(os.environ["OCTOP_CODEX_DIR"]).expanduser() +CURSOR_DIR = pathlib.Path(os.environ["OCTOP_CURSOR_DIR"]).expanduser() +HOOK_FILES = { + host: json.loads(os.environ["OCTOP_FILES_" + host.upper().replace("-", "_")]) + for host in ("claude-code", "kiro", "dsh", "codex", "cursor") +} +PRESET_FILES = json.loads(os.environ["OCTOP_FILES_PRESET"]) + +for terminal in os.environ["OCTOP_TERMINALS"].split(","): + terminal = terminal.strip() + if not terminal: + continue + print(f"· {terminal}") + { + "claude-code": do_claude_code, + "kiro": do_kiro, + "dsh": do_dsh, + "codex": do_codex, + "cursor": do_cursor, + }[terminal]() + +print(" · hook 角色:用户提交时召回(CC / DSH / Codex 的 UserPromptSubmit,Cursor 的 beforeSubmitPrompt);") +print(" 回合结束时上报(Stop / Cursor 的 stop)") +how = ( + f"OAuth 前置:条目带个人令牌({ENTRY_TOKEN_HEADER or 'Authorization'})" + if OAUTH_ENTRY + else f"直连 MCP token:条目写 {TOKEN_HEADER}" +) +print(f" · hooks 直连 {HOOK_URL};客户端 MCP 条目指向 {ENTRY_URL}({how})") +if USER_ID: + where = ( + "只写进 hooks 的 X-Octop-User-Id;条目身份由代理按令牌重写" + if OAUTH_ENTRY + else "写进条目 X-Octop-User-Id 与 hooks" + ) + print(f" · 用户标识:{USER_ID}({where})") +print(" · octop memory 接入完成(可重复执行,不会影响其它 MCP/hook 条目)") +'''.strip() + + +def _b64(text: str) -> str: + return base64.b64encode(text.encode("utf-8")).decode("ascii") + + +def _host_manifest(host: str) -> str: + """``{relative path: base64}`` for one host: its entries + the shared lib modules.""" + files = (*_LIB_FILES, *HOST_FILES[host]) + payload = {name: _b64((_HOOKS_SRC / name).read_text(encoding="utf-8")) for name in files} + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + +def _dsh_preset_manifest() -> str: + """``{relative path: base64}`` for the DSH preset bundle the installer writes.""" + payload = { + name: _b64((_DSH_PRESET_SRC / name).read_text(encoding="utf-8")) + for name in _DSH_PRESET_FILES + } + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + +def _server_name(mcp_url: str) -> str: + """``octop-memory-local`` for a loopback endpoint, ``octop-memory`` otherwise.""" + host = mcp_url.split("//", 1)[-1].split("/", 1)[0].split(":", 1)[0].lower() + return "octop-memory-local" if host in ("127.0.0.1", "localhost", "::1") else "octop-memory" + + +def _sh_single_quote(value: str) -> str: + """Always return a single-quoted shell literal (safe for script embedding). + + Unlike ``shlex.quote`` (which may leave safe tokens unquoted), this keeps the + ``VAR='…'`` form used throughout the installer and escapes embedded quotes. + """ + return "'" + str(value).replace("'", "'\"'\"'") + "'" + + +def render_setup_script( + *, + mcp_url: str, + hook_url: str = "", + agent_id: str = "", + terminals: str = "", + default_token: str = "", +) -> str: + """Render the interactive installer. + + ``mcp_url`` 是登记进客户端 MCP 条目的地址;``hook_url`` 是 hooks 真正 POST 的地址, + 默认同 ``mcp_url``。两者不同 = 条目走 OAuth 前置(客户端自己授权,条目不写凭据); + MCP 条目默认 **OAuth**(条目不写凭据,Authorization 由客户端临时注入);明确不要 OAuth 时 + 用 ``OCTOP_DISABLE_OAUTH=true`` 走直连 MCP token。 + + ``default_token`` 是部署内置的默认凭据(实例发布时设置了 ``OCTOP_MEMORY_MCP_TOKEN``): + 脚本把它当 ``OCTOP_MEMORY_TOKEN`` 的兜底,于是用户只需要专家 ID(可选用户标识)。 + 为空时照旧在安装时问 token;header 名由 ``OCTOP_TOKEN_HEADER`` / + ``OCTOP_ENTRY_TOKEN_HEADER`` 决定,缺省 ``Authorization``(此时值写成 ``Bearer ``)。 + ``agent_id`` 和 ``terminals``(逗号清单)只是给自动化预填提示用。 + """ + hook_url = hook_url or mcp_url + token_literal = _sh_single_quote(default_token) if default_token else "''" + # Query-string prefills must be shell-safe (same escaping as DEFAULT_TOKEN). + url_literal = _sh_single_quote(mcp_url) + hook_url_literal = _sh_single_quote(hook_url) + agent_literal = _sh_single_quote(agent_id) if agent_id else "''" + terminals_literal = _sh_single_quote(terminals) if terminals else "''" + default_token_line = f"DEFAULT_TOKEN={token_literal}" if default_token else "" + default_token_block = ( + f"""if [ -z "$TOKEN" ] && [ -n {token_literal} ]; then + TOKEN={token_literal} + echo " · token:用本实例部署时配置的默认凭据(要覆盖设 OCTOP_MEMORY_TOKEN)" +fi""" + if default_token + else "" + ) + return f"""#!/bin/sh +# octop memory — 本地终端接入(hooks 发往 {hook_url};可重复执行) +set -eu + +URL={url_literal} +HOOK_URL={hook_url_literal} +DEFAULT_AGENT={agent_literal} +{default_token_line} +DEFAULT_TERMINALS={terminals_literal} +CC_DIR='{HOOKS_DIRS["claude-code"]}' +KIRO_DIR='{HOOKS_DIRS["kiro"]}' +DSH_DIR='{HOOKS_DIRS["dsh"]}' +CODEX_DIR='{HOOKS_DIRS["codex"]}' +CURSOR_DIR='{HOOKS_DIRS["cursor"]}' + +# hooks 与「客户端 MCP 条目」可以是两个地址(默认同一个):条目可以走 OAuth 前置 +# (客户端自己授权,条目里不写凭据),hooks 永远直连本实例。 +SPLIT=0 +[ "$URL" = "$HOOK_URL" ] || SPLIT=1 + +# ---- 1) 检测本机装了哪些终端(目录或命令任一命中) +have_claude=0; have_kiro=0; have_dsh=0; have_codex=0; have_cursor=0 +{{ [ -e "$HOME/.claude.json" ] || [ -d "$HOME/.claude" ] || command -v claude >/dev/null 2>&1; }} && have_claude=1 +{{ [ -d "$HOME/.kiro" ] || command -v kiro >/dev/null 2>&1; }} && have_kiro=1 +{{ [ -d "$HOME/.dsh" ] || command -v dsh >/dev/null 2>&1; }} && have_dsh=1 +{{ [ -d "$HOME/.codex" ] || command -v codex >/dev/null 2>&1; }} && have_codex=1 +{{ [ -d "$HOME/.cursor" ] || command -v cursor >/dev/null 2>&1; }} && have_cursor=1 + +DETECTED='' +[ "$have_claude" = 1 ] && DETECTED="$DETECTED claude-code" +[ "$have_kiro" = 1 ] && DETECTED="$DETECTED kiro" +[ "$have_dsh" = 1 ] && DETECTED="$DETECTED dsh" +[ "$have_codex" = 1 ] && DETECTED="$DETECTED codex" +[ "$have_cursor" = 1 ] && DETECTED="$DETECTED cursor" +[ -n "$DETECTED" ] || DETECTED='claude-code' + +echo "octop memory 接入 → {mcp_url}" +echo "检测到:" +[ "$have_claude" = 1 ] && echo " 1) Claude Code (hook 装到 ~/.claude/hooks-events)" +[ "$have_kiro" = 1 ] && echo " 2) Kiro    (hook 装到 ~/.kiro/hooks)" +[ "$have_dsh" = 1 ] && echo " 3) DSH    (hook 装到 ~/.dsh/hooks)" +[ "$have_codex" = 1 ] && echo " 4) Codex   (hook 装到 ~/.codex/hooks,配置写 ~/.codex/config.toml)" +[ "$have_cursor" = 1 ] && echo " 5) Cursor   (hook 装到 ~/.cursor/hooks,配置写 ~/.cursor/hooks.json)" + +# 有 controlling tty 才交互:curl | sh 时 stdin 是脚本本身,读它会把脚本吃掉; +# 没有 tty(CI/cron)时依赖环境变量,缺什么就明确报错而不是盲读 stdin。 +HAS_TTY=0 +if (true /dev/null; then HAS_TTY=1; fi + +ask() {{ # ask <提示> <默认值> + if [ "$HAS_TTY" = 1 ]; then + printf '%s' "$1" > /dev/tty 2>/dev/null || true + read -r _answer < /dev/tty || _answer='' + else + _answer='' + fi + if [ -n "$_answer" ]; then printf '%s' "$_answer"; else printf '%s' "$2"; fi +}} + +# ---- 2) 选终端:交互式清单(↑↓ 移动 · 空格 选择 · a 全选 · 回车 确认); +# 没有 tty 或没有 python3 时回退到数字输入。 +TERMINALS="${{OCTOP_TERMINALS:-$DEFAULT_TERMINALS}}" +if [ -z "$TERMINALS" ] && [ "$HAS_TTY" = 1 ]; then + _picked='' + if command -v python3 >/dev/null 2>&1; then + set -- + [ "$have_claude" = 1 ] && set -- "$@" "claude-code|Claude Code (hook 装到 ~/.claude/hooks-events)" + [ "$have_kiro" = 1 ] && set -- "$@" "kiro|Kiro (hook 装到 ~/.kiro/hooks)" + [ "$have_dsh" = 1 ] && set -- "$@" "dsh|DSH (hook 装到 ~/.dsh/hooks)" + [ "$have_codex" = 1 ] && set -- "$@" "codex|Codex (hook 装到 ~/.codex/hooks)" + [ "$have_cursor" = 1 ] && set -- "$@" "cursor|Cursor (hook 装到 ~/.cursor/hooks)" + _picked="$(python3 - "$@" 2>/dev/null <<'OCTOP_SELECT' +{_SELECT_PY} +OCTOP_SELECT +)" || _picked='' + fi + if [ -n "$_picked" ]; then + TERMINALS="$_picked" + else + # 回退:数字输入(1,2 / 1 3 / 12 / a=全部;回车 = 全部检测到的;选不中会重问) + _attempt=0 + while [ -z "$TERMINALS" ] && [ "$_attempt" -lt 3 ]; do + _attempt=$((_attempt + 1)) + printf '要配置哪些?(可多选:1,2 / 1 3 / 12 / a=全部,回车=全部检测到的): ' > /dev/tty + read -r _pick < /dev/tty || _pick='' + _chosen='' + case "$_pick" in + ''|a|A|all|ALL|全部) _chosen="$DETECTED" ;; + *) + for _n in $(printf '%s' "$_pick" | tr ',' ' '); do + case "$_n" in + [0-9][0-9]*) _items="$(printf '%s' "$_n" | fold -w1 | tr '\n' ' ')" ;; + *) _items="$_n" ;; + esac + for _i in $_items; do + case "$_i" in + 1) _add="$([ "$have_claude" = 1 ] && echo claude-code)" ;; + 2) _add="$([ "$have_kiro" = 1 ] && echo kiro)" ;; + 3) _add="$([ "$have_dsh" = 1 ] && echo dsh)" ;; + 4) _add="$([ "$have_codex" = 1 ] && echo codex)" ;; + 5) _add="$([ "$have_cursor" = 1 ] && echo cursor)" ;; + *) _add='' ;; + esac + if [ -z "$_add" ]; then + echo " ? 已忽略:${{_i}}(未检测到或无法识别)" >&2 + else + case ",$_chosen," in *",$_add,"*) ;; *) _chosen="$_chosen,$_add" ;; esac + fi + done + done + ;; + esac + TERMINALS="$(printf '%s' "$_chosen" | sed 's/^,//')" + [ -n "$TERMINALS" ] || echo " ! 没有选中任何已检测到的终端,请重选(1-5,或 a=全部)" >&2 + done + fi +fi + +[ -n "$TERMINALS" ] || {{ echo "没有可配置的终端" >&2; exit 1; }} + +# 本机实例(loopback)的共享 token 走 Authorization: Bearer;判据用 hooks 真正要打的地址。 +URL_HOST="$(printf '%s' "$HOOK_URL" | sed -e 's#^[^/]*//##' -e 's#/.*##' -e 's#:.*##')" +IS_LOCAL=0 +case "$URL_HOST" in + 127.0.0.1|localhost) IS_LOCAL=1 ;; +esac + +# ---- 3) 凭据:hooks 与「客户端 MCP 条目」各带各的 +# 模式:部署声明了 OAuth 前置地址(SPLIT=1)且没显式 OCTOP_DISABLE_OAUTH=true → OAuth 前置; +# 其余(含"没声明代理地址")→ 直连。 +MODE=direct +if [ "$SPLIT" = 1 ]; then + case "${{OCTOP_DISABLE_OAUTH:-}}" in + 1|true|yes) MODE=direct ;; + *) MODE=oauth ;; + esac +fi + +# hooks 的凭据永远是「实例共享 token」(部署内置的 DEFAULT_TOKEN 就是它),与模式无关。 +TOKEN="${{OCTOP_MEMORY_TOKEN:-}}" +{default_token_block} +if [ -z "$TOKEN" ] && [ "$HAS_TTY" = 1 ]; then + stty -echo < /dev/tty 2>/dev/null || true + if [ "$SPLIT" = 1 ]; then + printf '共享 token(hook 直连端点 %s 用,通常是 octopoctop): ' "$HOOK_URL" > /dev/tty + elif [ "$IS_LOCAL" = 1 ]; then + printf '共享 token(本机实例,走 Authorization: Bearer): ' > /dev/tty + else + printf '共享 token(云上实例): ' > /dev/tty + fi + read -r _t < /dev/tty || _t='' + stty echo < /dev/tty 2>/dev/null || true + printf '\n' > /dev/tty + [ -n "$_t" ] && TOKEN="$_t" +fi +[ -n "$TOKEN" ] || {{ echo "缺少 token:非交互运行时请设 OCTOP_MEMORY_TOKEN=" >&2; exit 1; }} +# header 名可配(缺省 Authorization);header 是 Authorization 时值写成 ``Bearer ``。 +TOKEN_HEADER="${{OCTOP_TOKEN_HEADER:-Authorization}}" + +# 条目凭据:只有 OAuth 前置(条目指向代理)才需要,且**必须**给个人令牌。没给就当场问; +# 非交互(CI)拿不到就报错,不写一个必然 401 的条目。 +ENTRY_TOKEN='' +ENTRY_TOKEN_HEADER='' +if [ "$MODE" = oauth ]; then + ENTRY_TOKEN="${{OCTOP_ENTRY_TOKEN:-}}" + if [ -z "$ENTRY_TOKEN" ] && [ "$HAS_TTY" = 1 ]; then + stty -echo < /dev/tty 2>/dev/null || true + printf '个人令牌(OAuth 前置必须;不回显): ' > /dev/tty + read -r _et < /dev/tty || _et='' + stty echo < /dev/tty 2>/dev/null || true + printf '\n' > /dev/tty + if [ -n "$_et" ]; then ENTRY_TOKEN="$_et"; fi + fi + if [ -z "$ENTRY_TOKEN" ]; then + echo " ! OAuth 前置(${{URL}})必须带个人令牌:请设 OCTOP_ENTRY_TOKEN= 后重跑" >&2 + exit 1 + fi + ENTRY_TOKEN_HEADER="${{OCTOP_ENTRY_TOKEN_HEADER:-Authorization}}" +fi +echo " · hook 直连:$HOOK_URL$([ "$IS_LOCAL" = 1 ] && echo '(本机实例)' || true)" +if [ "$MODE" = oauth ]; then + echo " · 客户端 MCP 条目:${{URL}}(OAuth 前置:带个人令牌 ${{ENTRY_TOKEN_HEADER}})" +else + echo " · 客户端 MCP 条目:${{HOOK_URL}}(直连 MCP token:写 ${{TOKEN_HEADER}})" +fi +echo " · 认证方式:$TOKEN_HEADER" + +AGENT="${{OCTOP_AGENT_ID:-}}" +if [ -z "$AGENT" ]; then + if [ -n "$DEFAULT_AGENT" ]; then + AGENT="$(ask "专家 ID [$DEFAULT_AGENT]: " "$DEFAULT_AGENT")" + else + AGENT="$(ask "专家 ID: " "")" + fi +fi +[ -n "$AGENT" ] || {{ echo "缺少专家 ID:非交互运行时请设 OCTOP_AGENT_ID=" >&2; exit 1; }} + +# 用户标识(建议域账号):直连时「谁问的」只能靠 X-Octop-User-Id(条目和 hooks 都写它); +# OAuth 前置时条目身份由代理按令牌重写,但 hooks 仍靠这个头 —— 两种模式都问一次。 +USER_ID="${{OCTOP_USER_ID:-}}" +if [ -z "$USER_ID" ] && [ "$HAS_TTY" = 1 ]; then + USER_ID="$(ask "用户标识(建议填域账号,如 alice;写进 X-Octop-User-Id 标记谁问的): " "")" +fi + +command -v node >/dev/null 2>&1 || echo " ! 未找到 node:hook 需要它,请先安装 Node.js" >&2 +command -v python3 >/dev/null 2>&1 || {{ echo " ! 未找到 python3:无法合并终端配置" >&2; exit 1; }} + +# ---- 3.5) 解析 node 绝对路径:hook 由终端派生,常在非 login shell 里跑, +# nvm 的 node 不在 PATH —— 写裸 node 会静默失败(command hook 非 0 退出不报错)。 +NODE_BIN="${{OCTOP_NODE:-}}" +if [ -z "$NODE_BIN" ] && command -v node >/dev/null 2>&1; then + NODE_BIN="$(command -v node)" +fi +if [ -z "$NODE_BIN" ] && [ -d "$HOME/.nvm/versions/node" ]; then + _ver="$(ls -1 "$HOME/.nvm/versions/node" 2>/dev/null | sort | tail -n 1)" + [ -n "$_ver" ] && [ -x "$HOME/.nvm/versions/node/$_ver/bin/node" ] && NODE_BIN="$HOME/.nvm/versions/node/$_ver/bin/node" +fi +for _cand in /opt/homebrew/bin/node /usr/local/bin/node "$HOME/.volta/bin/node" /usr/bin/node; do + if [ -z "$NODE_BIN" ] && [ -x "$_cand" ]; then NODE_BIN="$_cand"; fi +done +if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then + NODE_BIN=node + echo " ! 找不到 node 的绝对路径:hook 里只能写裸 node,非 login shell 下可能静默失败" >&2 +else + echo " · node: $NODE_BIN" +fi +export OCTOP_NODE="$NODE_BIN" + +# Node 22.15+ 的 --use-system-ca 读操作系统证书库。企业根不在 Node 自带库里时, +# 不加这个前缀,hook 和下面的端点自检都会 TypeError: fetch failed。 +USE_SYSTEM_CA=0 +if "$NODE_BIN" --use-system-ca -e 'process.exit(0)' >/dev/null 2>&1; then + USE_SYSTEM_CA=1 + echo " · node 使用系统证书库(--use-system-ca)" +else + echo " ! 当前 node 不支持 --use-system-ca(需要 22.15+):企业根证书下 hook 的 fetch 可能失败" >&2 +fi +export OCTOP_USE_SYSTEM_CA="$USE_SYSTEM_CA" +if [ "$USE_SYSTEM_CA" = 1 ]; then + NODE_OPTIONS="${{NODE_OPTIONS:+$NODE_OPTIONS }}--use-system-ca" + export NODE_OPTIONS +fi + +# ---- 4) 先验证这份凭据在该端点可用(免得装完才发现 401) +if [ "$NODE_BIN" != node ] || command -v node >/dev/null 2>&1; then + OCTOP_URL="$HOOK_URL" OCTOP_TOKEN="$TOKEN" OCTOP_TOKEN_HEADER="$TOKEN_HEADER" \\ + OCTOP_AGENT="$AGENT" "$NODE_BIN" --input-type=module -e ' +const cfg = {{ url: process.env.OCTOP_URL, token: process.env.OCTOP_TOKEN }}; +const headers = {{ "Content-Type": "application/json", Accept: "application/json, text/event-stream", + "X-Octop-Agent-Id": process.env.OCTOP_AGENT }}; +const header = process.env.OCTOP_TOKEN_HEADER || "Authorization"; +headers[header] = header === "Authorization" ? `Bearer ${{cfg.token}}` : cfg.token; +try {{ + const r = await fetch(cfg.url, {{ method: "POST", headers, signal: AbortSignal.timeout(8000), body: JSON.stringify({{ jsonrpc: "2.0", id: 1, method: "tools/list" }}) }}); + console.log(` · 端点自检(hook 直连):HTTP ${{r.status}}${{r.status === 200 ? "" : "(凭据或地址不对,请检查)"}}`); +}} catch (e) {{ + console.log(` · 端点自检失败:${{e}}(安装仍继续,请自行确认地址)`); +}} +' 2>/dev/null || true +fi + +# ---- 5) 各终端各自安装 hook 脚本 + 合并其配置 +OCTOP_URL="$URL" OCTOP_HOOK_URL="$HOOK_URL" OCTOP_TOKEN="$TOKEN" OCTOP_TOKEN_HEADER="$TOKEN_HEADER" \\ +OCTOP_ENTRY_TOKEN="$ENTRY_TOKEN" OCTOP_ENTRY_TOKEN_HEADER="$ENTRY_TOKEN_HEADER" \\ +OCTOP_AGENT="$AGENT" OCTOP_SERVER_NAME='{_server_name(mcp_url)}' \\ +OCTOP_SERVER_NAME_DIRECT='{_server_name(hook_url)}' \\ +OCTOP_USER_ID="$USER_ID" \\ +OCTOP_CC_DIR="$CC_DIR" OCTOP_KIRO_DIR="$KIRO_DIR" OCTOP_DSH_DIR="$DSH_DIR" \\ +OCTOP_CODEX_DIR="$CODEX_DIR" OCTOP_CURSOR_DIR="$CURSOR_DIR" \\ +OCTOP_TERMINALS="$TERMINALS" \\ +OCTOP_FILES_CLAUDE_CODE='{_host_manifest("claude-code")}' \\ +OCTOP_FILES_KIRO='{_host_manifest("kiro")}' \\ +OCTOP_FILES_DSH='{_host_manifest("dsh")}' \\ +OCTOP_FILES_CODEX='{_host_manifest("codex")}' \\ +OCTOP_FILES_CURSOR='{_host_manifest("cursor")}' \\ +OCTOP_FILES_PRESET='{_dsh_preset_manifest()}' \\ +python3 - <<'OCTOP_PY' +{_MERGE_PY} +OCTOP_PY + +echo " · 重开终端/IDE(Kiro 重载 hooks;Codex 首次需在 codex 里信任该 hook)后生效" +""" + + +_UNINSTALL_PY = r""" +import json +import os +import pathlib +import shutil + + +home = pathlib.Path(os.path.expanduser("~")) +CC_DIR = pathlib.Path(os.environ["OCTOP_CC_DIR"]).expanduser() +KIRO_DIR = pathlib.Path(os.environ["OCTOP_KIRO_DIR"]).expanduser() +DSH_DIR = pathlib.Path(os.environ["OCTOP_DSH_DIR"]).expanduser() +CODEX_DIR = pathlib.Path(os.environ["OCTOP_CODEX_DIR"]).expanduser() +CURSOR_DIR = pathlib.Path(os.environ["OCTOP_CURSOR_DIR"]).expanduser() + +_OUR_SCRIPTS = ( + "user-prompt-recall.mjs", + "stop-capture.mjs", + "octop-user-prompt-recall.mjs", + "octop-stop-capture.mjs", +) +_OUR_MCP_TOOLS = ("memory_search", "memory_capture") + + +def _load(path): + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + print(f" ! 读不了 {path}:{exc}") + return None + + +def _dump(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _octop_servers(servers): + return [name for name in servers if str(name).replace("_", "-").startswith("octop-memory")] + + +def _is_our_handler(handler): + handler = handler or {} + if handler.get("type") == "mcp_tool": + return handler.get("tool") in _OUR_MCP_TOOLS + command = handler.get("command") or "" + return any(script in command for script in _OUR_SCRIPTS) + + +def _rm_tree(path, label): + if not path.exists(): + print(f" · {label} 不存在,跳过 → {path}") + return + shutil.rmtree(path) + print(f" ✓ 已删除 {label} → {path}") + + +def _strip_mcp(path): + cfg = _load(path) + if cfg is None: + return + servers = cfg.get("mcpServers") or {} + ours = _octop_servers(servers) + if not ours: + print(f" · {path} 无 octop-memory* MCP 条目") + return + for name in ours: + servers.pop(name, None) + cfg["mcpServers"] = servers + _dump(path, cfg) + print(f" ✓ 已移除 MCP 服务 {', '.join(ours)} → {path}") + + +def undo_claude_code(): + settings = home / ".claude" / "settings.json" + st = _load(settings) + if st is not None: + hooks = st.setdefault("hooks", {}) + changed = False + for event in list(hooks): + kept = [ + group + for group in (hooks.get(event) or []) + if not any(_is_our_handler(h) for h in (group or {}).get("hooks") or []) + ] + if len(kept) != len(hooks.get(event) or []): + changed = True + if kept: + hooks[event] = kept + else: + hooks.pop(event, None) + changed = True + if changed: + _dump(settings, st) + print(f" ✓ 已从 Claude Code settings 去掉 octop hooks → {settings}") + else: + print(f" · Claude Code settings 无 octop hooks → {settings}") + _strip_mcp(home / ".claude.json") + _rm_tree(CC_DIR, "Claude Code hooks 目录") + + +def undo_kiro(): + for name in ("memory-recall-on-start.json", "memory-save-on-stop.json"): + path = KIRO_DIR / name + if path.exists(): + path.unlink() + print(f" ✓ 已删除 Kiro hook 文件 → {path}") + else: + print(f" · Kiro hook 文件不存在 → {path}") + _strip_mcp(home / ".kiro" / "settings" / "mcp.json") + _rm_tree(KIRO_DIR, "Kiro hooks 目录") + + +def undo_dsh(): + bridge = DSH_DIR / "octop-hooks.json" + if bridge.exists(): + bridge.unlink() + print(f" ✓ 已删除 DSH 桥接 → {bridge}") + bundle = DSH_DIR.parent / "bundles" / "dsh-memory-share" + if bundle.exists(): + shutil.rmtree(bundle) + print(f" ✓ 已删除 DSH preset bundle → {bundle}") + profiles = home / ".dsh" / "profiles" + for name in ("web", "desktop", "default"): + package = profiles / name / "package.json" + cfg = _load(package) + if cfg is None: + continue + bundles = cfg.get("dsh", {}).get("profile", {}).get("bundles") + if not isinstance(bundles, list): + continue + kept = [b for b in bundles if b != "dsh-memory-share"] + if len(kept) != len(bundles): + cfg["dsh"]["profile"]["bundles"] = kept + _dump(package, cfg) + print(f" ✓ 已从 DSH profile「{name}」去掉 dsh-memory-share") + _rm_tree(DSH_DIR, "DSH hooks 目录") + + +def _toml_blocks(text): + blocks = [[]] + for line in text.splitlines(keepends=True): + if line.lstrip().startswith("[") and not line.lstrip().startswith("#"): + blocks.append([]) + blocks[-1].append(line) + return blocks + + +def undo_codex(): + path = home / ".codex" / "config.toml" + if path.exists(): + text = path.read_text(encoding="utf-8") + blocks, kept = _toml_blocks(text), [] + removed = 0 + for block in blocks: + body = "".join(block) + header = block[0].strip() if block else "" + ours = str(CODEX_DIR) in body + if ours and header.startswith("[[hooks.") and header.endswith(".hooks]]"): + event = header[len("[[hooks.") : -len(".hooks]]")] + if kept and kept[-1][0].strip() == f"[[hooks.{event}]]" and not "".join(kept[-1][1:]).strip(): + kept.pop() + removed += 1 + continue + kept.append(block) + if removed: + path.write_text("".join("".join(block) for block in kept), encoding="utf-8") + print(f" ✓ 已从 Codex config.toml 去掉 octop hooks → {path}") + else: + print(f" · Codex config.toml 无 octop hooks → {path}") + else: + print(f" · Codex config.toml 不存在 → {path}") + _rm_tree(CODEX_DIR, "Codex hooks 目录") + + +def undo_cursor(): + path = home / ".cursor" / "hooks.json" + cfg = _load(path) + if cfg is not None: + hooks = cfg.setdefault("hooks", {}) + marker = str(CURSOR_DIR) + changed = False + for event in list(hooks): + entries = hooks.get(event) or [] + kept = [ + entry + for entry in entries + if marker not in json.dumps(entry, ensure_ascii=False) + ] + if len(kept) != len(entries): + changed = True + if kept: + hooks[event] = kept + else: + hooks.pop(event, None) + changed = True + if changed: + cfg["hooks"] = hooks + _dump(path, cfg) + print(f" ✓ 已从 Cursor hooks.json 去掉 octop hooks → {path}") + else: + print(f" · Cursor hooks.json 无 octop hooks → {path}") + else: + print(f" · Cursor hooks.json 不存在 → {path}") + # setup 不为 Cursor 写 MCP,但检索常靠 ~/.cursor/mcp.json 里的 octop-memory* + _strip_mcp(home / ".cursor" / "mcp.json") + _rm_tree(CURSOR_DIR, "Cursor hooks 目录") + + +for terminal in os.environ["OCTOP_TERMINALS"].split(","): + terminal = terminal.strip() + if not terminal: + continue + print(f"· {terminal}") + { + "claude-code": undo_claude_code, + "kiro": undo_kiro, + "dsh": undo_dsh, + "codex": undo_codex, + "cursor": undo_cursor, + }[terminal]() + +print(" · octop memory 卸载完成(可重复执行;服务端已有记忆不会删除)") +print(" · 请重开终端/IDE 后生效") +""".strip() + + +def render_uninstall_script(*, terminals: str = "") -> str: + """Render the interactive uninstaller (counterpart of ``render_setup_script``). + + No token / agent prompts — only chooses which terminals to scrub. Safe to + re-run; leaves non-octop hooks and MCP servers alone. + """ + terminals_literal = _sh_single_quote(terminals) if terminals else "''" + return f"""#!/bin/sh +# octop memory — 卸载本地终端接入(与 /mcp/memory/setup 相对;可重复执行) +set -eu + +DEFAULT_TERMINALS={terminals_literal} +CC_DIR='{HOOKS_DIRS["claude-code"]}' +KIRO_DIR='{HOOKS_DIRS["kiro"]}' +DSH_DIR='{HOOKS_DIRS["dsh"]}' +CODEX_DIR='{HOOKS_DIRS["codex"]}' +CURSOR_DIR='{HOOKS_DIRS["cursor"]}' + +have_claude=0; have_kiro=0; have_dsh=0; have_codex=0; have_cursor=0 +{{ [ -e "$HOME/.claude.json" ] || [ -d "$HOME/.claude" ] || command -v claude >/dev/null 2>&1; }} && have_claude=1 +{{ [ -d "$HOME/.kiro" ] || command -v kiro >/dev/null 2>&1; }} && have_kiro=1 +{{ [ -d "$HOME/.dsh" ] || command -v dsh >/dev/null 2>&1; }} && have_dsh=1 +{{ [ -d "$HOME/.codex" ] || command -v codex >/dev/null 2>&1; }} && have_codex=1 +{{ [ -d "$HOME/.cursor" ] || command -v cursor >/dev/null 2>&1; }} && have_cursor=1 + +DETECTED='' +[ "$have_claude" = 1 ] && DETECTED="$DETECTED claude-code" +[ "$have_kiro" = 1 ] && DETECTED="$DETECTED kiro" +[ "$have_dsh" = 1 ] && DETECTED="$DETECTED dsh" +[ "$have_codex" = 1 ] && DETECTED="$DETECTED codex" +[ "$have_cursor" = 1 ] && DETECTED="$DETECTED cursor" +[ -n "$DETECTED" ] || DETECTED='cursor' + +echo "octop memory 卸载" +echo "检测到:" +[ "$have_claude" = 1 ] && echo " 1) Claude Code (清 ~/.claude/hooks-events + settings/MCP)" +[ "$have_kiro" = 1 ] && echo " 2) Kiro    (清 ~/.kiro/hooks + MCP)" +[ "$have_dsh" = 1 ] && echo " 3) DSH    (清 ~/.dsh/hooks + preset)" +[ "$have_codex" = 1 ] && echo " 4) Codex   (清 ~/.codex/hooks + config.toml 中的 octop hooks)" +[ "$have_cursor" = 1 ] && echo " 5) Cursor   (清 ~/.cursor/hooks + hooks.json;顺带去掉 mcp.json 里的 octop-memory*)" + +HAS_TTY=0 +if (true /dev/null; then HAS_TTY=1; fi + +TERMINALS="${{OCTOP_TERMINALS:-$DEFAULT_TERMINALS}}" +if [ -z "$TERMINALS" ] && [ "$HAS_TTY" = 1 ]; then + _picked='' + if command -v python3 >/dev/null 2>&1; then + set -- + [ "$have_claude" = 1 ] && set -- "$@" "claude-code|Claude Code" + [ "$have_kiro" = 1 ] && set -- "$@" "kiro|Kiro" + [ "$have_dsh" = 1 ] && set -- "$@" "dsh|DSH" + [ "$have_codex" = 1 ] && set -- "$@" "codex|Codex" + [ "$have_cursor" = 1 ] && set -- "$@" "cursor|Cursor" + _picked="$(python3 - "$@" 2>/dev/null <<'OCTOP_SELECT' +{_SELECT_PY} +OCTOP_SELECT +)" || _picked='' + fi + if [ -n "$_picked" ]; then + TERMINALS="$_picked" + else + _attempt=0 + while [ -z "$TERMINALS" ] && [ "$_attempt" -lt 3 ]; do + _attempt=$((_attempt + 1)) + printf '要卸载哪些?(可多选:1,2 / 1 3 / 12 / a=全部,回车=全部检测到的): ' > /dev/tty + read -r _pick < /dev/tty || _pick='' + _chosen='' + case "$_pick" in + ''|a|A|all|ALL|全部) _chosen="$DETECTED" ;; + *) + for _n in $(printf '%s' "$_pick" | tr ',' ' '); do + case "$_n" in + [0-9][0-9]*) _items="$(printf '%s' "$_n" | fold -w1 | tr '\\n' ' ')" ;; + *) _items="$_n" ;; + esac + for _i in $_items; do + case "$_i" in + 1) _add="$([ "$have_claude" = 1 ] && echo claude-code)" ;; + 2) _add="$([ "$have_kiro" = 1 ] && echo kiro)" ;; + 3) _add="$([ "$have_dsh" = 1 ] && echo dsh)" ;; + 4) _add="$([ "$have_codex" = 1 ] && echo codex)" ;; + 5) _add="$([ "$have_cursor" = 1 ] && echo cursor)" ;; + *) _add='' ;; + esac + if [ -z "$_add" ]; then + echo " ? 已忽略:${{_i}}" >&2 + else + case ",$_chosen," in *",$_add,"*) ;; *) _chosen="$_chosen,$_add" ;; esac + fi + done + done + ;; + esac + TERMINALS="$(printf '%s' "$_chosen" | sed 's/^,//')" + [ -n "$TERMINALS" ] || echo " ! 没有选中任何终端,请重选" >&2 + done + fi +fi + +[ -n "$TERMINALS" ] || {{ echo "没有可卸载的终端:非交互请设 OCTOP_TERMINALS=cursor" >&2; exit 1; }} +command -v python3 >/dev/null 2>&1 || {{ echo " ! 未找到 python3" >&2; exit 1; }} + +OCTOP_CC_DIR="$CC_DIR" OCTOP_KIRO_DIR="$KIRO_DIR" OCTOP_DSH_DIR="$DSH_DIR" \\ +OCTOP_CODEX_DIR="$CODEX_DIR" OCTOP_CURSOR_DIR="$CURSOR_DIR" \\ +OCTOP_TERMINALS="$TERMINALS" \\ +python3 - <<'OCTOP_PY' +{_UNINSTALL_PY} +OCTOP_PY +""" diff --git a/src/octop/infra/agents/hook_scripts/claude-code/stop-capture.mjs b/src/octop/infra/agents/hook_scripts/claude-code/stop-capture.mjs new file mode 100644 index 000000000..1e01f76cf --- /dev/null +++ b/src/octop/infra/agents/hook_scripts/claude-code/stop-capture.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Claude Code ``Stop`` hook: ship this session's transcript into octop memory. +// +// Claude-Code-specific in exactly two ways: the payload carries +// ``transcript_path``, and ``Stop`` fires *before* the current turn is flushed — +// so the hook hands the work to a detached worker that polls until an assistant +// record shows up after the last user record. The hook itself returns at once. +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { capture, isConfigured, loadConfig, makeLogger, sleep } from "../lib/octop-memory-client.mjs"; + +const log = makeLogger("stopcap.claude-code"); +const DELAY_MS = Number(process.env.OCTOP_CAPTURE_DELAY_MS ?? 3000); +const POLL_MS = Number(process.env.OCTOP_CAPTURE_POLL_MS ?? 1000); +const MAX_WAIT_MS = Number(process.env.OCTOP_CAPTURE_MAX_WAIT_MS ?? 20000); +const IS_WORKER = process.argv.includes("--worker"); + +let raw = ""; +if (IS_WORKER) { + raw = Buffer.from(process.env.OCTOP_HOOK_PAYLOAD_B64 || "", "base64").toString("utf8"); +} else { + try { + raw = fs.readFileSync(0, "utf8"); + } catch { + raw = ""; + } + if (process.env.OCTOP_CAPTURE_SYNC !== "1") { + try { + const worker = spawn(process.execPath, [fileURLToPath(import.meta.url), "--worker"], { + detached: true, + stdio: "ignore", + env: { ...process.env, OCTOP_HOOK_PAYLOAD_B64: Buffer.from(raw).toString("base64") }, + }); + worker.unref(); + log(`deferred: 交给后台进程,等本轮落盘(${DELAY_MS}ms 起轮询)`); + process.exit(0); // the hook returns at once; the worker waits, then uploads + } catch (exc) { + log(`detach failed, running inline: ${exc}`); + } + } +} +let hookInput = {}; +try { + if (raw.trim()) hookInput = JSON.parse(raw); +} catch { + log("stdin-not-json"); +} + +const sessionId = hookInput.session_id || ""; +const event = hookInput.hook_event_name || "Stop"; +const transcriptPath = hookInput.transcript_path || ""; +if (!transcriptPath) { + log(`${event} skip: payload has no transcript_path`); + process.exit(0); +} + +/** The turn landed when an assistant record follows the last user record. */ +function turnLanded(path) { + try { + let lastUser = -1; + let lastAssistant = -1; + const lines = fs.readFileSync(path, "utf8").split("\n"); + lines.forEach((line, index) => { + if (!line.trim()) return; + let record; + try { + record = JSON.parse(line); + } catch { + return; + } + if (record.type === "user") lastUser = index; + else if (record.type === "assistant") lastAssistant = index; + }); + return lastAssistant > lastUser; + } catch { + return true; // unreadable -> do not wait + } +} + +if (IS_WORKER) { + if (DELAY_MS > 0) await sleep(DELAY_MS); + const deadline = Date.now() + MAX_WAIT_MS; + let waited = 0; + while (Date.now() < deadline && !turnLanded(transcriptPath)) { + await sleep(POLL_MS); + waited += POLL_MS; + } + if (waited) log(`${event} waited ${waited}ms for the turn to land`); +} + +const config = loadConfig(); +if (!isConfigured(config)) { + log("skip: not configured (need OCTOP_MEMORY_MCP_URL / _TOKEN / _AGENT_ID or config.json)"); + process.exit(0); +} +let buffer; +try { + buffer = fs.readFileSync(transcriptPath); +} catch (exc) { + log(`${event} read failed: ${exc} (${transcriptPath})`); + process.exit(0); +} +await capture({ + config, + log, + event, + source: "claude-code", + sessionId, + transcriptPath, + buffer, + sessionPrefix: "cc:", +}); +process.exit(0); diff --git a/src/octop/infra/agents/hook_scripts/claude-code/user-prompt-recall.mjs b/src/octop/infra/agents/hook_scripts/claude-code/user-prompt-recall.mjs new file mode 100644 index 000000000..4db3d5099 --- /dev/null +++ b/src/octop/infra/agents/hook_scripts/claude-code/user-prompt-recall.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Claude Code ``UserPromptSubmit`` hook: recall before the model answers. +// +// ``mcp_tool`` hooks are silently skipped for UserPromptSubmit (2.1.263), so this +// is a command hook that calls ``memory_search`` itself and prints the block — +// Claude Code injects the stdout as extra context for this turn. +import fs from "node:fs"; +import { callTool, isConfigured, loadConfig, makeLogger } from "../lib/octop-memory-client.mjs"; + +const log = makeLogger("urecall.claude-code"); +const MARKER = "[octop memory search]"; +const payload = (() => { + try { + const raw = fs.readFileSync(0, "utf8"); + return raw.trim() ? JSON.parse(raw) : {}; + } catch { + return {}; + } +})(); + +async function main() { + const prompt = String(payload.prompt || "").trim(); + if (!prompt) { + log("skip: empty prompt"); + return; + } + const config = loadConfig(); + if (!isConfigured(config)) { + log("skip: not configured"); + return; + } + const maxResults = Number(process.env.OCTOP_RECALL_MAX_RESULTS ?? 5); + const result = await callTool(config, "memory_search", { + query: prompt, + corpus: "atom", + max_results: maxResults, + }); + const hits = result.detail?.hits ?? []; + log(`prompt="${prompt.slice(0, 60)}" hits=${hits.length} http=${result.status}`); + if (!hits.length) return; + const lines = [MARKER]; + for (const hit of hits) { + const when = String(hit.occurred_at || "").slice(0, 19); + lines.push(`- [${hit.layer}] (${hit.role_hint ?? hit.layer} @ ${when}) ${hit.snippet}`); + } + lines.push("若需要原文/证据,用 memory_get(path=…) 读全文;与本次工作无关则忽略。"); + process.stdout.write(lines.join("\n") + "\n"); +} + +await main(); +process.exit(0); diff --git a/src/octop/infra/agents/hook_scripts/codex/stop-capture.mjs b/src/octop/infra/agents/hook_scripts/codex/stop-capture.mjs new file mode 100644 index 000000000..8f89a97b1 --- /dev/null +++ b/src/octop/infra/agents/hook_scripts/codex/stop-capture.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +// Codex CLI ``Stop`` hook: ship this session's rollout transcript into octop memory. +// +// Codex-specific: hooks are declared in ``~/.codex/config.toml`` +// (``[[hooks.Stop]]`` with ``type = "command"``), the payload carries +// ``transcript_path`` (the ``rollout-*.jsonl`` under ``~/.codex/sessions``) and +// ``session_id``, and — like Claude Code — Stop can fire before the turn is +// flushed, so the hook defers to a detached worker and waits for the file to stop +// growing. +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { capture, isConfigured, loadConfig, makeLogger, sleep } from "../lib/octop-memory-client.mjs"; + +const log = makeLogger("stopcap.codex"); +const DELAY_MS = Number(process.env.OCTOP_CAPTURE_DELAY_MS ?? 3000); +const POLL_MS = Number(process.env.OCTOP_CAPTURE_POLL_MS ?? 1000); +const MAX_WAIT_MS = Number(process.env.OCTOP_CAPTURE_MAX_WAIT_MS ?? 20000); +const IS_WORKER = process.argv.includes("--worker"); + +let raw = ""; +if (IS_WORKER) { + raw = Buffer.from(process.env.OCTOP_HOOK_PAYLOAD_B64 || "", "base64").toString("utf8"); +} else { + try { + raw = fs.readFileSync(0, "utf8"); + } catch { + raw = ""; + } + if (process.env.OCTOP_CAPTURE_SYNC !== "1") { + try { + const worker = spawn(process.execPath, [fileURLToPath(import.meta.url), "--worker"], { + detached: true, + stdio: "ignore", + env: { ...process.env, OCTOP_HOOK_PAYLOAD_B64: Buffer.from(raw).toString("base64") }, + }); + worker.unref(); + log(`deferred: 交给后台进程,等本轮落盘(${DELAY_MS}ms 起轮询)`); + process.exit(0); + } catch (exc) { + log(`detach failed, running inline: ${exc}`); + } + } +} +let hookInput = {}; +try { + if (raw.trim()) hookInput = JSON.parse(raw); +} catch { + log("stdin-not-json"); +} + +const sessionId = hookInput.session_id || ""; +const event = hookInput.hook_event_name || "Stop"; +const transcriptPath = hookInput.transcript_path || ""; +if (!transcriptPath) { + log(`${event} skip: payload has no transcript_path`); + process.exit(0); +} + +if (IS_WORKER) { + if (DELAY_MS > 0) await sleep(DELAY_MS); + const deadline = Date.now() + MAX_WAIT_MS; + let previousSize = -1; + let waited = 0; + while (Date.now() < deadline) { + let size; + try { + size = fs.statSync(transcriptPath).size; + } catch { + break; + } + if (size === previousSize) break; + previousSize = size; + await sleep(POLL_MS); + waited += POLL_MS; + } + if (waited) log(`${event} waited ${waited}ms for the rollout to settle`); +} + +const config = loadConfig(); +if (!isConfigured(config)) { + log("skip: not configured"); + process.exit(0); +} +let buffer; +try { + buffer = fs.readFileSync(transcriptPath); +} catch (exc) { + log(`${event} read failed: ${exc} (${transcriptPath})`); + process.exit(0); +} +await capture({ + config, + log, + event, + source: "codex", + sessionId, + transcriptPath, + buffer, + sessionPrefix: "codex:", +}); +process.exit(0); diff --git a/src/octop/infra/agents/hook_scripts/codex/user-prompt-recall.mjs b/src/octop/infra/agents/hook_scripts/codex/user-prompt-recall.mjs new file mode 100644 index 000000000..7e657a65f --- /dev/null +++ b/src/octop/infra/agents/hook_scripts/codex/user-prompt-recall.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Codex CLI ``UserPromptSubmit`` hook: recall before the model answers. +// +// Codex hook output understands ``additionalContext`` (same contract as Claude +// Code), so the recalled block is injected for this turn. +import fs from "node:fs"; +import { callTool, isConfigured, loadConfig, makeLogger } from "../lib/octop-memory-client.mjs"; + +const log = makeLogger("urecall.codex"); +const MARKER = "[octop memory search]"; + +async function main() { + let payload = {}; + try { + const raw = fs.readFileSync(0, "utf8"); + if (raw.trim()) payload = JSON.parse(raw); + } catch { + log("stdin-not-json"); + } + const prompt = String(payload.prompt || "").trim(); + if (!prompt) { + log("skip: empty prompt"); + return; + } + const config = loadConfig(); + if (!isConfigured(config)) { + log("skip: not configured"); + return; + } + const result = await callTool(config, "memory_search", { + query: prompt, + corpus: "atom", + max_results: Number(process.env.OCTOP_RECALL_MAX_RESULTS ?? 5), + }); + const hits = result.detail?.hits ?? []; + log(`prompt="${prompt.slice(0, 60)}" hits=${hits.length} http=${result.status}`); + if (!hits.length) return; + const lines = [MARKER]; + for (const hit of hits) { + const when = String(hit.occurred_at || "").slice(0, 19); + lines.push(`- [${hit.layer}] (${hit.role_hint ?? hit.layer} @ ${when}) ${hit.snippet}`); + } + lines.push("若需要原文/证据,用 memory_get(path=…) 读全文;与本次工作无关则忽略。"); + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: lines.join("\n"), + }, + }) + "\n", + ); +} + +await main(); +process.exit(0); diff --git a/src/octop/infra/agents/hook_scripts/cursor/stop-capture.mjs b/src/octop/infra/agents/hook_scripts/cursor/stop-capture.mjs new file mode 100644 index 000000000..de38e7d0e --- /dev/null +++ b/src/octop/infra/agents/hook_scripts/cursor/stop-capture.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// Cursor ``stop`` hook: ship this session's transcript into octop memory. +// +// Cursor-specific: hooks live in ``~/.cursor/hooks.json`` (``{"version":1, +// "hooks":{"stop":[{"command":…}]}}``) and the payload carries ``transcript_path`` +// plus ``conversation_id`` / ``session_id``. That path is +// ``~/.cursor/projects//agent-transcripts//.jsonl`` +// (one JSON object per line: ``{role, message:{content:[{type,text}]}}``). +// ``~/.cursor/chats/**/store.db`` is protobuf and is not read — if a payload has +// no ``transcript_path``, this logs and skips instead of guessing. +import fs from "node:fs"; +import { capture, isConfigured, loadConfig, makeLogger, readStdinJson, sleep } from "../lib/octop-memory-client.mjs"; + +const log = makeLogger("stopcap.cursor"); +const hookInput = readStdinJson(log); +const event = hookInput.hook_event_name || "stop"; +const sessionId = hookInput.conversation_id || hookInput.session_id || ""; +const transcriptPath = hookInput.transcript_path || ""; +if (!transcriptPath) { + log(`${event} skip: payload has no transcript_path (session=${sessionId})`); + process.exit(0); +} + +const POLL_MS = Number(process.env.OCTOP_CAPTURE_POLL_MS ?? 1000); +const MAX_WAIT_MS = Number(process.env.OCTOP_CAPTURE_MAX_WAIT_MS ?? 20000); +const deadline = Date.now() + MAX_WAIT_MS; +let previousSize = -1; +let waited = 0; +while (Date.now() < deadline) { + let size; + try { + size = fs.statSync(transcriptPath).size; + } catch { + break; + } + if (size === previousSize) break; + previousSize = size; + await sleep(POLL_MS); + waited += POLL_MS; +} +if (waited) log(`${event} waited ${waited}ms for the transcript to settle`); + +const config = loadConfig(); +if (!isConfigured(config)) { + log("skip: not configured"); + process.exit(0); +} +let buffer; +try { + buffer = fs.readFileSync(transcriptPath); +} catch (exc) { + log(`${event} read failed: ${exc} (${transcriptPath})`); + process.exit(0); +} +await capture({ + config, + log, + event, + source: "cursor", + sessionId, + transcriptPath, + buffer, + sessionPrefix: "cursor:", +}); +process.exit(0); diff --git a/src/octop/infra/agents/hook_scripts/cursor/user-prompt-recall.mjs b/src/octop/infra/agents/hook_scripts/cursor/user-prompt-recall.mjs new file mode 100644 index 000000000..8b662856e --- /dev/null +++ b/src/octop/infra/agents/hook_scripts/cursor/user-prompt-recall.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// Cursor ``beforeSubmitPrompt`` hook: recall before the model answers. +// +// Cursor's payload carries ``prompt`` (and ``conversation_id``/``cwd``); its hook +// output understands ``additionalContext``, so the recalled block is injected for +// this turn. +import fs from "node:fs"; +import { callTool, isConfigured, loadConfig, makeLogger } from "../lib/octop-memory-client.mjs"; + +const log = makeLogger("urecall.cursor"); +const MARKER = "[octop memory search]"; + +async function main() { + let payload = {}; + try { + const raw = fs.readFileSync(0, "utf8"); + if (raw.trim()) payload = JSON.parse(raw); + } catch { + log("stdin-not-json"); + } + const prompt = String(payload.prompt || payload.user_message || "").trim(); + if (!prompt) { + log("skip: empty prompt"); + return; + } + const config = loadConfig(); + if (!isConfigured(config)) { + log("skip: not configured"); + return; + } + const result = await callTool(config, "memory_search", { + query: prompt, + corpus: "atom", + max_results: Number(process.env.OCTOP_RECALL_MAX_RESULTS ?? 5), + }); + const hits = result.detail?.hits ?? []; + log(`prompt="${prompt.slice(0, 60)}" hits=${hits.length} http=${result.status}`); + if (!hits.length) return; + const lines = [MARKER]; + for (const hit of hits) { + const when = String(hit.occurred_at || "").slice(0, 19); + lines.push(`- [${hit.layer}] (${hit.role_hint ?? hit.layer} @ ${when}) ${hit.snippet}`); + } + lines.push("若需要原文/证据,用 memory_get(path=…) 读全文;与本次工作无关则忽略。"); + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "beforeSubmitPrompt", + additionalContext: lines.join("\n"), + }, + }) + "\n", + ); +} + +await main(); +process.exit(0); diff --git a/src/octop/infra/agents/hook_scripts/dsh/memory-target.mjs b/src/octop/infra/agents/hook_scripts/dsh/memory-target.mjs new file mode 100644 index 000000000..c5b9feaad --- /dev/null +++ b/src/octop/infra/agents/hook_scripts/dsh/memory-target.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +// DSH-only target resolution for the memory hooks. +// +// The installer writes `config.json` next to the scripts, but a DSH whose MCP +// panel already knows the octop server needs no second copy of the credentials. +// Precedence: environment -> `config.json` -> the `octop-memory*` entry of this +// DSH's own MCP server table (`$DSH_HOME/storages/mcp_servers.json`), enabled +// servers first, then prod, local, oauth. `OCTOP_MEMORY_SERVER` names one entry +// explicitly and may pick a disabled one. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { loadConfig } from "../lib/octop-memory-client.mjs"; + +const ENV_VAR_NAME = /^[A-Z][A-Z0-9_]*$/; + +function serverTablePath() { + const home = process.env.DSH_HOME || path.join(os.homedir(), ".dsh"); + return path.join(home, "storages", "mcp_servers.json"); +} + +/** A bare env-var name resolves from the environment. */ +function headerValue(value) { + const text = String(value ?? "").trim(); + if (ENV_VAR_NAME.test(text) && process.env[text]) return process.env[text]; + return text; +} + +function headerMap(server) { + const headers = {}; + for (const item of Array.isArray(server.headers) ? server.headers : []) { + if (item && typeof item.name === "string" && item.name) headers[item.name] = headerValue(item.value); + } + return headers; +} + +function rank(server) { + const name = String(server.serverName ?? "").toLowerCase(); + if (name === "octop-memory-prod") return 10; + if (name === "octop-memory" || name === "octop-memory-local") return 20; + if (name === "octop-memory-oauth") return 30; + return 40; +} + +function configFromServer(server) { + const headers = headerMap(server); + const names = Object.keys(headers); + const tokenHeader = + names.find((name) => name.toLowerCase() === "authorization") ?? + names.find((name) => name.toLowerCase() === "x-octop-memory-token") ?? + ""; + return { + endpoint: String(server.url ?? "").trim(), + token: tokenHeader ? headers[tokenHeader].replace(/^Bearer\s+/i, "") : "", + tokenHeader, + agentId: headers["X-Octop-Agent-Id"] ?? "", + userId: headers["X-Octop-User-Id"] ?? "", + }; +} + +/** The usable octop entry of this DSH's MCP server table, or null. */ +export function discoverConfig() { + let servers; + try { + const parsed = JSON.parse(fs.readFileSync(serverTablePath(), "utf8")); + servers = Object.values(parsed?.tables?.servers ?? {}); + } catch { + return null; // no DSH storage here -> nothing to discover + } + const wanted = (process.env.OCTOP_MEMORY_SERVER || "").trim().toLowerCase(); + const candidates = servers + .filter((server) => server && typeof server === "object" && !server.stale) + .filter((server) => /^octop[-_]?memory/i.test(String(server.serverName ?? ""))) + .filter((server) => !wanted || String(server.serverName ?? "").toLowerCase() === wanted) + .sort((a, b) => (wanted ? 0 : Number(!a.enabled) - Number(!b.enabled)) || rank(a) - rank(b)); + for (const server of candidates) { + const config = configFromServer(server); + if (config.endpoint && config.token) return config; + } + return null; +} + +/** What these hooks should talk to. */ +export function resolveConfig() { + const configured = loadConfig(); + if (configured.endpoint && configured.token) return configured; + return discoverConfig() ?? configured; +} diff --git a/src/octop/infra/agents/hook_scripts/dsh/stop-capture.mjs b/src/octop/infra/agents/hook_scripts/dsh/stop-capture.mjs new file mode 100644 index 000000000..1c24326e7 --- /dev/null +++ b/src/octop/infra/agents/hook_scripts/dsh/stop-capture.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +// DSH ``Stop`` hook (through the hooks bridge): ship this session's +// ``session*.jsonl.zstd`` into octop memory. +// +// Two shipping modes, chosen by probing the endpoint (force one with +// ``OCTOP_CAPTURE_MODE=text|transcript``): +// +// text ``memory_capture`` takes ``assistant`` -> one call per turn with +// the roles in their own fields, so the assistant's answer is not +// stored as a user message. +// transcript no ``assistant`` field -> gzip the raw JSONL delta and let the +// server parse typed events (its DSH transcript path). +// +// DSH-specific: the payload has only ``session_id``, the file is versioned +// (``session.jsonl.zstd`` -> ``session.v3.jsonl.zstd`` -> …) so the newest one +// inside the session directory is used, and it is zstd-compressed — decoded here +// so the server needs no zstd dependency. +import fs from "node:fs"; +import os from "node:os"; +import { + capture, + captureTurns, + decompressZstdContainer, + isConfigured, + loadState, + makeLogger, + newestFile, + readStdinJson, + saveState, + sleep, + supportsAssistant, +} from "../lib/octop-memory-client.mjs"; +import { resolveConfig } from "./memory-target.mjs"; + +const log = makeLogger("stopcap.dsh"); +const event = "Stop"; +const sessionId = readStdinJson(log).session_id || ""; +if (!sessionId) { + log(`${event} skip: payload has no session_id`); + process.exit(0); +} + +const root = process.env.OCTOP_DSH_SESSIONS_ROOT || `${os.homedir()}/.dsh/sessions`; +function findSessionFile(id) { + try { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const found = newestFile(`${root}/${entry.name}/${id}`, /^session.*\.jsonl\.zstd$/); + if (found) return found; + } + } catch { + // no DSH install / unreadable root + } + return ""; +} + +const transcriptPath = findSessionFile(sessionId); +if (!transcriptPath) { + log(`${event} skip: no transcript for ${sessionId} under ${root}`); + process.exit(0); +} + +const POLL_MS = Number(process.env.OCTOP_CAPTURE_POLL_MS ?? 1000); +const MAX_WAIT_MS = Number(process.env.OCTOP_CAPTURE_MAX_WAIT_MS ?? 20000); +const deadline = Date.now() + MAX_WAIT_MS; +let previousSize = -1; +let waited = 0; +while (Date.now() < deadline) { + let size; + try { + size = fs.statSync(transcriptPath).size; + } catch { + break; + } + if (size === previousSize) break; + previousSize = size; + await sleep(POLL_MS); + waited += POLL_MS; +} +if (waited) log(`${event} waited ${waited}ms for the file to settle`); + +const config = resolveConfig(); +if (!isConfigured(config)) { + log("skip: not configured"); + process.exit(0); +} +let buffer; +try { + buffer = decompressZstdContainer(fs.readFileSync(transcriptPath)); +} catch (exc) { + log(`${event} zstd decompress failed: ${exc} (${transcriptPath})`); + process.exit(0); +} + +// --- transcript mode: the server parses the records into typed events --- +const forced = (process.env.OCTOP_CAPTURE_MODE || "auto").trim().toLowerCase(); +const asTurns = + forced === "text" ? true : forced === "transcript" ? false : await supportsAssistant(config); +if (!asTurns) { + await capture({ + config, + log, + event, + source: "dsh", + sessionId, + transcriptPath, + buffer, + sessionPrefix: "dsh:", + }); + process.exit(0); +} + +// --- text mode: one call per turn, roles in their own fields --- + +function textBlocks(content) { + if (!Array.isArray(content)) return ""; + return content + .filter((block) => block && block.type === "text" && typeof block.text === "string") + .map((block) => block.text.trim()) + .filter(Boolean) + .join("\n"); +} + +/** Bytes of ``buffer`` that end on a complete JSONL record; a torn tail waits for the next Stop. */ +function completeRecordsEnd() { + const lastNewline = buffer.lastIndexOf(0x0a); + if (lastNewline + 1 >= buffer.length) return lastNewline + 1; + const tail = buffer.subarray(lastNewline + 1).toString("utf8").trim(); + if (!tail) return buffer.length; + try { + JSON.parse(tail); + return buffer.length; + } catch { + return lastNewline + 1; + } +} + +/** + * Group the delta into turns with each turn's end offset, so the offset advances + * one shipped turn at a time. A ``user/message`` starts a turn only when its + * source kind is ``user`` — plugin snapshots, system reminders and hook + * injections share that record type; ``assistant/message`` keeps text blocks only. + */ +function turnBatches(start, end) { + const batches = []; + const closeTurn = (offset) => { + if (batches.length) batches[batches.length - 1].endOffset = offset; + }; + let cursor = start; + while (cursor < end) { + const newline = buffer.indexOf(0x0a, cursor); + const lineEnd = newline === -1 || newline >= end ? end : newline + 1; + const line = buffer + .subarray(cursor, lineEnd - (lineEnd === newline + 1 ? 1 : 0)) + .toString("utf8"); + const lineStart = cursor; + cursor = lineEnd; + if (!line.trim()) continue; + let record; + try { + record = JSON.parse(line); + } catch { + continue; // torn or non-JSON line + } + if (record?.type === "user/message") { + const kind = String(record?.data?.source?.kind || ""); + if (kind && kind !== "user") continue; + closeTurn(lineStart); + const text = textBlocks(record?.data?.content); + batches.push({ user: text ? [text] : [], assistant: [], endOffset: end }); + } else if (record?.type === "assistant/message") { + const text = textBlocks(record?.data?.message?.content); + if (!text) continue; + if (!batches.length) batches.push({ user: [], assistant: [], endOffset: end }); + batches[batches.length - 1].assistant.push(text); + } + } + closeTurn(end); + return batches.filter((batch) => batch.user.length || batch.assistant.length); +} + +const state = loadState(); +const previous = state[sessionId]; +let start = 0; +if (previous && previous.path === transcriptPath && previous.offset <= buffer.length) { + start = previous.offset; + // Only a boundary that still follows a newline is safe to resume from; a + // rewritten/compacted file falls back to a full re-send. + if (start > 0 && start < buffer.length && buffer[start - 1] !== 0x0a) start = 0; +} +const end = completeRecordsEnd(); +if (end <= start) { + log(`${event} session=${sessionId} no new records (offset=${start}/${end}) -> skip`); + process.exit(0); +} +let turns = turnBatches(start, end); +const maxTurns = Number(process.env.OCTOP_CAPTURE_MAX_TURNS ?? 25); +if (turns.length > maxTurns) turns = turns.slice(0, maxTurns); + +if (!turns.length) { + // Tool traffic only: consume it so the next Stop starts after it. + state[sessionId] = { path: transcriptPath, offset: end }; + saveState(state); + log(`${event} session=${sessionId} no user/assistant text in ${end - start} bytes -> offset advanced`); + process.exit(0); +} + +const result = await captureTurns({ + config, + log, + event, + source: "dsh", + sessionId, + transcriptPath, + turns, + sessionPrefix: "dsh:", + startOffset: start, +}); +log( + `${event} session=${sessionId} source=dsh http=${result.status} mode=text ` + + `from=${start} bytes=${end - start} turns=${turns.length} sent=${result.sent} offset=${result.offset}`, +); +process.exit(0); diff --git a/src/octop/infra/agents/hook_scripts/dsh/user-prompt-recall.mjs b/src/octop/infra/agents/hook_scripts/dsh/user-prompt-recall.mjs new file mode 100644 index 000000000..f40dd4398 --- /dev/null +++ b/src/octop/infra/agents/hook_scripts/dsh/user-prompt-recall.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// DSH ``UserPromptSubmit`` hook: search memory for the incoming prompt and inject +// the hits as extra context. +// +// The bridge reads context only from structured output, so the answer must be the +// ``hookSpecificOutput`` object below — plain stdout is silently dropped (unlike +// Claude Code, where the claude-code entry script's stdout becomes context). +// +// The store's full-text search is conjunctive: every term must match, so a long +// prompt whose tail is an instruction ("请只回答…") widens the query until nothing +// matches. Try the first sentence, then its shorter prefixes, then the whole +// prompt; only widen the layer to raw events as a last resort. +import { callTool, isConfigured, makeLogger, readStdinJson } from "../lib/octop-memory-client.mjs"; +import { resolveConfig } from "./memory-target.mjs"; + +const MARKER = "[octop memory search] related memories from this workspace, most relevant first:"; +const FOOTER = "若需要原文/证据,用 memory_get(path=…) 读全文;与本次工作无关则忽略。"; + +function candidateQueries(text) { + const candidates = []; + const push = (value) => { + const candidate = String(value || "").trim(); + if (candidate && !candidates.includes(candidate)) candidates.push(candidate); + }; + const firstSentence = + text.split(/[。!?!?;;\n\r]+/).map((part) => part.trim()).find(Boolean) ?? text; + push(firstSentence); + push(firstSentence.slice(0, 60)); + push(firstSentence.slice(0, 30)); + push(text.slice(0, 60)); + return candidates; +} + +async function main() { + const log = makeLogger("urecall.dsh"); + const prompt = String(readStdinJson(log).prompt || "").trim(); + if (!prompt) { + log("skip: empty prompt"); + return; + } + const config = resolveConfig(); + if (!isConfigured(config)) { + log("skip: not configured"); + return; + } + + const maxResults = Number(process.env.OCTOP_RECALL_MAX_RESULTS ?? 5); + const candidates = candidateQueries(prompt); + let hits = []; + let query = candidates[0]; + let corpus = "atom"; + for (const candidate of candidates) { + const result = await callTool(config, "memory_search", { + query: candidate, + corpus: corpus, + max_results: maxResults, + }); + hits = result.detail?.hits ?? []; + if (hits.length) { + query = candidate; + break; + } + } + if (!hits.length) { + // Fresh facts stay in the raw layer until extraction promotes them. + corpus = "all"; + const result = await callTool(config, "memory_search", { + query: candidates[0], + corpus: corpus, + max_results: maxResults, + }); + hits = result.detail?.hits ?? []; + } + log( + `prompt="${prompt.slice(0, 60)}" query="${query.slice(0, 40)}" ` + + `corpus=${corpus} hits=${hits.length}`, + ); + if (!hits.length) return; + + const lines = [MARKER]; + for (const hit of hits) { + const when = String(hit.occurred_at || "").slice(0, 19); + lines.push(`- [${hit.layer}] (${hit.role_hint ?? hit.layer}${when ? ` @ ${when}` : ""}) ${hit.snippet}`); + } + lines.push(FOOTER); + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: lines.join("\n"), + }, + }), + ); +} + +await main(); +process.exit(0); diff --git a/src/octop/infra/agents/hook_scripts/kiro/stop-capture.mjs b/src/octop/infra/agents/hook_scripts/kiro/stop-capture.mjs new file mode 100644 index 000000000..58b78ad9b --- /dev/null +++ b/src/octop/infra/agents/hook_scripts/kiro/stop-capture.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Kiro ``Stop`` hook: ship this session's ``messages.jsonl`` into octop memory. +// +// Kiro-specific: the payload has no file path (only ``sess_…``), so the session +// file is located as ``///messages.jsonl``. +import fs from "node:fs"; +import os from "node:os"; +import { capture, isConfigured, loadConfig, makeLogger, readStdinJson, sleep } from "../lib/octop-memory-client.mjs"; + +const log = makeLogger("stopcap.kiro"); +const event = "Stop"; +const sessionId = readStdinJson(log).session_id || ""; +if (!sessionId) { + log(`${event} skip: payload has no session_id`); + process.exit(0); +} + +const root = process.env.OCTOP_KIRO_SESSIONS_ROOT || `${os.homedir()}/.kiro/sessions`; +function findSessionFile(id) { + try { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = `${root}/${entry.name}/${id}/messages.jsonl`; + if (fs.existsSync(candidate)) return candidate; + } + } catch { + // no Kiro install / unreadable root + } + return ""; +} + +const transcriptPath = findSessionFile(sessionId); +if (!transcriptPath) { + log(`${event} skip: no transcript for ${sessionId} under ${root}`); + process.exit(0); +} + +// Kiro's Stop also fires before the file settles: wait until its size stops growing. +const POLL_MS = Number(process.env.OCTOP_CAPTURE_POLL_MS ?? 1000); +const MAX_WAIT_MS = Number(process.env.OCTOP_CAPTURE_MAX_WAIT_MS ?? 20000); +const deadline = Date.now() + MAX_WAIT_MS; +let previousSize = -1; +let waited = 0; +while (Date.now() < deadline) { + let size; + try { + size = fs.statSync(transcriptPath).size; + } catch { + break; + } + if (size === previousSize) break; + previousSize = size; + await sleep(POLL_MS); + waited += POLL_MS; +} +if (waited) log(`${event} waited ${waited}ms for the file to settle`); + +const config = loadConfig(); +if (!isConfigured(config)) { + log("skip: not configured"); + process.exit(0); +} +const buffer = fs.readFileSync(transcriptPath); +await capture({ + config, + log, + event, + source: "kiro", + sessionId, + transcriptPath, + buffer, + sessionPrefix: "kiro:", +}); +process.exit(0); diff --git a/src/octop/infra/agents/hook_scripts/lib/octop-memory-client.mjs b/src/octop/infra/agents/hook_scripts/lib/octop-memory-client.mjs new file mode 100644 index 000000000..915c3ab16 --- /dev/null +++ b/src/octop/infra/agents/hook_scripts/lib/octop-memory-client.mjs @@ -0,0 +1,362 @@ +// Shared transport for every octop-memory host hook. +// +// Host-independent on purpose: this module knows how to talk to the memory MCP +// (config, auth, incremental offsets, logging, gzip, DSH's zstd container) and +// nothing about *which* terminal called it. Each host gets its own entry script +// under ``/`` that decides what to read and which ``source`` / +// ``session_id`` prefix to use — so changing one host never touches another. +// +// Config precedence: environment variable -> ``config.json`` next to the entry +// script (the /mcp/memory/setup installer writes that file, so a hooked-up +// terminal needs no secret in its hook command line). +import fs from "node:fs"; +import os from "node:os"; +import zlib from "node:zlib"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const ENTRY_DIR = path.dirname(fileURLToPath(import.meta.url)); +const HOOK_DIR = path.dirname(ENTRY_DIR); // .../hook_scripts (or the host dir) + +// Where this hook may write. Claude Code runs hooks unsandboxed, so the hook +// directory next to the scripts works. DSH runs the same command hooks inside a +// workspace-write sandbox whose writable roots are the session workspace, /tmp +// and the process temp dir: a $HOME hook directory stays readable there but every +// log/state write is denied — silently, because both are best-effort. Probe once +// and fall back to a private temp directory so the incremental offset and the +// diagnostic log survive in both worlds. +function resolveScratchDir() { + for (const dir of [HOOK_DIR, `${os.tmpdir()}/octop-hooks`]) { + const probe = `${dir}/.octop-write-probe`; + try { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(probe, ""); + fs.unlinkSync(probe); + return dir; + } catch { + // not writable here -> try the next candidate + } + } + return HOOK_DIR; +} + +export const paths = (() => { + const scratchDir = resolveScratchDir(); + if (scratchDir !== HOOK_DIR) { + process.stderr.write( + `octop-memory: ${HOOK_DIR} 不可写,日志与增量状态改写到 ${scratchDir}\n`, + ); + } + return { + hookDir: HOOK_DIR, + configPath: `${HOOK_DIR}/config.json`, + scratchDir, + logPath: `${scratchDir}/octop-hook.log`, + statePath: `${scratchDir}/octop-transcript-state.json`, + }; +})(); + +export function loadConfig() { + let fromFile = {}; + try { + fromFile = JSON.parse(fs.readFileSync(paths.configPath, "utf8")); + } catch { + // no config file (env-only setups keep working) + } + return { + endpoint: process.env.OCTOP_MEMORY_MCP_URL || fromFile.url || "", + token: process.env.OCTOP_MEMORY_MCP_TOKEN || fromFile.token || "", + agentId: process.env.OCTOP_AGENT_ID || fromFile.agentId || "", + userId: process.env.OCTOP_USER_ID || fromFile.userId || "", + // Configurable header name; ``Authorization`` (the default) takes a bearer. + tokenHeader: process.env.OCTOP_TOKEN_HEADER || fromFile.tokenHeader || "Authorization", + }; +} + +function baseHeaders(config) { + const headers = { + "X-Octop-Agent-Id": config.agentId, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + if (config.userId) headers["X-Octop-User-Id"] = config.userId; + return headers; +} + +export function authHeaders(config) { + // One configurable header carries the token: ``Authorization`` (the default) + // takes ``Bearer ``, any other header name is written verbatim. + const headers = baseHeaders(config); + const header = config.tokenHeader || "Authorization"; + headers[header] = header.toLowerCase() === "authorization" ? `Bearer ${config.token}` : config.token; + return headers; +} + +/** + * Whether these hooks have somewhere to send to: an endpoint, an agent, and a token. + */ +export function isConfigured(config) { + return !!config.endpoint && !!config.agentId && !!config.token; +} + +/** One logger per host: ``tag`` is what shows up in the shared log file. */ +export function makeLogger(tag) { + return (line) => { + try { + fs.appendFileSync(paths.logPath, `[${new Date().toISOString()}] ${tag}: ${line}\n`, { + mode: 0o600, + }); + } catch { + // logging must never break the hook + } + }; +} + +export function loadState() { + try { + return JSON.parse(fs.readFileSync(paths.statePath, "utf8")); + } catch { + return {}; + } +} + +export function saveState(state) { + try { + fs.writeFileSync(paths.statePath, JSON.stringify(state), { mode: 0o600 }); + } catch { + // a lost offset only costs one redundant (idempotent) upload + } +} + +/** Read the hook payload from stdin (the host hands it over as JSON). */ +export function readStdinJson(log) { + try { + const raw = fs.readFileSync(0, "utf8"); + return raw.trim() ? JSON.parse(raw) : {}; + } catch { + log("stdin-not-json"); + return {}; + } +} + +// Decode DSH's session container: it is a concatenation of independently +// decodable Zstandard frames (one header frame, then one frame per durable +// batch). Node's zstd API stops after the first frame, so a whole-file +// ``zstdDecompressSync`` silently returns only the session header. Walk the +// frames structurally and decode each complete one; a torn final frame is left +// for the next Stop to pick up. +export function decompressZstdContainer(buffer) { + const ZSTD_MAGIC = 0xfd2fb528; + const parts = []; + let offset = 0; + while (offset < buffer.length) { + const start = offset; + if (buffer.length - offset < 4) break; + if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) { + throw new Error(`invalid Zstandard frame magic at byte ${offset}`); + } + offset += 4; + if (offset === buffer.length) break; + const descriptor = buffer.readUInt8(offset); + offset += 1; + if ((descriptor & 0x18) !== 0) { + throw new Error(`reserved Zstandard frame-header bit at byte ${offset - 1}`); + } + const contentSizeFlag = descriptor >>> 6; + const singleSegment = (descriptor & 0x20) !== 0; + const checksum = (descriptor & 0x04) !== 0; + const dictionaryFlag = descriptor & 0x03; + const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag; + const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag; + const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes; + if (buffer.length - offset < remainingHeaderBytes) break; + offset += remainingHeaderBytes; + + let torn = false; + for (;;) { + if (buffer.length - offset < 3) { + torn = true; + break; + } + const blockHeader = buffer.readUIntLE(offset, 3); + offset += 3; + const lastBlock = (blockHeader & 1) !== 0; + const blockType = (blockHeader >>> 1) & 0x03; + const blockSize = blockHeader >>> 3; + if (blockType === 0x03) throw new Error(`reserved Zstandard block type at byte ${offset - 3}`); + const payloadBytes = blockType === 0x01 ? 1 : blockSize; + if (buffer.length - offset < payloadBytes) { + torn = true; + break; + } + offset += payloadBytes; + if (lastBlock) break; + } + if (torn) break; + if (checksum) { + if (buffer.length - offset < 4) break; + offset += 4; + } + parts.push(zlib.zstdDecompressSync(buffer.subarray(start, offset))); + } + return Buffer.concat(parts); +} + +/** Call one memory MCP tool; returns ``{ok, status, detail, raw}`` (never throws). */ +export async function callTool(config, name, args) { + return rpc(config, "tools/call", { name, arguments: args }); +} + +/** One raw JSON-RPC call; returns ``{ok, status, detail, error, raw}`` (never throws). */ +export async function rpc(config, method, params) { + const response = await fetch(config.endpoint, { + method: "POST", + headers: authHeaders(config), + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const text = await response.text(); + let payload = text; + for (const line of text.split("\n")) { + if (line.startsWith("data: ")) { + payload = line.slice(6); + break; + } + } + let parsed = {}; + try { + parsed = JSON.parse(payload.slice(payload.indexOf("{"), payload.lastIndexOf("}") + 1)); + } catch { + // keep the raw body for diagnostics + } + return { + ok: response.ok, + status: response.status, + detail: parsed?.result?.structuredContent ?? parsed?.result ?? {}, + error: parsed?.error?.message ?? "", + raw: text, + }; +} + +/** + * Whether this endpoint's ``memory_capture`` takes the assistant side as its own + * field (``assistant``) instead of expecting one merged ``content``. + */ +export async function supportsAssistant(config) { + const result = await rpc(config, "tools/list", {}); + const tool = (result.detail?.tools ?? []).find((entry) => entry?.name === "memory_capture"); + return Object.prototype.hasOwnProperty.call(tool?.inputSchema?.properties ?? {}, "assistant"); +} + +/** + * Ship already-grouped conversation turns with the roles in their own fields: + * ``content`` = the user side, ``assistant`` = the assistant side. + * + * Each accepted turn advances the per-``session_id`` offset, so a failure or a + * cap resumes at the next turn instead of re-sending the whole session. + */ +export async function captureTurns(ctx) { + const { config, log, event, source, sessionId, transcriptPath, turns, sessionPrefix } = ctx; + const state = loadState(); + let sent = 0; + let status = 0; + let offset = ctx.startOffset ?? 0; + for (const turn of turns) { + const args = { content: turn.user.join("\n\n"), source }; + if (sessionId) args.session_id = `${sessionPrefix}${sessionId}`; + if (turn.assistant.length) args.assistant = turn.assistant.join("\n\n"); + const result = await callTool(config, "memory_capture", args); + status = result.status; + if (!result.ok) { + log( + `${event} session=${sessionId} turn ${sent + 1} http=${status} ` + + `err=${result.detail?.error ?? result.error ?? ""} -> offset kept at ${offset}`, + ); + break; + } + sent += 1; + offset = turn.endOffset; + if (sessionId) { + state[sessionId] = { path: transcriptPath, offset }; + saveState(state); + } + } + return { sent, status, offset }; +} + +/** + * Ship the *new* bytes of a session file to ``memory_capture``. + * + * The offset is per ``session_id`` and only advances after a 2xx; a failed + * upload is retried in full next time, and the server dedupes on record keys, so + * re-sending is free. + */ +export async function capture(ctx) { + const { config, log, event, source, sessionId, transcriptPath, buffer, sessionPrefix } = ctx; + const state = loadState(); + const previous = sessionId ? state[sessionId] : undefined; + + let start = 0; + if (previous && previous.path === transcriptPath && previous.offset <= buffer.length) { + start = previous.offset; + // Only a boundary that still follows a newline is safe to resume from; a + // rewritten/compacted file falls back to a full (idempotent) re-send. + if (start > 0 && start < buffer.length && buffer[start - 1] !== 0x0a) start = 0; + } + if (start >= buffer.length) { + log(`${event} session=${sessionId} no new bytes (offset=${start}) -> skip`); + return { skipped: true }; + } + + const delta = buffer.subarray(start); + const whole = start === 0; + let blob; + try { + blob = zlib.gzipSync(delta).toString("base64"); + } catch (exc) { + log(`${event} gzip failed: ${exc}`); + return { failed: true }; + } + + const args = { content: blob, source }; + if (sessionId) args.session_id = `${sessionPrefix}${sessionId}`; + // Opt-in only: the server default is "none" (tool traffic is most of a + // transcript's bytes and distorts both extraction and raw-layer recall), so set + // this to "all"/"calls" only for a terminal that needs the full record. + if (process.env.OCTOP_TOOL_EVENTS) args.tool_events = process.env.OCTOP_TOOL_EVENTS; + + try { + const result = await callTool(config, "memory_capture", args); + const detail = result.detail ?? {}; + log( + `${event} session=${sessionId} source=${source} http=${result.status} ` + + `mode=${whole ? "full" : "delta"} from=${start} bytes=${delta.length} gz=${blob.length} ` + + `parsed=${detail.parsed} imported=${detail.imported} skipped=${detail.skipped_existing} ` + + `extract=${detail.extract_scheduled} err=${detail.error ?? result.error ?? ""}`, + ); + if (result.ok && sessionId) { + state[sessionId] = { path: transcriptPath, offset: buffer.length }; + saveState(state); + } + return { status: result.status, detail }; + } catch (exc) { + log(`${event} upload failed: ${exc}`); + return { failed: true }; + } +} + +/** Latest mtime order helper for hosts that discover their session file. */ +export function newestFile(dir, pattern) { + try { + return fs + .readdirSync(dir) + .filter((name) => pattern.test(name)) + .map((name) => ({ name, path: `${dir}/${name}`, mtime: fs.statSync(`${dir}/${name}`).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime)[0]?.path ?? ""; + } catch { + return ""; + } +} + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/octop/infra/agents/memory_session_archive.py b/src/octop/infra/agents/memory_session_archive.py new file mode 100644 index 000000000..2ae219bd9 --- /dev/null +++ b/src/octop/infra/agents/memory_session_archive.py @@ -0,0 +1,210 @@ +"""Server-side markdown archive of the raw sessions uploaded to ``memory_capture``. + +L0 keeps only what the extractor and recall need (user / assistant messages), so the +tool traffic and the model's scratchpad are dropped there. This writer keeps the +*uploaded* session as readable markdown next to the agent's memory store, so the full +record stays available for later review: + + {workspace}/.octop/memory-sessions/.md + +The client uploads byte-deltas of the transcript, so the file is **append-only**: each +upload renders its new records and adds them; re-sends (``mode=full`` after a lost +offset state) are deduplicated by the record key written as an HTML comment, which is +invisible when rendered. Failures must never break capture — callers treat them as +best-effort and log. +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Iterator +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +# Reuse the transcript parser's record iteration and block normalization: the archive +# must agree with what the parser sees, and a second copy of those rules would drift. +from octop.infra.agents.claude_transcript import _block_text, _blocks, _iter_records + +ARCHIVE_DIRNAME = "memory-sessions" +"""Subdirectory of the agent system dir (``{workspace}/.octop/``) holding the archives.""" + +_KEY_MARKER = "" +_KEY_PATTERN = re.compile(r"") + +_MAX_BODY_CHARS = 20000 +"""Per-record cap in the markdown body — much looser than L0's 4000. + +The archive is for human inspection, so it keeps far more of a long tool result than +the L0 row does; it still bounds a single record so one 5MB build log cannot dominate +the file. +""" + + +def _now() -> str: + return datetime.now(UTC).astimezone().strftime("%Y-%m-%d %H:%M:%S") + + +def _cap(text: str, limit: int = _MAX_BODY_CHARS) -> str: + body = (text or "").strip() + return body if len(body) <= limit else body[: limit - 1].rstrip() + "…" + + +def _fence(body: str, language: str) -> str: + """Fence *body*, widening the fence when the body itself contains backticks.""" + fence = "```" + while fence in body: + fence += "`" + return f"{fence}{language}\n{body}\n{fence}" + + +def _record_role(record: dict[str, Any]) -> str: + return str(record.get("type") or "record") + + +def _render_record(key: str, record: dict[str, Any], index: int) -> str: + """One transcript record → one markdown section (all blocks, nothing dropped).""" + message = record.get("message") + message = message if isinstance(message, dict) else {} + role = str(message.get("role") or _record_role(record)) + stamp = record.get("timestamp") + when = "" + if isinstance(stamp, str) and stamp: + when = stamp.replace("T", " ").replace("Z", "")[:19] + lines: list[str] = [f"## {index} · {when or _now()} · {role}"] + lines.append(_KEY_MARKER.format(key)) + lines.append("") + sidechain = "(sidechain)" if record.get("isSidechain") else "" + if sidechain: + lines.append(sidechain) + lines.append("") + + blocks = _blocks(message.get("content")) + if not blocks: + raw = record.get("content") + if isinstance(raw, str) and raw.strip(): + blocks = [{"type": "text", "text": raw}] + for block in blocks: + kind = block.get("type") + if kind == "text": + body = _cap(str(block.get("text") or "")) + if body: + lines.append(body) + lines.append("") + elif kind == "thinking": + body = _cap(str(block.get("thinking") or block.get("text") or "")) + if body: + lines.append("*(thinking)*") + lines.append("") + lines.append("\n".join(f"> {line}" for line in body.splitlines()) or "> ") + lines.append("") + elif kind == "tool_use": + name = str(block.get("name") or "tool") + args = block.get("input") + body = json.dumps(args, ensure_ascii=False, indent=2, default=str) if args else "" + lines.append(f"**tool_call · {name}**") + lines.append("") + if body: + lines.append(_fence(_cap(body), "json")) + lines.append("") + elif kind == "tool_result": + name = str(block.get("name") or "") + body = _cap(_block_text(block.get("content"))) + flag = "(error)" if block.get("is_error") else "" + lines.append(f"**tool_result{flag}{' · ' + name if name else ''}**") + lines.append("") + if body: + lines.append(_fence(body, "text")) + lines.append("") + else: + lines.append(f"*({kind or 'unknown'} block)*") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def iter_conversation_records(text: str) -> Iterator[tuple[str, dict[str, Any]]]: + """Yield ``(record key, record)`` for the conversation records in *text*. + + Only ``user`` / ``assistant`` records — the transcript's bookkeeping records + (``mode`` / ``cost-state`` / ``attachment`` / ``file-history-snapshot``) are not + conversation. The key is the record's ``uuid`` — the same identity L0 + deduplicates on. + """ + for index, record in enumerate(_iter_records(text), start=1): + if not isinstance(record, dict): + continue + if str(record.get("type") or "") not in ("user", "assistant"): + continue + key = str(record.get("uuid") or f"line{index}") + yield key, record + + +def iter_sections(text: str) -> Iterator[tuple[str, str]]: + """Yield ``(record key, markdown section)`` for every conversation record in *text*. + + Every block is kept: text, thinking, ``tool_use`` and ``tool_result``. + """ + for index, (key, record) in enumerate(iter_conversation_records(text), start=1): + yield key, _render_record(key, record, index) + + +def _header(*, session_id: str, host: str, user: str | None) -> str: + rows = [f"# {host} session {session_id}", "", f"- host: {host}", f"- session: {session_id}"] + if user: + rows.append(f"- user: {user}") + rows += [ + f"- created: {_now()}", + "- source: `memory_capture` 上传的原始 transcript(含工具调用与结果、thinking)", + "", + "---", + "", + ] + return "\n".join(rows) + + +def archive_path(session_dir: Path, session_id: str) -> Path: + safe = re.sub(r"[^0-9A-Za-z._-]+", "_", session_id).strip("_") or "session" + return session_dir / f"{safe}.md" + + +def append_session( + session_dir: Path, + *, + session_id: str, + text: str, + host: str, + user: str | None = None, +) -> Path | None: + """Append the records of one uploaded chunk to the session's markdown archive. + + Returns the file path when something was written, ``None`` when the chunk carried + no new record (a re-send) — so callers can skip without special-casing. + """ + path = archive_path(session_dir, session_id) + existing = "" + if path.exists(): + existing = path.read_text(encoding="utf-8", errors="replace") + seen = set(_KEY_PATTERN.findall(existing)) + index = len(seen) + + sections: list[str] = [] + for key, record in iter_conversation_records(text): + if key in seen: + continue + seen.add(key) + index += 1 + sections.append(_render_record(key, record, index)) + if not sections: + return None + + session_dir.mkdir(parents=True, exist_ok=True) + body = "\n".join(section.rstrip() + "\n" for section in sections) + if existing: + with path.open("a", encoding="utf-8") as handle: + handle.write("\n" + body) + else: + path.write_text( + _header(session_id=session_id, host=host, user=user) + body, encoding="utf-8" + ) + return path diff --git a/tests/unit/agents/test_claude_transcript.py b/tests/unit/agents/test_claude_transcript.py new file mode 100644 index 000000000..dd8d38f9a --- /dev/null +++ b/tests/unit/agents/test_claude_transcript.py @@ -0,0 +1,830 @@ +"""Unit tests for the Claude Code transcript importer (infra/agents/claude_transcript).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from octop_memory.core import Memory + +from octop.infra.agents.claude_transcript import ( + import_content, + import_session, + parse_dsh_text, + parse_kiro_text, + parse_session, +) + +_SESSION = "sess-abc" +_TS = "2026-09-22T05:00:00.000Z" + + +def _record(**kwargs): + base = {"sessionId": _SESSION, "timestamp": _TS, "isSidechain": False} + base.update(kwargs) + return base + + +def _write(tmp_path: Path, records: list[dict]) -> Path: + path = tmp_path / "session.jsonl" + path.write_text( + "\n".join(json.dumps(record, ensure_ascii=False) for record in records), encoding="utf-8" + ) + return path + + +def _turn_records() -> list[dict]: + return [ + {"type": "queue-operation", "sessionId": _SESSION, "operation": "enqueue"}, + _record(type="progress", uuid="p1"), + _record(type="user", uuid="u1", message={"role": "user", "content": "帮我加一个导入命令"}), + _record( + type="assistant", + uuid="a1", + message={ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "model scratchpad"}, + {"type": "text", "text": "好的,先看现有 CLI 结构"}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "Read", + "input": {"file_path": "src/octop/cli/main.py"}, + }, + ], + }, + ), + _record( + type="user", + uuid="u2", + message={ + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": [{"type": "text", "text": "@click.group ..."}], + "is_error": False, + } + ], + }, + ), + ] + + +@pytest.fixture +def agent_memory(tmp_path: Path) -> Memory: + return Memory( + namespace="agent_TEST", + backend="sqlite", + backend_config={"db_path": str(tmp_path / "memory.sqlite")}, + ) + + +def test_parse_maps_conversation_records(tmp_path: Path) -> None: + parsed = parse_session(_write(tmp_path, _turn_records())) + + assert parsed.session_id == _SESSION + # thinking / progress / queue-operation carry no L0 content + assert [event.event_type for event in parsed.events] == [ + "user_message", + "assistant_message", + "tool_call", + "tool_result", + ] + # keys use the ORIGINAL block index (thinking keeps index 0) so they stay + # stable if the importer later changes which block types it maps + assert [event.key for event in parsed.events] == ["u1:0", "a1:1", "a1:2", "u2:0"] + + user, assistant, tool_call, tool_result = parsed.events + assert user.content == "帮我加一个导入命令" + assert assistant.content == "好的,先看现有 CLI 结构" + assert tool_call.content.startswith("Read ") + assert tool_call.payload["tool_name"] == "Read" + assert tool_call.payload["tool_use_id"] == "toolu_1" + assert tool_call.payload["input"] == {"file_path": "src/octop/cli/main.py"} + assert tool_result.content == "@click.group ..." + assert tool_result.payload["tool_use_id"] == "toolu_1" + assert tool_result.payload["is_error"] is False + assert user.timestamp.year == 2026 and user.timestamp.tzinfo is not None + # role/session markers survive on every event + assert {event.payload["role"] for event in parsed.events} == {"user", "assistant"} + assert all(event.payload["src_key"] == event.key for event in parsed.events) + + +def test_parse_skips_non_conversation_records(tmp_path: Path) -> None: + parsed = parse_session(_write(tmp_path, _turn_records())) + assert not [event for event in parsed.events if event.payload["src_key"].startswith("p1")] + + +def test_import_writes_l0_and_is_idempotent(tmp_path: Path, agent_memory: Memory) -> None: + path = _write(tmp_path, _turn_records()) + + first = import_session(agent_memory, path) + assert first.session_id == f"cc:{_SESSION}" + assert first.parsed == 4 + assert first.imported == 4 + assert first.skipped_existing == 0 + + stored = agent_memory.list_raw(session_id=f"cc:{_SESSION}", limit=100) + assert len(stored) == 4 + assert {raw.event_type for raw in stored} == { + "user_message", + "assistant_message", + "tool_call", + "tool_result", + } + assert all(raw.host == "claude-code" for raw in stored) + assert all(raw.thread_id == f"cc:{_SESSION}" for raw in stored) + + again = import_session(agent_memory, path) + assert again.imported == 0 + assert again.skipped_existing == 4 + assert len(agent_memory.list_raw(session_id=f"cc:{_SESSION}", limit=100)) == 4 + + +def test_import_appends_only_new_turns(tmp_path: Path, agent_memory: Memory) -> None: + records = _turn_records() + path = _write(tmp_path, records) + import_session(agent_memory, path) + + records.append( + _record(type="user", uuid="u3", message={"role": "user", "content": "继续,加上 --dry-run"}) + ) + path = _write(tmp_path, records) + + stats = import_session(agent_memory, path) + assert stats.imported == 1 + assert stats.skipped_existing == 4 + + +def test_import_dry_run_writes_nothing(tmp_path: Path, agent_memory: Memory) -> None: + path = _write(tmp_path, _turn_records()) + + stats = import_session(agent_memory, path, dry_run=True) + assert stats.imported == 0 + assert stats.parsed == 4 + assert agent_memory.list_raw(session_id=f"cc:{_SESSION}", limit=100) == [] + + +def test_import_caps_long_tool_results(tmp_path: Path, agent_memory: Memory) -> None: + huge = "x" * 20_000 + records = [ + _record( + type="user", + uuid="u1", + message={ + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "t", "content": huge, "is_error": False} + ], + }, + ) + ] + import_session(agent_memory, _write(tmp_path, records)) + + stored = agent_memory.list_raw(session_id=f"cc:{_SESSION}", limit=10) + assert len(stored) == 1 + assert len(stored[0].content) == 4000 + + +def test_user_attribution_is_stored(tmp_path: Path, agent_memory: Memory) -> None: + import_session(agent_memory, _write(tmp_path, _turn_records()), user="alice") + stored = agent_memory.list_raw(session_id=f"cc:{_SESSION}", limit=10) + assert {raw.user for raw in stored} == {"alice"} + + +@pytest.mark.parametrize( + "tool_name", + [ + "memory_search", + "mcp__octop-memory-local__memory_search", + "mcp__octop-memory-prod__memory_search", + "mcp__octop-memory-oauth__memory_search", + "mcp__whatever-the-user-called-it__memory_recall", + "mcp_octop_memory_local_memory_search", + "mcp_octop_memory_prod_memory_get", + ], +) +def test_our_read_tools_are_recognized_under_any_mcp_name(tool_name: str) -> None: + """MCP 服务名可能是 local / prod / oauth 或用户自取的名字:只按工具名认。""" + from octop.infra.agents.claude_transcript import _is_own_read_tool + + assert _is_own_read_tool(tool_name) + # 写工具(memory_capture / memory_save)不是「读工具」,别把用户自己的调用误丢 + assert not _is_own_read_tool("mcp__octop-memory-prod__memory_capture") + assert not _is_own_read_tool("Bash") + + +def test_parse_drops_our_own_memory_read_results(tmp_path: Path) -> None: + """Re-importing our own recall output would close the echo loop.""" + records = [ + _record( + type="assistant", + uuid="a1", + message={ + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "mcp__octop-memory-local__memory_search", + "input": {"query": "x"}, + }, + {"type": "tool_use", "id": "t2", "name": "Bash", "input": {"command": "ls"}}, + ], + }, + ), + _record( + type="user", + uuid="u1", + message={ + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": '{"hits": [{"snippet": "旧记忆"}]}', + "is_error": False, + }, + { + "type": "tool_result", + "tool_use_id": "t2", + "content": "file list", + "is_error": False, + }, + ], + }, + ), + ] + + parsed = parse_session(_write(tmp_path, records)) + + assert [event.event_type for event in parsed.events] == [ + "tool_call", + "tool_call", + "tool_result", + ] + assert parsed.events[-1].content == "file list" + assert parsed.skipped_own_results == 1 + + +def test_import_content_drops_marked_events(agent_memory: Memory) -> None: + """A recalled block must not be written back into L0 as new evidence.""" + records = [ + _record(type="user", uuid="u1", message={"role": "user", "content": "正常提问"}), + _record( + type="user", + uuid="u2", + message={"role": "user", "content": "## Memory Recall\n- [atom] 旧记忆块"}, + ), + ] + text = "\n".join(json.dumps(record, ensure_ascii=False) for record in records) + + stats = import_content(agent_memory, text, skip_markers=("## Memory Recall",)) + + assert stats.parsed == 2 + assert stats.skipped_echo == 1 + assert stats.imported == 1 + stored = agent_memory.list_raw(session_id=stats.session_id, limit=10) + assert [raw.content for raw in stored] == ["正常提问"] + + +def test_user_messages_are_attributed_to_the_caller(agent_memory: Memory) -> None: + """transcript 形态也要让发送者进正文:提取器 prompt 看不到 raw 的 ``user`` 列。""" + records = [ + _record(type="user", uuid="u1", message={"role": "user", "content": "帮我加一个导入命令"}), + _record( + type="assistant", + uuid="a1", + message={"role": "assistant", "content": [{"type": "text", "text": "好的"}]}, + ), + ] + text = "\n".join(json.dumps(record, ensure_ascii=False) for record in records) + + stats = import_content(agent_memory, text, session_id="cc:s1", user="alice") + + by_type = {raw.event_type: raw for raw in agent_memory.list_raw(session_id="cc:s1", limit=10)} + assert by_type["user_message"].content == "alice说:帮我加一个导入命令" + assert by_type["assistant_message"].content == "好的" # 助手侧不加前缀 + assert by_type["user_message"].user == "alice" # raw 的 user 列照旧 + assert stats.imported == 2 + + # 重发(offset 丢失后的 mode=full)靠 src_key 去重,也不会把前缀叠成两层 + again = import_content(agent_memory, text, session_id="cc:s1", user="alice") + assert again.imported == 0 + + +def test_sidechain_user_records_are_not_attributed(agent_memory: Memory) -> None: + """子代理记录里的 user 消息是系统合成提示,不是调用者说过的话。""" + records = [ + _record(type="user", uuid="u1", message={"role": "user", "content": "真实提问"}), + _record( + type="user", + uuid="u2", + isSidechain=True, + message={"role": "user", "content": "子代理提示"}, + ), + ] + text = "\n".join(json.dumps(record, ensure_ascii=False) for record in records) + + import_content(agent_memory, text, session_id="cc:s2", user="alice") + + contents = {raw.content for raw in agent_memory.list_raw(session_id="cc:s2", limit=10)} + assert "alice说:真实提问" in contents + assert "子代理提示" in contents + assert "alice说:子代理提示" not in contents + + +# --------------------------------------------------------------------------- +# Kiro (``~/.kiro/sessions///messages.jsonl``) +# --------------------------------------------------------------------------- + +_KIRO_RECORDS = [ + { + "id": "r1", + "timestamp": _TS, + "payload": {"type": "user", "content": "Kiro 里问的问题", "source": "chat"}, + }, + {"id": "r2", "timestamp": _TS, "payload": {"type": "turn_start", "executionId": "e1"}}, + { + "id": "r3", + "timestamp": _TS, + "payload": { + "type": "ContextualHookInvoked", + "hookId": "h", + "output": "[octop memory search] 旧记忆", + }, + }, + { + "id": "r4", + "timestamp": _TS, + "payload": { + "type": "tool_call", + "toolCallId": "c1", + "toolName": "readFiles", + "args": {"files": ["a.ts"]}, + }, + }, + { + "id": "r5", + "timestamp": _TS, + "payload": { + "type": "tool_result", + "toolCallId": "c1", + "content": "file body", + "success": True, + }, + }, + { + "id": "r6", + "timestamp": _TS, + "payload": {"type": "assistant", "content": "scratchpad", "operationType": "Reasoning"}, + }, + { + "id": "r7", + "timestamp": _TS, + "payload": {"type": "assistant", "content": "这是回答", "operationType": "Say"}, + }, + {"id": "r8", "timestamp": _TS, "payload": {"type": "turn_end", "stopReason": "end_turn"}}, +] + + +def _kiro_text() -> str: + return "\n".join(json.dumps(r, ensure_ascii=False) for r in _KIRO_RECORDS) + + +def test_parse_kiro_maps_records() -> None: + parsed = parse_kiro_text(_kiro_text()) + + assert [e.event_type for e in parsed.events] == [ + "user_message", + "tool_call", + "tool_result", + "assistant_message", + ] + assert parsed.events[3].content == "这是回答" # Reasoning 是草稿,跳过 + assert parsed.events[1].payload["tool_name"] == "readFiles" + assert parsed.events[2].payload["success"] is True + # ContextualHookInvoked 是我们自己 hook 的调用记录(连 output 都在里面) + assert parsed.skipped_own_results == 1 + assert all(e.event_type != "tool_result" or "旧记忆" not in e.content for e in parsed.events) + + +def test_import_content_kiro_is_idempotent(agent_memory: Memory) -> None: + first = import_content( + agent_memory, _kiro_text(), session_id="kiro:s1", host="kiro", parser="kiro" + ) + assert first.session_id == "kiro:s1" + assert first.imported == 4 + assert first.by_type == { + "user_message": 1, + "tool_call": 1, + "tool_result": 1, + "assistant_message": 1, + } + + again = import_content( + agent_memory, _kiro_text(), session_id="kiro:s1", host="kiro", parser="kiro" + ) + assert again.imported == 0 + assert again.skipped_existing == 4 + + +def test_import_content_rejects_unknown_parser(agent_memory: Memory) -> None: + with pytest.raises(ValueError, match="unknown transcript parser"): + import_content(agent_memory, _kiro_text(), parser="no-such-host") + + +# --------------------------------------------------------------------------- +# DSH (``~/.dsh/sessions///session.jsonl.zstd``) +# --------------------------------------------------------------------------- + +_DSH_MS = 1788952324078 +_DSH_RECORDS = [ + {"type": "session", "version": 0, "id": "session-x", "createdAt": _DSH_MS, "cwd": "/tmp"}, + { + "type": "user/message", + "seq": 1, + "time": _DSH_MS, + "data": {"content": [{"type": "text", "text": "真实提问"}], "source": {"kind": "user"}}, + }, + { + "type": "user/message", + "seq": 2, + "time": _DSH_MS, + "data": { + "content": [ + {"type": "text", "text": "Current runtime context. This snapshot supersedes…"} + ], + "source": {"kind": "plugin"}, + }, + }, + {"type": "assistant/chunk", "seq": 3, "time": _DSH_MS, "data": {"text": "流式增量"}}, + { + "type": "assistant/message", + "seq": 4, + "time": _DSH_MS, + "data": { + "turn": 1, + "step": 1, + "message": { + "role": "assistant", + "content": [ + {"type": "reasoning", "text": "草稿"}, + {"type": "text", "text": "回答正文"}, + { + "type": "tool-call", + "id": "call_1", + "name": "bash", + "arguments": '{"command": "ls"}', + }, + ], + }, + }, + }, + { + "type": "tool/call", + "seq": 5, + "time": _DSH_MS, + "data": { + "turn": 1, + "step": 1, + "callId": "call_1", + "name": "bash", + "arguments": '{"command": "ls"}', + }, + }, + { + "type": "tool/result", + "seq": 6, + "time": _DSH_MS, + "data": { + "turn": 1, + "step": 1, + "message": { + "source": {"kind": "tool", "callId": "call_1"}, + "content": [ + { + "type": "tool-result", + "toolCallId": "call_1", + "content": [{"type": "text", "text": "file list"}], + "isError": False, + } + ], + }, + }, + }, + {"type": "turn/end", "seq": 7, "time": _DSH_MS, "data": {"turn": 1, "stopReason": "end_turn"}}, +] + + +def _dsh_text() -> str: + return "\n".join(json.dumps(r, ensure_ascii=False) for r in _DSH_RECORDS) + + +def test_parse_dsh_maps_records() -> None: + parsed = parse_dsh_text(_dsh_text()) + + assert parsed.session_id == "session-x" + assert [e.event_type for e in parsed.events] == [ + "user_message", + "assistant_message", + "tool_call", + "tool_result", + ] + # 流式增量与 Reasoning 草稿都不入库 + assert all("流式增量" not in e.content for e in parsed.events) + assert all("草稿" not in e.content for e in parsed.events) + # 同一个 call 既在 assistant/message 的 tool-call 块里、又是独立 tool/call 记录 —— 只留一条 + assert [e.content for e in parsed.events if e.event_type == "tool_call"] == [ + 'bash {"command": "ls"}' + ] + assert parsed.events[3].content == "file list" + assert parsed.events[3].payload["is_error"] is False + # plugin / skill-catalog / agent-instructions 注入是管道,不是用户说的话 + assert parsed.skipped_own_results == 1 + + +def test_import_content_dsh_uses_terminal_prefix(agent_memory: Memory) -> None: + stats = import_content(agent_memory, _dsh_text(), host="dsh", parser="dsh") + + assert stats.session_id == "dsh:session-x" + assert stats.imported == 4 + assert stats.by_type == { + "user_message": 1, + "assistant_message": 1, + "tool_call": 1, + "tool_result": 1, + } + + +def test_echo_rule_differs_by_role() -> None: + """回声判定按角色区分:助手侧看是否以标记开头,用户侧看标记是否出现。""" + from octop.infra.agents.claude_transcript import _is_echo + + markers = ("[octop memory search]", "[octop memory capture]") + # 助手侧:只是引用标记的回答必须保留 + assert not _is_echo( + "靠两个 hook:召回把 [octop memory search] 块注入上下文", "assistant_message", markers + ) + assert _is_echo("[octop memory search] related memories…", "assistant_message", markers) + # 用户侧:出现标记就是我们注入的上下文(CC 会加 "Stop hook feedback:" 前缀) + assert _is_echo( + "Stop hook feedback:\n[octop memory search] 请记录要点", "user_message", markers + ) + assert not _is_echo("正常提问", "user_message", markers) + + +def test_a_reply_that_quotes_our_marker_survives(agent_memory: Memory) -> None: + """模型回答只是**引用**召回标记时必须入库——否则解释接入方式的对话会被当回声丢掉。""" + records = [ + _record(type="user", uuid="u1", message={"role": "user", "content": "记忆接入怎么做的?"}), + _record( + type="assistant", + uuid="a1", + message={ + "role": "assistant", + "content": [ + { + "type": "text", + "text": "靠两个 hook:召回会把 [octop memory search] 块注入上下文。", + } + ], + }, + ), + ] + text = "\n".join(json.dumps(record, ensure_ascii=False) for record in records) + + stats = import_content(agent_memory, text, skip_markers=("[octop memory search]",)) + + assert stats.skipped_echo == 0 + assert stats.imported == 2 + stored = agent_memory.list_raw(session_id=stats.session_id, limit=10) + assert any("[octop memory search]" in raw.content for raw in stored) + + +def test_codex_rollout_maps_to_events() -> None: + """Codex rollout:event_msg 带对话,response_item 带工具与(重复的)消息副本。""" + from octop.infra.agents.claude_transcript import parse_codex_text + + records = [ + { + "timestamp": "2026-08-08T13:47:31.000Z", + "type": "session_meta", + "payload": {"id": "s-1", "cwd": "/tmp", "cli_version": "0.142.5"}, + }, + { + "timestamp": "2026-08-08T13:47:31.163Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": "现在是什么模型\n"}, + }, + { + "timestamp": "2026-08-08T13:47:32.000Z", + "type": "response_item", + "payload": { + "type": "message", + "role": "developer", + "content": [{"type": "input_text", "text": ""}], + }, + }, + { + "timestamp": "2026-08-08T13:47:33.000Z", + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "id": "a1", + "content": [{"type": "output_text", "text": "我是 Codex。"}], + }, + }, + { + "timestamp": "2026-08-08T13:47:34.000Z", + "type": "response_item", + "payload": { + "type": "function_call", + "id": "t1", + "name": "shell", + "arguments": '{"command":"ls"}', + }, + }, + { + "timestamp": "2026-08-08T13:47:35.000Z", + "type": "response_item", + "payload": {"type": "function_call_output", "id": "t2", "output": "a.txt"}, + }, + { + "timestamp": "2026-08-08T13:47:36.000Z", + "type": "turn_context", + "payload": {"cwd": "/tmp", "model": "x"}, + }, + ] + text = "\n".join(json.dumps(record, ensure_ascii=False) for record in records) + + parsed = parse_codex_text(text) + + assert [event.event_type for event in parsed.events] == [ + "user_message", + "assistant_message", + "tool_call", + "tool_result", + ] + assert parsed.events[0].content == "现在是什么模型\n" + assert parsed.events[1].content == "我是 Codex。" + assert parsed.events[2].content.startswith("shell ") + assert parsed.events[3].content == "a.txt" + assert parsed.skipped_own_results == 1 # developer instructions 是宿主管线 + + +def test_codex_does_not_store_the_same_turn_twice() -> None: + """event_msg 与 response_item 会带同一段文本:只入库一次。""" + from octop.infra.agents.claude_transcript import parse_codex_text + + records = [ + { + "timestamp": "2026-08-08T13:47:31.163Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": "同一个问题"}, + }, + { + "timestamp": "2026-08-08T13:47:31.900Z", + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "id": "u9", + "content": [{"type": "input_text", "text": "同一个问题"}], + }, + }, + ] + text = "\n".join(json.dumps(record, ensure_ascii=False) for record in records) + + parsed = parse_codex_text(text) + + assert [event.content for event in parsed.events] == ["同一个问题"] + assert parsed.skipped_own_results == 1 + + +def test_cursor_transcript_shapes_are_tolerated() -> None: + """Cursor 的 transcript_path 格式未公开:JSONL / 数组 / {messages:[]} 都要能读。""" + from octop.infra.agents.claude_transcript import parse_cursor_text + + pairs = [("user", "怎么接入?"), ("assistant", "两条 hook 就够了。")] + shapes = [ + "\n".join(json.dumps({"role": r, "content": c}, ensure_ascii=False) for r, c in pairs), + json.dumps([{"role": r, "text": c} for r, c in pairs], ensure_ascii=False), + json.dumps( + {"messages": [{"role": r, "content": [{"type": "text", "text": c}]} for r, c in pairs]}, + ensure_ascii=False, + ), + ] + + for text in shapes: + parsed = parse_cursor_text(text) + assert [event.event_type for event in parsed.events] == [ + "user_message", + "assistant_message", + ], text[:60] + assert parsed.events[0].content == "怎么接入?" + + +def test_cursor_agent_transcript_jsonl_keeps_text_and_drops_tool_blocks() -> None: + """stop 交给的真实文件:一行一个 {role, message:{content:[{type,text}]}}。""" + from octop.infra.agents.claude_transcript import parse_cursor_text + + lines = [ + { + "role": "user", + "message": {"content": [{"type": "text", "text": "怎么接入?"}]}, + }, + { + "role": "assistant", + "message": { + "content": [ + {"type": "text", "text": "两条 hook 就够了。"}, + {"type": "tool_use", "name": "read", "input": {"path": "a"}}, + ] + }, + }, + ] + text = "\n".join(json.dumps(line, ensure_ascii=False) for line in lines) + + parsed = parse_cursor_text(text) + + assert [event.event_type for event in parsed.events] == [ + "user_message", + "assistant_message", + ] + assert parsed.events[0].content == "怎么接入?" + assert parsed.events[1].content == "两条 hook 就够了。" + + +def test_cursor_skips_entries_without_a_conversational_role() -> None: + from octop.infra.agents.claude_transcript import parse_cursor_text + + text = json.dumps( + [ + {"role": "user", "content": "问题"}, + {"kind": "toolCall", "tool": "read", "args": {"path": "a"}}, + {"role": "assistant", "content": "回答"}, + ], + ensure_ascii=False, + ) + + parsed = parse_cursor_text(text) + + assert [event.event_type for event in parsed.events] == ["user_message", "assistant_message"] + assert parsed.skipped_own_results == 1 + + +def test_cursor_delta_uploads_do_not_collide_on_line_index_src_key() -> None: + """stop hook ships byte deltas; each chunk used to renumber from line0. + + Distinct user turns at the head of consecutive deltas must get distinct + ``src_key`` values, otherwise the later user message is dropped as + ``skipped_existing`` while later assistant lines still import. + """ + from octop.infra.agents.claude_transcript import parse_cursor_text + + def _line(role: str, text: str) -> str: + return json.dumps( + {"role": role, "message": {"content": [{"type": "text", "text": text}]}}, + ensure_ascii=False, + ) + + delta_a = "\n".join( + [ + _line("user", "上一轮用户问题"), + _line("assistant", "上一轮助手回答"), + ] + ) + delta_b = "\n".join( + [ + _line("user", "原始素材只有回复,没有用户消息"), + _line("assistant", "这是助手的长回复,超过十二个字符。"), + ] + ) + + keys_a = {event.payload["src_key"] for event in parse_cursor_text(delta_a).events} + parsed_b = parse_cursor_text(delta_b) + keys_b = [event.payload["src_key"] for event in parsed_b.events] + + assert parsed_b.events[0].event_type == "user_message" + assert parsed_b.events[0].content == "原始素材只有回复,没有用户消息" + assert keys_b[0] not in keys_a + assert keys_b[0].startswith("c") and keys_b[0].endswith(":0") + # Re-parsing the same delta stays idempotent. + assert parse_cursor_text(delta_b).events[0].payload["src_key"] == keys_b[0] + + +def test_cursor_prefers_explicit_record_id_for_src_key() -> None: + from octop.infra.agents.claude_transcript import parse_cursor_text + + text = json.dumps( + {"id": "msg_abc", "role": "user", "content": "有稳定 id"}, + ensure_ascii=False, + ) + parsed = parse_cursor_text(text) + assert parsed.events[0].payload["src_key"] == "msg_abc:0" diff --git a/tests/unit/agents/test_client_setup.py b/tests/unit/agents/test_client_setup.py new file mode 100644 index 000000000..bd48176a1 --- /dev/null +++ b/tests/unit/agents/test_client_setup.py @@ -0,0 +1,1257 @@ +"""Unit tests for the interactive local-terminal installer generator.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tomllib +from pathlib import Path + +import pytest + +from octop.infra.agents.client_setup import ( + HOOKS_DIRS, + TERMINALS, + render_setup_script, + render_uninstall_script, +) + +_URL = "http://127.0.0.1:8088/mcp/memory/" + +posix_only = pytest.mark.skipif(os.name != "posix", reason="假的 dsh 命令用 POSIX 可执行脚本") + + +def _script(**overrides) -> str: + kwargs = {"mcp_url": _URL} + kwargs.update(overrides) + return render_setup_script(**kwargs) + + +def test_installer_detects_the_local_terminals() -> None: + script = _script() + + assert '[ -e "$HOME/.claude.json" ] || [ -d "$HOME/.claude" ] || command -v claude' in script + assert '[ -d "$HOME/.kiro" ] || command -v kiro' in script + assert '[ -d "$HOME/.dsh" ] || command -v dsh' in script + assert 'echo " 1) Claude Code' in script + assert 'echo " 2) Kiro' in script + assert 'echo " 3) DSH' in script + + +def test_installer_asks_instead_of_demanding_parameters() -> None: + script = _script() + + # 交互走 /dev/tty:curl | sh 时 stdin 是脚本本身,直接 read 会把脚本吃掉 + assert "if (true /dev/null; then HAS_TTY=1; fi" in script + assert script.count("< /dev/tty") >= 3 # 探测 + 选择 + token + 专家 ID + assert "要配置哪些?" in script + assert "专家 ID: " in script + # 凭据提示按目标分流:本机实例与云端都问实例共享 token + assert "共享 token(本机实例,走 Authorization: Bearer)" in script + assert "共享 token(云上实例): " in script + assert "stty -echo" in script # 不回显 + # 没有 tty 时不盲读 stdin,而是明确要求环境变量 + # hooks 的凭据是实例共享 token;缺凭据时明确报错 + assert 'TOKEN="${OCTOP_MEMORY_TOKEN:-}"' in script + assert 'ENTRY_TOKEN="${OCTOP_ENTRY_TOKEN:-}"' in script + assert "非交互运行时请设 OCTOP_MEMORY_TOKEN" in script + assert "非交互运行时请设 OCTOP_AGENT_ID" in script + # OAuth 前置必须有个人令牌,没有就当场问 / 非交互直接报错 + assert "个人令牌(OAuth 前置必须;不回显): " in script + assert "必须带个人令牌" in script + # 直连模式会问用户标识(写进 X-Octop-User-Id) + assert "用户标识(建议填域账号,如 alice" in script + + +def test_terminal_pick_allows_multiselect() -> None: + """三个选项可多选:1,2 / 1 3 / 12 / a=全部,回车=全部检测到的。""" + script = _script() + + assert "可多选:1,2 / 1 3 / 12 / a=全部" in script + assert "''|a|A|all|ALL|全部) _chosen=\"$DETECTED\"" in script + assert "fold -w1" in script # 连写数字 12/13 按位拆开 + assert 'case ",$_chosen," in *",$_add,"*' in script # 去重 + assert "已忽略" in script # 未检测到/无法识别 + assert "没有选中任何已检测到的终端,请重选" in script # 选不中会重问 + assert 'while [ -z "$TERMINALS" ]' in script + + +def test_installer_embeds_no_credential() -> None: + """没内置默认值时不内嵌密钥:token 由运行的人输入(部署可选地内置一个,见 DEFAULT_TOKEN)。""" + script = _script() + + assert "DEFAULT_TOKEN=" not in script + assert "Bearer " in script # 只在运行时按输入拼头,不是写死的 token + # header 名可配、缺省 Authorization(不再按 token 前缀猜方言) + assert 'TOKEN_HEADER="${OCTOP_TOKEN_HEADER:-Authorization}"' in script + assert 'ENTRY_TOKEN_HEADER="${OCTOP_ENTRY_TOKEN_HEADER:-Authorization}"' in script + + +def test_known_values_only_prefill_prompts() -> None: + script = _script(agent_id="RY9RQC", terminals="kiro") + + assert "DEFAULT_AGENT='RY9RQC'" in script + assert "DEFAULT_TERMINALS='kiro'" in script + + +def test_setup_script_shell_quotes_query_prefills() -> None: + script = _script(agent_id="x';id", terminals="cursor';id") + # Classic POSIX single-quote escape: ' → '"'"' + assert "DEFAULT_AGENT='x'\"'\"';id'" in script + assert "DEFAULT_TERMINALS='cursor'\"'\"';id'" in script + assert "DEFAULT_AGENT='x';id'" not in script + assert "DEFAULT_TERMINALS='cursor';id'" not in script + + +def test_each_terminal_gets_its_own_hooks_directory() -> None: + script = _script() + + assert "CC_DIR='~/.claude/hooks-events'" in script + assert "KIRO_DIR='~/.kiro/hooks'" in script + assert "DSH_DIR='~/.dsh/hooks'" in script + assert "CODEX_DIR='~/.codex/hooks'" in script + assert "CURSOR_DIR='~/.cursor/hooks'" in script + assert HOOKS_DIRS == { + "claude-code": "~/.claude/hooks-events", + "kiro": "~/.kiro/hooks", + "dsh": "~/.dsh/hooks", + "codex": "~/.codex/hooks", + "cursor": "~/.cursor/hooks", + } + + +def test_claude_code_uses_two_command_hooks() -> None: + """Claude Code:召回与上报都必须是 command hook。 + + - ``UserPromptSubmit`` 上 `mcp_tool` 会被静默跳过(实测 2.1.263); + - ``Stop`` 用脚本,脚本自己后台脱离+延迟,等本轮落盘后再读 transcript + (``SessionEnd`` 不可用:那里没有 MCP 客户端上下文)。 + """ + script = _script() + + assert ( + 'search = {"type": "command", "command": hook_command(f"{hooks_dir}/claude-code/user-prompt-recall.mjs")}' + in script + ) + assert ( + 'stop = {"type": "command", "command": hook_command(f"{hooks_dir}/claude-code/stop-capture.mjs")}' + in script + ) + assert "该事件不支持 ``mcp_tool``" in script + assert "SessionEnd`` 不能用" in script + # 简写形态:事件直接挂 handler 数组,不套 matcher 组那层 + # (某些终端的 SDK 侧不保证 hooks 数组按顺序执行,少一层嵌套更稳) + assert 'return {"UserPromptSubmit": [search], "Stop": [stop]}' in script + # 收尾提示按事件名列出两端角色(Cursor 的事件名不同,故不能只写 UserPromptSubmit/Stop) + assert "用户提交时召回" in script and "memory_search" in script + assert "回合结束时上报" in script and "memory_capture" in script + assert "Cursor 的 beforeSubmitPrompt" in script + + +def test_claude_code_clears_the_legacy_session_end_hook() -> None: + """早期版本挂过 SessionEnd(mcp_tool 在那里不可用、会持续报错):重装时要清掉。""" + script = _script() + + assert '_upsert_groups(st, "SessionEnd", [])' in script + assert 'st["hooks"].pop("SessionEnd", None)' in script + assert 'hooks["SessionEnd"]' not in script # 不再定义 + + +def test_previous_inline_hook_command_is_replaced_by_the_wrapper(tmp_path) -> None: + """老配置里那串内联 node 命令(含 ``.mjs`` 与转义字符)重装时换成包装脚本,不留重复。 + + 这也是升级路径:``_is_our_handler`` 同时认 ``.mjs`` / ``.sh`` 两种形态,所以老条目会被 + 替换掉而不是并列留下。 + """ + legacy = ( + "/usr/bin/node /home/u/.claude/hooks-events/claude-code/stop-capture.mjs " + '|| echo "octop-memory: hook 执行失败(node 路径不对或脚本报错)" >&2' + ) + home, _ = _run_claude_code_installer( + tmp_path, {}, {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": legacy}]}]}} + ) + stops = json.loads((home / ".claude/settings.json").read_text(encoding="utf-8"))["hooks"][ + "Stop" + ] + assert len(stops) == 1 # 老命令被替换,没有并列 + assert stops[0]["type"] == "command" # 换成简写形态(事件直接挂 handler) + command = stops[0]["command"] + assert command.endswith("claude-code/stop-capture.sh") + assert ".mjs" not in command # 配置里不再有那串长命令 + assert (home / ".claude/hooks-events/claude-code/stop-capture.sh").exists() + + +def test_foreign_hook_groups_are_kept_next_to_ours(tmp_path) -> None: + """别人的 hook(matcher 组或裸 handler)都要原样留着,只动我们自己那两条。""" + settings = { + "hooks": { + "Stop": [ + {"matcher": "*", "hooks": [{"type": "command", "command": "git-ai checkpoint"}]}, + {"type": "command", "command": "/usr/local/bin/other-check"}, + ] + } + } + home, _ = _run_claude_code_installer(tmp_path, {}, settings) + stops = json.loads((home / ".claude/settings.json").read_text(encoding="utf-8"))["hooks"][ + "Stop" + ] + + assert stops[0] == { + "matcher": "*", + "hooks": [{"type": "command", "command": "git-ai checkpoint"}], + } + assert stops[1] == {"type": "command", "command": "/usr/local/bin/other-check"} + assert stops[2]["command"].endswith("claude-code/stop-capture.sh") # 我们的追加在最后 + + +def test_scripts_are_installed_for_every_host_and_mode() -> None: + """脚本直传不再挑 token 方言:谁能问的由 X-Octop-User-Id 表达,所以任何模式都装。 + + (早期版本用 ``SCRIPT_OK = LOCAL`` 之类的判据把共享 token 挡在脚本之外,结果 Kiro 上报 + 退化成模型自己总结、DSH 干脆不装 hooks。) + """ + script = _script() + + assert "SCRIPT_OK" not in script + assert "CAPTURE_PROMPT" not in script # 上报绝不走"让模型总结"的提示词 + # Claude Code 任何模式都要装脚本(召回 hook 是 command 脚本) + assert "def do_claude_code():\n # 两条 hook 都是 command 脚本" in script + assert '_install_hooks(CC_DIR, "claude-code")' in script + assert '_install_hooks(KIRO_DIR, "kiro")' in script + assert '_install_hooks(DSH_DIR, "dsh")' in script + assert '_install_hooks(CODEX_DIR, "codex")' in script + assert '_install_hooks(CURSOR_DIR, "cursor")' in script + + +def test_kiro_capture_is_always_a_command_hook() -> None: + """Kiro:召回用提示词(Kiro 只支持 runCommand/askAgent);上报始终用脚本提交整段 jsonl。""" + script = _script() + + kiro = script[script.index("def do_kiro():") : script.index("def _dsh_bridge(")] + # 召回仍然是 agent 提示词(runCommand 的 stdout 不会被注入上下文) + assert '"action": {"type": "agent", "prompt": SEARCH_PROMPT}' in kiro + # 上报只有 command 一种形态,没有提示词兜底 + assert ( + '{"type": "command", "command": hook_command(f"{KIRO_DIR}/kiro/stop-capture.mjs")}' in kiro + ) + assert '"type": "agent", "prompt": CAPTURE_PROMPT' not in kiro + assert "提交整段 messages.jsonl" in kiro + assert "Kiro 上报 hook" in script + + +def test_prompts_carry_the_role_instructions() -> None: + script = _script() + + assert "先用 memory_search 检索" in script + # 上报没有提示词:模型"总结会话"不算捕获原始会话 + assert "用 memory_capture 记录" not in script + + +def test_generated_script_embeds_each_hosts_scripts() -> None: + """按宿主分别内嵌:共享传输模块 + 该宿主的入口脚本(含 detach/延迟逻辑)。""" + script = _script() + + assert "OCTOP_FILES_CLAUDE_CODE=" in script + assert "OCTOP_FILES_KIRO=" in script and "OCTOP_FILES_DSH=" in script + assert "OCTOP_FILES_CODEX=" in script and "OCTOP_FILES_CURSOR=" in script + assert "OCTOP_FILES_PRESET=" in script + assert "HOOK_FILES = {" in script and "OCTOP_FILES_" in script + assert "lib/octop-memory-client.mjs" in script + assert "claude-code/stop-capture.mjs" in script and "kiro/stop-capture.mjs" in script + assert "dsh/stop-capture.mjs" in script and "dsh/user-prompt-recall.mjs" in script + assert "dsh/memory-target.mjs" in script + assert "codex/user-prompt-recall.mjs" in script and "codex/stop-capture.mjs" in script + assert "cursor/user-prompt-recall.mjs" in script and "cursor/stop-capture.mjs" in script + # 旧版的单文件变量不再使用 + assert "OCTOP_STOP_B64" not in script and "OCTOP_RECALL_B64" not in script + + +def test_dsh_bridge_points_at_the_dsh_entry_scripts() -> None: + """DSH 的桥接只跑 command 钩子(mcp_tool / prompt 会被跳过),且必须指向 DSH 自己的脚本。 + + DSH 只装 ``dsh/`` 目录下的入口脚本;指向 ``claude-code/`` 等于两条钩子都指向不存在的文件。 + """ + script = _script() + + assert "def _dsh_bridge(hooks_dir):" in script + assert 'DSH_DIR / "octop-hooks.json"' in script + assert "{hooks_dir}/dsh/user-prompt-recall.mjs" in script + assert "{hooks_dir}/dsh/stop-capture.mjs" in script + bridge = script[script.index("def _dsh_bridge(") : script.index("def do_dsh():")] + assert "{hooks_dir}/claude-code/" not in bridge + + +def test_dsh_installs_the_preset_bundle() -> None: + """DSH ≥0.1.7 的 preset 由 bundle patch 声明:安装器生成 bundle 并装进 profile。""" + script = _script() + + assert "OCTOP_FILES_PRESET=" in script + assert "def _install_dsh_preset(bridge):" in script + assert "def _select_dsh_bundle(profile_dir, name):" in script + assert '"__OCTOP_HOOKS_JSON__"' in script # 模板占位符换成实际桥接文件 + assert '"dsh", "plugin", "--profile", profile, "add", str(bundle)' in script + assert '"pnpm", "add", str(bundle)' in script # 源码 checkout 常没有 dsh 命令 + assert "def _run_dsh_install(bundle, profile, profile_dir):" in script + assert ( + 'config.setdefault("dsh", {}).setdefault("profile", {}).setdefault("bundles", [])' in script + ) + assert "重启 DSH" in script + assert "没找到 DSH profile" in script + + +def test_loopback_targets_use_the_configured_header() -> None: + """本机实例的共享 token 缺省走 ``Authorization: Bearer``;header 名可配、不猜前缀。""" + script = _script() + + # 判据用 hooks 真正要打的地址(拆分模式下不是「客户端条目」那个地址) + assert 'URL_HOST="$(printf \'%s\' "$HOOK_URL"' in script + assert "127.0.0.1|localhost) IS_LOCAL=1" in script + assert "共享 token(本机实例,走 Authorization: Bearer)" in script # 本机问共享 token + assert "共享 token(云上实例): " in script # 云端也问实例共享 token + # 不再有 token 前缀方言判定或硬编码 header 名 + assert 'TOKEN_HEADER="${OCTOP_TOKEN_HEADER:-Authorization}"' in script + assert "hook 直连:" in script + + +def test_node_is_resolved_to_an_absolute_path() -> None: + """hook 由终端派生、常在非 login shell 里跑:node 必须解析成绝对路径。 + + 裸 node(nvm 场景)在那种 shell 里找不到,而 command hook 的非 0 退出是静默的。 + """ + script = _script() + + assert 'NODE_BIN="${OCTOP_NODE:-}"' in script + assert "command -v node" in script + assert "$HOME/.nvm/versions/node" in script # nvm 是最常见的坑 + assert "/opt/homebrew/bin/node" in script and "/usr/local/bin/node" in script + assert "找不到 node 的绝对路径" in script # 都找不到时明确告警 + assert 'export OCTOP_NODE="$NODE_BIN"' in script + + +def test_hook_commands_use_the_resolved_node_and_report_failures() -> None: + script = _script() + + assert 'NODE = os.environ.get("OCTOP_NODE") or "node"' in script + assert "def hook_command(script_path):" in script + assert 'hook_command(f"{hooks_dir}/claude-code/stop-capture.mjs")' in script + assert 'hook_command(f"{KIRO_DIR}/kiro/stop-capture.mjs")' in script + assert '|| echo "octop-memory: hook 执行失败' in script # 非 0 退出不再静默 + # 企业根在系统证书库里、不在 Node 自带库里:Node 22.15+ 才加 --use-system-ca + assert "OCTOP_USE_SYSTEM_CA" in script + assert "--use-system-ca" in script + assert 'NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--use-system-ca"' in script + + +def test_installer_names_the_log_and_state_location() -> None: + """日志与增量状态就落在终端自己的 hooks 目录(该目录不可写才退到临时目录)。""" + script = _script() + + assert "日志/增量状态:" in script + assert "{target}/octop-hook.log" in script + assert "{target}/octop-transcript-state.json" in script + assert "$TMPDIR/octop-hooks" in script + + +def test_mcp_entry_is_per_terminal_and_keeps_other_entries() -> None: + script = _script() + + assert 'home / ".claude.json"' in script + assert 'home / ".kiro" / "settings" / "mcp.json"' in script + assert "def _octop_servers(servers):" in script # 按 octop-memory* 认已有服务,不认具体名字 + assert "保持原样" in script + assert "if not any(" in script + + +def test_legacy_mcp_tool_hooks_are_recognized_by_tool_not_by_server_name() -> None: + """旧版 mcp_tool hook 的清理按工具名认:服务名可能是 local / prod / oauth 或改过名。""" + script = _script() + + assert '_OUR_MCP_TOOLS = ("memory_search", "memory_capture")' in script + assert 'handler.get("tool") in _OUR_MCP_TOOLS' in script + assert 'handler.get("server") == NAME' not in script + + +def test_endpoint_self_check_is_warn_only() -> None: + script = _script() + + assert "端点自检" in script + assert "安装仍继续" in script + # 自检与 hook 共用同一次探测;支持时先 export NODE_OPTIONS,再去 fetch + assert 'if "$NODE_BIN" --use-system-ca -e' in script + assert "export NODE_OPTIONS" in script + assert "AbortSignal.timeout(8000)" in script + + +def test_server_name_follows_the_endpoint() -> None: + assert "OCTOP_SERVER_NAME='octop-memory-local'" in _script() + remote = _script(mcp_url="https://octop.example.com/mcp/") + assert "OCTOP_SERVER_NAME='octop-memory'" in remote + assert "octop-memory-local" not in remote + + +def test_generator_knows_only_the_supported_terminals() -> None: + assert TERMINALS == ("claude-code", "kiro", "dsh", "codex", "cursor") + + +def test_codex_and_cursor_are_detected_and_offered() -> None: + """两个宿主都要进检测与菜单(此前只有脚本、没有安装入口)。""" + script = _script() + + assert '[ -d "$HOME/.codex" ] || command -v codex' in script + assert '[ -d "$HOME/.cursor" ] || command -v cursor' in script + assert 'echo " 4) Codex' in script and 'echo " 5) Cursor' in script + assert '"codex|Codex' in script and '"cursor|Cursor' in script + assert "请重选(1-5,或 a=全部)" in script + assert "4) _add=" in script and "5) _add=" in script + assert '"codex": do_codex' in script and '"cursor": do_cursor' in script + + +def test_terminal_selection_is_an_interactive_checklist() -> None: + """选终端是清单式:↑↓ 移动 · 空格 选择 · a 全选 · 回车 确认(参考常见安装器交互)。""" + script = _script() + + assert "OCTOP_SELECT" in script + assert "↑/↓ 移动 · 空格 选择 · a 全选 · 回车 确认" in script + assert 'if key == b"\\x1b":' in script # 方向键 ESC [ A/B + assert 'return {b"[A": "up", b"[B": "down"}' in script + assert 'if key == b" ":' in script # 空格切换 + assert "setraw(fd)" in script # raw 模式读单键 + assert 'box = "◉" if chosen[index] else "◯"' in script # 勾选状态可见 + # 一个都没选时回车不确认;无 tty / python3 不可用则回退数字输入 + assert "if not any(chosen):" in script + assert "可多选:1,2 / 1 3 / 12 / a=全部" in script + + +def test_checklist_helper_is_valid_python() -> None: + import ast + + from octop.infra.agents.client_setup import _SELECT_PY + + ast.parse(_SELECT_PY) # 片段直接内嵌进生成的脚本,必须能独立解析 + assert "sys.exit(main())" in _SELECT_PY + + +def test_generated_installer_actually_runs(tmp_path) -> None: + """把生成的安装脚本真跑一遍(沙箱 HOME)。 + + 脚本是 shell + 内嵌 python 拼出来的:语法正确不等于能跑,拼装错误(变量改名、 + 参数漏传)只有执行才会暴露。 + """ + script = tmp_path / "setup.sh" + script.write_text(_script(), encoding="utf-8") + home = tmp_path / "home" + for name in (".claude", ".kiro", ".dsh", ".codex", ".cursor"): + (home / name).mkdir(parents=True, exist_ok=True) + + result = subprocess.run( + ["sh", str(script)], + env={ + **os.environ, + "HOME": str(home), + "OCTOP_TERMINALS": "claude-code,kiro,dsh,codex,cursor", + "OCTOP_MEMORY_TOKEN": "fake-token", + "OCTOP_AGENT_ID": "AGT1", + }, + capture_output=True, + text=True, + timeout=180, + ) + + assert result.returncode == 0, result.stdout + result.stderr + # 每个宿主只有自己的入口脚本 + 一份共享传输模块 + config.json + assert (home / ".claude/hooks-events/claude-code/stop-capture.mjs").exists() + assert (home / ".claude/hooks-events/claude-code/user-prompt-recall.mjs").exists() + assert (home / ".kiro/hooks/kiro/stop-capture.mjs").exists() + assert (home / ".dsh/hooks/dsh/stop-capture.mjs").exists() + assert (home / ".dsh/hooks/dsh/user-prompt-recall.mjs").exists() + assert (home / ".dsh/hooks/dsh/memory-target.mjs").exists() + assert (home / ".codex/hooks/codex/stop-capture.mjs").exists() + assert (home / ".codex/hooks/codex/user-prompt-recall.mjs").exists() + assert (home / ".cursor/hooks/cursor/stop-capture.mjs").exists() + assert (home / ".cursor/hooks/cursor/user-prompt-recall.mjs").exists() + for hooks_dir in ( + ".claude/hooks-events", + ".kiro/hooks", + ".dsh/hooks", + ".codex/hooks", + ".cursor/hooks", + ): + assert (home / hooks_dir / "lib/octop-memory-client.mjs").exists() + assert (home / hooks_dir / "config.json").exists() + # 宿主之间不串:CC 目录里不该出现 kiro/dsh 的入口 + assert not (home / ".claude/hooks-events/kiro").exists() + assert not (home / ".kiro/hooks/claude-code").exists() + # Codex / Cursor 的 hook 配置只引用包装脚本路径(长命令收在 .sh 里,配置里没有引号/转义坑) + assert ".codex/hooks/codex/stop-capture.sh" in (home / ".codex/config.toml").read_text( + encoding="utf-8" + ) + cursor_hooks = json.loads((home / ".cursor/hooks.json").read_text(encoding="utf-8")) + assert ( + "cursor/hooks/cursor/user-prompt-recall.sh" + in cursor_hooks["hooks"]["beforeSubmitPrompt"][0]["command"] + ) + assert "cursor/hooks/cursor/stop-capture.sh" in cursor_hooks["hooks"]["stop"][0]["command"] + # 每个宿主的包装脚本:可执行,内容是「绝对 node 路径 + .mjs + 失败兜底」 + for hooks_dir, host in ( + (".claude/hooks-events", "claude-code"), + (".kiro/hooks", "kiro"), + (".dsh/hooks", "dsh"), + (".codex/hooks", "codex"), + (".cursor/hooks", "cursor"), + ): + wrapper = home / hooks_dir / host / "stop-capture.sh" + text = wrapper.read_text(encoding="utf-8") + assert text.startswith("#!/bin/sh") + assert f"{host}/stop-capture.mjs" in text + assert "hook 执行失败" in text # 非 0 退出不再静默 + assert os.access(wrapper, os.X_OK) + if "node 使用系统证书库" in result.stdout: + assert "--use-system-ca" in text + # DSH 桥接指向 DSH 自己的包装脚本,且 preset bundle 已生成并把占位符换成了桥接文件 + bridge = home / ".dsh/hooks/octop-hooks.json" + bridge_text = bridge.read_text(encoding="utf-8") + assert "dsh/user-prompt-recall.sh" in bridge_text + assert "dsh/stop-capture.sh" in bridge_text + assert "claude-code/" not in bridge_text + bundle = home / ".dsh/bundles/dsh-memory-share" + assert (bundle / "package.json").exists() + patch = (bundle / "cordis.patch.yml").read_text(encoding="utf-8") + assert "__OCTOP_HOOKS_JSON__" not in patch + assert str(bridge) in patch + assert "octop-shared-memory" in patch + + +_CODEX_EXISTING = """model = "gpt-5" + +[[hooks.PreToolUse]] + +[[hooks.PreToolUse.hooks]] +command = "/usr/local/bin/git-ai checkpoint codex" +type = "command" + +[[hooks.Stop]] + +[[hooks.Stop.hooks]] +command = "/usr/local/bin/git-ai checkpoint codex" +type = "command" + +[hooks.state."CONFIG:stop:0:0"] +enabled = true +trusted_hash = "sha256:deadbeef" +""" + + +def _run_terminals(tmp_path, terminals: str, prepare=None, extra_env=None): + """按给定终端跑两遍安装器(第二遍验证幂等)。""" + home = tmp_path / "home" + home.mkdir(parents=True, exist_ok=True) + if prepare is not None: + prepare(home) + script = tmp_path / "setup.sh" + script.write_text(_script(), encoding="utf-8") + env = { + **os.environ, + "HOME": str(home), + "OCTOP_TERMINALS": terminals, + "OCTOP_MEMORY_TOKEN": "fake-token", + "OCTOP_AGENT_ID": "AGT1", + **(extra_env or {}), + } + for _ in range(2): + result = subprocess.run( + ["sh", str(script)], env=env, capture_output=True, text=True, timeout=180 + ) + assert result.returncode == 0, result.stdout + result.stderr + return home + + +def test_user_id_is_written_and_sent_only_when_given(tmp_path) -> None: + """OAuth/个人令牌只是拿用户身份;直连端点时只能靠 X-Octop-User-Id 标「谁问的」。""" + home = _run_terminals( + tmp_path / "with-id", + "claude-code", + extra_env={"OCTOP_USER_ID": "alice", "OCTOP_DISABLE_OAUTH": "true"}, + ) + config = json.loads((home / ".claude/hooks-events/config.json").read_text(encoding="utf-8")) + assert config["userId"] == "alice" + + entry = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"][ + "octop-memory-local" + ] + assert entry["headers"]["X-Octop-User-Id"] == "alice" + + # 不传就保持匿名:配置里不写 userId,MCP 条目也不加头 + home2 = _run_terminals( + tmp_path / "anonymous", + "claude-code", + extra_env={"OCTOP_USER_ID": "", "OCTOP_DISABLE_OAUTH": "true"}, + ) + config2 = json.loads((home2 / ".claude/hooks-events/config.json").read_text(encoding="utf-8")) + assert "userId" not in config2 + entry2 = json.loads((home2 / ".claude.json").read_text(encoding="utf-8"))["mcpServers"][ + "octop-memory-local" + ] + assert "X-Octop-User-Id" not in entry2["headers"] + + +def test_direct_entry_uses_authorization_bearer(tmp_path) -> None: + """直连条目缺省是 ``X-Octop-Agent-Id`` + ``Authorization: Bearer ``。""" + home = _run_terminals(tmp_path, "claude-code", extra_env={"OCTOP_DISABLE_OAUTH": "true"}) + entry = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"][ + "octop-memory-local" + ] + assert entry["headers"]["Authorization"] == "Bearer fake-token" + assert "X-Octop-Memory-Token" not in entry["headers"] + + # header 名可配:非 Authorization 时原样写该 header(不加 Bearer 前缀) + home_custom = tmp_path / "custom-home" + (home_custom / ".claude").mkdir(parents=True) + custom_script = tmp_path / "setup-custom.sh" + custom_script.write_text( + _script(mcp_url="https://octop.example.com/mcp/memory/"), encoding="utf-8" + ) + result = subprocess.run( + ["sh", str(custom_script)], + env={ + **os.environ, + "HOME": str(home_custom), + "OCTOP_TERMINALS": "claude-code", + "OCTOP_MEMORY_TOKEN": "shared-token", + "OCTOP_TOKEN_HEADER": "X-Octop-Memory-Token", + "OCTOP_AGENT_ID": "AGT1", + "OCTOP_DISABLE_OAUTH": "true", + }, + capture_output=True, + text=True, + timeout=180, + ) + assert result.returncode == 0, result.stdout + result.stderr + custom_entry = json.loads((home_custom / ".claude.json").read_text(encoding="utf-8"))[ + "mcpServers" + ]["octop-memory"] + assert custom_entry["headers"]["X-Octop-Memory-Token"] == "shared-token" + assert "Authorization" not in custom_entry["headers"] + + +def test_non_ascii_user_id_is_ignored(tmp_path) -> None: + """HTTP header 只能 ASCII(Node fetch 对中文直接抛错)→ 明确忽略并提示,别装完才崩。""" + script = tmp_path / "setup.sh" + script.write_text(_script(), encoding="utf-8") + home = tmp_path / "home" + (home / ".claude").mkdir(parents=True) + result = subprocess.run( + ["sh", str(script)], + env={ + **os.environ, + "HOME": str(home), + "OCTOP_TERMINALS": "claude-code", + "OCTOP_MEMORY_TOKEN": "fake-token", + "OCTOP_AGENT_ID": "AGT1", + "OCTOP_USER_ID": "齐金龙", + "OCTOP_DISABLE_OAUTH": "true", # 直连模式才会把 X-Octop-User-Id 写进条目 + }, + capture_output=True, + text=True, + timeout=180, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "含非 ASCII" in result.stdout + config = json.loads((home / ".claude/hooks-events/config.json").read_text(encoding="utf-8")) + assert "userId" not in config + entry = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"][ + "octop-memory-local" + ] + assert "X-Octop-User-Id" not in entry["headers"] + + +def test_disable_oauth_defaults_to_oauth_and_opt_out_switches_to_direct(tmp_path) -> None: + """两种模式:**默认 OAuth**(条目不写凭据);明确不要 OAuth 用 ``OCTOP_DISABLE_OAUTH=true``。""" + local = "http://127.0.0.1:8088/mcp/memory/" + proxy = "https://octop-memory-oauth.example.com/mcp" + instance = "https://octop.example.com/mcp/memory/" + + def install(name: str, mcp_url: str, *, hook_url: str = "", flag: str = "", extra=None): + home = tmp_path / name + (home / ".claude").mkdir(parents=True) + script = tmp_path / f"{name}.sh" + script.write_text(_script(mcp_url=mcp_url, hook_url=hook_url), encoding="utf-8") + env = { + **os.environ, + "HOME": str(home), + "OCTOP_TERMINALS": "claude-code", + "OCTOP_MEMORY_TOKEN": "shared-token", + "OCTOP_AGENT_ID": "AGT1", + "OCTOP_USER_ID": "alice", + **(extra or {}), + } + if flag: + env["OCTOP_DISABLE_OAUTH"] = flag + result = subprocess.run( + ["sh", str(script)], env=env, capture_output=True, text=True, timeout=180 + ) + entry = None + cfg = home / ".claude.json" + if cfg.exists(): + servers = json.loads(cfg.read_text(encoding="utf-8"))["mcpServers"] + entry = list(servers.values())[0] if servers else None + return entry, result + + # 没声明代理地址(默认 OAuth)→ 回退直连:Authorization + agentId + userId + entry, result = install("no-proxy", local) + assert result.returncode == 0, result.stdout + result.stderr + assert entry is not None and entry["headers"] == { + "X-Octop-Agent-Id": "AGT1", + "Authorization": "Bearer shared-token", + "X-Octop-User-Id": "alice", + } + assert "未声明 OAuth 前置地址" in result.stdout + + # 显式 OCTOP_DISABLE_OAUTH=true:同样是直连 + entry, result = install("direct", local, flag="true") + assert result.returncode == 0, result.stdout + result.stderr + assert entry["headers"]["Authorization"] == "Bearer shared-token" + assert "直连 MCP token" in result.stdout + + # 声明了代理但没给个人令牌(非交互)→ 明确失败,不写一个必然 401 的条目 + entry, result = install("oauth-no-token", proxy, hook_url=instance) + assert result.returncode != 0 + assert "必须带个人令牌" in result.stderr + assert entry is None + + # 声明了代理 + 个人令牌 → OAuth 前置条目缺省带 ``Authorization: Bearer``,不写 userId + entry, result = install( + "oauth", proxy, hook_url=instance, extra={"OCTOP_ENTRY_TOKEN": "personal-token"} + ) + assert result.returncode == 0, result.stdout + result.stderr + assert entry["url"] == proxy + assert entry["headers"] == { + "X-Octop-Agent-Id": "AGT1", + "Authorization": "Bearer personal-token", + } + hooks_cfg = json.loads( + (tmp_path / "oauth/.claude/hooks-events/config.json").read_text(encoding="utf-8") + ) + assert hooks_cfg["token"] == "shared-token" # hooks 用实例共享 token,不是那把个人令牌 + assert hooks_cfg["tokenHeader"] == "Authorization" + + # 条目 header 名也可配:非 Authorization 时原样写该 header + entry, result = install( + "oauth-custom", + proxy, + hook_url=instance, + extra={ + "OCTOP_ENTRY_TOKEN": "personal-token", + "OCTOP_ENTRY_TOKEN_HEADER": "X-Octop-Memory-Token", + }, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert entry["headers"] == { + "X-Octop-Agent-Id": "AGT1", + "X-Octop-Memory-Token": "personal-token", + } + + +def test_direct_mode_ignores_the_declared_oauth_proxy_address(tmp_path) -> None: + """模式和地址配对:OAuth 走代理;直连不认识代理 → 条目落在实例自己的挂载点。""" + proxy = "https://octop-memory-oauth.example.com/mcp" + direct = "https://octop.example.com/mcp/memory/" + home = tmp_path / "home" + (home / ".claude").mkdir(parents=True) + script = tmp_path / "setup.sh" + script.write_text(_script(mcp_url=proxy, hook_url=direct), encoding="utf-8") + result = subprocess.run( + ["sh", str(script)], + env={ + **os.environ, + "HOME": str(home), + "OCTOP_TERMINALS": "claude-code", + "OCTOP_MEMORY_TOKEN": "shared-token", + "OCTOP_AGENT_ID": "AGT1", + "OCTOP_DISABLE_OAUTH": "true", + }, + capture_output=True, + text=True, + timeout=180, + ) + + assert result.returncode == 0, result.stdout + result.stderr + servers = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"] + name, entry = next(iter(servers.items())) + assert name == "octop-memory" # 名字跟着实际条目地址 + assert entry["url"] == direct # 声明了代理也不去:直连只打实例 + assert entry["headers"]["Authorization"] == "Bearer shared-token" + assert "忽略声明的代理地址" in result.stdout + + +def test_oauth_mode_without_a_declared_proxy_falls_back_to_direct(tmp_path) -> None: + """实例没声明 OAuth 前置地址 → 不再写一个没凭据的条目,直接按直连生成。""" + instance = "https://octop.example.com/mcp/memory/" + home = tmp_path / "home" + (home / ".claude").mkdir(parents=True) + script = tmp_path / "setup.sh" + script.write_text(_script(mcp_url=instance), encoding="utf-8") + result = subprocess.run( + ["sh", str(script)], + env={ + **os.environ, + "HOME": str(home), + "OCTOP_TERMINALS": "claude-code", + "OCTOP_MEMORY_TOKEN": "shared-token", + "OCTOP_AGENT_ID": "AGT1", + "OCTOP_USER_ID": "alice", + }, + capture_output=True, + text=True, + timeout=180, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "未声明 OAuth 前置地址" in result.stdout + servers = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"] + entry = next(iter(servers.values())) + assert entry["url"] == instance # 条目落在实例自己的挂载点 + assert entry["headers"] == { + "X-Octop-Agent-Id": "AGT1", + "Authorization": "Bearer shared-token", + "X-Octop-User-Id": "alice", + } + + +def test_invalid_disable_oauth_value_fails_before_writing_anything(tmp_path) -> None: + script = tmp_path / "setup.sh" + script.write_text(_script(), encoding="utf-8") + home = tmp_path / "home" + (home / ".claude").mkdir(parents=True) + result = subprocess.run( + ["sh", str(script)], + env={ + **os.environ, + "HOME": str(home), + "OCTOP_TERMINALS": "claude-code", + "OCTOP_MEMORY_TOKEN": "t", + "OCTOP_AGENT_ID": "AGT1", + "OCTOP_DISABLE_OAUTH": "maybe", + }, + capture_output=True, + text=True, + timeout=180, + ) + + assert result.returncode != 0 + assert "OCTOP_DISABLE_OAUTH 只能填 true/false" in result.stderr + assert not (home / ".claude.json").exists() + + +def test_split_urls_put_the_entry_into_oauth_mode(tmp_path) -> None: + """条目地址 ≠ hooks 地址 = 条目走 OAuth 前置:带**个人令牌**,客户端授权交给代理。 + + hooks 仍打实例(共享 token + ``X-Octop-User-Id``);条目只带代理认的个人令牌, + header 名由 ``OCTOP_ENTRY_TOKEN_HEADER`` 决定(缺省 ``Authorization``),身份由代理按 + 令牌重写,所以不写 ``X-Octop-User-Id``。 + """ + oauth = "https://octop-memory-oauth.example.com/mcp" + direct = "https://octop.example.com/mcp/memory/" + script = tmp_path / "setup.sh" + script.write_text(_script(mcp_url=oauth, hook_url=direct), encoding="utf-8") + home = tmp_path / "home" + (home / ".claude").mkdir(parents=True) + result = subprocess.run( + ["sh", str(script)], + env={ + **os.environ, + "HOME": str(home), + "OCTOP_TERMINALS": "claude-code", + "OCTOP_MEMORY_TOKEN": "shared-token", + "OCTOP_ENTRY_TOKEN": "personal-token", + "OCTOP_ENTRY_TOKEN_HEADER": "X-Octop-Memory-Token", + "OCTOP_AGENT_ID": "AGT1", + "OCTOP_USER_ID": "alice", + }, + capture_output=True, + text=True, + timeout=180, + ) + + assert result.returncode == 0, result.stdout + result.stderr + config = json.loads((home / ".claude/hooks-events/config.json").read_text(encoding="utf-8")) + assert config["url"] == direct # hooks 直连 + assert config["token"] == "shared-token" and config["userId"] == "alice" + + entry = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"][ + "octop-memory" + ] + assert entry["url"] == oauth + assert entry["headers"] == { + "X-Octop-Agent-Id": "AGT1", + "X-Octop-Memory-Token": "personal-token", + } + assert "OAuth 前置" in result.stdout + assert "hook 直连" in result.stdout + + +def test_builtin_default_token_saves_the_prompt(tmp_path) -> None: + """部署内置的默认凭据(实例发布时的 OCTOP_MEMORY_MCP_TOKEN)→ 用户只给专家 ID 就能装。""" + script = tmp_path / "setup.sh" + script.write_text(_script(default_token="octopoctop"), encoding="utf-8") + home = tmp_path / "home" + (home / ".claude").mkdir(parents=True) + result = subprocess.run( + ["sh", str(script)], + env={ + **os.environ, + "HOME": str(home), + "OCTOP_TERMINALS": "claude-code", + "OCTOP_AGENT_ID": "AGT1", # 没有 OCTOP_MEMORY_TOKEN / OCTOP_USER_ID + }, + capture_output=True, + text=True, + timeout=180, + ) + + assert result.returncode == 0, result.stdout + result.stderr + config = json.loads((home / ".claude/hooks-events/config.json").read_text(encoding="utf-8")) + assert config["token"] == "octopoctop" + assert "user" not in config # 没给 user id 就不写 + assert "用本实例部署时配置的默认凭据" in result.stdout + + +def test_without_a_builtin_token_the_installer_still_demands_one(tmp_path) -> None: + """没内置凭据 + 无 tty + 没给环境变量 → 明确报错,不盲读 stdin。""" + script = tmp_path / "setup.sh" + script.write_text(_script(), encoding="utf-8") + home = tmp_path / "home" + (home / ".claude").mkdir(parents=True) + env = { + **os.environ, + "HOME": str(home), + "OCTOP_TERMINALS": "claude-code", + "OCTOP_AGENT_ID": "AGT1", + } + env.pop("OCTOP_MEMORY_TOKEN", None) + result = subprocess.run( + ["sh", str(script)], env=env, capture_output=True, text=True, timeout=180 + ) + + assert result.returncode != 0 + assert "缺少 token" in result.stderr + assert "DEFAULT_TOKEN=" not in _script() # 没内置值就不该有赋值 + + +def test_codex_hooks_join_the_existing_config_without_clobbering_it(tmp_path) -> None: + """~/.codex/config.toml 已有别人的 hook:只追加我们的,别的字段与信任状态原样保留。""" + + def prepare(home): + (home / ".codex").mkdir(parents=True) + config = home / ".codex/config.toml" + (config).write_text(_CODEX_EXISTING.replace("CONFIG", str(config)), encoding="utf-8") + + home = _run_terminals(tmp_path, "codex", prepare) + cfg = tomllib.loads((home / ".codex/config.toml").read_text(encoding="utf-8")) + hooks = cfg["hooks"] + + def commands(event): + return [entry["command"] for group in hooks[event] for entry in group.get("hooks", [])] + + assert cfg["model"] == "gpt-5" # 其它字段不动 + assert len(commands("PreToolUse")) == 1 and "git-ai" in commands("PreToolUse")[0] + assert len(commands("Stop")) == 2 # 别人的 + 我们的(跑了两遍也不重复) + assert any("codex/stop-capture.sh" in command for command in commands("Stop")) + assert len(commands("UserPromptSubmit")) == 1 + assert any("codex/user-prompt-recall.sh" in command for command in commands("UserPromptSubmit")) + assert list(hooks["state"]) == [f"{home}/.codex/config.toml:stop:0:0"] # 信任状态保留 + + +def test_cursor_hooks_join_the_existing_json_without_clobbering_it(tmp_path) -> None: + """~/.cursor/hooks.json 其它事件(如 git-ai 的 postToolUse)原样保留。""" + + def prepare(home): + (home / ".cursor").mkdir(parents=True) + (home / ".cursor/hooks.json").write_text( + json.dumps( + {"hooks": {"postToolUse": [{"command": "git-ai cursor"}]}, "version": 1}, indent=2 + ) + + "\n", + encoding="utf-8", + ) + + home = _run_terminals(tmp_path, "cursor", prepare) + cfg = json.loads((home / ".cursor/hooks.json").read_text(encoding="utf-8")) + + assert cfg["version"] == 1 + assert cfg["hooks"]["postToolUse"] == [{"command": "git-ai cursor"}] + assert len(cfg["hooks"]["beforeSubmitPrompt"]) == 1 # 幂等:跑两遍仍只有一条 + assert ( + "cursor/hooks/cursor/user-prompt-recall.sh" + in cfg["hooks"]["beforeSubmitPrompt"][0]["command"] + ) + assert len(cfg["hooks"]["stop"]) == 1 + assert "cursor/hooks/cursor/stop-capture.sh" in cfg["hooks"]["stop"][0]["command"] + + +def test_uninstall_script_mentions_cursor_and_needs_no_token() -> None: + script = render_uninstall_script() + assert script.startswith("#!/bin/sh") + assert "octop memory 卸载" in script + assert "OCTOP_TERMINALS" in script + assert "Cursor" in script + assert "OCTOP_MEMORY_TOKEN" not in script + assert "undo_cursor" in script or "CURSOR_DIR" in script + + +def test_uninstall_script_shell_quotes_terminals_query() -> None: + """HTTP ``terminals`` must not break out of the shell assignment.""" + script = render_uninstall_script(terminals="cursor'; curl evil.example") + assert "DEFAULT_TERMINALS='cursor'\"'\"'; curl evil.example'" in script + assert "DEFAULT_TERMINALS='cursor'; curl evil.example'" not in script + + +def test_cursor_uninstall_removes_hooks_keeps_others(tmp_path) -> None: + """setup → uninstall:octop hooks/目录清掉,别人的 hook 与无关 MCP 保留。""" + + def prepare(home): + (home / ".cursor").mkdir(parents=True) + (home / ".cursor/hooks.json").write_text( + json.dumps( + {"hooks": {"postToolUse": [{"command": "git-ai cursor"}]}, "version": 1}, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + (home / ".cursor/mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "octop-memory": {"url": "https://example/mcp"}, + "other": {"url": "https://other"}, + } + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + home = _run_terminals(tmp_path, "cursor", prepare) + assert (home / ".cursor/hooks/cursor/stop-capture.mjs").exists() + + uninstall = tmp_path / "uninstall.sh" + uninstall.write_text(render_uninstall_script(), encoding="utf-8") + env = { + **os.environ, + "HOME": str(home), + "OCTOP_TERMINALS": "cursor", + } + result = subprocess.run( + ["sh", str(uninstall)], env=env, capture_output=True, text=True, timeout=60 + ) + assert result.returncode == 0, result.stdout + result.stderr + + cfg = json.loads((home / ".cursor/hooks.json").read_text(encoding="utf-8")) + assert cfg["hooks"]["postToolUse"] == [{"command": "git-ai cursor"}] + assert "beforeSubmitPrompt" not in cfg["hooks"] + assert "stop" not in cfg["hooks"] + assert not (home / ".cursor/hooks").exists() + + mcp = json.loads((home / ".cursor/mcp.json").read_text(encoding="utf-8")) + assert "octop-memory" not in mcp["mcpServers"] + assert mcp["mcpServers"]["other"]["url"] == "https://other" + + +def _run_claude_code_installer(tmp_path, claude_json: dict, settings: dict | None = None): + """只装 Claude Code:沙箱 HOME + 预置的 .claude.json / settings.json。""" + home = tmp_path / "home" + (home / ".claude").mkdir(parents=True) + (home / ".claude.json").write_text(json.dumps(claude_json, indent=2) + "\n", encoding="utf-8") + if settings is not None: + (home / ".claude/settings.json").write_text( + json.dumps(settings, indent=2) + "\n", encoding="utf-8" + ) + script = tmp_path / "setup.sh" + script.write_text(_script(), encoding="utf-8") + result = subprocess.run( + ["sh", str(script)], + env={ + **os.environ, + "HOME": str(home), + "OCTOP_TERMINALS": "claude-code", + "OCTOP_MEMORY_TOKEN": "fake-token", + "OCTOP_AGENT_ID": "AGT1", + }, + capture_output=True, + text=True, + timeout=180, + ) + assert result.returncode == 0, result.stdout + result.stderr + return home, result + + +def test_mcp_entry_is_added_when_no_octop_server_exists(tmp_path) -> None: + home, _ = _run_claude_code_installer( + tmp_path, {"mcpServers": {"other-mcp": {"type": "http", "url": "http://other/"}}} + ) + servers = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"] + assert "octop-memory-local" in servers # 全新安装才新建条目 + assert "other-mcp" in servers # 别人的条目原样保留 + + +def test_mcp_entry_reuses_an_existing_octop_entry_with_the_same_endpoint(tmp_path) -> None: + """已有同一个端点的 octop 服务(名字可能是 prod / oauth / 自定义)→ 复用,不新增条目。""" + home, result = _run_claude_code_installer( + tmp_path, + {"mcpServers": {"octop-memory-prod": {"type": "http", "url": _URL}}}, + ) + servers = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"] + assert list(servers) == ["octop-memory-prod"] + assert "复用" in result.stdout + + +def test_mcp_entry_does_not_add_a_duplicate_for_a_different_octop_endpoint(tmp_path) -> None: + """已有别的 octop 端点时保持原样:不按服务名硬塞第二个 octop 服务。""" + home, result = _run_claude_code_installer( + tmp_path, + { + "mcpServers": { + "octop-memory-oauth": {"type": "http", "url": "https://oauth.example/mcp/"} + } + }, + ) + servers = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"] + assert list(servers) == ["octop-memory-oauth"] + assert "保持原样" in result.stdout + + +def test_legacy_mcp_tool_hook_under_another_server_name_is_replaced(tmp_path) -> None: + """旧版 mcp_tool hook 的服务名可能是 octop-memory-prod:第 5 步按工具名清掉它。""" + legacy = { + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "mcp_tool", + "server": "octop-memory-prod", + "tool": "memory_search", + "input": {"query": "${prompt}"}, + } + ] + } + ] + } + } + home, _ = _run_claude_code_installer(tmp_path, {"mcpServers": {}}, settings=legacy) + entries = json.loads((home / ".claude/settings.json").read_text(encoding="utf-8"))["hooks"][ + "UserPromptSubmit" + ] + assert len(entries) == 1 # 旧 mcp_tool 那条被替换,只剩我们新写的 + assert entries[0]["type"] == "command" # 简写形态:事件直接挂 handler + assert not any(entry.get("type") == "mcp_tool" for entry in entries) + + +@posix_only +def test_dsh_bundle_is_auto_installed_when_the_dsh_cli_exists(tmp_path) -> None: + """有 dsh CLI 时优先用它装:pnpm 加依赖 + 把 bundle 写进 profile 的 dsh.profile.bundles。 + + DSH 只按 profile 的 bundle 名单加载 bundle,所以「装了依赖但没进名单」等于没装。 + """ + home = tmp_path / "home" + profile = home / ".dsh/profiles/web" + profile.mkdir(parents=True) + (profile / "package.json").write_text( + json.dumps( + {"name": "profile-web", "dsh": {"profile": {"bundles": ["@deepseek-ai/dsh-base"]}}}, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + shim = fake_bin / "dsh" + shim.write_text('#!/bin/sh\nprintf \'%s\\n\' "$@" >> "$0.args"\n', encoding="utf-8") + shim.chmod(0o755) + + script = tmp_path / "setup.sh" + script.write_text(_script(), encoding="utf-8") + env = { + **os.environ, + "HOME": str(home), + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "OCTOP_TERMINALS": "dsh", + "OCTOP_MEMORY_TOKEN": "fake-token", + "OCTOP_AGENT_ID": "AGT1", + } + for _ in range(2): # 第二次验证幂等 + result = subprocess.run( + ["sh", str(script)], env=env, capture_output=True, text=True, timeout=180 + ) + assert result.returncode == 0, result.stdout + result.stderr + + bundle = home / ".dsh/bundles/dsh-memory-share" + calls = (fake_bin / "dsh.args").read_text(encoding="utf-8") + assert "plugin" in calls and "--profile" in calls and "web" in calls + assert str(bundle) in calls + assert not (fake_bin / "pnpm.args").exists() # 有 dsh 就不必自己调 pnpm + bundles = json.loads((profile / "package.json").read_text(encoding="utf-8"))["dsh"]["profile"][ + "bundles" + ] + assert bundles == ["@deepseek-ai/dsh-base", "octop-dsh-memory-share"] # 已存在的不重复追加 + + +@posix_only +def test_dsh_bundle_falls_back_to_pnpm_in_the_profile(tmp_path) -> None: + """源码 checkout 往往没有 dsh 命令:退回在 profile 目录直接 pnpm add,再写 bundle 名单。""" + home = tmp_path / "home" + profile = home / ".dsh/profiles/web" + profile.mkdir(parents=True) + (profile / "package.json").write_text( + json.dumps({"name": "profile-web", "dsh": {"profile": {"bundles": []}}}, indent=2) + "\n", + encoding="utf-8", + ) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + shim = fake_bin / "pnpm" + shim.write_text( + '#!/bin/sh\nprintf \'%s\\n\' "$PWD" >> "$0.cwd"\nprintf \'%s\\n\' "$@" >> "$0.args"\n', + encoding="utf-8", + ) + shim.chmod(0o755) + + script = tmp_path / "setup.sh" + script.write_text(_script(), encoding="utf-8") + # 保留 shell 自己要用的 /bin、/usr/bin 与解释器目录,但不含任何 dsh 命令 + path_dirs = [str(fake_bin), "/bin", "/usr/bin"] + path_dirs += [ + str(Path(which).parent) + for which in (shutil.which("python3"), shutil.which("node")) + if which + ] + path = os.pathsep.join(dict.fromkeys(path_dirs)) + assert shutil.which("dsh", path=path) is None, "测试环境里 dsh 必须在 PATH 之外" + env = { + **os.environ, + "HOME": str(home), + "PATH": path, + "OCTOP_TERMINALS": "dsh", + "OCTOP_MEMORY_TOKEN": "fake-token", + "OCTOP_AGENT_ID": "AGT1", + } + result = subprocess.run( + ["sh", str(script)], env=env, capture_output=True, text=True, timeout=180 + ) + + assert result.returncode == 0, result.stdout + result.stderr + bundle = home / ".dsh/bundles/dsh-memory-share" + assert (fake_bin / "pnpm.args").read_text(encoding="utf-8").splitlines() == [ + "add", + str(bundle), + ] + assert (fake_bin / "pnpm.cwd").read_text(encoding="utf-8").strip() == str(profile) + bundles = json.loads((profile / "package.json").read_text(encoding="utf-8"))["dsh"]["profile"][ + "bundles" + ] + assert bundles == ["octop-dsh-memory-share"] + assert "已装进 profile「web」" in result.stdout diff --git a/tests/unit/agents/test_hook_scripts.py b/tests/unit/agents/test_hook_scripts.py new file mode 100644 index 000000000..21007f2b7 --- /dev/null +++ b/tests/unit/agents/test_hook_scripts.py @@ -0,0 +1,118 @@ +"""Hook scripts are data files, but their layout and write locations are a contract.""" + +from __future__ import annotations + +from pathlib import Path + +_HOOKS = Path(__file__).parents[3] / "src" / "octop" / "infra" / "agents" / "hook_scripts" +_LIB = _HOOKS / "lib" / "octop-memory-client.mjs" +_HOSTS = { + "claude-code": ("stop-capture.mjs", "user-prompt-recall.mjs"), + "kiro": ("stop-capture.mjs",), + "dsh": ("stop-capture.mjs", "user-prompt-recall.mjs", "memory-target.mjs"), + "codex": ("stop-capture.mjs", "user-prompt-recall.mjs"), + "cursor": ("stop-capture.mjs", "user-prompt-recall.mjs"), +} + + +def test_every_host_has_its_own_entry_scripts() -> None: + """每个宿主一个入口脚本:一个宿主的改动不会碰到别人。""" + for host, names in _HOSTS.items(): + for name in names: + assert (_HOOKS / host / name).exists(), f"{host}/{name} 缺失" + + +def test_entries_do_not_detect_the_host_at_runtime() -> None: + """宿主差异体现在安装时选的脚本里,脚本内部不再做宿主嗅探。""" + for host, names in _HOSTS.items(): + if not (_HOOKS / host).exists(): + continue + for name in names: + path = _HOOKS / host / name + if not path.exists(): + continue + text = path.read_text(encoding="utf-8") + for sniff in ("findDshSessionFile", "kiroRoot", "dshRoot", "sourceOverride"): + assert sniff not in text, f"{host}/{name} 仍在运行时探测宿主({sniff})" + + +def test_transport_module_is_host_agnostic() -> None: + """共享模块只做传输(配置/鉴权/偏移/日志/压缩),不认识任何宿主。""" + text = _LIB.read_text(encoding="utf-8") + + assert "export async function capture" in text + assert "export async function callTool" in text + assert "decompressZstdContainer" in text + # 用户标识是显式传进来的(env / config.json),不是从 token 里解出来的 + assert "process.env.OCTOP_USER_ID || fromFile.userId" in text + assert '"X-Octop-User-Id"' in text + for host_word in ("claude-code", "kiro", "dsh", "codex", "cursor", "transcript_path"): + assert host_word not in text, f"共享模块不应出现宿主细节:{host_word}" + + +def test_every_entry_uses_the_shared_configured_guard() -> None: + """「有没有配好」只由共享模块的 isConfigured() 判,入口脚本别各写一套。""" + assert "export function isConfigured" in _LIB.read_text(encoding="utf-8") + for host, names in _HOSTS.items(): + for name in names: + text = (_HOOKS / host / name).read_text(encoding="utf-8") + if "not configured" not in text: + continue + assert "isConfigured(config)" in text, f"{host}/{name} 没用共享守卫" + assert "!config.token" not in text, f"{host}/{name} 自己拼了 token 判断" + + +def test_scratch_dir_prefers_the_hook_directory() -> None: + """日志/增量状态默认写 hooks 目录,不可写才退到临时目录。""" + text = _LIB.read_text(encoding="utf-8") + assert "for (const dir of [HOOK_DIR," in text + assert "${scratchDir}/octop-hook.log" in text + + +def test_falling_back_to_tmp_is_announced() -> None: + """DSH 的 workspace-write 沙盒里 $HOME 只读,回退必须可见而不是静默。""" + text = _LIB.read_text(encoding="utf-8") + assert "if (scratchDir !== HOOK_DIR)" in text + assert "不可写,日志与增量状态改写到" in text + + +def test_claude_code_capture_defers_to_a_detached_worker() -> None: + """Stop 时本轮还没落盘:后台脱离 + 轮询到「最后一条 user 之后有 assistant」。""" + text = (_HOOKS / "claude-code" / "stop-capture.mjs").read_text(encoding="utf-8") + + assert "OCTOP_CAPTURE_DELAY_MS" in text and "const IS_WORKER" in text + assert 'spawn(process.execPath, [fileURLToPath(import.meta.url), "--worker"]' in text + assert "detached: true" in text and "worker.unref()" in text + assert "OCTOP_HOOK_PAYLOAD_B64" in text + assert "turnLanded" in text + assert '"cc:"' in text or 'sessionPrefix: "cc:"' in text + + +def test_kiro_and_dsh_capture_are_self_contained() -> None: + """Kiro 只认 messages.jsonl;DSH 只认 session*.jsonl.zstd 且自带解压。""" + kiro = (_HOOKS / "kiro" / "stop-capture.mjs").read_text(encoding="utf-8") + dsh = (_HOOKS / "dsh" / "stop-capture.mjs").read_text(encoding="utf-8") + + assert "messages.jsonl" in kiro and '"kiro:"' in kiro + assert "/.kiro/sessions" in kiro + assert "decompressZstdContainer" in dsh and '"dsh:"' in dsh + assert "/.dsh/sessions" in dsh and "session.*\\.jsonl\\.zstd" in dsh + + +def test_dsh_recall_answers_in_the_bridge_dialect() -> None: + """DSH 桥只从 hookSpecificOutput.additionalContext 取上下文:纯 stdout 会被丢弃。""" + text = (_HOOKS / "dsh" / "user-prompt-recall.mjs").read_text(encoding="utf-8") + + assert "hookSpecificOutput" in text + assert 'hookEventName: "UserPromptSubmit"' in text + assert "additionalContext" in text + + +def test_dsh_capture_probes_the_endpoint_before_sending_turns() -> None: + """能分开角色就按轮上报(content + assistant),否则退回服务端解析的 transcript 模式。""" + text = (_HOOKS / "dsh" / "stop-capture.mjs").read_text(encoding="utf-8") + + assert "supportsAssistant" in text and "captureTurns" in text + assert "OCTOP_CAPTURE_MODE" in text and "OCTOP_CAPTURE_MAX_TURNS" in text + assert 'kind && kind !== "user"' in text # 插件注入/系统提醒不算用户提问 + assert 'source: "dsh"' in text diff --git a/tests/unit/agents/test_memory_session_archive.py b/tests/unit/agents/test_memory_session_archive.py new file mode 100644 index 000000000..e30b8e385 --- /dev/null +++ b/tests/unit/agents/test_memory_session_archive.py @@ -0,0 +1,139 @@ +"""The raw-session markdown archive: L0 keeps messages, the archive keeps everything.""" + +from __future__ import annotations + +import json + +from octop.infra.agents.memory_session_archive import ( + _MAX_BODY_CHARS, + append_session, + archive_path, +) + + +def _jsonl(records: list[dict]) -> str: + return "\n".join(json.dumps(record, ensure_ascii=False) for record in records) + + +def _user(uuid: str, text: str = "跑一下测试", session: str = "s1") -> dict: + return { + "type": "user", + "uuid": uuid, + "sessionId": session, + "timestamp": "2026-09-29T02:00:00Z", + "message": {"role": "user", "content": text}, + } + + +def _assistant(uuid: str, blocks: list[dict], session: str = "s1") -> dict: + return { + "type": "assistant", + "uuid": uuid, + "sessionId": session, + "timestamp": "2026-09-29T02:00:05Z", + "message": {"role": "assistant", "content": blocks}, + } + + +def test_archive_renders_every_block_not_just_messages(tmp_path): + """L0 会丢 thinking / tool 事件,归档必须都留着。""" + text = _jsonl( + [ + _user("u1"), + _assistant( + "a1", + [ + {"type": "thinking", "thinking": "先想一下"}, + {"type": "text", "text": "我跑一下"}, + { + "type": "tool_use", + "id": "t1", + "name": "Bash", + "input": {"command": "pytest -q"}, + }, + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "1 failed", + }, + ], + ), + ] + ) + + path = append_session( + tmp_path, session_id="cc:s1", text=text, host="claude-code", user="alice" + ) + + assert path == archive_path(tmp_path, "cc:s1") + assert path.name == "cc_s1.md" # 文件名净化 + body = path.read_text(encoding="utf-8") + assert "# claude-code session cc:s1" in body + assert "- user: alice" in body + assert "跑一下测试" in body + assert "*(thinking)*" in body and "先想一下" in body + assert "**tool_call · Bash**" in body and '"command": "pytest -q"' in body + assert "**tool_result" in body and "1 failed" in body + assert body.count("") == 1 + assert after_delta.count("") == 1 + assert after_delta.count("# claude-code session cc:s2") == 1 # 头只写一次 + + +def test_archive_keeps_long_tool_output_beyond_the_l0_cap(tmp_path): + """归档给人看:单条上限比 L0 的 4000 宽松得多。""" + body_text = "line\n" * 2000 # ~10KB,超过 L0 的 4000 + text = _jsonl( + [ + _user("u1"), + _assistant( + "a1", + [{"type": "tool_result", "tool_use_id": "t1", "content": body_text}], + ), + ] + ) + + path = append_session(tmp_path, session_id="cc:s3", text=text, host="claude-code") + body = path.read_text(encoding="utf-8") + + assert body.count("line") >= 1500 # 远多于 4000 字符 + assert len(body) <= _MAX_BODY_CHARS * 2 # 仍然有界 + + +def test_archive_skips_non_records_and_keeps_thinking_optional(tmp_path): + """非 user/assistant 记录(mode / cost-state / attachment)不进归档。""" + text = _jsonl( + [ + {"type": "mode", "mode": "acceptEdits"}, + _user("u1", text="问了"), + {"type": "cost-state", "total": 1}, + ] + ) + + path = append_session(tmp_path, session_id="cc:s4", text=text, host="claude-code") + body = path.read_text(encoding="utf-8") + + assert "问了" in body + assert "acceptEdits" not in body + assert body.count("" in body + + # 整份重发(offset 丢失后的 mode=full):不重复追加 + again = _tools(mcp)["memory_capture"].fn( + content=_TOOL_TRANSCRIPT, source="claude-code", session_id="cc:s5" + ) + assert again["session_archive"] is None + assert archive.read_text(encoding="utf-8") == body + + +def test_memory_capture_dry_run_writes_no_archive(fake_memory, bind_agent, monkeypatch, tmp_path): + """dry_run 只解析:不落 L0,也不写归档。""" + monkeypatch.setattr(mm, "_resolve_agent_scope", lambda server, agent_id: (tmp_path, {})) + mcp = mm.build_memory_mcp(mock.MagicMock()) + + result = _tools(mcp)["memory_capture"].fn( + content=_TOOL_TRANSCRIPT, source="claude-code", session_id="cc:s6", dry_run=True + ) + + assert result["session_archive"] is None + assert not (tmp_path / "memory-sessions").exists() + + +def test_memory_capture_rejects_unknown_tool_events(fake_memory, bind_agent): + mcp = mm.build_memory_mcp(mock.MagicMock()) + with pytest.raises(ValueError, match="unknown tool_events"): + _tools(mcp)["memory_capture"].fn( + content=_TOOL_TRANSCRIPT, source="claude-code", tool_events="whatever" + ) + + +def test_memory_capture_transcript_drops_host_plumbing(fake_memory, bind_agent, monkeypatch): + """Terminal-injected instructions and bundled skill docs are not conversation. + + Slash-command wrappers and local-command output stay: those record a user action + and its effect. + """ + monkeypatch.setattr(mm, "_trigger_extract", lambda server, session_id: True) + mcp = mm.build_memory_mcp(mock.MagicMock()) + records = [ + { + "type": "user", + "uuid": "u1", + "sessionId": "s6", + "timestamp": "2026-09-22T05:00:00Z", + "message": {"role": "user", "content": "正常提问"}, + }, + { + "type": "user", + "uuid": "u2", + "sessionId": "s6", + "timestamp": "2026-09-22T05:00:01Z", + "message": { + "role": "user", + "content": "技能目录已更新", + }, + }, + { + "type": "user", + "uuid": "u3", + "sessionId": "s6", + "timestamp": "2026-09-22T05:00:02Z", + "message": { + "role": "user", + "content": "Base directory for this skill: /tmp/x\n# Building LLM apps", + }, + }, + { + "type": "user", + "uuid": "u4", + "sessionId": "s6", + "timestamp": "2026-09-22T05:00:03Z", + "message": { + "role": "user", + "content": "/modelmodel", + }, + }, + { + "type": "assistant", + "uuid": "a1", + "sessionId": "s6", + "timestamp": "2026-09-22T05:00:04Z", + "message": { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "t1", "name": "Read", "input": {"file_path": "x"}} + ], + }, + }, + { + "type": "user", + "uuid": "u5", + "sessionId": "s6", + "timestamp": "2026-09-22T05:00:05Z", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "1→Base directory for this skill: /tmp/x", + "is_error": False, + } + ], + }, + }, + ] + + result = _tools(mcp)["memory_capture"].fn( + content="\n".join(json.dumps(record, ensure_ascii=False) for record in records), + source="claude-code", + session_id="cc:s6", + tool_events="all", + ) + + assert result["parsed"] == 6 + assert result["skipped_echo"] == 2 # system-reminder + skill 文档(都是消息事件) + assert result["imported"] == 4 # 真实提问 / 斜杠命令 / tool_call / tool_result + raws = fake_memory.add_raw_batch.call_args.args[0] + contents = [raw.content for raw in raws] + assert any("正常提问" in c for c in contents) + assert any("" in c for c in contents) + assert all("system-reminder" not in c for c in contents) + # 工具输出里出现同样字符串属于数据(读到的文件刚好含这句),不能当作注入丢弃 + assert any("Base directory for this skill" in c for c in contents) + + +def test_memory_capture_transcript_drops_own_hook_text(fake_memory, bind_agent, monkeypatch): + """Our own hook instruction is prompt plumbing, not something the user said.""" + monkeypatch.setattr(mm, "_trigger_extract", lambda server, session_id: True) + mcp = mm.build_memory_mcp(mock.MagicMock()) + records = [ + { + "type": "user", + "uuid": "u1", + "sessionId": "s7", + "timestamp": "2026-09-22T05:00:00Z", + "message": {"role": "user", "content": "AI预定是哪个项目?"}, + }, + { + "type": "user", + "uuid": "u2", + "sessionId": "s7", + "timestamp": "2026-09-22T05:00:01Z", + "message": { + "role": "user", + "content": "Stop hook feedback:\n[octop-memory hook] 本轮对话结束前,请把要点记录下来", + }, + }, + ] + + result = _tools(mcp)["memory_capture"].fn( + content="\n".join(json.dumps(record, ensure_ascii=False) for record in records), + source="claude-code", + session_id="cc:s7", + ) + + assert result["parsed"] == 2 + assert result["skipped_echo"] == 1 + assert result["imported"] == 1 + raws = fake_memory.add_raw_batch.call_args.args[0] + assert all("octop-memory hook" not in raw.content for raw in raws) + + +def test_memory_capture_transcript_rejects_broken_gzip_marker(fake_memory, bind_agent): + """``H4sI…`` says gzip+base64; if it does not decode, say so instead of storing junk.""" + mcp = mm.build_memory_mcp(mock.MagicMock()) + with pytest.raises(ValueError, match="gzip\\+base64"): + _tools(mcp)["memory_capture"].fn(content="H4sI!!!not-base64", source="claude-code") + + +def test_memory_capture_text_source_ignores_gzip_sniffing(fake_memory, bind_agent): + """A plain-text capture that happens to look base64-ish must stay verbatim.""" + mcp = mm.build_memory_mcp(mock.MagicMock()) + _tools(mcp)["memory_capture"].fn(content="H4sI 是一段普通笔记", source="review-bot") + assert fake_memory.add_raw.call_args.args[0] == "H4sI 是一段普通笔记" + + +def test_memory_save_can_supersede_an_atom(fake_memory, bind_agent): + """Correcting a fact = save the new one and name what it replaces.""" + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_save"].fn( + content="new fact", source="review-bot", supersedes="atom1" + ) + fake_memory.deprecate_atom.assert_called_once_with( + "atom1", actor="user", note="superseded by memory_save" ) - fake_memory.deprecate_atom.assert_called_once_with("atom1", actor="user", note="mcp update") assert fake_memory.store.call_args.kwargs["metadata"] == { "source": "review-bot", "supersedes": "atom1", } assert result["deprecated"] is True + assert result["superseded_atom_id"] == "atom1" + + +def test_memory_save_without_supersedes_does_not_deprecate(fake_memory, bind_agent): + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_save"].fn(content="plain fact", source="review-bot") + fake_memory.deprecate_atom.assert_not_called() + assert fake_memory.store.call_args.kwargs["metadata"] == {"source": "review-bot"} + assert "superseded_atom_id" not in result def _asgi_scope(headers: list[tuple[bytes, bytes]] | None = None) -> dict: @@ -335,45 +975,105 @@ def test_trigger_extract_no_session_returns_false(): assert mm._trigger_extract(mock.MagicMock(), None) is False -def test_trigger_extract_no_service_returns_false(): - """Agent without memory runtime/service -> silently skipped.""" +def test_trigger_extract_no_service_warns_after_start(monkeypatch, caplog): + """Stopped / memory-less agent: schedule wake+extract, warn if still no service.""" + import asyncio + import logging + agent = mock.MagicMock() - runtime = mock.MagicMock() - runtime.service = None - agent._memory_runtime = runtime - registry = mock.MagicMock(get_agent=lambda aid: agent) + agent._memory_runtime = mock.MagicMock(service=None) + registry = mock.MagicMock() + registry.get_agent.return_value = agent + + async def _start(_aid: str) -> None: + return None + + registry.start = _start server = mock.MagicMock() server.app_runtime.agent_registry = registry + created: list = [] + + def _capture(coro): + created.append(coro) + return mock.MagicMock() + + monkeypatch.setattr(asyncio, "create_task", _capture) token = mm._current_agent_id.set("A1") try: - assert mm._trigger_extract(server, "kiro-chat") is False + with caplog.at_level(logging.WARNING, logger="octop.infra.agents.memory_mcp"): + assert mm._trigger_extract(server, "kiro-chat") is True + assert len(created) == 1 + asyncio.run(created[0]) finally: mm._current_agent_id.reset(token) + assert "MemoryService still unavailable" in caplog.text + + +def test_trigger_extract_starts_stopped_agent(monkeypatch): + """When get_agent fails (stopped), start the expert then extract.""" + import asyncio + + service = mock.MagicMock() + service.extract.return_value = {"candidates": 1} + agent = mock.MagicMock() + agent._memory_runtime = mock.MagicMock(service=service) + registry = mock.MagicMock() + registry.get_agent.side_effect = [ + RuntimeError("not running"), + agent, + ] + registry.start = mock.AsyncMock() + server = mock.MagicMock() + server.app_runtime.agent_registry = registry + created: list = [] + + def _capture(coro): + created.append(coro) + return mock.MagicMock() + + monkeypatch.setattr(asyncio, "create_task", _capture) + token = mm._current_agent_id.set("A1") + try: + assert mm._trigger_extract(server, "cursor:sess") is True + assert len(created) == 1 + asyncio.run(created[0]) + finally: + mm._current_agent_id.reset(token) + registry.start.assert_awaited_once_with("A1") + assert registry.get_agent.call_count == 2 + service.extract.assert_called_once_with( + "cursor:sess", incremental=True, promote=True, regen_pages=True + ) def test_trigger_extract_schedules_service(monkeypatch): """With a service, asynchronously schedule extract and return True.""" import asyncio - import time agent = mock.MagicMock() service = mock.MagicMock() + service.extract.return_value = {"candidates": 0} runtime = mock.MagicMock() runtime.service = service agent._memory_runtime = runtime registry = mock.MagicMock(get_agent=lambda aid: agent) server = mock.MagicMock() server.app_runtime.agent_registry = registry + created: list = [] + + def _capture(coro): + created.append(coro) + return mock.MagicMock() - async def _run(): - return mm._trigger_extract(server, "kiro-chat") + monkeypatch.setattr(asyncio, "create_task", _capture) token = mm._current_agent_id.set("A1") try: - assert asyncio.run(_run()) is True + assert mm._trigger_extract(server, "kiro-chat") is True + assert len(created) == 1 + asyncio.run(created[0]) finally: mm._current_agent_id.reset(token) - time.sleep(0.1) service.extract.assert_called() assert service.extract.call_args.args[0] == "kiro-chat" @@ -407,15 +1107,184 @@ def test_memory_candidates_not_a_status_raises(fake_memory): mm._current_agent_id.reset(token) -def test_memory_reject_passes_literal_status(fake_memory): +class _FakeBridge: + """Stand-in for ``octop_memory``'s ``Bridge``, recording dispatched RPCs.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict]] = [] + self.results: dict[str, dict] = {} + self.errors: dict[str, str] = {} + + def handle(self, request): + method, params = request["method"], request["params"] + self.calls.append((method, params)) + key = params.get("candidate_id") + if key in self.errors: + return { + "jsonrpc": "2.0", + "id": request["id"], + "error": {"code": -32602, "message": self.errors[key]}, + } + return {"jsonrpc": "2.0", "id": request["id"], "result": self.results.get(key, {})} + + +@pytest.fixture +def fake_bridge(monkeypatch): + """Replace the dashboard bridge so review tools need no real Memory.""" + bridge = _FakeBridge() + monkeypatch.setattr(mm, "_dashboard_bridge", lambda memory: bridge) + return bridge + + +_PROMOTED = { + "promoted": 1, + "merged": 0, + "conflicts": 0, + "needs_review": 0, + "dropped": 0, + "llm_calls": 0, +} + + +def test_memory_promote_delegates_to_dashboard_rpc(fake_memory, fake_bridge): + """采纳走 dashboard 的 ``promote_candidate``:同参数、同返回、逐条聚合。""" + fake_bridge.results["c1"] = _PROMOTED + fake_bridge.results["c2"] = {**_PROMOTED, "promoted": 0, "merged": 1} + mcp = mm.build_memory_mcp(mock.MagicMock()) + token = _with_agent("A1") + try: + result = _tools(mcp)["memory_promote"].fn(candidate_ids=["c1", "c2"]) + finally: + mm._current_agent_id.reset(token) + assert fake_bridge.calls == [ + ("promote_candidate", {"candidate_id": "c1"}), + ("promote_candidate", {"candidate_id": "c2"}), + ] + assert result["promoted"] == 1 + assert result["merged"] == 1 + assert [c["candidate_id"] for c in result["candidates"]] == ["c1", "c2"] + assert result["errors"] == [] + + +def test_memory_promote_isolates_per_candidate_errors(fake_memory, fake_bridge): + """单条被 dashboard 拒绝(如非 pending)不影响同批其它候选。""" + fake_bridge.results["c1"] = _PROMOTED + fake_bridge.errors["c2"] = "candidate 'c2' not pending (current status='promoted')" + mcp = mm.build_memory_mcp(mock.MagicMock()) + token = _with_agent("A1") + try: + result = _tools(mcp)["memory_promote"].fn(candidate_ids=["c1", "c2"]) + finally: + mm._current_agent_id.reset(token) + assert result["promoted"] == 1 + assert [c["candidate_id"] for c in result["candidates"]] == ["c1"] + assert result["errors"] == [ + {"candidate_id": "c2", "error": "candidate 'c2' not pending (current status='promoted')"} + ] + + +def test_memory_reject_delegates_to_dashboard_rpc(fake_memory, fake_bridge): + """忽略走 dashboard 的 ``reject_candidate``;缺省actor与面板一致不传。""" + fake_bridge.results["c1"] = {"candidate_id": "c1", "status": "rejected"} mcp = mm.build_memory_mcp(mock.MagicMock()) token = _with_agent("A1") try: result = _tools(mcp)["memory_reject"].fn(candidate_id="c1", reason="dup") finally: mm._current_agent_id.reset(token) - assert result["status"] == "rejected" - assert fake_memory.update_candidate_status.call_args.kwargs["status"] == "rejected" + assert fake_bridge.calls == [("reject_candidate", {"candidate_id": "c1", "reason": "dup"})] + assert result == {"ok": True, "candidate_id": "c1", "status": "rejected"} + + +def test_memory_reject_passes_actor_when_given(fake_memory, fake_bridge): + mcp = mm.build_memory_mcp(mock.MagicMock()) + token = _with_agent("A1") + try: + _tools(mcp)["memory_reject"].fn(candidate_id="c1", reason="dup", actor="auto") + finally: + mm._current_agent_id.reset(token) + assert fake_bridge.calls == [ + ("reject_candidate", {"candidate_id": "c1", "reason": "dup", "actor": "auto"}) + ] + + +def test_memory_reject_surfaces_bridge_error(fake_memory, fake_bridge): + fake_bridge.errors["c1"] = "candidate 'c1' cannot be rejected (current status='promoted')" + mcp = mm.build_memory_mcp(mock.MagicMock()) + token = _with_agent("A1") + try: + with pytest.raises(ValueError, match="cannot be rejected"): + _tools(mcp)["memory_reject"].fn(candidate_id="c1") + finally: + mm._current_agent_id.reset(token) + + +def test_memory_promote_end_to_end_through_real_bridge(tmp_path, monkeypatch): + """真实 harness-memory + Bridge:pending 候选经 MCP 采纳后落成原子。 + + 这是本次改动的核心契约——MCP 与记忆面板共用 dashboard_data,而不是各自 + 一份实现(旧实现直接调 ``Memory.promote_candidates``,对 conflict 候选无效)。 + """ + pytest.importorskip("octop_memory.adapters.bridge.handlers") + from octop_memory.core import Memory # noqa: PLC0415 + from octop_memory.types import Candidate, RawEvent # noqa: PLC0415 + + memory = Memory( + namespace="agent_a1", + backend="sqlite", + backend_config={"db_path": str(tmp_path / "memory.sqlite")}, + ) + memory.add_raw_batch( + [ + RawEvent( + id="raw-1", + host="octop", + session_id="s1", + thread_id="t1", + user="u", + timestamp=datetime.now(UTC), + event_type="user_message", + content="我喜欢喝美式咖啡", + ) + ] + ) + memory.add_candidate( + Candidate( + id="cand-1", + raw_event_ids=["raw-1"], + candidate_type="Preference", + status="pending", + title="美式咖啡偏好", + assertion="喜欢喝美式咖啡", + verbatim_quote="我喜欢喝美式咖啡", + quote_event_id="raw-1", + subject_name="user", + subject_entity_type="User", + target_entity_id=None, + confidence="high", + importance="high", + recommended_action="promote", + promotion_reason="seed", + extractor_version="test", + created_at=datetime.now(UTC), + ) + ) + monkeypatch.setattr(mm, "_open_memory", lambda server, agent_id: memory) + mcp = mm.build_memory_mcp(mock.MagicMock()) + token = _with_agent("A1") + try: + result = _tools(mcp)["memory_promote"].fn(candidate_ids=["cand-1"]) + again = _tools(mcp)["memory_promote"].fn(candidate_ids=["cand-1"]) + finally: + mm._current_agent_id.reset(token) + assert result["errors"] == [] + assert result["promoted"] == 1 + assert memory.get_candidate("cand-1").status == "promoted" + assert [a.assertion for a in memory.list_atoms()] == ["喜欢喝美式咖啡"] + # 重复采纳被 dashboard 的终态守卫挡下,且报错文本与面板一致(含当前状态)。 + assert again["promoted"] == 0 + assert len(again["errors"]) == 1 + assert "status=" in again["errors"][0]["error"] def _stub_runtime(monkeypatch, *, hits=None, get_result=None, captured=None): @@ -441,6 +1310,28 @@ def memory_get(self, params): monkeypatch.setattr(_runtime, "MemoryRuntime", _FakeRuntime) +def test_tool_responses_carry_the_family_marker(fake_memory, monkeypatch, bind_agent): + """One prefix (``[octop memory ``) lets any consumer filter all of our output.""" + _stub_runtime(monkeypatch, hits=[], get_result={"path": "atom/a1.md", "excerpt": ""}) + mcp = mm.build_memory_mcp(mock.MagicMock()) + tools = _tools(mcp) + + assert tools["memory_search"].fn(query="q")["marker"] == "[octop memory search]" + assert tools["memory_get"].fn(path="atom/a1.md")["marker"] == "[octop memory get]" + assert tools["memory_capture"].fn(content="x", source="s")["marker"] == "[octop memory capture]" + + +def test_memory_capture_drops_family_marker_content(fake_memory, bind_agent): + """A ``[octop memory …]`` payload is our own output, not something the user said.""" + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_capture"].fn( + content="[octop memory search] related memories: - [atom] 旧内容", source="hook" + ) + assert result["recorded"] is False + assert result["skipped"] == "recall_echo" + fake_memory.add_raw.assert_not_called() + + def test_memory_search_projects_hit_paths(fake_memory, monkeypatch, bind_agent): """memory_search runs the shared pipeline and returns path-carrying hits.""" captured = {} @@ -497,6 +1388,82 @@ def test_memory_search_raw_corpus_uses_l0_fts(fake_memory, bind_agent): assert result["hits"][0]["layer"] == "raw" +def test_memory_search_prefers_the_callers_own_hits( + fake_memory, monkeypatch, bind_agent, bind_user +): + """带了调用者就先把「这个人的」命中排前面:只调顺序,不过滤团队共享内容。""" + fake_memory.get_raw_events_by_ids.return_value = [mock.MagicMock(id="r1", user="alice")] + captured = {} + _stub_runtime( + monkeypatch, + hits=[ + {"path": "atom/a1.md", "layer": "atom", "snippet": "别人说的", "source_id": "a1"}, + { + "path": "raw/2026-08-19/r1.md", + "layer": "raw", + "snippet": "我说的", + "source_id": "r1", + }, + { + "path": "atom/a2.md", + "layer": "atom", + "snippet": "alice说:我的坑", + "source_id": "a2", + }, + ], + captured=captured, + ) + mcp = mm.build_memory_mcp(mock.MagicMock()) + + result = _tools(mcp)["memory_search"].fn(query="q", max_results=2) + + assert captured["search_params"]["maxResults"] == 8 # 带 caller 先放大候选池 + assert [hit["path"] for hit in result["hits"]] == ["raw/2026-08-19/r1.md", "atom/a2.md"] + assert result["caller"] == "alice" + assert result["personal_hits"] == 2 + assert all(hit["personal"] for hit in result["hits"]) + assert result["hits"][0]["user"] == "alice" # raw 命中带上 raw 的 user 列 + + +def test_memory_search_without_caller_keeps_the_pipeline_order( + fake_memory, monkeypatch, bind_agent +): + """没有调用者:不放大候选池、不加 personal 字段、顺序原样。""" + captured = {} + _stub_runtime( + monkeypatch, + hits=[{"path": "atom/a1.md", "layer": "atom", "snippet": "x", "source_id": "a1"}], + captured=captured, + ) + mcp = mm.build_memory_mcp(mock.MagicMock()) + + result = _tools(mcp)["memory_search"].fn(query="q", max_results=3) + + assert captured["search_params"]["maxResults"] == 3 + assert result["caller"] is None + assert result["personal_hits"] == 0 + assert "personal" not in result["hits"][0] + + +def test_memory_search_raw_corpus_prefers_the_callers_events(fake_memory, bind_agent, bind_user): + """``corpus=raw`` 同样按 raw 的 user 列把人自己的行排前面。""" + from datetime import datetime + + other, mine = mock.MagicMock(), mock.MagicMock() + for event, uid, event_id in ((other, "someone-else", "r1"), (mine, "alice", "r2")): + event.id, event.user, event.content = event_id, uid, "内容" + event.timestamp = datetime(2026, 8, 19, 12, 0) + fake_memory.search_raw.return_value = [other, mine] + + mcp = mm.build_memory_mcp(mock.MagicMock()) + result = _tools(mcp)["memory_search"].fn(query="q", max_results=2, corpus="raw") + + fake_memory.search_raw.assert_called_once_with("q", limit=8) + assert [hit["source_id"] for hit in result["hits"]] == ["r2", "r1"] + assert [hit["personal"] for hit in result["hits"]] == [True, False] + assert [hit["user"] for hit in result["hits"]] == ["alice", "someone-else"] + + def test_memory_search_rejects_unknown_corpus(fake_memory, bind_agent): mcp = mm.build_memory_mcp(mock.MagicMock()) with pytest.raises(ValueError): @@ -570,11 +1537,9 @@ def test_memory_save_prefixes_sender(fake_memory, bind_agent, bind_user): assert fake_memory.store.call_args.kwargs["metadata"]["user"] == "alice" -def test_memory_update_prefixes_sender(fake_memory, bind_agent, bind_user): +def test_memory_save_supersede_prefixes_sender(fake_memory, bind_agent, bind_user): mcp = mm.build_memory_mcp(mock.MagicMock()) - _tools(mcp)["memory_update"].fn( - atom_id="a1", new_content="端点改到 /api/memory-mcp", source="s" - ) + _tools(mcp)["memory_save"].fn(content="端点改到 /api/memory-mcp", source="s", supersedes="a1") assert fake_memory.store.call_args.args[0] == "alice说:端点改到 /api/memory-mcp" @@ -608,3 +1573,275 @@ def test_memory_capture_still_records_normal_content(fake_memory, bind_agent, bi result = _tools(mcp)["memory_capture"].fn(content="接口先不要动", source="hook") assert result["recorded"] is True assert fake_memory.add_raw.call_args.args[0] == "alice说:接口先不要动" + + +def _installer_scope(query: str = "", method: str = "GET") -> dict: + return { + "type": "http", + "method": method, + "path": "/mcp/memory/setup", + "root_path": "/mcp/memory", + "query_string": query.encode(), + "headers": [(b"host", b"127.0.0.1:8088")], + } + + +async def _run_installer(scope: dict) -> tuple[list[dict], str]: + app = mm._InstallerApp(mock.MagicMock()) + sent: list[dict] = [] + + async def _send(msg): + sent.append(msg) + + await app(scope, lambda: {}, _send) + return sent, sent[1]["body"].decode() if len(sent) > 1 else "" + + +@pytest.mark.asyncio +async def test_installer_serves_the_script_without_authentication(monkeypatch): + """公共端点:只返回脚本,不要求鉴权;实例没配默认凭据时脚本里也没有凭据。""" + monkeypatch.delenv("OCTOP_MEMORY_MCP_TOKEN", raising=False) + sent, body = await _run_installer(_installer_scope("agent=AGT1&terminals=kiro")) + + assert sent[0]["status"] == 200 + assert (b"content-type", b"text/x-shellscript; charset=utf-8") in sent[0]["headers"] + assert "octop-stop-capture.mjs" in body + assert "DEFAULT_AGENT='AGT1'" in body + assert "DEFAULT_TERMINALS='kiro'" in body + assert "DEFAULT_TOKEN=" not in body + + +@pytest.mark.asyncio +async def test_installer_bakes_the_instance_token_as_the_default(monkeypatch): + """实例发布时配的 OCTOP_MEMORY_MCP_TOKEN 直接内置成默认凭据:用户只需专家 ID。""" + monkeypatch.setenv("OCTOP_MEMORY_MCP_TOKEN", "octopoctop") + _, body = await _run_installer(_installer_scope()) + + assert "DEFAULT_TOKEN='octopoctop'" in body + assert 'TOKEN="${OCTOP_MEMORY_TOKEN:-}"' in body # 现场变量仍可覆盖 + assert 'ENTRY_TOKEN="${OCTOP_ENTRY_TOKEN:-}"' in body + + +@pytest.mark.asyncio +async def test_installer_defaults_to_the_host_it_was_fetched_from(): + _, body = await _run_installer(_installer_scope()) + + assert "URL='http://127.0.0.1:8088/mcp/memory/'" in body + + +@pytest.mark.asyncio +async def test_installer_url_query_wins_over_the_default(): + _, body = await _run_installer( + _installer_scope("url=https://octop-memory-oauth.example.com/mcp") + ) + + assert "URL='https://octop-memory-oauth.example.com/mcp'" in body + assert "OCTOP_SERVER_NAME='octop-memory'" in body + + +@pytest.mark.asyncio +async def test_installer_public_url_override(monkeypatch): + """部署可通过环境变量指定"客户端该连哪个地址"(例如另一个 ingress 域名)。""" + monkeypatch.setenv("OCTOP_MEMORY_PUBLIC_MCP_URL", "https://oauth.example.com/mcp") + _, body = await _run_installer(_installer_scope()) + + assert "URL='https://oauth.example.com/mcp'" in body + + +@pytest.mark.asyncio +async def test_installer_rejects_non_get(): + sent, body = await _run_installer(_installer_scope(method="POST")) + + assert sent[0]["status"] == 405 + assert "GET only" in body + + +def _one_turn_transcript() -> str: + return json.dumps( + { + "type": "user", + "uuid": "tp1", + "sessionId": "s-tp", + "timestamp": "2026-09-22T05:00:00Z", + "message": {"role": "user", "content": "从文件读的一段会话"}, + } + ) + + +def test_memory_capture_reads_a_local_transcript_path( + fake_memory, bind_agent, tmp_path, monkeypatch +): + """mcp_tool hook 只传 ${transcript_path}:同机服务端把文件读进来。""" + monkeypatch.setattr(mm, "_TRANSCRIPT_PATH_ROOTS", (str(tmp_path),)) + session_file = tmp_path / "s-tp.jsonl" + session_file.write_text(_one_turn_transcript(), encoding="utf-8") + mcp = mm.build_memory_mcp(mock.MagicMock()) + + result = _tools(mcp)["memory_capture"].fn( + content="", source="claude-code", transcript_path=str(session_file), session_id="cc:s-tp" + ) + + assert result["parsed"] == 1 + assert result["imported"] == 1 + assert result["events_by_type"] == {"user_message": 1} + + +def test_memory_capture_refuses_paths_outside_the_session_stores(fake_memory, bind_agent, tmp_path): + """不给任意文件读取留后门:只允许终端会话目录。""" + mcp = mm.build_memory_mcp(mock.MagicMock()) + stray = tmp_path / "secrets.txt" + stray.write_text("nope", encoding="utf-8") + + with pytest.raises(ValueError, match="terminal session store"): + _tools(mcp)["memory_capture"].fn( + content="", source="claude-code", transcript_path=str(stray) + ) + + +def test_memory_capture_refuses_a_compressed_path(fake_memory, bind_agent, tmp_path, monkeypatch): + monkeypatch.setattr(mm, "_TRANSCRIPT_PATH_ROOTS", (str(tmp_path),)) + packed = tmp_path / "session.v3.jsonl.zstd" + packed.write_bytes(b"zstd-bytes") + mcp = mm.build_memory_mcp(mock.MagicMock()) + + with pytest.raises(ValueError, match=r"gzip\+base64"): + _tools(mcp)["memory_capture"].fn( + content="", source="claude-code", transcript_path=str(packed) + ) + + +@pytest.mark.asyncio +async def test_installer_defaults_to_the_deployment_config(monkeypatch, tmp_path): + """部署在 config.json 里声明一次 memory_public_mcp_url,同学就不用传 ?url=。""" + monkeypatch.delenv("OCTOP_MEMORY_PUBLIC_MCP_URL", raising=False) + monkeypatch.setenv("OCTOP_HOME", str(tmp_path)) + (tmp_path / "config.json").write_text( + json.dumps({"memory_public_mcp_url": "https://octop-memory-oauth.example.com/mcp"}), + encoding="utf-8", + ) + + _, body = await _run_installer(_installer_scope()) + + assert "URL='https://octop-memory-oauth.example.com/mcp'" in body + + +@pytest.mark.asyncio +async def test_installer_env_overrides_the_config_file(monkeypatch, tmp_path): + monkeypatch.setenv("OCTOP_HOME", str(tmp_path)) + (tmp_path / "config.json").write_text( + json.dumps({"memory_public_mcp_url": "https://from-config.example.com/mcp"}), + encoding="utf-8", + ) + monkeypatch.setenv("OCTOP_MEMORY_PUBLIC_MCP_URL", "https://from-env.example.com/mcp") + + _, body = await _run_installer(_installer_scope()) + + assert "URL='https://from-env.example.com/mcp'" in body + + +_EXTERNAL_HOST = "octop.example.com" + + +@pytest.mark.parametrize("host", [_EXTERNAL_HOST, "octop.internal.example"]) +@pytest.mark.parametrize("proto", ["", "http", "https"]) +@pytest.mark.asyncio +async def test_installer_defaults_to_this_instance_for_any_host(monkeypatch, host, proto): + """没有显式覆盖时,条目与 hooks 都指向本实例挂载点;非本机一律 https。""" + monkeypatch.delenv("OCTOP_MEMORY_PUBLIC_MCP_URL", raising=False) + scope = _installer_scope() + scope["headers"] = [(b"host", host.encode())] + if proto: + scope["headers"].append((b"x-forwarded-proto", proto.encode())) + + _, body = await _run_installer(scope) + + # 精确匹配整行:只写 "URL=" 会被 "HOOK_URL=" 子串误命中 + assert f"\nURL='https://{host}/mcp/memory/'\n" in body + assert f"\nHOOK_URL='https://{host}/mcp/memory/'\n" in body + + +@pytest.mark.asyncio +async def test_installer_splits_the_client_entry_from_the_hook_target(monkeypatch): + """条目可以是自建 OAuth 代理(客户端自己授权),hooks 必须拿本实例的直连挂载点。""" + monkeypatch.setenv("OCTOP_MEMORY_PUBLIC_MCP_URL", "https://octop-memory-oauth.example.com/mcp") + scope = _installer_scope() + scope["headers"] = [ + (b"host", _EXTERNAL_HOST.encode()), + (b"x-forwarded-proto", b"https"), + ] + + _, body = await _run_installer(scope) + + assert "URL='https://octop-memory-oauth.example.com/mcp'" in body + assert f"HOOK_URL='https://{_EXTERNAL_HOST}/mcp/memory/'" in body + + +@pytest.mark.asyncio +async def test_installer_hook_url_can_be_overridden(monkeypatch): + """直连挂载点不在本机时,?hook_url= 显式指定。""" + monkeypatch.delenv("OCTOP_MEMORY_PUBLIC_MCP_URL", raising=False) + scope = _installer_scope() + scope["query_string"] = b"hook_url=https%3A%2F%2Foctop.example.com%2Fmcp%2Fmemory%2F" + + _, body = await _run_installer(scope) + + assert "HOOK_URL='https://octop.example.com/mcp/memory/'" in body + + +@pytest.mark.parametrize("proto", ["", "http", "https"]) +@pytest.mark.asyncio +async def test_installer_keeps_loopback_on_this_instance(monkeypatch, proto): + """本机开发实例指向自己的挂载点:没有 TLS 前置,照请求的 scheme 走 http。""" + monkeypatch.delenv("OCTOP_MEMORY_PUBLIC_MCP_URL", raising=False) + scope = _installer_scope() + if proto: + scope["headers"].append((b"x-forwarded-proto", proto.encode())) + + _, body = await _run_installer(scope) + + scheme = "https" if proto == "https" else "http" + assert f"URL='{scheme}://127.0.0.1:8088/mcp/memory/'" in body + + +def test_mount_serves_setup_before_the_token_check(monkeypatch): + """GET /mcp/memory/setup 免鉴权(只发脚本);同一挂载下的其它路径仍要 token。""" + from types import SimpleNamespace + + from starlette.applications import Starlette + from starlette.testclient import TestClient + + monkeypatch.setenv("OCTOP_MEMORY_MCP_TOKEN", "secret") + app = Starlette() + server = SimpleNamespace(services=SimpleNamespace(agent_repo=mock.MagicMock())) + mm.mount_memory_mcp(app, server) + client = TestClient(app) + + served = client.get("/mcp/memory/setup") + assert served.status_code == 200 + assert served.text.startswith("#!/bin/sh") + + refused = client.post("/mcp/memory/setup") + assert refused.status_code == 405 # 只支持 GET + + assert client.post("/mcp/memory/", json={}).status_code == 401 # 其它路径照旧鉴权 + + +def test_mount_serves_uninstall_before_the_token_check(monkeypatch): + """GET /mcp/memory/uninstall 同样免鉴权,只发卸载脚本。""" + from types import SimpleNamespace + + from starlette.applications import Starlette + from starlette.testclient import TestClient + + monkeypatch.setenv("OCTOP_MEMORY_MCP_TOKEN", "secret") + app = Starlette() + server = SimpleNamespace(services=SimpleNamespace(agent_repo=mock.MagicMock())) + mm.mount_memory_mcp(app, server) + client = TestClient(app) + + served = client.get("/mcp/memory/uninstall") + assert served.status_code == 200 + assert served.text.startswith("#!/bin/sh") + assert "卸载" in served.text + + assert client.post("/mcp/memory/uninstall").status_code == 405 From aa0c1930a3d06c749a852850bf3759f82fff0b65 Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Tue, 13 Oct 2026 13:25:23 +0800 Subject: [PATCH 18/21] feat(memory-mcp): drop the memory_recall tool memory_search already covers the read path (the shipped hooks inject its output), so the rendered-markdown recall tool was redundant surface. - read surface is now search / get / raws: 9 tools total - removed the tool, its docs and its tests, plus the read-side session/thread scoping it was the only carrier of - kept the write-side echo guard and the legacy transcript filter: both still match the [octop memory ...] / upstream recall markers recorded by earlier installs verified: - pytest tests/unit/agents/test_memory_{mcp,recall_patch,backend}.py + terminal integration tests -> 205 passed - pytest tests/unit/agents tests/unit/gateway/test_slash_memory.py tests/integration/test_memory_api.py -> 962 passed, 2 skipped - ruff check / ruff format --check / mypy -> clean --- docs/memory-mcp.md | 61 ++++---- src/octop/infra/agents/claude_transcript.py | 3 + src/octop/infra/agents/memory_mcp.py | 149 +++++--------------- tests/unit/agents/test_memory_mcp.py | 75 +--------- 4 files changed, 66 insertions(+), 222 deletions(-) diff --git a/docs/memory-mcp.md b/docs/memory-mcp.md index 07153bb8e..3ece22760 100644 --- a/docs/memory-mcp.md +++ b/docs/memory-mcp.md @@ -4,7 +4,7 @@ > 其他 AI 工具),能力与进程内 `MemoryService` 对齐:外部 agent 可以召回记忆、按路径下钻读全文、 > 写入新的事件与事实,并能区分"这条记忆是谁说的"。 > -> 当前工具面:**10 个 `memory_*` 工具**(读取 4 个 / 写入 2 个 / 提取与审核 4 个)。 +> 当前工具面:**9 个 `memory_*` 工具**(读取 3 个 / 写入 2 个 / 提取与审核 4 个)。 > > 本地终端(Claude Code / Kiro / DSH / Codex / Cursor)可以用服务端自带的**一键安装器** > 自动完成「MCP 条目 + hooks(+ DSH 共享记忆预设)」,见 §4.7。 @@ -48,19 +48,18 @@ --- -## 2. 工具清单(10 个) +## 2. 工具清单(9 个) -### 2.1 读取(4 个) +### 2.1 读取(3 个) | 工具 | 参数 | 说明 | |---|---|---| -| `memory_recall` | `query: str`, `limit: int = 5`, `session_id?: str`, `thread_id?: str`, `user?: str` | **读入口首选**。跑完整召回管线(分词 → 路由 → FTS → 重排 → 去重),返回结构化片段 + 可直接注入 system prompt 的 markdown(`rendered`)。L2 原子优先,主题页标题并入 atom 命中,L0 原始事件仅兜底。**自动注入(hook)场景建议传 `session_id`**:管线会据此把本会话的 raw 排除,避免"注入 → 被记录 → 下轮又召回"的回声;`thread_id` 则启用共指消解("那个项目" 靠该线程的 active-entity stack) | -| `memory_search` | `query: str`, `max_results: int = 5`, `corpus: str = "all"` | 同一套召回管线,但**不渲染 markdown**,而是给每条命中一个虚拟 `path`,交给 `memory_get` 下钻。`corpus`:`all`/`memory`(原子+原始事件同管线)、`atom`(只要 L2 原子)、`raw`(直接走 L0 全文检索,不受"有原子命中就丢 raw"的兜底影响) | +| `memory_search` | `query: str`, `max_results: int = 5`, `corpus: str = "all"`, `user?: str` | **读入口**。跑完整召回/重排管线(分词 → 路由 → FTS → 重排 → 去重),给每条命中一个虚拟 `path`,交给 `memory_get` 下钻;只想"把背景拉进上下文"直接用命中里的 `snippet` 即可。自动注入(hook)场景用的就是它:注入块以 `[octop memory search]` 开头,`memory_capture` 按该标记丢弃回声,不会被当成新事件采回来。`corpus`:`all`/`memory`(原子+原始事件同管线)、`atom`(只要 L2 原子)、`raw`(直接走 L0 全文检索,不受"有原子命中就丢 raw"的兜底影响) | | `memory_get` | `path: str`, `start?: int`, `lines?: int` | 把命中路径解析成完整 markdown。支持 `atom/.md`、`page/.md`、`raw//.md`;长内容用 `start`/`lines` 分页(1-based)。路径非法或过期时返回 `{error, hint}` 而不是抛栈 | | `memory_raws` | `query?: str`, `session_id?: str`, `host?: str`, `user?: str`, `limit: int = 50` | **原始事件(证据源)**。`query` 走全文检索(写入后立即可见,提取前也能查),其余字段做结构化过滤,按时间倒序返回 | **按调用者偏好排序**:调用者 id 有值时(`X-Octop-User-Id` 头或显式 `user` 参数), -`memory_recall` / `memory_search` 把该调用者的命中排在最前、其他人的紧随其后——只调顺序、 +`memory_search` 把该调用者的命中排在最前、其他人的紧随其后——只调顺序、 **不过滤**(记忆是专家级共享的)。raw 命中按 raw 的 `user` 列判定,原子/主题页按正文里的 `<调用者>说:` 前缀判定;命中带 `personal` 布尔,raw 命中还带 `user`,返回体带 `caller` 与 `personal_hits`。 @@ -68,8 +67,6 @@ 返回形状: ```jsonc -// memory_recall -{ "memories": [{ "source_id", "timestamp", "layer", "text" }], "count", "rendered", "caller" } // memory_search { "hits": [{ "path", "layer", "snippet", "occurred_at", "source_id" }], "total", "corpus", "hint" } // memory_get @@ -82,16 +79,16 @@ | 工具 | 参数 | 说明 | |---|---|---| -| `memory_capture` | `content: str`, `source: str`, `session_id?: str`, `assistant?: str`, `thread_id?: str`, `dry_run?: bool`, `extract?: bool`, `tool_events?: str`, `transcript_path?: str`, `user?: str` | **记录原始事件(L0)**:交给「提取 → 候选 → 晋升」流水线,适合记录对话/事件原文。写入后立即可用 `memory_raws` / `memory_search(corpus="raw")` 查;晋升成原子后才能被 `memory_recall` 召回。`source` 既是谁记录的,也决定内容形态:`claude-code` / `kiro` / `dsh` + JSONL(`H4sI` 开头自动按 gzip+base64 解码)走该终端的 transcript 解析,整份 session 文件解析成多条事件;其它 `source` 按「一轮对话」处理,`content` 是用户侧、`assistant` 是助手侧(角色由字段隐含,别把助手产出塞进 `content`)。`tool_events` 决定 transcript 形态落哪些事件:`none`(默认)只落 user/assistant,`calls` 带工具调用(丢输出),`all` 连工具输出一起落。`dry_run` 只解析不写;`extract=False` 先只落 L0,再用 `memory_extract` 受控提取。`transcript_path` 仅供终端 `mcp_tool` hook 直传本机文件(限 `~/.claude/projects` / `~/.kiro/sessions` / `~/.dsh/sessions`;远程部署读不到,请改用 hook 上报 `content`)。`session_id` 缺省派生:一轮对话为 `ext:{source}:{user}`,transcript 为 `cc:` | +| `memory_capture` | `content: str`, `source: str`, `session_id?: str`, `assistant?: str`, `thread_id?: str`, `dry_run?: bool`, `extract?: bool`, `tool_events?: str`, `transcript_path?: str`, `user?: str` | **记录原始事件(L0)**:交给「提取 → 候选 → 晋升」流水线,适合记录对话/事件原文。写入后立即可用 `memory_raws` / `memory_search(corpus="raw")` 查;晋升成原子后才回到常规检索结果里。`source` 既是谁记录的,也决定内容形态:`claude-code` / `kiro` / `dsh` + JSONL(`H4sI` 开头自动按 gzip+base64 解码)走该终端的 transcript 解析,整份 session 文件解析成多条事件;其它 `source` 按「一轮对话」处理,`content` 是用户侧、`assistant` 是助手侧(角色由字段隐含,别把助手产出塞进 `content`)。`tool_events` 决定 transcript 形态落哪些事件:`none`(默认)只落 user/assistant,`calls` 带工具调用(丢输出),`all` 连工具输出一起落。`dry_run` 只解析不写;`extract=False` 先只落 L0,再用 `memory_extract` 受控提取。`transcript_path` 仅供终端 `mcp_tool` hook 直传本机文件(限 `~/.claude/projects` / `~/.kiro/sessions` / `~/.dsh/sessions`;远程部署读不到,请改用 hook 上报 `content`)。`session_id` 缺省派生:一轮对话为 `ext:{source}:{user}`,transcript 为 `cc:` | | `memory_save` | `content: str`, `source: str`, `topic?: str`, `supersedes?: str`, `user?: str` | **直接保存事实(L2)**:不经过提取,立即可召回。适合已知的明确事实/约定。`supersedes` 传**旧原子 id** 即完成「更正」:旧原子标记 deprecated,新事实带 `supersedes` 关联——更正事实 = 保存新事实 + 声明它取代谁,不需要单独的更新工具 | `memory_capture` 是**幂等**的:同一 `session_id` + 同一内容重复写入时不会产生重复 L0 事件, 返回里带 `duplicate: true` 并复用已有 `event_id`(下游提取因此可以反复重跑)。 -它还有**回声保护**:内容里带 `memory_recall` 的注入标记(`[memory] Earlier in this workspace` / +它还有**回声保护**:内容里带记忆注入标记(`[octop memory …]` / `[memory] Earlier in this workspace` / `## Memory Recall`)时**不写入**,直接返回 `{recorded: false, skipped: "recall_echo"}`。 原因:MCP 写路径直接调 `Memory.add_raw`,绕过了 `MemoryRuntime.capture` 的 `skip_memory_echo`; -不补这层,hook 注入的召回块会被当作新事件采集,形成"注入 → 采集 → 再召回"的放大环。 +不补这层,hook 注入的记忆块会被当作新事件采集,形成"注入 → 采集 → 再召回"的放大环。 transcript 形态还有**原文归档**:上传的整份 session 会另存一份可读 markdown (`{workspace}/.octop/memory-sessions/.md`,增量追加、重发去重),返回体的 @@ -116,15 +113,15 @@ transcript 形态还有**原文归档**:上传的整份 session 会另存一 |---|---|---| | L0 | 原始事件(原话、证据) | `memory_capture` 写入;`memory_raws` / `memory_search(corpus="raw")` 读取 | | L1 | 候选(提取产物,待审核) | `memory_extract` 产生;`memory_candidates` 查看;`memory_promote` / `memory_reject` 裁决 | -| L2 | 原子(长期记忆,可被召回) | `memory_save` 直写(`supersedes` 做更正);`memory_capture` 经流水线晋升;`memory_recall` / `memory_search` 召回 | +| L2 | 原子(长期记忆,可被检索到) | `memory_save` 直写(`supersedes` 做更正);`memory_capture` 经流水线晋升;`memory_search` 检索 | | L3 | 主题页(实体页) | 随晋升重新生成;可用 `memory_get(page/.md)` 读取 | 按意图选工具: -- 只想把相关背景拉进上下文 → `memory_recall` +- 想把相关背景拉进上下文 → `memory_search`(命中里的 `snippet` 直接可用) - 要定位某条具体记忆并读全文 → `memory_search` 拿 `path`,再 `memory_get(path)` - 要原话/证据,或内容刚写入还没晋升 → `memory_raws`(或 `memory_search(corpus="raw")`) -- 记录对话/事件 → `memory_capture`;记录明确事实/规则 → `memory_save`;纠正过时事实 → `memory_update` +- 记录对话/事件 → `memory_capture`;记录明确事实/规则 → `memory_save`;纠正过时事实 → `memory_save(supersedes=<旧原子 id>)` - 处理审核队列 → `memory_candidates` → `memory_promote` / `memory_reject` --- @@ -167,10 +164,7 @@ curl -s -X POST "$BASE/" \ { "name": "memory_save", "arguments": { "content": "部署约定:记忆端点挂在 /mcp/memory", "source": "planning-agent", "topic": "octop-deploy" } } -// 召回 -{ "name": "memory_recall", "arguments": { "query": "部署约定", "limit": 5 } } - -// 检索拿 path +// 检索(拿 path 或直接读 snippet) { "name": "memory_search", "arguments": { "query": "部署约定", "corpus": "atom" } } // 读全文(path 来自上一步 hits[].path) @@ -206,7 +200,7 @@ async def main(): "memory_capture", {"content": "报告页横幅没有渲染出来", "source": "review-bot"}, ) - res = await session.call_tool("memory_recall", {"query": "报告页 横幅"}) + res = await session.call_tool("memory_search", {"query": "报告页 横幅", "corpus": "atom"}) print(res) @@ -241,8 +235,8 @@ Kiro 原生支持远程 MCP(`url` + `headers`)。配置分两级,同名条 Kiro 侧注意事项: - **远程 `url` 必须是 `https`**(只有本地回环允许 `http`)。 -- 工具名带**服务器名前缀**:服务器名 `octop-memory` → 工具 `mcp_octop_memory_memory_recall`;在 hooks 或提示词里必须写全名。 -- 工具默认不放行:在 `~/.kiro/settings/permissions.yaml` 里找到 `capability: mcp` 的 `match` 列表,按需加 `effect: allow`(只读先放 `memory_recall` / `memory_raws`,需要沉淀再放 `memory_capture` / `memory_save`)。 +- 工具名带**服务器名前缀**:服务器名 `octop-memory` → 工具 `mcp_octop_memory_memory_search`;在 hooks 或提示词里必须写全名。 +- 工具默认不放行:在 `~/.kiro/settings/permissions.yaml` 里找到 `capability: mcp` 的 `match` 列表,按需加 `effect: allow`(只读先放 `memory_search` / `memory_get` / `memory_raws`,需要沉淀再放 `memory_capture` / `memory_save`)。 - 用 `${VAR}` 引用环境变量时,要先把变量加入 Kiro 的允许列表(设置项 `Mcp Approved Env Vars`),否则不会被展开。 - 校验:会话里执行 `/mcp`,确认服务器已连接、工具已加载。 @@ -261,7 +255,7 @@ Kiro 侧注意事项: "trigger": "UserPromptSubmit", "action": { "type": "agent", - "prompt": "回答前,按需主动召回相关记忆来辅助本次工作。结合用户本次输入与当前项目上下文构造查询,调用 `mcp_octop_memory_memory_recall`。若本次输入与已召回记忆无关可跳过,不要重复召回同一查询。" + "prompt": "回答前,按需主动检索相关记忆来辅助本次工作。结合用户本次输入与当前项目上下文构造查询,调用 `mcp_octop_memory_memory_search`(corpus=atom)。若本次输入与已检索记忆无关可跳过,不要重复检索同一查询。" }, "description": "收到消息时自动召回相关记忆,提供上下文连续性。", "enabled": true @@ -324,7 +318,7 @@ claude mcp add --transport http octop-memory https:///mcp/memory/ \ Claude Code 侧注意事项: - Streamable HTTP 写 `"type": "http"`(MCP 规范名 `streamable-http` 也接受);**带 `url` 就必须带 `type`**,否则该条目会被当作 stdio 服务器跳过。 -- 工具名带前缀:`mcp__octop-memory__memory_recall`。 +- 工具名带前缀:`mcp__octop-memory__memory_search`。 - 排查:`claude mcp list`、`claude mcp get octop-memory`,会话内 `/mcp`。 #### 自动化:hooks(配好 MCP 之后必配) @@ -354,10 +348,10 @@ Claude Code 侧注意事项: ```javascript #!/usr/bin/env node -// UserPromptSubmit:把「先召回」指令注入本轮上下文;真正调用 MCP 工具的是模型。 +// UserPromptSubmit:把「先检索」指令注入本轮上下文;真正调用 MCP 工具的是模型。 process.stdout.write( - "[octop-memory] 回答前先按需召回相关记忆:结合用户输入与当前项目上下文构造查询," + - "调用 MCP 工具 mcp__octop-memory__memory_recall;与本轮无关可跳过,不要重复召回同一查询。\n", + "[octop-memory] 回答前先按需检索相关记忆:结合用户输入与当前项目上下文构造查询," + + "调用 MCP 工具 mcp__octop-memory__memory_search(corpus=atom);与本轮无关可跳过,不要重复检索同一查询。\n", ); ``` @@ -419,7 +413,7 @@ DSH 在 **Settings → MCP** 里管理 MCP 服务器(定义持久化在 `~/.ds 和 Kiro / Claude Code 要挂 hooks 一样,DSH 这边还要**新增一个「共享记忆」模式的 agent preset,并用它开会话**,自动化才会发生: -- 会话首轮:先 `memory_recall`,把相关原子记忆拉进上下文; +- 会话首轮:先 `memory_search`(`corpus=atom`),把相关原子记忆拉进上下文; - 每轮结束:若本轮产生了可沉淀的事实,调用一次 `memory_capture`(`source` 标记客户端来源、`session_id` 用工作目录派生);寒暄或已记录内容不重复写。 预设是一个目录(`~/.dsh/.agent-presets/<你的预设>/`),含 `preset.yml`(名称与描述)和 `agent.cordis.yml`(该预设的完整组合): @@ -434,9 +428,9 @@ DSH 在 **Settings → MCP** 里管理 MCP 服务器(定义持久化在 `~/.ds ```text ── 共享记忆 (shared memory) ───────────────────────────── -This preset auto-reads and auto-writes the octop-memory expert store so durable facts carry across sessions and experts: memory_recall at conversation start, memory_capture at turn end. +This preset auto-reads and auto-writes the octop-memory expert store so durable facts carry across sessions and experts: memory_search at conversation start, memory_capture at turn end. -会话开始时(本会话首轮、尚无历史):若工具列表里没有 `memory_recall`,先按需注入 octop-memory 工具,再调用 `memory_recall(query=用户当前目标全文)`,把相关原子记忆纳入上下文。 +会话开始时(本会话首轮、尚无历史):若工具列表里没有 `memory_search`,先按需注入 octop-memory 工具,再调用 `memory_search(query=用户当前目标全文, corpus=atom)`,把相关原子记忆纳入上下文。 每轮结束时(给出最终回复前):若本轮产生了可沉淀的事实(需求、决定、偏好、约定、结论、关键路径),调用一次 `memory_capture`: content: 1–3 句简洁事实摘要(不写机密、不做原始转储) @@ -526,11 +520,12 @@ curl -fsSL "https:///mcp/memory/setup" | sh - **写入分两条通道**:`memory_capture` 走"提取 → 候选 → 晋升"(学习型记忆,质量由流水线把关); `memory_save`(`supersedes` 更正)是权威直写(规则/明确事实,立即可召回)。 晋升治理(提取触发、候选审核)保留在服务端,站内会话与外部写入走**同一套**记忆治理路径。 -- **capture 之后 recall 无结果属预期**:内容还在 L0,需要经提取晋升成原子才会被召回; - 想立刻看到请用 `memory_raws` 或 `memory_search(corpus="raw")`。 +- **capture 之后常规检索没有结果属预期**:内容还在 L0,需要经提取晋升成原子才会进入 + `memory_search` 的结果;想立刻看到请用 `memory_raws` 或 `memory_search(corpus="raw")`。 - **capture 幂等**:同 `session_id` + 同内容不重复落库(见 §2.2)。 -- **召回回声有双向保护**:写入侧 `memory_capture` 丢弃带召回标记的内容(`skipped=recall_echo`); - 读取侧建议 hook 给 `memory_recall` 传与 capture 一致的 `session_id`,把本会话的 raw 排除。 +- **召回回声有写入侧保护**:`memory_capture` 丢弃带注入标记的内容(`skipped=recall_echo`)—— + 注入块以 `[octop memory search]` 开头,所以 hook 注入 → 采回 → 再检索的放大环被写侧断掉, + 不需要读侧再传 `session_id` 配合。 两侧都做,才不会出现"注入 → 采集 → 再召回"的放大环(§4.3 / §4.4 的自动召回就是这个场景)。 - **召回是专家级共享**,不做按人隔离;按发送者定位依赖正文里的 `说:` 前缀 + 全文检索。 - **错误形态**:`memory_get` 的坏路径返回 `{error, hint}`;`memory_search` 的非法 `corpus`、 diff --git a/src/octop/infra/agents/claude_transcript.py b/src/octop/infra/agents/claude_transcript.py index 413251918..4d486fe3d 100644 --- a/src/octop/infra/agents/claude_transcript.py +++ b/src/octop/infra/agents/claude_transcript.py @@ -108,6 +108,9 @@ def _dropped_tool_types(tool_events: str) -> frozenset[str]: would close the "inject → capture → recall again" loop. They are dropped at parse time — before the marker-based check — because a hook may format its own injection block and lose the marker. + +``memory_recall`` is no longer exposed by the MCP server; it stays listed so +transcripts recorded by earlier installs still get filtered. """ diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py index 2922dd9ef..0560afa86 100644 --- a/src/octop/infra/agents/memory_mcp.py +++ b/src/octop/infra/agents/memory_mcp.py @@ -14,8 +14,8 @@ * ``memory_capture`` -> ``add_raw``: writes an **L0 raw event**, which goes through the extraction pipeline (extract -> candidate -> promote -> atom). Use it to record raw conversations / events. The record is visible - immediately via ``memory_raws``; ``memory_recall`` returns it only - after extraction promotes it to an atom. + immediately via ``memory_raws`` / ``memory_search(corpus="raw")``; the + regular search returns it only after extraction promotes it to an atom. * ``memory_save`` -> ``store``: persists a structured fact directly into the canonical atom/tree (durable, no extraction). Use it when you already know the exact fact to remember. @@ -23,26 +23,24 @@ Read surface (three tools, same storage as the in-process ``memory_search`` / ``memory_get`` exposed to Octop's own agents): -* ``memory_recall`` -> ``recall_for_prompt``: ranked, prompt-injectable text. - L2 atoms first; ``page`` headlines are folded into atom hits; L0 raw is only - a fallback (dropped as soon as any atom matches). Takes ``session_id`` / - ``thread_id`` so an auto-inject hook can exclude the current session's raw and - use the thread's active-entity stack for co-reference. * ``memory_search`` -> ``MemoryRuntime.memory_search`` (``corpus=raw`` uses - ``Memory.search_raw``): the same ranking, returned as hits that carry a - virtual ``path`` instead of rendered markdown. + ``Memory.search_raw``): ranked hits that carry a virtual ``path`` instead of + rendered markdown. L2 atoms first; ``page`` headlines are folded into atom + hits; L0 raw is only a fallback (dropped as soon as any atom matches). +* ``memory_raws`` -> L0 inspection: the raw events themselves, newest first. * ``memory_get`` -> ``MemoryRuntime.memory_get``: resolve that path to the full markdown (``atom/.md`` / ``page/.md`` / ``raw//.md``). Recall echo guard: ``memory_capture`` drops content carrying a marker from ``_SKIP_CONTENT_MARKERS``, the same rule ``MemoryRuntime.capture`` applies via ``skip_memory_echo`` — the MCP write path calls ``Memory.add_raw`` directly and -would otherwise capture a hook's own injected recall block as a new event. +would otherwise capture a hook's own injected memory block as a new event. Content markers: everything this service puts back into a conversation is tagged -``[octop memory ]`` — today ``recall`` / ``search`` / ``get`` / ``capture``, -emitted as the ``marker`` field of the tool response and as the first line of the -blocks our hooks inject (the hook prefixes ``[octop memory search]``). Filtering is +``[octop memory ]`` — today ``search`` / ``get`` / ``capture`` (the retired +``recall`` tool shares the prefix), emitted as the ``marker`` field of the tool +response and as the first line of the blocks our hooks inject (the hook prefixes +``[octop memory search]``). Filtering is therefore one rule: drop anything containing ``_OWN_CONTENT_PREFIX``. Older markers (``## Memory Recall`` / ``[memory] Earlier in this workspace`` / ``[octop-memory hook]``) stay in the list because transcripts already on disk carry them. @@ -367,11 +365,11 @@ def _snippet(text: str) -> str: def _is_recall_echo(content: str) -> bool: - """True when ``content`` is our own recall block coming back as a new event. + """True when ``content`` is our own memory block coming back as a new event. Mirrors ``MemoryRuntime.capture``'s anti-feedback rule; without it a hook that - injects ``memory_recall`` output and then captures the turn via MCP would feed the - injected block back in, and each round would recall (and re-capture) more of it. + injects ``memory_search`` output and then captures the turn via MCP would feed the + injected block back in, and each round would surface (and re-capture) more of it. """ return any(marker in content for marker in _SKIP_CONTENT_MARKERS) @@ -554,84 +552,6 @@ def _observe_tool( """ return fn() - @mcp.tool() - def memory_recall( - query: str, - limit: int = 5, - session_id: str | None = None, - thread_id: str | None = None, - user: str | None = None, - ctx: Context | None = None, # type: ignore[type-arg] - ) -> dict[str, Any]: - """**召回专家记忆(读入口首选)**:把与 query 相关的记忆召回进上下文。 - - 每次对话/任务开始前先调一次。运行完整召回管线(分词 → 路由 → FTS → - 重排 → 去重 → token 预算),返回结构化片段 + 可直接注入 system prompt 的 markdown。 - 自动注入(hook)场景建议传 ``session_id``:管线会据此把**本会话**的 raw 排除, - 避免"注入 → 被记录 → 下轮又召回"的回声。 - - 三个读工具怎么选: - - 只想把相关背景拉进上下文 → 用本工具(一次调用,``rendered`` 直接可注入)。 - - 要**定位某条具体记忆并读全文** → ``memory_search`` 拿 ``path``, - 再 ``memory_get(path)`` 读完整 markdown(支持分页)。 - - 要**原话/证据**,或 ``memory_capture`` 刚写入、还没晋升成原子的内容 → - ``memory_raws``(L0 全文检索,capture 后立即可见)。 - - 覆盖范围(与内置 ``memory_search`` 同一套管线):L2 原子优先,``page`` - 主题页标题会并入 atom 命中;L0 原始事件只做兜底——只要有原子命中,raw 就被 - 整层丢弃。L1 候选不在召回范围内,请用 ``memory_candidates``。 - - Args: - query: 自然语言问题/关键词,整句传入(内部对中文做 n-gram 分词)。 - limit: 最多返回片段数,默认 5。 - session_id: 可选,当前会话 id。用于把本会话的 raw 从召回里排除(防回声); - 与 ``memory_capture`` 传入的 ``session_id`` 一致才生效。 - thread_id: 可选,会话线程 id。用于共指消解("那个项目" 靠该线程的 - active-entity stack)并把命中写回实体栈;不传则只做普通检索。 - user: 可选调用者 id(覆盖 ``X-Octop-User-Id`` 头)。 - ctx: MCP 注入的上下文(读取 ``X-Octop-User-Id`` 头)。 - """ - from octop_memory.pipeline.recall import recall_for_prompt # noqa: PLC0415 - - caller = user or _caller_user(ctx) - - def _run() -> dict[str, Any]: - memory = _memory() - result = recall_for_prompt( - memory, - query, - thread_id=thread_id, - session_id=session_id, - limit=limit, - ) - return { - "marker": "[octop memory recall]", - "memories": [ - { - "source_id": s.source_id, - "timestamp": s.timestamp_iso, - "layer": s.layer, - "text": s.text, - } - for s in result.snippets - ], - "count": len(result.snippets), - "rendered": result.rendered, - "caller": caller or None, - } - - return _observe_tool( - "recall", - session_id=session_id, - input_payload={ - "query": query, - "limit": limit, - "thread_id": thread_id, - }, - metadata={"caller": caller or None}, - fn=_run, - ) - @mcp.tool() def memory_search( query: str, @@ -640,12 +560,12 @@ def memory_search( user: str | None = None, ctx: Context | None = None, # type: ignore[type-arg] ) -> dict[str, Any]: - """**检索记忆(返回可下钻的 path)**:全文检索记忆,返回带虚拟路径的命中列表。 + """**检索记忆(读入口)**:全文检索记忆,返回带虚拟路径的命中列表。 - 与 ``memory_recall`` 走同一套召回/重排管线,区别是本工具不渲染 markdown,而是给 - 每条命中一个 ``path``,交给 ``memory_get`` 读全文。要"引用出处/读全文"用 - search + get,只想"把背景拉进上下文"用 ``memory_recall``,要"原话/证据"用 - ``memory_raws``。 + 每条命中带一个 ``path``,交给 ``memory_get`` 读全文;只想"把背景拉进上下文" + 就直接用命中里的 ``snippet``,要"原话/证据"用 ``memory_raws``。自动注入(hook) + 场景用的就是本工具:注入块以 ``[octop memory search]`` 开头,``memory_capture`` + 按该标记丢弃回声,所以不会被当成新事件采回来。 **按调用者偏好排序**:调用者 id 有值(``X-Octop-User-Id`` 头或显式 ``user``)时, 这个人的命中排在最前、其他人的紧随其后 —— 只调顺序,**不过滤**:记忆是团队共享的, @@ -733,7 +653,7 @@ def memory_get( start: int | None = None, lines: int | None = None, ) -> dict[str, Any]: - """**读取记忆全文**:把 ``memory_search`` / ``memory_recall`` 命中的虚拟路径解析成 markdown。 + """**读取记忆全文**:把 ``memory_search`` 命中的虚拟路径解析成 markdown。 支持的路径形态:``atom/.md``(L2 原子)、``page/.md`` (L3 主题页)、``raw//.md``(L0 原始事件)。长内容用 @@ -796,7 +716,7 @@ def memory_save( ``memory_capture`` 交给提取管线。 旧事实过时了就用 ``supersedes`` 传旧原子 id:旧原子标记 deprecated,新事实 - 立即可被 ``memory_recall`` 召回并带上 ``supersedes`` 关联——"更正事实"就是 + 立即可被 ``memory_search`` 检索到并带上 ``supersedes`` 关联——"更正事实"就是 保存新事实 + 声明它取代谁,不需要另一个工具。 来源写入 ``metadata.source``,调用者写入 ``metadata.user``;调用者 id 还会自动 @@ -886,7 +806,8 @@ def memory_capture( 只记助手结论时 ``content`` 留空字符串。 鉴权、专家路由与用户归属都由 MCP 接入层按连接配置自动带上,工具本身不感知。 - 记录后立即可用 ``memory_raws`` 查询,晋升后才可被 ``memory_recall`` 召回。 + 记录后立即可用 ``memory_raws`` / ``memory_search(corpus="raw")`` 查询, + 晋升成原子后才回到常规检索结果里。 调用者 id(``X-Octop-User-Id``)会自动拼成内容前缀 ``说:…``:发送者由此进入 原子正文,既能被 FTS 直接搜到,也能在召回时一眼看出是谁说的——**不要**自己再 @@ -896,15 +817,15 @@ def memory_capture( 回声保护(两层,都是为了断掉"注入 → 采集 → 再召回"的放大环): - 1. 带 ``memory_recall`` 注入标记(``[memory] Earlier in this workspace`` / + 1. 带记忆注入标记(``[octop memory …]`` / ``[memory] Earlier in this workspace`` / ``## Memory Recall``)的事件**不写入**;一轮对话形态下两条都是回声才整体 返回 ``skipped=recall_echo``,transcript 形态下逐条丢弃并计入 ``skipped_echo``。transcript 形态还会一并丢弃**我们自己的 hook 指令文本** (``[octop-memory hook]``)——那是提示词管道,不是用户说过的话。 - 2. transcript 形态下还会丢弃**我们自己的记忆读工具**(``memory_recall`` / - ``memory_search`` / ``memory_get`` / ``memory_raws`` / ``memory_candidates``) - 产生的 ``tool_result``——那些内容本来就来自库,且调用方 hook 自己拼注入块时 - 未必带标记。丢弃条数见返回体的 ``skipped_own_results``。 + 2. transcript 形态下还会丢弃**我们自己的记忆读工具**(``memory_search`` / + ``memory_get`` / ``memory_raws`` / ``memory_candidates``,以及已下线的 + ``memory_recall``)产生的 ``tool_result``——那些内容本来就来自库,且调用方 + hook 自己拼注入块时未必带标记。丢弃条数见返回体的 ``skipped_own_results``。 3. transcript 形态下同样丢弃**终端自己注入的文本**:````、 ````、``Base directory for this skill:``(自带技能文档)。 斜杠命令包装(```` / ```` / ````) @@ -970,11 +891,9 @@ def memory_capture( (``memory_raws(host=…)`` 可筛)。 assistant: 一轮对话时的**助手这一侧**回复(不加发送者前缀);留空则该次只写人这一侧。 session_id: 可选会话 id,用于提取分组;一轮对话缺省派生为 ``ext:{source}:{user}``, - transcript 缺省为 ``cc:``。传了之后, - ``memory_recall`` 用同一个 id 就能把本会话的 raw 排除(防回声); - 同一段工作请保持同一个 id,跨调用稳定。 - thread_id: 可选线程 id,透传给 L0;``memory_recall(thread_id=…)`` 会用它做 - 线程级作用域与共指消解。 + transcript 缺省为 ``cc:``。同一段工作请保持同一个 + id,跨调用稳定。 + thread_id: 可选线程 id,透传给 L0。 dry_run: 只解析不写入,返回将要写入的条数 / 事件列表。 extract: 写入后是否立即触发提取(默认 ``True``)。批量回灌传 ``False``, 先只落 L0,再用 ``memory_extract`` 受控提取。 @@ -1088,9 +1007,9 @@ def _run() -> dict[str, Any]: "recorded": False, "skipped": "recall_echo", "reason": ( - "every event carries a recall injection marker " + "every event carries a memory injection marker " f"(e.g. {_OWN_CONTENT_PREFIX!r}); " - "dropped so our own recall output is not captured as a new event" + "dropped so our own injected block is not captured as a new event" ), "user": caller or None, } @@ -1177,7 +1096,7 @@ def _run() -> dict[str, Any]: "extract_scheduled": extract_scheduled, "note": ( "raw (L0) events recorded; visible now via memory_raws, " - "recallable via memory_recall after the extraction pipeline " + "searchable via memory_search after the extraction pipeline " "promotes them to atom(s)" ), } diff --git a/tests/unit/agents/test_memory_mcp.py b/tests/unit/agents/test_memory_mcp.py index 5b30895e0..602e85d5f 100644 --- a/tests/unit/agents/test_memory_mcp.py +++ b/tests/unit/agents/test_memory_mcp.py @@ -109,10 +109,9 @@ def fake_open(server, agent_id): assert captured["agent_id"] == "EXPERT42" -def test_build_registers_ten_tools(fake_memory): +def test_build_registers_nine_tools(fake_memory): mcp = mm.build_memory_mcp(mock.MagicMock()) assert set(_tools(mcp)) == { - "memory_recall", "memory_search", "memory_get", "memory_save", @@ -125,78 +124,6 @@ def test_build_registers_ten_tools(fake_memory): } -def test_memory_recall_uses_full_pipeline(fake_memory, monkeypatch, bind_agent): - """memory_recall runs the full recall pipeline (recall_for_prompt).""" - import octop_memory.pipeline.recall as _recall - - class _Snippet: - source_id = "atom-1" - timestamp_iso = "2026-08-19T00:00:00+00:00" - layer = "atom" - text = "billing-migration is the local clone" - - fake_result = mock.MagicMock() - fake_result.snippets = [_Snippet()] - fake_result.rendered = "markdown" - monkeypatch.setattr(_recall, "recall_for_prompt", lambda m, q, **kw: fake_result) - - mcp = mm.build_memory_mcp(mock.MagicMock()) - result = _tools(mcp)["memory_recall"].fn(query="billing-migration", limit=3) - assert result["count"] == 1 - assert result["memories"][0]["text"] == "billing-migration is the local clone" - assert result["rendered"] == "markdown" - assert result["marker"] == "[octop memory recall]" - - -def test_memory_recall_forwards_session_and_thread(fake_memory, monkeypatch, bind_agent): - """Hook callers can scope recall to a session/thread (echo guard + co-reference).""" - import octop_memory.pipeline.recall as _recall - - captured = {} - - def _fake(memory, query, **kwargs): - captured["memory"] = memory - captured["query"] = query - captured.update(kwargs) - result = mock.MagicMock() - result.snippets = [] - result.rendered = "" - return result - - monkeypatch.setattr(_recall, "recall_for_prompt", _fake) - mcp = mm.build_memory_mcp(mock.MagicMock()) - _tools(mcp)["memory_recall"].fn( - query="那个项目", - limit=4, - session_id="sess-1", - thread_id="thr-1", - ) - assert captured["memory"] is fake_memory - assert captured["query"] == "那个项目" - assert captured["session_id"] == "sess-1" - assert captured["thread_id"] == "thr-1" - assert captured["limit"] == 4 - - -def test_memory_recall_without_scope_passes_none(fake_memory, monkeypatch, bind_agent): - import octop_memory.pipeline.recall as _recall - - captured = {} - - def _fake(memory, query, **kwargs): - captured.update(kwargs) - result = mock.MagicMock() - result.snippets = [] - result.rendered = "" - return result - - monkeypatch.setattr(_recall, "recall_for_prompt", _fake) - mcp = mm.build_memory_mcp(mock.MagicMock()) - _tools(mcp)["memory_recall"].fn(query="q") - assert captured["session_id"] is None - assert captured["thread_id"] is None - - def test_memory_save_goes_store(fake_memory, bind_agent): mcp = mm.build_memory_mcp(mock.MagicMock()) result = _tools(mcp)["memory_save"].fn(content="remember X", source="coding-agent") From 28b1de221ace37b750f329ee1189e341f7e7eada Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Tue, 13 Oct 2026 13:46:23 +0800 Subject: [PATCH 19/21] docs(memory-mcp): lead with a curl quick start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new 快速上手 section before §1: interactive one-liner, zero-interaction env form, post-install self-check and uninstall - §4.7 now points at it and keeps the implementation detail (?agent/?terminals/?url/?hook_url) - every env var in the table was checked against the generated installer script, and the documented zero-interaction line was executed end to end (Claude Code + Kiro, exit 0, entries carry X-Octop-Agent-Id / Authorization / X-Octop-User-Id) --- docs/memory-mcp.md | 72 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/docs/memory-mcp.md b/docs/memory-mcp.md index 3ece22760..1cc18f14e 100644 --- a/docs/memory-mcp.md +++ b/docs/memory-mcp.md @@ -7,7 +7,67 @@ > 当前工具面:**9 个 `memory_*` 工具**(读取 3 个 / 写入 2 个 / 提取与审核 4 个)。 > > 本地终端(Claude Code / Kiro / DSH / Codex / Cursor)可以用服务端自带的**一键安装器** -> 自动完成「MCP 条目 + hooks(+ DSH 共享记忆预设)」,见 §4.7。 +> 自动完成「MCP 条目 + hooks(+ DSH 共享记忆预设)」,见下方**快速上手**。 + +--- + +## 快速上手(curl 一行接入) + +前提只有两条:① 服务端已设 `OCTOP_MEMORY_MCP_TOKEN` 并重启(未设置时 `/mcp/memory` 不挂载, +见 §5);② 有一个可用的**专家 ID**(即 `X-Octop-Agent-Id`,用 `octop agent list` 查,或用默认专家)。 + +在**要接入记忆的那台机器**上执行——`terminals` 可多选,逗号分隔: + +```bash +curl -fLsS "https:///mcp/memory/setup?agent=<专家ID>&terminals=claude-code" | sh +``` + +脚本会:检测本机装了哪些终端 → 让你勾选 → 问一次 token(部署已配 `OCTOP_MEMORY_MCP_TOKEN` +时内置,不再问)与用户标识 → 写入每个终端**自己的** hooks 目录与配置,并登记 MCP 条目。 +结尾会打一行 `octop memory 接入完成`,顺便做一次端点自检(`HTTP 200` 即通)。 + +**可重复执行**:重跑就是升级路径——先清理早期版本的 hook 文件与本安装器的陈旧登记, +同一端点只保留一条 MCP 条目,不会动你其它的 MCP / hook 条目。 + +### 零交互(CI / 批量装机 / 脚本化) + +`curl | sh` 没有 tty 时不会提问,全部走环境变量或 URL 参数: + +```bash +curl -fLsS "https:///mcp/memory/setup" \ + | OCTOP_AGENT_ID='<专家ID>' \ + OCTOP_TERMINALS='claude-code,kiro' \ + OCTOP_MEMORY_TOKEN='<共享 token>' \ + OCTOP_USER_ID='<你的域账号>' \ + OCTOP_DISABLE_OAUTH=true \ + sh +``` + +| 变量 | 作用 | +|---|---| +| `OCTOP_AGENT_ID` / `OCTOP_TERMINALS` | 等价于 `?agent=` / `?terminals=`;缺专家 ID 会直接报错退出 | +| `OCTOP_MEMORY_TOKEN` | 覆盖部署内置的默认凭据(即 `OCTOP_MEMORY_MCP_TOKEN` 的值) | +| `OCTOP_USER_ID` | 写进 `X-Octop-User-Id`,决定记忆里"谁说的";**建议域账号**(HTTP 头只能 ASCII,中文会被忽略) | +| `OCTOP_DISABLE_OAUTH=true` | 直连实例挂载点(条目写 `Authorization: Bearer `),不走 OAuth 前置 | +| `OCTOP_TOKEN_HEADER` | hooks 用的鉴权头名,缺省 `Authorization` | +| `OCTOP_ENTRY_TOKEN` + `OCTOP_ENTRY_TOKEN_HEADER` | 仅当条目要指向自建 OAuth 前置时的个人令牌与其头名(缺省 `Authorization`) | + +### 装完自检 / 卸载 + +```bash +# 1) 端点活着:不带 token 应 401(挂载了才可能 401,未挂载是 404) +curl -s -o /dev/null -w '%{http_code}\n' -X POST "https:///mcp/memory/" \ + -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' + +# 2) 重开终端/IDE(Kiro 重载 hooks;Codex 首次需在 codex 里信任该 hook), +# 然后问一句与本项目相关的问题——模型侧应自动调到 memory_search,命中会注入上下文。 + +# 卸载(等价于把安装器写过的条目与 hook 文件清掉) +curl -fLsS "https:///mcp/memory/uninstall" | sh +``` + +> 手工接入(不想跑脚本)看 §4.3 Kiro / §4.4 Claude Code / §4.5 DSH / §4.6 其它客户端; +> 安装器具体写了哪些文件、每个终端的路径与工具清单,见 §4.7。 --- @@ -466,16 +526,16 @@ This preset auto-reads and auto-writes the octop-memory expert store so durable ### 4.7 一键安装器(Claude Code / Kiro / DSH / Codex / Cursor) -服务端自带安装器,`curl | sh` 即可完成「检测终端 → 多选终端 → 填专家 ID → 写 MCP 条目 + -hooks(DSH 另装共享记忆预设)」: +服务端自带安装器。**命令、零交互变量、自检与卸载见开头的「快速上手」**;这一节是它的实现细节 +(写了哪些文件、地址参数、鉴权头)。 ```bash curl -fsSL "https:///mcp/memory/setup" | sh -# 无人值守可预填查询参数: -# ?url= 客户端 MCP 条目的地址(缺省 = 本实例挂载点) -# ?hook_url= hooks 实际 POST 的地址(缺省 = 本实例挂载点) +# 可预填的参数(等价的环境变量见快速上手): # ?agent= 专家 ID # ?terminals= claude-code,kiro,dsh,codex,cursor +# ?url= 客户端 MCP 条目的地址(缺省 = 本实例挂载点) +# ?hook_url= hooks 实际 POST 的地址(缺省 = 本实例挂载点) ``` - **不发放凭据**:token 由运行脚本的人当场输入(读 `/dev/tty`,不回显);可用 From d811b6f5625194dd29020e3e09101e40a650b230 Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Tue, 13 Oct 2026 13:52:56 +0800 Subject: [PATCH 20/21] docs(memory-mcp): state that the quick setup needs only the instance token Answers the recurring question: direct mode needs just OCTOP_MEMORY_MCP_TOKEN (baked in as DEFAULT_TOKEN, overridable via OCTOP_MEMORY_TOKEN). A personal token (OCTOP_ENTRY_TOKEN) is only required when the client entry is pointed at a self-hosted OAuth front, where the proxy identifies the caller from the token. --- docs/memory-mcp.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/memory-mcp.md b/docs/memory-mcp.md index 1cc18f14e..444f0b8d2 100644 --- a/docs/memory-mcp.md +++ b/docs/memory-mcp.md @@ -54,6 +54,13 @@ curl -fLsS "https:///mcp/memory/setup" \ ### 装完自检 / 卸载 +> **凭据只要一个**:直连模式(自建实例的默认形态)只需要**实例共享 token**,也就是服务端 +> `OCTOP_MEMORY_MCP_TOKEN` 的值——它由安装器内置成默认凭据,正常无需手传,要覆盖才设 +> `OCTOP_MEMORY_TOKEN`。**不需要"每人的个人令牌"**。只有当 `?url=` / +> `OCTOP_MEMORY_PUBLIC_MCP_URL` 把客户端条目指向**自建 OAuth 前置**、且未设 +> `OCTOP_DISABLE_OAUTH=true` 时,才需要个人令牌 `OCTOP_ENTRY_TOKEN`(那时代理按令牌识别调用人, +> 没法靠共享 token 区分"谁问的");没声明 OAuth 前置时脚本会自动降级为直连。 + ```bash # 1) 端点活着:不带 token 应 401(挂载了才可能 401,未挂载是 404) curl -s -o /dev/null -w '%{http_code}\n' -X POST "https:///mcp/memory/" \ From 1e9acf4a5bc68f4b21fd0182d796431a6d95fcde Mon Sep 17 00:00:00 2001 From: "Arvin.qi" Date: Tue, 13 Oct 2026 14:09:09 +0800 Subject: [PATCH 21/21] refactor(memory-mcp): drop the OAuth entry mode and the second credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork is a plain self-hosted instance: one address, one credential. - removed OCTOP_DISABLE_OAUTH / the OAuth-front branch and the personal-token path (OCTOP_ENTRY_TOKEN / OCTOP_ENTRY_TOKEN_HEADER); hooks and the client MCP entry now always share the instance token (OCTOP_MEMORY_MCP_TOKEN, overridable via OCTOP_MEMORY_TOKEN, header name via OCTOP_TOKEN_HEADER) - ?url= / OCTOP_MEMORY_PUBLIC_MCP_URL survive as a plain entry-address override - no second server-name env: the MCP entry is always named from the entry address - also dropped the last internal artefacts found while editing: an employee's Chinese name used as a non-ASCII test fixture, and 'oauth' in the server-name example list - docs: quick start without the removed variables, single-credential note, §5 table trimmed verified: - pytest tests/unit/agents/{test_client_setup,test_memory_mcp,test_memory_recall_patch, test_memory_backend,test_hook_scripts,test_memory_session_archive,test_claude_transcript}.py -> 203 passed - pytest tests/unit/agents tests/unit/gateway/test_slash_memory.py tests/integration/test_memory_api.py -> 960 passed, 2 skipped - ruff check / ruff format --check / mypy -> clean - live server: generated installer has 0 OAuth/ada/internal markers; the documented zero-interaction line installs Claude Code + Kiro with the shared token (exit 0); the installed UserPromptSubmit hook still injects memory_search hits --- docs/memory-mcp.md | 21 +-- src/octop/infra/agents/client_setup.py | 150 +++-------------- src/octop/infra/agents/memory_mcp.py | 8 +- tests/unit/agents/test_client_setup.py | 213 ++++++------------------- tests/unit/agents/test_memory_mcp.py | 18 +-- 5 files changed, 95 insertions(+), 315 deletions(-) diff --git a/docs/memory-mcp.md b/docs/memory-mcp.md index 444f0b8d2..a3b5d3bca 100644 --- a/docs/memory-mcp.md +++ b/docs/memory-mcp.md @@ -39,7 +39,6 @@ curl -fLsS "https:///mcp/memory/setup" \ OCTOP_TERMINALS='claude-code,kiro' \ OCTOP_MEMORY_TOKEN='<共享 token>' \ OCTOP_USER_ID='<你的域账号>' \ - OCTOP_DISABLE_OAUTH=true \ sh ``` @@ -48,18 +47,14 @@ curl -fLsS "https:///mcp/memory/setup" \ | `OCTOP_AGENT_ID` / `OCTOP_TERMINALS` | 等价于 `?agent=` / `?terminals=`;缺专家 ID 会直接报错退出 | | `OCTOP_MEMORY_TOKEN` | 覆盖部署内置的默认凭据(即 `OCTOP_MEMORY_MCP_TOKEN` 的值) | | `OCTOP_USER_ID` | 写进 `X-Octop-User-Id`,决定记忆里"谁说的";**建议域账号**(HTTP 头只能 ASCII,中文会被忽略) | -| `OCTOP_DISABLE_OAUTH=true` | 直连实例挂载点(条目写 `Authorization: Bearer `),不走 OAuth 前置 | | `OCTOP_TOKEN_HEADER` | hooks 用的鉴权头名,缺省 `Authorization` | -| `OCTOP_ENTRY_TOKEN` + `OCTOP_ENTRY_TOKEN_HEADER` | 仅当条目要指向自建 OAuth 前置时的个人令牌与其头名(缺省 `Authorization`) | ### 装完自检 / 卸载 -> **凭据只要一个**:直连模式(自建实例的默认形态)只需要**实例共享 token**,也就是服务端 -> `OCTOP_MEMORY_MCP_TOKEN` 的值——它由安装器内置成默认凭据,正常无需手传,要覆盖才设 -> `OCTOP_MEMORY_TOKEN`。**不需要"每人的个人令牌"**。只有当 `?url=` / -> `OCTOP_MEMORY_PUBLIC_MCP_URL` 把客户端条目指向**自建 OAuth 前置**、且未设 -> `OCTOP_DISABLE_OAUTH=true` 时,才需要个人令牌 `OCTOP_ENTRY_TOKEN`(那时代理按令牌识别调用人, -> 没法靠共享 token 区分"谁问的");没声明 OAuth 前置时脚本会自动降级为直连。 +> **凭据只有一份**:**实例共享 token**,也就是服务端 `OCTOP_MEMORY_MCP_TOKEN` 的值。 +> 它由安装器内置成默认凭据,正常无需手传,要覆盖才设 `OCTOP_MEMORY_TOKEN`;hooks 与客户端 +> MCP 条目用的是同一份,没有"每人的个人令牌"这种东西。`?url=` / `OCTOP_MEMORY_PUBLIC_MCP_URL` +> 只改变**条目地址**(指向自建的公网入口),不改凭据。 ```bash # 1) 端点活着:不带 token 应 401(挂载了才可能 401,未挂载是 404) @@ -547,9 +542,8 @@ curl -fsSL "https:///mcp/memory/setup" | sh - **不发放凭据**:token 由运行脚本的人当场输入(读 `/dev/tty`,不回显);可用 `OCTOP_MEMORY_TOKEN` 预置。部署若已设 `OCTOP_MEMORY_MCP_TOKEN`,该值会内置成脚本默认凭据。 -- 鉴权头由环境变量决定:`OCTOP_TOKEN_HEADER`(hooks 侧,缺省 `Authorization` → `Bearer `); - `OCTOP_ENTRY_TOKEN` + `OCTOP_ENTRY_TOKEN_HEADER`(仅当条目指向自建 OAuth 前置时使用,缺省 - 同样是 `Authorization`)。 +- 鉴权头由 `OCTOP_TOKEN_HEADER` 决定,缺省 `Authorization`(此时值写成 `Bearer `); + hooks 与条目共用这一份凭据。 - 每个终端各写自己的 hooks 目录与配置文件:Claude Code `~/.claude/hooks-events` + `settings.json`、Kiro `~/.kiro/hooks`、DSH `~/.dsh/hooks/octop-hooks.json`、 Codex `~/.codex/hooks` + `config.toml`、Cursor `~/.cursor/hooks` + `hooks.json`; @@ -567,9 +561,8 @@ curl -fsSL "https:///mcp/memory/setup" | sh | `OCTOP_MEMORY_MCP_TOKEN` | 必填。未设置时 `/mcp/memory` 不挂载(fail-closed);同时作为 §4.7 安装器脚本的默认凭据 | | `X-Octop-Agent-Id` | 每个请求必填,指向一个存在且启用的专家 | | `X-Octop-User-Id` | 可选;用于把发送者写进记忆正文(§1.3) | -| `OCTOP_MEMORY_PUBLIC_MCP_URL` / `config.json: memory_public_mcp_url` | 可选。把客户端 MCP **条目**指向自建的 OAuth 前置(hooks 仍打本实例挂载点);不配则条目 = 本实例挂载点。安装器也可用 `?url=` 单次覆盖 | +| `OCTOP_MEMORY_PUBLIC_MCP_URL` / `config.json: memory_public_mcp_url` | 可选。把客户端 MCP **条目**指向自建的公网入口(hooks 仍打本实例挂载点,凭据同一份);不配则条目 = 本实例挂载点。安装器也可用 `?url=` 单次覆盖 | | `OCTOP_TOKEN_HEADER` | 可选。安装器生成的 hooks 使用的鉴权头名,缺省 `Authorization`(值写成 `Bearer `) | -| `OCTOP_ENTRY_TOKEN` / `OCTOP_ENTRY_TOKEN_HEADER` | 可选。条目指向 OAuth 前置时的个人令牌与头名,缺省 `Authorization` | | 记忆后端 | 由专家配置 `memory.backend` 决定:默认 SQLite(`memory.sqlite`);PostgreSQL 控制面下默认复用控制面 DSN(每专家 schema) | 开启步骤: diff --git a/src/octop/infra/agents/client_setup.py b/src/octop/infra/agents/client_setup.py index 6bc196208..3a44e6bf9 100644 --- a/src/octop/infra/agents/client_setup.py +++ b/src/octop/infra/agents/client_setup.py @@ -218,27 +218,18 @@ def _install_hooks(target, host): def _entry(transport=None): - # 两种模式,凭据各带各的: - # 直连(条目落在实例挂载点):带**实例共享 token**;header 名由 ``OCTOP_TOKEN_HEADER`` - # 决定(缺省 ``Authorization`` → ``Bearer ``),身份只能靠 ``X-Octop-User-Id`` - # 头表达。 - # OAuth 前置(条目指向代理):必须带**个人令牌**;header 名由 ``OCTOP_ENTRY_TOKEN_HEADER`` - # 决定(缺省 ``Authorization``),身份由代理按令牌重写,所以条目不写 ``X-Octop-User-Id``。 + # 条目与 hooks 共用同一份凭据:**实例共享 token**;header 名由 ``OCTOP_TOKEN_HEADER`` + # 决定(缺省 ``Authorization`` → ``Bearer ``)。「谁问的」靠 ``X-Octop-User-Id`` 头。 headers = {"X-Octop-Agent-Id": AGENT} - if OAUTH_ENTRY: - if ENTRY_TOKEN: - header = ENTRY_TOKEN_HEADER or "Authorization" - headers[header] = f"Bearer {ENTRY_TOKEN}" if header == "Authorization" else ENTRY_TOKEN - else: - headers[TOKEN_HEADER] = f"Bearer {TOKEN}" if TOKEN_HEADER == "Authorization" else TOKEN - if USER_ID: - headers["X-Octop-User-Id"] = USER_ID # 「谁问的」靠这个头,不靠 token 解析 + headers[TOKEN_HEADER] = f"Bearer {TOKEN}" if TOKEN_HEADER == "Authorization" else TOKEN + if USER_ID: + headers["X-Octop-User-Id"] = USER_ID # 「谁问的」靠这个头,不靠 token 解析 entry = {"type": transport or "http", "url": ENTRY_URL, "headers": headers} return entry def _octop_servers(servers): - """配置里已有的 octop memory 服务名——``local`` / ``prod`` / ``oauth`` / ``test`` 都算。""" + """配置里已有的 octop memory 服务名——``local`` / ``prod`` / ``test`` 这类后缀都算。""" return [name for name in servers if str(name).replace("_", "-").startswith("octop-memory")] @@ -369,7 +360,7 @@ def do_kiro(): ``~/.kiro/sessions///messages.jsonl`` 提交进 L0 —— Kiro 侧 最有价值的就是**原始的用户/助手消息**,绝不能让模型自己总结(早期版本在共享 token 下退化为 ``agent`` 提示词,结果是"AI 复述"进 L0)。共享 token 下"谁问的"由脚本带的 - ``X-Octop-User-Id``(安装时的 ``OCTOP_USER_ID``)表达,不再需要额外的个人令牌。 + ``X-Octop-User-Id``(安装时的 ``OCTOP_USER_ID``)表达,不存在第二份额外凭据。 """ _merge_mcp(home / ".kiro" / "settings" / "mcp.json", transport="streamable_http") _install_hooks(KIRO_DIR, "kiro") @@ -623,8 +614,6 @@ def do_dsh(): print(" ! 没自动装进 profile,手动两步:") print(f" 1) 在 {profile_dir} 执行:pnpm add {bundle}") print(f" 2) 把 {name} 加进该目录 package.json 的 dsh.profile.bundles,然后重启 DSH") - if OAUTH_ENTRY: - print(" · DSH 侧这条目带个人令牌(OAuth 前置),静态凭据由代理校验") print(" · DSH 的 MCP 服务请在它的 MCP 面板/CLI 里添加(该条目由 DSH 自己管理):") print(f" url: {URL}") print(f" headers: {json.dumps(_entry()['headers'], ensure_ascii=False)}") @@ -635,49 +624,19 @@ def do_dsh(): HOOK_URL = os.environ.get("OCTOP_HOOK_URL") or URL # hooks 实际 POST 的地址(直连) TOKEN = os.environ["OCTOP_TOKEN"] TOKEN_HEADER = os.environ["OCTOP_TOKEN_HEADER"] -# 条目专属凭据(只有 OAuth 前置用得到):hooks 打实例、客户端条目打代理,两个凭据可以不同。 -ENTRY_TOKEN = os.environ.get("OCTOP_ENTRY_TOKEN", "").strip() -ENTRY_TOKEN_HEADER = os.environ.get("OCTOP_ENTRY_TOKEN_HEADER", "").strip() NODE = os.environ.get("OCTOP_NODE") or "node" # 安装时解析出的绝对路径(非 login shell 也能跑) -# 用户标识:token 的唯一作用是让服务端知道「谁问的」,直连端点时只能靠这个头。 +# 用户标识:token 的唯一作用是让服务端知道「谁问的」,只能靠这个头表达。 # 建议填域账号:HTTP header 只能是 ASCII,Node 的 fetch 对非 ASCII 直接抛错(hook 会崩)。 USER_ID = os.environ.get("OCTOP_USER_ID", "").strip() if USER_ID and not USER_ID.isascii(): print(f" ! 用户标识「{USER_ID}」含非 ASCII 字符:header 传不了(hook 会直接报错),已忽略") print(" · 请填域账号(如 alice);要中文显示名需服务端支持 percent 解码") USER_ID = "" -# MCP 条目只有两种模式:**OAuth(默认)** 或 直连 MCP token(明确不要 OAuth 时)。 -# OCTOP_DISABLE_OAUTH=true(1/yes)→ 直连:条目写 X-Octop-Agent-Id + 实例共享 token -# 不传 / false(0/no) → OAuth:条目只留 X-Octop-Agent-Id,Authorization 由客户端临时注入 -_DISABLE_OAUTH = (os.environ.get("OCTOP_DISABLE_OAUTH") or "").strip().lower() -if _DISABLE_OAUTH in ("", "0", "false", "no"): - OAUTH_ENTRY = True -elif _DISABLE_OAUTH in ("1", "true", "yes"): - OAUTH_ENTRY = False -else: - raise SystemExit( - f"OCTOP_DISABLE_OAUTH 只能填 true/false(1/0),收到 {_DISABLE_OAUTH!r}" - ) -# 模式与地址是配对的:OAuth 只能打代理(静态 token 在那儿会被 401),直连必须打 octop 实例 -# 自己的挂载点(代理不做静态 token)。所以由模式决定条目地址,而不是由地址猜模式。 -if OAUTH_ENTRY and URL == HOOK_URL: - # 部署没声明 OAuth 前置地址 → OAuth 条目点的是实例挂载点、又没凭据,必然 401。 - # 这时按直连模式生成(实例 token 走 Authorization,身份靠 X-Octop-User-Id)。 - print(" · 本实例未声明 OAuth 前置地址(memory_public_mcp_url / ?url=)→ 按直连模式生成条目") - OAUTH_ENTRY = False - -if OAUTH_ENTRY: - ENTRY_URL = URL # 部署声明的代理地址(memory_public_mcp_url / ?url=) - NAME = os.environ["OCTOP_SERVER_NAME"] - if not ENTRY_TOKEN: - print(" ! OAuth 前置必须带个人令牌,但没拿到:条目调用会 401") - print(" · 重跑并设 OCTOP_ENTRY_TOKEN=") -else: - ENTRY_URL = HOOK_URL # 直连不认识代理,声明了也不用 - NAME = os.environ["OCTOP_SERVER_NAME_DIRECT"] - if URL != HOOK_URL: - print(f" · 直连模式忽略声明的代理地址 {URL},条目指向实例 {HOOK_URL}") +# 条目地址默认 = 实例挂载点;部署可用 ?url= / OCTOP_MEMORY_PUBLIC_MCP_URL 把它指向公网 +# ingress(hooks 仍打 HOOK_URL)。凭据不分模式:条目与 hooks 都用实例共享 token。 +ENTRY_URL = URL +NAME = os.environ["OCTOP_SERVER_NAME"] def hook_command(script_path): @@ -736,19 +695,9 @@ def hook_command(script_path): print(" · hook 角色:用户提交时召回(CC / DSH / Codex 的 UserPromptSubmit,Cursor 的 beforeSubmitPrompt);") print(" 回合结束时上报(Stop / Cursor 的 stop)") -how = ( - f"OAuth 前置:条目带个人令牌({ENTRY_TOKEN_HEADER or 'Authorization'})" - if OAUTH_ENTRY - else f"直连 MCP token:条目写 {TOKEN_HEADER}" -) -print(f" · hooks 直连 {HOOK_URL};客户端 MCP 条目指向 {ENTRY_URL}({how})") +print(f" · hooks 打 {HOOK_URL};客户端 MCP 条目指向 {ENTRY_URL}(实例共享 token:写 {TOKEN_HEADER})") if USER_ID: - where = ( - "只写进 hooks 的 X-Octop-User-Id;条目身份由代理按令牌重写" - if OAUTH_ENTRY - else "写进条目 X-Octop-User-Id 与 hooks" - ) - print(f" · 用户标识:{USER_ID}({where})") + print(f" · 用户标识:{USER_ID}(写进条目 X-Octop-User-Id 与 hooks)") print(" · octop memory 接入完成(可重复执行,不会影响其它 MCP/hook 条目)") '''.strip() @@ -798,15 +747,14 @@ def render_setup_script( ) -> str: """Render the interactive installer. - ``mcp_url`` 是登记进客户端 MCP 条目的地址;``hook_url`` 是 hooks 真正 POST 的地址, - 默认同 ``mcp_url``。两者不同 = 条目走 OAuth 前置(客户端自己授权,条目不写凭据); - MCP 条目默认 **OAuth**(条目不写凭据,Authorization 由客户端临时注入);明确不要 OAuth 时 - 用 ``OCTOP_DISABLE_OAUTH=true`` 走直连 MCP token。 + ``mcp_url`` 是登记进客户端 MCP 条目的地址(唯一对外地址);``hook_url`` 是 hooks 真正 + POST 的地址,默认同 ``mcp_url``。两者不同时,条目走部署声明的公网 ingress、hooks 打实例 + 挂载点,但凭据仍是同一个**实例共享 token**。 ``default_token`` 是部署内置的默认凭据(实例发布时设置了 ``OCTOP_MEMORY_MCP_TOKEN``): 脚本把它当 ``OCTOP_MEMORY_TOKEN`` 的兜底,于是用户只需要专家 ID(可选用户标识)。 - 为空时照旧在安装时问 token;header 名由 ``OCTOP_TOKEN_HEADER`` / - ``OCTOP_ENTRY_TOKEN_HEADER`` 决定,缺省 ``Authorization``(此时值写成 ``Bearer ``)。 + 为空时在安装时问一次;header 名由 ``OCTOP_TOKEN_HEADER`` 决定,缺省 ``Authorization`` + (此时值写成 ``Bearer ``)。 ``agent_id`` 和 ``terminals``(逗号清单)只是给自动化预填提示用。 """ hook_url = hook_url or mcp_url @@ -840,11 +788,6 @@ def render_setup_script( CODEX_DIR='{HOOKS_DIRS["codex"]}' CURSOR_DIR='{HOOKS_DIRS["cursor"]}' -# hooks 与「客户端 MCP 条目」可以是两个地址(默认同一个):条目可以走 OAuth 前置 -# (客户端自己授权,条目里不写凭据),hooks 永远直连本实例。 -SPLIT=0 -[ "$URL" = "$HOOK_URL" ] || SPLIT=1 - # ---- 1) 检测本机装了哪些终端(目录或命令任一命中) have_claude=0; have_kiro=0; have_dsh=0; have_codex=0; have_cursor=0 {{ [ -e "$HOME/.claude.json" ] || [ -d "$HOME/.claude" ] || command -v claude >/dev/null 2>&1; }} && have_claude=1 @@ -952,28 +895,15 @@ def render_setup_script( 127.0.0.1|localhost) IS_LOCAL=1 ;; esac -# ---- 3) 凭据:hooks 与「客户端 MCP 条目」各带各的 -# 模式:部署声明了 OAuth 前置地址(SPLIT=1)且没显式 OCTOP_DISABLE_OAUTH=true → OAuth 前置; -# 其余(含"没声明代理地址")→ 直连。 -MODE=direct -if [ "$SPLIT" = 1 ]; then - case "${{OCTOP_DISABLE_OAUTH:-}}" in - 1|true|yes) MODE=direct ;; - *) MODE=oauth ;; - esac -fi - -# hooks 的凭据永远是「实例共享 token」(部署内置的 DEFAULT_TOKEN 就是它),与模式无关。 +# ---- 3) 凭据:hooks 与客户端 MCP 条目共用同一个「实例共享 token」 TOKEN="${{OCTOP_MEMORY_TOKEN:-}}" {default_token_block} if [ -z "$TOKEN" ] && [ "$HAS_TTY" = 1 ]; then stty -echo < /dev/tty 2>/dev/null || true - if [ "$SPLIT" = 1 ]; then - printf '共享 token(hook 直连端点 %s 用,通常是 octopoctop): ' "$HOOK_URL" > /dev/tty - elif [ "$IS_LOCAL" = 1 ]; then - printf '共享 token(本机实例,走 Authorization: Bearer): ' > /dev/tty + if [ "$IS_LOCAL" = 1 ]; then + printf '实例共享 token(本机实例,走 Authorization: Bearer): ' > /dev/tty else - printf '共享 token(云上实例): ' > /dev/tty + printf '实例共享 token(云上实例): ' > /dev/tty fi read -r _t < /dev/tty || _t='' stty echo < /dev/tty 2>/dev/null || true @@ -983,34 +913,7 @@ def render_setup_script( [ -n "$TOKEN" ] || {{ echo "缺少 token:非交互运行时请设 OCTOP_MEMORY_TOKEN=" >&2; exit 1; }} # header 名可配(缺省 Authorization);header 是 Authorization 时值写成 ``Bearer ``。 TOKEN_HEADER="${{OCTOP_TOKEN_HEADER:-Authorization}}" - -# 条目凭据:只有 OAuth 前置(条目指向代理)才需要,且**必须**给个人令牌。没给就当场问; -# 非交互(CI)拿不到就报错,不写一个必然 401 的条目。 -ENTRY_TOKEN='' -ENTRY_TOKEN_HEADER='' -if [ "$MODE" = oauth ]; then - ENTRY_TOKEN="${{OCTOP_ENTRY_TOKEN:-}}" - if [ -z "$ENTRY_TOKEN" ] && [ "$HAS_TTY" = 1 ]; then - stty -echo < /dev/tty 2>/dev/null || true - printf '个人令牌(OAuth 前置必须;不回显): ' > /dev/tty - read -r _et < /dev/tty || _et='' - stty echo < /dev/tty 2>/dev/null || true - printf '\n' > /dev/tty - if [ -n "$_et" ]; then ENTRY_TOKEN="$_et"; fi - fi - if [ -z "$ENTRY_TOKEN" ]; then - echo " ! OAuth 前置(${{URL}})必须带个人令牌:请设 OCTOP_ENTRY_TOKEN= 后重跑" >&2 - exit 1 - fi - ENTRY_TOKEN_HEADER="${{OCTOP_ENTRY_TOKEN_HEADER:-Authorization}}" -fi -echo " · hook 直连:$HOOK_URL$([ "$IS_LOCAL" = 1 ] && echo '(本机实例)' || true)" -if [ "$MODE" = oauth ]; then - echo " · 客户端 MCP 条目:${{URL}}(OAuth 前置:带个人令牌 ${{ENTRY_TOKEN_HEADER}})" -else - echo " · 客户端 MCP 条目:${{HOOK_URL}}(直连 MCP token:写 ${{TOKEN_HEADER}})" -fi -echo " · 认证方式:$TOKEN_HEADER" +echo " · 客户端 MCP 条目与 hooks:${{URL}}(实例共享 token:写 ${{TOKEN_HEADER}})" AGENT="${{OCTOP_AGENT_ID:-}}" if [ -z "$AGENT" ]; then @@ -1022,8 +925,7 @@ def render_setup_script( fi [ -n "$AGENT" ] || {{ echo "缺少专家 ID:非交互运行时请设 OCTOP_AGENT_ID=" >&2; exit 1; }} -# 用户标识(建议域账号):直连时「谁问的」只能靠 X-Octop-User-Id(条目和 hooks 都写它); -# OAuth 前置时条目身份由代理按令牌重写,但 hooks 仍靠这个头 —— 两种模式都问一次。 +# 用户标识(建议域账号):「谁问的」只能靠 X-Octop-User-Id,条目和 hooks 都写它。 USER_ID="${{OCTOP_USER_ID:-}}" if [ -z "$USER_ID" ] && [ "$HAS_TTY" = 1 ]; then USER_ID="$(ask "用户标识(建议填域账号,如 alice;写进 X-Octop-User-Id 标记谁问的): " "")" @@ -1088,9 +990,7 @@ def render_setup_script( # ---- 5) 各终端各自安装 hook 脚本 + 合并其配置 OCTOP_URL="$URL" OCTOP_HOOK_URL="$HOOK_URL" OCTOP_TOKEN="$TOKEN" OCTOP_TOKEN_HEADER="$TOKEN_HEADER" \\ -OCTOP_ENTRY_TOKEN="$ENTRY_TOKEN" OCTOP_ENTRY_TOKEN_HEADER="$ENTRY_TOKEN_HEADER" \\ OCTOP_AGENT="$AGENT" OCTOP_SERVER_NAME='{_server_name(mcp_url)}' \\ -OCTOP_SERVER_NAME_DIRECT='{_server_name(hook_url)}' \\ OCTOP_USER_ID="$USER_ID" \\ OCTOP_CC_DIR="$CC_DIR" OCTOP_KIRO_DIR="$KIRO_DIR" OCTOP_DSH_DIR="$DSH_DIR" \\ OCTOP_CODEX_DIR="$CODEX_DIR" OCTOP_CURSOR_DIR="$CURSOR_DIR" \\ diff --git a/src/octop/infra/agents/memory_mcp.py b/src/octop/infra/agents/memory_mcp.py index 0560afa86..a65998c38 100644 --- a/src/octop/infra/agents/memory_mcp.py +++ b/src/octop/infra/agents/memory_mcp.py @@ -1720,7 +1720,7 @@ def _default_mcp_url(scheme: str, host: str) -> str: """客户端条目默认该连的地址:本实例自己的挂载点。 ``?url=`` / ``OCTOP_MEMORY_PUBLIC_MCP_URL`` / ``memory_public_mcp_url`` 仍是**显式覆盖**, - 部署可以用它把客户端条目指向自建的 OAuth 前置。 + 部署可以用它把客户端条目指向自建的公网入口。 """ return _host_mount_url(scheme, host) @@ -1773,9 +1773,9 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None } scheme = headers.get("x-forwarded-proto", "").split(",")[0].strip().lower() host = headers.get("host", "127.0.0.1") - # hooks 走本实例的直连挂载点(脚本没有客户端的 OAuth 凭据);mcp_url 是登记进 - # 客户端 MCP 条目的地址,缺省就是本实例的挂载点(部署可用 ?url= / memory_public_mcp_url - # 指向自建的 OAuth 前置,客户端自己授权);与 hook_url 不同即进入该模式。 + # hooks 走本实例的直连挂载点;mcp_url 是登记进客户端 MCP 条目的地址,缺省就是本实例 + # 的挂载点(部署可用 ?url= / memory_public_mcp_url 指向自建的公网入口)。两份地址共用 + # 同一份实例共享 token。 instance_url = _host_mount_url(scheme, host) script = render_setup_script( mcp_url=query.get("url") or _public_mcp_url() or _default_mcp_url(scheme, host), diff --git a/tests/unit/agents/test_client_setup.py b/tests/unit/agents/test_client_setup.py index bd48176a1..d600c0e2c 100644 --- a/tests/unit/agents/test_client_setup.py +++ b/tests/unit/agents/test_client_setup.py @@ -48,20 +48,19 @@ def test_installer_asks_instead_of_demanding_parameters() -> None: assert script.count("< /dev/tty") >= 3 # 探测 + 选择 + token + 专家 ID assert "要配置哪些?" in script assert "专家 ID: " in script - # 凭据提示按目标分流:本机实例与云端都问实例共享 token - assert "共享 token(本机实例,走 Authorization: Bearer)" in script - assert "共享 token(云上实例): " in script + # 凭据提示按目标分流:本机实例与云端都问同一个实例共享 token + assert "实例共享 token(本机实例,走 Authorization: Bearer)" in script + assert "实例共享 token(云上实例): " in script assert "stty -echo" in script # 不回显 # 没有 tty 时不盲读 stdin,而是明确要求环境变量 - # hooks 的凭据是实例共享 token;缺凭据时明确报错 assert 'TOKEN="${OCTOP_MEMORY_TOKEN:-}"' in script - assert 'ENTRY_TOKEN="${OCTOP_ENTRY_TOKEN:-}"' in script assert "非交互运行时请设 OCTOP_MEMORY_TOKEN" in script assert "非交互运行时请设 OCTOP_AGENT_ID" in script - # OAuth 前置必须有个人令牌,没有就当场问 / 非交互直接报错 - assert "个人令牌(OAuth 前置必须;不回显): " in script - assert "必须带个人令牌" in script - # 直连模式会问用户标识(写进 X-Octop-User-Id) + # 只有一份凭据:不再有 OAuth 前置 / 个人令牌这套东西 + assert "OCTOP_ENTRY_TOKEN" not in script + assert "OCTOP_DISABLE_OAUTH" not in script + assert "个人令牌" not in script + # 会问用户标识(写进 X-Octop-User-Id) assert "用户标识(建议填域账号,如 alice" in script @@ -84,9 +83,9 @@ def test_installer_embeds_no_credential() -> None: assert "DEFAULT_TOKEN=" not in script assert "Bearer " in script # 只在运行时按输入拼头,不是写死的 token - # header 名可配、缺省 Authorization(不再按 token 前缀猜方言) + # header 名可配、缺省 Authorization(不再按 token 前缀猜方言,也没有第二份凭据) assert 'TOKEN_HEADER="${OCTOP_TOKEN_HEADER:-Authorization}"' in script - assert 'ENTRY_TOKEN_HEADER="${OCTOP_ENTRY_TOKEN_HEADER:-Authorization}"' in script + assert "OCTOP_ENTRY_TOKEN" not in script def test_known_values_only_prefill_prompts() -> None: @@ -305,14 +304,14 @@ def test_loopback_targets_use_the_configured_header() -> None: """本机实例的共享 token 缺省走 ``Authorization: Bearer``;header 名可配、不猜前缀。""" script = _script() - # 判据用 hooks 真正要打的地址(拆分模式下不是「客户端条目」那个地址) + # 判据用 hooks 真正要打的地址 assert 'URL_HOST="$(printf \'%s\' "$HOOK_URL"' in script assert "127.0.0.1|localhost) IS_LOCAL=1" in script - assert "共享 token(本机实例,走 Authorization: Bearer)" in script # 本机问共享 token - assert "共享 token(云上实例): " in script # 云端也问实例共享 token + assert "实例共享 token(本机实例,走 Authorization: Bearer)" in script + assert "实例共享 token(云上实例): " in script # 不再有 token 前缀方言判定或硬编码 header 名 assert 'TOKEN_HEADER="${OCTOP_TOKEN_HEADER:-Authorization}"' in script - assert "hook 直连:" in script + assert "客户端 MCP 条目与 hooks:" in script def test_node_is_resolved_to_an_absolute_path() -> None: @@ -568,11 +567,11 @@ def _run_terminals(tmp_path, terminals: str, prepare=None, extra_env=None): def test_user_id_is_written_and_sent_only_when_given(tmp_path) -> None: - """OAuth/个人令牌只是拿用户身份;直连端点时只能靠 X-Octop-User-Id 标「谁问的」。""" + """「谁问的」只能靠 X-Octop-User-Id 表达:给了就写,没给就不写。""" home = _run_terminals( tmp_path / "with-id", "claude-code", - extra_env={"OCTOP_USER_ID": "alice", "OCTOP_DISABLE_OAUTH": "true"}, + extra_env={"OCTOP_USER_ID": "alice"}, ) config = json.loads((home / ".claude/hooks-events/config.json").read_text(encoding="utf-8")) assert config["userId"] == "alice" @@ -586,7 +585,7 @@ def test_user_id_is_written_and_sent_only_when_given(tmp_path) -> None: home2 = _run_terminals( tmp_path / "anonymous", "claude-code", - extra_env={"OCTOP_USER_ID": "", "OCTOP_DISABLE_OAUTH": "true"}, + extra_env={"OCTOP_USER_ID": ""}, ) config2 = json.loads((home2 / ".claude/hooks-events/config.json").read_text(encoding="utf-8")) assert "userId" not in config2 @@ -596,9 +595,9 @@ def test_user_id_is_written_and_sent_only_when_given(tmp_path) -> None: assert "X-Octop-User-Id" not in entry2["headers"] -def test_direct_entry_uses_authorization_bearer(tmp_path) -> None: - """直连条目缺省是 ``X-Octop-Agent-Id`` + ``Authorization: Bearer ``。""" - home = _run_terminals(tmp_path, "claude-code", extra_env={"OCTOP_DISABLE_OAUTH": "true"}) +def test_entry_uses_authorization_bearer(tmp_path) -> None: + """条目缺省是 ``X-Octop-Agent-Id`` + ``Authorization: Bearer ``。""" + home = _run_terminals(tmp_path, "claude-code") entry = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"][ "octop-memory-local" ] @@ -621,7 +620,6 @@ def test_direct_entry_uses_authorization_bearer(tmp_path) -> None: "OCTOP_MEMORY_TOKEN": "shared-token", "OCTOP_TOKEN_HEADER": "X-Octop-Memory-Token", "OCTOP_AGENT_ID": "AGT1", - "OCTOP_DISABLE_OAUTH": "true", }, capture_output=True, text=True, @@ -649,8 +647,7 @@ def test_non_ascii_user_id_is_ignored(tmp_path) -> None: "OCTOP_TERMINALS": "claude-code", "OCTOP_MEMORY_TOKEN": "fake-token", "OCTOP_AGENT_ID": "AGT1", - "OCTOP_USER_ID": "齐金龙", - "OCTOP_DISABLE_OAUTH": "true", # 直连模式才会把 X-Octop-User-Id 写进条目 + "OCTOP_USER_ID": "用户甲", }, capture_output=True, text=True, @@ -667,101 +664,17 @@ def test_non_ascii_user_id_is_ignored(tmp_path) -> None: assert "X-Octop-User-Id" not in entry["headers"] -def test_disable_oauth_defaults_to_oauth_and_opt_out_switches_to_direct(tmp_path) -> None: - """两种模式:**默认 OAuth**(条目不写凭据);明确不要 OAuth 用 ``OCTOP_DISABLE_OAUTH=true``。""" - local = "http://127.0.0.1:8088/mcp/memory/" - proxy = "https://octop-memory-oauth.example.com/mcp" - instance = "https://octop.example.com/mcp/memory/" - - def install(name: str, mcp_url: str, *, hook_url: str = "", flag: str = "", extra=None): - home = tmp_path / name - (home / ".claude").mkdir(parents=True) - script = tmp_path / f"{name}.sh" - script.write_text(_script(mcp_url=mcp_url, hook_url=hook_url), encoding="utf-8") - env = { - **os.environ, - "HOME": str(home), - "OCTOP_TERMINALS": "claude-code", - "OCTOP_MEMORY_TOKEN": "shared-token", - "OCTOP_AGENT_ID": "AGT1", - "OCTOP_USER_ID": "alice", - **(extra or {}), - } - if flag: - env["OCTOP_DISABLE_OAUTH"] = flag - result = subprocess.run( - ["sh", str(script)], env=env, capture_output=True, text=True, timeout=180 - ) - entry = None - cfg = home / ".claude.json" - if cfg.exists(): - servers = json.loads(cfg.read_text(encoding="utf-8"))["mcpServers"] - entry = list(servers.values())[0] if servers else None - return entry, result - - # 没声明代理地址(默认 OAuth)→ 回退直连:Authorization + agentId + userId - entry, result = install("no-proxy", local) - assert result.returncode == 0, result.stdout + result.stderr - assert entry is not None and entry["headers"] == { - "X-Octop-Agent-Id": "AGT1", - "Authorization": "Bearer shared-token", - "X-Octop-User-Id": "alice", - } - assert "未声明 OAuth 前置地址" in result.stdout +def test_declared_public_url_becomes_the_entry_address(tmp_path) -> None: + """条目地址与 hooks 地址可以分开:条目走部署声明的公网地址,hooks 打实例挂载点。 - # 显式 OCTOP_DISABLE_OAUTH=true:同样是直连 - entry, result = install("direct", local, flag="true") - assert result.returncode == 0, result.stdout + result.stderr - assert entry["headers"]["Authorization"] == "Bearer shared-token" - assert "直连 MCP token" in result.stdout - - # 声明了代理但没给个人令牌(非交互)→ 明确失败,不写一个必然 401 的条目 - entry, result = install("oauth-no-token", proxy, hook_url=instance) - assert result.returncode != 0 - assert "必须带个人令牌" in result.stderr - assert entry is None - - # 声明了代理 + 个人令牌 → OAuth 前置条目缺省带 ``Authorization: Bearer``,不写 userId - entry, result = install( - "oauth", proxy, hook_url=instance, extra={"OCTOP_ENTRY_TOKEN": "personal-token"} - ) - assert result.returncode == 0, result.stdout + result.stderr - assert entry["url"] == proxy - assert entry["headers"] == { - "X-Octop-Agent-Id": "AGT1", - "Authorization": "Bearer personal-token", - } - hooks_cfg = json.loads( - (tmp_path / "oauth/.claude/hooks-events/config.json").read_text(encoding="utf-8") - ) - assert hooks_cfg["token"] == "shared-token" # hooks 用实例共享 token,不是那把个人令牌 - assert hooks_cfg["tokenHeader"] == "Authorization" - - # 条目 header 名也可配:非 Authorization 时原样写该 header - entry, result = install( - "oauth-custom", - proxy, - hook_url=instance, - extra={ - "OCTOP_ENTRY_TOKEN": "personal-token", - "OCTOP_ENTRY_TOKEN_HEADER": "X-Octop-Memory-Token", - }, - ) - assert result.returncode == 0, result.stdout + result.stderr - assert entry["headers"] == { - "X-Octop-Agent-Id": "AGT1", - "X-Octop-Memory-Token": "personal-token", - } - - -def test_direct_mode_ignores_the_declared_oauth_proxy_address(tmp_path) -> None: - """模式和地址配对:OAuth 走代理;直连不认识代理 → 条目落在实例自己的挂载点。""" - proxy = "https://octop-memory-oauth.example.com/mcp" + 两份地址共用同一个**实例共享 token**(不再有个人令牌 / OAuth 模式)。 + """ + public = "https://octop-memory.example.com/mcp/memory/" direct = "https://octop.example.com/mcp/memory/" home = tmp_path / "home" (home / ".claude").mkdir(parents=True) script = tmp_path / "setup.sh" - script.write_text(_script(mcp_url=proxy, hook_url=direct), encoding="utf-8") + script.write_text(_script(mcp_url=public, hook_url=direct), encoding="utf-8") result = subprocess.run( ["sh", str(script)], env={ @@ -770,7 +683,7 @@ def test_direct_mode_ignores_the_declared_oauth_proxy_address(tmp_path) -> None: "OCTOP_TERMINALS": "claude-code", "OCTOP_MEMORY_TOKEN": "shared-token", "OCTOP_AGENT_ID": "AGT1", - "OCTOP_DISABLE_OAUTH": "true", + "OCTOP_USER_ID": "alice", }, capture_output=True, text=True, @@ -780,14 +693,19 @@ def test_direct_mode_ignores_the_declared_oauth_proxy_address(tmp_path) -> None: assert result.returncode == 0, result.stdout + result.stderr servers = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"] name, entry = next(iter(servers.items())) - assert name == "octop-memory" # 名字跟着实际条目地址 - assert entry["url"] == direct # 声明了代理也不去:直连只打实例 - assert entry["headers"]["Authorization"] == "Bearer shared-token" - assert "忽略声明的代理地址" in result.stdout + assert name == "octop-memory" + assert entry["url"] == public + assert entry["headers"] == { + "X-Octop-Agent-Id": "AGT1", + "Authorization": "Bearer shared-token", + "X-Octop-User-Id": "alice", + } + config = json.loads((home / ".claude/hooks-events/config.json").read_text(encoding="utf-8")) + assert config["url"] == direct and config["token"] == "shared-token" -def test_oauth_mode_without_a_declared_proxy_falls_back_to_direct(tmp_path) -> None: - """实例没声明 OAuth 前置地址 → 不再写一个没凭据的条目,直接按直连生成。""" +def test_entry_defaults_to_the_instance_mount(tmp_path) -> None: + """没有声明公网地址时,条目与 hooks 都落在实例自己的挂载点。""" instance = "https://octop.example.com/mcp/memory/" home = tmp_path / "home" (home / ".claude").mkdir(parents=True) @@ -809,10 +727,9 @@ def test_oauth_mode_without_a_declared_proxy_falls_back_to_direct(tmp_path) -> N ) assert result.returncode == 0, result.stdout + result.stderr - assert "未声明 OAuth 前置地址" in result.stdout servers = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"] entry = next(iter(servers.values())) - assert entry["url"] == instance # 条目落在实例自己的挂载点 + assert entry["url"] == instance assert entry["headers"] == { "X-Octop-Agent-Id": "AGT1", "Authorization": "Bearer shared-token", @@ -820,42 +737,16 @@ def test_oauth_mode_without_a_declared_proxy_falls_back_to_direct(tmp_path) -> N } -def test_invalid_disable_oauth_value_fails_before_writing_anything(tmp_path) -> None: - script = tmp_path / "setup.sh" - script.write_text(_script(), encoding="utf-8") - home = tmp_path / "home" - (home / ".claude").mkdir(parents=True) - result = subprocess.run( - ["sh", str(script)], - env={ - **os.environ, - "HOME": str(home), - "OCTOP_TERMINALS": "claude-code", - "OCTOP_MEMORY_TOKEN": "t", - "OCTOP_AGENT_ID": "AGT1", - "OCTOP_DISABLE_OAUTH": "maybe", - }, - capture_output=True, - text=True, - timeout=180, - ) - - assert result.returncode != 0 - assert "OCTOP_DISABLE_OAUTH 只能填 true/false" in result.stderr - assert not (home / ".claude.json").exists() - - -def test_split_urls_put_the_entry_into_oauth_mode(tmp_path) -> None: - """条目地址 ≠ hooks 地址 = 条目走 OAuth 前置:带**个人令牌**,客户端授权交给代理。 +def test_split_urls_keep_one_shared_token(tmp_path) -> None: + """条目与 hooks 地址不同也只有一个凭据:实例共享 token(不再有个人令牌)。 - hooks 仍打实例(共享 token + ``X-Octop-User-Id``);条目只带代理认的个人令牌, - header 名由 ``OCTOP_ENTRY_TOKEN_HEADER`` 决定(缺省 ``Authorization``),身份由代理按 - 令牌重写,所以不写 ``X-Octop-User-Id``。 + hooks 打实例(共享 token + ``X-Octop-User-Id``);条目走部署声明的公网地址, + 用的是同一份共享 token——由 ingress 原样转发。 """ - oauth = "https://octop-memory-oauth.example.com/mcp" + public = "https://octop-memory.example.com/mcp/memory/" direct = "https://octop.example.com/mcp/memory/" script = tmp_path / "setup.sh" - script.write_text(_script(mcp_url=oauth, hook_url=direct), encoding="utf-8") + script.write_text(_script(mcp_url=public, hook_url=direct), encoding="utf-8") home = tmp_path / "home" (home / ".claude").mkdir(parents=True) result = subprocess.run( @@ -865,8 +756,6 @@ def test_split_urls_put_the_entry_into_oauth_mode(tmp_path) -> None: "HOME": str(home), "OCTOP_TERMINALS": "claude-code", "OCTOP_MEMORY_TOKEN": "shared-token", - "OCTOP_ENTRY_TOKEN": "personal-token", - "OCTOP_ENTRY_TOKEN_HEADER": "X-Octop-Memory-Token", "OCTOP_AGENT_ID": "AGT1", "OCTOP_USER_ID": "alice", }, @@ -877,19 +766,19 @@ def test_split_urls_put_the_entry_into_oauth_mode(tmp_path) -> None: assert result.returncode == 0, result.stdout + result.stderr config = json.loads((home / ".claude/hooks-events/config.json").read_text(encoding="utf-8")) - assert config["url"] == direct # hooks 直连 + assert config["url"] == direct # hooks 直连实例 assert config["token"] == "shared-token" and config["userId"] == "alice" entry = json.loads((home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"][ "octop-memory" ] - assert entry["url"] == oauth + assert entry["url"] == public assert entry["headers"] == { "X-Octop-Agent-Id": "AGT1", - "X-Octop-Memory-Token": "personal-token", + "Authorization": "Bearer shared-token", + "X-Octop-User-Id": "alice", } - assert "OAuth 前置" in result.stdout - assert "hook 直连" in result.stdout + assert "实例共享 token" in result.stdout def test_builtin_default_token_saves_the_prompt(tmp_path) -> None: diff --git a/tests/unit/agents/test_memory_mcp.py b/tests/unit/agents/test_memory_mcp.py index 602e85d5f..0d417ea3a 100644 --- a/tests/unit/agents/test_memory_mcp.py +++ b/tests/unit/agents/test_memory_mcp.py @@ -1546,7 +1546,7 @@ async def test_installer_bakes_the_instance_token_as_the_default(monkeypatch): assert "DEFAULT_TOKEN='octopoctop'" in body assert 'TOKEN="${OCTOP_MEMORY_TOKEN:-}"' in body # 现场变量仍可覆盖 - assert 'ENTRY_TOKEN="${OCTOP_ENTRY_TOKEN:-}"' in body + assert "OCTOP_ENTRY_TOKEN" not in body # 只有一份凭据 @pytest.mark.asyncio @@ -1558,11 +1558,9 @@ async def test_installer_defaults_to_the_host_it_was_fetched_from(): @pytest.mark.asyncio async def test_installer_url_query_wins_over_the_default(): - _, body = await _run_installer( - _installer_scope("url=https://octop-memory-oauth.example.com/mcp") - ) + _, body = await _run_installer(_installer_scope("url=https://octop-memory.example.com/mcp")) - assert "URL='https://octop-memory-oauth.example.com/mcp'" in body + assert "URL='https://octop-memory.example.com/mcp'" in body assert "OCTOP_SERVER_NAME='octop-memory'" in body @@ -1643,13 +1641,13 @@ async def test_installer_defaults_to_the_deployment_config(monkeypatch, tmp_path monkeypatch.delenv("OCTOP_MEMORY_PUBLIC_MCP_URL", raising=False) monkeypatch.setenv("OCTOP_HOME", str(tmp_path)) (tmp_path / "config.json").write_text( - json.dumps({"memory_public_mcp_url": "https://octop-memory-oauth.example.com/mcp"}), + json.dumps({"memory_public_mcp_url": "https://octop-memory.example.com/mcp"}), encoding="utf-8", ) _, body = await _run_installer(_installer_scope()) - assert "URL='https://octop-memory-oauth.example.com/mcp'" in body + assert "URL='https://octop-memory.example.com/mcp'" in body @pytest.mark.asyncio @@ -1689,8 +1687,8 @@ async def test_installer_defaults_to_this_instance_for_any_host(monkeypatch, hos @pytest.mark.asyncio async def test_installer_splits_the_client_entry_from_the_hook_target(monkeypatch): - """条目可以是自建 OAuth 代理(客户端自己授权),hooks 必须拿本实例的直连挂载点。""" - monkeypatch.setenv("OCTOP_MEMORY_PUBLIC_MCP_URL", "https://octop-memory-oauth.example.com/mcp") + """条目地址可以被部署指到公网入口,hooks 仍拿本实例的直连挂载点(凭据同一份共享 token)。""" + monkeypatch.setenv("OCTOP_MEMORY_PUBLIC_MCP_URL", "https://octop-memory.example.com/mcp") scope = _installer_scope() scope["headers"] = [ (b"host", _EXTERNAL_HOST.encode()), @@ -1699,7 +1697,7 @@ async def test_installer_splits_the_client_entry_from_the_hook_target(monkeypatc _, body = await _run_installer(scope) - assert "URL='https://octop-memory-oauth.example.com/mcp'" in body + assert "URL='https://octop-memory.example.com/mcp'" in body assert f"HOOK_URL='https://{_EXTERNAL_HOST}/mcp/memory/'" in body