diff --git a/.github/workflows/openclaw-plugin-publish.yml b/.github/workflows/openclaw-plugin-publish.yml index 54375f0ae..c78cd85a7 100644 --- a/.github/workflows/openclaw-plugin-publish.yml +++ b/.github/workflows/openclaw-plugin-publish.yml @@ -10,6 +10,14 @@ on: description: "npm dist-tag (latest for production, beta/next/alpha for testing)" required: true default: "latest" + git_ref: + description: "Optional Git ref to build/publish (branch, tag, or SHA), e.g. mem-agent-0512. Leave blank to use the branch selected in Run workflow." + required: false + default: "" + +concurrency: + group: openclaw-plugin-publish-${{ github.workflow }}-${{ github.repository }} + cancel-in-progress: false defaults: run: @@ -34,6 +42,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.git_ref != '' && inputs.git_ref || github.ref }} - uses: actions/setup-node@v4 with: @@ -64,6 +74,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.git_ref != '' && inputs.git_ref || github.ref }} - uses: actions/setup-node@v4 with: diff --git a/apps/memos-local-openclaw/tests/policy.test.ts b/apps/memos-local-openclaw/tests/policy.test.ts index 3e135f951..0c15a06a5 100644 --- a/apps/memos-local-openclaw/tests/policy.test.ts +++ b/apps/memos-local-openclaw/tests/policy.test.ts @@ -22,6 +22,7 @@ import { captureMessages } from "../src/capture"; let plugin: MemosLocalPlugin; let tmpDir: string; +const embeddingApiKey = process.env.INFINI_AI_EMBEDDING_API_KEY ?? ""; const noopLog = { debug: () => {}, @@ -38,7 +39,7 @@ beforeAll(async () => { embedding: { provider: "openai_compatible" as any, endpoint: "https://cloud.infini-ai.com/AIStudio/inference/api/if-dchmmprfd5jlyvsa/v1", - apiKey: "sk-g3k5fclhdufjlzr3", + apiKey: embeddingApiKey, model: "bge-embedding-m3", }, }, diff --git a/apps/memos-local-plugin/.gitignore b/apps/memos-local-plugin/.gitignore index 0c52ba684..1df7952e4 100644 --- a/apps/memos-local-plugin/.gitignore +++ b/apps/memos-local-plugin/.gitignore @@ -17,6 +17,7 @@ coverage/ TODO.local.md AGENTS_*.md .test_* +.claude # ARMS telemetry credentials — generated by CI from secrets before # `npm publish` (see scripts/generate-telemetry-credentials.cjs and diff --git a/apps/memos-local-plugin/ARCHITECTURE.md b/apps/memos-local-plugin/ARCHITECTURE.md index 7814a2d8f..e7713453b 100644 --- a/apps/memos-local-plugin/ARCHITECTURE.md +++ b/apps/memos-local-plugin/ARCHITECTURE.md @@ -167,7 +167,7 @@ heavyweight client today. Standard OpenClaw plugin. Imports `core/` directly. Provides: - `plugin.ts` — `definePluginEntry` wiring; passes config + paths into `createMemoryCore`. -- `tools.ts` — `memory_search`, `memory_get`, `memory_timeline` tool definitions. +- `tools.ts` — `memos_search`, `memos_get`, `memos_timeline` tool definitions. - `hooks.ts` — `onConversationTurn`, `onShutdown`, etc. - `host-llm-bridge.ts` — when `llm.fallback_to_host: true`, route LLM calls through the OpenClaw host's LLM rather than failing. @@ -237,7 +237,7 @@ to this codebase: | Trigger | What runs | Where it lands | |---------------------------------------------------|--------------------------------------------|--------------------------------------------| | New user turn arrives (`onConversationTurn`) | `turnStartRetrieve` — full Tier-1+2+3 | Prepended as `memos_context` to this turn | -| LLM asks for `memory_search` / `memory_timeline` | `toolDrivenRetrieve` — Tier-1+2, no Tier-3 | Returned as the tool's result | +| LLM asks for `memos_search` / `memos_timeline` | `toolDrivenRetrieve` — Tier-1+2, no Tier-3 | Returned as the tool's result | | LLM asks for `skill.` directly | `skillInvokeRetrieve` — the named skill | Returned as the tool's result (cached) | | SubAgent starts (`onSubAgentStart`) | `subAgentRetrieve` — Tier-1+2 scoped to sub-agent role | Prepended to the sub-agent's first turn | | Decision-repair signal fires (see §4.3) | `repairRetrieve` — targeted preference/anti-pattern lookup | Prepended to the **next** LLM step | @@ -275,7 +275,7 @@ agent.turn(input) │ └── tier3 (world-model, top-K=2) └── returns InjectionPacket to adapter ─── agent.execute - ├── (optional) tool call: memory_search + ├── (optional) tool call: memos_search │ └── orchestrator.toolDrivenRetrieve (lightweight; no tier3) ├── (optional) tool call: skill. │ └── orchestrator.skillInvokeRetrieve (single skill, cached) diff --git a/apps/memos-local-plugin/adapters/README.md b/apps/memos-local-plugin/adapters/README.md index d451ec789..a0df0b876 100644 --- a/apps/memos-local-plugin/adapters/README.md +++ b/apps/memos-local-plugin/adapters/README.md @@ -26,7 +26,7 @@ adapters/ │ ├── README.md │ ├── openclaw-api.ts # locally re-declared OpenClaw SDK types │ ├── bridge.ts # OpenClaw events ↔ MemoryCore DTOs -│ ├── tools.ts # memory_search, memory_get, … tool registrations +│ ├── tools.ts # memos_search, memos_get, … tool registrations │ └── index.ts # register(api) — plugin entry point └── hermes/ # hermes-agent plugin (Python, out-of-process) ├── README.md diff --git a/apps/memos-local-plugin/adapters/hermes/install.hermes.sh b/apps/memos-local-plugin/adapters/hermes/install.hermes.sh index 4d431c792..cfec47682 100755 --- a/apps/memos-local-plugin/adapters/hermes/install.hermes.sh +++ b/apps/memos-local-plugin/adapters/hermes/install.hermes.sh @@ -36,7 +36,7 @@ if command -v npm >/dev/null 2>&1; then npm install --no-audit --no-fund --prefer-offline fi else - warn "npm not found on PATH; bridge.cts requires Node.js ≥ 20." + warn "npm not found on PATH; the bridge runtime requires Node.js ≥ 20." fi # ── 2. viewer bundle ────────────────────────────────────────────────────────── diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py index 4ea213f35..fd9d0d827 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py @@ -4,7 +4,7 @@ by the hermes-agent host (see ``hermes-agent/agent/memory_provider.py``). All heavy lifting lives in the Node.js ``memos-local-plugin`` core; this adapter is a thin Python client -that speaks JSON-RPC 2.0 over stdio to ``bridge.cts``. +that speaks JSON-RPC 2.0 over stdio to the packaged Node bridge. Discovery --------- @@ -64,7 +64,7 @@ sys.path.insert(0, str(_PLUGIN_DIR)) from bridge_client import BridgeError, MemosBridgeClient # noqa: E402 -from daemon_manager import ensure_bridge_running # noqa: E402 +from daemon_manager import ensure_bridge_running, ensure_viewer_daemon # noqa: E402 try: # pragma: no cover — host-provided base class, absent in unit tests @@ -83,6 +83,11 @@ class MemoryProvider: # type: ignore[no-redef] PLUGIN_ID = "memos-local-hermes" PLUGIN_VERSION = "2.0.0-beta.1" +_TOOL_FAILURE_REPAIR_HINT = ( + "This tool has failed multiple times in a row. You may want to call " + "`memos_search` for relevant past experience before deciding what to do next." +) +_TOOL_FAILURE_HINT_THRESHOLD = 3 _HERMES_INTERNAL_REVIEW_PREFIXES = ( "review the conversation above and consider saving to memory if appropriate.", @@ -239,6 +244,7 @@ class MemTensorProvider(MemoryProvider): def __init__(self) -> None: self._bridge: MemosBridgeClient | None = None + self._reconnect_lock = threading.Lock() self._session_id: str = "" self._episode_id: str = "" self._hermes_home: str = "" @@ -271,6 +277,7 @@ def __init__(self) -> None: self._skip_current_turn = False # Track the last trace ID for feedback submission self._last_trace_id: str = "" + self._tool_failure_streaks: dict[str, int] = {} # ─── Identity ───────────────────────────────────────────────────────── @@ -308,15 +315,21 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: # type: ignore[ov logger.warning("MemOS: failed to start bridge — %s", err) return try: - self._bridge = MemosBridgeClient() + ensure_viewer_daemon() + except Exception as err: + logger.warning("MemOS: viewer daemon check failed — %s", err) + new_bridge: MemosBridgeClient | None = None + try: + new_bridge = MemosBridgeClient() # Register the fallback LLM handler BEFORE we open the # session so it is available the very first time the # plugin's facade asks for help (e.g. on the first # `turn.start` retrieval call). - self._bridge.register_host_handler( + new_bridge.register_host_handler( "host.llm.complete", self._handle_host_llm_complete, ) + self._bridge = new_bridge self._open_session(session_id) logger.info( "MemOS: bridge ready session=%s platform=%s (episode deferred)", @@ -325,6 +338,9 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: # type: ignore[ov ) except Exception as err: logger.warning("MemOS: bridge init failed — %s", err) + if new_bridge is not None: + with contextlib.suppress(Exception): + new_bridge.close() self._bridge = None # Register a Hermes plugin hook to capture tool calls as they # happen. The `post_tool_call` hook fires after every tool @@ -337,14 +353,14 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: # type: ignore[ov def system_prompt_block(self) -> str: # type: ignore[override] return ( "# MemOS Memory\n" - "Persistent long-term memory is active. Call `memory_search`, " - "`memory_get`, `memory_timeline`, `memory_environment`, " - "`skill_list`, or `skill_get` when prior context or learned " + "Persistent long-term memory is active. Call `memos_search`, " + "`memos_get`, `memos_timeline`, `memos_environment`, " + "`memos_skill_list`, or `memos_skill_get` when prior context or learned " "procedures would help. Relevant memories are automatically " "injected at the start of every turn.\n\n" "**Not the same as repo skills:** Hermes' `` / " "`skill_view(name=…)` load **repository SKILL.md** files. " - "`skill_get` / `skill_list` refer to **MemOS-crystallized** " + "`memos_skill_get` / `memos_skill_list` refer to **MemOS-crystallized** " "skills (learned from your runs). If both apply, you may use " "both: repo skills for product conventions, MemOS skills for " "workflows proven on *your* past tasks." @@ -415,11 +431,80 @@ def _register_tool_call_hook(self) -> None: mgr = get_plugin_manager() mgr._hooks.setdefault("post_tool_call", []).append(self._on_post_tool_call) mgr._hooks.setdefault("post_llm_call", []).append(self._on_post_llm_call) + mgr._hooks.setdefault("transform_tool_result", []).append( + self._on_transform_tool_result + ) self._hook_registered = True - logger.debug("MemOS: registered post_tool_call + post_llm_call hooks") + logger.debug( + "MemOS: registered post_tool_call + post_llm_call + transform_tool_result hooks" + ) except Exception as err: logger.debug("MemOS: could not register tool hook — %s", err) + def _on_transform_tool_result( + self, + tool_name: str = "", + arguments: dict | None = None, + result: str = "", + task_id: str | None = None, + **kwargs: Any, + ) -> str | None: + """Append a small repair hint after repeated same-turn tool failures.""" + session_id = str(kwargs.get("session_id") or kwargs.get("sessionId") or "") + if not self._matches_session(session_id): + return None + + tool = str(tool_name or kwargs.get("toolName") or "unknown_tool") + if not self._tool_result_failed(result, kwargs): + self._tool_failure_streaks.pop(tool, None) + return None + + count = self._tool_failure_streaks.get(tool, 0) + 1 + self._tool_failure_streaks[tool] = count + if count < _TOOL_FAILURE_HINT_THRESHOLD: + return None + if _TOOL_FAILURE_REPAIR_HINT in (result or ""): + return None + text = (result or "").rstrip() + return f"{text}\n\n{_TOOL_FAILURE_REPAIR_HINT}" if text else _TOOL_FAILURE_REPAIR_HINT + + @staticmethod + def _tool_result_failed(result: str, payload: dict[str, Any]) -> bool: + for key in ("is_error", "isError", "error", "failed"): + value = payload.get(key) + if value is True: + return True + if isinstance(value, str) and value.strip(): + return True + try: + parsed = json.loads(result or "") + except Exception: + parsed = None + if isinstance(parsed, dict): + error = parsed.get("error") + if error is True: + return True + if isinstance(error, str) and error.strip(): + return True + if parsed.get("is_error") is True or parsed.get("isError") is True: + return True + normalized = " ".join((result or "").strip().lower().split()) + if not normalized: + return False + failure_prefixes = ( + "error:", + "failed:", + "failure:", + "exception:", + "traceback ", + "traceback:", + "command failed", + "tool failed", + ) + if normalized.startswith(failure_prefixes): + return True + return " traceback (most recent call last)" in normalized + def _on_post_tool_call( self, *, @@ -756,6 +841,7 @@ def on_turn_start(self, turn_number: int, message: str, **_kwargs: Any) -> None: # belong only to this turn. self._turn_thinking = "" self._tool_calls = [] + self._tool_failure_streaks = {} def prefetch(self, query: str, *, session_id: str = "") -> str: # type: ignore[override] """Inject relevant memories ahead of the next model call. @@ -1060,7 +1146,7 @@ def _int_arg(args: dict[str, Any], key: str, default: int, lower: int, upper: in def get_tool_schemas(self) -> list[dict[str, Any]]: # type: ignore[override] return [ { - "name": "memory_search", + "name": "memos_search", "description": ( "Search the local MemOS memory (traces, policies, world models, skills). " "Prefer this before claiming prior context is unavailable." @@ -1088,7 +1174,7 @@ def get_tool_schemas(self) -> list[dict[str, Any]]: # type: ignore[override] }, }, { - "name": "memory_get", + "name": "memos_get", "description": ( "Fetch the full body of a memory item by id. `kind` can be " '"trace" (default), "policy", or "world_model".' @@ -1107,7 +1193,7 @@ def get_tool_schemas(self) -> list[dict[str, Any]]: # type: ignore[override] }, }, { - "name": "memory_timeline", + "name": "memos_timeline", "description": "Return the ordered traces for an episode id.", "parameters": { "type": "object", @@ -1119,7 +1205,7 @@ def get_tool_schemas(self) -> list[dict[str, Any]]: # type: ignore[override] }, }, { - "name": "skill_list", + "name": "memos_skill_list", "description": ( "List callable skills the agent can invoke. Filter by status " "(candidate | active | archived)." @@ -1141,7 +1227,7 @@ def get_tool_schemas(self) -> list[dict[str, Any]]: # type: ignore[override] }, }, { - "name": "memory_environment", + "name": "memos_environment", "description": ( "Return accumulated environment knowledge (L3 world models): " "structural facts, behavioral rules, and project constraints." @@ -1163,7 +1249,7 @@ def get_tool_schemas(self) -> list[dict[str, Any]]: # type: ignore[override] }, }, { - "name": "skill_get", + "name": "memos_skill_get", "description": "Return the full invocation guide for a crystallized skill.", "parameters": { "type": "object", @@ -1177,7 +1263,7 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) if not self._bridge: return json.dumps({"error": "bridge not connected"}) try: - if tool_name == "memory_search": + if tool_name == "memos_search": query = (args.get("query") or "").strip() if not query: return json.dumps({"error": "missing query"}) @@ -1199,7 +1285,7 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) params, ) return json.dumps({"hits": resp.get("hits", [])}) - if tool_name == "memory_get": + if tool_name == "memos_get": item_id = (args.get("id") or "").strip() if not item_id: return json.dumps({"error": "missing id"}) @@ -1256,7 +1342,7 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) "meta": meta, } ) - if tool_name == "memory_timeline": + if tool_name == "memos_timeline": resp = self._bridge.request( "memory.timeline", { @@ -1267,13 +1353,13 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) limit = self._int_arg(args, "limit", 20, 1, 100) traces = resp.get("traces", [])[:limit] return json.dumps({"traces": traces}) - if tool_name == "skill_list": + if tool_name == "memos_skill_list": limit = self._int_arg(args, "limit", 10, 1, 50) params = {"limit": limit, "namespace": self._runtime_namespace()} if args.get("status"): params["status"] = args["status"] return json.dumps(self._bridge.request("skill.list", params)) - if tool_name == "memory_environment": + if tool_name == "memos_environment": query = (args.get("query") or "").strip() limit = self._int_arg(args, "limit", 5, 1, 30) if not query: @@ -1322,7 +1408,7 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) "queried": True, } ) - if tool_name == "skill_get": + if tool_name == "memos_skill_get": skill_id = (args.get("id") or "").strip() if not skill_id: return json.dumps({"error": "missing id"}) @@ -1426,16 +1512,18 @@ def on_session_end(self, messages: list[dict[str, Any]]) -> None: # type: ignor def shutdown(self) -> None: # type: ignore[override] self._bridge_keepalive_stop.set() if self._bridge_keepalive_thread and self._bridge_keepalive_thread.is_alive(): - self._bridge_keepalive_thread.join(timeout=2.0) + self._bridge_keepalive_thread.join( + timeout=12.0 + ) # Increased to cover health check timeout (10s) + margin if self._prefetch_thread and self._prefetch_thread.is_alive(): self._prefetch_thread.join(timeout=5.0) if self._bridge: + pid = getattr(self._bridge, "pid", "?") + logger.info("MemOS: shutting down bridge (pid=%s)", pid) with contextlib.suppress(Exception): self._bridge.close() self._bridge = None - # DON'T call shutdown_bridge() — the bridge process stays alive - # as a daemon if its viewer is running, so the memory panel - # remains accessible between `hermes chat` sessions. + logger.info("MemOS: bridge shutdown complete (pid=%s)", pid) # ─── Host LLM bridge (fallback for plugin-side model failures) ──────── @@ -1637,17 +1725,49 @@ def _is_transport_closed(self, err: Exception) -> bool: return "broken pipe" in msg or "bridge closed" in msg or "transport_closed" in msg def _reconnect_bridge(self, session_id: str = "", *, timeout: float = 30.0) -> None: - old_bridge = self._bridge - if old_bridge: - with contextlib.suppress(Exception): - old_bridge.close() - ensure_bridge_running() - self._bridge = MemosBridgeClient() - self._bridge.register_host_handler( - "host.llm.complete", - self._handle_host_llm_complete, - ) - self._open_session(session_id, timeout=timeout) + # Don't reconnect if we're shutting down + if self._bridge_keepalive_stop.is_set(): + logger.debug("MemOS: skipping reconnect during shutdown") + return + + with self._reconnect_lock: + # Double-check after acquiring lock + if self._bridge_keepalive_stop.is_set(): + logger.debug("MemOS: skipping reconnect during shutdown (after lock)") + return + + old_bridge = self._bridge + old_pid = getattr(old_bridge, "pid", None) if old_bridge else None + + if old_bridge: + logger.info("MemOS: closing old bridge (pid=%s)", old_pid) + with contextlib.suppress(Exception): + old_bridge.close() + logger.info("MemOS: old bridge closed (pid=%s)", old_pid) + + ensure_bridge_running() + try: + ensure_viewer_daemon() + except Exception as err: + logger.warning("MemOS: viewer daemon check failed during reconnect — %s", err) + new_bridge: MemosBridgeClient | None = None + try: + new_bridge = MemosBridgeClient() + logger.info("MemOS: new bridge created (pid=%s)", getattr(new_bridge, "pid", "?")) + + new_bridge.register_host_handler( + "host.llm.complete", + self._handle_host_llm_complete, + ) + self._bridge = new_bridge + self._open_session(session_id, timeout=timeout) + except Exception: + if new_bridge is not None: + with contextlib.suppress(Exception): + new_bridge.close() + if self._bridge is new_bridge: + self._bridge = None + raise def _ensure_bridge(self, session_id: str = "", *, timeout: float = 30.0) -> bool: if self._bridge: diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py index 25396ccb9..5b986ec66 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py @@ -1,9 +1,10 @@ """JSON-RPC 2.0 over stdio client for the MemOS bridge. -Spawns ``node bridge.cts --agent=hermes`` as a subprocess and communicates -via line-delimited JSON messages on its stdin/stdout. Responses are -matched by ``id``. Notifications (events + logs) are forwarded to -registered callbacks on a reader thread. +Spawns the packaged bridge subprocess (compiled ``dist/bridge.cjs`` when +available, otherwise the source ``bridge.cts`` through ``tsx``) and communicates +via line-delimited JSON messages on its stdin/stdout. Responses are matched by +``id``. Notifications (events + logs) are forwarded to registered callbacks on a +reader thread. The client is *blocking* by design — callers wanting async behaviour should wrap requests in a thread pool. @@ -29,6 +30,8 @@ logger = logging.getLogger(__name__) +HOST_HANDLER_WAIT_SECONDS = 5.0 + def _installed_node_binary(plugin_root: Path) -> str | None: marker = plugin_root / ".memos-node-bin" @@ -41,6 +44,13 @@ def _installed_node_binary(plugin_root: Path) -> str | None: return None +def _bridge_script(plugin_root: Path) -> Path: + compiled = plugin_root / "dist" / "bridge.cjs" + if compiled.exists(): + return compiled + return plugin_root / "bridge.cts" + + class BridgeError(RuntimeError): """Raised when the bridge returns a JSON-RPC error object.""" @@ -70,6 +80,7 @@ def __init__( bridge_path: str | None = None, node_binary: str | None = None, agent: str = "hermes", + no_viewer: bool = True, extra_env: dict[str, str] | None = None, ) -> None: self._lock = threading.Lock() @@ -85,6 +96,7 @@ def __init__( # handler returns a JSON-serialisable value or raises to # surface a JSON-RPC error back to the bridge. self._host_handlers: dict[str, Callable[[dict[str, Any]], Any]] = {} + self._host_handlers_cv = threading.Condition() self._closed = False plugin_root = Path(__file__).resolve().parent.parent.parent.parent @@ -95,30 +107,30 @@ def __init__( or shutil.which("node") or "node" ) - script = bridge_path or str(plugin_root / "bridge.cts") + script_path = Path(bridge_path) if bridge_path else _bridge_script(plugin_root) + script = str(script_path) env = {**os.environ, **(extra_env or {})} - # The plugin ships raw TypeScript (no precompiled `dist/`). Node's - # own `--experimental-strip-types` strips type annotations but does - # not rewrite `.js` import specifiers to the corresponding `.ts` - # files on disk — and the source tree uses `.js` extensions in - # every import per the TSC / bundler convention. We therefore - # launch the bridge via the bundled `tsx` CLI, which handles - # both jobs (strip types + extension rewrite). On Windows the - # `.bin/tsx` file is a POSIX shell shim; invoking it as - # `node .bin/tsx` makes Node parse shell syntax as JavaScript. - # Use tsx's real JS entrypoint when we are launching through a - # specific Node binary. + # Prefer the compiled CommonJS bridge from packaged installs. The raw + # TypeScript entry remains as a development fallback and needs `tsx` + # for stripping types plus `.js` → `.ts` import resolution. On Windows + # the `.bin/tsx` file is a shell shim, so use tsx's real JS entrypoint + # whenever we have to launch the source entry through a specific Node. tsx_cli = plugin_root / "node_modules" / "tsx" / "dist" / "cli.mjs" - if tsx_cli.exists(): - cmd = [node, str(tsx_cli), script, f"--agent={agent}"] + bridge_args = [script, f"--agent={agent}"] + if no_viewer: + bridge_args.append("--no-viewer") + if script_path.suffix == ".cjs": + cmd = [node, *bridge_args] + elif tsx_cli.exists(): + cmd = [node, str(tsx_cli), *bridge_args] else: # Fallback path: `node --import tsx` reproduces the same loader # inline. Requires tsx to be resolvable as a package from the # plugin root — true whenever node_modules exists. If tsx is # genuinely missing the child will fail fast with a loader # error the stderr reader will surface. - cmd = [node, "--import", "tsx", script, f"--agent={agent}"] + cmd = [node, "--import", "tsx", *bridge_args] self._proc = subprocess.Popen( cmd, stdin=subprocess.PIPE, @@ -144,6 +156,11 @@ def __init__( ) self._stderr_reader.start() + @property + def pid(self) -> int: + """Return the PID of the bridge subprocess.""" + return int(getattr(self._proc, "pid", 0) or 0) + # ─── Public API ── def request( @@ -216,20 +233,48 @@ def register_host_handler( heavy work (e.g. an LLM call) are still expected to return within the bridge-side timeout (default 60 s). """ - self._host_handlers[method] = handler + with self._host_handlers_cv: + self._host_handlers[method] = handler + self._host_handlers_cv.notify_all() def close(self) -> None: if self._closed: return - self._closed = True + with self._host_handlers_cv: + self._closed = True + self._host_handlers_cv.notify_all() + + pid = self.pid + + # 1. Close stdin (triggers bridge's graceful exit) with contextlib.suppress(Exception): self._proc.stdin.close() - # DON'T wait() or kill() the bridge process. If it has an - # active viewer (HTTP server), it will stay alive as a daemon - # so the memory panel remains accessible between `hermes chat` - # sessions. If it's headless (viewer port was taken), it will - # notice stdin EOF and exit on its own. - # unblock any pending waiters + + # 2. Wait for process to exit gracefully (up to 5 seconds) + try: + self._proc.wait(timeout=5.0) + logger.debug("MemOS: bridge process %d exited gracefully", pid) + except subprocess.TimeoutExpired: + # 3. If still running, send SIGTERM + logger.warning( + "MemOS: bridge process %d did not exit after stdin close, sending SIGTERM", pid + ) + try: + self._proc.terminate() # Send SIGTERM + self._proc.wait(timeout=5.0) # Increased from 2.0 to 5.0 for viewer cleanup + logger.debug("MemOS: bridge process %d terminated", pid) + except subprocess.TimeoutExpired: + # 4. Last resort: SIGKILL + logger.error( + "MemOS: bridge process %d did not respond to SIGTERM, sending SIGKILL", pid + ) + self._proc.kill() # Send SIGKILL + try: + self._proc.wait(timeout=1.0) + except subprocess.TimeoutExpired: + logger.error("MemOS: bridge process %d could not be killed", pid) + + # 5. Clean up pending requests with self._lock: for entry in list(self._pending.values()): entry["error"] = { @@ -282,7 +327,7 @@ def _read_loop(self) -> None: and "result" not in msg and "error" not in msg ): - handler = self._host_handlers.get(method) + handler = self._host_handler_for(method) if handler is None: self._send_response( rpc_id, @@ -311,6 +356,28 @@ def _read_loop(self) -> None: ) continue + def _host_handler_for( + self, + method: str, + *, + timeout: float = HOST_HANDLER_WAIT_SECONDS, + ) -> Callable[[dict[str, Any]], Any] | None: + """Return a reverse-RPC handler, waiting briefly during startup. + + The Node bridge now starts stdio before ``core.init()`` so host LLM + fallback can run during startup recovery. On a fast machine that + reverse request can arrive just before ``initialize()`` registers + ``host.llm.complete``. Waiting here turns that sub-millisecond race + into the intended handshake while still returning ``unknown_method`` + for genuinely unsupported methods. + """ + with self._host_handlers_cv: + self._host_handlers_cv.wait_for( + lambda: method in self._host_handlers or self._closed, + timeout=timeout, + ) + return self._host_handlers.get(method) + def _send_response( self, rpc_id: Any, diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py index 62810cc5b..19c8a7be0 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py @@ -16,26 +16,90 @@ from __future__ import annotations +import contextlib import logging +import os import shutil +import signal import subprocess import threading +import time +import urllib.error +import urllib.request from pathlib import Path logger = logging.getLogger(__name__) -_lock = threading.Lock() +_lock = threading.RLock() _bridge_ok: bool | None = None +_viewer_status: str | None = None +_viewer_last_probe_at = 0.0 +_viewer_process: subprocess.Popen | None = None + +HERMES_VIEWER_PORT = 18800 +VIEWER_PROBE_TTL_SEC = 30.0 +VIEWER_START_LOCK_TIMEOUT_SEC = 20.0 +VIEWER_START_LOCK_STALE_SEC = 60.0 + + +@contextlib.contextmanager +def _viewer_start_lock(timeout: float = VIEWER_START_LOCK_TIMEOUT_SEC): + """Cross-process guard for the Hermes viewer daemon startup path.""" + lock_dir = _plugin_root() / "daemon" / "viewer-start.lock" + lock_dir.parent.mkdir(parents=True, exist_ok=True) + deadline = time.time() + timeout + acquired = False + + while True: + try: + lock_dir.mkdir() + acquired = True + with contextlib.suppress(Exception): + (lock_dir / "owner").write_text( + f"pid={os.getpid()} started_at={time.time()}\n", + encoding="utf-8", + ) + break + except FileExistsError: + stale = False + with contextlib.suppress(Exception): + stale = time.time() - lock_dir.stat().st_mtime > VIEWER_START_LOCK_STALE_SEC + if stale: + with contextlib.suppress(Exception): + shutil.rmtree(lock_dir) + continue + if time.time() >= deadline: + yield False + return + time.sleep(0.1) + + try: + yield True + finally: + if acquired: + with contextlib.suppress(Exception): + shutil.rmtree(lock_dir) def _bridge_script() -> Path: - return Path(__file__).resolve().parent.parent.parent.parent / "bridge.cts" + plugin_root = _plugin_root() + compiled = plugin_root / "dist" / "bridge.cjs" + if compiled.exists(): + return compiled + return plugin_root / "bridge.cts" + + +def _plugin_root() -> Path: + plugin_root = Path(__file__).resolve().parent.parent.parent.parent + if plugin_root.name == "dist": + return plugin_root.parent + return plugin_root def _node_available() -> bool: - node = shutil.which("node") + node = _node_binary() if not node: return False try: @@ -45,6 +109,44 @@ def _node_available() -> bool: return False +def _installed_node_binary(plugin_root: Path) -> str | None: + marker = plugin_root / ".memos-node-bin" + try: + candidate = marker.read_text(encoding="utf-8").strip() + except OSError: + return None + if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + +def _node_binary() -> str | None: + plugin_root = _plugin_root() + return ( + os.environ.get("MEMOS_NODE_BINARY") + or _installed_node_binary(plugin_root) + or shutil.which("node") + ) + + +def _bridge_command(*, daemon: bool) -> list[str]: + plugin_root = _plugin_root() + node = _node_binary() + if not node: + raise RuntimeError("Node.js not found on PATH") + script_path = _bridge_script() + script = str(script_path) + tsx_cli = plugin_root / "node_modules" / "tsx" / "dist" / "cli.mjs" + bridge_args = [script, "--agent=hermes"] + if daemon: + bridge_args.append("--daemon") + if script_path.suffix == ".cjs": + return [node, *bridge_args] + if tsx_cli.exists(): + return [node, str(tsx_cli), *bridge_args] + return [node, "--import", "tsx", *bridge_args] + + def ensure_bridge_running(*, probe_only: bool = False) -> bool: """Return True when the bridge is (or can be) operational. @@ -69,8 +171,208 @@ def ensure_bridge_running(*, probe_only: bool = False) -> bool: return True +def _probe_viewer() -> str: + """Classify the service currently listening on Hermes' viewer port.""" + ping_url = f"http://127.0.0.1:{HERMES_VIEWER_PORT}/api/v1/ping" + ping_status = _probe_json_url(ping_url) + if ping_status == "free": + return "free" + if isinstance(ping_status, dict) and ping_status.get("service") == "memos-local-plugin": + return "running_memos" + + # Backwards compatibility for already-running viewers installed before + # `/api/v1/ping` carried a service marker. + health_url = f"http://127.0.0.1:{HERMES_VIEWER_PORT}/api/v1/health" + health_status = _probe_json_url(health_url) + if health_status == "free": + return "free" + if not isinstance(health_status, dict): + return "blocked" + if ( + health_status.get("service") == "memos-local-plugin" + and health_status.get("agent") == "hermes" + ): + return "running_memos" + if health_status.get("agent") == "hermes" and isinstance(health_status.get("version"), str): + return "running_memos" + return "blocked" + + +def _probe_json_url(url: str) -> dict | str: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=1.5) as resp: + content_type = resp.headers.get("content-type", "") + raw = resp.read(8192) + except urllib.error.URLError as err: + reason = getattr(err, "reason", None) + errno = getattr(reason, "errno", None) + if errno in {61, 111}: # macOS/Linux connection refused + return "free" + msg = str(err).lower() + if "connection refused" in msg or "failed to establish" in msg: + return "free" + return "blocked" + except TimeoutError: + return "blocked" + except Exception: + return "blocked" + + if "json" not in content_type.lower() and raw[:1] not in (b"{", b"["): + return "blocked" + try: + import json + + return json.loads(raw.decode("utf-8", errors="replace")) + except Exception: + return "blocked" + + +def ensure_viewer_daemon(*, probe_only: bool = False) -> bool: + """Ensure the singleton Hermes Viewer daemon owns :18800. + + Returns True when the MemOS Hermes Viewer is already running or was + started. Returns False when the port is occupied by another service, Node + is unavailable, or the daemon did not become healthy quickly. This status + must not affect stdio memory capture. + """ + global _viewer_last_probe_at, _viewer_process, _viewer_status + with _lock: + now = time.time() + if ( + probe_only + and _viewer_status == "running_memos" + and now - _viewer_last_probe_at < VIEWER_PROBE_TTL_SEC + ): + return True + + status = _probe_viewer() + _viewer_status = status + _viewer_last_probe_at = now + if status == "running_memos": + return True + if status == "blocked": + logger.warning( + "MemOS: viewer port %d is occupied by a non-MemOS service; " + "memory capture will continue without the web panel", + HERMES_VIEWER_PORT, + ) + return False + if probe_only: + return False + with _viewer_start_lock() as lock_acquired: + status = _probe_viewer() + _viewer_status = status + _viewer_last_probe_at = time.time() + if status == "running_memos": + return True + if status == "blocked": + logger.warning( + "MemOS: viewer port %d is occupied by a non-MemOS service; " + "memory capture will continue without the web panel", + HERMES_VIEWER_PORT, + ) + return False + if not lock_acquired: + logger.warning( + "MemOS: timed out waiting for viewer daemon startup lock; " + "memory capture will continue without the web panel", + ) + return False + if not ensure_bridge_running(): + return False + + plugin_root = _plugin_root() + logs_dir = plugin_root / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + log_file = logs_dir / "daemon-start.log" + try: + log_handle = log_file.open("a", encoding="utf-8") + _viewer_process = subprocess.Popen( + _bridge_command(daemon=True), + cwd=str(plugin_root), + stdout=log_handle, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + text=True, + start_new_session=True, + ) + log_handle.close() + except Exception as err: + with contextlib.suppress(Exception): + log_handle.close() # type: ignore[possibly-undefined] + logger.warning("MemOS: failed to start viewer daemon — %s", err) + return False + + deadline = time.time() + 15.0 + while time.time() < deadline: + if _viewer_process.poll() is not None: + logger.warning( + "MemOS: viewer daemon exited early with code %s", + _viewer_process.returncode, + ) + return False + status = _probe_viewer() + _viewer_status = status + _viewer_last_probe_at = time.time() + if status == "running_memos": + logger.info("MemOS: viewer daemon running on port %d", HERMES_VIEWER_PORT) + return True + if status == "blocked": + logger.warning( + "MemOS: viewer port %d became occupied by a non-MemOS service", + HERMES_VIEWER_PORT, + ) + return False + time.sleep(0.5) + logger.warning("MemOS: viewer daemon did not become healthy within 15s") + return False + + def shutdown_bridge() -> None: """Best-effort cleanup; each client owns its own subprocess.""" global _bridge_ok with _lock: _bridge_ok = None + + +def wait_for_process_exit(pid: int, timeout: float = 5.0) -> bool: + """Wait for a process to exit. + + Returns True if the process has exited, False if still running after timeout. + """ + start = time.time() + while time.time() - start < timeout: + try: + # Check if process exists (signal 0 doesn't actually send a signal) + os.kill(pid, 0) + time.sleep(0.1) + except (OSError, ProcessLookupError): + # Process doesn't exist = has exited + return True + return False + + +def terminate_bridge_process(pid: int, timeout: float = 7.0) -> bool: + """Terminate a bridge process gracefully, then forcefully if needed. + + Returns True if the process was successfully terminated. + """ + try: + # Check if process exists first + os.kill(pid, 0) + except (OSError, ProcessLookupError): + return True # Already gone + + try: + # 1. Send SIGTERM (graceful shutdown) + os.kill(pid, signal.SIGTERM) + if wait_for_process_exit(pid, timeout=5.0): + return True + + # 2. If still running, send SIGKILL (force kill) + logger.warning("MemOS: bridge process %d did not exit after SIGTERM, sending SIGKILL", pid) + os.kill(pid, signal.SIGKILL) + return wait_for_process_exit(pid, timeout=2.0) + except (OSError, ProcessLookupError): + return True diff --git a/apps/memos-local-plugin/adapters/openclaw/README.md b/apps/memos-local-plugin/adapters/openclaw/README.md index 0a5efb5d8..61734f775 100644 --- a/apps/memos-local-plugin/adapters/openclaw/README.md +++ b/apps/memos-local-plugin/adapters/openclaw/README.md @@ -13,8 +13,8 @@ adapter exposes a `register(api)` function that wires our - **Turn hooks**: `before_prompt_build` → `onTurnStart`, `agent_end` → `onTurnEnd`. -- **Memory tools**: `memory_search`, `memory_get`, `memory_timeline`, - `skill_list`, `skill_get` — all thin wrappers around `MemoryCore`. +- **Memory tools**: `memos_search`, `memos_get`, `memos_timeline`, + `memos_skill_list`, `memos_skill_get` — all thin wrappers around `MemoryCore`. - **Tool-outcome observation**: every tool call's success/failure is forwarded to `recordToolOutcome` so decision-repair can react on the next turn. diff --git a/apps/memos-local-plugin/adapters/openclaw/bridge.ts b/apps/memos-local-plugin/adapters/openclaw/bridge.ts index aa81f92e8..f849fe5f1 100644 --- a/apps/memos-local-plugin/adapters/openclaw/bridge.ts +++ b/apps/memos-local-plugin/adapters/openclaw/bridge.ts @@ -46,6 +46,7 @@ import type { SessionStartEvent, SubagentEndedEvent, SubagentSpawnedEvent, + ToolResultPersistEvent, } from "./openclaw-api.js"; // ─── Message flattening ──────────────────────────────────────────────────── @@ -483,7 +484,7 @@ function stripOpenClawUserEnvelope(raw: string): string { "", ); text = text.replace( - /## Memory system\n+No memories were automatically recalled[^\n]*(?:\n[^\n]*memory_search[^\n]*)*/gi, + /## Memory system\n+No memories were automatically recalled[^\n]*(?:\n[^\n]*memos_search[^\n]*)*/gi, "", ); @@ -795,16 +796,18 @@ function isExplicitOneShotSessionKey(sessionKey: string | undefined): boolean { const CONTEXT_OPEN = ""; const CONTEXT_CLOSE = ""; +const OPENCLAW_CONTEXT_CHAR_CAP = 6_000; +const TOOL_FAILURE_REPAIR_HINT = + "This tool has failed multiple times in a row. You may want to call `memos_search` for relevant past experience before deciding what to do next."; +const TOOL_FAILURE_HINT_THRESHOLD = 3; /** * Render the retrieval result as a prompt-prependable block. * - * When the store is cold (no hits), we still emit a short "memory - * tools are available" hint — the legacy `memos-local-openclaw` - * adapter does the same via `noRecallHint`, and without it the LLM - * has no reason to call `memory_search` at the start of a - * conversation. The hint is kept *small* so repeated turns don't - * bloat the system prompt. + * Callers may opt into a short cold-start hint when the store has no + * hits. The automatic OpenClaw before-prompt path disables that hint so + * no-hit turns continue with the user's prompt instead of injecting + * extra context. */ export function renderContextBlock( packet: RetrievalResultDTO | null, @@ -817,16 +820,31 @@ export function renderContextBlock( } if (opts.hintWhenEmpty === false) return ""; // Cold-start hint — mirrors the legacy adapter's behaviour so the - // model is nudged to reach for `memory_search` even on the first + // model is nudged to reach for `memos_search` even on the first // turn of a fresh session. const hint = [ "No prior memories matched this query — the store may simply be cold.", - "You can still call `memory_search` with a shorter or rephrased query", + "You can still call `memos_search` with a shorter or rephrased query", "if you expect there to be relevant past context.", ].join(" "); return `${CONTEXT_OPEN}\n${hint}\n${CONTEXT_CLOSE}`; } +function capContextBlock(block: string): { block: string; truncated: boolean } { + if (block.length <= OPENCLAW_CONTEXT_CHAR_CAP) { + return { block, truncated: false }; + } + const suffix = `\n\n[Memory context truncated to ${OPENCLAW_CONTEXT_CHAR_CAP} characters.]\n${CONTEXT_CLOSE}`; + const prefix = block.startsWith(`${CONTEXT_OPEN}\n`) ? `${CONTEXT_OPEN}\n` : ""; + const bodyStart = prefix.length; + const bodyBudget = Math.max(0, OPENCLAW_CONTEXT_CHAR_CAP - prefix.length - suffix.length); + const body = block.slice(bodyStart, bodyStart + bodyBudget).trimEnd(); + return { + block: `${prefix}${body}${suffix}`, + truncated: true, + }; +} + // ─── Bridge factory ──────────────────────────────────────────────────────── export interface BridgeOptions { @@ -859,6 +877,12 @@ export interface BridgeHandle { ctx: PluginHookToolContext, ) => Promise; + /** Handler for `tool_result_persist` — append repeated-failure hint. */ + handleToolResultPersist: ( + event: ToolResultPersistEvent, + ctx: PluginHookToolContext, + ) => { message?: unknown } | void; + /** Handler for `session_start`. */ handleSessionStart: ( event: SessionStartEvent, @@ -888,6 +912,82 @@ export interface BridgeHandle { trackedToolCalls: () => number; } +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function toolFailureStreakKey( + toolName: string, + event: ToolResultPersistEvent, + ctx: PluginHookToolContext, +): string { + const run = ctx.runId ?? event.runId ?? ctx.sessionId ?? ctx.sessionKey ?? "global"; + return `${run}:${toolName}`; +} + +function clearToolFailureStreaksForTurn( + streaks: Map, + ctx: { runId?: string; sessionId?: string; sessionKey?: string }, +): void { + const prefix = `${ctx.runId ?? ctx.sessionId ?? ctx.sessionKey ?? "global"}:`; + for (const key of streaks.keys()) { + if (key.startsWith(prefix)) streaks.delete(key); + } +} + +function toolResultPersistFailed(event: ToolResultPersistEvent): boolean { + if (event.error) return true; + const msg = asRecord(event.message); + if (msg) { + if (msg.isError === true || msg.error === true) return true; + if (typeof msg.error === "string" && msg.error.trim()) return true; + const details = asRecord(msg.details); + if (details?.isError === true || details?.error === true) return true; + if (typeof details?.error === "string" && details.error.trim()) return true; + } + const result = asRecord(event.result); + if (result?.isError === true || result?.error === true) return true; + if (typeof result?.error === "string" && result.error.trim()) return true; + return false; +} + +function appendFailureHintToToolResultMessage(message: unknown): unknown { + const msg = asRecord(message); + if (!msg) return message; + const content = msg.content; + if (typeof content === "string") { + if (content.includes(TOOL_FAILURE_REPAIR_HINT)) return message; + return { ...msg, content: appendFailureHint(content) }; + } + if (Array.isArray(content)) { + let idx = -1; + for (let i = content.length - 1; i >= 0; i--) { + const p = asRecord(content[i]); + if (p?.type === "text" && typeof p.text === "string") { + idx = i; + break; + } + } + if (idx >= 0) { + const part = asRecord(content[idx])!; + const text = String(part.text); + if (text.includes(TOOL_FAILURE_REPAIR_HINT)) return message; + const next = [...content]; + next[idx] = { ...part, text: appendFailureHint(text) }; + return { ...msg, content: next }; + } + return { ...msg, content: [...content, { type: "text", text: TOOL_FAILURE_REPAIR_HINT }] }; + } + return message; +} + +function appendFailureHint(content: string): string { + const trimmed = content.trimEnd(); + return `${trimmed}${trimmed ? "\n\n" : ""}${TOOL_FAILURE_REPAIR_HINT}`; +} + export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { const now = opts.now ?? (() => Date.now()); @@ -916,6 +1016,7 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { toolName?: string; params?: Record; }>(); + const toolFailureStreaks = new Map(); type ObservedToolCall = ToolCallDTO & { runId?: string; order: number }; const observedToolCallsBySession = new Map(); let observedToolCallSeq = 0; @@ -1050,6 +1151,7 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { event: BeforePromptBuildEvent, ctx: PluginHookAgentContext, ): Promise { + const startedAt = now(); try { // Ephemeral sub-agents (slug generator, internal probes) share // the plugin host and would otherwise open a throwaway episode @@ -1092,6 +1194,11 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { const namespace = namespaceFromAgentCtx(ctx); const sessionId = await ensureSession(ctx.agentId, ctx.sessionKey, namespace); + clearToolFailureStreaksForTurn(toolFailureStreaks, { + runId: ctx.runId, + sessionId: ctx.sessionId ?? sessionId, + sessionKey: ctx.sessionKey, + }); lastUserTextBySession.set(sessionId, prompt); const turn: TurnInputDTO = { @@ -1125,6 +1232,16 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { } } + const renderedBlock = renderContextBlock(packet, { + // Avoid making OpenClaw do a second tool-driven search when + // auto-recall found nothing. A no-hit turn should simply + // continue with the user's prompt; tools remain available if + // the model independently decides to use them. + hintWhenEmpty: false, + }); + const { block, truncated } = capContextBlock(renderedBlock); + const durationMs = now() - startedAt; + opts.log.info("memos.onTurnStart", { sessionKey: ctx.sessionKey, agentId: ctx.agentId, @@ -1132,9 +1249,18 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { episodeId: routedEpisodeId, hits: packet.hits.length, tierLatencyMs: packet.tierLatencyMs, + durationMs, + contextChars: block.length, + injected: block.length > 0, + truncated, }); + opts.log.info( + `memos.onTurnStart returned hits=${packet.hits.length} ` + + `durationMs=${durationMs} contextChars=${block.length} ` + + `injected=${block.length > 0 ? "yes" : "no"} ` + + `truncated=${truncated ? "yes" : "no"}`, + ); - const block = renderContextBlock(packet, { hintWhenEmpty: true }); if (!block) return; return { prependContext: block + "\n\n" }; } catch (err) { @@ -1369,6 +1495,27 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { } } + function handleToolResultPersist( + event: ToolResultPersistEvent, + ctx: PluginHookToolContext, + ): { message?: unknown } | void { + if (isEphemeralSessionKey(ctx.sessionKey)) return; + const toolName = event.toolName || ctx.toolName || "unknown"; + const key = toolFailureStreakKey(toolName, event, ctx); + if (!toolResultPersistFailed(event)) { + toolFailureStreaks.delete(key); + return; + } + + const nextCount = (toolFailureStreaks.get(key) ?? 0) + 1; + toolFailureStreaks.set(key, nextCount); + if (nextCount < TOOL_FAILURE_HINT_THRESHOLD) return; + + const message = appendFailureHintToToolResultMessage(event.message); + if (message === event.message) return; + return { message }; + } + async function handleSessionStart( event: SessionStartEvent, ctx: PluginHookSessionContext, @@ -1478,6 +1625,7 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { handleAgentEnd, handleBeforeToolCall, handleAfterToolCall, + handleToolResultPersist, handleSessionStart, handleSessionEnd, handleSubagentSpawned, diff --git a/apps/memos-local-plugin/adapters/openclaw/index.ts b/apps/memos-local-plugin/adapters/openclaw/index.ts index acb0bf36a..ba56848cb 100644 --- a/apps/memos-local-plugin/adapters/openclaw/index.ts +++ b/apps/memos-local-plugin/adapters/openclaw/index.ts @@ -11,6 +11,7 @@ * • `agent_end` → `onTurnEnd` (capture + reward chain) * • `before_tool_call` → duration tracker * • `after_tool_call` → `recordToolOutcome` (decision-repair) + * • `tool_result_persist` → repeated-failure memos_search hint * • `session_start` / `session_end` → core session lifecycle * 5. Register a service so the host can flush + shut down cleanly. * @@ -138,7 +139,7 @@ async function createRuntime(api: OpenClawPluginApi): Promise { await core.init(); // Anonymous ARMS telemetry. Mirrors `bridge.cts`'s setup so OpenClaw - // emits the same `plugin_started` / `daily_active` / `memory_search` + // emits the same `plugin_started` / `daily_active` / `memos_search` // / `memory_ingested` / `feedback_submitted` / `viewer_opened` // events under the same `memos_local_hermes_v2` group as Hermes. // Without this every OpenClaw user was invisible in ARMS — only the @@ -242,29 +243,43 @@ function register(api: OpenClawPluginApi): void { // fails later. api.registerMemoryCapability?.({ promptBuilder: ({ availableTools }) => { - const hasSearch = availableTools.has("memory_search"); - const hasGet = availableTools.has("memory_get"); - const hasTimeline = availableTools.has("memory_timeline"); - const hasEnv = availableTools.has("memory_environment"); - if (!hasSearch && !hasGet && !hasTimeline && !hasEnv) return []; + const hasSearch = availableTools.has("memos_search"); + const hasGet = availableTools.has("memos_get"); + const hasTimeline = availableTools.has("memos_timeline"); + const hasEnv = availableTools.has("memos_environment"); + const hasSkillList = availableTools.has("memos_skill_list"); + const hasSkillGet = availableTools.has("memos_skill_get"); + if (!hasSearch && !hasGet && !hasTimeline && !hasEnv && !hasSkillList && !hasSkillGet) { + return []; + } const lines: string[] = [ "## Memory (MemOS Local)", "This workspace uses MemOS Local — a self-evolving layered memory (L1/L2/L3 + Skills).", ]; if (hasSearch) { lines.push( - "- `memory_search` — search prior traces, policies, world models, and skills.", + "- `memos_search` — search prior traces, policies, world models, and skills.", ); } if (hasEnv) { lines.push( - "- `memory_environment` — list / query accumulated environment knowledge " + + "- `memos_environment` — list / query accumulated environment knowledge " + "(project layout, behavioural rules, constraints). Use before exploring an unfamiliar area.", ); } if (hasGet || hasTimeline) { lines.push( - "- `memory_get` / `memory_timeline` — fetch full bodies + episode timelines.", + "- `memos_get` / `memos_timeline` — fetch full bodies + episode timelines.", + ); + } + if (hasSkillList) { + lines.push( + "- `memos_skill_list` — list MemOS-crystallized skills learned from prior runs.", + ); + } + if (hasSkillGet) { + lines.push( + "- `memos_skill_get` — load the full invocation guide for a MemOS skill.", ); } lines.push( @@ -330,6 +345,12 @@ function register(api: OpenClawPluginApi): void { await r.bridge.handleAfterToolCall(event, ctx); }); + api.on("tool_result_persist", async (event, ctx) => { + const r = await ensureRuntime(); + if (!r) return; + return r.bridge.handleToolResultPersist(event, ctx); + }); + api.on("session_start", async (event, ctx) => { const r = await ensureRuntime(); if (!r) return; diff --git a/apps/memos-local-plugin/adapters/openclaw/openclaw-api.ts b/apps/memos-local-plugin/adapters/openclaw/openclaw-api.ts index c65f2e410..06bf10d6d 100644 --- a/apps/memos-local-plugin/adapters/openclaw/openclaw-api.ts +++ b/apps/memos-local-plugin/adapters/openclaw/openclaw-api.ts @@ -96,6 +96,7 @@ export type OpenClawHookName = | "agent_end" | "before_tool_call" | "after_tool_call" + | "tool_result_persist" | "session_start" | "session_end" | "subagent_spawned" @@ -172,6 +173,15 @@ export interface AfterToolCallEvent { durationMs?: number; } +export interface ToolResultPersistEvent { + toolName: string; + toolCallId?: string; + runId?: string; + message: unknown; + error?: string; + result?: unknown; +} + export interface SessionStartEvent { sessionId: string; sessionKey?: string; @@ -231,6 +241,10 @@ export interface OpenClawHookHandlerMap { event: AfterToolCallEvent, ctx: PluginHookToolContext, ) => void | Promise; + tool_result_persist: ( + event: ToolResultPersistEvent, + ctx: PluginHookToolContext, + ) => { message?: unknown } | void | Promise<{ message?: unknown } | void>; session_start: ( event: SessionStartEvent, ctx: PluginHookSessionContext, diff --git a/apps/memos-local-plugin/adapters/openclaw/tools.ts b/apps/memos-local-plugin/adapters/openclaw/tools.ts index 673d37dc8..f8492e87a 100644 --- a/apps/memos-local-plugin/adapters/openclaw/tools.ts +++ b/apps/memos-local-plugin/adapters/openclaw/tools.ts @@ -176,15 +176,14 @@ async function resolveCore(opts: ToolsOptions): Promise { export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions): void { const bodyCap = opts.maxBodyChars ?? DEFAULT_BODY_CAP; - // ── memory_search ── + // ── memos_search ── api.registerTool( (ctx: OpenClawPluginToolContext): AgentToolDescriptor => ({ - name: "memory_search", + name: "memos_search", label: "Memory Search", description: - "Search the local MemOS memory (traces + policies + world models + skills). " + - "Returns a ranked list of grounded snippets. Prefer this before claiming prior " + - "context is unavailable.", + "Search MemOS memory (local traces + policies + world models + skills, plus connected Team Hub memories). " + + "Returns a ranked list of grounded snippets. Prefer this before claiming prior context is unavailable.", parameters: MemorySearchParams, async execute(_toolCallId: string, params: MemorySearchParamsT) { const started = Date.now(); @@ -213,13 +212,13 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions return textToolResult(details, formatHitList(details.hits)); }, }), - { name: "memory_search" }, + { name: "memos_search" }, ); - // ── memory_get ── + // ── memos_get ── api.registerTool( (ctx: OpenClawPluginToolContext): AgentToolDescriptor => ({ - name: "memory_get", + name: "memos_get", label: "Memory Get", description: 'Fetch the full body of a memory item by id. `kind` can be "trace" (default), ' + @@ -291,13 +290,13 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions return textToolResult(details, `${wm.title}\n\n${details.body}`.trim()); }, }), - { name: "memory_get" }, + { name: "memos_get" }, ); - // ── memory_timeline ── + // ── memos_timeline ── api.registerTool( (ctx: OpenClawPluginToolContext): AgentToolDescriptor => ({ - name: "memory_timeline", + name: "memos_timeline", label: "Memory Timeline", description: "Return the ordered traces inside a single episode. Useful for reconstructing " + @@ -327,13 +326,13 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions return textToolResult(details, text); }, }), - { name: "memory_timeline" }, + { name: "memos_timeline" }, ); - // ── skill_list ── + // ── memos_skill_list ── api.registerTool( (ctx: OpenClawPluginToolContext): AgentToolDescriptor => ({ - name: "skill_list", + name: "memos_skill_list", label: "Skill List", description: "List callable skills the agent can invoke. Filter by status (candidate | active | archived).", @@ -363,10 +362,10 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions return textToolResult(details, text); }, }), - { name: "skill_list" }, + { name: "memos_skill_list" }, ); - // ── memory_environment ── + // ── memos_environment ── // // Dedicated Tier-3 lookup. The turn-start injector already folds // environment knowledge into `prependContext`, but during a long @@ -377,7 +376,7 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions // knowledge on demand. api.registerTool( (ctx: OpenClawPluginToolContext): AgentToolDescriptor => ({ - name: "memory_environment", + name: "memos_environment", label: "Environment Knowledge", description: "Return the agent's accumulated environment knowledge (L3 world models) — " + @@ -437,13 +436,13 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions return textToolResult(details, text); }, }), - { name: "memory_environment" }, + { name: "memos_environment" }, ); - // ── skill_get ── + // ── memos_skill_get ── api.registerTool( (ctx: OpenClawPluginToolContext): AgentToolDescriptor => ({ - name: "skill_get", + name: "memos_skill_get", label: "Skill Get", description: "Return the full invocation guide for a crystallized skill.", parameters: SkillGetParams, @@ -481,7 +480,7 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions return textToolResult(details, `${skill.name}\n\n${skill.invocationGuide}`.trim()); }, }), - { name: "skill_get" }, + { name: "memos_skill_get" }, ); } @@ -506,10 +505,10 @@ function topKParams( /** Exposed for tests + documentation. */ export const TOOL_SCHEMAS = { - memory_search: MemorySearchParams, - memory_get: MemoryGetParams, - memory_timeline: MemoryTimelineParams, - memory_environment: EnvironmentQueryParams, - skill_list: SkillListParams, - skill_get: SkillGetParams, + memos_search: MemorySearchParams, + memos_get: MemoryGetParams, + memos_timeline: MemoryTimelineParams, + memos_environment: EnvironmentQueryParams, + memos_skill_list: SkillListParams, + memos_skill_get: SkillGetParams, } as const; diff --git a/apps/memos-local-plugin/agent-contract/dto.ts b/apps/memos-local-plugin/agent-contract/dto.ts index 7428b8d7a..e9fe8db90 100644 --- a/apps/memos-local-plugin/agent-contract/dto.ts +++ b/apps/memos-local-plugin/agent-contract/dto.ts @@ -9,7 +9,7 @@ export type AgentKind = "openclaw" | "hermes" | string; -export type ShareScope = "private" | "local" | "public" | "hub"; +export type ShareScope = "private" | "public" | "hub"; export interface RuntimeNamespace { agentKind: AgentKind; @@ -430,9 +430,9 @@ export interface SkillDTO extends OwnershipDTO { } | null; /** Last user edit through the viewer's edit modal. */ editedAt?: EpochMs; - /** Number of successful `skill_get` calls that loaded this skill. */ + /** Number of successful `memos_skill_get` calls that loaded this skill. */ usageCount?: number; - /** Last successful `skill_get` time. */ + /** Last successful `memos_skill_get` time. */ lastUsedAt?: EpochMs | null; } @@ -565,6 +565,8 @@ export interface RetrievalHitDTO { ownerProfileId?: string; ownerWorkspaceId?: string | null; shareScope?: ShareScope; + /** Original source trace id when a result is a Hub projection of a local trace. */ + sourceTraceId?: string; } export interface RetrievalResultDTO { @@ -602,7 +604,7 @@ export interface ToolDrivenCtx { namespace?: RuntimeNamespace; sessionId: SessionId; episodeId?: EpisodeId; - /** Which memory tool was called (memory_search / memory_timeline / …). */ + /** Which memory tool was called (memos_search / memos_timeline / …). */ tool: string; /** The tool's input arguments verbatim. */ args: Record; diff --git a/apps/memos-local-plugin/agent-contract/memory-core.ts b/apps/memos-local-plugin/agent-contract/memory-core.ts index 5c648e5bb..9c3ad826c 100644 --- a/apps/memos-local-plugin/agent-contract/memory-core.ts +++ b/apps/memos-local-plugin/agent-contract/memory-core.ts @@ -29,6 +29,7 @@ import type { TurnResultDTO, WorldModelDTO, RuntimeNamespace, + ShareScope, } from "./dto.js"; import type { CoreEvent } from "./events.js"; import type { LogRecord } from "./log-record.js"; @@ -236,7 +237,7 @@ export interface MemoryCore { shareTrace( id: string, share: { - scope: "private" | "local" | "public" | "hub" | null; + scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, @@ -308,7 +309,7 @@ export interface MemoryCore { sharePolicy( id: string, share: { - scope: "private" | "local" | "public" | "hub" | null; + scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, @@ -320,7 +321,7 @@ export interface MemoryCore { shareWorldModel( id: string, share: { - scope: "private" | "local" | "public" | "hub" | null; + scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, @@ -410,7 +411,7 @@ export interface MemoryCore { /** * Paged listing of the rich api_logs table ({@link ApiLogDTO}). - * Fuels the viewer's Logs page — shows every memory_search and + * Fuels the viewer's Logs page — shows every memos_search and * memory_add call with the full input/output JSON. */ listApiLogs(input?: { @@ -458,7 +459,7 @@ export interface MemoryCore { shareSkill( id: SkillId, share: { - scope: "private" | "local" | "public" | "hub" | null; + scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, @@ -476,6 +477,12 @@ export interface MemoryCore { */ patchConfig(patch: Record): Promise>; + // ── optional Hub runtime hooks (HTTP viewer uses these when present) ── + hubAdminSnapshot?(): Promise; + approveHubUser?(userId: string): Promise; + rejectHubUser?(userId: string): Promise; + removeHubUser?(userId: string): Promise; + // ── analytics (viewer dashboard) ── /** * Aggregate counts for the viewer's Analytics tab. `days` controls diff --git a/apps/memos-local-plugin/bridge.cts b/apps/memos-local-plugin/bridge.cts index 506102064..4e7ea1ad4 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -3,7 +3,7 @@ * * Started by non-TypeScript hosts (e.g. the Hermes Python client) via: * - * node_modules/.bin/tsx bridge.cts --agent=hermes + * node_modules/.bin/tsx bridge.cts --agent=hermes --no-viewer * * The `.cts` extension is intentional: it lets the file be required * from CommonJS environments that spawn Node with `require("...")` @@ -30,6 +30,8 @@ const path = require("node:path") as typeof import("node:path"); const fs = require("node:fs") as typeof import("node:fs"); // eslint-disable-next-line @typescript-eslint/no-require-imports const childProcess = require("node:child_process") as typeof import("node:child_process"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const url = require("node:url") as typeof import("node:url"); const BRIDGE_STATUS_HEARTBEAT_MS = 5_000; const BRIDGE_STATUS_STALE_MS = 20_000; @@ -37,6 +39,7 @@ const BRIDGE_STATUS_FILE = "bridge-status.json"; interface BridgeArgs { daemon: boolean; + noViewer: boolean; tcpPort?: number; agent: "openclaw" | "hermes"; } @@ -51,9 +54,10 @@ interface BridgeStatusSnapshot { } function parseArgs(argv: readonly string[]): BridgeArgs { - const args: BridgeArgs = { daemon: false, agent: "openclaw" }; + const args: BridgeArgs = { daemon: false, noViewer: false, agent: "openclaw" }; for (const raw of argv) { if (raw === "--daemon") args.daemon = true; + else if (raw === "--no-viewer") args.noViewer = true; else if (raw.startsWith("--tcp=")) args.tcpPort = Number(raw.slice(6)); else if (raw === "--agent=hermes") args.agent = "hermes"; else if (raw === "--agent=openclaw") args.agent = "openclaw"; @@ -61,25 +65,111 @@ function parseArgs(argv: readonly string[]): BridgeArgs { return args; } +// ─── PID file singleton guard ─────────────────────────────────────────── +// Prevents bridge process accumulation: each new bridge that wants to +// own the viewer port kills the previous holder via its PID file. +// `--no-viewer` (headless) bridges skip this PID file entirely — they don't +// need the port and should coexist with the daemon that owns it. + +const PID_FILENAME = "bridge.pid"; + +function pidFilePath(agent: string): string { + const agentHome = agent === "hermes" ? ".hermes" : ".openclaw"; + return path.join( + process.env.HOME ?? "/tmp", + agentHome, + "memos-plugin", + "daemon", + PID_FILENAME, + ); +} + +function readPidFile(pidPath: string): number | null { + try { + const raw = fs.readFileSync(pidPath, "utf8").trim(); + const pid = parseInt(raw, 10); + if (isNaN(pid) || pid <= 0) return null; + process.kill(pid, 0); // throws if not alive + return pid; + } catch { + return null; + } +} + +function writePidFile(pidPath: string): void { + fs.mkdirSync(path.dirname(pidPath), { recursive: true }); + fs.writeFileSync(pidPath, String(process.pid), "utf8"); +} + +function removePidFile(pidPath: string): void { + try { + const content = fs.readFileSync(pidPath, "utf8").trim(); + if (content === String(process.pid)) fs.unlinkSync(pidPath); + } catch { + /* best-effort; another bridge may have overwritten */ + } +} + +function killExistingBridge(pidPath: string, timeoutMs = 5000): void { + const existingPid = readPidFile(pidPath); + if (existingPid === null || existingPid === process.pid) return; + + process.stderr.write( + `bridge: killing stale bridge pid=${existingPid} before startup\n`, + ); + try { + process.kill(existingPid, "SIGTERM"); + } catch { + return; // already dead + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + process.kill(existingPid, 0); + } catch { + return; // gone + } + childProcess.spawnSync("sleep", ["0.5"]); + } + try { + process.kill(existingPid, "SIGKILL"); + } catch { + /* already dead */ + } +} + async function main(): Promise { const args = parseArgs(process.argv.slice(2)); + // ─── Singleton: kill previous bridge that owns the viewer port ─── + const pidPath = pidFilePath(args.agent); + const ownsViewerPort = args.daemon || !args.noViewer; + const removeOwnedPidFile = () => { + if (ownsViewerPort) removePidFile(pidPath); + }; + if (ownsViewerPort) { + killExistingBridge(pidPath); + writePidFile(pidPath); + } + // Lazy-import ESM core. Using dynamic import so this file remains // CommonJS and stays `require`-able. - const { bootstrapMemoryCoreFull } = (await import( - pathToEsmUrl(path.resolve(__dirname, "core/pipeline/index.ts")) + const { bootstrapMemoryCoreFull } = (await importEsm( + runtimeModule("core/pipeline/index.ts", "dist/core/pipeline/index.js") )) as typeof import("./core/pipeline/index.js"); - const { startStdioServer, waitForShutdown } = (await import( - pathToEsmUrl(path.resolve(__dirname, "bridge/stdio.ts")) + const { startStdioServer, waitForShutdown } = (await importEsm( + runtimeModule("bridge/stdio.ts", "dist/bridge/stdio.js") )) as typeof import("./bridge/stdio.js"); - const { memoryBuffer, rootLogger } = (await import( - pathToEsmUrl(path.resolve(__dirname, "core/logger/index.ts")) + const { memoryBuffer, rootLogger } = (await importEsm( + runtimeModule("core/logger/index.ts", "dist/core/logger/index.js") )) as typeof import("./core/logger/index.js"); - const { startHttpServer } = (await import( - pathToEsmUrl(path.resolve(__dirname, "server/http.ts")) + const { startHttpServer } = (await importEsm( + runtimeModule("server/http.ts", "dist/server/http.js") )) as typeof import("./server/http.js"); - const pkgVersion = require("./package.json").version; + const rootDir = pluginRoot(); + const pkgVersion = require(path.join(rootDir, "package.json")).version; // ─── Host LLM bridge (reverse RPC, lazy-bound to stdio) ──────── // We need to register the bridge BEFORE bootstrap creates the @@ -87,7 +177,8 @@ async function main(): Promise { // non-null bridge), but `stdio` itself doesn't exist until later // in this function. The trick: hand a placeholder closure to // bootstrap that defers actual stdio access to the time of the - // first fallback call. By then `stdio` has been assigned. + // first fallback call. In stdio mode we start the server before + // `core.init()` so startup recovery can also use host fallback. // // Routing through `bootstrapMemoryCoreFull({ hostLlmBridge })` // (instead of having `bridge.cts` call `registerHostLlmBridge` @@ -140,8 +231,8 @@ async function main(): Promise { }, }; - const { Telemetry } = (await import( - pathToEsmUrl(path.resolve(__dirname, "core/telemetry/index.ts")) + const { Telemetry } = (await importEsm( + runtimeModule("core/telemetry/index.ts", "dist/core/telemetry/index.js") )) as typeof import("./core/telemetry/index.js"); const { core, config, home } = await bootstrapMemoryCoreFull({ @@ -151,25 +242,24 @@ async function main(): Promise { hostLlmBridge: args.daemon ? null : lazyHostLlmBridge, }); - const bridgeStatus = - args.agent === "hermes" - ? createBridgeStatusTracker( - path.join(home.root, BRIDGE_STATUS_FILE), - args.daemon, - ) - : null; - await core.init(); - const telemetry = new Telemetry( config.telemetry ?? {}, home.root, pkgVersion, rootLogger.child({ channel: "core.telemetry" }), - __dirname, + rootDir, ); (core as { bindTelemetry?: (t: InstanceType) => void }).bindTelemetry?.(telemetry); telemetry.trackPluginStarted(args.agent); + const bridgeStatus = + args.agent === "hermes" + ? createBridgeStatusTracker( + path.join(home.root, BRIDGE_STATUS_FILE), + args.daemon, + ) + : null; + // Process-level error reporting. Without these handlers a crash in // a background task (capture / reward / L2 inducer) silently kills // the bridge process and never surfaces in ARMS — making "0 @@ -212,6 +302,40 @@ async function main(): Promise { const AGENT_DEFAULT_PORTS = { openclaw: 18799, hermes: 18800 } as const; const viewerPort = AGENT_DEFAULT_PORTS[args.agent]; + let bridgeHeartbeat: + | ReturnType["startHeartbeat"]> + | undefined; + + // In stdio mode the host fallback path is a reverse JSON-RPC request + // over the same pipe as normal bridge traffic. `core.init()` may + // recover dirty episodes and run reflection/reward/L2/skill work; if + // that work hits a broken primary skill-evolver model, the LLM facade + // can fall back to host before init returns. Start stdio first so that + // fallback has a transport instead of tripping the lazy bridge guard. + if (!args.daemon) { + stdio = startStdioServer({ core }); + bridgeStatus?.markConnected(); + bridgeHeartbeat = bridgeStatus?.startHeartbeat(); + void stdio.done.then(() => { + bridgeHeartbeat?.stop(); + bridgeStatus?.markDisconnected("Hermes chat disconnected"); + }); + } + + try { + await core.init(); + } catch (err) { + bridgeHeartbeat?.stop(); + if (stdio) { + try { + await stdio.close(); + } catch { + /* best-effort */ + } + } + throw err; + } + // ─── Daemon mode ────────────────────────────────────────────── // When started with `--daemon`, skip stdio and run as a pure HTTP // viewer daemon. Used by install.sh (post-install) and admin/restart @@ -239,7 +363,7 @@ async function main(): Promise { { port: viewerPort, host: config.viewer.bindHost, - staticRoot: path.resolve(__dirname, "viewer/dist"), + staticRoot: path.resolve(rootDir, "viewer/dist"), agent: args.agent, }, ); @@ -273,6 +397,7 @@ async function main(): Promise { const shutdownDaemon = async (sig: string) => { process.stderr.write(`bridge: daemon received ${sig}, shutting down\n`); + removeOwnedPidFile(); try { await viewer!.close(); } catch { /* best-effort */ } await core.shutdown(); process.exit(0); @@ -284,55 +409,60 @@ async function main(): Promise { } // ─── Normal (stdio) mode ────────────────────────────────────── - // Assign the stdio handle into the closure variable so the host - // LLM bridge (registered earlier inside bootstrap) can dispatch - // reverse-direction requests to the adapter. - stdio = startStdioServer({ core }); - bridgeStatus?.markConnected(); - const bridgeHeartbeat = bridgeStatus?.startHeartbeat(); - void stdio.done.then(() => { - bridgeHeartbeat?.stop(); - bridgeStatus?.markDisconnected("Hermes chat disconnected"); - }); + // The stdio handle was started before `core.init()` above so host + // fallback is available during startup recovery. + const activeStdio = stdio; + if (!activeStdio) { + throw new Error("internal bridge error: stdio server was not started"); + } - // Try to bind the viewer port. EADDRINUSE → stay headless. + // Try to bind the viewer port unless the caller requested a pure stdio + // bridge. Hermes chat uses --no-viewer; the standalone --daemon process is + // the single owner of :18800. let viewer: import("./server/types.js").ServerHandle | null = null; - try { - viewer = await startHttpServer( - { - core, - home, - logTail: () => memoryBuffer().tail({ limit: 200 }), - bridgeStatus: bridgeStatus ? () => bridgeStatus.snapshot() : undefined, - telemetry, - }, - { - port: viewerPort, - host: config.viewer.bindHost, - staticRoot: path.resolve(__dirname, "viewer/dist"), - agent: args.agent, - }, - ); + if (args.noViewer) { process.stderr.write( - `bridge: viewer live at ${viewer.url} (agent=${args.agent})\n`, + `bridge: stdio mode running without viewer (agent=${args.agent})\n`, ); - } catch (err) { - const e = err as NodeJS.ErrnoException; - if (e?.code === "EADDRINUSE") { - process.stderr.write( - `bridge: viewer port :${viewerPort} is already in use — ` + - `${args.agent} will run headless (stdio only). ` + - `Free the port to expose the viewer.\n`, + } else { + try { + viewer = await startHttpServer( + { + core, + home, + logTail: () => memoryBuffer().tail({ limit: 200 }), + bridgeStatus: bridgeStatus ? () => bridgeStatus.snapshot() : undefined, + telemetry, + }, + { + port: viewerPort, + host: config.viewer.bindHost, + staticRoot: path.resolve(rootDir, "viewer/dist"), + agent: args.agent, + }, ); - } else { process.stderr.write( - `bridge: viewer failed to start: ${e?.message ?? String(err)}\n`, + `bridge: viewer live at ${viewer.url} (agent=${args.agent})\n`, ); + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e?.code === "EADDRINUSE") { + process.stderr.write( + `bridge: viewer port :${viewerPort} is already in use — ` + + `${args.agent} will run headless (stdio only). ` + + `Free the port to expose the viewer.\n`, + ); + } else { + process.stderr.write( + `bridge: viewer failed to start: ${e?.message ?? String(err)}\n`, + ); + } } } const shutdown = async (sig: string) => { process.stderr.write(`bridge: received ${sig}, shutting down\n`); + removeOwnedPidFile(); if (viewer) { try { await viewer.close(); @@ -340,7 +470,7 @@ async function main(): Promise { /* best-effort */ } } - await waitForShutdown(core, stdio!); + await waitForShutdown(core, activeStdio); process.exit(0); }; @@ -348,7 +478,7 @@ async function main(): Promise { process.on("SIGTERM", () => void shutdown("SIGTERM")); // Keep the process alive until stdin ends (client disconnects). - await stdio.done; + await activeStdio.done; // If a viewer is running, keep the process alive as a daemon so the // memory panel stays accessible between `hermes chat` sessions. @@ -360,6 +490,7 @@ async function main(): Promise { const keepalive = setInterval(() => { if (viewer!.closed) { clearInterval(keepalive); + removeOwnedPidFile(); void core.shutdown().then(() => process.exit(0)); } }, 5_000); @@ -368,15 +499,35 @@ async function main(): Promise { } // No viewer (headless bridge) — clean exit. + removeOwnedPidFile(); await core.shutdown(); process.exit(0); } +function pluginRoot(): string { + // Source entry: /bridge.cts. Built entry: /dist/bridge.cjs. + if (fs.existsSync(path.join(__dirname, "package.json"))) return __dirname; + const parent = path.resolve(__dirname, ".."); + if (fs.existsSync(path.join(parent, "package.json"))) return parent; + return __dirname; +} + +function runtimeModule(sourceRel: string, distRel: string): string { + const root = pluginRoot(); + const distAbs = path.resolve(root, distRel); + const sourceAbs = path.resolve(root, sourceRel); + return pathToEsmUrl(fs.existsSync(distAbs) ? distAbs : sourceAbs); +} + function pathToEsmUrl(abs: string): string { - const u = abs.startsWith("/") ? `file://${abs}` : `file:///${abs}`; - return u; + return url.pathToFileURL(abs).href; } +const importEsm = new Function( + "specifier", + "return import(specifier)", +) as (specifier: string) => Promise; + /** * Best-effort error classification for ARMS `plugin_error.error_type`. * @@ -529,8 +680,9 @@ function isHermesChatRunning(): boolean { } void main().catch((err) => { + const detail = err instanceof Error ? err.stack ?? err.message : String(err); process.stderr.write( - `bridge: fatal: ${err instanceof Error ? err.message : String(err)}\n`, + `bridge: fatal: ${detail}\n`, ); process.exit(1); }); diff --git a/apps/memos-local-plugin/core/capture/ALGORITHMS.md b/apps/memos-local-plugin/core/capture/ALGORITHMS.md index 0c117e24b..15a15ac64 100644 --- a/apps/memos-local-plugin/core/capture/ALGORITHMS.md +++ b/apps/memos-local-plugin/core/capture/ALGORITHMS.md @@ -119,7 +119,8 @@ Bookkeeping (`CaptureResult.llmCalls`): Stable prompt fingerprint: -- `op = capture.reflection.batch.v1` (see `BATCH_OP_TAG` constant). +- `op = capture.reflection.batch.v3` (see `BATCH_OP_TAG` constant; version + matches `BATCH_REFLECTION_PROMPT.version`). Bumping `BATCH_REFLECTION_PROMPT.version` changes the op tag so audit rows remain attributable. diff --git a/apps/memos-local-plugin/core/capture/README.md b/apps/memos-local-plugin/core/capture/README.md index 2f43f7c81..49adcf329 100644 --- a/apps/memos-local-plugin/core/capture/README.md +++ b/apps/memos-local-plugin/core/capture/README.md @@ -148,6 +148,34 @@ Failures in the batched call (LLM throw, malformed JSON, length mismatch) are logged as a `stage: "batch"` warning and capture **automatically falls back** to the per-step path — no traces are lost. +## 6b. Downstream preview for long per-step reflection + +For long episodes, `batchMode: "auto"` still falls back to per-step scoring +when `stepCount > batchThreshold`. Operators can enrich that fallback without +making it serial: + +```yaml +algorithm: + capture: + reflectionContextMode: task_downstream + longEpisodeReflectMode: per_step_downstream +``` + +This keeps `runConcurrently(...)` intact. Before launching the per-step work, +capture precomputes a read-only preview for each step from the already +normalized episode: + +- up to `downstreamStepCount` following steps, capped at 3; +- labels are always `step+1`, `step+2`, `step+3`; +- `text` steps are inserted as standalone downstream text blocks; +- `tooluse` steps include tool names and tool output; +- if a downstream tool step already has adapter/extracted reflection, that + existing reflection is included; newly synthesized reflections from the + same run are not used, so there is no reverse-order dependency. + +`reflectionContextMode: task` is the default, preserving task-summary +enrichment while leaving downstream preview opt-in. + ## 7. Embedding - When `config.capture.embedTraces=true` and `embedder` is non-null, we diff --git a/apps/memos-local-plugin/core/capture/alpha-scorer.ts b/apps/memos-local-plugin/core/capture/alpha-scorer.ts index 6afb92f97..11ccee959 100644 --- a/apps/memos-local-plugin/core/capture/alpha-scorer.ts +++ b/apps/memos-local-plugin/core/capture/alpha-scorer.ts @@ -24,13 +24,14 @@ import { import { REFLECTION_SCORE_PROMPT } from "../llm/prompts/reflection.js"; import { rootLogger } from "../logger/index.js"; import { sanitizeDerivedText } from "../safety/content.js"; -import type { NormalizedStep, ReflectionScore } from "./types.js"; +import type { NormalizedStep, ReflectionContext, ReflectionScore } from "./types.js"; -export interface AlphaInput { +export interface AlphaInput extends ReflectionContext { step: NormalizedStep; reflectionText: string; episodeId?: string; phase?: string; + outcomeMaxChars?: number; } export interface AlphaOutput { @@ -48,6 +49,9 @@ export async function scoreReflection( const thinking = (input.step.agentThinking ?? "").trim(); const userPayload = [ + `TASK CONTEXT:`, + input.taskSummary?.trim().slice(0, 1_200) || "(none)", + ``, `STATE:`, input.step.userText.slice(0, 1_200) || "(none)", ``, @@ -68,7 +72,10 @@ export async function scoreReflection( ``, `OUTCOME:`, // Use the last 1 tool output as the "outcome" signal if present. - lastToolOutcome(input.step), + lastToolOutcome(input.step, input.outcomeMaxChars ?? 600), + ``, + `DOWNSTREAM STEP PREVIEW:`, + formatDownstreamPreview(input), ``, `REFLECTION:`, input.reflectionText.slice(0, 1_500), @@ -130,6 +137,7 @@ export async function scoreReflection( usable, rawAlpha, model: rsp.servedBy, + reason, }); return { alpha: finalAlpha, usable, reason, model: rsp.servedBy }; @@ -169,12 +177,34 @@ function outputOf(t: { output?: unknown }): string { } } -function lastToolOutcome(step: NormalizedStep): string { +function lastToolOutcome(step: NormalizedStep, maxChars: number): string { const last = step.toolCalls[step.toolCalls.length - 1]; if (!last) return "(assistant-only step)"; - return (last.errorCode ? `ERROR[${last.errorCode}] ` : "") + truncate(outputOf(last), 600); + return (last.errorCode ? `ERROR[${last.errorCode}] ` : "") + truncate(outputOf(last), maxChars); } function truncate(s: string, n: number): string { - return s.length > n ? s.slice(0, n) + "…" : s; + return s.length > n ? s.slice(0, n) + "..." : s; +} + +function formatDownstreamPreview(input: AlphaInput): string { + const preview = input.downstream ?? []; + if (preview.length === 0) return "(none)"; + return preview + .map((item) => { + const label = `step+${item.offset}`; + if (item.kind === "tooluse") { + const lines = [ + `[${label}] type=tooluse`, + `tool_names: ${item.toolNames?.join(", ") || "(unknown)"}`, + `tool_output: ${item.toolOutput?.trim() || "(none)"}`, + ]; + if (item.reflection?.trim()) { + lines.push(`existing_reflection: ${item.reflection.trim()}`); + } + return lines.join("\n"); + } + return [`[${label}] type=text`, item.text?.trim() || "(empty)"].join("\n"); + }) + .join("\n\n"); } diff --git a/apps/memos-local-plugin/core/capture/batch-scorer.ts b/apps/memos-local-plugin/core/capture/batch-scorer.ts index 31d3831df..e7b8ab50f 100644 --- a/apps/memos-local-plugin/core/capture/batch-scorer.ts +++ b/apps/memos-local-plugin/core/capture/batch-scorer.ts @@ -23,7 +23,8 @@ * for us, and on hard failure capture.ts falls back to per-step. * * Wire format ↔ prompt: - * Send `{steps: [{idx, state, action, outcome, reflection, synth_allowed}]}`. + * Send `{ host_context?, task_context?, steps: [{idx, state, action, outcome, reflection, synth_allowed}] }`. + * `task_context` is episode-level task summary (nullable string). * Receive `{scores: [{idx, reflection_text, alpha, usable, reason}]}`. * See `core/llm/prompts/reflection.ts :: BATCH_REFLECTION_PROMPT`. */ @@ -57,6 +58,7 @@ export interface BatchScoreOptions { synthReflections: boolean; episodeId?: string; phase?: string; + taskSummary?: string | null; /** * Cap per-field text we shovel into the prompt. Default 1_200 chars per * `state`/`outcome`, 1_500 per `action`. Mirrors per-step prompts. @@ -122,6 +124,7 @@ export async function batchScoreReflections( const payload = { host_context: batchHostContext(inputs, llm), + task_context: opts.taskSummary?.trim().slice(0, 1_200) || null, steps: inputs.map((input, i) => ({ idx: i, state: clip(input.step.userText, fieldChars.state), @@ -188,6 +191,7 @@ export async function batchScoreReflections( const usable = Boolean(raw.usable); const rawAlpha = clamp01(numOrZero(raw.alpha)); const alpha = usable ? rawAlpha : 0; + const reason = typeof raw.reason === "string" ? sanitizeDerivedText(raw.reason) : null; let finalText: string | null; let source: ReflectionScore["source"]; @@ -210,6 +214,7 @@ export async function batchScoreReflections( text: finalText, alpha, usable: usable && finalText !== null, + reason, source, model: rsp.servedBy, }; diff --git a/apps/memos-local-plugin/core/capture/capture.ts b/apps/memos-local-plugin/core/capture/capture.ts index a0b83e612..9d52f749e 100644 --- a/apps/memos-local-plugin/core/capture/capture.ts +++ b/apps/memos-local-plugin/core/capture/capture.ts @@ -19,7 +19,7 @@ import type { Embedder } from "../embedding/index.js"; import type { LlmClient } from "../llm/index.js"; import { rootLogger } from "../logger/index.js"; import { ids } from "../id.js"; -import type { EpisodeRow, TraceRow, TraceId } from "../types.js"; +import type { EpisodeRow, TraceRow, TraceId, EpochMs } from "../types.js"; import type { makeEmbeddingRetryQueueRepo } from "../storage/repos/embedding_retry_queue.js"; import type { makeTracesRepo } from "../storage/repos/traces.js"; import type { EpisodesRepo } from "../session/persistence.js"; @@ -39,7 +39,9 @@ import type { CaptureEventBus, CaptureInput, CaptureResult, + DownstreamStepPreview, NormalizedStep, + ReflectionContext, ReflectionScore, ScoredStep, StepCandidate, @@ -79,6 +81,11 @@ export interface CaptureRunner { * Safe to call after every `addTurn` cycle. */ runLite(input: CaptureInput): Promise; + /** + * Lightweight memory capture. Writes one trace per user/assistant turn + * instead of per tool/action step, and never emits `capture.done`. + */ + runLightweight(input: CaptureInput): Promise; /** * Topic-end "reflect" capture. Runs the batch reflection scorer over * EVERY step of the (now-finalized) episode in one LLM call so the @@ -231,6 +238,127 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { return result; } + async function runLightweight(input: CaptureInput): Promise { + const startedAt = now(); + const warnings: CaptureResult["warnings"] = []; + const llmCalls = newLlmCounters(); + + emit({ + kind: "capture.started", + episodeId: input.episode.id, + sessionId: input.episode.sessionId, + }); + + const extractStart = now(); + const rawAll = extractSteps(input.episode); + const existingTraces = deps.tracesRepo.list({ episodeId: input.episode.id }); + const seenTurnIds = new Set( + existingTraces + .map((t) => t.turnId) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)), + ); + const rawByTurn = new Map(); + for (const step of rawAll) { + const turnId = pickTurnId(step.meta, step.ts); + if (seenTurnIds.has(turnId)) continue; + const bucket = rawByTurn.get(turnId) ?? []; + bucket.push(step); + rawByTurn.set(turnId, bucket); + } + const raw = Array.from(rawByTurn.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([turnId, steps]) => mergeTurnSteps(input.episode.id, turnId, steps)); + const extractMs = now() - extractStart; + + const normStart = now(); + const normalized = normalizeSteps(raw, deps.cfg); + const normalizeMs = now() - normStart; + + if (normalized.length === 0) { + return emptyResult(input, startedAt, { + extract: extractMs, + normalize: normalizeMs, + }, llmCalls, warnings); + } + + const scored: ScoredStep[] = normalized.map((s) => ({ + ...s, + reflection: { text: null, alpha: 0, usable: false, source: "none" }, + })); + + const summarizeStart = now(); + const { summaries, summarizeMs } = await runSummarize( + scored, + summarizeStart, + llmCalls, + warnings, + { episodeId: input.episode.id, phase: "lightweight" }, + ); + + const { vecs: summaryOnlyVecs, embedMs } = await runEmbed( + scored, + summaries, + warnings, + { summaryOnly: true }, + ); + + const persistStart = now(); + const rows = buildRows(scored, summaries, summaryOnlyVecs, input.episode, { + lightweightMemory: true, + }); + const persisted = await persistRows(rows, input, warnings, { + skipActionVectorRetry: true, + }); + if (!persisted) { + return finalResult( + input, + startedAt, + [], + scored.map(toCandidate(rows)), + { + extract: extractMs, + normalize: normalizeMs, + reflect: 0, + alpha: 0, + summarize: summarizeMs, + embed: embedMs, + persist: now() - persistStart, + }, + llmCalls, + warnings, + ); + } + const persistMs = now() - persistStart; + + const result = finalResult( + input, + startedAt, + rows.map((r) => r.id), + buildTraceCandidates(scored, rows), + { + extract: extractMs, + normalize: normalizeMs, + reflect: 0, + alpha: 0, + summarize: summarizeMs, + embed: embedMs, + persist: persistMs, + }, + llmCalls, + warnings, + ); + log.info("capture.lightweight.done", { + episodeId: input.episode.id, + sessionId: input.episode.sessionId, + traces: result.traceIds.length, + llmCalls, + totalMs: result.completedAt - startedAt, + warnings: warnings.length, + }); + emit({ kind: "capture.lite.done", result }); + return result; + } + /** * Topic-end reflect pass — see `CaptureRunner.runReflect` for contract. * Reads every trace already written for this episode, batch-scores @@ -314,12 +442,38 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { const reflectStart = now(); const rLlm = deps.reflectLlm ?? deps.llm; const useBatch = shouldBatch(deps.cfg, normalized.length, rLlm !== null); + const contextEnabled = contextModeFor(deps.cfg, useBatch, normalized.length); + const taskSummary = contextEnabled.includeTask + ? buildTaskReflectionSummary(input.episode, normalized, deps.cfg.taskContextMaxChars) + : null; + const downstreamByStep = contextEnabled.includeDownstream + ? buildDownstreamStepPreviews(normalized, deps.cfg) + : normalized.map(() => []); + log.info("capture.reflect.scoring.start", { + episodeId: input.episode.id, + sessionId: input.episode.sessionId, + steps: normalized.length, + mode: useBatch ? "batch" : contextEnabled.includeDownstream ? "per_step_downstream" : "per_step", + reflectionContextMode: deps.cfg.reflectionContextMode, + downstreamPreview: contextEnabled.includeDownstream, + provider: rLlm?.provider ?? "none", + model: rLlm?.model ?? "none", + taskSummary: taskSummary ? taskSummary.slice(0, 240) : null, + }); let scored: ScoredStep[] = []; if (useBatch) { - scored = await runBatchScoring(normalized, rLlm!, deps, warnings, llmCalls, input.episode.id); + scored = await runBatchScoring(normalized, rLlm!, deps, warnings, llmCalls, input.episode.id, taskSummary); } if (!useBatch || scored.length === 0) { - scored = await runPerStepScoring(normalized, rLlm, deps, warnings, llmCalls, input.episode.id); + scored = await runPerStepScoring( + normalized, + rLlm, + deps, + warnings, + llmCalls, + input.episode.id, + buildReflectionContexts(normalized, taskSummary, downstreamByStep), + ); } const reflectMs = now() - reflectStart; @@ -339,6 +493,20 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { continue; } try { + log.info("capture.reflect.trace.scored", { + episodeId: input.episode.id, + sessionId: input.episode.sessionId, + traceId: row.id, + stepKey: s.key, + ts: s.ts, + turnId: pickTurnId(s.meta, s.ts), + alpha: s.reflection.alpha ?? 0, + usable: s.reflection.usable, + reason: s.reflection.reason ?? null, + source: s.reflection.source, + model: s.reflection.model ?? null, + reflection: s.reflection.text, + }); deps.tracesRepo.updateReflection(row.id, { reflection: s.reflection.text, alpha: s.reflection.alpha ?? 0, @@ -482,13 +650,14 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { scored: ScoredStep[], summaries: string[], warnings: CaptureResult["warnings"], + opts: { summaryOnly?: boolean } = {}, ): Promise<{ vecs: VecPair[]; embedMs: number }> { const start = now(); if (!deps.cfg.embedTraces || !deps.embedder) { return { vecs: scored.map(() => ({ summary: null, action: null })), embedMs: now() - start }; } try { - const vecs = await embedSteps(deps.embedder, scored, summaries); + const vecs = await embedSteps(deps.embedder, scored, summaries, opts); return { vecs, embedMs: now() - start }; } catch (err) { warnings.push({ @@ -505,6 +674,7 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { summaries: string[], vecs: VecPair[], episode: CaptureInput["episode"], + opts: { lightweightMemory?: boolean } = {}, ): TraceRow[] { const owner = ownerFromEpisode(episode); const traces: TraceCandidate[] = scored.map((s, i) => ({ @@ -535,7 +705,7 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { // so retrieval can find the row immediately; reward backprop // overwrites it once the topic is reflected on. priority: 0.5, - tags: t.tags, + tags: opts.lightweightMemory ? mergeTags(t.tags, ["lightweight_memory"]) : t.tags, errorSignatures: extractErrorSignatures({ toolCalls: t.toolCalls, agentText: t.agentText, @@ -597,6 +767,7 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { rows: TraceRow[], input: CaptureInput, warnings: CaptureResult["warnings"], + opts: { skipActionVectorRetry?: boolean } = {}, ): Promise { const existingBeforeInsert = deps.tracesRepo.list({ episodeId: input.episode.id }); const seenSignatures = new Set(existingBeforeInsert.map(traceIdentitySignature)); @@ -620,7 +791,7 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { try { for (const row of rows) deps.tracesRepo.insert(row); - enqueueMissingTraceVectors(rows, warnings); + enqueueMissingTraceVectors(rows, warnings, opts); } catch (err) { const failure = errDetail(err); log.error("persist.failed", { @@ -760,6 +931,7 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { function enqueueMissingTraceVectors( rows: TraceRow[], warnings: CaptureResult["warnings"], + opts: { skipActionVectorRetry?: boolean } = {}, ): void { if (!deps.cfg.embedTraces || !deps.embeddingRetryQueue || !deps.embedder) return; const queuedAt = now(); @@ -776,7 +948,7 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { }); queued++; } - if (!row.vecAction) { + if (!opts.skipActionVectorRetry && !row.vecAction) { deps.embeddingRetryQueue.enqueue({ id: `er_${ids.span()}`, targetKind: "trace", @@ -797,6 +969,10 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { } } + function mergeTags(existing: readonly string[], extra: readonly string[]): string[] { + return Array.from(new Set([...existing, ...extra])).sort(); + } + function finalResult( input: CaptureInput, startedAt: number, @@ -836,7 +1012,7 @@ export function createCaptureRunner(deps: CaptureDeps): CaptureRunner { }); } - return { runLite, runReflect }; + return { runLite, runLightweight, runReflect }; } // ─── helpers ──────────────────────────────────────────────────────────────── @@ -857,6 +1033,35 @@ function shouldBatch(cfg: CaptureConfig, stepCount: number, hasLlm: boolean): bo return stepCount <= cfg.batchThreshold; } +function contextModeFor( + cfg: CaptureConfig, + useBatch: boolean, + stepCount: number, +): { includeTask: boolean; includeDownstream: boolean } { + const mode = cfg.reflectionContextMode; + const includeTask = mode === "task" || mode === "task_downstream"; + const wantsDownstream = mode === "downstream" || mode === "task_downstream"; + const longPerStep = !useBatch && stepCount > cfg.batchThreshold; + const includeDownstream = + wantsDownstream && + cfg.longEpisodeReflectMode === "per_step_downstream" && + cfg.downstreamStepCount > 0 && + cfg.downstreamContextMaxChars > 0 && + longPerStep; + return { includeTask, includeDownstream }; +} + +function buildReflectionContexts( + steps: readonly NormalizedStep[], + taskSummary: string | null, + downstreamByStep: readonly DownstreamStepPreview[][], +): ReflectionContext[] { + return steps.map((_, idx) => ({ + taskSummary, + downstream: downstreamByStep[idx] ?? [], + })); +} + async function runBatchScoring( normalized: NormalizedStep[], llm: LlmClient, @@ -864,6 +1069,7 @@ async function runBatchScoring( warnings: CaptureResult["warnings"], llmCalls: { reflectionSynth: number; alphaScoring: number; batchedReflection: number }, episodeId: string, + taskSummary: string | null, ): Promise { const inputs: BatchScoreInput[] = normalized.map((step) => ({ step, @@ -875,6 +1081,7 @@ async function runBatchScoring( synthReflections: deps.cfg.synthReflections, episodeId, phase: "reflect", + taskSummary, }); llmCalls.batchedReflection += 1; return normalized.map((step, i) => ({ @@ -901,12 +1108,14 @@ async function runPerStepScoring( warnings: CaptureResult["warnings"], llmCalls: { reflectionSynth: number; alphaScoring: number }, episodeId: string, + contexts: ReflectionContext[], ): Promise { const concurrency = Math.max(1, deps.cfg.llmConcurrency); - return runConcurrently(normalized, concurrency, async (step): Promise => { - const { score, synthCount } = await resolveReflection(step, llm, deps, warnings, episodeId); + return runConcurrently(normalized, concurrency, async (step, idx): Promise => { + const context = contexts[idx] ?? {}; + const { score, synthCount } = await resolveReflection(step, llm, deps, warnings, episodeId, context); llmCalls.reflectionSynth += synthCount; - const finalScore = await resolveAlpha(step, score, llm, deps, warnings, episodeId); + const finalScore = await resolveAlpha(step, score, llm, deps, warnings, episodeId, context); if (finalScore !== score) llmCalls.alphaScoring += 1; return { ...step, reflection: finalScore }; }); @@ -918,6 +1127,7 @@ async function resolveReflection( deps: CaptureDeps, warnings: CaptureResult["warnings"], episodeId: string, + context: ReflectionContext, ): Promise<{ score: ReflectionScore; synthCount: number }> { const adapterProvided = step.rawReflection !== null && step.rawReflection.trim().length > 0; const extracted = extractReflection(step); @@ -931,7 +1141,13 @@ async function resolveReflection( return { score: disabledScore(null, "none"), synthCount: 0 }; } try { - const synth = await synthesizeReflection(llm, step, { episodeId, phase: "reflect" }); + const synth = await synthesizeReflection(llm, step, { + episodeId, + phase: "reflect", + taskSummary: context.taskSummary, + downstream: context.downstream, + outcomeMaxChars: deps.cfg.synthOutcomeMaxChars, + }); if (synth.text) { return { score: { text: synth.text, alpha: null, usable: true, source: "synth", model: synth.model }, @@ -956,6 +1172,7 @@ async function resolveAlpha( deps: CaptureDeps, warnings: CaptureResult["warnings"], episodeId: string, + context: ReflectionContext, ): Promise { if (!current.text) return current; // nothing to grade if (!deps.cfg.alphaScoring || !llm) return current; @@ -966,11 +1183,15 @@ async function resolveAlpha( reflectionText: current.text, episodeId, phase: "reflect", + taskSummary: context.taskSummary, + downstream: context.downstream, + outcomeMaxChars: deps.cfg.synthOutcomeMaxChars, }); return { ...current, alpha: scored.alpha, usable: scored.usable, + reason: scored.reason, model: scored.model, }; } catch (err) { @@ -1018,6 +1239,117 @@ function traceActionText(row: Pick): string return [row.agentText.trim(), toolSig].filter((s) => s.length > 0).join("\n---\n") || "(empty)"; } +function buildTaskReflectionSummary( + episode: CaptureInput["episode"], + steps: readonly NormalizedStep[], + maxChars = 1_200, +): string | null { + const firstUser = episode.turns.find((t) => t.role === "user" && t.content.trim()); + const finalAssistant = [...episode.turns] + .reverse() + .find((t) => t.role === "assistant" && t.content.trim()); + const toolNames = Array.from( + new Set(steps.flatMap((s) => s.toolCalls.map((t) => t.name).filter(Boolean))), + ).slice(0, 12); + + const parts = [ + firstUser ? `Task: ${clipForPrompt(firstUser.content, Math.min(500, maxChars))}` : "", + `Intent: ${episode.intent.kind} (${episode.intent.reason})`, + finalAssistant ? `Final assistant response: ${clipForPrompt(finalAssistant.content, Math.min(500, maxChars))}` : "", + toolNames.length > 0 ? `Tools used: ${toolNames.join(", ")}` : "", + ].filter(Boolean); + + const summary = parts.length > 0 ? parts.join("\n") : null; + return summary ? clipForPrompt(summary, maxChars) : null; +} + +function buildDownstreamStepPreviews( + steps: readonly NormalizedStep[], + cfg: CaptureConfig, +): DownstreamStepPreview[][] { + return steps.map((_, idx) => { + const out: DownstreamStepPreview[] = []; + let usedChars = 0; + const count = Math.max(0, Math.min(3, cfg.downstreamStepCount)); + for (let offset = 1; offset <= count; offset++) { + const step = steps[idx + offset]; + if (!step) break; + const remaining = cfg.downstreamContextMaxChars - usedChars; + if (remaining <= 0) break; + const item = downstreamPreviewForStep( + step, + offset as 1 | 2 | 3, + Math.min(cfg.downstreamPerStepMaxChars, remaining), + ); + usedChars += previewSize(item); + out.push(item); + } + return out; + }); +} + +function downstreamPreviewForStep( + step: NormalizedStep, + offset: 1 | 2 | 3, + maxChars: number, +): DownstreamStepPreview { + const existingReflection = extractReflection(step); + if (step.toolCalls.length > 0) { + return { + offset, + kind: "tooluse", + toolNames: step.toolCalls.map((t) => t.name).filter(Boolean), + toolOutput: clipForPrompt(summarizeToolOutputs(step), maxChars), + reflection: existingReflection ? clipForPrompt(existingReflection, Math.floor(maxChars / 2)) : null, + }; + } + return { + offset, + kind: "text", + text: clipForPrompt(textPreviewForStep(step), maxChars), + }; +} + +function summarizeToolOutputs(step: NormalizedStep): string { + return step.toolCalls + .map((t) => { + const label = t.errorCode ? `${t.name} ERROR[${t.errorCode}]` : t.name; + const output = outputOfToolCall(t); + return `${label}: ${output || "(no output)"}`; + }) + .join("\n"); +} + +function outputOfToolCall(t: { output?: unknown }): string { + if (t.output === undefined || t.output === null) return ""; + if (typeof t.output === "string") return t.output; + try { + return JSON.stringify(t.output); + } catch { + return String(t.output); + } +} + +function textPreviewForStep(step: NormalizedStep): string { + const parts = [ + step.userText.trim() ? `state: ${step.userText.trim()}` : "", + step.agentText.trim() ? `action: ${step.agentText.trim()}` : "", + ].filter(Boolean); + return parts.join("\n") || "(empty)"; +} + +function previewSize(item: DownstreamStepPreview): number { + return [ + item.kind, + item.text, + item.toolNames?.join(", "), + item.toolOutput, + item.reflection, + ] + .filter(Boolean) + .join("\n").length; +} + function stringMeta(meta: Record, key: string): string | undefined { const value = meta[key]; return typeof value === "string" && value.trim() ? value.trim() : undefined; @@ -1033,6 +1365,55 @@ function safeStringify(v: unknown): string { } } +function clipForPrompt(s: string, n: number): string { + return s.length > n ? `${s.slice(0, n)}...` : s; +} + +function mergeTurnSteps( + episodeId: string, + turnId: number, + steps: readonly StepCandidate[], +): StepCandidate { + const ordered = [...steps].sort((a, b) => a.ts - b.ts); + const first = ordered[0]!; + const userText = firstNonEmpty(ordered.map((s) => s.userText)); + const agentText = ordered + .map((s) => s.agentText.trim()) + .filter(Boolean) + .join("\n\n"); + const agentThinking = ordered + .map((s) => s.agentThinking?.trim() ?? "") + .filter(Boolean) + .join("\n\n") || null; + const rawReflection = firstNonEmpty(ordered.map((s) => s.rawReflection ?? "")); + const toolCalls = ordered.flatMap((s) => s.toolCalls); + const lastTs = ordered.reduce((m, s) => Math.max(m, s.ts), first.ts); + + return { + key: `${episodeId}:${turnId}:lightweight`, + ts: lastTs as EpochMs, + userText, + agentText, + agentThinking, + toolCalls, + rawReflection: rawReflection || null, + depth: Math.min(...ordered.map((s) => s.depth)), + isSubagent: ordered.some((s) => s.isSubagent), + meta: { + ...ordered.reduce>( + (acc, s) => ({ ...acc, ...s.meta }), + {}, + ), + turnId, + lightweightMemory: true, + }, + }; +} + +function firstNonEmpty(values: readonly string[]): string { + return values.map((v) => v.trim()).find(Boolean) ?? ""; +} + /** * Pull the `turnId` stamped by `step-extractor` out of the * `StepCandidate.meta` blob. Falls back to the trace's own `ts` so diff --git a/apps/memos-local-plugin/core/capture/embedder.ts b/apps/memos-local-plugin/core/capture/embedder.ts index 92c999586..65fa71cfb 100644 --- a/apps/memos-local-plugin/core/capture/embedder.ts +++ b/apps/memos-local-plugin/core/capture/embedder.ts @@ -33,6 +33,7 @@ export async function embedSteps( * the viewer displays. */ summaryOverrides?: readonly string[], + opts: { summaryOnly?: boolean } = {}, ): Promise { const log = rootLogger.child({ channel: "core.capture.embed" }); if (steps.length === 0) return []; @@ -43,6 +44,17 @@ export async function embedSteps( return summaryText(s); }); const actionTexts = steps.map(actionText); + if (opts.summaryOnly) { + try { + const vecs = await embedder.embedMany( + summaryTexts.map((t) => ({ text: t || "(empty)", role: "document" as const })), + ); + return steps.map((_, i) => ({ summary: vecs[i] ?? null, action: null })); + } catch (err) { + log.warn("embed.failed_all", { err: errDetail(err), stepCount: steps.length }); + return steps.map(() => ({ summary: null, action: null })); + } + } // Pack summary first then action — both in the same batch to amortize // HTTP round trips when the provider is remote. const inputs = [ diff --git a/apps/memos-local-plugin/core/capture/reflection-synth.ts b/apps/memos-local-plugin/core/capture/reflection-synth.ts index 31ace2461..bc076a320 100644 --- a/apps/memos-local-plugin/core/capture/reflection-synth.ts +++ b/apps/memos-local-plugin/core/capture/reflection-synth.ts @@ -14,7 +14,7 @@ import { MemosError } from "../../agent-contract/errors.js"; import type { LlmClient } from "../llm/index.js"; import { rootLogger } from "../logger/index.js"; import { sanitizeDerivedText } from "../safety/content.js"; -import type { NormalizedStep } from "./types.js"; +import type { NormalizedStep, ReflectionContext } from "./types.js"; const SYSTEM = `You are reviewing a single step of an AI agent's decision. @@ -29,15 +29,24 @@ export interface SynthesizedReflection { model: string; } +export interface ReflectionSynthContext extends ReflectionContext { + episodeId?: string; + phase?: string; + outcomeMaxChars?: number; +} + export async function synthesizeReflection( llm: LlmClient, step: NormalizedStep, - context?: { episodeId?: string; phase?: string }, + context?: ReflectionSynthContext, ): Promise { const log = rootLogger.child({ channel: "core.capture.reflection" }); const thinking = (step.agentThinking ?? "").trim(); const userPayload = [ + `TASK CONTEXT:`, + context?.taskSummary?.trim().slice(0, 1_200) || "(none)", + ``, `USER/OBSERVATION:`, step.userText.slice(0, 1_200) || "(none)", ``, @@ -55,6 +64,12 @@ export async function synthesizeReflection( ) .join("\n")}` : "", + ``, + `OUTCOME:`, + lastToolOutcome(step, context?.outcomeMaxChars ?? 600), + ``, + `DOWNSTREAM STEP PREVIEW:`, + formatDownstreamPreview(context), ] .filter(Boolean) .join("\n"); @@ -99,3 +114,45 @@ function safeStringify(v: unknown): string { return String(v); } } + +function lastToolOutcome(step: NormalizedStep, maxChars: number): string { + const last = step.toolCalls[step.toolCalls.length - 1]; + if (!last) return "(assistant-only step)"; + return (last.errorCode ? `ERROR[${last.errorCode}] ` : "") + truncate(outputOf(last), maxChars); +} + +function outputOf(t: { output?: unknown }): string { + if (t.output === undefined || t.output === null) return ""; + if (typeof t.output === "string") return t.output; + try { + return JSON.stringify(t.output); + } catch { + return String(t.output); + } +} + +function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n) + "..." : s; +} + +function formatDownstreamPreview(context?: ReflectionSynthContext): string { + const preview = context?.downstream ?? []; + if (preview.length === 0) return "(none)"; + return preview + .map((item) => { + const label = `step+${item.offset}`; + if (item.kind === "tooluse") { + const lines = [ + `[${label}] type=tooluse`, + `tool_names: ${item.toolNames?.join(", ") || "(unknown)"}`, + `tool_output: ${item.toolOutput?.trim() || "(none)"}`, + ]; + if (item.reflection?.trim()) { + lines.push(`existing_reflection: ${item.reflection.trim()}`); + } + return lines.join("\n"); + } + return [`[${label}] type=text`, item.text?.trim() || "(empty)"].join("\n"); + }) + .join("\n\n"); +} diff --git a/apps/memos-local-plugin/core/capture/subscriber.ts b/apps/memos-local-plugin/core/capture/subscriber.ts index d90371037..3af03445f 100644 --- a/apps/memos-local-plugin/core/capture/subscriber.ts +++ b/apps/memos-local-plugin/core/capture/subscriber.ts @@ -47,6 +47,10 @@ export function attachCaptureSubscriber( log.debug("subscriber.skip_abandoned", { episodeId: evt.episode.id }); return; } + if (evt.episode.meta?.lightweightMemory === true) { + log.debug("subscriber.skip_lightweight", { episodeId: evt.episode.id }); + return; + } // Topic ended → batch reflect across every step + emit // `capture.done` so the reward subscriber kicks off R_human + V // backprop. Per-turn lite captures already wrote the trace rows; diff --git a/apps/memos-local-plugin/core/capture/types.ts b/apps/memos-local-plugin/core/capture/types.ts index d2ee73fc8..efdc637f2 100644 --- a/apps/memos-local-plugin/core/capture/types.ts +++ b/apps/memos-local-plugin/core/capture/types.ts @@ -70,12 +70,37 @@ export interface ReflectionScore { alpha: number | null; /** LLM `usable` flag: false → alpha forced to 0 per V7 eq. 5. */ usable: boolean; + /** Optional LLM explanation for the α/usable decision. */ + reason?: string | null; /** Source of the reflection text. */ source: "adapter" | "extracted" | "synth" | "none"; /** Optional LLM servedBy model label for audit. */ model?: string; } +export type ReflectionContextMode = "none" | "task" | "downstream" | "task_downstream"; +export type LongEpisodeReflectMode = "per_step_parallel" | "per_step_downstream"; + +export interface DownstreamStepPreview { + /** Relative position from the current step: 1 => step+1, 2 => step+2. */ + offset: 1 | 2 | 3; + /** `text` means conversational content; `tooluse` means tool output evidence. */ + kind: "text" | "tooluse"; + /** For `text`, the standalone downstream text block. */ + text?: string; + /** For `tooluse`, one or more tool names observed in that downstream step. */ + toolNames?: string[]; + /** For `tooluse`, summarized output from the downstream tool call(s). */ + toolOutput?: string; + /** Existing adapter/extracted reflection only; never depends on this run's synth. */ + reflection?: string | null; +} + +export interface ReflectionContext { + taskSummary?: string | null; + downstream?: DownstreamStepPreview[]; +} + export interface ScoredStep extends NormalizedStep { reflection: ReflectionScore; } @@ -190,6 +215,26 @@ export interface CaptureConfig { * against prompt-window overflow on very long agent traces. */ batchThreshold: number; + /** + * Controls which extra context is included in per-step reflection and α + * prompts. Defaults to "task"; downstream preview remains opt-in. + */ + reflectionContextMode: ReflectionContextMode; + /** + * Long episodes can stay legacy parallel per-step, or enrich each per-step + * prompt with a precomputed step+1..step+3 preview while remaining parallel. + */ + longEpisodeReflectMode: LongEpisodeReflectMode; + /** Max downstream steps attached to a per-step prompt (0..3). */ + downstreamStepCount: number; + /** Character cap for the task-context block. */ + taskContextMaxChars: number; + /** Total character cap for all downstream preview blocks. */ + downstreamContextMaxChars: number; + /** Character cap for each downstream preview block. */ + downstreamPerStepMaxChars: number; + /** Character cap for current-step outcome in synth / alpha prompts. */ + synthOutcomeMaxChars: number; } // ─── Capture event types (published on their own bus) ────────────────────── diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 680957819..1cf2d2cf6 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -54,6 +54,9 @@ export const DEFAULT_CONFIG: ResolvedConfig = { timeoutMs: 60_000, }, algorithm: { + lightweightMemory: { + enabled: true, + }, capture: { maxTextChars: 4_000, maxToolOutputChars: 2_000, @@ -78,6 +81,29 @@ export const DEFAULT_CONFIG: ResolvedConfig = { // remain task-end events handled by `core/reward`, unchanged. batchMode: "auto", batchThreshold: 12, + // `reflectionContextMode` controls which extra prompt context blocks + // topic-end reflection receives: + // - "none": no TASK CONTEXT and no DOWNSTREAM STEP PREVIEW + // - "task": inject TASK CONTEXT only + // - "downstream": inject DOWNSTREAM STEP PREVIEW only + // - "task_downstream": inject both blocks + // `longEpisodeReflectMode` controls the fallback used when an episode is + // too long for batch scoring: + // - "per_step_parallel": keep the current parallel per-step path. Each + // step is reflected independently, using only the context blocks + // enabled by `reflectionContextMode` that are available without + // downstream preview. + // - "per_step_downstream": still run per-step work in parallel, but + // prebuild a bounded DOWNSTREAM STEP PREVIEW for each step (step+1 + // through step+N, capped by `downstreamStepCount`) and inject it when + // `reflectionContextMode` includes "downstream". + reflectionContextMode: "task_downstream", + longEpisodeReflectMode: "per_step_downstream", + downstreamStepCount: 3, + taskContextMaxChars: 800, + downstreamContextMaxChars: 1_200, + downstreamPerStepMaxChars: 400, + synthOutcomeMaxChars: 600, }, reward: { gamma: 0.9, @@ -123,6 +149,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { minTraceValue: 0.005, useLlm: true, traceCharCap: 3_000, + gainEmaAlpha: 0.4, archiveGain: -0.05, }, l3Abstraction: { @@ -184,6 +211,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { failureThreshold: 3, failureWindow: 5, valueDelta: 0.5, + minLowValueThreshold: 0.01, useLlm: true, attachToPolicy: true, cooldownMs: 60_000, diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 675cade70..7c9ff193b 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -92,6 +92,15 @@ const SkillEvolverSchema = Type.Object({ }, { default: {} }); const AlgorithmSchema = Type.Object({ + lightweightMemory: Type.Object({ + /** + * Low-cost mode for users who only want raw conversation memory + + * recall. When enabled, the runtime skips task/reward/L2/L3/skill + * evolution and keeps only summarize + embedding + retrieval filter. + * The viewer exposes the inverse as "memory self-evolution". + */ + enabled: Bool(true), + }, { default: {} }), capture: Type.Object({ /** Cap on agent/user text length (chars). Longer content is summarized. */ maxTextChars: NumberInRange(4_000, 200, 64_000), @@ -121,6 +130,38 @@ const AlgorithmSchema = Type.Object({ * to per-step calls so the batched prompt cannot overflow context. */ batchThreshold: NumberInRange(12, 1, 64), + /** + * Optional context blocks for per-step reflection and α prompts. + * Defaults to "task" to preserve the current task-summary enrichment; + * downstream preview remains opt-in. + */ + reflectionContextMode: Type.Union( + [ + Type.Literal("none"), + Type.Literal("task"), + Type.Literal("downstream"), + Type.Literal("task_downstream"), + ], + { default: "task" }, + ), + /** + * Long-episode fallback mode after batch auto-threshold is exceeded. + * `per_step_downstream` keeps parallelism but adds step+1..step+3 preview. + */ + longEpisodeReflectMode: Type.Union( + [Type.Literal("per_step_parallel"), Type.Literal("per_step_downstream")], + { default: "per_step_parallel" }, + ), + /** Max downstream steps attached to a per-step prompt. */ + downstreamStepCount: NumberInRange(3, 0, 3), + /** Character cap for the task-context block. */ + taskContextMaxChars: NumberInRange(800, 100, 4_000), + /** Total character cap for all downstream preview blocks. */ + downstreamContextMaxChars: NumberInRange(1_200, 0, 8_000), + /** Character cap per downstream preview block. */ + downstreamPerStepMaxChars: NumberInRange(400, 100, 2_000), + /** Character cap for current-step tool outcome in synth / α prompts. */ + synthOutcomeMaxChars: NumberInRange(600, 100, 4_000), }, { default: {} }), reward: Type.Object({ /** V7 §0.6 eq. 4/5: discount factor γ for reflection-weighted backprop. */ @@ -185,6 +226,8 @@ const AlgorithmSchema = Type.Object({ useLlm: Bool(true), /** Character cap for traces handed into the `l2.induction` prompt. */ traceCharCap: NumberInRange(3_000, 600, 16_000), + /** EMA alpha for gain updates. 1 means overwrite, lower values preserve history. */ + gainEmaAlpha: NumberInRange(0.4, 0, 1), /** Archive active policies whose gain dips below this value. */ archiveGain: NumberInRange(-0.05, -1, 1), }, { default: {} }), @@ -253,6 +296,14 @@ const AlgorithmSchema = Type.Object({ failureWindow: NumberInRange(5, 2, 50), /** Min |mean(high) - mean(low)| to fire without an explicit user signal. */ valueDelta: NumberInRange(0.5, 0, 2), + /** + * Minimum absolute value threshold for lowValue traces. Only traces with + * value < -minLowValueThreshold will be collected as failure evidence + * (unless they match isFailureLike patterns). This filters out trivial + * negative feedback (e.g., value = -0.001) and focuses on genuine failures. + * Default 0.01 — adjust higher (e.g., 0.1) to be more conservative. + */ + minLowValueThreshold: NumberInRange(0.01, 0, 1), /** Let the LLM rewrite the preference / anti-pattern lines. */ useLlm: Bool(true), /** Tag the L2 policies referenced by the evidence with the guidance. */ @@ -384,8 +435,8 @@ const AlgorithmSchema = Type.Object({ /** * How Tier-1 skills are surfaced in the injected prompt: * - "summary" (default): inject only `name + η + 1-line summary + - * a `skill_get(id="…")` hint`. The agent decides whether to - * fetch the full procedure via the `skill_get` tool. Keeps the + * a `memos_skill_get(id="…")` hint`. The agent decides whether to + * fetch the full procedure via the `memos_skill_get` tool. Keeps the * prompt small and avoids paying for skills the agent never * uses. * - "full": inline the entire `invocationGuide` body (legacy diff --git a/apps/memos-local-plugin/core/feedback/evidence.ts b/apps/memos-local-plugin/core/feedback/evidence.ts index ba7ec9217..4bcaa0368 100644 --- a/apps/memos-local-plugin/core/feedback/evidence.ts +++ b/apps/memos-local-plugin/core/feedback/evidence.ts @@ -44,6 +44,7 @@ export function gatherRepairEvidence( ): EvidenceResult { const cap = input.limit ?? deps.config.evidenceLimit; const needle = input.keyword?.toLowerCase().trim() ?? ""; + const minLowValueThreshold = deps.config.minLowValueThreshold; // Pull a generous recent batch and split by value sign. Limiting at the // SQL layer is fine because the caller passes a small `limit`. @@ -64,7 +65,7 @@ export function gatherRepairEvidence( // empty we keep the filtered result — the synthesizer is designed to // fall back to template output in that case without needing extra // context. - const firstPass = partition(recent, cap, needle); + const firstPass = partition(recent, cap, needle, minLowValueThreshold); const firstPassEmpty = firstPass.highValue.length === 0 && firstPass.lowValue.length === 0; if (!needle || !firstPassEmpty) { @@ -77,7 +78,7 @@ export function gatherRepairEvidence( }); return firstPass; } - const relaxed = partition(recent, cap, ""); + const relaxed = partition(recent, cap, "", minLowValueThreshold); deps.log.debug("evidence.gathered", { sessionId: input.sessionId, highValue: relaxed.highValue.length, @@ -92,6 +93,7 @@ function partition( traces: readonly TraceRow[], cap: number, needle: string, + minLowValueThreshold: number, ): EvidenceResult { const highValue: TraceRow[] = []; const lowValue: TraceRow[] = []; @@ -99,7 +101,7 @@ function partition( if (needle && !traceContains(trace, needle)) continue; if (trace.value > 0) { if (highValue.length < cap) highValue.push(trace); - } else if (trace.value < 0 || isFailureLike(trace)) { + } else if (trace.value < -minLowValueThreshold || isFailureLike(trace)) { if (lowValue.length < cap) lowValue.push(trace); } if (highValue.length >= cap && lowValue.length >= cap) break; diff --git a/apps/memos-local-plugin/core/feedback/types.ts b/apps/memos-local-plugin/core/feedback/types.ts index 3df34604b..0373bc31d 100644 --- a/apps/memos-local-plugin/core/feedback/types.ts +++ b/apps/memos-local-plugin/core/feedback/types.ts @@ -44,6 +44,14 @@ export interface FeedbackConfig { * value-guided comparison to fire. V7 §2.4.6 → `δ ≈ 0.5`. */ valueDelta: number; + /** + * Minimum absolute value threshold for lowValue traces. Only traces with + * value < -minLowValueThreshold will be collected as failure evidence + * (unless they match isFailureLike patterns). This filters out trivial + * negative feedback (e.g., value = -0.001) and focuses on genuine failures. + * Default 0.01 — adjust higher (e.g., 0.1) to be more conservative. + */ + minLowValueThreshold: number; /** * Call the LLM to produce the final preference / anti-pattern lines. * When false, fall back to a simple template using the most relevant diff --git a/apps/memos-local-plugin/core/hub/auth.ts b/apps/memos-local-plugin/core/hub/auth.ts new file mode 100644 index 000000000..46618ad5a --- /dev/null +++ b/apps/memos-local-plugin/core/hub/auth.ts @@ -0,0 +1,66 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +import type { HubRole, HubUserStatus } from "../storage/repos/hub.js"; + +export interface UserTokenPayload { + userId: string; + username: string; + role: HubRole; + status: HubUserStatus; +} + +interface SignedUserTokenPayload extends UserTokenPayload { + exp: number; +} + +export function makeSharedToken(): string { + return base64url(randomBytes(24)); +} + +export function issueUserToken( + payload: UserTokenPayload, + secret: string, + ttlMs = 24 * 60 * 60 * 1000, +): string { + const full: SignedUserTokenPayload = { ...payload, exp: Date.now() + ttlMs }; + const body = base64url(JSON.stringify(full)); + return `${body}.${sign(body, secret)}`; +} + +export function verifyUserToken(token: string, secret: string): UserTokenPayload | null { + const idx = token.lastIndexOf("."); + if (idx <= 0) return null; + const body = token.slice(0, idx); + const sig = token.slice(idx + 1); + const expected = sign(body, secret); + try { + if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null; + const parsed = JSON.parse(unbase64url(body).toString("utf8")) as SignedUserTokenPayload; + if (!parsed.userId || parsed.exp < Date.now()) return null; + return { + userId: parsed.userId, + username: parsed.username, + role: parsed.role, + status: parsed.status, + }; + } catch { + return null; + } +} + +function sign(value: string, secret: string): string { + return base64url(createHmac("sha256", secret).update(value).digest()); +} + +function base64url(input: Buffer | string): string { + return Buffer.from(input) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function unbase64url(input: string): Buffer { + const padded = input.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((input.length + 3) % 4); + return Buffer.from(padded, "base64"); +} diff --git a/apps/memos-local-plugin/core/hub/client.ts b/apps/memos-local-plugin/core/hub/client.ts new file mode 100644 index 000000000..b5c740efa --- /dev/null +++ b/apps/memos-local-plugin/core/hub/client.ts @@ -0,0 +1,562 @@ +import { createHash } from "node:crypto"; +import os from "node:os"; + +import type { ResolvedConfig } from "../config/index.js"; +import type { Logger } from "../logger/types.js"; +import type { ClientHubConnection } from "../storage/repos/hub.js"; + +type HubRepo = import("../storage/repos/index.js").Repos["hub"]; + +const REQUEST_TIMEOUT_MS = 20_000; + +export interface HubClientStatus { + connected: boolean; + hubUrl?: string; + user: null | Record; + error?: string; +} + +export class PendingApprovalError extends Error { + constructor(public readonly userId: string) { + super("Awaiting hub admin approval"); + this.name = "PendingApprovalError"; + } +} + +export class HubClientRuntime { + private heartbeatTimer: ReturnType | null = null; + private pendingPollTimer: ReturnType | null = null; + + constructor( + private readonly deps: { + repo: HubRepo; + config: ResolvedConfig; + log: Logger; + }, + ) {} + + async start(): Promise { + const hubAddress = this.deps.config.hub.address; + const hubUrl = normalizeHubUrl(hubAddress); + if (!hubUrl) { + throw new Error("hub.address is required when hub.role=client"); + } + + try { + const conn = await this.connect(hubUrl); + this.startHeartbeat(); + return conn; + } catch (err) { + if (err instanceof PendingApprovalError) { + this.deps.log.info("hub.client.pending_approval", { userId: err.userId, hubUrl }); + this.startPendingPoll(); + return this.deps.repo.getClientConnection(); + } + throw err; + } + } + + async stop(): Promise { + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + if (this.pendingPollTimer) clearInterval(this.pendingPollTimer); + this.heartbeatTimer = null; + this.pendingPollTimer = null; + } + + status(): HubClientStatus { + const conn = this.deps.repo.getClientConnection(); + if (!conn) return { connected: false, user: null }; + return { + connected: !!conn.userToken && conn.lastKnownStatus === "active", + hubUrl: conn.hubUrl, + user: { + id: conn.userId, + username: conn.username, + name: conn.username, + role: conn.role, + status: conn.lastKnownStatus || (conn.userToken ? "active" : "pending"), + }, + }; + } + + async refreshStatus(): Promise { + const conn = this.deps.repo.getClientConnection(); + const configuredHubUrl = normalizeHubUrl(this.deps.config.hub.address); + const hubUrl = conn?.hubUrl || configuredHubUrl; + if (!hubUrl) return { connected: false, user: null }; + + if (conn?.hubUrl && configuredHubUrl && conn.hubUrl !== configuredHubUrl) { + this.deps.repo.setClientConnection({ + ...conn, + hubUrl: configuredHubUrl, + userToken: "", + lastKnownStatus: "hub_changed", + }); + return this.status(); + } + + const persistedToken = conn?.userToken || ""; + const legacyConfiguredToken = secretValue(this.deps.config.hub.userToken); + const userToken = persistedToken || legacyConfiguredToken; + const teamToken = secretValue(this.deps.config.hub.teamToken); + + if (!teamToken) { + if (conn) { + this.deps.repo.setClientConnection({ + ...conn, + hubUrl, + userToken: "", + lastKnownStatus: "missing_team_token", + }); + } + return { ...this.status(), error: "hub.teamToken is required to join a hub" }; + } + + if (conn?.userId) { + try { + const checked = await this.checkRegistrationStatus(hubUrl, conn, teamToken); + if (checked) { + this.stopPendingPoll(); + this.startHeartbeat(); + return await this.refreshActiveUser(hubUrl, checked.userToken, checked); + } + } catch (err) { + if (err instanceof PendingApprovalError) return this.status(); + if (isInvalidTeamTokenHubError(err)) { + this.markConnectionStatus(conn, hubUrl, "invalid_team_token"); + return { ...this.status(), error: "invalid_team_token" }; + } + if (isUsernameTakenHubError(err)) { + this.markConnectionStatus(conn, hubUrl, "username_taken"); + return { ...this.status(), error: "username_taken" }; + } + this.deps.log.debug("hub.client.registration_status_refresh_failed", { + err: err instanceof Error ? err.message : String(err), + }); + } + return this.status(); + } + + if (!userToken) return this.status(); + + try { + const refreshed = await this.refreshActiveUser(hubUrl, userToken, conn ?? undefined); + const latestConn = this.deps.repo.getClientConnection(); + if (latestConn?.userId) { + const checked = await this.checkRegistrationStatus(hubUrl, latestConn, teamToken); + if (checked) return await this.refreshActiveUser(hubUrl, checked.userToken, checked); + } + this.stopPendingPoll(); + this.startHeartbeat(); + return refreshed; + } catch (err) { + if (err instanceof PendingApprovalError) return this.status(); + if (isInvalidTeamTokenHubError(err)) { + const latestConn = this.deps.repo.getClientConnection(); + if (latestConn) this.markConnectionStatus(latestConn, hubUrl, "invalid_team_token"); + return { ...this.status(), error: "invalid_team_token" }; + } + if (isUsernameTakenHubError(err)) { + const latestConn = this.deps.repo.getClientConnection(); + if (latestConn) this.markConnectionStatus(latestConn, hubUrl, "username_taken"); + return { ...this.status(), error: "username_taken" }; + } + if (conn && isUnauthorizedHubError(err)) { + if (teamToken) { + try { + const checked = await this.checkRegistrationStatus(hubUrl, conn, teamToken); + if (checked) { + this.stopPendingPoll(); + this.startHeartbeat(); + return await this.refreshActiveUser(hubUrl, checked.userToken, checked); + } + } catch (statusErr) { + if (statusErr instanceof PendingApprovalError) return this.status(); + if (isInvalidTeamTokenHubError(statusErr)) { + this.markConnectionStatus(conn, hubUrl, "invalid_team_token"); + return { ...this.status(), error: "invalid_team_token" }; + } + if (isUsernameTakenHubError(statusErr)) { + this.markConnectionStatus(conn, hubUrl, "username_taken"); + return { ...this.status(), error: "username_taken" }; + } + this.deps.log.debug("hub.client.registration_status_refresh_failed", { + err: statusErr instanceof Error ? statusErr.message : String(statusErr), + }); + } + } + this.deps.repo.setClientConnection({ + ...conn, + hubUrl, + userToken: "", + lastKnownStatus: "token_expired", + }); + return this.status(); + } + this.deps.log.debug("hub.client.status_refresh_failed", { + err: err instanceof Error ? err.message : String(err), + }); + return this.status(); + } + } + + async requestJson(route: string, init: RequestInit = {}): Promise { + const conn = this.deps.repo.getClientConnection(); + if (!conn?.hubUrl || !conn.userToken) { + throw new Error("hub client is not connected"); + } + return hubRequestJson(conn.hubUrl, conn.userToken, route, init); + } + + private async connect(hubUrl: string): Promise { + const persisted = this.deps.repo.getClientConnection(); + const persistedToken = persisted?.userToken || ""; + // `hub.userToken` is kept only for legacy/manual configs. Normal + // users join with a team token; the approved member credential is + // stored in client_hub_connection. + const legacyConfiguredToken = secretValue(this.deps.config.hub.userToken); + const teamToken = secretValue(this.deps.config.hub.teamToken); + + if (!teamToken) { + if (persisted) this.markConnectionStatus(persisted, hubUrl, "missing_team_token"); + throw new Error("hub.teamToken is required to join a hub"); + } + + if (persisted?.userId) { + try { + const checked = await this.checkRegistrationStatus(hubUrl, persisted, teamToken); + if (checked) return checked; + } catch (err) { + if (err instanceof PendingApprovalError) throw err; + if (isInvalidTeamTokenHubError(err)) { + this.markConnectionStatus(persisted, hubUrl, "invalid_team_token"); + throw err; + } + if (isUsernameTakenHubError(err)) { + this.markConnectionStatus(persisted, hubUrl, "username_taken"); + throw err; + } + if (!isHubNotFoundError(err)) throw err; + this.markConnectionStatus(persisted, hubUrl, "not_registered"); + } + } + + const userToken = persistedToken || legacyConfiguredToken; + if (userToken) { + try { + const [me, info] = await Promise.all([ + hubRequestJson>(hubUrl, userToken, "/api/v1/hub/me", { method: "GET" }), + hubRequestJson>(hubUrl, "", "/api/v1/hub/info", { method: "GET" }) + .catch((): Record => ({})), + ]); + const conn: ClientHubConnection = { + hubUrl, + userId: String(me.id ?? ""), + username: String(me.username ?? me.name ?? ""), + userToken, + role: String(me.role ?? "member") === "admin" ? "admin" : "member", + connectedAt: Date.now(), + identityKey: persisted?.identityKey || "", + lastKnownStatus: "active", + hubInstanceId: String(info.hubInstanceId ?? persisted?.hubInstanceId ?? ""), + }; + const checked = await this.checkRegistrationStatus(hubUrl, conn, teamToken); + if (checked) return checked; + this.deps.repo.setClientConnection(conn); + this.rememberJoinConfig(hubUrl, teamToken); + return conn; + } catch (err) { + if (!isUnauthorizedHubError(err)) throw err; + this.deps.log.info("hub.client.token_rejected_rejoin", { hubUrl }); + if (persisted?.userId) { + const checked = await this.checkRegistrationStatus(hubUrl, { + ...persisted, + hubUrl, + userToken: "", + lastKnownStatus: "unknown", + }, teamToken); + if (checked) return checked; + } + } + } + + if (!teamToken) { + throw new Error("hub.teamToken is required to join a hub"); + } + return this.autoJoin(hubUrl, teamToken, persisted); + } + + private async refreshActiveUser( + hubUrl: string, + userToken: string, + existing?: ClientHubConnection, + ): Promise { + const [me, info] = await Promise.all([ + hubRequestJson>(hubUrl, userToken, "/api/v1/hub/me", { method: "GET" }), + hubRequestJson>(hubUrl, "", "/api/v1/hub/info", { method: "GET" }) + .catch((): Record => ({})), + ]); + const conn: ClientHubConnection = { + hubUrl, + userId: String(me.id ?? existing?.userId ?? ""), + username: String(me.username ?? me.name ?? existing?.username ?? ""), + userToken, + role: String(me.role ?? existing?.role ?? "member") === "admin" ? "admin" : "member", + connectedAt: Date.now(), + identityKey: existing?.identityKey || "", + lastKnownStatus: String(me.status ?? "active"), + hubInstanceId: String(info.hubInstanceId ?? existing?.hubInstanceId ?? ""), + }; + this.deps.repo.setClientConnection(conn); + return this.status(); + } + + private async checkRegistrationStatus( + hubUrl: string, + persisted: ClientHubConnection, + teamToken: string, + ): Promise { + const result = await hubRequestJson>(hubUrl, "", "/api/v1/hub/registration-status", { + method: "POST", + body: JSON.stringify({ + teamToken, + userId: persisted.userId, + username: this.currentUsername(), + identityKey: persisted.identityKey, + }), + }); + const status = String(result.status || ""); + if (status === "active" && result.userToken) { + const conn: ClientHubConnection = { + ...persisted, + hubUrl, + userId: String(result.userId || persisted.userId), + username: String(result.username || persisted.username || this.currentUsername()), + userToken: String(result.userToken), + connectedAt: Date.now(), + identityKey: String(result.identityKey || persisted.identityKey || ""), + lastKnownStatus: "active", + }; + this.deps.repo.setClientConnection(conn); + this.rememberJoinConfig(hubUrl, teamToken); + return conn; + } + if (status === "pending") { + this.deps.repo.setClientConnection({ + ...persisted, + hubUrl, + userId: String(result.userId || persisted.userId), + username: String(result.username || persisted.username || this.currentUsername()), + userToken: "", + identityKey: String(result.identityKey || persisted.identityKey || ""), + lastKnownStatus: "pending", + }); + this.rememberJoinConfig(hubUrl, teamToken); + throw new PendingApprovalError(String(result.userId || persisted.userId)); + } + if (status) { + this.deps.repo.setClientConnection({ + ...persisted, + hubUrl, + username: String(result.username || persisted.username || this.currentUsername()), + userToken: "", + lastKnownStatus: status, + }); + this.rememberJoinConfig(hubUrl, teamToken); + } + return null; + } + + private async autoJoin( + hubUrl: string, + teamToken: string, + persisted: ClientHubConnection | null, + ): Promise { + const hostname = os.hostname() || "unknown"; + const username = this.currentUsername(); + const info = await hubRequestJson>(hubUrl, "", "/api/v1/hub/info", { method: "GET" }) + .catch((): Record => ({})); + const result = await hubRequestJson>(hubUrl, "", "/api/v1/hub/join", { + method: "POST", + body: JSON.stringify({ + teamToken, + username, + deviceName: hostname, + identityKey: persisted?.identityKey || "", + clientIp: firstLanIp(), + }), + }); + const identityKey = String(result.identityKey || persisted?.identityKey || ""); + if (result.status === "pending") { + const pending: ClientHubConnection = { + hubUrl, + userId: String(result.userId || ""), + username, + userToken: "", + role: "member", + connectedAt: Date.now(), + identityKey, + lastKnownStatus: "pending", + hubInstanceId: String(info.hubInstanceId ?? ""), + }; + this.deps.repo.setClientConnection(pending); + this.rememberJoinConfig(hubUrl, teamToken); + throw new PendingApprovalError(pending.userId); + } + if (result.status !== "active" || !result.userToken) { + throw new Error(`hub join failed: ${JSON.stringify(result)}`); + } + const conn: ClientHubConnection = { + hubUrl, + userId: String(result.userId || ""), + username, + userToken: String(result.userToken), + role: "member", + connectedAt: Date.now(), + identityKey, + lastKnownStatus: "active", + hubInstanceId: String(info.hubInstanceId ?? ""), + }; + this.deps.repo.setClientConnection(conn); + this.rememberJoinConfig(hubUrl, teamToken); + return conn; + } + + private startHeartbeat(): void { + if (this.heartbeatTimer) return; + this.heartbeatTimer = setInterval(() => { + void this.requestJson("/api/v1/hub/heartbeat", { method: "POST" }) + .catch((err) => this.deps.log.debug("hub.client.heartbeat_failed", { + err: err instanceof Error ? err.message : String(err), + })); + }, 30_000); + } + + private startPendingPoll(): void { + if (this.pendingPollTimer) return; + this.pendingPollTimer = setInterval(() => { + const hubUrl = normalizeHubUrl(this.deps.config.hub.address); + const teamToken = secretValue(this.deps.config.hub.teamToken); + const persisted = this.deps.repo.getClientConnection(); + if (!hubUrl || !teamToken || !persisted?.userId) return; + void this.checkRegistrationStatus(hubUrl, persisted, teamToken) + .then((conn) => { + if (conn) { + this.deps.log.info("hub.client.approved", { userId: conn.userId, hubUrl }); + if (this.pendingPollTimer) clearInterval(this.pendingPollTimer); + this.pendingPollTimer = null; + this.startHeartbeat(); + } + }) + .catch((err) => { + if (err instanceof PendingApprovalError) return; + this.deps.log.debug("hub.client.pending_poll_failed", { + err: err instanceof Error ? err.message : String(err), + }); + }); + }, 30_000); + } + + private stopPendingPoll(): void { + if (!this.pendingPollTimer) return; + clearInterval(this.pendingPollTimer); + this.pendingPollTimer = null; + } + + private markConnectionStatus(conn: ClientHubConnection, hubUrl: string, status: string): void { + this.deps.repo.setClientConnection({ + ...conn, + hubUrl, + userToken: "", + lastKnownStatus: status, + }); + } + + private rememberJoinConfig(hubUrl: string, teamToken: string): void { + this.deps.repo.setClientJoinConfig({ + hubUrl: normalizeHubUrl(hubUrl), + teamTokenHash: hashTeamToken(teamToken), + }); + } + + private currentUsername(): string { + return this.deps.config.hub.nickname || os.userInfo().username || "user"; + } +} + +export async function hubRequestJson( + hubUrl: string, + userToken: string, + route: string, + init: RequestInit = {}, +): Promise { + const timeoutSignal = + typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" + ? AbortSignal.timeout(REQUEST_TIMEOUT_MS) + : undefined; + const mergedSignal = + timeoutSignal && init.signal + ? AbortSignal.any([timeoutSignal, init.signal]) + : (timeoutSignal ?? init.signal); + const extraHeaders: Record = {}; + if (userToken) extraHeaders.authorization = `Bearer ${userToken}`; + if (init.body) extraHeaders["content-type"] = "application/json"; + const res = await fetch(`${normalizeHubUrl(hubUrl)}${route}`, { + ...init, + ...(mergedSignal ? { signal: mergedSignal } : {}), + headers: mergeHeaders(init.headers, extraHeaders), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`hub request failed (${res.status}): ${text || res.statusText}`); + } + if (res.status === 204) return null as T; + return await res.json() as T; +} + +export function normalizeHubUrl(hubAddress: string): string { + const trimmed = hubAddress.trim().replace(/\/+$/, ""); + if (!trimmed) return ""; + return /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; +} + +function secretValue(value: string): string { + return value === "__memos_secret__" ? "" : value.trim(); +} + +function isUnauthorizedHubError(err: unknown): boolean { + return err instanceof Error && /\(401\)|unauthorized/i.test(err.message); +} + +function isInvalidTeamTokenHubError(err: unknown): boolean { + return err instanceof Error && /\(403\).*invalid_team_token|invalid_team_token/i.test(err.message); +} + +function isHubNotFoundError(err: unknown): boolean { + return err instanceof Error && /\(404\)|not_found/i.test(err.message); +} + +function isUsernameTakenHubError(err: unknown): boolean { + return err instanceof Error && /\(409\).*username_taken|username_taken/i.test(err.message); +} + +function hashTeamToken(teamToken: string): string { + return createHash("sha256").update(teamToken).digest("hex"); +} + +function firstLanIp(): string { + for (const entries of Object.values(os.networkInterfaces())) { + for (const net of entries ?? []) { + if (net.family === "IPv4" && !net.internal) return net.address; + } + } + return ""; +} + +function mergeHeaders(base: RequestInit["headers"] | undefined, extra: Record): Headers { + const headers = new Headers(base); + for (const [key, value] of Object.entries(extra)) { + headers.set(key, value); + } + return headers; +} diff --git a/apps/memos-local-plugin/core/hub/runtime.ts b/apps/memos-local-plugin/core/hub/runtime.ts new file mode 100644 index 000000000..b1369dc66 --- /dev/null +++ b/apps/memos-local-plugin/core/hub/runtime.ts @@ -0,0 +1,395 @@ +import type { + PolicyDTO, + SkillDTO, + TraceDTO, + WorldModelDTO, +} from "../../agent-contract/dto.js"; +import type { ResolvedConfig } from "../config/index.js"; +import type { Logger } from "../logger/types.js"; +import type { HubSharedMemorySearchHit } from "../storage/repos/hub.js"; +import type { Repos } from "../storage/repos/index.js"; +import type { EmbeddingVector } from "../types.js"; +import { HubClientRuntime, type HubClientStatus } from "./client.js"; +import { HubServerRuntime } from "./server.js"; + +export interface HubAdminPayload { + enabled: boolean; + role?: "hub" | "client"; + status?: "disabled" | "starting" | "running" | "pending" | "connected" | "error"; + error?: string; + url?: string; + pending?: Array<{ + id: string; + name: string; + requestedAt: number; + groupName?: string; + }>; + users?: Array<{ + id: string; + name: string; + groupName?: string; + connected: boolean; + role?: string; + status?: string; + memoryCount?: number; + skillCount?: number; + }>; + groups?: Array<{ id: string; name: string; memberCount: number }>; +} + +export interface HubRuntime { + start(): Promise; + stop(): Promise; + adminSnapshot(): Promise; + approveUser(userId: string): Promise<{ ok: boolean; token?: string }>; + rejectUser(userId: string): Promise<{ ok: boolean }>; + removeUser(userId: string): Promise<{ ok: boolean }>; + publishTrace(trace: TraceDTO, embedding?: EmbeddingVector | null): Promise; + unpublishTrace(traceId: string): Promise; + publishPolicy(policy: PolicyDTO): Promise; + unpublishPolicy(policyId: string): Promise; + publishWorldModel(world: WorldModelDTO): Promise; + unpublishWorldModel(worldModelId: string): Promise; + publishSkill(skill: SkillDTO): Promise; + unpublishSkill(skillId: string): Promise; + searchMemories(query: string, limit?: number): Promise; +} + +export type HubMemorySearchHit = HubSharedMemorySearchHit; + +export function createHubRuntime(deps: { + repos: Repos; + config: ResolvedConfig; + log: Logger; + agent: string; + version: string; +}): HubRuntime { + return new DefaultHubRuntime(deps); +} + +class DefaultHubRuntime implements HubRuntime { + private server: HubServerRuntime | null = null; + private client: HubClientRuntime | null = null; + private status: HubAdminPayload["status"] = "disabled"; + private error: string | null = null; + + constructor( + private readonly deps: { + repos: Repos; + config: ResolvedConfig; + log: Logger; + agent: string; + version: string; + }, + ) {} + + async start(): Promise { + if (!this.deps.config.hub.enabled) { + this.status = "disabled"; + return; + } + this.status = "starting"; + this.error = null; + try { + if (this.deps.config.hub.role === "hub") { + this.server = new HubServerRuntime({ + repo: this.deps.repos.hub, + config: this.deps.config, + log: this.deps.log.child({ channel: "core.hub.server" }), + version: this.deps.version, + }); + await this.server.start(); + this.status = "running"; + } else { + this.client = new HubClientRuntime({ + repo: this.deps.repos.hub, + config: this.deps.config, + log: this.deps.log.child({ channel: "core.hub.client" }), + }); + const conn = await this.client.start(); + this.status = conn?.userToken ? "connected" : "pending"; + } + } catch (err) { + this.status = "error"; + this.error = err instanceof Error ? err.message : String(err); + this.deps.log.warn("hub.start.failed", { err: this.error }); + } + } + + async stop(): Promise { + await this.client?.stop(); + await this.server?.stop(); + this.client = null; + this.server = null; + } + + async adminSnapshot(): Promise { + const cfg = this.deps.config.hub; + if (!cfg.enabled) return { enabled: false, status: "disabled" }; + if (cfg.role === "hub") { + const pending = this.deps.repos.hub.listUsers("pending").map((u) => ({ + id: u.id, + name: u.username, + requestedAt: u.rejoinRequestedAt ?? u.createdAt, + })); + const contrib = this.deps.repos.hub.contributionsByUser(); + const now = Date.now(); + const users = this.deps.repos.hub + .listUsers() + .filter((u) => u.status === "active") + .map((u) => ({ + id: u.id, + name: u.username, + connected: u.id === this.server?.ownerUserId || (!!u.lastActiveAt && now - u.lastActiveAt < 2 * 60_000), + role: u.role, + status: u.status, + memoryCount: contrib[u.id]?.memoryCount ?? 0, + skillCount: contrib[u.id]?.skillCount ?? 0, + })); + let url: string | undefined; + try { + url = this.server?.snapshot().url; + } catch { + url = undefined; + } + return { + enabled: true, + role: "hub", + status: this.status, + error: this.error ?? undefined, + url, + pending, + users, + groups: [], + }; + } + + const clientStatus = this.client + ? await this.client.refreshStatus() + : clientStatusFromRepo(this.deps.repos); + const user = clientStatus.user; + const state = user?.status === "pending" + ? "pending" + : clientStatus.connected + ? "connected" + : "error"; + this.status = state; + return { + enabled: true, + role: "client", + status: state, + error: clientStatus.error ?? this.error ?? undefined, + url: clientStatus.hubUrl, + pending: [], + users: user + ? [{ + id: String(user.id), + name: String(user.username || user.name || ""), + connected: clientStatus.connected, + role: String(user.role || "member"), + status: String(user.status || ""), + }] + : [], + groups: [], + }; + } + + async approveUser(userId: string): Promise<{ ok: boolean; token?: string }> { + const approved = this.server?.approveUser(userId); + return approved ? { ok: true, token: approved.token } : { ok: false }; + } + + async rejectUser(userId: string): Promise<{ ok: boolean }> { + return { ok: !!this.server?.rejectUser(userId) }; + } + + async removeUser(userId: string): Promise<{ ok: boolean }> { + return { ok: !!this.server?.removeUser(userId) }; + } + + async publishTrace(trace: TraceDTO, embedding?: EmbeddingVector | null): Promise { + return this.publishMemory({ + sourceTraceId: trace.id, + sourceAgent: String(trace.ownerAgentKind || this.deps.agent), + kind: "trace", + summary: trace.summary || summarize(trace.userText || trace.agentText), + content: joinBlocks([ + trace.userText ? `User:\n${trace.userText}` : "", + trace.agentText ? `Agent:\n${trace.agentText}` : "", + ]), + embedding, + }); + } + + async unpublishTrace(traceId: string): Promise { + await this.unpublishMemory(traceId); + } + + async publishPolicy(policy: PolicyDTO): Promise { + return this.publishMemory({ + sourceTraceId: policy.id, + sourceAgent: String(policy.ownerAgentKind || this.deps.agent), + kind: "policy", + summary: policy.title, + content: joinBlocks([ + `Title:\n${policy.title}`, + `Trigger:\n${policy.trigger}`, + `Procedure:\n${policy.procedure}`, + `Verification:\n${policy.verification}`, + `Boundary:\n${policy.boundary}`, + ]), + }); + } + + async unpublishPolicy(policyId: string): Promise { + await this.unpublishMemory(policyId); + } + + async publishWorldModel(world: WorldModelDTO): Promise { + return this.publishMemory({ + sourceTraceId: world.id, + sourceAgent: String(world.ownerAgentKind || this.deps.agent), + kind: "world_model", + summary: world.title, + content: joinBlocks([`Title:\n${world.title}`, world.body]), + }); + } + + async unpublishWorldModel(worldModelId: string): Promise { + await this.unpublishMemory(worldModelId); + } + + async publishSkill(skill: SkillDTO): Promise { + const payload = { + metadata: { + id: skill.id, + name: skill.name, + invocationGuide: skill.invocationGuide, + version: skill.version, + qualityScore: skill.eta, + }, + bundle: { + invocationGuide: skill.invocationGuide, + decisionGuidance: skill.decisionGuidance, + evidenceAnchors: skill.evidenceAnchors, + sourcePolicyIds: skill.sourcePolicyIds, + sourceWorldModelIds: skill.sourceWorldModelIds, + }, + }; + if (this.server) { + return this.server.publishSkillAsOwner({ + sourceSkillId: skill.id, + name: skill.name, + invocationGuide: skill.invocationGuide, + version: skill.version, + qualityScore: skill.eta, + bundle: payload.bundle, + }).id; + } + const result = await this.client?.requestJson<{ skillId?: string }>("/api/v1/hub/skills/publish", { + method: "POST", + body: JSON.stringify(payload), + }); + return result?.skillId ?? null; + } + + async unpublishSkill(skillId: string): Promise { + if (this.server) { + this.server.unpublishSkillAsOwner(skillId); + return; + } + await this.client?.requestJson("/api/v1/hub/skills/unpublish", { + method: "POST", + body: JSON.stringify({ sourceSkillId: skillId }), + }); + } + + async searchMemories( + query: string, + limit = 5, + ): Promise { + if (!this.deps.config.hub.enabled) return []; + if (this.server) { + return this.server.searchMemories(query, limit); + } + const result = await this.client?.requestJson<{ memories?: HubMemorySearchHit[] }>( + "/api/v1/hub/memories/search", + { + method: "POST", + body: JSON.stringify({ + query, + limit, + }), + }, + ); + return result?.memories ?? []; + } + + private async publishMemory(input: { + sourceTraceId: string; + sourceAgent: string; + kind: string; + summary: string; + content: string; + embedding?: EmbeddingVector | null; + }): Promise { + if (this.server) { + return this.server.publishMemoryAsOwner({ + ...input, + summary: truncate(input.summary, 500), + content: truncate(input.content, 20_000), + }).id; + } + const result = await this.client?.requestJson<{ memoryId?: string }>("/api/v1/hub/memories/share", { + method: "POST", + body: JSON.stringify({ + memory: { + ...input, + sourceChunkId: input.sourceTraceId, + summary: truncate(input.summary, 500), + content: truncate(input.content, 20_000), + embedding: input.embedding ? Array.from(input.embedding) : undefined, + }, + }), + }); + return result?.memoryId ?? null; + } + + private async unpublishMemory(sourceTraceId: string): Promise { + if (this.server) { + this.server.unpublishMemoryAsOwner(sourceTraceId); + return; + } + await this.client?.requestJson("/api/v1/hub/memories/unshare", { + method: "POST", + body: JSON.stringify({ sourceTraceId }), + }); + } +} + +function clientStatusFromRepo(repos: Repos): HubClientStatus { + const conn = repos.hub.getClientConnection(); + if (!conn) return { connected: false, user: null }; + return { + connected: !!conn.userToken && conn.lastKnownStatus === "active", + hubUrl: conn.hubUrl, + user: { + id: conn.userId, + username: conn.username, + role: conn.role, + status: conn.lastKnownStatus, + }, + }; +} + +function joinBlocks(blocks: string[]): string { + return blocks.filter(Boolean).join("\n\n"); +} + +function summarize(text: string): string { + return truncate(text.replace(/\s+/g, " ").trim(), 160); +} + +function truncate(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max - 1)}...` : text; +} diff --git a/apps/memos-local-plugin/core/hub/server.ts b/apps/memos-local-plugin/core/hub/server.ts new file mode 100644 index 000000000..5c9876ce4 --- /dev/null +++ b/apps/memos-local-plugin/core/hub/server.ts @@ -0,0 +1,747 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import os from "node:os"; + +import type { ResolvedConfig } from "../config/index.js"; +import type { Logger } from "../logger/types.js"; +import { HUB_SHARED_MEMORY_TOMBSTONE_TTL_MS } from "../storage/repos/hub.js"; +import type { + HubAuthState, + HubRole, + HubSharedMemoryRecord, + HubSharedMemorySearchHit, + HubSharedSkillRecord, + HubUserRecord, +} from "../storage/repos/hub.js"; +import type { EmbeddingVector } from "../types.js"; +import { issueUserToken, verifyUserToken } from "./auth.js"; + +type HubRepo = import("../storage/repos/index.js").Repos["hub"]; +type HubSharedMemoryInput = Omit< + HubSharedMemoryRecord, + "id" | "sourceUserId" | "visible" | "deletedAt" | "createdAt" | "updatedAt" +>; + +export interface HubServerSnapshot { + url: string; + port: number; + hubInstanceId: string; + ownerUserId: string; + ownerToken: string; +} + +export interface AuthenticatedHubUser { + userId: string; + username: string; + role: HubRole; +} + +export class HubServerRuntime { + private server: Server | null = null; + private actualPort = 0; + private authState: HubAuthState; + private owner: { userId: string; token: string } | null = null; + + constructor( + private readonly deps: { + repo: HubRepo; + config: ResolvedConfig; + log: Logger; + version: string; + }, + ) { + this.authState = this.loadAuthState(); + } + + async start(): Promise { + if (this.server?.listening && this.owner) { + return this.snapshot(); + } + const token = this.teamToken; + if (!token) { + throw new Error("hub.teamToken is required when hub.role=hub"); + } + + this.server = createServer((req, res) => { + void this.handle(req, res).catch((err) => { + this.deps.log.warn("hub.request.failed", { + err: err instanceof Error ? err.message : String(err), + }); + this.json(res, 500, { error: "internal_error" }); + }); + }); + + let listenPort = this.configuredPort; + await new Promise((resolve, reject) => { + let retries = 0; + const onError = (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE" && retries < 3) { + retries++; + listenPort = this.configuredPort + retries; + this.deps.log.warn("hub.port.busy_retry", { port: listenPort - 1, nextPort: listenPort }); + this.server!.listen(listenPort, "0.0.0.0"); + return; + } + this.server?.off("listening", onListening); + reject(err); + }; + const onListening = () => { + this.server?.off("error", onError); + resolve(); + }; + this.server!.on("error", onError); + this.server!.once("listening", onListening); + this.server!.listen(listenPort, "0.0.0.0"); + }); + + this.actualPort = listenPort; + this.owner = this.ensureBootstrapAdmin(); + this.pruneExpiredSharedMemories(); + this.deps.log.info("hub.started", { + url: `http://127.0.0.1:${this.actualPort}`, + bindHost: "0.0.0.0", + teamName: this.teamName, + }); + return this.snapshot(); + } + + async stop(): Promise { + const server = this.server; + this.server = null; + this.owner = null; + if (!server) return; + await new Promise((resolve) => server.close(() => resolve())); + this.deps.log.info("hub.stopped", {}); + } + + snapshot(): HubServerSnapshot { + if (!this.owner) { + throw new Error("hub server has not started"); + } + return { + url: `http://127.0.0.1:${this.actualPort || this.configuredPort}`, + port: this.actualPort || this.configuredPort, + hubInstanceId: this.hubInstanceId, + ownerUserId: this.owner.userId, + ownerToken: this.owner.token, + }; + } + + get hubInstanceId(): string { + return this.authState.hubInstanceId!; + } + + get ownerUserId(): string { + return this.owner?.userId ?? this.authState.bootstrapAdminUserId ?? ""; + } + + get ownerToken(): string { + return this.owner?.token ?? this.authState.bootstrapAdminToken ?? ""; + } + + approveUser(userId: string): { token: string; user: HubUserRecord } | null { + const user = this.deps.repo.getUser(userId); + if (!user) return null; + const token = this.issueToken(user, "member"); + const updated: HubUserRecord = { + ...user, + role: "member", + status: "active", + tokenHash: hashToken(token), + approvedAt: Date.now(), + }; + this.deps.repo.upsertUser(updated); + return { token, user: updated }; + } + + rejectUser(userId: string): HubUserRecord | null { + const user = this.deps.repo.getUser(userId); + if (!user) return null; + const updated: HubUserRecord = { + ...user, + status: "rejected", + rejectedAt: Date.now(), + tokenHash: "", + }; + this.deps.repo.upsertUser(updated); + return updated; + } + + removeUser(userId: string): HubUserRecord | null { + const user = this.deps.repo.getUser(userId); + if (!user || user.role === "admin") return null; + const updated: HubUserRecord = { + ...user, + status: "removed", + tokenHash: "", + removedAt: Date.now(), + }; + this.deps.repo.upsertUser(updated); + this.deps.repo.deleteSharedMemoriesByUser(user.id); + this.deps.repo.deleteSharedSkillsByUser(user.id); + return updated; + } + + publishMemoryAsOwner(input: HubSharedMemoryInput): HubSharedMemoryRecord { + return this.publishMemoryForUser(this.ownerUserId, input); + } + + searchMemories( + query: string, + limit = 10, + ): HubSharedMemorySearchHit[] { + this.pruneExpiredSharedMemories(); + return this.deps.repo.searchSharedMemories(query, limit); + } + + unpublishMemoryAsOwner(sourceTraceId: string): void { + this.deps.repo.hideSharedMemoryBySource(this.ownerUserId, sourceTraceId); + } + + publishSkillAsOwner(input: Omit): HubSharedSkillRecord { + return this.publishSkillForUser(this.ownerUserId, input); + } + + unpublishSkillAsOwner(sourceSkillId: string): void { + this.deps.repo.deleteSharedSkillBySource(this.ownerUserId, sourceSkillId); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.actualPort || this.configuredPort}`); + const path = url.pathname; + const method = (req.method ?? "GET").toUpperCase(); + + if (method === "GET" && path === "/api/v1/hub/info") { + return this.json(res, 200, { + teamName: this.teamName, + version: this.deps.version, + apiVersion: "v1", + hubInstanceId: this.hubInstanceId, + }); + } + + if (method === "POST" && path === "/api/v1/hub/join") { + const body = await this.readJson(req); + if (!body || body.teamToken !== this.teamToken) { + return this.json(res, 403, { error: "invalid_team_token" }); + } + return this.handleJoin(req, res, body); + } + + if (method === "POST" && path === "/api/v1/hub/registration-status") { + const body = await this.readJson(req); + if (!body || body.teamToken !== this.teamToken) { + return this.json(res, 403, { error: "invalid_team_token" }); + } + const userId = String(body.userId || ""); + const requestedUsername = optionalSafeUsername(body.username); + let user = userId ? this.deps.repo.getUser(userId) : null; + if (user && requestedUsername && user.username !== requestedUsername) { + const conflict = this.findUserByUsername(requestedUsername); + if (conflict && conflict.id !== user.id) { + return this.json(res, 409, { + error: "username_taken", + message: `Username "${requestedUsername}" is already in use.`, + }); + } + user = { ...user, username: requestedUsername }; + this.deps.repo.upsertUser(user); + } + if (!user && requestedUsername) { + user = this.findUserByUsername(requestedUsername); + } + if (!user) return this.json(res, 404, { error: "not_found" }); + if (user.status === "active") { + const token = this.issueToken(user, user.role); + this.deps.repo.upsertUser({ ...user, tokenHash: hashToken(token) }); + return this.json(res, 200, { + status: "active", + userId: user.id, + username: user.username, + identityKey: user.identityKey, + userToken: token, + }); + } + return this.json(res, 200, { + status: user.status, + userId: user.id, + username: user.username, + identityKey: user.identityKey, + }); + } + + const auth = this.authenticate(req); + if (!auth) return this.json(res, 401, { error: "unauthorized" }); + + if (method === "POST" && path === "/api/v1/hub/heartbeat") { + return this.json(res, 200, { ok: true }); + } + + if (method === "GET" && path === "/api/v1/hub/me") { + const user = this.deps.repo.getUser(auth.userId); + if (!user) return this.json(res, 401, { error: "unauthorized" }); + return this.json(res, 200, publicUser(user)); + } + + if (method === "GET" && path === "/api/v1/hub/admin/pending-users") { + if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" }); + return this.json(res, 200, { users: this.deps.repo.listUsers("pending").map(publicUser) }); + } + + if (method === "GET" && path === "/api/v1/hub/admin/users") { + if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" }); + const contrib = this.deps.repo.contributionsByUser(); + const now = Date.now(); + const users = this.deps.repo.listUsers() + .filter((u) => u.status === "active") + .map((u) => ({ + ...publicUser(u), + isOwner: u.id === this.ownerUserId, + isOnline: u.id === this.ownerUserId || (!!u.lastActiveAt && now - u.lastActiveAt < 2 * 60_000), + memoryCount: contrib[u.id]?.memoryCount ?? 0, + skillCount: contrib[u.id]?.skillCount ?? 0, + })); + return this.json(res, 200, { users }); + } + + if (method === "POST" && path === "/api/v1/hub/admin/approve-user") { + if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" }); + const body = await this.readJson(req); + const approved = this.approveUser(String(body?.userId ?? "")); + if (!approved) return this.json(res, 404, { error: "not_found" }); + return this.json(res, 200, { status: "active", token: approved.token }); + } + + if (method === "POST" && path === "/api/v1/hub/admin/reject-user") { + if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" }); + const body = await this.readJson(req); + const rejected = this.rejectUser(String(body?.userId ?? "")); + if (!rejected) return this.json(res, 404, { error: "not_found" }); + return this.json(res, 200, { status: "rejected" }); + } + + if (method === "POST" && path === "/api/v1/hub/admin/remove-user") { + if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" }); + const body = await this.readJson(req); + const removed = this.removeUser(String(body?.userId ?? "")); + if (!removed) return this.json(res, 404, { error: "not_found" }); + return this.json(res, 200, { status: "removed" }); + } + + if (method === "POST" && path === "/api/v1/hub/memories/share") { + const body = await this.readJson(req); + if (!body?.memory) return this.json(res, 400, { error: "invalid_payload" }); + const memory = this.publishMemoryForUser(auth.userId, normalizeMemoryPayload(body.memory)); + return this.json(res, 200, { ok: true, memoryId: memory.id, visibility: "public" }); + } + + if (method === "POST" && path === "/api/v1/hub/memories/unshare") { + const body = await this.readJson(req); + const sourceTraceId = String(body?.sourceTraceId ?? body?.sourceChunkId ?? ""); + if (!sourceTraceId) return this.json(res, 400, { error: "missing_source_trace_id" }); + this.deps.repo.hideSharedMemoryBySource(auth.userId, sourceTraceId); + return this.json(res, 200, { ok: true }); + } + + if (method === "GET" && path === "/api/v1/hub/memories") { + this.pruneExpiredSharedMemories(); + return this.json(res, 200, { + memories: this.deps.repo + .listSharedMemories(Number(url.searchParams.get("limit") || 100)) + .map(publicMemory), + }); + } + + if (method === "POST" && path === "/api/v1/hub/memories/search") { + const body = await this.readJson(req); + const query = String(body.query ?? ""); + const limit = Number(body.limit ?? body.maxResults ?? 10); + return this.json(res, 200, { + memories: this.searchMemories(query, limit).map(publicMemory), + }); + } + + if (method === "POST" && path === "/api/v1/hub/skills/publish") { + const body = await this.readJson(req); + const metadata = asRecord(body?.metadata); + const bundle = asRecord(body?.bundle); + const sourceSkillId = String(metadata.id || ""); + if (!sourceSkillId) return this.json(res, 400, { error: "missing_skill_id" }); + const skill = this.publishSkillForUser(auth.userId, { + sourceSkillId, + name: String(metadata.name || sourceSkillId), + invocationGuide: String(metadata.invocationGuide || bundle.invocationGuide || ""), + version: Number(metadata.version || 1), + qualityScore: metadata.qualityScore == null ? null : Number(metadata.qualityScore), + bundle, + }); + return this.json(res, 200, { ok: true, skillId: skill.id, visibility: "public" }); + } + + if (method === "POST" && path === "/api/v1/hub/skills/unpublish") { + const body = await this.readJson(req); + const sourceSkillId = String(body?.sourceSkillId || ""); + if (!sourceSkillId) return this.json(res, 400, { error: "missing_skill_id" }); + this.deps.repo.deleteSharedSkillBySource(auth.userId, sourceSkillId); + return this.json(res, 200, { ok: true }); + } + + if (method === "GET" && path === "/api/v1/hub/skills/list") { + return this.json(res, 200, { + skills: this.deps.repo.listSharedSkills(Number(url.searchParams.get("limit") || 100)), + }); + } + + return this.json(res, 404, { error: "not_found" }); + } + + private handleJoin(req: IncomingMessage, res: ServerResponse, body: Record): void { + const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : ""; + const username = safeUsername(String(body.username || os.userInfo().username || "member")); + const joinIp = + (typeof body.clientIp === "string" && body.clientIp.trim()) || + (req.headers["x-client-ip"] as string | undefined)?.trim() || + req.socket.remoteAddress || + ""; + + const nameMatch = this.findUserByUsername(username); + if (nameMatch) { + this.respondExistingJoin(res, nameMatch, identityKey, joinIp); + return; + } + + const identityMatch = identityKey ? this.deps.repo.findUserByIdentityKey(identityKey) : null; + if (identityMatch) { + const conflict = this.findUserByUsername(username); + if (conflict && conflict.id !== identityMatch.id) { + return this.json(res, 409, { error: "username_taken", message: `Username "${username}" is already in use.` }); + } + const renamed = identityMatch.username === username + ? identityMatch + : { ...identityMatch, username }; + if (renamed !== identityMatch) this.deps.repo.upsertUser(renamed); + this.respondExistingJoin(res, renamed, identityKey, joinIp); + return; + } + + const now = Date.now(); + const generatedIdentityKey = identityKey || randomUUID(); + const user: HubUserRecord = { + id: randomUUID(), + username, + deviceName: String(body.deviceName || ""), + role: "member", + status: "pending", + tokenHash: "", + identityKey: generatedIdentityKey, + createdAt: now, + approvedAt: null, + rejectedAt: null, + leftAt: null, + removedAt: null, + lastIp: joinIp, + lastActiveAt: now, + rejoinRequestedAt: null, + }; + this.deps.repo.upsertUser(user); + this.deps.log.info("hub.join.pending", { userId: user.id, username: user.username }); + return this.json(res, 200, { status: "pending", userId: user.id, identityKey: generatedIdentityKey }); + } + + private respondExistingJoin( + res: ServerResponse, + matched: HubUserRecord, + identityKey: string, + joinIp: string, + ): void { + this.deps.repo.updateUserActivity(matched.id, joinIp); + if (matched.status === "active") { + const token = this.issueToken(matched, matched.role); + const updated = { + ...matched, + identityKey: matched.identityKey || identityKey, + tokenHash: hashToken(token), + }; + this.deps.repo.upsertUser(updated); + return this.json(res, 200, { + status: "active", + userId: matched.id, + username: matched.username, + userToken: token, + identityKey: updated.identityKey, + }); + } + if (matched.status === "pending") { + return this.json(res, 200, { + status: "pending", + userId: matched.id, + username: matched.username, + identityKey: matched.identityKey || identityKey, + }); + } + if (matched.status === "rejected" || matched.status === "left" || matched.status === "removed") { + const pending: HubUserRecord = { + ...matched, + status: "pending", + tokenHash: "", + identityKey: matched.identityKey || identityKey, + rejoinRequestedAt: Date.now(), + }; + this.deps.repo.upsertUser(pending); + return this.json(res, 200, { + status: "pending", + userId: pending.id, + username: pending.username, + identityKey: pending.identityKey, + }); + } + return this.json(res, 200, { status: matched.status, userId: matched.id, username: matched.username }); + } + + private findUserByUsername(username: string): HubUserRecord | null { + return this.deps.repo + .listUsers() + .find((u) => u.username === username && u.status !== "left" && u.status !== "removed") ?? null; + } + + private authenticate(req: IncomingMessage): AuthenticatedHubUser | null { + const header = req.headers.authorization; + if (!header?.startsWith("Bearer ")) return null; + const token = header.slice("Bearer ".length); + const payload = verifyUserToken(token, this.authState.authSecret); + if (!payload) return null; + const user = this.deps.repo.getUser(payload.userId); + if (!user || user.status !== "active" || user.tokenHash !== hashToken(token)) return null; + const ip = + (req.headers["x-client-ip"] as string | undefined)?.trim() || + req.socket.remoteAddress || + ""; + this.deps.repo.updateUserActivity(user.id, ip); + return { userId: user.id, username: user.username, role: user.role }; + } + + private publishMemoryForUser( + sourceUserId: string, + input: HubSharedMemoryInput, + ): HubSharedMemoryRecord { + this.pruneExpiredSharedMemories(); + const existing = this.deps.repo.getSharedMemoryBySource(sourceUserId, input.sourceTraceId); + const now = Date.now(); + const memory: HubSharedMemoryRecord = { + ...input, + id: existing?.id ?? randomUUID(), + sourceUserId, + visible: true, + deletedAt: null, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + this.deps.repo.upsertSharedMemory(memory); + return memory; + } + + private pruneExpiredSharedMemories(now = Date.now()): void { + const purged = this.deps.repo.purgeExpiredSharedMemories( + now - HUB_SHARED_MEMORY_TOMBSTONE_TTL_MS, + ); + if (purged > 0) { + this.deps.log.info("hub.shared_memories.purged", { count: purged }); + } + } + + private publishSkillForUser( + sourceUserId: string, + input: Omit, + ): HubSharedSkillRecord { + const existing = this.deps.repo.getSharedSkillBySource(sourceUserId, input.sourceSkillId); + const now = Date.now(); + const skill: HubSharedSkillRecord = { + ...input, + id: existing?.id ?? randomUUID(), + sourceUserId, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + this.deps.repo.upsertSharedSkill(skill); + return skill; + } + + private ensureBootstrapAdmin(): { userId: string; token: string } { + const now = Date.now(); + const existingByState = this.authState.bootstrapAdminUserId + ? this.deps.repo.getUser(this.authState.bootstrapAdminUserId) + : null; + const existing = + existingByState ?? + this.deps.repo.listUsers().find((u) => u.role === "admin" && u.status === "active") ?? + null; + + const base: HubUserRecord = existing ?? { + id: randomUUID(), + username: "admin", + deviceName: os.hostname() || "hub", + role: "admin", + status: "active", + tokenHash: "", + identityKey: "", + createdAt: now, + approvedAt: now, + rejectedAt: null, + leftAt: null, + removedAt: null, + lastIp: "", + lastActiveAt: now, + rejoinRequestedAt: null, + }; + const activeAdmin: HubUserRecord = { + ...base, + role: "admin", + status: "active", + approvedAt: base.approvedAt ?? now, + }; + const token = this.issueToken(activeAdmin, "admin", 3650 * 24 * 60 * 60 * 1000); + this.deps.repo.upsertUser({ ...activeAdmin, tokenHash: hashToken(token) }); + this.authState.bootstrapAdminUserId = activeAdmin.id; + this.authState.bootstrapAdminToken = token; + this.saveAuthState(); + return { userId: activeAdmin.id, token }; + } + + private issueToken(user: HubUserRecord, role: HubRole = user.role, ttlMs?: number): string { + return issueUserToken( + { userId: user.id, username: user.username, role, status: "active" }, + this.authState.authSecret, + ttlMs, + ); + } + + private loadAuthState(): HubAuthState { + const existing = this.deps.repo.getAuthState(); + if (existing?.authSecret) { + const state = { + ...existing, + hubInstanceId: existing.hubInstanceId || randomUUID(), + }; + this.deps.repo.setAuthState(state); + return state; + } + const state: HubAuthState = { + authSecret: randomBytes(32).toString("hex"), + hubInstanceId: randomUUID(), + }; + this.deps.repo.setAuthState(state); + return state; + } + + private saveAuthState(): void { + this.deps.repo.setAuthState(this.authState); + } + + private get configuredPort(): number { + return this.deps.config.hub.port || 18912; + } + + private get teamName(): string { + return this.deps.config.hub.teamName || os.hostname() || "MemOS Hub"; + } + + private get teamToken(): string { + return this.deps.config.hub.teamToken || ""; + } + + private async readJson(req: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buf.length; + if (size > 2 * 1024 * 1024) { + throw Object.assign(new Error("request body too large"), { statusCode: 413 }); + } + chunks.push(buf); + } + if (chunks.length === 0) return {}; + const text = Buffer.concat(chunks).toString("utf8"); + return text ? asRecord(JSON.parse(text)) : {}; + } + + private json(res: ServerResponse, status: number, body: unknown): void { + if (res.headersSent) return; + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + } +} + +function normalizeMemoryPayload(raw: unknown): HubSharedMemoryInput { + const body = asRecord(raw); + const sourceTraceId = String(body.sourceTraceId || body.sourceChunkId || ""); + return { + sourceTraceId, + sourceAgent: String(body.sourceAgent || ""), + kind: String(body.kind || "trace"), + summary: String(body.summary || ""), + content: String(body.content || ""), + embedding: parseEmbeddingVector(body.embedding), + }; +} + +function parseEmbeddingVector(value: unknown): EmbeddingVector | null { + if (!Array.isArray(value) || value.length === 0) return null; + const out = new Float32Array(value.length); + for (let i = 0; i < value.length; i++) { + const n = Number(value[i]); + if (!Number.isFinite(n)) return null; + out[i] = n; + } + return out; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +function publicUser(user: HubUserRecord): Record { + return { + id: user.id, + username: user.username, + name: user.username, + deviceName: user.deviceName, + role: user.role, + status: user.status, + createdAt: user.createdAt, + approvedAt: user.approvedAt, + lastIp: user.lastIp, + lastActiveAt: user.lastActiveAt, + }; +} + +function publicMemory(memory: HubSharedMemoryRecord & { score?: number }): Record { + return { + id: memory.id, + sourceTraceId: memory.sourceTraceId, + sourceUserId: memory.sourceUserId, + sourceAgent: memory.sourceAgent, + kind: memory.kind, + summary: memory.summary, + content: memory.content, + createdAt: memory.createdAt, + updatedAt: memory.updatedAt, + ...(memory.score == null ? {} : { score: memory.score }), + }; +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +function safeUsername(raw: string): string { + const trimmed = raw.trim().replace(/\s+/g, "-").slice(0, 32); + return trimmed.length >= 2 ? trimmed : `user-${randomUUID().slice(0, 8)}`; +} + +function optionalSafeUsername(raw: unknown): string { + if (typeof raw !== "string" || !raw.trim()) return ""; + return safeUsername(raw); +} diff --git a/apps/memos-local-plugin/core/injection/scheduler.ts b/apps/memos-local-plugin/core/injection/scheduler.ts new file mode 100644 index 000000000..4cfde07f8 --- /dev/null +++ b/apps/memos-local-plugin/core/injection/scheduler.ts @@ -0,0 +1,87 @@ +import type { SessionId, EpisodeId } from "../../agent-contract/dto.js"; +import type { IntentDecision, TurnRelation } from "../session/types.js"; + +export type InjectionScenarioId = + | "CHITCHAT" + | "META" + | "MEMORY_PROBE" + | "NEW_TASK" + | "FOLLOW_UP" + | "TASK" + | "UNKNOWN_SAFE"; + +export interface SchedulerContext { + userText: string; + sessionId: SessionId; + episodeId: EpisodeId; + intent: IntentDecision; + relation?: TurnRelation | "bootstrap" | "lightweight_memory"; +} + +export interface RetrievePlan { + scenarioId: InjectionScenarioId; + entry: "turn_start" | "turn_start_skip"; + wantTier1: boolean; + wantTier2: boolean; + wantTier3: boolean; + prepend: boolean; +} + +export function scheduleInjection(ctx: SchedulerContext): RetrievePlan { + const { intent, relation } = ctx; + + if (intent.kind === "chitchat" && intent.confidence >= 0.6) { + return skipPlan("CHITCHAT"); + } + + if (intent.kind === "chitchat") { + return retrievePlan("UNKNOWN_SAFE", { tier1: true, tier2: true, tier3: true }); + } + + if (intent.kind === "meta") { + return skipPlan("META"); + } + + if (intent.kind === "memory_probe") { + return retrievePlan("MEMORY_PROBE", intent.retrieval); + } + + if (relation === "new_task") { + return retrievePlan("NEW_TASK", intent.retrieval); + } + + if (relation === "revision" || relation === "follow_up" || relation === "unknown") { + return retrievePlan("FOLLOW_UP", intent.retrieval); + } + + if (intent.kind === "unknown") { + return retrievePlan("UNKNOWN_SAFE", { tier1: true, tier2: true, tier3: true }); + } + + return retrievePlan("TASK", intent.retrieval); +} + +function skipPlan(scenarioId: Extract): RetrievePlan { + return { + scenarioId, + entry: "turn_start_skip", + wantTier1: false, + wantTier2: false, + wantTier3: false, + prepend: false, + }; +} + +function retrievePlan( + scenarioId: Exclude, + retrieval: IntentDecision["retrieval"], +): RetrievePlan { + return { + scenarioId, + entry: "turn_start", + wantTier1: retrieval.tier1, + wantTier2: retrieval.tier2, + wantTier3: retrieval.tier3, + prepend: true, + }; +} diff --git a/apps/memos-local-plugin/core/llm/prompts/reflection.ts b/apps/memos-local-plugin/core/llm/prompts/reflection.ts index 0a340ed1a..063bf8e85 100644 --- a/apps/memos-local-plugin/core/llm/prompts/reflection.ts +++ b/apps/memos-local-plugin/core/llm/prompts/reflection.ts @@ -77,7 +77,7 @@ Return JSON: */ export const BATCH_REFLECTION_PROMPT: PromptDef = { id: "reflection.batch", - version: 2, + version: 3, description: "Score (and optionally synthesize) reflections for an entire episode in one call, with full thinking + tool-call context.", system: `You are reviewing every step of one AI agent episode in a single pass. @@ -106,6 +106,15 @@ Do NOT project the reflection model's own identity/provider/capabilities onto the host agent. If hostModel/hostProvider are present, treat them as the authoritative runtime context unless the episode itself contains a correction. +The user payload may also include "task_context" (string or null). When +non-null and non-empty, it is the **episode-level task summary**: a compact +overview of what this episode was about (e.g. initial user goal, intent +metadata, closing assistant reply, and tools used across the episode). It +applies to **every** step — use it to keep per-step reflections and α scores +aligned with the overall task; it is NOT a substitute for each step's own +"state", "outcome", or "tool_calls". When "task_context" is null or missing, +infer the episode goal only from the "steps" timeline. + For EACH input step, return one object containing: - "idx": copy the input idx exactly - "reflection_text": diff --git a/apps/memos-local-plugin/core/llm/prompts/retrieval-filter.ts b/apps/memos-local-plugin/core/llm/prompts/retrieval-filter.ts index c39da29be..f91a35159 100644 --- a/apps/memos-local-plugin/core/llm/prompts/retrieval-filter.ts +++ b/apps/memos-local-plugin/core/llm/prompts/retrieval-filter.ts @@ -50,7 +50,7 @@ Decision guidance: without such facts should be dropped. - RANK a SKILL when its name / description plausibly addresses the user's sub-problem. The agent decides later whether to call - \`skill_get\` for the full procedure — err on the side of ranking + \`memos_skill_get\` for the full procedure — err on the side of ranking every candidate skill that could plausibly help. - RANK a WORLD-MODEL when its topic matches the domain of the query and the body contains structural information the agent would @@ -75,7 +75,7 @@ After ranking useful candidates, self-report whether that useful set is enough: - \`sufficient: true\` when the useful items plausibly answer the QUERY as-is. - \`sufficient: false\` when the useful items are only a starting point - and the agent should broaden recall (e.g. run \`memory_search\` with + and the agent should broaden recall (e.g. run \`memos_search\` with a different query). ──── Example 1 (React dark mode, RANK 2 useful candidates) ──── diff --git a/apps/memos-local-plugin/core/memory/l2/gain.ts b/apps/memos-local-plugin/core/memory/l2/gain.ts index 877391c38..d2c322562 100644 --- a/apps/memos-local-plugin/core/memory/l2/gain.ts +++ b/apps/memos-local-plugin/core/memory/l2/gain.ts @@ -60,6 +60,7 @@ export const V7_NEUTRAL_BASELINE = 0.5; * samples. Five is roughly "one short episode worth" of signal. */ export const WITHOUT_PRIOR_PSEUDOCOUNT = 5; +export const MIN_ADAPTIVE_BASELINE = 0.2; export interface ComputeGainOpts { tauSoftmax: number; @@ -70,10 +71,15 @@ export function computeGain(input: GainInput, opts: ComputeGainOpts): GainResult const withMean = arithmeticMeanValue(input.withTraces); const withoutMean = arithmeticMeanValue(input.withoutTraces); const effectiveWith = input.withTraces.length >= 3 ? weightedWith : withMean; + const allTraces = [...input.withTraces, ...input.withoutTraces]; + const poolMean = allTraces.length > 0 + ? arithmeticMeanValue(allTraces) + : V7_NEUTRAL_BASELINE; + const baseline = adaptiveBaseline(poolMean); const blendedWithout = shrinkTowardBaseline( withoutMean, input.withoutTraces.length, - V7_NEUTRAL_BASELINE, + baseline, WITHOUT_PRIOR_PSEUDOCOUNT, ); const gain = effectiveWith - blendedWithout; @@ -85,9 +91,27 @@ export function computeGain(input: GainInput, opts: ComputeGainOpts): GainResult withCount: input.withTraces.length, withoutCount: input.withoutTraces.length, weightedWith, + poolMean, + baseline, }; } +export function adaptiveBaseline(poolMean: number): number { + if (!Number.isFinite(poolMean)) return V7_NEUTRAL_BASELINE; + return Math.max(MIN_ADAPTIVE_BASELINE, Math.min(V7_NEUTRAL_BASELINE, poolMean)); +} + +export function smoothGain(args: { + newGain: number; + currentGain: number; + alpha: number; + isFirst: boolean; +}): number { + if (args.isFirst) return args.newGain; + const alpha = clamp01(args.alpha); + return alpha * args.newGain + (1 - alpha) * args.currentGain; +} + /** * Beta-binomial style shrinkage: the empirical mean over `nObserved` * samples is blended with a `priorMean` carrying `priorPseudocount` of @@ -105,6 +129,11 @@ function shrinkTowardBaseline( return (empiricalMean * nObserved + priorMean * priorPseudocount) / denom; } +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(1, value)); +} + /** * Decide what status a policy should hold given support + gain + current * status. Used after gain recomputation; returns the possibly-new status. diff --git a/apps/memos-local-plugin/core/memory/l2/l2.ts b/apps/memos-local-plugin/core/memory/l2/l2.ts index 014eb39b0..9c6685409 100644 --- a/apps/memos-local-plugin/core/memory/l2/l2.ts +++ b/apps/memos-local-plugin/core/memory/l2/l2.ts @@ -34,7 +34,7 @@ import { L2_INDUCTION_PROMPT } from "../../llm/prompts/l2-induction.js"; import { associateTraces } from "./associate.js"; import { makeCandidatePool } from "./candidate-pool.js"; import { buildPolicyRow, induceDraft } from "./induce.js"; -import { applyGain, computeGain } from "./gain.js"; +import { applyGain, computeGain, smoothGain } from "./gain.js"; import { signatureOf } from "./signature.js"; import { tracePolicySimilarity } from "./similarity.js"; import type { @@ -48,7 +48,7 @@ import type { } from "./types.js"; export interface RunL2Deps { - repos: Pick; + repos: Pick; db: Parameters[0]["db"]; llm: LlmClient | null; log: Logger; @@ -94,6 +94,19 @@ export async function runL2( if (!tr) continue; a.signature = signatureOf(tr); if (a.matchedPolicyId) { + try { + repos.tracePolicyLinks.link({ + traceId: a.traceId, + policyId: a.matchedPolicyId, + episodeId: input.episodeId, + now: input.now ?? Date.now(), + }); + } catch (err) { + warnings.push(stageWarn("trace-policy-link", err, { + traceId: a.traceId, + policyId: a.matchedPolicyId, + })); + } emit(bus, { kind: "l2.trace.associated", episodeId: input.episodeId, @@ -187,6 +200,20 @@ export async function runL2( const evidence = inductionEvidenceByPolicy.get(dup.id) ?? new Set(); for (const id of bucket.evidenceTraceIds) evidence.add(id); inductionEvidenceByPolicy.set(dup.id, evidence); + for (const traceId of bucket.evidenceTraceIds) { + const trace = traces.find((t) => t.id === traceId); + if (!trace) continue; + try { + repos.tracePolicyLinks.link({ + traceId, + policyId: dup.id, + episodeId: trace.episodeId, + now: input.now ?? Date.now(), + }); + } catch (err) { + warnings.push(stageWarn("trace-policy-link", err, { traceId, policyId: dup.id })); + } + } continue; } @@ -241,6 +268,23 @@ export async function runL2( duplicate.id, new Set(bucket.evidenceTraceIds as string[]), ); + for (const traceId of bucket.evidenceTraceIds) { + const trace = traces.find((t) => t.id === traceId); + if (!trace) continue; + try { + repos.tracePolicyLinks.link({ + traceId, + policyId: duplicate.id, + episodeId: trace.episodeId, + now: input.now ?? Date.now(), + }); + } catch (err) { + warnings.push(stageWarn("trace-policy-link", err, { + traceId, + policyId: duplicate.id, + })); + } + } inductions.push({ signature: bucket.signature, policyId: duplicate.id, @@ -275,6 +319,23 @@ export async function runL2( policy.id, new Set(bucket.evidenceTraceIds as string[]), ); + for (const traceId of bucket.evidenceTraceIds) { + const trace = traces.find((t) => t.id === traceId); + if (!trace) continue; + try { + repos.tracePolicyLinks.link({ + traceId, + policyId: policy.id, + episodeId: trace.episodeId, + now: input.now ?? Date.now(), + }); + } catch (err) { + warnings.push(stageWarn("trace-policy-link", err, { + traceId, + policyId: policy.id, + })); + } + } inductions.push({ signature: bucket.signature, policyId: policy.id, @@ -323,6 +384,10 @@ export async function runL2( if (inductionIds) { for (const id of inductionIds) withIds.add(id); } + const newSupportIds = new Set(withIds); + for (const id of repos.tracePolicyLinks.getWithTraceIds(policy.id)) { + withIds.add(id); + } // Gain is computed over ALL traces currently in scope — the // current episode's traces PLUS the induction evidence traces @@ -332,29 +397,40 @@ export async function runL2( // gain. Pull missing induction traces from the repo. const traceById = new Map(); for (const t of input.traces) traceById.set(t.id, t); - if (inductionIds) { - for (const id of inductionIds) { - if (traceById.has(id)) continue; - const t = repos.traces.getById(id as TraceRow["id"]); - if (t) traceById.set(t.id, t); + for (const id of withIds) { + if (traceById.has(id)) continue; + const t = repos.traces.getById(id as TraceRow["id"]); + if (t) traceById.set(t.id, t); + } + for (const episodeId of repos.tracePolicyLinks.getLinkedEpisodeIds(policy.id)) { + for (const t of repos.traces.list({ episodeId, limit: 50, newestFirst: true })) { + traceById.set(t.id, t); } } - const allTraces = Array.from(traceById.values()); + const allTraces = Array.from(traceById.values()) + .sort((a, b) => b.ts - a.ts || b.id.localeCompare(a.id)); - const withTraces: TraceRow[] = allTraces.filter((t) => withIds.has(t.id)); - const withoutTraces: TraceRow[] = allTraces.filter((t) => !withIds.has(t.id)); + const withTraces: TraceRow[] = allTraces.filter((t) => withIds.has(t.id)).slice(0, 50); + const withoutTraces: TraceRow[] = allTraces.filter((t) => !withIds.has(t.id)).slice(0, 50); - const gain = computeGain( + const rawGain = computeGain( { policyId: policy.id, withTraces, withoutTraces }, { tauSoftmax: config.tauSoftmax }, ); + const smoothedGainValue = smoothGain({ + newGain: rawGain.gain, + currentGain: policy.gain, + alpha: config.gainEmaAlpha, + isFirst: policy.support === 0, + }); + const gain = { ...rawGain, gain: smoothedGainValue }; // `deltaSupport` must reflect only the *new* positive evidence // we just observed — both fresh associations AND the induction // evidence for a newly-minted policy. Previously only `withIds` // from associations contributed, so a new policy's support // stayed at 0 until someone re-associated in a later round. - const deltaSupport = withIds.size; + const deltaSupport = newSupportIds.size; const persisted = applyGain({ gain, diff --git a/apps/memos-local-plugin/core/memory/l2/subscriber.ts b/apps/memos-local-plugin/core/memory/l2/subscriber.ts index 48f928699..2682ab7b0 100644 --- a/apps/memos-local-plugin/core/memory/l2/subscriber.ts +++ b/apps/memos-local-plugin/core/memory/l2/subscriber.ts @@ -24,7 +24,7 @@ import type { L2Config, L2EventBus } from "./types.js"; export interface L2SubscriberDeps { db: StorageDb; - repos: Pick; + repos: Pick; rewardBus: RewardEventBus; l2Bus: L2EventBus; llm: LlmClient | null; diff --git a/apps/memos-local-plugin/core/memory/l2/types.ts b/apps/memos-local-plugin/core/memory/l2/types.ts index 9b8332ca5..33da18e8e 100644 --- a/apps/memos-local-plugin/core/memory/l2/types.ts +++ b/apps/memos-local-plugin/core/memory/l2/types.ts @@ -47,6 +47,8 @@ export interface L2Config { minEpisodesForInduction: number; /** Character cap for traces passed into the induction prompt. */ inductionTraceCharCap: number; + /** EMA alpha for gain smoothing. */ + gainEmaAlpha: number; } // ─── Pattern signature ───────────────────────────────────────────────────── @@ -129,6 +131,10 @@ export interface GainResult { withoutCount: number; /** V7 §0.6 eq. 3: softmax(V/τ) mean. Used when `withCount ≥ 3`. */ weightedWith: number; + /** Mean value across with + without traces; used to derive the adaptive baseline. */ + poolMean: number; + /** Shrinkage baseline after adapting to the participating trace pool. */ + baseline: number; } // ─── Inputs / outputs for the orchestrator ───────────────────────────────── diff --git a/apps/memos-local-plugin/core/pipeline/deps.ts b/apps/memos-local-plugin/core/pipeline/deps.ts index 9d699ab43..fd9c2bbf0 100644 --- a/apps/memos-local-plugin/core/pipeline/deps.ts +++ b/apps/memos-local-plugin/core/pipeline/deps.ts @@ -115,6 +115,9 @@ export function extractAlgorithmConfig( ): PipelineAlgorithmConfig { const alg = deps.config.algorithm; return { + lightweightMemory: { + enabled: alg.lightweightMemory.enabled, + }, capture: alg.capture, reward: alg.reward, l2Induction: { @@ -126,6 +129,7 @@ export function extractAlgorithmConfig( minTraceValue: alg.l2Induction.minTraceValue, minEpisodesForInduction: alg.l2Induction.minEpisodesForInduction, inductionTraceCharCap: alg.l2Induction.traceCharCap, + gainEmaAlpha: alg.l2Induction.gainEmaAlpha, }, l3Abstraction: alg.l3Abstraction, skill: alg.skill, @@ -152,10 +156,11 @@ export function extractAlgorithmConfig( skillInjectionMode: alg.retrieval.skillInjectionMode, skillSummaryChars: alg.retrieval.skillSummaryChars, decayHalfLifeDays: alg.reward.decayHalfLifeDays, - llmFilterEnabled: alg.retrieval.llmFilterEnabled, + llmFilterEnabled: alg.lightweightMemory.enabled ? true : alg.retrieval.llmFilterEnabled, llmFilterMaxKeep: alg.retrieval.llmFilterMaxKeep, - llmFilterMinCandidates: alg.retrieval.llmFilterMinCandidates, + llmFilterMinCandidates: alg.lightweightMemory.enabled ? 1 : alg.retrieval.llmFilterMinCandidates, llmFilterCandidateBodyChars: alg.retrieval.llmFilterCandidateBodyChars, + lightweightMemory: alg.lightweightMemory.enabled, }, session: { followUpMode: alg.session.followUpMode, @@ -320,8 +325,15 @@ export function buildPipelineSession( deps: PipelineDeps, bus: SessionEventBus, ): PipelineSessionSet { - const intent = createIntentClassifier({ llm: deps.llm ?? undefined }); - const relation = createRelationClassifier({ llm: deps.llm ?? undefined }); + const llmDisabled = deps.config.algorithm.lightweightMemory.enabled; + const intent = createIntentClassifier({ + llm: deps.llm ?? undefined, + disableLlm: llmDisabled, + }); + const relation = createRelationClassifier({ + llm: deps.llm ?? undefined, + disableLlm: llmDisabled, + }); const episodeManager = createEpisodeManager({ sessionsRepo: adaptSessionsRepo(deps.repos.sessions), episodesRepo: adaptEpisodesRepo(deps.repos.episodes), @@ -335,6 +347,7 @@ export function buildPipelineSession( bus, episodeManager, now: deps.now, + lightweightMemory: llmDisabled, }); return { intent, relation, sessionManager, episodeManager }; } diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 042a0deb7..4974ee16d 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -37,6 +37,7 @@ import type { RetrievalQueryDTO, RetrievalResultDTO, SessionId, + ShareScope, SkillDTO, SkillId, SubagentOutcomeDTO, @@ -98,11 +99,20 @@ import { isVisibleTo, visibilityWhere, } from "../runtime/namespace.js"; -import type { RetrievalConfig } from "../retrieval/types.js"; +import { createHubRuntime, type HubMemorySearchHit, type HubRuntime } from "../hub/runtime.js"; +import { llmFilterCandidates } from "../retrieval/llm-filter.js"; +import type { RankedCandidate } from "../retrieval/ranker.js"; +import type { + RetrievalConfig, + TraceCandidate, +} from "../retrieval/types.js"; import type { UserFeedback } from "../reward/types.js"; // ─── Public bootstrap helpers ─────────────────────────────────────────────── +const FINAL_HUB_LLM_FILTER_TIMEOUT_MS = 3_000; +const IMPORT_WRITE_BATCH_SIZE = 500; + export interface BootstrapOptions { agent: AgentKind; namespace?: RuntimeNamespace; @@ -468,6 +478,8 @@ export function createMemoryCore( let shutDown = false; /** Per-episode monotonic step counter for tool outcomes. */ const toolStepByEpisode = new Map(); + let hubRuntime: HubRuntime | null = null; + let hubRuntimeConfig: ResolvedConfig = handle.config; const skillStartedAtByPolicy = new Map(); const skillRunDurationBySkill = new Map(); const l2StartedAtByEpisode = new Map(); @@ -527,6 +539,10 @@ export function createMemoryCore( return row.ownerAgentKind === ns.agentKind && row.ownerProfileId === ns.profileId; } + function isLightweightEpisode(row: { meta?: Record | null }): boolean { + return row.meta?.lightweightMemory === true; + } + // ─── Stale topic auto-finalize ── // Open topics are allowed to survive clean session closes and process // restarts so the next user turn can be classified against them. Once a @@ -543,7 +559,9 @@ export function createMemoryCore( if (nowMs - lastStaleScan < 30_000) return; lastStaleScan = nowMs; try { - const openEpisodes = handle.repos.episodes.list({ status: "open", limit: 200 }); + const openEpisodes = handle.repos.episodes + .list({ status: "open", limit: 200 }) + .filter((ep) => !isLightweightEpisode(ep)); if (openEpisodes.length === 0) return; const stale: Array }> = []; for (const ep of openEpisodes) { @@ -573,7 +591,7 @@ export function createMemoryCore( try { const dirtyClosed = handle.repos.episodes .list({ status: "closed", limit: 500 }) - .filter((ep) => episodeRewardIsDirty(ep)); + .filter((ep) => !isLightweightEpisode(ep) && episodeRewardIsDirty(ep)); if (dirtyClosed.length > 0) { await recoverDirtyClosedEpisodes(dirtyClosed); } @@ -584,6 +602,269 @@ export function createMemoryCore( } } + function makeHubRuntime(config: ResolvedConfig): HubRuntime { + return createHubRuntime({ + repos: handle.repos, + config, + log: rootLogger.child({ channel: "core.hub" }), + agent: handle.agent, + version: pkgVersion, + }); + } + + async function searchHubMemoryHits( + query: string, + limit = 5, + ): Promise { + if (!hubRuntimeConfig.hub.enabled || !hubRuntime || !query.trim()) return []; + try { + const hits = await withTimeout( + hubRuntime.searchMemories(query, limit), + 1_500, + "hub_search_timeout", + ); + return hits.map(hubMemoryToRetrievalHit); + } catch (err) { + log.debug("hub.search.failed", { + err: err instanceof Error ? err.message : String(err), + }); + return []; + } + } + + function hubMemoryToRetrievalHit(hit: HubMemorySearchHit): RetrievalHitDTO { + const source = hit.sourceAgent ? ` from ${hit.sourceAgent}` : ""; + return { + tier: 2, + refKind: "trace", + refId: `hub:${hit.id}`, + score: hit.score, + snippet: clipText( + [ + `Team Hub memory${source}`, + hit.summary ? `Summary: ${hit.summary}` : "", + hit.content, + ].filter(Boolean).join("\n"), + 900, + ), + ownerAgentKind: hit.sourceAgent || undefined, + ownerProfileId: hit.sourceUserId, + shareScope: "hub", + sourceTraceId: hit.sourceTraceId, + }; + } + + async function finalFilterMergedHits(input: { + query: string; + localHits: readonly RetrievalHitDTO[]; + hubHits: readonly RetrievalHitDTO[]; + localAlreadyFiltered: boolean; + config: RetrievalConfig; + episodeId?: string; + }): Promise<{ + hits: RetrievalHitDTO[]; + dropped: RetrievalHitDTO[]; + outcome: string; + sufficient: boolean | null; + deduped: number; + }> { + const merged = dedupeMergedRetrievalHits(input.localHits, input.hubHits); + const deduped = input.localHits.length + input.hubHits.length - merged.length; + const hasHubAfterDedupe = merged.some((hit) => hit.shareScope === "hub"); + if (merged.length === 0 || (input.localAlreadyFiltered && !hasHubAfterDedupe)) { + return { + hits: merged, + dropped: [], + outcome: input.hubHits.length === 0 + ? "no_hub" + : merged.length === 0 + ? "empty" + : "hub_deduped", + sufficient: null, + deduped, + }; + } + + const rankedPairs = merged.map((hit, index) => ({ + hit, + ranked: rankedCandidateFromRetrievalHit(hit, index), + })); + let filtered = await llmFilterCandidates( + { + query: input.query, + ranked: rankedPairs.map((pair) => pair.ranked), + episodeId: input.episodeId, + }, + { + llm: handle.retrievalDeps().llm ?? null, + log, + timeoutMs: FINAL_HUB_LLM_FILTER_TIMEOUT_MS, + config: input.config, + }, + ); + if (input.config.lightweightMemory && !llmFilterOutcomeSucceeded(filtered.outcome)) { + filtered = { + ...filtered, + kept: [], + dropped: [...filtered.dropped, ...filtered.kept], + }; + } + const kept = new Set(filtered.kept); + const dropped = new Set(filtered.dropped); + return { + hits: rankedPairs.filter((pair) => kept.has(pair.ranked)).map((pair) => pair.hit), + dropped: rankedPairs.filter((pair) => dropped.has(pair.ranked)).map((pair) => pair.hit), + outcome: filtered.outcome, + sufficient: filtered.sufficient, + deduped, + }; + } + + function dedupeMergedRetrievalHits( + localHits: readonly RetrievalHitDTO[], + hubHits: readonly RetrievalHitDTO[], + ): RetrievalHitDTO[] { + const localTraceIds = new Set(localHits.map((hit) => hit.refId)); + const out: RetrievalHitDTO[] = []; + const normalizedSeen: string[] = []; + for (const hit of [...localHits, ...hubHits]) { + if (hit.shareScope === "hub" && hit.sourceTraceId && localTraceIds.has(hit.sourceTraceId)) { + continue; + } + const normalized = normalizeRetrievedSnippet(hit.snippet); + if (normalized && normalizedSeen.some((seen) => retrievedTextLooksDuplicate(seen, normalized))) { + continue; + } + out.push(hit); + if (normalized) normalizedSeen.push(normalized); + } + return out; + } + + function rankedCandidateFromRetrievalHit(hit: RetrievalHitDTO, index: number): RankedCandidate { + const score = Number.isFinite(hit.score) ? Math.max(0, hit.score) : 0; + const text = hit.snippet.trim(); + const candidate: TraceCandidate = { + tier: "tier2", + refKind: "trace", + refId: hit.refId as TraceId, + cosine: Math.min(1, score), + ts: Date.now(), + vec: null, + channels: [{ + channel: "pattern", + rank: index, + score: Math.min(1, Math.max(0.001, score)), + }], + value: 0, + priority: Math.min(1, score), + episodeId: (`final-filter:${index}`) as EpisodeId, + sessionId: "final-filter" as SessionId, + vecKind: "summary", + userText: text, + agentText: "", + summary: firstNonEmptyLine(text), + reflection: null, + tags: hit.shareScope === "hub" ? ["hub"] : [], + }; + return { + candidate, + relevance: score, + rrf: 0, + score, + normSq: null, + }; + } + + function renderFinalHitsContext(hits: readonly RetrievalHitDTO[]): string { + if (hits.length === 0) return ""; + return [ + "## Retrieved Memories", + ...hits.map((hit, index) => { + const source = hit.shareScope === "hub" ? "Hub" : "Local"; + const title = `${index + 1}. [${source} ${hit.refKind}]`; + return `${title} ${clipText(hit.snippet, 1_000).replace(/\n/g, "\n ")}`; + }), + ].join("\n\n"); + } + + function normalizeRetrievedSnippet(text: string): string { + return text + .toLowerCase() + .replace(/^team hub memory[^\n]*\n?/gim, "") + .replace(/\bsummary:\s*/gi, "") + .replace(/\[(user|assistant|note)\]\s*/gi, "") + .replace(/\b(user|assistant|note)\s*[::]\s*/gi, "") + .replace(/[《》"'“”‘’`*_#>\-::\s]+/g, "") + .trim(); + } + + function retrievedTextLooksDuplicate(a: string, b: string): boolean { + if (!a || !b) return false; + if (a === b) return true; + const min = Math.min(a.length, b.length); + return min >= 12 && (a.includes(b) || b.includes(a)); + } + + function firstNonEmptyLine(text: string): string { + return text.split(/\n+/).map((line) => line.trim()).find(Boolean)?.slice(0, 240) ?? ""; + } + + function llmFilterOutcomeSucceeded(outcome: string): boolean { + return outcome === "llm_kept_all" || outcome === "llm_filtered"; + } + + function logCandidatesFromHits(hits: readonly RetrievalHitDTO[]): Array<{ + tier: number; + refKind: string; + refId: string; + score: number; + snippet: string; + sourceTraceId?: string; + }> { + return hits.map((h) => ({ + tier: h.tier, + refKind: h.refKind, + refId: h.refId, + score: h.score, + snippet: h.snippet, + sourceTraceId: h.sourceTraceId, + })); + } + + async function ensureHubRuntimeStarted(config: ResolvedConfig): Promise { + hubRuntimeConfig = config; + if (!config.hub.enabled) { + if (hubRuntime) { + await hubRuntime.stop(); + hubRuntime = null; + } + return; + } + if (!hubRuntime) { + hubRuntime = makeHubRuntime(config); + } + await hubRuntime.start(); + } + + async function restartHubRuntime(config: ResolvedConfig): Promise { + hubRuntimeConfig = config; + const previous = hubRuntime; + hubRuntime = null; + if (previous) { + try { + await previous.stop(); + } catch (err) { + log.warn("hub.runtime.stop_failed", { + err: err instanceof Error ? err.message : String(err), + }); + } + } + if (!config.hub.enabled) return; + hubRuntime = makeHubRuntime(config); + await hubRuntime.start(); + } + // ─── Lifecycle ── async function init(): Promise { if (shutDown) { @@ -594,6 +875,8 @@ export function createMemoryCore( } initialized = true; + await ensureHubRuntimeStarted(handle.config); + // Preserve recent open topics across restarts. A crash or Ctrl+C is // not evidence that the topic ended; the next user turn gets routed // through relation classification. Only hard-stale open topics are @@ -602,13 +885,24 @@ export function createMemoryCore( const orphans = handle.repos.episodes.list({ status: "open", limit: 500 }); if (orphans.length > 0) { const nowMs = Date.now(); - const stale = orphans.filter( + const lightweight = orphans.filter((ep) => isLightweightEpisode(ep)); + for (const ep of lightweight) { + handle.repos.episodes.close(ep.id as EpisodeId, nowMs, ep.rTask ?? undefined); + handle.repos.episodes.updateMeta(ep.id as EpisodeId, { + lightweightMemory: true, + closeReason: "finalized", + recoveredAtStartup: nowMs, + recoveryReason: "lightweight_startup_close", + }); + } + const normalOrphans = orphans.filter((ep) => !isLightweightEpisode(ep)); + const stale = normalOrphans.filter( (ep) => ep.rTask != null || (ep.traceIds?.length ?? 0) > 0 || nowMs - (ep.endedAt ?? ep.startedAt) > STALE_EPISODE_TIMEOUT_MS, ); - const recent = orphans.filter((ep) => !stale.includes(ep)); + const recent = normalOrphans.filter((ep) => !stale.includes(ep)); for (const ep of recent) { handle.repos.episodes.updateMeta(ep.id as EpisodeId, { topicState: (ep.meta?.topicState as string | undefined) ?? "interrupted", @@ -622,7 +916,7 @@ export function createMemoryCore( } const dirtyClosed = handle.repos.episodes .list({ status: "closed", limit: 500 }) - .filter((ep) => episodeRewardIsDirty(ep)); + .filter((ep) => !isLightweightEpisode(ep) && episodeRewardIsDirty(ep)); if (dirtyClosed.length > 0) { await recoverDirtyClosedEpisodes(dirtyClosed); } @@ -688,7 +982,7 @@ export function createMemoryCore( // ─── Skill lifecycle → api_logs(skill_*) ────────────────────────── // Emit structured rows for the Logs page so users can watch skill // generation / verification / retirement events with the same JSON - // detail the memory_search / memory_add cards show. Event shapes + // detail the memos_search / memory_add cards show. Event shapes // vary per kind — we spread the raw event into `output` (with any // sensitive fields already redacted upstream) rather than hand- // rolling per-kind schemas. @@ -855,6 +1149,7 @@ export function createMemoryCore( const needsRewardFallback: EpisodeId[] = []; for (const ep of orphans) { + if (isLightweightEpisode(ep)) continue; try { const episodeId = ep.id as EpisodeId; const traceIds = (ep.traceIds ?? []) as TraceId[]; @@ -947,6 +1242,7 @@ export function createMemoryCore( ): Promise { log.info("init.dirty_closed_episodes.rescore", { count: episodes.length }); for (const ep of episodes) { + if (isLightweightEpisode(ep)) continue; const episodeId = ep.id as EpisodeId; const endedAt = ep.endedAt ?? Date.now(); handle.repos.episodes.updateMeta(episodeId, { @@ -968,6 +1264,7 @@ export function createMemoryCore( function episodeRewardIsDirty(ep: EpisodeRow & { meta?: Record }): boolean { const meta = ep.meta ?? {}; + if (meta.lightweightMemory === true) return false; if (meta.rewardDirty && typeof meta.rewardDirty === "object") return true; const reward = meta.reward; @@ -1106,6 +1403,13 @@ export function createMemoryCore( if (shutDown) return; shutDown = true; try { + try { + await hubRuntime?.stop(); + } catch (err) { + log.warn("hub.stop_failed", { + err: err instanceof Error ? err.message : String(err), + }); + } await handle.shutdown("memory-core.shutdown"); } finally { if (telemetry) { @@ -1300,17 +1604,32 @@ export function createMemoryCore( const startedAt = Date.now(); let ok = true; let packet: Awaited> | null = null; + let hubCandidates: Array<{ + tier: number; + refKind: string; + refId: string; + score: number; + snippet: string; + }> = []; + let finalFilteredCandidates: typeof hubCandidates = []; + let finalDroppedCandidates: typeof hubCandidates = []; + let finalFilterStats: RetrievalStatsLogPayload["finalFilter"] | undefined; + let finalHubKept = 0; + let hubHits: RetrievalHitDTO[] = []; const ns = namespaceFor(turn.agent, turn); activeNamespace = ns; - const namespacedTurn = { - ...turn, - namespace: ns, - contextHints: { - ...(turn.contextHints ?? {}), - ...namespaceMeta(ns), - }, - }; try { + hubHits = await searchHubMemoryHits(turn.userText, 5); + hubCandidates = logCandidatesFromHits(hubHits); + const namespacedTurn = { + ...turn, + namespace: ns, + contextHints: { + ...(turn.contextHints ?? {}), + ...namespaceMeta(ns), + ...(hubHits.length > 0 ? { __memosDeferLlmFilterToCaller: true } : {}), + }, + }; packet = await handle.onTurnStart(namespacedTurn); // The orchestrator stamps the *routed* session / episode id onto the @@ -1338,10 +1657,32 @@ export function createMemoryCore( snippet: snip.body, }; }); + const final = await finalFilterMergedHits({ + query: turn.userText, + localHits: hits, + hubHits, + localAlreadyFiltered: hubHits.length === 0, + config: handle.retrievalDeps().config, + episodeId: packet.episodeId, + }); + finalFilteredCandidates = logCandidatesFromHits(final.hits); + finalDroppedCandidates = logCandidatesFromHits(final.dropped); + finalFilterStats = hubHits.length > 0 + ? { + outcome: final.outcome, + kept: final.hits.length, + dropped: final.dropped.length, + sufficient: final.sufficient, + deduped: final.deduped, + } + : undefined; + finalHubKept = final.hits.filter((hit) => hit.shareScope === "hub").length; return { query, - hits, - injectedContext: packet.rendered, + hits: final.hits, + injectedContext: hubHits.length > 0 + ? renderFinalHitsContext(final.hits) + : packet.rendered, tierLatencyMs: packet.tierLatencyMs, }; } catch (err) { @@ -1360,7 +1701,7 @@ export function createMemoryCore( } finally { // Log every retrieval — not just adhoc `searchMemory` calls — // so the viewer's Logs page can show what was recalled for - // each real agent turn. Without this, `memory_search` rows + // each real agent turn. Without this, `memos_search` rows // only showed up when the viewer's search box was used. try { const snippets = packet?.snippets ?? []; @@ -1374,11 +1715,17 @@ export function createMemoryCore( const droppedIds = new Set( (packet?.droppedByLlm ?? []).map((s) => s.refId as string), ); - const filtered = candidates.filter((c) => !droppedIds.has(c.refId)); - const dropped = candidates.filter((c) => droppedIds.has(c.refId)); + const localFiltered = candidates.filter((c) => !droppedIds.has(c.refId)); + const filtered = hubCandidates.length > 0 + ? finalFilteredCandidates + : localFiltered; + const localDropped = candidates.filter((c) => droppedIds.has(c.refId)); + const dropped = hubCandidates.length > 0 + ? [...localDropped, ...finalDroppedCandidates] + : localDropped; const stats = packet ? handle.consumeRetrievalStats(packet.packetId) : null; handle.repos.apiLogs.insert({ - toolName: "memory_search", + toolName: handle.algorithm.lightweightMemory.enabled ? "memory_search" : "memos_search", input: { type: "turn_start", agent: turn.agent, @@ -1389,10 +1736,18 @@ export function createMemoryCore( output: ok ? { candidates, - hubCandidates: [] as unknown[], + hubCandidates, filtered, droppedByLlm: dropped, - stats: stats ? retrievalStatsPayload(stats) : undefined, + stats: stats + ? withHubStats( + retrievalStatsPayload(stats), + hubCandidates.length, + filtered.length, + finalHubKept, + finalFilterStats, + ) + : undefined, } : { error: "turn_start_retrieval_failed" }, durationMs: Date.now() - startedAt, @@ -1400,7 +1755,7 @@ export function createMemoryCore( calledAt: startedAt, }); } catch (logErr) { - log.debug("apiLogs.memory_search.turn_start.skipped", { + log.debug("apiLogs.memos_search.turn_start.skipped", { err: logErr instanceof Error ? logErr.message : String(logErr), }); } @@ -1492,6 +1847,18 @@ export function createMemoryCore( : null; const sessionId = episode?.sessionId ?? trace?.sessionId ?? null; const text = feedbackText(row); + const lightweightFeedback = handle.algorithm.lightweightMemory.enabled || + (episode ? isLightweightEpisode(episode) : false); + + if (lightweightFeedback) { + if (telemetry) { + telemetry.trackFeedback( + handle.namespace.agentKind, + feedback.polarity, + ); + } + return toFeedbackDTO(row); + } if (episode && sessionId) { const rewardFeedback: UserFeedback = { @@ -1941,7 +2308,7 @@ export function createMemoryCore( namespace: ns, repos: wrapRetrievalRepos(handle.repos, ns), }; - const { turnStartRetrieve } = await import("../retrieval/retrieve.js"); + const { toolDrivenRetrieve } = await import("../retrieval/retrieve.js"); const sessionId = query.sessionId ?? ("adhoc-session-" + randomUUID().slice(0, 8) as SessionId); @@ -1956,40 +2323,23 @@ export function createMemoryCore( snippet: string; }> = []; let filtered: typeof candidates = []; - let retrievalStats: { - raw?: number; - ranked?: number; - droppedByThreshold?: number; - thresholdFloor?: number; - topRelevance?: number; - llmFilter?: { - outcome?: string; - kept?: number; - dropped?: number; - sufficient?: boolean | null; - }; - channelHits?: Record; - queryTokens?: number; - queryTags?: string[]; - embedding?: { - attempted: boolean; - ok: boolean; - degraded: boolean; - errorCode?: string; - errorMessage?: string; - }; - } | undefined; + let droppedByFinalFilter: typeof candidates = []; + let hubCandidates: typeof candidates = []; + let retrievalStats: RetrievalStatsLogPayload | undefined; + let finalHubKept = 0; try { - const result = await turnStartRetrieve(deps, { - reason: "turn_start", + const hubHits = await searchHubMemoryHits(query.query, query.topK?.tier2 ?? 5); + hubCandidates = logCandidatesFromHits(hubHits); + const result = await toolDrivenRetrieve(deps, { + reason: "tool_driven", agent: query.agent, namespace: ns, sessionId, episodeId: query.episodeId, - userText: query.query, - contextHints: query.filters ?? {}, + tool: "memos_search", + args: { ...(query.filters ?? {}), query: query.query }, ts, - }); + }, { skipLlmFilter: hubHits.length > 0 }); let hits: RetrievalHitDTO[] = result.packet.snippets.map((snip) => ({ tier: inferTier(snip.refKind), refId: snip.refId, @@ -2021,6 +2371,27 @@ export function createMemoryCore( }); } + const final = await finalFilterMergedHits({ + query: query.query, + localHits: hits, + hubHits, + localAlreadyFiltered: hubHits.length === 0, + config: deps.config, + episodeId: query.episodeId, + }); + const returnedHits = final.hits; + const finalFilterStats: RetrievalStatsLogPayload["finalFilter"] | undefined = + hubHits.length > 0 + ? { + outcome: final.outcome, + kept: final.hits.length, + dropped: final.dropped.length, + sufficient: final.sufficient, + deduped: final.deduped, + } + : undefined; + finalHubKept = final.hits.filter((hit) => hit.shareScope === "hub").length; + // Build the logs-page payload BEFORE returning so the row // reflects the exact shape the adapter sees. `candidates` lists // everything tiered/retrieved; `filtered` is what the injector @@ -2033,14 +2404,21 @@ export function createMemoryCore( score: h.score, snippet: h.snippet, })); - filtered = candidates; // post-filter is what we return → same list. + filtered = logCandidatesFromHits(returnedHits); // final list returned to the adapter. + droppedByFinalFilter = logCandidatesFromHits(final.dropped); // Three-stage observability — surfaced verbatim so the viewer's // Logs page can render "raw → threshold → ranked → LLM filter" // funnels. All fields are optional on the producer side so older // consumers keep working. const s = result.stats; - retrievalStats = retrievalStatsPayload(s); + retrievalStats = withHubStats( + retrievalStatsPayload(s), + hubCandidates.length, + filtered.length, + finalHubKept, + finalFilterStats, + ); if (s.embedding?.degraded) { handle.repos.apiLogs.insert({ toolName: "system_error", @@ -2060,15 +2438,17 @@ export function createMemoryCore( return { query, - hits, - injectedContext: result.packet.rendered, + hits: returnedHits, + injectedContext: hubHits.length > 0 + ? renderFinalHitsContext(returnedHits) + : result.packet.rendered, tierLatencyMs: result.packet.tierLatencyMs, }; } catch (err) { ok = false; if (telemetry) { telemetry.trackError( - "memory_search", + handle.algorithm.lightweightMemory.enabled ? "memory_search" : "memos_search", err instanceof MemosError ? err.code : "unknown", ); } @@ -2076,7 +2456,7 @@ export function createMemoryCore( } finally { try { handle.repos.apiLogs.insert({ - toolName: "memory_search", + toolName: handle.algorithm.lightweightMemory.enabled ? "memory_search" : "memos_search", input: { type: "tool_call", agent: query.agent, @@ -2088,8 +2468,9 @@ export function createMemoryCore( output: ok ? { candidates, - hubCandidates: [] as unknown[], + hubCandidates, filtered, + droppedByLlm: droppedByFinalFilter, stats: retrievalStats, } : { error: "retrieval_failed" }, @@ -2098,7 +2479,7 @@ export function createMemoryCore( calledAt: startedAt, }); } catch (logErr) { - log.debug("apiLogs.memory_search.skipped", { + log.debug("apiLogs.memos_search.skipped", { err: logErr instanceof Error ? logErr.message : String(logErr), }); } @@ -2142,10 +2523,14 @@ export function createMemoryCore( ensureLive(); const existing = handle.repos.traces.getById(id); if (!existing || !ownedByCurrent(existing)) return { deleted: false }; + const wasHubShared = existing.share?.scope === "hub"; handle.db.tx(() => { handle.repos.episodes.removeTraceIds(existing.episodeId, [id]); handle.repos.traces.deleteById(id); }); + if (wasHubShared) { + await runHubSync(() => hubRuntime?.unpublishTrace(id), "trace", id); + } return { deleted: true }; } @@ -2157,10 +2542,14 @@ export function createMemoryCore( for (const id of ids) { const existing = handle.repos.traces.getById(id); if (!existing || !ownedByCurrent(existing)) continue; + const wasHubShared = existing.share?.scope === "hub"; handle.db.tx(() => { handle.repos.episodes.removeTraceIds(existing.episodeId, [id]); handle.repos.traces.deleteById(id); }); + if (wasHubShared) { + await runHubSync(() => hubRuntime?.unpublishTrace(id), "trace", id); + } deleted++; } return { deleted }; @@ -2169,7 +2558,7 @@ export function createMemoryCore( async function shareTrace( id: string, share: { - scope: "private" | "local" | "public" | "hub" | null; + scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, @@ -2179,6 +2568,9 @@ export function createMemoryCore( if (!existing || !ownedByCurrent(existing)) return null; handle.repos.traces.updateShare(id, share); const updated = handle.repos.traces.getById(id); + if (updated) { + await syncHubTraceShare(traceRowToDTO(updated, handle.repos.episodes.getById(updated.episodeId))); + } return updated ? traceRowToDTO(updated, handle.repos.episodes.getById(updated.episodeId)) : null; @@ -2270,7 +2662,11 @@ export function createMemoryCore( ensureLive(); const existing = handle.repos.policies.getById(id); if (!existing || !ownedByCurrent(existing)) return { deleted: false }; + const wasHubShared = existing.share?.scope === "hub"; handle.repos.policies.deleteById(id); + if (wasHubShared) { + await runHubSync(() => hubRuntime?.unpublishPolicy(id), "policy", id); + } return { deleted: true }; } @@ -2362,14 +2758,18 @@ export function createMemoryCore( ensureLive(); const existing = handle.repos.worldModel.getById(id); if (!existing || !ownedByCurrent(existing)) return { deleted: false }; + const wasHubShared = existing.share?.scope === "hub"; handle.repos.worldModel.deleteById(id); + if (wasHubShared) { + await runHubSync(() => hubRuntime?.unpublishWorldModel(id), "world_model", id); + } return { deleted: true }; } async function sharePolicy( id: string, share: { - scope: "private" | "local" | "public" | "hub" | null; + scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, @@ -2379,13 +2779,14 @@ export function createMemoryCore( if (!existing || !ownedByCurrent(existing)) return null; handle.repos.policies.updateShare(id, share); const updated = handle.repos.policies.getById(id); + if (updated) await syncHubPolicyShare(policyRowToDTO(updated)); return updated ? policyRowToDTO(updated) : null; } async function shareWorldModel( id: string, share: { - scope: "private" | "local" | "public" | "hub" | null; + scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, @@ -2395,6 +2796,7 @@ export function createMemoryCore( if (!existing || !ownedByCurrent(existing)) return null; handle.repos.worldModel.updateShare(id, share); const updated = handle.repos.worldModel.getById(id); + if (updated) await syncHubWorldModelShare(worldModelRowToDTO(updated)); return updated ? worldModelRowToDTO(updated) : null; } @@ -2469,7 +2871,9 @@ export function createMemoryCore( limit: input.limit ?? 50, offset: input.offset ?? 0, }); - return rows.filter((r: EpisodeRow) => visibleToCurrent(r)).map((r: EpisodeRow) => r.id as EpisodeId); + return rows + .filter((r: EpisodeRow) => visibleToCurrent(r) && !isLightweightEpisode(r)) + .map((r: EpisodeRow) => r.id as EpisodeId); } async function countEpisodes(input?: { @@ -2480,7 +2884,9 @@ export function createMemoryCore( }): Promise { ensureLive(); return handle.repos.episodes.list({ sessionId: input?.sessionId, limit: 100_000 }).filter((r) => - (input?.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) + (input?.includeAllNamespaces || visibleToCurrent(r)) && + matchesNamespaceFilter(r, input) && + !isLightweightEpisode(r) ).length; } @@ -2505,7 +2911,9 @@ export function createMemoryCore( limit: input?.ownerAgentKind || input?.ownerProfileId ? 100_000 : input?.limit ?? 50, offset: input?.ownerAgentKind || input?.ownerProfileId ? 0 : input?.offset ?? 0, }).filter((r) => - (input?.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) + (input?.includeAllNamespaces || visibleToCurrent(r)) && + matchesNamespaceFilter(r, input) && + !isLightweightEpisode(r) ); const pagedRows = input?.ownerAgentKind || input?.ownerProfileId ? rows.slice(input?.offset ?? 0, (input?.offset ?? 0) + (input?.limit ?? 50)) @@ -2654,7 +3062,6 @@ export function createMemoryCore( ensureLive(); if (input.namespace) activeNamespace = input.namespace; const episode = handle.repos.episodes.getById(input.episodeId); - if (episode && !input.includeAllNamespaces && !visibleToCurrent(episode)) return []; const rows = handle.repos.traces.list({ episodeId: input.episodeId, limit: 500, @@ -2726,7 +3133,7 @@ export function createMemoryCore( sessionId: input?.sessionId, ownerAgentKind: input?.ownerAgentKind, ownerProfileId: input?.ownerProfileId, - }); + }, vis); } // q substring scan — mirror `listTraces`. Walk all matching // traces from the repo (no limit) and apply the same filter. @@ -2959,19 +3366,37 @@ export function createMemoryCore( ): Promise { ensureLive(); if (opts?.namespace) activeNamespace = opts.namespace; - const row = handle.repos.skills.getById(id); + const row = resolveSkillRowForGet(id, opts); if (!row || (!opts?.includeAllNamespaces && !visibleToCurrent(row))) return null; if (opts?.recordUse) { - handle.repos.skills.recordUse(id, Date.now()); + handle.repos.skills.recordUse(row.id, Date.now()); if (opts.recordTrial) { - recordSkillTrial(id, opts); + recordSkillTrial(row.id, opts); } - const updated = handle.repos.skills.getById(id); + const updated = handle.repos.skills.getById(row.id); return updated ? skillRowToDTO(updated) : skillRowToDTO(row); } return skillRowToDTO(row); } + function resolveSkillRowForGet( + id: SkillId, + opts?: { includeAllNamespaces?: boolean }, + ) { + const exact = handle.repos.skills.getById(id); + if (exact) return exact; + + const rawId = String(id); + const shortId = rawId.includes(":") ? rawId.slice(rawId.lastIndexOf(":") + 1) : rawId; + const candidates = handle.repos.skills.list({ limit: 5_000 }).filter((row) => { + if (!opts?.includeAllNamespaces && !visibleToCurrent(row)) return false; + if (row.name === rawId || row.name === shortId) return true; + if (rawId.includes(":")) return row.id === shortId; + return row.id.endsWith(`:${rawId}`); + }); + return candidates.length === 1 ? candidates[0]! : null; + } + function recordSkillTrial( skillId: SkillId, opts: { @@ -3011,7 +3436,7 @@ export function createMemoryCore( createdAt: Date.now(), resolvedAt: null, evidence: { - source: "skill_get", + source: "memos_skill_get", }, }); } @@ -3239,186 +3664,254 @@ export function createMemoryCore( // deterministic for the user — they opt in via a de-duplicating // pre-pass if they want merging. const traces = Array.isArray(bundle.traces) ? bundle.traces : []; - - // Phase 0 — ensure every referenced (sessionId, episodeId) row - // exists before we try to `traces.insert`. Without this the FK - // constraint on `traces.episode_id REFERENCES episodes(id)` makes - // every legacy/external row bounce with "FOREIGN KEY constraint - // failed". This was the "Imported 0 traces, 0 skills, 0 tasks" - // bug the user reported on the legacy import button. + const defaultOwner = ownerFromNamespace(activeNamespace); const seenSessions = new Set(); const seenEpisodes = new Set(); - for (const raw of traces) { - const dto = raw as TraceDTO; - if (!dto?.id || !dto.episodeId || !dto.sessionId) continue; - if (!seenSessions.has(dto.sessionId)) { - try { - if (!handle.repos.sessions.getById(dto.sessionId)) { - handle.repos.sessions.upsert({ - id: dto.sessionId, - agent: handle.agent, - startedAt: dto.ts ?? Date.now(), - lastSeenAt: dto.ts ?? Date.now(), - meta: { source: "import" }, - } as never); + + for (const batch of chunkArray(traces, IMPORT_WRITE_BATCH_SIZE)) { + const result = handle.db.tx(() => { + let batchImported = 0; + let batchSkipped = 0; + const valid = batch + .map((raw) => raw as TraceDTO) + .filter((dto) => dto?.id && dto.episodeId && dto.sessionId); + batchSkipped += batch.length - valid.length; + + // Phase 0 — ensure every referenced (sessionId, episodeId) row + // exists before `traces.insert`, otherwise the FK constraint would + // bounce imported legacy/external rows. + for (const dto of valid) { + const owner = importOwnerFields(dto, defaultOwner); + const ts = Number.isFinite(dto.ts) ? dto.ts : Date.now(); + if (!seenSessions.has(dto.sessionId)) { + try { + if (!handle.repos.sessions.getById(dto.sessionId)) { + handle.repos.sessions.upsert({ + id: dto.sessionId, + agent: dto.ownerAgentKind ?? handle.agent, + ...owner, + startedAt: ts, + lastSeenAt: ts, + meta: { source: "import" }, + } as never); + } + } catch { + // If the synthetic session row is rejected, the FK insert + // below will fail and be counted as `skipped`. + } + seenSessions.add(dto.sessionId); + } + if (!seenEpisodes.has(dto.episodeId)) { + try { + if (!handle.repos.episodes.getById(dto.episodeId)) { + handle.repos.episodes.upsert({ + id: dto.episodeId, + sessionId: dto.sessionId, + ...owner, + share: dto.share ?? null, + startedAt: ts, + endedAt: ts, + traceIds: [], + rTask: null, + status: "closed", + meta: { source: "import" }, + } as never); + } + } catch { + /* see comment above */ + } + seenEpisodes.add(dto.episodeId); } - } catch { - // If the synthetic session row is rejected, the FK insert - // below will fail and be counted as `skipped`. Don't abort - // the entire import batch for one bad session. } - seenSessions.add(dto.sessionId); - } - if (!seenEpisodes.has(dto.episodeId)) { - try { - if (!handle.repos.episodes.getById(dto.episodeId)) { - handle.repos.episodes.upsert({ - id: dto.episodeId, + + const existingIds = new Set( + handle.repos.traces + .getManyByIds(valid.map((dto) => dto.id as TraceId)) + .map((row) => row.id), + ); + const addedByEpisode = new Map(); + for (const dto of valid) { + try { + if (existingIds.has(dto.id)) { batchSkipped++; continue; } + const owner = importOwnerFields(dto, defaultOwner); + const ts = Number.isFinite(dto.ts) ? dto.ts : Date.now(); + const turnId = Number.isFinite(dto.turnId) ? dto.turnId : ts; + handle.repos.traces.insert({ + ...owner, + id: dto.id, + episodeId: dto.episodeId, sessionId: dto.sessionId, - startedAt: dto.ts ?? Date.now(), - endedAt: dto.ts ?? Date.now(), - traceIds: [], - rTask: null, - status: "closed", - meta: { source: "import" }, - } as never); + ts, + userText: dto.userText ?? "", + agentText: dto.agentText ?? "", + summary: dto.summary ?? null, + share: dto.share ?? null, + toolCalls: dto.toolCalls ?? [], + agentThinking: dto.agentThinking ?? null, + reflection: dto.reflection ?? null, + value: dto.value ?? 0, + alpha: dto.alpha ?? 0, + rHuman: dto.rHuman ?? null, + priority: dto.priority ?? 0, + tags: dto.tags ?? [], + vecSummary: null, + vecAction: null, + turnId, + schemaVersion: 1, + } as TraceRow); + existingIds.add(dto.id); + if (!addedByEpisode.has(dto.episodeId)) addedByEpisode.set(dto.episodeId, []); + addedByEpisode.get(dto.episodeId)!.push(dto.id as TraceId); + batchImported++; + } catch { + batchSkipped++; } - } catch { - /* see comment above */ } - seenEpisodes.add(dto.episodeId); - } - } - - for (const raw of traces) { - try { - const dto = raw as TraceDTO; - if (!dto?.id) { skipped++; continue; } - const existing = handle.repos.traces.getById(dto.id); - if (existing) { skipped++; continue; } - // The trace table requires a fuller row shape than TraceDTO. - // We reconstitute a stub row. Vectors start null here; the - // embedding maintenance endpoint can backfill them with the - // currently configured embedding model after import. - handle.repos.traces.insert({ - id: dto.id, - episodeId: dto.episodeId, - sessionId: dto.sessionId, - ts: dto.ts, - userText: dto.userText, - agentText: dto.agentText, - toolCalls: dto.toolCalls ?? [], - reflection: dto.reflection ?? null, - value: dto.value ?? 0, - alpha: dto.alpha ?? 0, - rHuman: dto.rHuman ?? null, - priority: dto.priority ?? 0, - tags: [], - vecSummary: null, - vecAction: null, - turnId: dto.turnId, - schemaVersion: 1, - } as TraceRow); - imported++; - } catch { - skipped++; - } + for (const [episodeId, ids] of addedByEpisode) { + const episode = handle.repos.episodes.getById(episodeId); + if (!episode) continue; + handle.repos.episodes.appendTrace( + episodeId, + dedupeTraceIds([...episode.traceIds, ...ids]) as string[], + ); + } + return { imported: batchImported, skipped: batchSkipped }; + }); + imported += result.imported; + skipped += result.skipped; + await yieldToEventLoop(); } // Policies / world models / skills use existing repo.insert shape. - for (const raw of bundle.policies ?? []) { - try { - const dto = raw as PolicyDTO; - if (!dto?.id || handle.repos.policies.getById(dto.id)) { skipped++; continue; } - handle.repos.policies.insert({ - id: dto.id, - title: dto.title, - trigger: dto.trigger, - procedure: dto.procedure, - verification: dto.verification, - boundary: dto.boundary, - support: dto.support ?? 0, - gain: dto.gain ?? 0, - status: dto.status, - experienceType: dto.experienceType ?? "success_pattern", - evidencePolarity: dto.evidencePolarity ?? "positive", - salience: dto.salience ?? 0, - confidence: dto.confidence ?? 0.5, - skillEligible: dto.skillEligible !== false, - sourceEpisodeIds: dto.sourceEpisodeIds ?? [], - sourceFeedbackIds: dto.sourceFeedbackIds ?? [], - sourceTraceIds: dto.sourceTraceIds ?? [], - inducedBy: "import", - decisionGuidance: { - preference: [...(dto.preference ?? [])], - antiPattern: [...(dto.antiPattern ?? [])], - }, - verifierMeta: dto.verifierMeta ?? null, - vec: null, - createdAt: dto.createdAt ?? Date.now(), - updatedAt: dto.updatedAt ?? Date.now(), - }); - imported++; - } catch { - skipped++; - } + for (const batch of chunkArray(bundle.policies ?? [], IMPORT_WRITE_BATCH_SIZE)) { + const result = handle.db.tx(() => { + let batchImported = 0; + let batchSkipped = 0; + for (const raw of batch) { + try { + const dto = raw as PolicyDTO; + if (!dto?.id || handle.repos.policies.getById(dto.id)) { batchSkipped++; continue; } + handle.repos.policies.insert({ + ...importOwnerFields(dto, defaultOwner), + id: dto.id, + title: dto.title, + trigger: dto.trigger, + procedure: dto.procedure, + verification: dto.verification, + boundary: dto.boundary, + support: dto.support ?? 0, + gain: dto.gain ?? 0, + status: dto.status, + experienceType: dto.experienceType ?? "success_pattern", + evidencePolarity: dto.evidencePolarity ?? "positive", + salience: dto.salience ?? 0, + confidence: dto.confidence ?? 0.5, + skillEligible: dto.skillEligible !== false, + sourceEpisodeIds: dto.sourceEpisodeIds ?? [], + sourceFeedbackIds: dto.sourceFeedbackIds ?? [], + sourceTraceIds: dto.sourceTraceIds ?? [], + inducedBy: "import", + decisionGuidance: { + preference: [...(dto.preference ?? [])], + antiPattern: [...(dto.antiPattern ?? [])], + }, + verifierMeta: dto.verifierMeta ?? null, + share: dto.share ?? null, + vec: null, + createdAt: dto.createdAt ?? Date.now(), + updatedAt: dto.updatedAt ?? Date.now(), + }); + batchImported++; + } catch { + batchSkipped++; + } + } + return { imported: batchImported, skipped: batchSkipped }; + }); + imported += result.imported; + skipped += result.skipped; + await yieldToEventLoop(); } - for (const raw of bundle.skills ?? []) { - try { - const dto = raw as SkillDTO; - if (!dto?.id || handle.repos.skills.getById(dto.id)) { skipped++; continue; } - handle.repos.skills.insert({ - id: dto.id, - name: dto.name, - status: dto.status, - invocationGuide: dto.invocationGuide, - eta: dto.eta ?? 0, - support: dto.support ?? 0, - gain: dto.gain ?? 0, - trialsAttempted: 0, - trialsPassed: 0, - sourcePolicyIds: dto.sourcePolicyIds ?? [], - sourceWorldModelIds: dto.sourceWorldModelIds ?? [], - evidenceAnchors: dto.evidenceAnchors ?? [], - procedureJson: {}, - vec: null, - createdAt: dto.createdAt ?? Date.now(), - updatedAt: dto.updatedAt ?? Date.now(), - version: dto.version ?? 1, - usageCount: dto.usageCount ?? 0, - lastUsedAt: dto.lastUsedAt ?? null, - } as SkillRow); - imported++; - } catch { - skipped++; - } + for (const batch of chunkArray(bundle.skills ?? [], IMPORT_WRITE_BATCH_SIZE)) { + const result = handle.db.tx(() => { + let batchImported = 0; + let batchSkipped = 0; + for (const raw of batch) { + try { + const dto = raw as SkillDTO; + if (!dto?.id || handle.repos.skills.getById(dto.id)) { batchSkipped++; continue; } + handle.repos.skills.insert({ + ...importOwnerFields(dto, defaultOwner), + id: dto.id, + name: dto.name, + status: dto.status, + invocationGuide: dto.invocationGuide, + eta: dto.eta ?? 0, + support: dto.support ?? 0, + gain: dto.gain ?? 0, + trialsAttempted: 0, + trialsPassed: 0, + sourcePolicyIds: dto.sourcePolicyIds ?? [], + sourceWorldModelIds: dto.sourceWorldModelIds ?? [], + evidenceAnchors: dto.evidenceAnchors ?? [], + procedureJson: {}, + share: dto.share ?? null, + vec: null, + createdAt: dto.createdAt ?? Date.now(), + updatedAt: dto.updatedAt ?? Date.now(), + version: dto.version ?? 1, + usageCount: dto.usageCount ?? 0, + lastUsedAt: dto.lastUsedAt ?? null, + } as SkillRow); + batchImported++; + } catch { + batchSkipped++; + } + } + return { imported: batchImported, skipped: batchSkipped }; + }); + imported += result.imported; + skipped += result.skipped; + await yieldToEventLoop(); } - for (const raw of bundle.worldModels ?? []) { - try { - const dto = raw as WorldModelDTO; - if (!dto?.id || handle.repos.worldModel.getById(dto.id)) { skipped++; continue; } - handle.repos.worldModel.insert({ - id: dto.id, - title: dto.title, - body: dto.body, - structure: { environment: [], inference: [], constraints: [] }, - domainTags: [], - confidence: 0.5, - policyIds: dto.policyIds ?? [], - sourceEpisodeIds: [], - inducedBy: "import", - vec: null, - createdAt: dto.createdAt ?? Date.now(), - updatedAt: dto.updatedAt ?? Date.now(), - version: dto.version ?? 1, - status: dto.status ?? "active", - } as WorldModelRow); - imported++; - } catch { - skipped++; - } + for (const batch of chunkArray(bundle.worldModels ?? [], IMPORT_WRITE_BATCH_SIZE)) { + const result = handle.db.tx(() => { + let batchImported = 0; + let batchSkipped = 0; + for (const raw of batch) { + try { + const dto = raw as WorldModelDTO; + if (!dto?.id || handle.repos.worldModel.getById(dto.id)) { batchSkipped++; continue; } + handle.repos.worldModel.insert({ + ...importOwnerFields(dto, defaultOwner), + id: dto.id, + title: dto.title, + body: dto.body, + structure: { environment: [], inference: [], constraints: [] }, + domainTags: [], + confidence: 0.5, + policyIds: dto.policyIds ?? [], + sourceEpisodeIds: [], + inducedBy: "import", + share: dto.share ?? null, + vec: null, + createdAt: dto.createdAt ?? Date.now(), + updatedAt: dto.updatedAt ?? Date.now(), + version: dto.version ?? 1, + status: dto.status ?? "active", + } as WorldModelRow); + batchImported++; + } catch { + batchSkipped++; + } + } + return { imported: batchImported, skipped: batchSkipped }; + }); + imported += result.imported; + skipped += result.skipped; + await yieldToEventLoop(); } return { imported, skipped }; @@ -3601,14 +4094,16 @@ export function createMemoryCore( sourceText: row.summary?.trim() || row.userText.trim() || "(empty)", update: (vec) => handle.repos.traces.updateVector(row.id, "vecSummary", vec), }); - slots.push({ - kind: "trace", - id: row.id, - field: "vec_action", - vec: row.vecAction, - sourceText: traceActionEmbeddingText(row), - update: (vec) => handle.repos.traces.updateVector(row.id, "vecAction", vec), - }); + if (!isLightweightMemoryTrace(row)) { + slots.push({ + kind: "trace", + id: row.id, + field: "vec_action", + vec: row.vecAction, + sourceText: traceActionEmbeddingText(row), + update: (vec) => handle.repos.traces.updateVector(row.id, "vecAction", vec), + }); + } } if (rows.length < pageSize) break; } @@ -3666,6 +4161,10 @@ export function createMemoryCore( return !slot.vec || (dimension > 0 && slot.vec.length !== dimension); } + function isLightweightMemoryTrace(row: TraceRow): boolean { + return row.tags.includes("lightweight_memory"); + } + function emptyEmbeddingStatsByKind(): EmbeddingMaintenanceStats["byKind"] { const empty = () => ({ totalSlots: 0, @@ -3763,6 +4262,9 @@ export function createMemoryCore( // empty in the UI without wiping their existing value. const filtered = stripEmptySecrets(patch); const result = await applyPatch(handle.home, filtered); + if (patchTouchesHub(filtered)) { + await restartHubRuntime(result.config); + } return maskSecrets(result.config as unknown as Record); } @@ -3842,7 +4344,7 @@ export function createMemoryCore( async function shareSkill( id: SkillId, share: { - scope: "private" | "local" | "public" | "hub" | null; + scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, @@ -3852,9 +4354,88 @@ export function createMemoryCore( if (!existing || !ownedByCurrent(existing)) return null; handle.repos.skills.updateShare(id, share); const updated = handle.repos.skills.getById(id); + if (updated) await syncHubSkillShare(skillRowToDTO(updated)); return updated ? skillRowToDTO(updated) : null; } + async function syncHubTraceShare(trace: TraceDTO): Promise { + const row = handle.repos.traces.getById(trace.id); + await runHubSync( + trace.share?.scope === "hub" + ? () => hubRuntime?.publishTrace(trace, row?.vecSummary ?? null) + : () => hubRuntime?.unpublishTrace(trace.id), + "trace", + trace.id, + ); + } + + async function syncHubPolicyShare(policy: PolicyDTO): Promise { + await runHubSync( + policy.share?.scope === "hub" + ? () => hubRuntime?.publishPolicy(policy) + : () => hubRuntime?.unpublishPolicy(policy.id), + "policy", + policy.id, + ); + } + + async function syncHubWorldModelShare(world: WorldModelDTO): Promise { + await runHubSync( + world.share?.scope === "hub" + ? () => hubRuntime?.publishWorldModel(world) + : () => hubRuntime?.unpublishWorldModel(world.id), + "world_model", + world.id, + ); + } + + async function syncHubSkillShare(skill: SkillDTO): Promise { + await runHubSync( + skill.share?.scope === "hub" + ? () => hubRuntime?.publishSkill(skill) + : () => hubRuntime?.unpublishSkill(skill.id), + "skill", + skill.id, + ); + } + + async function runHubSync( + op: () => Promise | unknown, + kind: string, + id: string, + ): Promise { + if (!hubRuntimeConfig.hub.enabled) return; + try { + await op(); + } catch (err) { + log.warn("hub.sync_failed", { + kind, + id, + err: err instanceof Error ? err.message : String(err), + }); + } + } + + async function hubAdminSnapshot(): Promise { + ensureLive(); + return await hubRuntime?.adminSnapshot() ?? { enabled: !!hubRuntimeConfig.hub.enabled }; + } + + async function approveHubUser(userId: string): Promise { + ensureLive(); + return await hubRuntime?.approveUser(userId) ?? { ok: false }; + } + + async function rejectHubUser(userId: string): Promise { + ensureLive(); + return await hubRuntime?.rejectUser(userId) ?? { ok: false }; + } + + async function removeHubUser(userId: string): Promise { + ensureLive(); + return await hubRuntime?.removeUser(userId) ?? { ok: false }; + } + // ─── Observability ── function subscribeEvents(handler: (e: CoreEvent) => void): Unsubscribe { return handle.subscribeEvents(handler); @@ -3923,6 +4504,10 @@ export function createMemoryCore( reactivateSkill, updateSkill, shareSkill, + hubAdminSnapshot, + approveHubUser, + rejectHubUser, + removeHubUser, getConfig, patchConfig, metrics, @@ -4010,6 +4595,10 @@ function stripEmptySecrets(patch: Record): Record): boolean { + return Object.prototype.hasOwnProperty.call(patch, "hub"); +} + function orderTraceRowsForEpisode( rows: readonly TraceRow[], traceIds: readonly TraceId[], @@ -4224,6 +4813,48 @@ function compareTraceRowsForEpisodeOrder( return a.ts - b.ts; } +function importOwnerFields( + row: { + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + ownerWorkspaceId?: string | null; + }, + fallback: ReturnType, +): { + ownerAgentKind: AgentKind; + ownerProfileId: string; + ownerWorkspaceId: string | null; +} { + return { + ownerAgentKind: row.ownerAgentKind ?? fallback.ownerAgentKind, + ownerProfileId: row.ownerProfileId ?? fallback.ownerProfileId, + ownerWorkspaceId: row.ownerWorkspaceId ?? fallback.ownerWorkspaceId ?? null, + }; +} + +function dedupeTraceIds(ids: readonly TraceId[]): TraceId[] { + const seen = new Set(); + const out: TraceId[] = []; + for (const id of ids) { + if (seen.has(id)) continue; + seen.add(id); + out.push(id); + } + return out; +} + +function chunkArray(items: readonly T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; +} + +function yieldToEventLoop(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + // ─── Row → DTO mappers ─────────────────────────────────────────────────────── export function traceRowToDTO(row: TraceRow, episode?: EpisodeRow | null): TraceDTO { @@ -4549,7 +5180,9 @@ function findLatestPersistedModelStatus( return null; } -function retrievalStatsPayload(s: import("../retrieval/types.js").RetrievalStats): { +type RetrievalStatsLogPayload = { + scenarioId?: string; + plannedTiers?: { tier1: boolean; tier2: boolean; tier3: boolean }; raw?: number; ranked?: number; droppedByThreshold?: number; @@ -4565,8 +5198,23 @@ function retrievalStatsPayload(s: import("../retrieval/types.js").RetrievalStats queryTokens?: number; queryTags?: string[]; embedding?: import("../retrieval/types.js").RetrievalStats["embedding"]; -} { + localReturned?: number; + hubReturned?: number; + hubKept?: number; + finalReturned?: number; + finalFilter?: { + outcome?: string; + kept?: number; + dropped?: number; + sufficient?: boolean | null; + deduped?: number; + }; +}; + +function retrievalStatsPayload(s: import("../retrieval/types.js").RetrievalStats): RetrievalStatsLogPayload { return { + scenarioId: s.scenarioId, + plannedTiers: s.plannedTiers, raw: s.rawCandidateCount, ranked: s.rankedCount, droppedByThreshold: s.droppedByThresholdCount, @@ -4585,6 +5233,23 @@ function retrievalStatsPayload(s: import("../retrieval/types.js").RetrievalStats }; } +function withHubStats( + stats: RetrievalStatsLogPayload, + hubReturned: number, + finalReturned: number, + hubKept: number, + finalFilter?: RetrievalStatsLogPayload["finalFilter"], +): RetrievalStatsLogPayload { + return { + ...stats, + localReturned: Math.max(0, finalReturned - hubKept), + hubReturned, + hubKept, + finalReturned, + ...(finalFilter ? { finalFilter } : {}), + }; +} + function llmHealth( llm: PipelineHandle["llm"], // Kept in the signature for source compatibility with older callers @@ -4922,6 +5587,20 @@ function formatThreshold(n: number): string { return Number(n.toFixed(3)).toString(); } +function clipText(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max - 1)}...` : text; +} + +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(label)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + /** * Produce a short content string from toolCalls when userText/agentText * are both empty (sub-steps after the first in a multi-tool turn). diff --git a/apps/memos-local-plugin/core/pipeline/orchestrator.ts b/apps/memos-local-plugin/core/pipeline/orchestrator.ts index ad5490e30..8d4f51d20 100644 --- a/apps/memos-local-plugin/core/pipeline/orchestrator.ts +++ b/apps/memos-local-plugin/core/pipeline/orchestrator.ts @@ -40,6 +40,7 @@ import { repairRetrieve, } from "../retrieval/retrieve.js"; import type { RetrievalResult } from "../retrieval/types.js"; +import { scheduleInjection, type RetrievePlan } from "../injection/scheduler.js"; import { buildPipelineBuses, @@ -80,13 +81,14 @@ import { memoryBuffer } from "../logger/index.js"; import { onBroadcastLog } from "../logger/transports/sse-broadcast.js"; import { createEmbeddingRetryWorker, systemErrorEvent } from "../embedding/index.js"; import type { EpisodeSnapshot } from "../session/index.js"; -import type { RelationDecision } from "../session/types.js"; +import type { IntentDecision, RelationDecision, TurnRelation } from "../session/types.js"; // ─── Factory ────────────────────────────────────────────────────────────── export function createPipeline(deps: PipelineDeps): PipelineHandle { const log = pipelineLogger(deps); const algorithm = extractAlgorithmConfig(deps); + const lightweightMode = algorithm.lightweightMemory.enabled; const buses = buildPipelineBuses(); // Session + intent. @@ -310,6 +312,53 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { return snap.id as SessionId; } + function lightweightEpisodeMeta(meta: Record): Record { + if (!lightweightMode) return meta; + return { + ...meta, + lightweightMemory: true, + relation: "lightweight_memory", + topicState: "active", + }; + } + + async function startLightweightEpisode( + sessionId: SessionId, + userText: string, + meta: Record, + turnTs?: number, + ): Promise { + const currentEpId = openEpisodeBySession.get(sessionId); + if (currentEpId) { + const current = session.sessionManager.getEpisode(currentEpId); + if (current?.status === "open") { + session.sessionManager.finalizeEpisode(currentEpId, { + patchMeta: { + lightweightMemory: true, + closeReason: "finalized", + recoveryReason: "lightweight_boundary_before_new_turn", + }, + }); + } + openEpisodeBySession.delete(sessionId); + } + lastEpisodeBySession.delete(sessionId); + const snap = await session.sessionManager.startEpisode({ + sessionId, + userMessage: userText, + ts: turnTs, + meta: lightweightEpisodeMeta(meta), + }); + openEpisodeBySession.set(sessionId, snap.id as EpisodeId); + return snap; + } + + function isLightweightEpisode( + episode: Pick | null | undefined, + ): boolean { + return episode?.meta?.lightweightMemory === true; + } + /** * Decide whether the new turn continues the current episode, opens a * new episode in the same session, or requires a brand-new session. @@ -350,6 +399,11 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { const mergeCapMs = algorithm.session.mergeMaxGapMs; const turnTs = timestampFromMeta(meta, "startedAtTurnTs"); + if (lightweightMode) { + const snap = await startLightweightEpisode(sessionId, userText, meta, turnTs); + return { episode: snap, sessionId, relation: "lightweight_memory" }; + } + // ─── Case 1: there is a currently open episode ────────────────── const currentEpId = openEpisodeBySession.get(sessionId); if (currentEpId) { @@ -915,7 +969,10 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { }; } - async function retrieveTurnStart(input: TurnInputDTO): Promise { + async function retrieveTurnStart( + input: TurnInputDTO, + plan?: RetrievePlan, + ): Promise { const ctx = { reason: "turn_start" as const, agent: input.agent, @@ -928,7 +985,18 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { const result: RetrievalResult = await turnStartRetrieve( retrievalDepsFor(input.namespace), ctx, - { events: buses.retrieval }, + { + events: buses.retrieval, + skipLlmFilter: input.contextHints?.__memosDeferLlmFilterToCaller === true, + plan: plan + ? { + scenarioId: plan.scenarioId, + wantTier1: plan.wantTier1, + wantTier2: plan.wantTier2, + wantTier3: plan.wantTier3, + } + : undefined, + }, ); turnStartRetrievalStats.set(result.packet.packetId, result.stats); return result.packet; @@ -1007,9 +1075,47 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { sessionId, episodeId: episode.id as EpisodeId, }; + const schedulerIntent = await intentForCurrentTurn({ + episode, + userText: input.userText, + ts: input.ts, + }); + const retrievePlan = scheduleInjection({ + userText: input.userText, + sessionId, + episodeId: episode.id as EpisodeId, + intent: schedulerIntent, + relation: schedulerRelation(routing.relation), + }); try { - const packet = await retrieveTurnStart(normalized); + if (retrievePlan.entry === "turn_start_skip") { + const packet = emptyInjectionPacket(input.agent, sessionId, episode.id as EpisodeId, input.ts); + turnStartRetrievalStats.set( + packet.packetId, + skippedRetrievalStats({ + agent: input.agent, + sessionId, + episodeId: episode.id as EpisodeId, + scenarioId: retrievePlan.scenarioId, + userText: input.userText, + elapsedMs: now() - t0, + }), + ); + log.info("turn.started", { + agent: input.agent, + sessionId, + episodeId: episode.id, + userChars: input.userText.length, + retrievalScenario: retrievePlan.scenarioId, + retrievalSkipped: true, + retrievalTotalMs: 0, + elapsedMs: now() - t0, + }); + return packet; + } + + const packet = await retrieveTurnStart(normalized, retrievePlan); // Always stamp the routed sessionId + episodeId on the packet so // adapters can correlate the subsequent `agent_end` / `turn.end` // call without needing a separate round-trip to the session @@ -1026,6 +1132,7 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { sessionId, episodeId: episode.id, userChars: input.userText.length, + retrievalScenario: retrievePlan.scenarioId, retrievalTotalMs: packet.tierLatencyMs.tier1 + packet.tierLatencyMs.tier2 + packet.tierLatencyMs.tier3, @@ -1134,7 +1241,9 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { let liteTraceIds: string[] = []; if (liveEpisode) { try { - const captureResult = await subs.captureRunner.runLite({ episode: liveEpisode }); + const captureResult = isLightweightEpisode(liveEpisode) + ? await subs.captureRunner.runLightweight({ episode: liveEpisode }) + : await subs.captureRunner.runLite({ episode: liveEpisode }); liteTraceIds = captureResult.traceIds; if (captureResult.traceIds.length > 0) { session.sessionManager.attachTraceIds(episodeId, captureResult.traceIds as string[]); @@ -1153,12 +1262,14 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { // even though the episode isn't closed yet — the classifier doesn't // care about `endedAt`, only about prev-user / prev-assistant text. const initialUserTurn = liveEpisode?.turns.find((t) => t.role === "user"); - lastEpisodeBySession.set(sessionId, { - episodeId, - endedAt: now(), - userText: (initialUserTurn?.content ?? "").slice(0, 1000), - assistantText: (result.agentText ?? "").slice(0, 2000), - }); + if (!lightweightMode) { + lastEpisodeBySession.set(sessionId, { + episodeId, + endedAt: now(), + userText: (initialUserTurn?.content ?? "").slice(0, 1000), + assistantText: (result.agentText ?? "").slice(0, 2000), + }); + } log.info("turn.ended", { agent: result.agent, @@ -1182,6 +1293,7 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { // ─── Tool outcomes (decision repair) ──────────────────────────────────── function recordToolOutcome(outcome: RecordToolOutcomeInput): void { + if (lightweightMode) return; const sessionId = outcome.sessionId; const context = outcome.context ?? @@ -1220,6 +1332,10 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { const nextTick = () => new Promise((resolve) => setImmediate(resolve)); await subs.subscriptions.capture.drain(); + if (lightweightMode) { + await embeddingRetryWorker.flush(); + return; + } await nextTick(); await subs.subscriptions.reward.drain(); await nextTick(); @@ -1279,6 +1395,7 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { action: string; durationMs: number; }): void { + if (lightweightMode) return; try { deps.repos.apiLogs.insert({ toolName: "session_relation_classify", @@ -1322,6 +1439,27 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { return typeof ts === "number" && Number.isFinite(ts) ? ts : undefined; } + async function intentForCurrentTurn(input: { + episode: EpisodeSnapshot; + userText: string; + ts?: number; + }): Promise { + const firstTurn = input.episode.turns[0]; + const isFreshEpisodeForThisTurn = + input.episode.turns.length === 1 && + firstTurn?.role === "user" && + firstTurn.content === input.userText && + (input.ts == null || firstTurn.ts === input.ts); + + if (isFreshEpisodeForThisTurn) { + return input.episode.intent; + } + + return session.intent.classify(input.userText, { + episodeId: input.episode.id as EpisodeId, + }); + } + /** * Build richer context for the relation classifier from episode turns. * @@ -1437,6 +1575,66 @@ function emptyInjectionPacket( }; } +function skippedRetrievalStats(input: { + agent: AgentKind; + sessionId: SessionId; + episodeId: EpisodeId; + scenarioId: string; + userText: string; + elapsedMs: number; +}): RetrievalResult["stats"] { + return { + reason: "turn_start", + scenarioId: input.scenarioId, + agent: input.agent, + sessionId: input.sessionId, + episodeId: input.episodeId, + plannedTiers: { tier1: false, tier2: false, tier3: false }, + tier1Count: 0, + tier2Count: 0, + tier3Count: 0, + tier1LatencyMs: 0, + tier2LatencyMs: 0, + tier3LatencyMs: 0, + fuseLatencyMs: 0, + totalLatencyMs: Math.max(0, input.elapsedMs), + queryTokens: Math.ceil(input.userText.length / 4), + queryTags: [], + emptyPacket: true, + embedding: { + attempted: false, + ok: false, + degraded: false, + }, + rawCandidateCount: 0, + droppedByThresholdCount: 0, + thresholdFloor: 0, + topRelevance: 0, + rankedCount: 0, + llmFilterOutcome: "skipped_by_scheduler", + llmFilterSufficient: true, + llmFilterKept: 0, + llmFilterDropped: 0, + channelHits: {}, + }; +} + +function schedulerRelation( + relation: string | undefined, +): TurnRelation | "bootstrap" | "lightweight_memory" | undefined { + if ( + relation === "revision" || + relation === "follow_up" || + relation === "new_task" || + relation === "unknown" || + relation === "bootstrap" || + relation === "lightweight_memory" + ) { + return relation; + } + return undefined; +} + function _assertConfigShape( algorithm: PipelineAlgorithmConfig, feedback: FeedbackConfig, diff --git a/apps/memos-local-plugin/core/pipeline/types.ts b/apps/memos-local-plugin/core/pipeline/types.ts index 495dddf3d..7969b7d39 100644 --- a/apps/memos-local-plugin/core/pipeline/types.ts +++ b/apps/memos-local-plugin/core/pipeline/types.ts @@ -82,6 +82,7 @@ import type { LogRecord } from "../../agent-contract/log-record.js"; * know its own defaults. */ export interface PipelineAlgorithmConfig { + lightweightMemory: LightweightMemoryConfig; capture: CaptureConfig; reward: RewardConfig; l2Induction: L2Config; @@ -92,6 +93,10 @@ export interface PipelineAlgorithmConfig { session: SessionRoutingConfig; } +export interface LightweightMemoryConfig { + enabled: boolean; +} + /** * How the pipeline routes a new user turn relative to the previously * closed episode. See `algorithm.session` in the config schema for the diff --git a/apps/memos-local-plugin/core/retrieval/ALGORITHMS.md b/apps/memos-local-plugin/core/retrieval/ALGORITHMS.md index 0b2cea6e6..63435a000 100644 --- a/apps/memos-local-plugin/core/retrieval/ALGORITHMS.md +++ b/apps/memos-local-plugin/core/retrieval/ALGORITHMS.md @@ -146,7 +146,7 @@ Callers treat `null` as "don't inject anything". degenerate packets. Values are user-tunable via `algorithm.retrieval.*`. 5. **Tier-1 summary mode** — V7 §2.6 implies the full Skill body is injected at turn start. We default to a *summary* representation - (`name + η + 1-line description + a `skill_get(id="…")` invocation + (`name + η + 1-line description + a `memos_skill_get(id="…")` invocation hint`) so the host model can pull the full procedure on demand instead of bloating every prompt with skills it may never use. Hosts without function calling can opt back into full-body inlining by diff --git a/apps/memos-local-plugin/core/retrieval/README.md b/apps/memos-local-plugin/core/retrieval/README.md index c722f1c6f..fc57414b4 100644 --- a/apps/memos-local-plugin/core/retrieval/README.md +++ b/apps/memos-local-plugin/core/retrieval/README.md @@ -33,7 +33,7 @@ All five return a `RetrievalResult = { packet, stats }`: ```ts import { turnStartRetrieve, // onConversationTurn — full Tier 1+2+3 - toolDrivenRetrieve, // memory_search / memory_timeline / … + toolDrivenRetrieve, // memos_search / memos_timeline / … skillInvokeRetrieve, // agent is about to call skill. subAgentRetrieve, // sub-agent spawned with mission prompt repairRetrieve, // N tool failures → anti-pattern recall @@ -75,7 +75,7 @@ prompt with content the agent may never use. We support two modes via | Mode | What lands in the prompt | When to use | |-----------|----------------------------------------------------------------------------|-------------------------------------------------| -| `summary` (default) | `name`, `η`, `status`, a 1-line summary, plus a `skill_get(id="…")` invocation hint. The footer also lists `skill_get` / `skill_list`. | Tool-calling hosts (OpenClaw, Hermes). Keeps prompts small; the agent calls `skill_get` only for skills it actually wants. | +| `summary` (default) | `name`, `η`, `status`, a 1-line summary, plus a `memos_skill_get(id="…")` invocation hint. The footer also lists `memos_skill_get` / `memos_skill_list`. | Tool-calling hosts (OpenClaw, Hermes). Keeps prompts small; the agent calls `memos_skill_get` only for skills it actually wants. | | `full` | Legacy: full `invocationGuide` body per skill (truncated to 640 chars). | Hosts without function-calling support. | The summary text is the first paragraph of `invocationGuide` (clamped to diff --git a/apps/memos-local-plugin/core/retrieval/decision-guidance.ts b/apps/memos-local-plugin/core/retrieval/decision-guidance.ts index 85e83fd82..555c03c07 100644 --- a/apps/memos-local-plugin/core/retrieval/decision-guidance.ts +++ b/apps/memos-local-plugin/core/retrieval/decision-guidance.ts @@ -26,6 +26,7 @@ import type { EpisodeId } from "../../agent-contract/dto.js"; import type { RankedCandidate } from "./ranker.js"; import type { RetrievalRepos, + EpisodeCandidate, ExperienceCandidate, SkillCandidate, TraceCandidate, @@ -84,6 +85,8 @@ export function collectDecisionGuidance(input: CollectInput): CollectedGuidance const c = r.candidate; if (c.tier === "tier2" && c.refKind === "trace") { traceEpisodeIds.add((c as TraceCandidate).episodeId); + } else if (c.tier === "tier2" && c.refKind === "episode") { + traceEpisodeIds.add((c as EpisodeCandidate).refId); } else if (c.tier === "tier2" && c.refKind === "experience") { policyIds.add((c as ExperienceCandidate).refId); } else if (c.tier === "tier1") { diff --git a/apps/memos-local-plugin/core/retrieval/dedupe-trace-episode.ts b/apps/memos-local-plugin/core/retrieval/dedupe-trace-episode.ts new file mode 100644 index 000000000..c0aef9cc8 --- /dev/null +++ b/apps/memos-local-plugin/core/retrieval/dedupe-trace-episode.ts @@ -0,0 +1,80 @@ +/** + * Post-rank dedupe: avoid injecting both trace(s) and an episode rollup for + * the same `episodeId`. + * + * `rollupEpisodes` builds episode summaries from the same trace pool that + * also enters ranking as individual traces, so MMR can keep both. After + * LLM filter, choose either the trace side or the episode side for each + * episode. Multiple trace hits from the same episode are still distinct + * concrete turns and are preserved unless an episode rollup wins the group. + */ + +import type { RankedCandidate } from "./ranker.js"; +import type { EpisodeCandidate, TierCandidate, TraceCandidate } from "./types.js"; +import type { EpisodeId } from "../types.js"; + +export interface DedupeTraceEpisodeResult { + ranked: RankedCandidate[]; + dedupedByEpisodeCount: number; +} + +function episodeIdOf(candidate: TierCandidate): EpisodeId | null { + if (candidate.refKind === "trace") { + return (candidate as TraceCandidate).episodeId; + } + if (candidate.refKind === "episode") { + return (candidate as EpisodeCandidate).refId; + } + return null; +} + +function compareTraceEpisodeRanked(a: RankedCandidate, b: RankedCandidate): number { + if (b.score !== a.score) return b.score - a.score; + const aEp = a.candidate.refKind === "episode"; + const bEp = b.candidate.refKind === "episode"; + if (aEp && !bEp) return -1; + if (!aEp && bEp) return 1; + return b.relevance - a.relevance; +} + +export function dedupeTraceEpisodeByEpisodeId( + ranked: readonly RankedCandidate[], +): DedupeTraceEpisodeResult { + const groups = new Map< + string, + { traces: RankedCandidate[]; episodes: RankedCandidate[] } + >(); + for (const r of ranked) { + const epId = episodeIdOf(r.candidate); + if (!epId) continue; + const group = groups.get(epId) ?? { traces: [], episodes: [] }; + if (r.candidate.refKind === "trace") group.traces.push(r); + if (r.candidate.refKind === "episode") group.episodes.push(r); + groups.set(epId, group); + } + if (groups.size === 0) { + return { ranked: [...ranked], dedupedByEpisodeCount: 0 }; + } + + const dropped = new Set(); + for (const group of groups.values()) { + if (group.traces.length === 0 || group.episodes.length === 0) continue; + + const bestTrace = [...group.traces].sort(compareTraceEpisodeRanked)[0]!; + const bestEpisode = [...group.episodes].sort(compareTraceEpisodeRanked)[0]!; + const winner = compareTraceEpisodeRanked(bestTrace, bestEpisode) <= 0 + ? bestTrace + : bestEpisode; + + if (winner.candidate.refKind === "episode") { + group.traces.forEach((r) => dropped.add(r)); + group.episodes.filter((r) => r !== winner).forEach((r) => dropped.add(r)); + } else { + group.episodes.forEach((r) => dropped.add(r)); + } + } + + const rankedOut = ranked.filter((r) => !dropped.has(r)); + + return { ranked: rankedOut, dedupedByEpisodeCount: dropped.size }; +} diff --git a/apps/memos-local-plugin/core/retrieval/injector.ts b/apps/memos-local-plugin/core/retrieval/injector.ts index 69fa6066b..610326372 100644 --- a/apps/memos-local-plugin/core/retrieval/injector.ts +++ b/apps/memos-local-plugin/core/retrieval/injector.ts @@ -34,6 +34,16 @@ import type { const MAX_SNIPPET_BODY_CHARS = 640; const DEFAULT_SKILL_SUMMARY_CHARS = 200; +const MEMORY_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { + weekday: "short", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZoneName: "shortOffset", +}); export type SkillInjectionMode = "summary" | "full"; @@ -52,7 +62,7 @@ export interface InjectorInput { episodeId: EpisodeId; /** * How Tier-1 skill candidates should be rendered. Defaults to - * `"summary"` — a short descriptor + `skill_get(id="…")` invocation + * `"summary"` — a short descriptor + `memos_skill_get(id="…")` invocation * hint, so the host model decides whether to pull the full guide. */ skillInjectionMode?: SkillInjectionMode; @@ -188,7 +198,7 @@ function renderSnippet(c: TierCandidate, opts: RenderOpts): InjectionSnippet | n * Render a Tier-1 Skill candidate. * * **Summary mode** (default): the prompt only carries a 1-line teaser - * and a `skill_get(id="…")` hint. The host model can call that tool on + * and a `memos_skill_get(id="…")` hint. The host model can call that tool on * demand to fetch the full procedure — keeps prompts small and avoids * paying for skills the agent never needs. * @@ -208,11 +218,13 @@ function renderSkill(c: SkillCandidate, opts: RenderOpts): InjectionSnippet { }; } - const summary = firstLineSummary(c.invocationGuide, opts.skillSummaryChars); - const lines: string[] = []; - if (summary) lines.push(summary); + const description = firstLineSummary(c.invocationGuide, opts.skillSummaryChars); + const lines: string[] = [ + `Name: ${c.skillName}`, + `Description: ${description || "(not provided)"}`, + ]; lines.push( - `→ call \`skill_get(id="${c.refId}")\` to load the full procedure if you decide to use it`, + `→ call \`memos_skill_get(id="${c.refId}")\` to load the full procedure if you decide to use it`, ); return { refKind: "skill", @@ -255,8 +267,11 @@ function renderTrace(c: TraceCandidate): InjectionSnippet { if (c.userText) parts.push(`[user] ${c.userText}`); if (c.agentText) parts.push(`[assistant] ${c.agentText}`); if (c.reflection) parts.push(`[note] ${c.reflection}`); - const body = truncate(parts.join("\n")); - const when = new Date(c.ts).toISOString().slice(0, 16).replace("T", " "); + const body = withToolFollowUp( + truncate(parts.join("\n")), + `→ call \`memos_get(id="${c.refId}", kind="trace")\` for the full turn`, + ); + const when = formatMemoryTimestamp(c.ts); return { refKind: "trace", refId: c.refId, @@ -269,12 +284,15 @@ function renderEpisode(c: EpisodeCandidate): InjectionSnippet { // Episode summary already comes with step-by-step action sequence // (see tier2-trace.ts::renderEpisodeSummary). Keep prompt-facing text // free of retrieval metrics; they are useful for logs, not for answers. - const body = truncate(stripEpisodePromptMetrics(c.summary)); - const when = new Date(c.ts).toISOString().slice(0, 16).replace("T", " "); + const body = withToolFollowUp( + truncate(stripEpisodePromptMetrics(c.summary)), + `→ call \`memos_timeline(episodeId="${c.refId}")\` for the full step-by-step traces`, + ); + const when = formatMemoryTimestamp(c.ts); return { refKind: "episode", refId: c.refId, - title: `Sub-task · ${when}`, + title: `Past task · ${when}`, body, }; } @@ -282,10 +300,12 @@ function renderEpisode(c: EpisodeCandidate): InjectionSnippet { function stripEpisodePromptMetrics(summary: string): string { return summary .replace( - /^episode\s+\d+\s+steps\s*·\s*best\s+V=[+-]?\d+(?:\.\d+)?\s*·\s*goal-sim=[+-]?\d+(?:\.\d+)?/i, - "Past similar episode", + /^episode\s+\d+\s+steps\s*·\s*best\s+V=[+-]?\d+(?:\.\d+)?\s*·\s*goal-sim=[+-]?\d+(?:\.\d+)?\s*\n?/i, + "", ) - .replace(/\bstep\s+(\d+)\s+\(V=[+-]?\d+(?:\.\d+)?\)/gi, "step $1"); + .replace(/^Past similar episode\s*\n?/i, "") + .replace(/\bstep\s+(\d+)\s+\(V=[+-]?\d+(?:\.\d+)?\)/gi, "step $1") + .trim(); } function renderExperience(c: ExperienceCandidate): InjectionSnippet { @@ -302,12 +322,18 @@ function renderExperience(c: ExperienceCandidate): InjectionSnippet { refKind: "experience", refId: c.refId, title: c.title, - body: truncate(parts.join("\n")), + body: withToolFollowUp( + truncate(parts.join("\n")), + `→ call \`memos_get(id="${c.refId}", kind="policy")\` for the full experience`, + ), }; } function renderWorldModel(c: WorldModelCandidate): InjectionSnippet { - const body = truncate(`World model: ${c.title}\n${c.body}`); + const body = withToolFollowUp( + truncate(`World model: ${c.title}\n${c.body}`), + `→ call \`memos_get(id="${c.refId}", kind="world_model")\` for the full environment knowledge`, + ); return { refKind: "world-model", refId: c.refId, @@ -334,6 +360,14 @@ function renderWorldModel(c: WorldModelCandidate): InjectionSnippet { * * ## Memories * + * ### Similar Past Tasks + * + * 1. [Past task · 2026-03-05 10:12] + * Past similar episode + * step 1 … + * + * ### Relevant Trace Memories + * * 1. [Trace · 2026-03-05 10:12] * [user] 我喜欢的运动是游泳 * [assistant] 记住了。 @@ -344,7 +378,7 @@ function renderWorldModel(c: WorldModelCandidate): InjectionSnippet { * When container pip fails, install -dev OS lib first … * * Available follow-up tools: - * - call `memory_search(query=...)` for a shorter, more targeted query + * - call `memos_search(query=...)` for a shorter, more targeted query * ``` * * We deliberately keep the "IMPORTANT" instructions — without them the @@ -362,21 +396,18 @@ function renderWholePacket( const parts: string[] = [header]; const skills = snippets.filter((s) => s.refKind === "skill"); - const traces = snippets.filter( - (s) => - s.refKind === "trace" || - s.refKind === "episode", - ); + const episodes = snippets.filter((s) => s.refKind === "episode"); + const traces = snippets.filter((s) => s.refKind === "trace"); const experiences = snippets.filter((s) => s.refKind === "experience"); const worlds = snippets.filter((s) => s.refKind === "world-model"); if (skills.length > 0) { if (opts.skillMode === "summary") { // In summary mode, frame the section as "candidate skills you can - // call". The bodies already carry the per-skill `skill_get(...)` + // call". The bodies already carry the per-skill `memos_skill_get(...)` // hint, so the agent knows how to expand them on demand. parts.push( - "## Candidate skills (call `skill_get` to load any you decide to use)\n", + "## Candidate skills (call `memos_skill_get` to load any you decide to use)\n", ); } else { parts.push("## Skills\n"); @@ -386,12 +417,7 @@ function renderWholePacket( }); } - if (traces.length > 0) { - parts.push("## Memories\n"); - traces.forEach((s, i) => { - parts.push(renderNumberedSnippet(s, i + 1)); - }); - } + parts.push(...renderMemoriesSection(episodes, traces)); if (experiences.length > 0) { parts.push("## Experiences\n"); @@ -413,10 +439,32 @@ function renderWholePacket( // "preferred / avoided" lines distilled from past failures + fixes. if (guidanceBlock) parts.push(guidanceBlock); - parts.push(footerFor(opts.skillMode, skills.length > 0)); + parts.push(footerFor(opts.skillMode, snippets)); return parts.join("\n\n"); } +function renderMemoriesSection( + episodes: readonly InjectionSnippet[], + traces: readonly InjectionSnippet[], +): string[] { + if (episodes.length === 0 && traces.length === 0) return []; + + const parts: string[] = ["## Memories"]; + if (episodes.length > 0) { + parts.push("### Similar Past Tasks"); + episodes.forEach((s, i) => { + parts.push(renderNumberedSnippet(s, i + 1)); + }); + } + if (traces.length > 0) { + parts.push("### Relevant Trace Memories"); + traces.forEach((s, i) => { + parts.push(renderNumberedSnippet(s, i + 1)); + }); + } + return parts; +} + /** * Render the V7 §2.4.6 "Decision guidance" section. Returns `null` when * no preference / anti-pattern lines were collected — the caller skips @@ -454,12 +502,44 @@ function renderDecisionGuidance(g: CollectedGuidance | undefined): string | null function renderNumberedSnippet(s: InjectionSnippet, n: number): string { const title = s.title ?? s.refId; - const block = [`${n}. ${title}`, s.body] + const body = stripRedundantTitleFromBody(title, s.body, s.refKind); + const block = [`${n}. ${title}`, body] .filter(Boolean) .join("\n"); return indentBlock(block); } +function normalizeSnippetLabel(value: string): string { + return value.trim().toLowerCase(); +} + +/** + * Drop body lines that repeat the numbered-list title (e.g. `Name: X` when + * the heading is already `X`, or `Trigger:` when it matches the title). + */ +function stripRedundantTitleFromBody( + title: string, + body: string, + refKind: InjectionSnippet["refKind"], +): string { + const normalizedTitle = normalizeSnippetLabel(title); + const lines = body.split("\n"); + const kept = lines.filter((line) => { + const nameMatch = line.match(/^Name:\s*(.+)\s*$/i); + if (nameMatch && normalizeSnippetLabel(nameMatch[1]!) === normalizedTitle) { + return false; + } + if (refKind === "experience") { + const triggerMatch = line.match(/^Trigger:\s*(.+)\s*$/i); + if (triggerMatch && normalizeSnippetLabel(triggerMatch[1]!) === normalizedTitle) { + return false; + } + } + return true; + }); + return kept.join("\n").trim(); +} + const HEADER_BY_REASON: Record = { turn_start: "# User's conversation history (from memory system)\n\n" + @@ -481,26 +561,60 @@ const HEADER_BY_REASON: Record = { "distilled from similar past situations. Please adapt your plan accordingly.", }; -const FOOTER_LINES_COMMON: readonly string[] = [ - "- `memory_search(query, maxResults?)` — re-query with a shorter / rephrased string", +const FOOTER_LINES_SEARCH: readonly string[] = [ + "- `memos_search(query, maxResults?)` — re-query with a shorter / rephrased string", ]; const FOOTER_LINES_SKILL_SUMMARY: readonly string[] = [ - "- `skill_get(id)` — load the full procedure/verification of a candidate skill listed above", + "- `memos_skill_get(id)` — load the full procedure/verification of a candidate skill listed above", +]; + +const FOOTER_LINES_TIMELINE: readonly string[] = [ + "- `memos_timeline(episodeId, limit?)` — expand a similar past task into step-by-step traces", +]; + +const FOOTER_LINES_TRACE_GET: readonly string[] = [ + "- `memos_get(id, kind=\"trace\")` — fetch a full trace turn by id", +]; + +const FOOTER_LINES_POLICY_GET: readonly string[] = [ + "- `memos_get(id, kind=\"policy\")` — fetch a full experience by id", +]; + +const FOOTER_LINES_WORLD_MODEL: readonly string[] = [ + "- `memos_get(id, kind=\"world_model\")` — fetch full environment knowledge by id", ]; function footerFor( skillMode: SkillInjectionMode, - hasSkills: boolean, + snippets: readonly InjectionSnippet[], ): string { + const kinds = new Set(snippets.map((s) => s.refKind)); const lines: string[] = ["Available follow-up tools:"]; - if (skillMode === "summary" && hasSkills) { + if (skillMode === "summary" && kinds.has("skill")) { lines.push(...FOOTER_LINES_SKILL_SUMMARY); } - lines.push(...FOOTER_LINES_COMMON); + if (kinds.has("episode")) { + lines.push(...FOOTER_LINES_TIMELINE); + } + if (kinds.has("trace")) { + lines.push(...FOOTER_LINES_TRACE_GET); + } + if (kinds.has("experience")) { + lines.push(...FOOTER_LINES_POLICY_GET); + } + if (kinds.has("world-model")) { + lines.push(...FOOTER_LINES_WORLD_MODEL); + } + lines.push(...FOOTER_LINES_SEARCH); return lines.join("\n"); } +function withToolFollowUp(body: string, hint: string): string { + if (!hint) return body; + return body ? `${body}\n${hint}` : hint; +} + function indentBlock(s: string): string { return s .split("\n") @@ -509,6 +623,13 @@ function indentBlock(s: string): string { .replace(/^ {3}/, ""); // first line flush with the bullet number } +function formatMemoryTimestamp(ts: number): string { + const parts = MEMORY_TIME_FORMATTER.formatToParts(new Date(ts)); + const get = (type: string): string => + parts.find((part) => part.type === type)?.value ?? ""; + return `${get("weekday")} ${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")} ${get("timeZoneName")}`; +} + function truncate(s: string): string { if (s.length <= MAX_SNIPPET_BODY_CHARS) return s; const head = s.slice(0, MAX_SNIPPET_BODY_CHARS - 16); diff --git a/apps/memos-local-plugin/core/retrieval/llm-filter.ts b/apps/memos-local-plugin/core/retrieval/llm-filter.ts index af54804f5..15868fb68 100644 --- a/apps/memos-local-plugin/core/retrieval/llm-filter.ts +++ b/apps/memos-local-plugin/core/retrieval/llm-filter.ts @@ -45,6 +45,7 @@ export interface FilterInput { export interface FilterDeps { llm: LlmClient | null; log: Logger; + timeoutMs?: number; config: Pick< RetrievalConfig, | "llmFilterEnabled" @@ -66,6 +67,7 @@ export interface FilterResult { | "no_llm" | "below_threshold" | "empty_query" + | "deferred_to_final" | "llm_kept_all" | "llm_filtered" // The LLM was supposed to run but the call failed / parsed badly. @@ -76,7 +78,7 @@ export interface FilterResult { /** * The LLM's self-report on whether the *kept* candidates are enough * to answer `query`, or whether the caller should widen recall / - * run a follow-up `memory_search`. `null` when the filter didn't + * run a follow-up `memos_search`. `null` when the filter didn't * run (disabled / passthrough / failure paths). */ sufficient: boolean | null; @@ -137,6 +139,7 @@ ${list}`, phase: "retrieve", episodeId: input.episodeId, temperature: 0, + timeoutMs: deps.timeoutMs, // Output is only ordered indices + one bool, but the list can // legitimately be as long as the ranked candidates. maxTokens: filterOutputTokenBudget(ranked.length), diff --git a/apps/memos-local-plugin/core/retrieval/query-builder.ts b/apps/memos-local-plugin/core/retrieval/query-builder.ts index 982f66135..b63819ad5 100644 --- a/apps/memos-local-plugin/core/retrieval/query-builder.ts +++ b/apps/memos-local-plugin/core/retrieval/query-builder.ts @@ -77,6 +77,12 @@ export function buildQuery(ctx: RetrievalCtx): CompiledQuery { return finalize(parts.join("\n")); } case "tool_driven": { + if (typeof ctx.args?.query === "string" && ctx.args.query.trim()) { + const rest = { ...ctx.args }; + delete rest.query; + const restText = Object.keys(rest).length > 0 ? renderArgs(rest) : ""; + return finalize([ctx.args.query.trim(), restText].filter(Boolean).join("\n")); + } const args = renderArgs(ctx.args); return finalize(`tool:${ctx.tool}\n${args}`); } diff --git a/apps/memos-local-plugin/core/retrieval/retrieve.ts b/apps/memos-local-plugin/core/retrieval/retrieve.ts index 93b62a994..fb13b191f 100644 --- a/apps/memos-local-plugin/core/retrieval/retrieve.ts +++ b/apps/memos-local-plugin/core/retrieval/retrieve.ts @@ -31,6 +31,7 @@ import { rootLogger } from "../logger/index.js"; import { collectDecisionGuidance } from "./decision-guidance.js"; import { buildQuery, type CompiledQuery } from "./query-builder.js"; import type { RetrievalEventBus } from "./events.js"; +import { dedupeTraceEpisodeByEpisodeId } from "./dedupe-trace-episode.js"; import { toPacket, renderSnippetForDebug } from "./injector.js"; import { llmFilterCandidates } from "./llm-filter.js"; import { rank, type RankedCandidate } from "./ranker.js"; @@ -65,6 +66,22 @@ export interface RetrieveOptions { events?: RetrievalEventBus; /** Override `limit` default (tier totals honored when unspecified). */ limit?: number; + /** Turn-start scheduler override. V1 uses this for intent tier gating. */ + plan?: RetrievePlanOverride; + /** + * Return mechanically ranked candidates without the local LLM pass. + * Used when the caller will merge another retrieval route, then run + * one unified final LLM filter across all routes. + */ + skipLlmFilter?: boolean; +} + +export interface RetrievePlanOverride { + scenarioId?: string; + wantTier1?: boolean; + wantTier2?: boolean; + wantTier3?: boolean; + limit?: number; } // ─── Entry point: turn_start ──────────────────────────────────────────────── @@ -74,7 +91,17 @@ export async function turnStartRetrieve( ctx: TurnStartRetrieveCtx, opts: RetrieveOptions = {}, ): Promise { - return runAll(deps, ctx, opts, { + if (deps.config.lightweightMemory) { + return runAll(deps, ctx, opts, applyPlanOverride({ + wantTier1: false, + wantTier2: true, + wantTier3: false, + includeLowValue: false, + limit: opts.limit ?? Math.max(1, deps.config.tier2TopK), + traceOnly: true, + }, opts.plan)); + } + return runAll(deps, ctx, opts, applyPlanOverride({ wantTier1: true, wantTier2: true, wantTier3: true, @@ -82,7 +109,7 @@ export async function turnStartRetrieve( limit: opts.limit ?? deps.config.tier1TopK + deps.config.tier2TopK + deps.config.tier3TopK, - }); + }, opts.plan)); } // ─── Entry point: tool_driven ─────────────────────────────────────────────── @@ -98,9 +125,10 @@ export async function toolDrivenRetrieve( return runAll(deps, ctx, opts, { wantTier1: false, wantTier2: true, - wantTier3: true, - includeLowValue: false, + wantTier3: deps.config.lightweightMemory ? false : true, + includeLowValue: deps.config.includeLowValue, limit: opts.limit ?? Math.max(1, deps.config.tier2TopK), + traceOnly: deps.config.lightweightMemory, }); } @@ -111,6 +139,16 @@ export async function skillInvokeRetrieve( ctx: SkillInvokeRetrieveCtx, opts: RetrieveOptions = {}, ): Promise { + if (deps.config.lightweightMemory) { + return runAll(deps, ctx, opts, { + wantTier1: false, + wantTier2: true, + wantTier3: false, + includeLowValue: false, + limit: opts.limit ?? Math.max(1, deps.config.tier2TopK), + traceOnly: true, + }); + } // Just-in-time: the agent is about to execute a named Skill. We want // (a) the actual Skill's invocation guide if still fresh, and (b) a // handful of trace hits to double-check it's the right call. @@ -133,9 +171,10 @@ export async function subAgentRetrieve( return runAll(deps, ctx, opts, { wantTier1: false, wantTier2: true, - wantTier3: true, + wantTier3: deps.config.lightweightMemory ? false : true, includeLowValue: false, limit: opts.limit ?? deps.config.tier2TopK + deps.config.tier3TopK, + traceOnly: deps.config.lightweightMemory, }); } @@ -149,6 +188,7 @@ export async function repairRetrieve( // Only kicks in after we've hit `failureCount ≥ threshold`. The packet // may be `null` when we have no relevant history — callers should treat // that as "don't inject anything". + if (deps.config.lightweightMemory) return null; if (ctx.failureCount <= 0) return null; const result = await runAll(deps, ctx, opts, { wantTier1: true, @@ -164,11 +204,25 @@ export async function repairRetrieve( // ─── Shared pipeline ──────────────────────────────────────────────────────── interface RunPlan { + scenarioId?: string; wantTier1: boolean; wantTier2: boolean; wantTier3: boolean; includeLowValue: boolean; limit: number; + traceOnly?: boolean; +} + +function applyPlanOverride(plan: RunPlan, override?: RetrievePlanOverride): RunPlan { + if (!override) return plan; + return { + ...plan, + scenarioId: override.scenarioId ?? plan.scenarioId, + wantTier1: override.wantTier1 ?? plan.wantTier1, + wantTier2: override.wantTier2 ?? plan.wantTier2, + wantTier3: override.wantTier3 ?? plan.wantTier3, + limit: override.limit ?? plan.limit, + }; } async function runAll( @@ -229,6 +283,7 @@ async function runAll( const wantTier1 = plan.wantTier1 && deps.config.tier1TopK > 0; const wantTier2 = plan.wantTier2 && deps.config.tier2TopK > 0; const wantTier3 = plan.wantTier3 && deps.config.tier3TopK > 0; + const traceOnly = plan.traceOnly === true || deps.config.lightweightMemory === true; const tier1Start = Date.now(); const tier1Promise: Promise = @@ -257,12 +312,14 @@ async function runAll( ftsMatch: compiled.ftsMatch, patternTerms: compiled.patternTerms, includeLowValue: plan.includeLowValue, + excludeSessionId: + ctx.reason === "turn_start" && sessionId ? sessionId : undefined, }, ) : Promise.resolve({ traces: [], episodes: [] }); const tier2ExperiencePromise: Promise = - wantTier2 && !noUsableChannel + wantTier2 && !traceOnly && !noUsableChannel ? runTier2Experience( { repos: deps.repos, config: deps.config }, { @@ -307,7 +364,7 @@ async function runAll( const ranked = rank({ tier1, tier2Traces: tier2.traces, - tier2Episodes: tier2.episodes, + tier2Episodes: traceOnly ? [] : tier2.episodes, tier2Experiences, tier3, limit: plan.limit, @@ -326,20 +383,39 @@ async function runAll( // Mechanical retrieval produces high-recall but low-precision // candidates. A small LLM round-trip (see `llm-filter.ts`) prunes // items that share surface keywords with the query but aren't - // actually relevant. Fails open — on any error we keep the - // mechanical ranking. + // actually relevant. Full mode fails open to preserve recall; + // lightweight mode fails closed because it promises summarizer-LLM + // screened raw memories only. const queryText = (ctx as { userText?: string }).userText ?? compiled.text ?? ""; - const filtered = await llmFilterCandidates( - { query: queryText, ranked: mechanicalRanked, episodeId }, - { - llm: deps.llm ?? null, - log, - config: deps.config, - }, - ); + const filterResult = opts.skipLlmFilter + ? { + kept: mechanicalRanked, + dropped: [], + outcome: "deferred_to_final" as const, + sufficient: null, + } + : await llmFilterCandidates( + { query: queryText, ranked: mechanicalRanked, episodeId }, + { + llm: deps.llm ?? null, + log, + config: deps.config, + }, + ); + const filtered = + !opts.skipLlmFilter && + deps.config.lightweightMemory && + !llmFilterSucceeded(filterResult.outcome) + ? { + ...filterResult, + kept: [], + dropped: [...filterResult.dropped, ...filterResult.kept], + } + : filterResult; log.debug("llm_filter.done", { outcome: filtered.outcome, + enforced: deps.config.lightweightMemory && filtered !== filterResult, sufficient: filtered.sufficient, raw: rawCandidateCount, afterThreshold: mechanicalRanked.length, @@ -355,13 +431,19 @@ async function runAll( // share evidence with what we just retrieved. Cheap (one bounded // scan of active policies) and produces nothing when there's // nothing to say, so it's safe to call unconditionally here. - const decisionGuidance = collectDecisionGuidance({ - ranked: filtered.kept, - repos: deps.repos, - }); + const { ranked: dedupedKept, dedupedByEpisodeCount } = + dedupeTraceEpisodeByEpisodeId(filtered.kept); + + const decisionGuidance = traceOnly + ? undefined + : collectDecisionGuidance({ + ranked: dedupedKept, + repos: deps.repos, + }); if ( - decisionGuidance.preference.length > 0 || - decisionGuidance.antiPattern.length > 0 + decisionGuidance && + (decisionGuidance.preference.length > 0 || + decisionGuidance.antiPattern.length > 0) ) { log.debug("decision_guidance.collected", { preference: decisionGuidance.preference.length, @@ -371,7 +453,7 @@ async function runAll( } const { packet } = toPacket({ - ranked: filtered.kept, + ranked: dedupedKept, reason: ctx.reason, tierLatencyMs: { tier1: tier1LatencyMs, @@ -386,7 +468,7 @@ async function runAll( sessionId: sessionId ?? (`adhoc-session-${ids.span()}` as SessionId), episodeId: episodeId ?? (`adhoc-episode-${ids.span()}` as EpisodeId), // V7 §2.6 — Tier-1 default = "summary" so we surface skill - // descriptors + a `skill_get(...)` invocation hint instead of + // descriptors + a `memos_skill_get(...)` invocation hint instead of // inlining every full guide. Hosts without tool support can flip // this to "full" via `algorithm.retrieval.skillInjectionMode`. skillInjectionMode: deps.config.skillInjectionMode, @@ -402,11 +484,17 @@ async function runAll( const stats: RetrievalStats = { reason: ctx.reason, + scenarioId: plan.scenarioId, agent, sessionId, episodeId, + plannedTiers: { + tier1: plan.wantTier1, + tier2: plan.wantTier2, + tier3: plan.wantTier3, + }, tier1Count: tier1.length, - tier2Count: tier2.traces.length + tier2.episodes.length + tier2Experiences.length, + tier2Count: tier2.traces.length + (traceOnly ? 0 : tier2.episodes.length) + tier2Experiences.length, tier3Count: tier3.length, tier1LatencyMs, tier2LatencyMs, @@ -426,6 +514,8 @@ async function runAll( llmFilterSufficient: filtered.sufficient ?? undefined, llmFilterKept: filtered.kept.length, llmFilterDropped: filtered.dropped.length, + dedupedByEpisodeCount: + dedupedByEpisodeCount > 0 ? dedupedByEpisodeCount : undefined, channelHits: ranked.channelHits, }; @@ -434,7 +524,7 @@ async function runAll( sessionId, tier1: tier1.length, tier2: tier2.traces.length, - tier2Ep: tier2.episodes.length, + tier2Ep: traceOnly ? 0 : tier2.episodes.length, tier2Experience: tier2Experiences.length, tier3: tier3.length, kept: packet.snippets.length, @@ -547,6 +637,10 @@ function round(n: number, d: number): number { return Math.round(n * f) / f; } +function llmFilterSucceeded(outcome: string): boolean { + return outcome === "llm_kept_all" || outcome === "llm_filtered"; +} + /** Thin façade so pipelines can `new Retriever(deps)` if they prefer OO. */ export class Retriever { constructor(private readonly deps: RetrievalDeps) {} diff --git a/apps/memos-local-plugin/core/retrieval/tier2-trace.ts b/apps/memos-local-plugin/core/retrieval/tier2-trace.ts index acfdef22d..1f902f54c 100644 --- a/apps/memos-local-plugin/core/retrieval/tier2-trace.ts +++ b/apps/memos-local-plugin/core/retrieval/tier2-trace.ts @@ -26,7 +26,7 @@ import { rootLogger } from "../logger/index.js"; import { priorityFor } from "../reward/backprop.js"; -import type { EmbeddingVector, EpisodeId, TraceId } from "../types.js"; +import type { EmbeddingVector, EpisodeId, SessionId, TraceId } from "../types.js"; import type { ChannelRank, EpisodeCandidate, @@ -66,6 +66,11 @@ export interface Tier2Input { patternTerms?: readonly string[]; /** Whether `decision_repair` forced `includeLowValue`. */ includeLowValue?: boolean; + /** + * When set, trace search excludes rows from this session (cross-session + * turn-start retrieval should not repeat the current chat window). + */ + excludeSessionId?: SessionId; } export interface Tier2Result { @@ -77,8 +82,7 @@ export async function runTier2(deps: Tier2Deps, input: Tier2Input): Promise 0"; + const searchFilters = buildTraceSearchFilters(deps, input); const vecPoolSize = Math.max( config.tier2TopK, Math.ceil(config.tier2TopK * config.candidatePoolFactor), @@ -96,18 +100,22 @@ export async function runTier2(deps: Tier2Deps, input: Tier2Input): Promise { const sigs = row.errorSignatures ?? []; @@ -236,7 +247,9 @@ export async function runTier2(deps: Tier2Deps, input: Tier2Input): Promise } { + const parts: string[] = []; + const params: Record = {}; + const includeLow = input.includeLowValue ?? deps.config.includeLowValue; + if (!includeLow) parts.push("priority > 0"); + if (input.excludeSessionId) { + parts.push("session_id != @exclude_session_id"); + params.exclude_session_id = input.excludeSessionId; + } + if (parts.length === 0) return {}; + return { where: parts.join(" AND "), params }; +} + function resolveTagFilter( tags: readonly string[], config: RetrievalConfig, diff --git a/apps/memos-local-plugin/core/retrieval/types.ts b/apps/memos-local-plugin/core/retrieval/types.ts index 6c8e3f8e3..ae1b6f944 100644 --- a/apps/memos-local-plugin/core/retrieval/types.ts +++ b/apps/memos-local-plugin/core/retrieval/types.ts @@ -270,7 +270,7 @@ export interface RetrievalConfig { /** * V7 §2.6 Tier-1 rendering mode. * - "summary" (default): inject `name + η + first-line summary + - * a `skill_get(id="…")` invocation hint`. Lets the host model + * a `memos_skill_get(id="…")` invocation hint`. Lets the host model * pull the full procedure on demand instead of bloating every * prompt with skills it may never use. * - "full": inline the full `invocationGuide` body (legacy). @@ -314,6 +314,8 @@ export interface RetrievalConfig { * window pays for itself). */ llmFilterCandidateBodyChars?: number; + /** Low-cost mode: retrieve raw trace memories only. */ + lightweightMemory?: boolean; } /** @@ -688,9 +690,13 @@ export interface RetrievalResult { export interface RetrievalStats { reason: RetrievalReason; + /** Injection scheduler scenario, when turn-start routing was planned. */ + scenarioId?: string; agent: AgentKind; sessionId: SessionId; episodeId?: EpisodeId; + /** Tier gates requested by the scheduler/retrieval entry before config caps. */ + plannedTiers?: { tier1: boolean; tier2: boolean; tier3: boolean }; tier1Count: number; tier2Count: number; tier3Count: number; @@ -727,6 +733,8 @@ export interface RetrievalStats { | "no_llm" | "below_threshold" | "empty_query" + | "skipped_by_scheduler" + | "deferred_to_final" | "llm_kept_all" | "llm_filtered" | "llm_failed_safe_cutoff"; @@ -748,6 +756,8 @@ export interface RetrievalStats { | "structural", number >>; + /** Trace + episode rows dropped after rank (same `episodeId`). */ + dedupedByEpisodeCount?: number; } /** Discriminated context union — one per entry point in `retrieve.ts`. */ diff --git a/apps/memos-local-plugin/core/reward/subscriber.ts b/apps/memos-local-plugin/core/reward/subscriber.ts index ca8fd6b16..3c72a193c 100644 --- a/apps/memos-local-plugin/core/reward/subscriber.ts +++ b/apps/memos-local-plugin/core/reward/subscriber.ts @@ -163,8 +163,12 @@ export function attachRewardSubscriber( // Skill induction of any positive evidence). const flushed: PendingEpisode[] = []; for (const entry of pending.values()) { - if (entry.timer) clearTimeout(entry.timer); - flushed.push(entry); + if (entry.timer) { + clearTimeout(entry.timer); + flushed.push(entry); + } else if (entry.feedback.length > 0) { + flushed.push(entry); + } } pending.clear(); for (const entry of flushed) { diff --git a/apps/memos-local-plugin/core/runtime/namespace.ts b/apps/memos-local-plugin/core/runtime/namespace.ts index e58e95bc1..c474b8f9e 100644 --- a/apps/memos-local-plugin/core/runtime/namespace.ts +++ b/apps/memos-local-plugin/core/runtime/namespace.ts @@ -93,7 +93,8 @@ export function ownerParams(ns: RuntimeNamespace, prefix = "owner"): Record `${alias ? `${alias}.` : ""}${name}`; const normalized = normalizeNamespace(ns, ns?.agentKind ?? "unknown"); + const ownerKind = col("owner_agent_kind"); + const ownerProfile = col("owner_profile_id"); + const shareScope = `COALESCE(${col("share_scope")}, 'private')`; return { sql: - `(${col("owner_agent_kind")} = @vis_owner_agent_kind` + - ` OR COALESCE(${col("share_scope")}, 'private') IN ('local', 'public', 'hub'))`, + `((` + + `${ownerKind} = @vis_owner_agent_kind AND ` + + `COALESCE(${ownerProfile}, @vis_default_profile_id) = @vis_owner_profile_id` + + `) OR ${ownerKind} IS NULL` + + ` OR ${ownerKind} = 'unknown'` + + ` OR (${shareScope} IN ('local', 'public') AND ${ownerKind} = @vis_owner_agent_kind)` + + ` OR ${shareScope} = 'hub')`, params: { vis_owner_agent_kind: normalized.agentKind, + vis_owner_profile_id: normalized.profileId, + vis_default_profile_id: DEFAULT_PROFILE_ID, }, }; } @@ -139,12 +150,18 @@ export function isVisibleTo( ns: RuntimeNamespace, ): boolean { const scope = normalizeShareScope(row.share?.scope); - if (scope === "local" || scope === "public" || scope === "hub") return true; if (!row.ownerAgentKind || row.ownerAgentKind === "unknown") { return true; } const normalized = normalizeNamespace(ns, ns.agentKind); - return row.ownerAgentKind === normalized.agentKind; + const sameAgentFramework = row.ownerAgentKind === normalized.agentKind; + const sameAgent = + sameAgentFramework && + (row.ownerProfileId ?? DEFAULT_PROFILE_ID) === normalized.profileId; + if (sameAgent) return true; + if (scope === "public") return sameAgentFramework; + if (scope === "hub") return true; + return false; } export function namespaceMeta(ns: RuntimeNamespace): Record { diff --git a/apps/memos-local-plugin/core/session/manager.ts b/apps/memos-local-plugin/core/session/manager.ts index 8dfd9a3f2..44da570b2 100644 --- a/apps/memos-local-plugin/core/session/manager.ts +++ b/apps/memos-local-plugin/core/session/manager.ts @@ -47,6 +47,8 @@ export interface SessionManagerDeps { bus?: SessionEventBus; /** Injected episode manager (for tests). */ episodeManager?: EpisodeManager; + /** Lightweight memory mode closes technical episodes without reflect/reward semantics. */ + lightweightMemory?: boolean; } export interface StartEpisodeInput { @@ -170,14 +172,27 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { // confusion. True crash-orphans get a separate recovery path // at plugin bootstrap (see `recoverOrphanedEpisodes` in // `core/pipeline/memory-core.ts`). - if (isCompletedExchange(ep)) { + if (deps.lightweightMemory && ep.meta.lightweightMemory === true) { epm.finalize(ep.id, { - patchMeta: { sessionCloseReason: reason }, + patchMeta: { + lightweightMemory: true, + sessionCloseReason: reason, + }, }); continue; } - if (isDiscardableEmptyEpisode(ep)) { - epm.discardEmpty(ep.id, `session_closed:${reason}`); + if (reason.startsWith("shutdown:")) { + epm.patchMeta(ep.id, { + topicState: "paused", + pauseReason: `session_closed:${reason}`, + sessionCloseReason: reason, + }); + continue; + } + if (isCompletedExchange(ep)) { + epm.finalize(ep.id, { + patchMeta: { sessionCloseReason: reason }, + }); continue; } epm.patchMeta(ep.id, { @@ -346,6 +361,15 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { for (const ep of epm.listOpen()) { if (!live.has(ep.sessionId)) { if (isCompletedExchange(ep)) { + if (deps.lightweightMemory && ep.meta.lightweightMemory === true) { + finalizeEpisode(ep.id, { + patchMeta: { + lightweightMemory: true, + sessionCloseReason: `shutdown:${reason}`, + }, + }); + continue; + } finalizeEpisode(ep.id, { patchMeta: { sessionCloseReason: `shutdown:${reason}` }, }); diff --git a/apps/memos-local-plugin/core/storage/migrations/010-trace-policy-links.sql b/apps/memos-local-plugin/core/storage/migrations/010-trace-policy-links.sql new file mode 100644 index 000000000..cb65962e9 --- /dev/null +++ b/apps/memos-local-plugin/core/storage/migrations/010-trace-policy-links.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS trace_policy_links ( + trace_id TEXT NOT NULL REFERENCES traces(id) ON DELETE CASCADE, + policy_id TEXT NOT NULL REFERENCES policies(id) ON DELETE CASCADE, + episode_id TEXT NOT NULL REFERENCES episodes(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + PRIMARY KEY (trace_id, policy_id) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_tpl_policy ON trace_policy_links(policy_id); +CREATE INDEX IF NOT EXISTS idx_tpl_episode ON trace_policy_links(episode_id); diff --git a/apps/memos-local-plugin/core/storage/migrations/011-hub-sharing.sql b/apps/memos-local-plugin/core/storage/migrations/011-hub-sharing.sql new file mode 100644 index 000000000..3946fba37 --- /dev/null +++ b/apps/memos-local-plugin/core/storage/migrations/011-hub-sharing.sql @@ -0,0 +1,86 @@ +-- Optional team sharing runtime. +-- +-- These tables are intentionally separate from the local L1/L2/L3/Skill +-- tables. Hub content is a network-facing projection, not a merge of local +-- private databases. + +CREATE TABLE IF NOT EXISTS hub_users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + device_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL CHECK (role IN ('admin','member')) DEFAULT 'member', + status TEXT NOT NULL CHECK (status IN ('pending','active','rejected','blocked','left','removed')) DEFAULT 'pending', + token_hash TEXT NOT NULL DEFAULT '', + identity_key TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + approved_at INTEGER, + rejected_at INTEGER, + left_at INTEGER, + removed_at INTEGER, + last_ip TEXT NOT NULL DEFAULT '', + last_active_at INTEGER, + rejoin_requested_at INTEGER +) STRICT; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_hub_users_identity + ON hub_users(identity_key) + WHERE identity_key <> ''; +CREATE INDEX IF NOT EXISTS idx_hub_users_status ON hub_users(status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_hub_users_role ON hub_users(role, status); + +CREATE TABLE IF NOT EXISTS client_hub_connection ( + id INTEGER PRIMARY KEY CHECK (id = 1), + hub_url TEXT NOT NULL, + user_id TEXT NOT NULL DEFAULT '', + username TEXT NOT NULL DEFAULT '', + user_token TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'member', + connected_at INTEGER NOT NULL, + identity_key TEXT NOT NULL DEFAULT '', + last_known_status TEXT NOT NULL DEFAULT '', + hub_instance_id TEXT NOT NULL DEFAULT '' +) STRICT; + +CREATE TABLE IF NOT EXISTS hub_shared_memories ( + id TEXT PRIMARY KEY, + source_trace_id TEXT NOT NULL, + source_user_id TEXT NOT NULL REFERENCES hub_users(id) ON DELETE CASCADE, + source_agent TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT 'trace', + summary TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + embedding BLOB, + embedding_norm2 REAL, + visible INTEGER NOT NULL DEFAULT 1 CHECK (visible IN (0,1)), + deleted_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(source_user_id, source_trace_id) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_hub_shared_memories_user + ON hub_shared_memories(source_user_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_hub_shared_memories_updated + ON hub_shared_memories(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_hub_shared_memories_deleted + ON hub_shared_memories(visible, deleted_at) + WHERE visible = 0 AND deleted_at IS NOT NULL; + +CREATE TABLE IF NOT EXISTS hub_shared_skills ( + id TEXT PRIMARY KEY, + source_skill_id TEXT NOT NULL, + source_user_id TEXT NOT NULL REFERENCES hub_users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + invocation_guide TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL DEFAULT 1, + quality_score REAL, + bundle_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(bundle_json)), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(source_user_id, source_skill_id) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_hub_shared_skills_user + ON hub_shared_skills(source_user_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_hub_shared_skills_updated + ON hub_shared_skills(updated_at DESC); diff --git a/apps/memos-local-plugin/core/storage/migrations/012-trace-turn-pagination-index.sql b/apps/memos-local-plugin/core/storage/migrations/012-trace-turn-pagination-index.sql new file mode 100644 index 000000000..0a76721ab --- /dev/null +++ b/apps/memos-local-plugin/core/storage/migrations/012-trace-turn-pagination-index.sql @@ -0,0 +1,12 @@ +-- Speed up Memories pagination after large imports. +-- +-- The Memories page groups L1 traces by (episode_id, turn_id) and orders +-- those groups by the newest trace timestamp. Large imports can push the +-- trace table into the tens of thousands of rows, so keep a covering index +-- for the grouped list path in addition to the episode-local ordering index. + +CREATE INDEX IF NOT EXISTS idx_traces_turn_page + ON traces(owner_agent_kind, owner_profile_id, episode_id, turn_id, ts DESC); + +CREATE INDEX IF NOT EXISTS idx_traces_turn_recent + ON traces(episode_id, turn_id, ts DESC); diff --git a/apps/memos-local-plugin/core/storage/migrator.ts b/apps/memos-local-plugin/core/storage/migrator.ts index 23ded31ba..da4c3144d 100644 --- a/apps/memos-local-plugin/core/storage/migrator.ts +++ b/apps/memos-local-plugin/core/storage/migrator.ts @@ -133,6 +133,7 @@ export function runMigrations(db: StorageDb, dir: string = defaultMigrationsDir( if (needsUnsafe) db.raw.unsafeMode(false); } + ensureHubSharingSearchColumns(db); markReady(db); log.info("migrations.summary", { @@ -177,6 +178,12 @@ function applyMigration(db: StorageDb, file: MigrationFile): void { ensureFeedbackExperienceMetadataColumns(db); return; } + if (file.version === 9 && file.name === "policies-fts") { + if (tableExists(db, "policies")) { + db.exec(fs.readFileSync(file.fullPath, "utf8")); + } + return; + } db.exec(fs.readFileSync(file.fullPath, "utf8")); } @@ -308,6 +315,24 @@ function ensureFeedbackExperienceMetadataColumns(db: StorageDb): void { db.exec(`CREATE INDEX IF NOT EXISTS idx_policies_skill_eligible ON policies(skill_eligible, status, updated_at DESC)`); } +function ensureHubSharingSearchColumns(db: StorageDb): void { + if (!tableExists(db, "hub_shared_memories")) return; + ensureColumn(db, "hub_shared_memories", "embedding", "BLOB"); + ensureColumn(db, "hub_shared_memories", "embedding_norm2", "REAL"); + ensureColumn( + db, + "hub_shared_memories", + "visible", + "INTEGER NOT NULL DEFAULT 1 CHECK (visible IN (0,1))", + ); + ensureColumn(db, "hub_shared_memories", "deleted_at", "INTEGER"); + db.exec( + `CREATE INDEX IF NOT EXISTS idx_hub_shared_memories_deleted + ON hub_shared_memories(visible, deleted_at) + WHERE visible = 0 AND deleted_at IS NOT NULL`, + ); +} + function execIfTable(db: StorageDb, table: string, sql: string): void { if (tableExists(db, table)) db.exec(sql); } diff --git a/apps/memos-local-plugin/core/storage/repos/_helpers.ts b/apps/memos-local-plugin/core/storage/repos/_helpers.ts index 33afb3cb6..8fde00488 100644 --- a/apps/memos-local-plugin/core/storage/repos/_helpers.ts +++ b/apps/memos-local-plugin/core/storage/repos/_helpers.ts @@ -7,7 +7,7 @@ import { rootLogger } from "../../logger/index.js"; import { decodeVector, encodeVector } from "../vector.js"; import type { EmbeddingVector } from "../../types.js"; -import type { RuntimeNamespace } from "../../../agent-contract/dto.js"; +import type { RuntimeNamespace, ShareScope } from "../../../agent-contract/dto.js"; import { normalizeShareScope, ownerFromNamespace, @@ -134,7 +134,7 @@ export function visibilityWhere(ns: RuntimeNamespace, alias = ""): { return runtimeVisibilityWhere(ns, alias); } -export function normalizeShareForStorage(scope: unknown): string { +export function normalizeShareForStorage(scope: unknown): ShareScope { return normalizeShareScope(scope); } diff --git a/apps/memos-local-plugin/core/storage/repos/api_logs.ts b/apps/memos-local-plugin/core/storage/repos/api_logs.ts index f0cbb7f13..4d56a4842 100644 --- a/apps/memos-local-plugin/core/storage/repos/api_logs.ts +++ b/apps/memos-local-plugin/core/storage/repos/api_logs.ts @@ -1,6 +1,6 @@ /** * `api_logs` repository — structured log of the user-facing memory - * operations (`memory_search`, `memory_add`). Mirrors the legacy + * operations (`memos_search`, `memory_add`). Mirrors the legacy * `memos-local-openclaw` plugin's table so the new viewer can render * the same rich JSON payloads (candidates, filtered, hub results, * ingestion stats, …). diff --git a/apps/memos-local-plugin/core/storage/repos/episodes.ts b/apps/memos-local-plugin/core/storage/repos/episodes.ts index 0ded6c0af..3a3f4822b 100644 --- a/apps/memos-local-plugin/core/storage/repos/episodes.ts +++ b/apps/memos-local-plugin/core/storage/repos/episodes.ts @@ -210,7 +210,7 @@ function mapRow(r: RawEpisodeRow): EpisodeRow & EpisodeMetaRow { id: r.id, sessionId: r.session_id, ...ownerFieldsFromRaw(r), - share: { scope: normalizeShareForStorage(r.share_scope) as "private" | "local" | "public" | "hub" }, + share: { scope: normalizeShareForStorage(r.share_scope) }, startedAt: r.started_at, endedAt: r.ended_at, traceIds: fromJsonText(r.trace_ids_json, []), diff --git a/apps/memos-local-plugin/core/storage/repos/hub.ts b/apps/memos-local-plugin/core/storage/repos/hub.ts new file mode 100644 index 000000000..5038d8c71 --- /dev/null +++ b/apps/memos-local-plugin/core/storage/repos/hub.ts @@ -0,0 +1,630 @@ +import type { StorageDb } from "../types.js"; +import type { makeKvRepo } from "./kv.js"; +import type { EmbeddingVector } from "../../types.js"; +import { decodeVector, encodeVector, norm2 } from "../vector.js"; + +export type HubRole = "admin" | "member"; +export type HubUserStatus = + | "pending" + | "active" + | "rejected" + | "blocked" + | "left" + | "removed"; + +export interface HubAuthState { + authSecret: string; + bootstrapAdminUserId?: string; + bootstrapAdminToken?: string; + hubInstanceId?: string; +} + +export interface HubUserRecord { + id: string; + username: string; + deviceName: string; + role: HubRole; + status: HubUserStatus; + tokenHash: string; + identityKey: string; + createdAt: number; + approvedAt: number | null; + rejectedAt: number | null; + leftAt: number | null; + removedAt: number | null; + lastIp: string; + lastActiveAt: number | null; + rejoinRequestedAt: number | null; +} + +export interface ClientHubConnection { + hubUrl: string; + userId: string; + username: string; + userToken: string; + role: HubRole; + connectedAt: number; + identityKey: string; + lastKnownStatus: string; + hubInstanceId: string; +} + +export interface HubSharedMemoryRecord { + id: string; + sourceTraceId: string; + sourceUserId: string; + sourceAgent: string; + kind: string; + summary: string; + content: string; + embedding?: EmbeddingVector | null; + embeddingNorm2?: number | null; + visible: boolean; + deletedAt: number | null; + createdAt: number; + updatedAt: number; +} + +export interface HubSharedMemorySearchHit extends HubSharedMemoryRecord { + score: number; +} + +export interface HubSharedSkillRecord { + id: string; + sourceSkillId: string; + sourceUserId: string; + name: string; + invocationGuide: string; + version: number; + qualityScore: number | null; + bundle: Record; + createdAt: number; + updatedAt: number; +} + +type KvRepo = ReturnType; + +const AUTH_STATE_KEY = "hub.auth"; +const CLIENT_JOIN_CONFIG_KEY = "hub.client.join_config"; +export const HUB_SHARED_MEMORY_TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +export interface ClientHubJoinConfig { + hubUrl: string; + teamTokenHash: string; +} + +export function makeHubRepo(db: StorageDb, kv: KvRepo) { + return { + getAuthState(): HubAuthState | null { + return kv.get(AUTH_STATE_KEY, null); + }, + + setAuthState(state: HubAuthState): void { + kv.set(AUTH_STATE_KEY, state); + }, + + upsertUser(user: HubUserRecord): void { + db.prepare>( + `INSERT INTO hub_users ( + id, username, device_name, role, status, token_hash, identity_key, + created_at, approved_at, rejected_at, left_at, removed_at, + last_ip, last_active_at, rejoin_requested_at + ) VALUES ( + @id, @username, @device_name, @role, @status, @token_hash, @identity_key, + @created_at, @approved_at, @rejected_at, @left_at, @removed_at, + @last_ip, @last_active_at, @rejoin_requested_at + ) + ON CONFLICT(id) DO UPDATE SET + username=excluded.username, + device_name=excluded.device_name, + role=excluded.role, + status=excluded.status, + token_hash=excluded.token_hash, + identity_key=excluded.identity_key, + approved_at=excluded.approved_at, + rejected_at=excluded.rejected_at, + left_at=excluded.left_at, + removed_at=excluded.removed_at, + last_ip=excluded.last_ip, + last_active_at=excluded.last_active_at, + rejoin_requested_at=excluded.rejoin_requested_at`, + ).run(userToParams(user)); + }, + + getUser(id: string): HubUserRecord | null { + const row = db.prepare<{ id: string }, HubUserRow>( + `SELECT * FROM hub_users WHERE id=@id`, + ).get({ id }); + return row ? rowToUser(row) : null; + }, + + findUserByIdentityKey(identityKey: string): HubUserRecord | null { + if (!identityKey) return null; + const row = db.prepare<{ identity_key: string }, HubUserRow>( + `SELECT * FROM hub_users WHERE identity_key=@identity_key`, + ).get({ identity_key: identityKey }); + return row ? rowToUser(row) : null; + }, + + listUsers(status?: HubUserStatus): HubUserRecord[] { + const rows = status + ? db.prepare<{ status: string }, HubUserRow>( + `SELECT * FROM hub_users WHERE status=@status ORDER BY created_at DESC`, + ).all({ status }) + : db.prepare( + `SELECT * FROM hub_users ORDER BY created_at DESC`, + ).all(); + return rows.map(rowToUser); + }, + + updateUserActivity(userId: string, ip: string, at = Date.now()): void { + db.prepare<{ id: string; last_ip: string; last_active_at: number }>( + `UPDATE hub_users SET last_ip=@last_ip, last_active_at=@last_active_at WHERE id=@id`, + ).run({ id: userId, last_ip: ip, last_active_at: at }); + }, + + setClientConnection(conn: ClientHubConnection): void { + db.prepare>( + `INSERT INTO client_hub_connection ( + id, hub_url, user_id, username, user_token, role, connected_at, + identity_key, last_known_status, hub_instance_id + ) VALUES ( + 1, @hub_url, @user_id, @username, @user_token, @role, @connected_at, + @identity_key, @last_known_status, @hub_instance_id + ) + ON CONFLICT(id) DO UPDATE SET + hub_url=excluded.hub_url, + user_id=excluded.user_id, + username=excluded.username, + user_token=excluded.user_token, + role=excluded.role, + connected_at=excluded.connected_at, + identity_key=excluded.identity_key, + last_known_status=excluded.last_known_status, + hub_instance_id=excluded.hub_instance_id`, + ).run({ + hub_url: conn.hubUrl, + user_id: conn.userId, + username: conn.username, + user_token: conn.userToken, + role: conn.role, + connected_at: conn.connectedAt, + identity_key: conn.identityKey, + last_known_status: conn.lastKnownStatus, + hub_instance_id: conn.hubInstanceId, + }); + }, + + getClientConnection(): ClientHubConnection | null { + const row = db.prepare( + `SELECT * FROM client_hub_connection WHERE id=1`, + ).get(); + return row ? rowToClientConnection(row) : null; + }, + + clearClientConnection(): void { + db.prepare(`DELETE FROM client_hub_connection WHERE id=1`).run(); + }, + + getClientJoinConfig(): ClientHubJoinConfig | null { + return kv.get(CLIENT_JOIN_CONFIG_KEY, null); + }, + + setClientJoinConfig(config: ClientHubJoinConfig): void { + kv.set(CLIENT_JOIN_CONFIG_KEY, config); + }, + + clearClientJoinConfig(): void { + kv.del(CLIENT_JOIN_CONFIG_KEY); + }, + + upsertSharedMemory(memory: HubSharedMemoryRecord): void { + db.prepare>( + `INSERT INTO hub_shared_memories ( + id, source_trace_id, source_user_id, source_agent, kind, + summary, content, embedding, embedding_norm2, visible, deleted_at, + created_at, updated_at + ) VALUES ( + @id, @source_trace_id, @source_user_id, @source_agent, @kind, + @summary, @content, @embedding, @embedding_norm2, @visible, @deleted_at, + @created_at, @updated_at + ) + ON CONFLICT(source_user_id, source_trace_id) DO UPDATE SET + summary=excluded.summary, + content=excluded.content, + kind=excluded.kind, + source_agent=excluded.source_agent, + embedding=excluded.embedding, + embedding_norm2=excluded.embedding_norm2, + visible=excluded.visible, + deleted_at=excluded.deleted_at, + updated_at=excluded.updated_at`, + ).run({ + id: memory.id, + source_trace_id: memory.sourceTraceId, + source_user_id: memory.sourceUserId, + source_agent: memory.sourceAgent, + kind: memory.kind, + summary: memory.summary, + content: memory.content, + embedding: memory.embedding ? encodeVector(memory.embedding) : null, + embedding_norm2: memory.embedding + ? (memory.embeddingNorm2 ?? norm2(memory.embedding)) + : null, + visible: memory.visible ? 1 : 0, + deleted_at: memory.deletedAt, + created_at: memory.createdAt, + updated_at: memory.updatedAt, + }); + }, + + getSharedMemoryBySource(sourceUserId: string, sourceTraceId: string): HubSharedMemoryRecord | null { + const row = db.prepare<{ source_user_id: string; source_trace_id: string }, HubSharedMemoryRow>( + `SELECT * FROM hub_shared_memories + WHERE source_user_id=@source_user_id AND source_trace_id=@source_trace_id`, + ).get({ source_user_id: sourceUserId, source_trace_id: sourceTraceId }); + return row ? rowToSharedMemory(row) : null; + }, + + hideSharedMemoryBySource( + sourceUserId: string, + sourceTraceId: string, + deletedAt = Date.now(), + ): void { + db.prepare<{ source_user_id: string; source_trace_id: string; deleted_at: number }>( + `UPDATE hub_shared_memories + SET visible=0, + deleted_at=COALESCE(deleted_at, @deleted_at), + updated_at=@deleted_at + WHERE source_user_id=@source_user_id AND source_trace_id=@source_trace_id`, + ).run({ source_user_id: sourceUserId, source_trace_id: sourceTraceId, deleted_at: deletedAt }); + }, + + deleteSharedMemoryBySource(sourceUserId: string, sourceTraceId: string): void { + db.prepare<{ source_user_id: string; source_trace_id: string }>( + `DELETE FROM hub_shared_memories + WHERE source_user_id=@source_user_id AND source_trace_id=@source_trace_id`, + ).run({ source_user_id: sourceUserId, source_trace_id: sourceTraceId }); + }, + + listSharedMemories(limit = 100): HubSharedMemoryRecord[] { + const rows = db.prepare<{ limit: number }, HubSharedMemoryRow>( + `SELECT * FROM hub_shared_memories + WHERE visible=1 + ORDER BY updated_at DESC LIMIT @limit`, + ).all({ limit: clampLimit(limit) }); + return rows.map(rowToSharedMemory); + }, + + searchSharedMemories( + query: string, + limit = 10, + ): HubSharedMemorySearchHit[] { + const cap = Math.max(50, Math.min(500, clampLimit(limit) * 40)); + const memories = db.prepare<{ limit: number }, HubSharedMemoryRow>( + `SELECT * FROM hub_shared_memories + WHERE visible=1 + ORDER BY updated_at DESC LIMIT @limit`, + ).all({ limit: cap }).map(rowToSharedMemory); + const byId = new Map(); + + for (const scored of scoreSharedMemoriesByText(query, memories)) { + upsertSearchHit(byId, scored.memory, scored.score); + } + + return [...byId.values()] + .filter((hit) => hit.score > 0) + .sort((a, b) => b.score - a.score || b.updatedAt - a.updatedAt) + .slice(0, clampLimit(limit)); + }, + + deleteSharedMemoriesByUser(sourceUserId: string): void { + db.prepare<{ source_user_id: string }>( + `DELETE FROM hub_shared_memories WHERE source_user_id=@source_user_id`, + ).run({ source_user_id: sourceUserId }); + }, + + purgeExpiredSharedMemories( + before = Date.now() - HUB_SHARED_MEMORY_TOMBSTONE_TTL_MS, + ): number { + const result = db.prepare<{ before: number }>( + `DELETE FROM hub_shared_memories + WHERE visible=0 AND deleted_at IS NOT NULL AND deleted_at <= @before`, + ).run({ before }); + return Number(result.changes ?? 0); + }, + + upsertSharedSkill(skill: HubSharedSkillRecord): void { + db.prepare>( + `INSERT INTO hub_shared_skills ( + id, source_skill_id, source_user_id, name, invocation_guide, + version, quality_score, bundle_json, created_at, updated_at + ) VALUES ( + @id, @source_skill_id, @source_user_id, @name, @invocation_guide, + @version, @quality_score, @bundle_json, @created_at, @updated_at + ) + ON CONFLICT(source_user_id, source_skill_id) DO UPDATE SET + name=excluded.name, + invocation_guide=excluded.invocation_guide, + version=excluded.version, + quality_score=excluded.quality_score, + bundle_json=excluded.bundle_json, + updated_at=excluded.updated_at`, + ).run({ + id: skill.id, + source_skill_id: skill.sourceSkillId, + source_user_id: skill.sourceUserId, + name: skill.name, + invocation_guide: skill.invocationGuide, + version: skill.version, + quality_score: skill.qualityScore, + bundle_json: JSON.stringify(skill.bundle ?? {}), + created_at: skill.createdAt, + updated_at: skill.updatedAt, + }); + }, + + getSharedSkillBySource(sourceUserId: string, sourceSkillId: string): HubSharedSkillRecord | null { + const row = db.prepare<{ source_user_id: string; source_skill_id: string }, HubSharedSkillRow>( + `SELECT * FROM hub_shared_skills + WHERE source_user_id=@source_user_id AND source_skill_id=@source_skill_id`, + ).get({ source_user_id: sourceUserId, source_skill_id: sourceSkillId }); + return row ? rowToSharedSkill(row) : null; + }, + + deleteSharedSkillBySource(sourceUserId: string, sourceSkillId: string): void { + db.prepare<{ source_user_id: string; source_skill_id: string }>( + `DELETE FROM hub_shared_skills + WHERE source_user_id=@source_user_id AND source_skill_id=@source_skill_id`, + ).run({ source_user_id: sourceUserId, source_skill_id: sourceSkillId }); + }, + + listSharedSkills(limit = 100): HubSharedSkillRecord[] { + const rows = db.prepare<{ limit: number }, HubSharedSkillRow>( + `SELECT * FROM hub_shared_skills ORDER BY updated_at DESC LIMIT @limit`, + ).all({ limit: clampLimit(limit) }); + return rows.map(rowToSharedSkill); + }, + + deleteSharedSkillsByUser(sourceUserId: string): void { + db.prepare<{ source_user_id: string }>( + `DELETE FROM hub_shared_skills WHERE source_user_id=@source_user_id`, + ).run({ source_user_id: sourceUserId }); + }, + + contributionsByUser(): Record { + const out: Record = {}; + const memRows = db.prepare( + `SELECT source_user_id, COUNT(*) AS n + FROM hub_shared_memories + WHERE visible=1 + GROUP BY source_user_id`, + ).all(); + for (const row of memRows) { + out[row.source_user_id] = out[row.source_user_id] ?? { memoryCount: 0, skillCount: 0 }; + out[row.source_user_id]!.memoryCount = row.n; + } + const skillRows = db.prepare( + `SELECT source_user_id, COUNT(*) AS n FROM hub_shared_skills GROUP BY source_user_id`, + ).all(); + for (const row of skillRows) { + out[row.source_user_id] = out[row.source_user_id] ?? { memoryCount: 0, skillCount: 0 }; + out[row.source_user_id]!.skillCount = row.n; + } + return out; + }, + }; +} + +interface HubUserRow { + id: string; + username: string; + device_name: string; + role: HubRole; + status: HubUserStatus; + token_hash: string; + identity_key: string; + created_at: number; + approved_at: number | null; + rejected_at: number | null; + left_at: number | null; + removed_at: number | null; + last_ip: string; + last_active_at: number | null; + rejoin_requested_at: number | null; +} + +interface ClientHubConnectionRow { + hub_url: string; + user_id: string; + username: string; + user_token: string; + role: HubRole; + connected_at: number; + identity_key: string; + last_known_status: string; + hub_instance_id: string; +} + +interface HubSharedMemoryRow { + id: string; + source_trace_id: string; + source_user_id: string; + source_agent: string; + kind: string; + summary: string; + content: string; + embedding: Buffer | null; + embedding_norm2: number | null; + visible: number; + deleted_at: number | null; + created_at: number; + updated_at: number; +} + +interface HubSharedSkillRow { + id: string; + source_skill_id: string; + source_user_id: string; + name: string; + invocation_guide: string; + version: number; + quality_score: number | null; + bundle_json: string; + created_at: number; + updated_at: number; +} + +function userToParams(user: HubUserRecord): Record { + return { + id: user.id, + username: user.username, + device_name: user.deviceName, + role: user.role, + status: user.status, + token_hash: user.tokenHash, + identity_key: user.identityKey, + created_at: user.createdAt, + approved_at: user.approvedAt, + rejected_at: user.rejectedAt, + left_at: user.leftAt, + removed_at: user.removedAt, + last_ip: user.lastIp, + last_active_at: user.lastActiveAt, + rejoin_requested_at: user.rejoinRequestedAt, + }; +} + +function rowToUser(row: HubUserRow): HubUserRecord { + return { + id: row.id, + username: row.username, + deviceName: row.device_name, + role: row.role, + status: row.status, + tokenHash: row.token_hash, + identityKey: row.identity_key, + createdAt: row.created_at, + approvedAt: row.approved_at, + rejectedAt: row.rejected_at, + leftAt: row.left_at, + removedAt: row.removed_at, + lastIp: row.last_ip, + lastActiveAt: row.last_active_at, + rejoinRequestedAt: row.rejoin_requested_at, + }; +} + +function rowToClientConnection(row: ClientHubConnectionRow): ClientHubConnection { + return { + hubUrl: row.hub_url, + userId: row.user_id, + username: row.username, + userToken: row.user_token, + role: row.role, + connectedAt: row.connected_at, + identityKey: row.identity_key, + lastKnownStatus: row.last_known_status, + hubInstanceId: row.hub_instance_id, + }; +} + +function rowToSharedMemory(row: HubSharedMemoryRow): HubSharedMemoryRecord { + return { + id: row.id, + sourceTraceId: row.source_trace_id, + sourceUserId: row.source_user_id, + sourceAgent: row.source_agent, + kind: row.kind, + summary: row.summary, + content: row.content, + embedding: decodeVector(row.embedding), + embeddingNorm2: row.embedding_norm2, + visible: row.visible === 1, + deletedAt: row.deleted_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function rowToSharedSkill(row: HubSharedSkillRow): HubSharedSkillRecord { + let bundle: Record = {}; + try { + const parsed = JSON.parse(row.bundle_json); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + bundle = parsed as Record; + } + } catch { + bundle = {}; + } + return { + id: row.id, + sourceSkillId: row.source_skill_id, + sourceUserId: row.source_user_id, + name: row.name, + invocationGuide: row.invocation_guide, + version: row.version, + qualityScore: row.quality_score, + bundle, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function scoreSharedMemoriesByText( + query: string, + memories: readonly HubSharedMemoryRecord[], +): Array<{ memory: HubSharedMemoryRecord; score: number }> { + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) return []; + const terms = searchTerms(normalizedQuery); + return memories + .map((memory) => { + const haystack = normalizeSearchText(`${memory.summary}\n${memory.content}`); + let score = 0; + if (haystack.includes(normalizedQuery)) score += 3; + for (const term of terms) { + if (haystack.includes(term)) score += term.length >= 3 ? 1.5 : 1; + } + return { memory, score }; + }) + .filter((hit) => hit.score > 0); +} + +function upsertSearchHit( + into: Map, + memory: HubSharedMemoryRecord, + score: number, +): void { + const existing = into.get(memory.id); + if (existing) { + existing.score += score; + return; + } + into.set(memory.id, { ...memory, score }); +} + +function normalizeSearchText(text: string): string { + return text.toLowerCase().replace(/\s+/g, " ").trim(); +} + +function searchTerms(text: string): string[] { + const terms = new Set(); + const cjk = text.match(/[\p{Script=Han}]+/gu) ?? []; + for (const segment of cjk) { + for (let i = 0; i < segment.length - 1; i++) { + terms.add(segment.slice(i, i + 2)); + } + for (let i = 0; i < segment.length - 2; i++) { + terms.add(segment.slice(i, i + 3)); + } + } + for (const term of text.match(/[a-z0-9_.$/-]{2,}/g) ?? []) { + terms.add(term); + } + return [...terms].slice(0, 80); +} + +function clampLimit(limit: number): number { + return Math.max(1, Math.min(500, Math.floor(limit || 100))); +} diff --git a/apps/memos-local-plugin/core/storage/repos/index.ts b/apps/memos-local-plugin/core/storage/repos/index.ts index 9feacd8b4..12cca9f33 100644 --- a/apps/memos-local-plugin/core/storage/repos/index.ts +++ b/apps/memos-local-plugin/core/storage/repos/index.ts @@ -12,12 +12,14 @@ import { makeDecisionRepairsRepo } from "./decision_repairs.js"; import { makeEmbeddingRetryQueueRepo } from "./embedding_retry_queue.js"; import { makeEpisodesRepo } from "./episodes.js"; import { makeFeedbackRepo } from "./feedback.js"; +import { makeHubRepo } from "./hub.js"; import { makeKvRepo } from "./kv.js"; import { makeMigrationsRepo } from "./migrations.js"; import { makePoliciesRepo } from "./policies.js"; import { makeSessionsRepo } from "./sessions.js"; import { makeSkillTrialsRepo } from "./skill_trials.js"; import { makeSkillsRepo } from "./skills.js"; +import { makeTracePolicyLinksRepo } from "./trace-policy-links.js"; import { makeTracesRepo } from "./traces.js"; import { makeWorldModelRepo } from "./world_model.js"; @@ -29,17 +31,20 @@ export interface Repos { embeddingRetryQueue: ReturnType; episodes: ReturnType; feedback: ReturnType; + hub: ReturnType; kv: ReturnType; migrations: ReturnType; policies: ReturnType; sessions: ReturnType; skillTrials: ReturnType; skills: ReturnType; + tracePolicyLinks: ReturnType; traces: ReturnType; worldModel: ReturnType; } export function makeRepos(db: StorageDb): Repos { + const kv = makeKvRepo(db); return { apiLogs: makeApiLogsRepo(db), audit: makeAuditRepo(db), @@ -48,12 +53,14 @@ export function makeRepos(db: StorageDb): Repos { embeddingRetryQueue: makeEmbeddingRetryQueueRepo(db), episodes: makeEpisodesRepo(db), feedback: makeFeedbackRepo(db), - kv: makeKvRepo(db), + hub: makeHubRepo(db, kv), + kv, migrations: makeMigrationsRepo(db), policies: makePoliciesRepo(db), sessions: makeSessionsRepo(db), skillTrials: makeSkillTrialsRepo(db), skills: makeSkillsRepo(db), + tracePolicyLinks: makeTracePolicyLinksRepo(db), traces: makeTracesRepo(db), worldModel: makeWorldModelRepo(db), }; @@ -67,11 +74,13 @@ export { makeDecisionRepairsRepo } from "./decision_repairs.js"; export { makeEmbeddingRetryQueueRepo } from "./embedding_retry_queue.js"; export { makeEpisodesRepo } from "./episodes.js"; export { makeFeedbackRepo } from "./feedback.js"; +export { makeHubRepo } from "./hub.js"; export { makeKvRepo } from "./kv.js"; export { makeMigrationsRepo } from "./migrations.js"; export { makePoliciesRepo } from "./policies.js"; export { makeSessionsRepo } from "./sessions.js"; export { makeSkillTrialsRepo } from "./skill_trials.js"; export { makeSkillsRepo } from "./skills.js"; +export { makeTracePolicyLinksRepo } from "./trace-policy-links.js"; export { makeTracesRepo } from "./traces.js"; export { makeWorldModelRepo } from "./world_model.js"; diff --git a/apps/memos-local-plugin/core/storage/repos/policies.ts b/apps/memos-local-plugin/core/storage/repos/policies.ts index b96435cfd..3a9b3390a 100644 --- a/apps/memos-local-plugin/core/storage/repos/policies.ts +++ b/apps/memos-local-plugin/core/storage/repos/policies.ts @@ -1,4 +1,4 @@ -import type { EmbeddingVector, PolicyId, PolicyRow } from "../../types.js"; +import type { EmbeddingVector, PolicyId, PolicyRow, ShareScope } from "../../types.js"; import type { PolicyListFilter, StorageDb } from "../types.js"; import { buildInsert, buildUpdate } from "../tx.js"; import { scanAndTopK, type VectorHit } from "../vector.js"; @@ -311,7 +311,7 @@ export function makePoliciesRepo(db: StorageDb) { updateShare( id: PolicyId, share: { - scope: "private" | "local" | "public" | "hub" | null; + scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, @@ -507,7 +507,7 @@ function mapRow(r: RawPolicyRow): PolicyRow { share: r.share_scope != null ? { - scope: normalizeShareForStorage(r.share_scope) as "private" | "local" | "public" | "hub", + scope: normalizeShareForStorage(r.share_scope) as ShareScope, target: r.share_target, sharedAt: r.shared_at, } diff --git a/apps/memos-local-plugin/core/storage/repos/skills.ts b/apps/memos-local-plugin/core/storage/repos/skills.ts index 985977d2d..db5a972e7 100644 --- a/apps/memos-local-plugin/core/storage/repos/skills.ts +++ b/apps/memos-local-plugin/core/storage/repos/skills.ts @@ -1,4 +1,4 @@ -import type { EmbeddingVector, SkillId, SkillRow } from "../../types.js"; +import type { EmbeddingVector, ShareScope, SkillId, SkillRow } from "../../types.js"; import type { SkillListFilter, StorageDb } from "../types.js"; import { buildInsert, buildUpdate } from "../tx.js"; import { scanAndTopK, type VectorHit } from "../vector.js"; @@ -294,7 +294,7 @@ export function makeSkillsRepo(db: StorageDb) { updateShare( id: SkillId, share: { - scope: "private" | "local" | "public" | "hub" | null; + scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, @@ -462,7 +462,7 @@ function mapRow(r: RawSkillRow): SkillRow { share: r.share_scope != null ? { - scope: normalizeShareForStorage(r.share_scope) as "private" | "local" | "public" | "hub", + scope: normalizeShareForStorage(r.share_scope) as ShareScope, target: r.share_target, sharedAt: r.shared_at, } diff --git a/apps/memos-local-plugin/core/storage/repos/trace-policy-links.ts b/apps/memos-local-plugin/core/storage/repos/trace-policy-links.ts new file mode 100644 index 000000000..a3c443a9c --- /dev/null +++ b/apps/memos-local-plugin/core/storage/repos/trace-policy-links.ts @@ -0,0 +1,51 @@ +import type { EpisodeId, PolicyId, TraceId } from "../../types.js"; +import type { StorageDb } from "../types.js"; + +export function makeTracePolicyLinksRepo(db: StorageDb) { + const insert = db.prepare<{ + trace_id: TraceId; + policy_id: PolicyId; + episode_id: EpisodeId; + created_at: number; + }>( + `INSERT OR IGNORE INTO trace_policy_links + (trace_id, policy_id, episode_id, created_at) + VALUES (@trace_id, @policy_id, @episode_id, @created_at)`, + ); + const selectTraceIds = db.prepare<{ policy_id: PolicyId }, { trace_id: TraceId }>( + `SELECT trace_id + FROM trace_policy_links + WHERE policy_id=@policy_id + ORDER BY created_at DESC, trace_id DESC`, + ); + const selectEpisodeIds = db.prepare<{ policy_id: PolicyId }, { episode_id: EpisodeId }>( + `SELECT DISTINCT episode_id + FROM trace_policy_links + WHERE policy_id=@policy_id + ORDER BY episode_id`, + ); + + return { + link(args: { + traceId: TraceId; + policyId: PolicyId; + episodeId: EpisodeId; + now?: number; + }): void { + insert.run({ + trace_id: args.traceId, + policy_id: args.policyId, + episode_id: args.episodeId, + created_at: args.now ?? Date.now(), + }); + }, + + getWithTraceIds(policyId: PolicyId): TraceId[] { + return selectTraceIds.all({ policy_id: policyId }).map((r) => r.trace_id); + }, + + getLinkedEpisodeIds(policyId: PolicyId): EpisodeId[] { + return selectEpisodeIds.all({ policy_id: policyId }).map((r) => r.episode_id); + }, + }; +} diff --git a/apps/memos-local-plugin/core/storage/repos/traces.ts b/apps/memos-local-plugin/core/storage/repos/traces.ts index 43656ba19..20ea1a5fc 100644 --- a/apps/memos-local-plugin/core/storage/repos/traces.ts +++ b/apps/memos-local-plugin/core/storage/repos/traces.ts @@ -1,4 +1,4 @@ -import type { EmbeddingVector, EpisodeId, SessionId, TraceId, TraceRow } from "../../types.js"; +import type { EmbeddingVector, EpisodeId, SessionId, ShareScope, TraceId, TraceRow } from "../../types.js"; import type { StorageDb, TraceListFilter } from "../types.js"; import { buildInClause, buildInsert, buildUpdate } from "../tx.js"; import { scanAndTopK, topKCosine, type VectorHit, type VectorRow } from "../vector.js"; @@ -144,7 +144,10 @@ export function makeTracesRepo(db: StorageDb) { * Total row count matching the same filter (no limit/offset). * Used by list endpoints so the viewer can show "Page N of M". */ - count(filter: Omit = {}): number { + count( + filter: Omit = {}, + visibility?: { sql: string; params: Record }, + ): number { const tr = timeRangeWhere(filter, "ts"); const fragments: string[] = []; const params: Record = { ...tr.params }; @@ -168,6 +171,10 @@ export function makeTracesRepo(db: StorageDb) { fragments.push(`abs(value) >= @min_abs_value`); params.min_abs_value = filter.minAbsValue; } + if (visibility) { + fragments.push(visibility.sql); + Object.assign(params, visibility.params); + } if (tr.sql) fragments.push(tr.sql); const where = joinWhere(fragments); const sql = `SELECT COUNT(*) AS n FROM traces ${where}`; @@ -610,7 +617,7 @@ export function makeTracesRepo(db: StorageDb) { updateShare( id: TraceId, share: { - scope: "private" | "local" | "public" | "hub" | null; + scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, @@ -751,7 +758,7 @@ function mapRow(r: RawTraceRow): TraceRow { share: r.share_scope != null ? { - scope: normalizeShareForStorage(r.share_scope) as "private" | "local" | "public" | "hub", + scope: normalizeShareForStorage(r.share_scope) as ShareScope, target: r.share_target, sharedAt: r.shared_at, } diff --git a/apps/memos-local-plugin/core/storage/repos/world_model.ts b/apps/memos-local-plugin/core/storage/repos/world_model.ts index ce61beac4..f56939308 100644 --- a/apps/memos-local-plugin/core/storage/repos/world_model.ts +++ b/apps/memos-local-plugin/core/storage/repos/world_model.ts @@ -1,5 +1,6 @@ import type { EmbeddingVector, + ShareScope, WorldModelId, WorldModelRow, WorldModelStructure, @@ -292,7 +293,7 @@ export function makeWorldModelRepo(db: StorageDb) { updateShare( id: WorldModelId, share: { - scope: "private" | "local" | "public" | "hub" | null; + scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, @@ -425,7 +426,7 @@ function mapRow(r: RawWorldRow): WorldModelRow { share: r.share_scope != null ? { - scope: normalizeShareForStorage(r.share_scope) as "private" | "local" | "public" | "hub", + scope: normalizeShareForStorage(r.share_scope) as ShareScope, target: r.share_target, sharedAt: r.shared_at, } diff --git a/apps/memos-local-plugin/core/telemetry/sender.ts b/apps/memos-local-plugin/core/telemetry/sender.ts index 4a72c7bdf..dced19e7b 100644 --- a/apps/memos-local-plugin/core/telemetry/sender.ts +++ b/apps/memos-local-plugin/core/telemetry/sender.ts @@ -272,7 +272,7 @@ export class Telemetry { } trackTurnStart(agentName: string, latencyMs: number, hitCount: number): void { - this.capture("memory_search", { + this.capture("memos_search", { agent_name: agentName, type: "turn_start", latency_ms: Math.round(latencyMs), @@ -288,7 +288,7 @@ export class Telemetry { } trackMemorySearch(agentName: string, latencyMs: number, hitCount: number): void { - this.capture("memory_search", { + this.capture("memos_search", { agent_name: agentName, type: "adhoc", latency_ms: Math.round(latencyMs), diff --git a/apps/memos-local-plugin/core/types.ts b/apps/memos-local-plugin/core/types.ts index efe49b963..0e4f87f52 100644 --- a/apps/memos-local-plugin/core/types.ts +++ b/apps/memos-local-plugin/core/types.ts @@ -305,9 +305,9 @@ export interface SkillRow extends OwnedRow { } | null; /** Last user edit through the viewer's edit modal (migration 009). */ editedAt?: EpochMs | null; - /** Number of successful `skill_get` calls that loaded this skill. */ + /** Number of successful `memos_skill_get` calls that loaded this skill. */ usageCount?: number; - /** Last successful `skill_get` time, or null when never loaded. */ + /** Last successful `memos_skill_get` time, or null when never loaded. */ lastUsedAt?: EpochMs | null; } @@ -339,6 +339,7 @@ export interface EpisodeRow extends OwnedRow { rTask: Reward | null; /** "open" | "closed". Open episodes accept new traces. */ status: "open" | "closed"; + meta?: Record; } export interface FeedbackRow extends OwnedRow { diff --git a/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md b/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md index eed568197..014e3deea 100644 --- a/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md +++ b/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md @@ -154,9 +154,9 @@ algorithm: smartSeed: true # MMR seed-by-tier only when tier's best clears the relative floor skillInjectionMode: summary # summary (default) | full # summary → Tier-1 skills land in the prompt as `name + η + 1-line - # description + a `skill_get(id="…")` invocation hint`. The + # description + a `memos_skill_get(id="…")` invocation hint`. The # host model loads the full procedure on demand by calling - # the `skill_get` tool. Keeps prompts small. + # the `memos_skill_get` tool. Keeps prompts small. # full → Inline the entire `invocationGuide` body (legacy). Use # this if your host doesn't support tool / function calls. skillSummaryChars: 200 # char cap for the per-skill summary line diff --git a/apps/memos-local-plugin/docs/DEMO_TaskCLI_OpenClaw_test.md b/apps/memos-local-plugin/docs/DEMO_TaskCLI_OpenClaw_test.md index c14c3cc11..ed4cd8efb 100644 --- a/apps/memos-local-plugin/docs/DEMO_TaskCLI_OpenClaw_test.md +++ b/apps/memos-local-plugin/docs/DEMO_TaskCLI_OpenClaw_test.md @@ -185,7 +185,7 @@ sqlite3 ~/.openclaw/memos-plugin/data/memos.db \ - 命令返回 `"stopReason": "stop"` 后**手动 Ctrl+C**(agent 会进入 hub retry 循环,不会自动退出) - 每轮 Ctrl+C 后**等 40-60 秒**让 capture / reward / L2 / L3 / skill 订阅者收尾,再去面板验证 -- **召回看日志页**:每轮 `memory_search` 卡片展开后有「初步召回 / Hub 远端 / LLM 筛选后」三段,被注入 assistant 的就是「LLM 筛选后」 +- **召回看日志页**:每轮 `memos_search` 卡片展开后有「初步召回 / Hub 远端 / LLM 筛选后」三段,被注入 assistant 的就是「LLM 筛选后」 --- @@ -212,8 +212,8 @@ openclaw agent --session-id "$SESSION" --timeout 120 --json --message \ | 记忆 | 多条 step trace(一次 tool call 一条 trace + 一条总结 trace) | | 任务 | 1 个**进行中**的 episode,r_task 暂时 null | | 经验 / 环境认知 / 技能 | 0 | -| 日志 → `memory_search` | **「初步召回」为空** `candidates: []` —— 完美的"冷启动空召回" | -| 日志 | `memory_add` + `memory_search` 各 1 条 | +| 日志 → `memos_search` | **「初步召回」为空** `candidates: []` —— 完美的"冷启动空召回" | +| 日志 | `memory_add` + `memos_search` 各 1 条 | > ✅ 这一步演示了 **L1 trace 写入 + 反思加权 V 框架已就位**。注意 V/α 此刻仍然是 0,要等 Round 2 的 `new_task` 信号触发 R1 的 reward 评分后才回填。 @@ -259,7 +259,7 @@ openclaw agent --session-id "$SESSION" --timeout 120 --json --message \ |---|---| | 任务 → R2 episode | status=closed、r_task≈0.75 | | 经验 | 第 1 条 L2 policy 应已出现(可能 `candidate`,可能升 `active`,看阈值) | -| **日志 → `memory_search`** | **第一次出现 Tier 2 trace 召回!**「初步召回」里有 R1/R2 的 storage 实现 trace,score 0.7+,「LLM 筛选后」保留并**注入 prompt** | +| **日志 → `memos_search`** | **第一次出现 Tier 2 trace 召回!**「初步召回」里有 R1/R2 的 storage 实现 trace,score 0.7+,「LLM 筛选后」保留并**注入 prompt** | > ✅ **生成→召回闭环的第一次显形**:R1/R2 写盘的代码被 Tier 2 召回,进入 R3 的 prompt——assistant 写 SQLite 时能直接参考之前的 storage 风格。 @@ -326,7 +326,7 @@ openclaw agent --session-id "$SESSION" --timeout 120 --json --message \ |---|---| | 经验 | pytest policy `support` 升到 3 | | 技能 | 可能 version 升到 2(rebuild),eta 上调 | -| **日志 → `memory_search`** | **Tier 1 技能召回首次出现** —— 「初步召回」第一条是 Skill:`validate_python_syntax_compile (η=0.5)`,技能 invocation guide 注入 prompt | +| **日志 → `memos_search`** | **Tier 1 技能召回首次出现** —— 「初步召回」第一条是 Skill:`validate_python_syntax_compile (η=0.5)`,技能 invocation guide 注入 prompt | > ✅ **演示第二个高潮**:技能召回首次接管任务,OpenClaw 直接照已结晶的 pytest 模板写。 @@ -401,11 +401,11 @@ openclaw agent --session-id task-cli-demo-recall-show --timeout 150 --json --mes ### 5.3 召回查证 — 看注入 prompt 的实际内容 -#### 5.3.1 数据库查 `memory_search.candidates`(最直接) +#### 5.3.1 数据库查 `memos_search.candidates`(最直接) ```bash sqlite3 ~/.openclaw/memos-plugin/data/memos.db \ - "SELECT output_json FROM api_logs WHERE tool_name='memory_search' ORDER BY called_at DESC LIMIT 1;" \ + "SELECT output_json FROM api_logs WHERE tool_name='memos_search' ORDER BY called_at DESC LIMIT 1;" \ | python3 -c " import json, sys data = json.loads(sys.stdin.read()) @@ -458,12 +458,12 @@ print(body) IMPORTANT: The following are facts from previous conversations with this user. You MUST treat these as established knowledge and use them directly when answering. -## Candidate skills (call `skill_get` to load any you decide to use) +## Candidate skills (call `memos_skill_get` to load any you decide to use) 1. validate_python_syntax_compile validate_python_syntax_compile — η=0.50, status=candidate 验证Python文件语法正确性 - → call `skill_get(id="sk_xxxxxxx")` to load the full procedure if you decide to use it + → call `memos_skill_get(id="sk_xxxxxxx")` to load the full procedure if you decide to use it ## Memories @@ -483,14 +483,14 @@ You MUST treat these as established knowledge and use them directly when answeri 各自实现统一的 load(path)/save(path, tasks) 接口;CLI 子命令在 task_cli/commands/ 下,... Available follow-up tools: -- `skill_get(id)` — ... -- `memory_search(query, maxResults?)` — ... +- `memos_skill_get(id)` — ... +- `memos_search(query, maxResults?)` — ... ``` #### 5.3.3 面板「日志」tab 看(适合演示时秀给观众) -打开 `http://127.0.0.1:18799` → 「日志」tab → 找最新的 `memory_search` 卡片展开,能看到: +打开 `http://127.0.0.1:18799` → 「日志」tab → 找最新的 `memos_search` 卡片展开,能看到: - **「初步召回」段** — Tier 1 / Tier 2 / Tier 3 三种 candidate 都列出来 - **「Hub 远端」段** — 演示场景下应该是空 @@ -531,7 +531,7 @@ assistant 的回答应该明显使用了召回的内容: | V7 概念 | 第几轮出现 | 在面板哪里看 | |---|---|---| -| 冷启动空召回 | Round 1 | 日志 → `memory_search` candidates=[] | +| 冷启动空召回 | Round 1 | 日志 → `memos_search` candidates=[] | | **Tier 2 记忆召回** | Round 3 起 | 日志 → 初步召回出现 trace | | **Tier 1 技能召回** | Round 6 起 | 日志 → 初步召回首条是 Skill | | **三层同时召回** | **收官轮 R10** | 日志 → 同时出现 Skill + Trace + WorldModel | diff --git a/apps/memos-local-plugin/docs/E2E_TEST_SCENARIO.md b/apps/memos-local-plugin/docs/E2E_TEST_SCENARIO.md index 4d548d716..c98b6494c 100644 --- a/apps/memos-local-plugin/docs/E2E_TEST_SCENARIO.md +++ b/apps/memos-local-plugin/docs/E2E_TEST_SCENARIO.md @@ -57,7 +57,7 @@ bash apps/memos-local-plugin/scripts/e2e-probe.sh - `traces Δ+5`:5 轮对话都被记忆(必现) - `episodes Δ+1`:5 轮对话被归纳为 1 个任务(必现;若 LLM 判定为新任务会有多个) -- `apiLogs Δ+10`:5×(memory_search + memory_add) = 10 条 API 日志 +- `apiLogs Δ+10`:5×(memos_search + memory_add) = 10 条 API 日志 - `policies Δ+1`:**经验生成** — 需要 Summarizer 和 Skill-Evolver 都配了真实 LLM Key 才会出 - `worldModels Δ`:**环境认知** — 需要至少 2 条结构相似的经验才结晶;单次 probe 通常不够 - `skills Δ+1`:**技能** — 经验被验证后才生成 @@ -83,7 +83,7 @@ bash apps/memos-local-plugin/scripts/e2e-probe.sh |--------------|--------------------------------------------------------------------------| | 记忆 | 多出 2 条,每条显示 summary、私有 pill、时间戳、V/α 数值 | | 日志 (`memory_add`) | 卡片**默认展开**,行内直接显示新加入的记忆内容(不用点击) | -| 日志 (`memory_search`) | 展开后三段:初步召回 / Hub 远端 / LLM 筛选后,候选带分数和 role pill | +| 日志 (`memos_search`) | 展开后三段:初步召回 / Hub 远端 / LLM 筛选后,候选带分数和 role pill | ### 第二轮 —— 检索 + 任务归纳 @@ -93,7 +93,7 @@ bash apps/memos-local-plugin/scripts/e2e-probe.sh | 面板 tab | 期待看到 | |---------|---------| -| 日志 (`memory_search`) | 新条目,"LLM 筛选后"段落命中上一轮"喝豆浆"的记忆 | +| 日志 (`memos_search`) | 新条目,"LLM 筛选后"段落命中上一轮"喝豆浆"的记忆 | | OpenClaw 回复 | 反映出"你早上喝豆浆,不喝咖啡" → 说明召回注入到 prompt 了 | | 任务 | 出现一条任务卡;多轮后状态会变成"已完成";点卡片右侧抽屉是**聊天视图**(左 assistant 气泡 / 右 user 气泡) | @@ -123,7 +123,7 @@ bash apps/memos-local-plugin/scripts/e2e-probe.sh | 面板 tab | 期待看到 | |---------|---------| -| 日志 (`memory_search`) | 第三轮那条经验被检索出来放进 prompt | +| 日志 (`memos_search`) | 第三轮那条经验被检索出来放进 prompt | | 经验 | 原经验的 `support` / `gain` 数值增加 | | 技能 | 原技能的 η 提升 | @@ -179,7 +179,7 @@ hi | Skills | 技能 tab | `tests/unit/skill/*` + `skill.integration.test.ts` | | L3 environment | 环境认知 tab | `tests/unit/memory/l3/*` + `l3.integration.test.ts` | | Feedback → Policy | 用户反馈转经验 | `tests/unit/feedback/*` + `feedback.integration.test.ts` | -| Retrieval | `memory_search` 日志三段 | `tests/unit/retrieval/*` | +| Retrieval | `memos_search` 日志三段 | `tests/unit/retrieval/*` | | Reward | 任务 V/α 数值 | `tests/unit/reward/*` + `reward.integration.test.ts` | 跑 `npm test` 全绿(700+ tests)= V7 算法管道在技术层面不破;本文档两部分的**前端可见验收** = 算法确实在你装好的实际环境里生效。 diff --git a/apps/memos-local-plugin/docs/MANUAL_E2E_TESTING.md b/apps/memos-local-plugin/docs/MANUAL_E2E_TESTING.md index 0a2f6fbc4..c3e9b0684 100644 --- a/apps/memos-local-plugin/docs/MANUAL_E2E_TESTING.md +++ b/apps/memos-local-plugin/docs/MANUAL_E2E_TESTING.md @@ -382,16 +382,16 @@ curl -s -b "memos_sess=$SESS" 'http://127.0.0.1:18799/api/v1/policies' \ L3 环境知识会在每次 turn 开始时通过 `prependContext` 注入 prompt (见 `adapters/openclaw/bridge.ts::renderContextBlock`)。不需要额外 -验证;看 memory_search api_logs 的 `candidates[]` 里出现 `tier=3, +验证;看 memos_search api_logs 的 `candidates[]` 里出现 `tier=3, refKind=world-model` 即可。 -此外新增了 `memory_environment` 工具,让 agent 可以在 tool-call +此外新增了 `memos_environment` 工具,让 agent 可以在 tool-call 阶段按需再查一次环境知识: ```bash # agent 调用示例(通过 openclaw) openclaw agent --session-id env-probe-$(date +%s) \ - --message "先用 memory_environment 查这个项目相关的环境知识,再回答:项目里 pytest 测试应该放在哪个目录?" \ + --message "先用 memos_environment 查这个项目相关的环境知识,再回答:项目里 pytest 测试应该放在哪个目录?" \ --timeout 90 --json ``` diff --git a/apps/memos-local-plugin/docs/PROMPT-INJECTION-AND-RETRIEVAL-FILTER.md b/apps/memos-local-plugin/docs/PROMPT-INJECTION-AND-RETRIEVAL-FILTER.md index 8c8be3b6a..c2417fec4 100644 --- a/apps/memos-local-plugin/docs/PROMPT-INJECTION-AND-RETRIEVAL-FILTER.md +++ b/apps/memos-local-plugin/docs/PROMPT-INJECTION-AND-RETRIEVAL-FILTER.md @@ -35,7 +35,7 @@ OpenClaw before_prompt_build ```text -No prior memories matched this query — the store may simply be cold. You can still call `memory_search` with a shorter or rephrased query if you expect there to be relevant past context. +No prior memories matched this query — the store may simply be cold. You can still call `memos_search` with a shorter or rephrased query if you expect there to be relevant past context. ``` @@ -60,11 +60,11 @@ IMPORTANT: The following are facts from previous conversations with this user. You MUST treat these as established knowledge and use them directly when answering. Do NOT say you don't know or don't have information if the answer is in these memories. -## Candidate skills (call `skill_get` to load any you decide to use) +## Candidate skills (call `memos_skill_get` to load any you decide to use) 1. {skillName} {skillSummary} - → call `skill_get(id="{skillId}")` to load the full procedure if you decide to use it + → call `memos_skill_get(id="{skillId}")` to load the full procedure if you decide to use it ## Memories @@ -96,17 +96,17 @@ or avoid in this kind of context. 1. {antiPatternText} Available follow-up tools: -- `skill_get(id)` — load the full procedure/verification of a candidate skill listed above -- `memory_search(query, maxResults?)` — re-query with a shorter / rephrased string +- `memos_skill_get(id)` — load the full procedure/verification of a candidate skill listed above +- `memos_search(query, maxResults?)` — re-query with a shorter / rephrased string ``` 精简点: - Skill 摘要不再注入 `η={eta}` 和 `status={status}`。 - 通用 snippet footer 不再注入 `refId="..."`。 -- `memory_get` / `memory_timeline` / `skill_list` 不再放进注入 footer,因为删除通用 refId 后这些提示对当前回答帮助有限。 +- `memos_get` / `memos_timeline` / `memos_skill_list` 不再放进注入 footer,因为删除通用 refId 后这些提示对当前回答帮助有限。 - `packet.snippets` 结构化数据里仍保留 `refId`,用于日志、API、调试和内部映射;只是模型可见 prompt 不再展示它。 -- `skill_get(id="...")` 保留,因为 summary-mode skill 需要它按需加载完整 procedure。 +- `memos_skill_get(id="...")` 保留,因为 summary-mode skill 需要它按需加载完整 procedure。 ## 2. LLM 筛选召回内容的 Prompt @@ -196,7 +196,7 @@ Decision guidance: without such facts should be dropped. - RANK a SKILL when its name / description plausibly addresses the user's sub-problem. The agent decides later whether to call - `skill_get` for the full procedure — err on the side of ranking + `memos_skill_get` for the full procedure — err on the side of ranking every candidate skill that could plausibly help. - RANK a WORLD-MODEL when its topic matches the domain of the query and the body contains structural information the agent would diff --git a/apps/memos-local-plugin/install.ps1 b/apps/memos-local-plugin/install.ps1 index 79efa5e4f..0290ee47b 100644 --- a/apps/memos-local-plugin/install.ps1 +++ b/apps/memos-local-plugin/install.ps1 @@ -267,7 +267,7 @@ function Install-OpenClaw { "homepage": "https://github.com/MemTensor/MemOS", "extensions": ["$RuntimeEntry"], "contracts": { - "tools": ["memory_search", "memory_get", "memory_timeline", "skill_list", "memory_environment", "skill_get"] + "tools": ["memos_search", "memos_get", "memos_timeline", "memos_skill_list", "memos_environment", "memos_skill_get"] }, "configSchema": { "type": "object", @@ -301,6 +301,14 @@ const { PLUGIN_VERSION: pluginVersion, LEGACY_JSON: legacyCsv, } = process.env; const legacyIds = (legacyCsv || '').split(',').filter(Boolean); +const MEMOS_TOOL_NAMES = [ + 'memos_search', + 'memos_get', + 'memos_timeline', + 'memos_environment', + 'memos_skill_list', + 'memos_skill_get', +]; let config = {}; if (fs.existsSync(configPath)) { @@ -316,6 +324,14 @@ if (!config.gateway || typeof config.gateway !== 'object' || Array.isArray(confi } if (!config.gateway.mode) config.gateway.mode = 'local'; +if (!config.tools || typeof config.tools !== 'object' || Array.isArray(config.tools)) { + config.tools = {}; +} +if (!Array.isArray(config.tools.alsoAllow)) config.tools.alsoAllow = []; +for (const toolName of MEMOS_TOOL_NAMES) { + if (!config.tools.alsoAllow.includes(toolName)) config.tools.alsoAllow.push(toolName); +} + if (!config.plugins || typeof config.plugins !== 'object' || Array.isArray(config.plugins)) { config.plugins = {}; } @@ -390,13 +406,15 @@ function Install-Hermes { $ConfigFile = Join-Path $env:LOCALAPPDATA "hermes\config.yaml" $AdapterDir = Join-Path $Prefix "adapters\hermes" - Get-Process -Name "node" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match "bridge.cts" } | Stop-Process -Force -ErrorAction SilentlyContinue + Get-Process -Name "node" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match "bridge\.(cts|cjs)" } | Stop-Process -Force -ErrorAction SilentlyContinue Get-Process -Name "hermes" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue Deploy-Tarball -Prefix $Prefix Ensure-RuntimeHome -Agent "hermes" -HomeDir $HomeDir -Prefix $Prefix - Set-Content -Path (Join-Path $AdapterDir "bridge_path.txt") -Value (Join-Path $Prefix "bridge.cts") -Encoding UTF8 + $BridgeEntry = Join-Path $Prefix "dist\bridge.cjs" + if (-not (Test-Path $BridgeEntry)) { $BridgeEntry = Join-Path $Prefix "bridge.cts" } + Set-Content -Path (Join-Path $AdapterDir "bridge_path.txt") -Value $BridgeEntry -Encoding UTF8 $PythonBin = "" $VenvPy = Join-Path $env:LOCALAPPDATA "hermes\hermes-agent\venv\Scripts\python.exe" @@ -456,13 +474,22 @@ memory: } Write-Info "Starting Memory Viewer daemon" - $TsxBin = Join-Path $Prefix "node_modules\.bin\tsx.cmd" + $NodeBin = Get-Content -Path (Join-Path $Prefix ".memos-node-bin") -ErrorAction SilentlyContinue + if (-not $NodeBin) { $NodeBin = (Get-Command "node.exe" -ErrorAction SilentlyContinue).Source } + $TsxBin = Join-Path $Prefix "node_modules\tsx\dist\cli.mjs" $BridgeCts = Join-Path $Prefix "bridge.cts" + $BridgeCjs = Join-Path $Prefix "dist\bridge.cjs" + $BridgeEntry = $BridgeCjs + if (-not (Test-Path $BridgeEntry)) { $BridgeEntry = $BridgeCts } - if ((Test-Path $TsxBin) -and (Test-Path $BridgeCts)) { + if ($NodeBin -and (Test-Path $BridgeEntry) -and ($BridgeEntry.EndsWith(".cjs") -or (Test-Path $TsxBin))) { $DaemonLog = Join-Path $Prefix "logs\daemon-start.log" $DaemonLogErr = Join-Path $Prefix "logs\daemon-start-err.log" - Start-Process -FilePath $TsxBin -ArgumentList "$BridgeCts --agent=hermes --daemon" -WindowStyle Hidden -RedirectStandardOutput $DaemonLog -RedirectStandardError $DaemonLogErr + if ($BridgeEntry.EndsWith(".cjs")) { + Start-Process -FilePath $NodeBin -ArgumentList "$BridgeEntry --agent=hermes --daemon" -WindowStyle Hidden -RedirectStandardOutput $DaemonLog -RedirectStandardError $DaemonLogErr + } else { + Start-Process -FilePath $NodeBin -ArgumentList "$TsxBin $BridgeEntry --agent=hermes --daemon" -WindowStyle Hidden -RedirectStandardOutput $DaemonLog -RedirectStandardError $DaemonLogErr + } if (Wait-ForViewer -Port $HermesPort -Timeout 120) { Write-Success "Memory Viewer daemon running" @@ -470,7 +497,7 @@ memory: Write-Warn "Memory Viewer did not respond within 120s." } } else { - Write-Warn "tsx not found - skipping daemon start." + Write-Warn "node or bridge runtime not found - skipping daemon start." } } diff --git a/apps/memos-local-plugin/install.sh b/apps/memos-local-plugin/install.sh index 75cb7c8cd..989f0ea24 100755 --- a/apps/memos-local-plugin/install.sh +++ b/apps/memos-local-plugin/install.sh @@ -338,20 +338,25 @@ deploy_tarball_to_prefix() { success "Package extracted" step "Installing npm dependencies" - command -v node > "${prefix}/.memos-node-bin" - ( cd "${prefix}" && MEMOS_SKIP_SETUP=1 npm install --omit=dev --no-fund --no-audit --loglevel=error >/dev/null 2>&1 ) + local node_bin node_dir node_version + node_bin="$(command -v node || true)" + [[ -n "${node_bin}" && -x "${node_bin}" ]] || die "Node.js not found after bootstrap." + node_dir="$(dirname "${node_bin}")" + node_version="$("${node_bin}" -v 2>/dev/null || echo "unknown")" + printf "%s\n" "${node_bin}" > "${prefix}/.memos-node-bin" + ( cd "${prefix}" && PATH="${node_dir}:${PATH}" MEMOS_SKIP_SETUP=1 npm install --omit=dev --no-fund --no-audit --loglevel=error >/dev/null 2>&1 ) [[ -d "${prefix}/node_modules" ]] || die "npm install failed in ${prefix}" if [[ -d "${prefix}/node_modules/better-sqlite3" ]]; then - step "Rebuilding better-sqlite3 for Node $(node -v)" - ( cd "${prefix}" && npm rebuild better-sqlite3 --loglevel=error >/dev/null 2>&1 ) \ - || ( cd "${prefix}" && npm rebuild better-sqlite3 --build-from-source --loglevel=error >/dev/null 2>&1 ) \ + step "Rebuilding better-sqlite3 for Node ${node_version}" + ( cd "${prefix}" && PATH="${node_dir}:${PATH}" npm rebuild better-sqlite3 --loglevel=error >/dev/null 2>&1 ) \ + || ( cd "${prefix}" && PATH="${node_dir}:${PATH}" npm rebuild better-sqlite3 --build-from-source --loglevel=error >/dev/null 2>&1 ) \ || warn "better-sqlite3 rebuild did not complete cleanly." - if ( cd "${prefix}" && node -e "require('better-sqlite3')" >/dev/null 2>&1 ); then + if ( cd "${prefix}" && "${node_bin}" -e "require('better-sqlite3')" >/dev/null 2>&1 ); then success "better-sqlite3 native module OK" else warn "better-sqlite3 not loadable — plugin will fail at startup." - printf " ${DIM}Fix: cd ${prefix} && npm rebuild better-sqlite3${NC}\n" >&2 + printf " ${DIM}Fix: cd ${prefix} && PATH=${node_dir}:\$PATH npm rebuild better-sqlite3${NC}\n" >&2 fi fi success "Dependencies ready" @@ -458,12 +463,12 @@ install_openclaw() { "extensions": ["${OPENCLAW_RUNTIME_ENTRY}"], "contracts": { "tools": [ - "memory_search", - "memory_get", - "memory_timeline", - "skill_list", - "memory_environment", - "skill_get" + "memos_search", + "memos_get", + "memos_timeline", + "memos_skill_list", + "memos_environment", + "memos_skill_get" ] }, "configSchema": { @@ -493,6 +498,14 @@ const { PLUGIN_VERSION: pluginVersion, LEGACY_JSON: legacyCsv, } = process.env; const legacyIds = (legacyCsv || '').split(',').filter(Boolean); +const MEMOS_TOOL_NAMES = [ + 'memos_search', + 'memos_get', + 'memos_timeline', + 'memos_environment', + 'memos_skill_list', + 'memos_skill_get', +]; let config = {}; if (fs.existsSync(configPath)) { @@ -508,6 +521,14 @@ if (!config.gateway || typeof config.gateway !== 'object' || Array.isArray(confi } if (!config.gateway.mode) config.gateway.mode = 'local'; +if (!config.tools || typeof config.tools !== 'object' || Array.isArray(config.tools)) { + config.tools = {}; +} +if (!Array.isArray(config.tools.alsoAllow)) config.tools.alsoAllow = []; +for (const toolName of MEMOS_TOOL_NAMES) { + if (!config.tools.alsoAllow.includes(toolName)) config.tools.alsoAllow.push(toolName); +} + if (!config.plugins || typeof config.plugins !== 'object' || Array.isArray(config.plugins)) { config.plugins = {}; } @@ -541,6 +562,9 @@ if (!config.plugins.entries[pluginId] || typeof config.plugins.entries[pluginId] config.plugins.entries[pluginId] = {}; } config.plugins.entries[pluginId].enabled = true; +// OpenClaw blocks conversation-level typed hooks for non-bundled plugins +// unless the user config explicitly grants access. The memory plugin needs +// agent_end to capture completed turns. if ( !config.plugins.entries[pluginId].hooks || typeof config.plugins.entries[pluginId].hooks !== 'object' || @@ -548,9 +572,6 @@ if ( ) { config.plugins.entries[pluginId].hooks = {}; } -// OpenClaw >= 2026.5 gates conversation transcript hooks for non-bundled -// plugins. MemOS needs agent_end/before_prompt_build access to capture turns -// and inject retrieval context, so keep this explicit capability on install. config.plugins.entries[pluginId].hooks.allowConversationAccess = true; if (!config.plugins.installs || typeof config.plugins.installs !== 'object') config.plugins.installs = {}; @@ -633,15 +654,15 @@ install_hermes() { step "Stopping existing bridge daemon" local bridge_pids="" - bridge_pids="$(pgrep -f "bridge.cts" 2>/dev/null || true)" + bridge_pids="$(pgrep -f "bridge\\.(cts|cjs)" 2>/dev/null || true)" if [[ -n "${bridge_pids}" ]]; then kill ${bridge_pids} >/dev/null 2>&1 || true local i for i in {1..10}; do sleep 1 - pgrep -f "bridge.cts" >/dev/null 2>&1 || break + pgrep -f "bridge\\.(cts|cjs)" >/dev/null 2>&1 || break done - bridge_pids="$(pgrep -f "bridge.cts" 2>/dev/null || true)" + bridge_pids="$(pgrep -f "bridge\\.(cts|cjs)" 2>/dev/null || true)" if [[ -n "${bridge_pids}" ]]; then kill -9 ${bridge_pids} >/dev/null 2>&1 || true sleep 1 @@ -674,7 +695,9 @@ install_hermes() { step "Configuring runtime environment" ensure_runtime_home "hermes" "${home}" "${prefix}" - echo "${prefix}/bridge.cts" > "${adapter_dir}/bridge_path.txt" + local bridge_entry="${prefix}/dist/bridge.cjs" + [[ -f "${bridge_entry}" ]] || bridge_entry="${prefix}/bridge.cts" + echo "${bridge_entry}" > "${adapter_dir}/bridge_path.txt" success "Bridge path recorded" step "Locating Hermes Python environment" @@ -955,14 +978,21 @@ CFGEOF step "Starting Memory Viewer daemon" local node_bin node_bin="$(cat "${prefix}/.memos-node-bin" 2>/dev/null || command -v node || true)" - local tsx_bin="${prefix}/node_modules/.bin/tsx" + local tsx_bin="${prefix}/node_modules/tsx/dist/cli.mjs" local bridge_cts="${prefix}/bridge.cts" - if [[ -n "${node_bin}" && -x "${node_bin}" && -x "${tsx_bin}" && -f "${bridge_cts}" ]]; then + local bridge_cjs="${prefix}/dist/bridge.cjs" + local bridge_entry="${bridge_cjs}" + [[ -f "${bridge_entry}" ]] || bridge_entry="${bridge_cts}" + if [[ -n "${node_bin}" && -x "${node_bin}" && -f "${bridge_entry}" && ( "${bridge_entry}" == *.cjs || -f "${tsx_bin}" ) ]]; then local daemon_log="${prefix}/logs/daemon-start.log" mkdir -p "${prefix}/logs" # Launch bridge in --daemon mode (pure HTTP, no stdio). # The process stays alive to serve the Memory Viewer. - ( cd "${prefix}" && nohup "${node_bin}" "${tsx_bin}" "${bridge_cts}" --agent=hermes --daemon >"${daemon_log}" 2>&1 & ) + if [[ "${bridge_entry}" == *.cjs ]]; then + ( cd "${prefix}" && nohup "${node_bin}" "${bridge_entry}" --agent=hermes --daemon >"${daemon_log}" 2>&1 & ) + else + ( cd "${prefix}" && nohup "${node_bin}" "${tsx_bin}" "${bridge_entry}" --agent=hermes --daemon >"${daemon_log}" 2>&1 & ) + fi if wait_for_viewer "${HERMES_PORT}" 120; then success "Memory Viewer daemon running" @@ -972,7 +1002,7 @@ CFGEOF return 1 fi else - warn "node or tsx not found — skipping daemon start." + warn "node or bridge runtime not found — skipping daemon start." fi fi diff --git a/apps/memos-local-plugin/openclaw.plugin.json b/apps/memos-local-plugin/openclaw.plugin.json index fc952fec7..5005bf2c0 100644 --- a/apps/memos-local-plugin/openclaw.plugin.json +++ b/apps/memos-local-plugin/openclaw.plugin.json @@ -6,12 +6,12 @@ "kind": "memory", "contracts": { "tools": [ - "memory_search", - "memory_get", - "memory_timeline", - "memory_environment", - "skill_list", - "skill_get" + "memos_search", + "memos_get", + "memos_timeline", + "memos_environment", + "memos_skill_list", + "memos_skill_get" ] }, "configSchema": { diff --git a/apps/memos-local-plugin/package-lock.json b/apps/memos-local-plugin/package-lock.json index 97304d334..d7400fffd 100644 --- a/apps/memos-local-plugin/package-lock.json +++ b/apps/memos-local-plugin/package-lock.json @@ -1,12 +1,12 @@ { "name": "@memtensor/memos-local-plugin", - "version": "2.0.1", + "version": "2.0.2-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@memtensor/memos-local-plugin", - "version": "2.0.1", + "version": "2.0.2-beta.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/apps/memos-local-plugin/package.json b/apps/memos-local-plugin/package.json index 4ff8d66bb..93bfcb599 100644 --- a/apps/memos-local-plugin/package.json +++ b/apps/memos-local-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@memtensor/memos-local-plugin", - "version": "2.0.1", + "version": "2.0.5", "description": "Reflect2Evolve memory plugin: layered L1/L2/L3 memory, reflection-weighted value backprop, cross-task policy induction, skill crystallization, three-tier retrieval. Adapters for OpenClaw and Hermes Agent via a shared algorithm core.", "type": "module", "main": "dist/core/index.js", @@ -15,6 +15,7 @@ }, "files": [ "dist", + "telemetry.credentials.json", "openclaw.plugin.json", "bridge.cts", "bridge/**/*.ts", diff --git a/apps/memos-local-plugin/server/ALGORITHMS.md b/apps/memos-local-plugin/server/ALGORITHMS.md index 7c4f851e9..57bd4edd9 100644 --- a/apps/memos-local-plugin/server/ALGORITHMS.md +++ b/apps/memos-local-plugin/server/ALGORITHMS.md @@ -78,10 +78,12 @@ The router is a `Map<"METHOD /path", handler>`. This is intentional: ## S7 — Request body is size-capped -Default `maxBodyBytes = 1 MiB`. A stream overflowing this throws -during `readBody`. The outer dispatch turns that into a 500, which is -not strictly accurate but sufficient — 4xx vs 5xx coding here matters -less than refusing the input. +Default `maxBodyBytes = 1 MiB`; `/api/v1/import` is allowed up to +64 MiB so exported memory bundles can round-trip through the viewer. +A stream overflowing its route cap throws during `readBody`. The outer +dispatch turns that into a 500, which is not strictly accurate but +sufficient — 4xx vs 5xx coding here matters less than refusing the +input. ## S8 — Static files are never cached permanently diff --git a/apps/memos-local-plugin/server/http.ts b/apps/memos-local-plugin/server/http.ts index e90c0a859..df047f4d1 100644 --- a/apps/memos-local-plugin/server/http.ts +++ b/apps/memos-local-plugin/server/http.ts @@ -46,6 +46,8 @@ const AGENT_DEFAULT_PORTS: Record = { openclaw: 18799, hermes: 18800, }; +const DEFAULT_MAX_BODY_BYTES = 1_048_576; +const IMPORT_MAX_BODY_BYTES = 64 * 1024 * 1024; export async function startHttpServer( deps: ServerDeps, @@ -197,7 +199,7 @@ async function dispatch( const key = `${method} ${pathname}`; const exact = routes.getExact(key); if (exact) { - const body = await readBody(req, options.maxBodyBytes ?? 1_048_576); + const body = await readBody(req, bodyLimitForPath(pathname, options.maxBodyBytes)); const result = await exact({ req, res, url, body, deps, params: {} }); if (!res.headersSent && result !== undefined) { writeJson(res, 200, result); @@ -208,7 +210,7 @@ async function dispatch( // Pattern-route fallback (e.g. `/api/v1/traces/:id`). const pattern = routes.matchPattern(method, pathname); if (pattern) { - const body = await readBody(req, options.maxBodyBytes ?? 1_048_576); + const body = await readBody(req, bodyLimitForPath(pathname, options.maxBodyBytes)); const result = await pattern.handler({ req, res, @@ -234,3 +236,7 @@ async function dispatch( void deps; } +function bodyLimitForPath(pathname: string, configured?: number): number { + if (configured !== undefined) return configured; + return pathname === "/api/v1/import" ? IMPORT_MAX_BODY_BYTES : DEFAULT_MAX_BODY_BYTES; +} diff --git a/apps/memos-local-plugin/server/routes/api-logs.ts b/apps/memos-local-plugin/server/routes/api-logs.ts index 8fc59b5f8..6738cc138 100644 --- a/apps/memos-local-plugin/server/routes/api-logs.ts +++ b/apps/memos-local-plugin/server/routes/api-logs.ts @@ -1,11 +1,11 @@ /** * `GET /api/v1/api-logs` — paged listing of the structured `api_logs` * table (defined in the squashed initial schema). Fuels the viewer's - * Logs page which renders rich per-tool templates for `memory_search` + * Logs page which renders rich per-tool templates for `memos_search` * and `memory_add`. * * Query parameters: - * - `tool` optional tool-name filter (e.g. `memory_search`) + * - `tool` optional tool-name filter (e.g. `memos_search`) * - `tools` optional comma-separated tool-name filter * - `limit` default 50, capped server-side at 500 * - `offset` default 0 @@ -57,6 +57,7 @@ export function registerApiLogsRoutes(routes: Routes, deps: ServerDeps): void { // API simple. Any tool name not in this list still appears if the // user passes it via `?tool=` explicitly. const tools = [ + "memos_search", "memory_search", "memory_add", "skill_generate", diff --git a/apps/memos-local-plugin/server/routes/auth.ts b/apps/memos-local-plugin/server/routes/auth.ts index 3e5b33269..bb6dc63a4 100644 --- a/apps/memos-local-plugin/server/routes/auth.ts +++ b/apps/memos-local-plugin/server/routes/auth.ts @@ -410,7 +410,8 @@ export function requireSession( agent?: string | null, ): boolean { // Public: auth endpoints + health (so the viewer can tell whether - // the backend is up BEFORE unlocking). + // the backend is up BEFORE unlocking). Other API routes, including + // ping, fall through and are only open when no password is configured. if (pathname.startsWith("/api/v1/auth/")) return true; if (pathname === "/api/v1/health") return true; diff --git a/apps/memos-local-plugin/server/routes/health.ts b/apps/memos-local-plugin/server/routes/health.ts index 414a5a47b..9dee2774c 100644 --- a/apps/memos-local-plugin/server/routes/health.ts +++ b/apps/memos-local-plugin/server/routes/health.ts @@ -10,11 +10,18 @@ import type { ServerDeps } from "../types.js"; import type { RouteContext, Routes } from "./registry.js"; export function registerHealthRoutes(routes: Routes, deps: ServerDeps): void { + const serviceIdentity = { + service: "memos-local-plugin", + }; routes.set("GET /api/v1/health", async () => { const health = await deps.core.health(); const bridge = deps.bridgeStatus?.(); - return bridge ? { ...health, bridge } : health; + const identity = { + ...serviceIdentity, + agent: health.agent, + }; + return bridge ? { ...health, ...identity, bridge } : { ...health, ...identity }; }); - routes.set("GET /api/v1/ping", () => ({ ok: true, ts: Date.now() })); + routes.set("GET /api/v1/ping", () => ({ ok: true, ...serviceIdentity, ts: Date.now() })); void ({} as RouteContext); } diff --git a/apps/memos-local-plugin/server/routes/hub-admin.ts b/apps/memos-local-plugin/server/routes/hub-admin.ts index c719932cc..12f8fb5e9 100644 --- a/apps/memos-local-plugin/server/routes/hub-admin.ts +++ b/apps/memos-local-plugin/server/routes/hub-admin.ts @@ -1,33 +1,71 @@ -/** - * Hub admin endpoint stub. - * - * Returns a shape the viewer's Admin view understands. When the user - * hasn't enabled team sharing, we just send back `{enabled: false}` — - * the UI has a dedicated onboarding empty state for that path. - * - * When sharing IS enabled, we still return an empty `pending/users/ - * groups` list for now: the `core/hub/` runtime is a stub, and wiring - * in real sync state is a separate phase. - */ import type { ServerDeps } from "../types.js"; -import type { Routes } from "./registry.js"; +import { parseJson, writeError, type Routes } from "./registry.js"; export function registerHubAdminRoutes(routes: Routes, deps: ServerDeps): void { routes.set("GET /api/v1/hub/admin", async () => { - const config = await deps.core.getConfig(); - const hub = (config?.hub ?? {}) as { - enabled?: boolean; - role?: "hub" | "client"; - }; - if (!hub.enabled) { - return { enabled: false }; + if (deps.core.hubAdminSnapshot) { + return await deps.core.hubAdminSnapshot(); } + const config = await deps.core.getConfig(); + const hub = (config?.hub ?? {}) as { enabled?: boolean; role?: "hub" | "client" }; return { - enabled: true, + enabled: !!hub.enabled, role: hub.role ?? "client", pending: [], users: [], groups: [], }; }); + + routes.set("GET /api/v1/hub/status", async () => { + if (deps.core.hubAdminSnapshot) { + return await deps.core.hubAdminSnapshot(); + } + return { enabled: false }; + }); + + routes.set("POST /api/v1/hub/admin/approve-user", async (ctx) => { + const body = parseJson<{ userId?: string }>(ctx); + const userId = String(body.userId || ""); + if (!userId) { + writeError(ctx, 400, "invalid_argument", "userId is required"); + return; + } + const result = await deps.core.approveHubUser?.(userId); + if (!result || (typeof result === "object" && (result as { ok?: boolean }).ok === false)) { + writeError(ctx, 404, "not_found", `hub user not found: ${userId}`); + return; + } + return result; + }); + + routes.set("POST /api/v1/hub/admin/reject-user", async (ctx) => { + const body = parseJson<{ userId?: string }>(ctx); + const userId = String(body.userId || ""); + if (!userId) { + writeError(ctx, 400, "invalid_argument", "userId is required"); + return; + } + const result = await deps.core.rejectHubUser?.(userId); + if (!result || (typeof result === "object" && (result as { ok?: boolean }).ok === false)) { + writeError(ctx, 404, "not_found", `hub user not found: ${userId}`); + return; + } + return result; + }); + + routes.set("POST /api/v1/hub/admin/remove-user", async (ctx) => { + const body = parseJson<{ userId?: string }>(ctx); + const userId = String(body.userId || ""); + if (!userId) { + writeError(ctx, 400, "invalid_argument", "userId is required"); + return; + } + const result = await deps.core.removeHubUser?.(userId); + if (!result || (typeof result === "object" && (result as { ok?: boolean }).ok === false)) { + writeError(ctx, 404, "not_found", `hub user not found: ${userId}`); + return; + } + return result; + }); } diff --git a/apps/memos-local-plugin/server/routes/import-export.ts b/apps/memos-local-plugin/server/routes/import-export.ts index 124eb18be..efdf16595 100644 --- a/apps/memos-local-plugin/server/routes/import-export.ts +++ b/apps/memos-local-plugin/server/routes/import-export.ts @@ -35,6 +35,22 @@ import { writeJson } from "../middleware/io.js"; const NATIVE_IMPORT_DEFAULT_BATCH = 25; const NATIVE_IMPORT_MAX_BATCH = 200; +const NATIVE_IMPORT_CACHE_TTL_MS = 5 * 60 * 1000; + +interface HermesNativeSource { + memories: string[]; + bytes: number; + mtimeMs: number; +} + +interface OpenClawNativeSource { + messages: OpenClawNativeMessage[]; + files: number; + sessions: number; +} + +const hermesNativeCache = new Map(); +const openClawNativeCache = new Map(); export function registerImportExportRoutes( routes: Routes, @@ -143,7 +159,7 @@ export function registerImportExportRoutes( const limit = coerceBatchLimit(body.limit); try { - const source = await readHermesNativeMemories(path); + const source = await readHermesNativeMemories(path, { force: offset === 0 }); const total = source.memories.length; const batch = source.memories.slice(offset, offset + limit); if (batch.length === 0) { @@ -221,7 +237,7 @@ export function registerImportExportRoutes( const limit = coerceBatchLimit(body.limit); try { - const source = await readOpenClawNativeMessages(path); + const source = await readOpenClawNativeMessages(path, { force: offset === 0 }); const total = source.messages.length; const batch = source.messages.slice(offset, offset + limit); if (batch.length === 0) { @@ -340,7 +356,7 @@ interface HermesNativeScanResult { async function scanHermesNativeMemories(path: string): Promise { try { - const source = await readHermesNativeMemories(path); + const source = await readHermesNativeMemories(path, { force: true }); return { found: true, agent: "hermes", @@ -359,18 +375,28 @@ async function scanHermesNativeMemories(path: string): Promise { +async function readHermesNativeMemories( + path: string, + opts: { force?: boolean } = {}, +): Promise { const info = await stat(path); + const cached = hermesNativeCache.get(path); + if ( + !opts.force && + cached && + cached.source.bytes === info.size && + cached.source.mtimeMs === info.mtimeMs + ) { + return cached.source; + } const raw = await readFile(path, "utf8"); - return { + const source = { memories: splitHermesNativeMemories(raw), bytes: info.size, mtimeMs: info.mtimeMs, }; + hermesNativeCache.set(path, { source }); + return source; } function splitHermesNativeMemories(raw: string): string[] { @@ -446,7 +472,7 @@ interface OpenClawNativeScanResult { async function scanOpenClawNativeSessions(path: string): Promise { try { - const source = await readOpenClawNativeMessages(path); + const source = await readOpenClawNativeMessages(path, { force: true }); return { found: true, agent: "openclaw", @@ -468,11 +494,14 @@ async function scanOpenClawNativeSessions(path: string): Promise { +async function readOpenClawNativeMessages( + path: string, + opts: { force?: boolean } = {}, +): Promise { + const cached = openClawNativeCache.get(path); + if (!opts.force && cached && Date.now() - cached.cachedAt < NATIVE_IMPORT_CACHE_TTL_MS) { + return cached.source; + } const agentEntries = await readdir(path, { withFileTypes: true }); const messages: OpenClawNativeMessage[] = []; let files = 0; @@ -549,7 +578,9 @@ async function readOpenClawNativeMessages(path: string): Promise<{ if (af !== bf) return af.localeCompare(bf); return a.lineNo - b.lineNo; }); - return { messages, files, sessions }; + const source = { messages, files, sessions }; + openClawNativeCache.set(path, { source, cachedAt: Date.now() }); + return source; } function buildOpenClawNativeTraces(messages: readonly OpenClawNativeMessage[]): TraceDTO[] { @@ -669,7 +700,7 @@ function stripOpenClawMemoryInjection(text: string): string { "", ); cleaned = cleaned.replace( - /## Memory system\n+No memories were automatically recalled[^\n]*(?:\n[^\n]*memory_search[^\n]*)*/gi, + /## Memory system\n+No memories were automatically recalled[^\n]*(?:\n[^\n]*memos_search[^\n]*)*/gi, "", ); return cleaned.trim(); diff --git a/apps/memos-local-plugin/server/routes/metrics.ts b/apps/memos-local-plugin/server/routes/metrics.ts index 34c4343d8..fdf6b583f 100644 --- a/apps/memos-local-plugin/server/routes/metrics.ts +++ b/apps/memos-local-plugin/server/routes/metrics.ts @@ -8,7 +8,7 @@ * GET /api/v1/metrics/tools?minutes=N (alias: ?days=N) * Per-tool call latency + success-rate table. Data source: the * `api_logs` table, which records every plugin internal operation - * (memory_search / memory_add / policy_generate / skill_generate / + * (memos_search / memory_add / policy_generate / skill_generate / * world_model_generate / task_done / task_failed) with its * `durationMs` and `success` flag. We also fold in any agent-side * tool invocations recorded on `traces.tool_calls_json` so the @@ -92,7 +92,7 @@ export function registerMetricsRoutes(routes: Routes, deps: ServerDeps): void { // 1. Plugin internal operations — from api_logs. We only surface // entries that represent **actual tool/handler calls the agent - // made or the user cares about latency for**: `memory_search` + // made or the user cares about latency for**: `memos_search` // and `memory_add`. Purely internal pipeline lifecycle events // (`task_done`, `task_failed`, `skill_generate`, `skill_evolve`, // `policy_generate`, `policy_evolve`, `world_model_generate`, @@ -100,7 +100,7 @@ export function registerMetricsRoutes(routes: Routes, deps: ServerDeps): void { // with names like "task_failed" that users don't recognise as // tools, and their timings reflect background work rather than // response latency. - const PUBLIC_API_LOG_TOOLS = new Set(["memory_search", "memory_add"]); + const PUBLIC_API_LOG_TOOLS = new Set(["memos_search", "memory_search", "memory_add"]); const { logs } = await deps.core.listApiLogs({ limit: 5_000, offset: 0 }); for (const lg of logs) { if (lg.calledAt < sinceMs) continue; diff --git a/apps/memos-local-plugin/server/routes/policies.ts b/apps/memos-local-plugin/server/routes/policies.ts index 4de5a8790..14b236cf2 100644 --- a/apps/memos-local-plugin/server/routes/policies.ts +++ b/apps/memos-local-plugin/server/routes/policies.ts @@ -46,14 +46,12 @@ export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { q, ownerAgentKind, ownerProfileId, - includeAllNamespaces: true, }); const total = await deps.core.countPolicies({ status, q, ownerAgentKind, ownerProfileId, - includeAllNamespaces: true, }); return { policies, @@ -70,7 +68,7 @@ export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { writeError(ctx, 400, "invalid_argument", "id is required"); return; } - const policy = await deps.core.getPolicy(id, undefined, { includeAllNamespaces: true }); + const policy = await deps.core.getPolicy(id); if (!policy) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; @@ -139,7 +137,7 @@ export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { } let updated = hasContent ? await deps.core.updatePolicy(id, contentPatch) - : await deps.core.getPolicy(id, undefined, { includeAllNamespaces: true }); + : await deps.core.getPolicy(id); if (!updated) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; @@ -168,7 +166,7 @@ export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { return; } const body = parseJson<{ - scope?: "private" | "local" | "public" | "hub" | null; + scope?: "private" | "public" | "hub" | null; target?: string | null; }>(ctx); const scope = body.scope === undefined ? "public" : body.scope; @@ -242,14 +240,14 @@ export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { writeError(ctx, 400, "invalid_argument", "id is required"); return; } - const policy = await deps.core.getPolicy(id, undefined, { includeAllNamespaces: true }); + const policy = await deps.core.getPolicy(id); if (!policy) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; } const [skills, worldModels] = await Promise.all([ - deps.core.listSkills({ limit: 500, includeAllNamespaces: true }), - deps.core.listWorldModels({ limit: 500, includeAllNamespaces: true }), + deps.core.listSkills({ limit: 500 }), + deps.core.listWorldModels({ limit: 500 }), ]); return { skills: skills @@ -284,13 +282,11 @@ export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { q, ownerAgentKind, ownerProfileId, - includeAllNamespaces: true, }); const total = await deps.core.countWorldModels({ q, ownerAgentKind, ownerProfileId, - includeAllNamespaces: true, }); return { worldModels, @@ -307,7 +303,7 @@ export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { writeError(ctx, 400, "invalid_argument", "id is required"); return; } - const model = await deps.core.getWorldModel(id, undefined, { includeAllNamespaces: true }); + const model = await deps.core.getWorldModel(id); if (!model) { writeError(ctx, 404, "not_found", `world model not found: ${id}`); return; @@ -327,14 +323,14 @@ export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { writeError(ctx, 400, "invalid_argument", "id is required"); return; } - const wm = await deps.core.getWorldModel(id, undefined, { includeAllNamespaces: true }); + const wm = await deps.core.getWorldModel(id); if (!wm) { writeError(ctx, 404, "not_found", `world model not found: ${id}`); return; } const policies = await Promise.all( wm.policyIds.map(async (pid) => { - const p = await deps.core.getPolicy(pid, undefined, { includeAllNamespaces: true }); + const p = await deps.core.getPolicy(pid); return p ? { id: p.id, title: p.title, status: p.status, gain: p.gain } : { id: pid, title: null, status: null, gain: null }; @@ -403,7 +399,7 @@ export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { return; } const body = parseJson<{ - scope?: "private" | "local" | "public" | "hub" | null; + scope?: "private" | "public" | "hub" | null; target?: string | null; }>(ctx); const scope = body.scope === undefined ? "public" : body.scope; diff --git a/apps/memos-local-plugin/server/routes/skill.ts b/apps/memos-local-plugin/server/routes/skill.ts index 69f392d37..cf8edd4b2 100644 --- a/apps/memos-local-plugin/server/routes/skill.ts +++ b/apps/memos-local-plugin/server/routes/skill.ts @@ -27,7 +27,6 @@ export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void { limit: q ? 5000 : pageSize + offset + 1, ownerAgentKind, ownerProfileId, - includeAllNamespaces: true, }); if (q) { all = all.filter( @@ -40,7 +39,6 @@ export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void { status, ownerAgentKind, ownerProfileId, - includeAllNamespaces: true, }); return { skills: page, @@ -57,7 +55,7 @@ export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void { writeError(ctx, 400, "invalid_argument", "id is required"); return; } - const skill = await deps.core.getSkill(id as SkillId, { includeAllNamespaces: true }); + const skill = await deps.core.getSkill(id as SkillId); if (skill === null) { writeError(ctx, 404, "not_found", `skill not found: ${id}`); return; @@ -71,7 +69,7 @@ export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void { writeError(ctx, 400, "invalid_argument", "id is required"); return; } - const skill = await deps.core.getSkill(id as SkillId, { includeAllNamespaces: true }); + const skill = await deps.core.getSkill(id as SkillId); if (skill === null) { writeError(ctx, 404, "not_found", `skill not found: ${id}`); return; @@ -151,14 +149,14 @@ export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void { writeError(ctx, 400, "invalid_argument", "id is required"); return; } - const skill = await deps.core.getSkill(id as SkillId, { includeAllNamespaces: true }); + const skill = await deps.core.getSkill(id as SkillId); if (!skill) { writeError(ctx, 404, "not_found", `skill not found: ${id}`); return; } const sourcePolicies = await Promise.all( skill.sourcePolicyIds.map(async (pid) => { - const p = await deps.core.getPolicy(pid, undefined, { includeAllNamespaces: true }); + const p = await deps.core.getPolicy(pid); return p ? { id: p.id, title: p.title, status: p.status, gain: p.gain } : { id: pid, title: null, status: null, gain: null }; @@ -166,7 +164,7 @@ export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void { ); const sourceWorldModels = await Promise.all( skill.sourceWorldModelIds.map(async (wid) => { - const w = await deps.core.getWorldModel(wid, undefined, { includeAllNamespaces: true }); + const w = await deps.core.getWorldModel(wid); return w ? { id: w.id, title: w.title } : { id: wid, title: null }; }), ); @@ -255,7 +253,7 @@ export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void { return; } const body = parseJson<{ - scope?: "private" | "local" | "public" | "hub" | null; + scope?: "private" | "public" | "hub" | null; target?: string | null; }>(ctx); const scope = body.scope === undefined ? "public" : body.scope; @@ -282,7 +280,7 @@ export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void { writeError(ctx, 400, "invalid_argument", "id is required"); return; } - const skill = await deps.core.getSkill(id as SkillId, { includeAllNamespaces: true }); + const skill = await deps.core.getSkill(id as SkillId); if (!skill) { writeError(ctx, 404, "not_found", `skill not found: ${id}`); return; diff --git a/apps/memos-local-plugin/server/routes/trace.ts b/apps/memos-local-plugin/server/routes/trace.ts index 05cf848e6..ec1dca4c8 100644 --- a/apps/memos-local-plugin/server/routes/trace.ts +++ b/apps/memos-local-plugin/server/routes/trace.ts @@ -7,7 +7,7 @@ * The unparameterised endpoints `/api/v1/memory/trace?id=…` live on * for backward compatibility. */ -import type { EpisodeId, SessionId } from "../../agent-contract/dto.js"; +import type { EpisodeId, SessionId, TraceDTO } from "../../agent-contract/dto.js"; import type { ServerDeps } from "../types.js"; import { parseJson, writeError, type Routes } from "./registry.js"; @@ -42,30 +42,35 @@ export function registerTraceRoutes(routes: Routes, deps: ServerDeps): void { // pair as one "memory" — matching the viewer's grouped display where // a user query + its tool steps + final reply collapse into one card. const groupByTurn = params.get("groupByTurn") === "true"; - const traces = await deps.core.listTraces({ - limit, + const includeTotal = params.get("includeTotal") !== "false"; + const listLimit = includeTotal ? limit : limit + 1; + const rawTraces = await deps.core.listTraces({ + limit: listLimit, offset, sessionId: sessionId as SessionId | undefined, ownerAgentKind, ownerProfileId, q, groupByTurn, - includeAllNamespaces: true, - }); - const total = await deps.core.countTraces({ - sessionId: sessionId as SessionId | undefined, - ownerAgentKind, - ownerProfileId, - q, - groupByTurn, - includeAllNamespaces: true, }); + const { traces, hasMore } = trimTracePage(rawTraces, limit, groupByTurn); + const total = includeTotal + ? await deps.core.countTraces({ + sessionId: sessionId as SessionId | undefined, + ownerAgentKind, + ownerProfileId, + q, + groupByTurn, + }) + : undefined; // When grouping, `traces.length === limit` is no longer a reliable // "has more" signal (a single turn can yield many traces). Use the // total count instead to detect a next page. - const nextOffset = groupByTurn - ? offset + limit < total ? offset + limit : undefined - : traces.length === limit ? offset + limit : undefined; + const nextOffset = includeTotal + ? groupByTurn + ? offset + limit < (total ?? 0) ? offset + limit : undefined + : traces.length === limit ? offset + limit : undefined + : hasMore ? offset + limit : undefined; return { traces, limit, @@ -158,7 +163,7 @@ export function registerTraceRoutes(routes: Routes, deps: ServerDeps): void { return; } const body = parseJson<{ - scope?: "private" | "local" | "public" | "hub" | null; + scope?: "private" | "public" | "hub" | null; target?: string | null; }>(ctx); const scope = body.scope === undefined ? "public" : body.scope; @@ -180,11 +185,39 @@ export function registerTraceRoutes(routes: Routes, deps: ServerDeps): void { writeError(ctx, 400, "invalid_argument", "id is required"); return; } - const traces = await deps.core.timeline({ episodeId: id, includeAllNamespaces: true }); + const traces = await deps.core.timeline({ episodeId: id }); return { episodeId: id, traces }; }); } +function trimTracePage( + traces: TraceDTO[], + limit: number, + groupByTurn: boolean, +): { traces: TraceDTO[]; hasMore: boolean } { + if (!groupByTurn) { + return { + traces: traces.slice(0, limit), + hasMore: traces.length > limit, + }; + } + const turnOrder = new Map(); + const kept: TraceDTO[] = []; + for (const trace of traces) { + const key = `${trace.episodeId ?? "_"}:${trace.turnId}`; + let index = turnOrder.get(key); + if (index === undefined) { + index = turnOrder.size; + turnOrder.set(key, index); + } + if (index < limit) kept.push(trace); + } + return { + traces: kept, + hasMore: turnOrder.size > limit, + }; +} + function parseNamespace(value: string | null): { ownerAgentKind: string; ownerProfileId: string } | null { if (!value) return null; const [ownerAgentKind, ownerProfileId] = value.split("/", 2).map((part) => part.trim()); diff --git a/apps/memos-local-plugin/templates/config.hermes.yaml b/apps/memos-local-plugin/templates/config.hermes.yaml index 10420f551..191580f85 100644 --- a/apps/memos-local-plugin/templates/config.hermes.yaml +++ b/apps/memos-local-plugin/templates/config.hermes.yaml @@ -6,7 +6,7 @@ # log rotation, retrieval budgets, etc.) see docs/CONFIG-ADVANCED.md in # the source repo; any field there can be added here and will take effect. # -# Sensitive fields (apiKey, teamToken, userToken) live HERE — there is no +# Sensitive fields (apiKey, teamToken) live HERE — there is no # .env file. install.sh sets this file's mode to 600. version: 1 @@ -27,11 +27,14 @@ llm: apiKey: "" # REQUIRED — fill in before running model: "" # blank = provider default (e.g. gpt-4o-mini for openai_compatible) +algorithm: + lightweightMemory: + enabled: true # true = low-cost summaries only; false = memory self-evolution with tasks/experiences/world models/skills + hub: enabled: false address: "" teamToken: "" - userToken: "" telemetry: enabled: true diff --git a/apps/memos-local-plugin/templates/config.openclaw.yaml b/apps/memos-local-plugin/templates/config.openclaw.yaml index 2ad0fea01..e263dd3c2 100644 --- a/apps/memos-local-plugin/templates/config.openclaw.yaml +++ b/apps/memos-local-plugin/templates/config.openclaw.yaml @@ -6,7 +6,7 @@ # log rotation, retrieval budgets, etc.) see docs/CONFIG-ADVANCED.md in # the source repo; any field there can be added here and will take effect. # -# Sensitive fields (apiKey, teamToken, userToken) live HERE — there is no +# Sensitive fields (apiKey, teamToken) live HERE — there is no # .env file. install.sh sets this file's mode to 600. version: 1 @@ -26,11 +26,14 @@ llm: apiKey: "" # required except for provider=host | local_only model: "" # blank = let the provider pick its default +algorithm: + lightweightMemory: + enabled: true # true = low-cost summaries only; false = memory self-evolution with tasks/experiences/world models/skills + hub: enabled: false address: "" # e.g. http://10.0.0.12:18912 (required when enabled=true and role=client) teamToken: "" - userToken: "" telemetry: enabled: true # anonymous usage events; opt-out any time diff --git a/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts b/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts index 3ff5dd78e..6ec654173 100644 --- a/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts +++ b/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts @@ -343,6 +343,7 @@ function buildPipeline(db: TmpDbHandle, llm: LlmClient): PipelineHandle { embedding: { ...DEFAULT_CONFIG.embedding, dimensions: DIMS }, algorithm: { ...DEFAULT_CONFIG.algorithm, + lightweightMemory: { enabled: false }, // Disable the 30 s fallback timer — we'll call the reward // runner synchronously at the end of the test so tests stay // deterministic. @@ -445,6 +446,25 @@ class OpenClawSimulator { ); tick(2_000); } + + async close(): Promise { + const sessionId = this.agentCtx.sessionId as string; + await this.bridge.handleSessionEnd( + { + sessionId, + sessionKey: this.sessionKey, + messageCount: this.messages.length, + durationMs: 1_000, + reason: "idle", + }, + { + agentId: "main", + sessionId, + sessionKey: this.sessionKey, + }, + ); + tick(1_000); + } } // ─── Test ──────────────────────────────────────────────────────────────── @@ -499,6 +519,7 @@ describe("OpenClaw adapter integration — multi-session full V7 chain", () => { "太好了, 再加一个 unittest 测试, 覆盖前 10 项", '```python\nimport unittest\n\nclass FibTest(unittest.TestCase):\n def test_small(self):\n self.assertEqual([fib(i) for i in range(10)], [0,1,1,2,3,5,8,13,21,34])\n```', ); + await s1.close(); // Session 2 — quicksort const s2 = new OpenClawSimulator({ bridge, sessionKey: "s2-sort" }); @@ -510,6 +531,7 @@ describe("OpenClaw adapter integration — multi-session full V7 chain", () => { "好的, 再写个 pytest 测试", '```python\nimport pytest\n\ndef test_quicksort_small():\n assert quicksort([3,1,4,1,5,9,2,6]) == [1,1,2,3,4,5,6,9]\n```', ); + await s2.close(); // Session 3 — binary search + lru cache const s3 = new OpenClawSimulator({ bridge, sessionKey: "s3-misc" }); @@ -521,6 +543,7 @@ describe("OpenClaw adapter integration — multi-session full V7 chain", () => { "再写个 lru_cache 装饰器示例", '```python\nfrom functools import lru_cache\n\n@lru_cache(maxsize=128)\ndef get_expensive(k: str) -> int:\n """Memoised expensive call."""\n return hash(k) % 1000\n\nprint(get_expensive("hello"))\n```', ); + await s3.close(); // Drain the async capture pipeline first. await pipeline!.flush(); diff --git a/apps/memos-local-plugin/tests/python/test_bridge_client.py b/apps/memos-local-plugin/tests/python/test_bridge_client.py index 5bf797ef0..b47d5660e 100644 --- a/apps/memos-local-plugin/tests/python/test_bridge_client.py +++ b/apps/memos-local-plugin/tests/python/test_bridge_client.py @@ -10,10 +10,13 @@ from __future__ import annotations +import contextlib import io import json import sys +import tempfile import threading +import time import unittest from pathlib import Path @@ -27,6 +30,7 @@ sys.path.insert(0, str(_p)) import bridge_client as bridge_client_mod # noqa: E402 +import daemon_manager as daemon_manager_mod # noqa: E402 from bridge_client import BridgeError, MemosBridgeClient # noqa: E402 @@ -39,6 +43,8 @@ class FakePopen: """ def __init__(self, *_args, **_kwargs) -> None: + self.cmd = list(_args[0]) if _args else [] + self.pid = 12345 self.stdin = io.StringIO() self._stdin_lines: list[str] = [] self.stdout = _ServerStream() @@ -271,7 +277,7 @@ def test_request_surfaces_error_on_rpc_error(self) -> None: self.assertIn("boom", ctx.exception.message) client.close() - def test_memory_search_roundtrip(self) -> None: + def test_memos_search_roundtrip(self) -> None: client = MemosBridgeClient(bridge_path="/tmp/bridge.cts") res = client.request("memory.search", {"query": "yesterday"}) self.assertEqual(len(res["hits"]), 1) @@ -289,6 +295,54 @@ def test_close_is_idempotent(self) -> None: client.close() client.close() # second call must not raise + def test_stdio_bridge_starts_without_viewer_by_default(self) -> None: + client = MemosBridgeClient(bridge_path="/tmp/bridge.cts") + assert self._fake is not None + cmd = getattr(self._fake, "cmd", []) + self.assertIn("--no-viewer", cmd) + client.close() + + def test_reverse_request_waits_for_late_host_handler_registration(self) -> None: + client = MemosBridgeClient(bridge_path="/tmp/bridge.cts") + assert self._fake is not None + + self._fake.stdout._enqueue( + { + "jsonrpc": "2.0", + "id": "srv-1", + "method": "host.llm.complete", + "params": {"messages": [{"role": "user", "content": "ping"}]}, + } + ) + time.sleep(0.1) + + client.register_host_handler( + "host.llm.complete", + lambda params: { + "text": f"host:{params['messages'][-1]['content']}", + "model": "host-test", + }, + ) + + response = self._wait_for_client_write(lambda msg: msg.get("id") == "srv-1") + self.assertEqual(response["result"]["text"], "host:ping") + self.assertNotIn("error", response) + client.close() + + def _wait_for_client_write(self, predicate, timeout: float = 2.0) -> dict: + assert self._fake is not None + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for raw in self._fake._stdin_lines: + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + if predicate(msg): + return msg + time.sleep(0.01) + self.fail("timed out waiting for client write") + class MemTensorProviderTests(unittest.TestCase): """Exercise `MemTensorProvider` against a mocked bridge.""" @@ -302,6 +356,7 @@ def setUp(self) -> None: self._patches = [ patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), ] for p in self._patches: p.start() @@ -325,19 +380,19 @@ def test_get_tool_schemas_lists_memory_tools(self) -> None: self.assertSetEqual( names, { - "memory_search", - "memory_get", - "memory_timeline", - "skill_list", - "memory_environment", - "skill_get", + "memos_search", + "memos_get", + "memos_timeline", + "memos_skill_list", + "memos_environment", + "memos_skill_get", }, ) def test_handle_tool_call_fails_gracefully_without_bridge(self) -> None: p = self._provider_mod.MemTensorProvider() # bridge is None — should not crash, returns error JSON - res = p.handle_tool_call("memory_search", {"query": "x"}) + res = p.handle_tool_call("memos_search", {"query": "x"}) parsed = json.loads(res) self.assertIn("error", parsed) @@ -350,7 +405,7 @@ def test_handle_tool_call_routes_all_exposed_tools(self) -> None: search = json.loads( p.handle_tool_call( - "memory_search", + "memos_search", {"query": "HERMES_MEMOS_E2E_0428", "maxResults": 7, "sessionScope": True}, ) ) @@ -359,48 +414,50 @@ def test_handle_tool_call_routes_all_exposed_tools(self) -> None: self.assertEqual(bridge.calls[-1][1]["sessionId"], "hermes:session:1") self.assertEqual(bridge.calls[-1][1]["topK"]["tier1"], 7) - got_trace = json.loads(p.handle_tool_call("memory_get", {"id": "tr-1"})) + got_trace = json.loads(p.handle_tool_call("memos_get", {"id": "tr-1"})) self.assertTrue(got_trace["found"]) self.assertEqual(got_trace["kind"], "trace") self.assertIn("HERMES_MEMOS_E2E_0428", got_trace["meta"]["userText"]) self.assertEqual(bridge.calls[-1][0], "memory.get_trace") - got_policy = json.loads(p.handle_tool_call("memory_get", {"id": "p-1", "kind": "policy"})) + got_policy = json.loads(p.handle_tool_call("memos_get", {"id": "p-1", "kind": "policy"})) self.assertEqual(got_policy["kind"], "policy") self.assertIn("Hermes validation", got_policy["body"]) self.assertEqual(bridge.calls[-1][0], "memory.get_policy") got_world = json.loads( - p.handle_tool_call("memory_get", {"id": "wm-1", "kind": "world_model"}) + p.handle_tool_call("memos_get", {"id": "wm-1", "kind": "world_model"}) ) self.assertEqual(got_world["kind"], "world_model") self.assertEqual(got_world["meta"]["policyIds"], ["p-1"]) self.assertEqual(bridge.calls[-1][0], "memory.get_world") - timeline = json.loads(p.handle_tool_call("memory_timeline", {"episodeId": "ep-1"})) + timeline = json.loads(p.handle_tool_call("memos_timeline", {"episodeId": "ep-1"})) self.assertEqual(len(timeline["traces"]), 2) self.assertEqual(bridge.calls[-1][0], "memory.timeline") - skills = json.loads(p.handle_tool_call("skill_list", {"status": "active", "limit": 3})) + skills = json.loads( + p.handle_tool_call("memos_skill_list", {"status": "active", "limit": 3}) + ) self.assertEqual(skills["skills"][0]["id"], "sk-1") self.assertEqual(bridge.calls[-1][0], "skill.list") self.assertEqual(bridge.calls[-1][1]["limit"], 3) self.assertEqual(bridge.calls[-1][1]["status"], "active") self.assertEqual(bridge.calls[-1][1]["namespace"]["agentKind"], "hermes") - env = json.loads(p.handle_tool_call("memory_environment", {"limit": 2})) + env = json.loads(p.handle_tool_call("memos_environment", {"limit": 2})) self.assertFalse(env["queried"]) self.assertEqual(env["worldModels"][0]["id"], "wm-1") self.assertEqual(bridge.calls[-1][0], "memory.list_world_models") env_query = json.loads( - p.handle_tool_call("memory_environment", {"query": "Hermes install", "limit": 2}) + p.handle_tool_call("memos_environment", {"query": "Hermes install", "limit": 2}) ) self.assertTrue(env_query["queried"]) self.assertEqual(bridge.calls[-1][0], "memory.search") self.assertEqual(bridge.calls[-1][1]["topK"], {"tier1": 0, "tier2": 0, "tier3": 2}) - skill = json.loads(p.handle_tool_call("skill_get", {"id": "sk-1"})) + skill = json.loads(p.handle_tool_call("memos_skill_get", {"id": "sk-1"})) self.assertTrue(skill["found"]) self.assertEqual(skill["skill"]["id"], "sk-1") self.assertEqual(bridge.calls[-1][0], "skill.get") @@ -411,13 +468,13 @@ def test_handle_tool_call_validates_required_arguments(self) -> None: p = self._provider_mod.MemTensorProvider() p._bridge = RecordingBridge() - self.assertIn("missing query", p.handle_tool_call("memory_search", {})) - self.assertIn("missing id", p.handle_tool_call("memory_get", {})) + self.assertIn("missing query", p.handle_tool_call("memos_search", {})) + self.assertIn("missing id", p.handle_tool_call("memos_get", {})) self.assertIn( "unknown memory kind", - p.handle_tool_call("memory_get", {"id": "x", "kind": "bad"}), + p.handle_tool_call("memos_get", {"id": "x", "kind": "bad"}), ) - self.assertIn("missing id", p.handle_tool_call("skill_get", {})) + self.assertIn("missing id", p.handle_tool_call("memos_skill_get", {})) self.assertIn("unknown tool", p.handle_tool_call("not_a_tool", {})) def test_prefetch_lazily_reconnects_when_bridge_is_missing(self) -> None: @@ -601,5 +658,90 @@ def test_save_config_writes_yaml_with_correct_mode(self) -> None: self.assertEqual(loaded["llm"]["provider"], "openai_compatible") +class ViewerDaemonTests(unittest.TestCase): + def tearDown(self) -> None: + daemon_manager_mod._viewer_status = None + daemon_manager_mod._viewer_last_probe_at = 0.0 + daemon_manager_mod._viewer_process = None + + def test_existing_memos_viewer_is_reused(self) -> None: + with ( + patch.object(daemon_manager_mod, "_probe_viewer", return_value="running_memos"), + patch.object(daemon_manager_mod.subprocess, "Popen") as popen, + ): + self.assertTrue(daemon_manager_mod.ensure_viewer_daemon()) + popen.assert_not_called() + + def test_non_memos_port_occupant_blocks_daemon_start(self) -> None: + with ( + patch.object(daemon_manager_mod, "_probe_viewer", return_value="blocked"), + patch.object(daemon_manager_mod.subprocess, "Popen") as popen, + ): + self.assertFalse(daemon_manager_mod.ensure_viewer_daemon()) + popen.assert_not_called() + + def test_free_port_starts_daemon_once(self) -> None: + class FakeDaemon: + returncode = None + + def poll(self): + return None + + with tempfile.TemporaryDirectory() as tmp: + bridge_path = Path(tmp) / "bridge.cts" + bridge_path.write_text("", encoding="utf-8") + with ( + patch.object( + daemon_manager_mod, + "_probe_viewer", + side_effect=["free", "free", "running_memos"], + ), + patch.object(daemon_manager_mod, "_bridge_script", return_value=bridge_path), + patch.object(daemon_manager_mod, "ensure_bridge_running", return_value=True), + patch.object( + daemon_manager_mod, + "_bridge_command", + return_value=["node", "bridge.cts", "--agent=hermes", "--daemon"], + ), + patch.object( + daemon_manager_mod.subprocess, + "Popen", + return_value=FakeDaemon(), + ) as popen, + ): + self.assertTrue(daemon_manager_mod.ensure_viewer_daemon()) + popen.assert_called_once() + + def test_start_lock_reprobes_before_spawning_daemon(self) -> None: + @contextlib.contextmanager + def acquired_lock(): + yield True + + with ( + patch.object( + daemon_manager_mod, + "_probe_viewer", + side_effect=["free", "running_memos"], + ), + patch.object(daemon_manager_mod, "_viewer_start_lock", acquired_lock), + patch.object(daemon_manager_mod.subprocess, "Popen") as popen, + ): + self.assertTrue(daemon_manager_mod.ensure_viewer_daemon()) + popen.assert_not_called() + + def test_start_lock_timeout_does_not_spawn_daemon(self) -> None: + @contextlib.contextmanager + def busy_lock(): + yield False + + with ( + patch.object(daemon_manager_mod, "_probe_viewer", side_effect=["free", "free"]), + patch.object(daemon_manager_mod, "_viewer_start_lock", busy_lock), + patch.object(daemon_manager_mod.subprocess, "Popen") as popen, + ): + self.assertFalse(daemon_manager_mod.ensure_viewer_daemon()) + popen.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py b/apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py index 6f114efd4..149cea586 100644 --- a/apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py +++ b/apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py @@ -72,6 +72,7 @@ def test_lifecycle_persists_turn_and_closes_real_episode(self) -> None: bridge = FakeBridge() with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", return_value=bridge), ): provider = memos_provider.MemTensorProvider() @@ -132,6 +133,7 @@ def bridge_factory() -> FakeBridge: with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", side_effect=bridge_factory), ): provider = memos_provider.MemTensorProvider() @@ -161,6 +163,7 @@ def test_delegation_recovers_when_initial_bridge_open_timed_out(self) -> None: with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", side_effect=lambda: bridge_attempts.pop(0)), ): provider = memos_provider.MemTensorProvider() @@ -185,6 +188,7 @@ def test_sync_turn_lazily_starts_turn_when_prefetch_was_skipped(self) -> None: bridge = FakeBridge() with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", return_value=bridge), ): provider = memos_provider.MemTensorProvider() @@ -209,6 +213,7 @@ def test_internal_hermes_review_prompt_is_not_persisted_as_user_turn(self) -> No ) with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", return_value=bridge), ): provider = memos_provider.MemTensorProvider() @@ -217,7 +222,7 @@ def test_internal_hermes_review_prompt_is_not_persisted_as_user_turn(self) -> No provider.on_turn_start(10, review_prompt) self.assertEqual(provider.prefetch(review_prompt), "") provider._on_post_tool_call( - tool_name="memory_search", + tool_name="memos_search", args={"query": "conversation"}, result="[]", tool_call_id="tool-1", @@ -234,6 +239,7 @@ def test_on_pre_compress_reuses_last_user_text_for_snapshot(self) -> None: bridge = FakeBridge() with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", return_value=bridge), ): provider = memos_provider.MemTensorProvider() @@ -251,6 +257,7 @@ def test_prefetch_suppresses_memory_injection_for_explicit_delegation(self) -> N bridge = FakeBridge() with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", return_value=bridge), ): provider = memos_provider.MemTensorProvider() @@ -268,6 +275,7 @@ def test_tool_hook_ignores_other_sessions(self) -> None: bridge = FakeBridge() with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", return_value=bridge), ): provider = memos_provider.MemTensorProvider() @@ -298,6 +306,7 @@ def test_on_delegation_targets_parent_episode(self) -> None: bridge = FakeBridge() with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", return_value=bridge), ): provider = memos_provider.MemTensorProvider() @@ -351,6 +360,7 @@ def test_on_delegation_backfills_child_session_tool_calls(self) -> None: ) with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", return_value=bridge), ): provider = memos_provider.MemTensorProvider() @@ -373,6 +383,7 @@ def test_post_llm_call_backfills_tool_calls_without_post_tool_hook(self) -> None bridge = FakeBridge() with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", return_value=bridge), ): provider = memos_provider.MemTensorProvider() @@ -417,6 +428,7 @@ def test_post_tool_call_merges_with_llm_tool_aliases(self) -> None: bridge = FakeBridge() with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", return_value=bridge), ): provider = memos_provider.MemTensorProvider() @@ -462,6 +474,7 @@ def test_post_llm_call_preserves_visible_text_before_tool_call(self) -> None: bridge = FakeBridge() with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", return_value=bridge), ): provider = memos_provider.MemTensorProvider() @@ -500,10 +513,74 @@ def test_post_llm_call_preserves_visible_text_before_tool_call(self) -> None: "好的,这是经典的 Kaggle 房价预测数据集。先创建计划。", ) + def test_transform_tool_result_appends_memos_search_hint_after_three_failures(self) -> None: + provider = memos_provider.MemTensorProvider() + provider.on_turn_start(1, "run failing command") + + self.assertIsNone( + provider._on_transform_tool_result( + tool_name="terminal", + result="boom", + is_error=True, + ) + ) + self.assertIsNone( + provider._on_transform_tool_result( + tool_name="terminal", + result="boom again", + is_error=True, + ) + ) + third = provider._on_transform_tool_result( + tool_name="terminal", + result="boom third", + is_error=True, + ) + self.assertIsNotNone(third) + self.assertIn("failed multiple times in a row", third or "") + self.assertIn("memos_search", third or "") + + provider._on_transform_tool_result( + tool_name="terminal", + result="ok", + is_error=False, + ) + self.assertIsNone( + provider._on_transform_tool_result( + tool_name="terminal", + result="boom after reset", + is_error=True, + ) + ) + + def test_transform_tool_result_detects_plain_error_text(self) -> None: + provider = memos_provider.MemTensorProvider() + provider.on_turn_start(1, "run failing command") + + self.assertIsNone( + provider._on_transform_tool_result( + tool_name="terminal", + result="Error: command failed", + ) + ) + self.assertIsNone( + provider._on_transform_tool_result( + tool_name="terminal", + result="Error: command failed again", + ) + ) + third = provider._on_transform_tool_result( + tool_name="terminal", + result="Error: command failed third time", + ) + self.assertIsNotNone(third) + self.assertIn("memos_search", third or "") + def test_post_llm_call_orders_backfilled_tools_before_later_tool_results(self) -> None: bridge = FakeBridge() with ( patch("memos_provider.ensure_bridge_running", return_value=True), + patch("memos_provider.ensure_viewer_daemon", return_value=True), patch("memos_provider.MemosBridgeClient", return_value=bridge), ): provider = memos_provider.MemTensorProvider() diff --git a/apps/memos-local-plugin/tests/unit/adapters/hermes-persistence.test.ts b/apps/memos-local-plugin/tests/unit/adapters/hermes-persistence.test.ts index dc93b1062..5217c0b67 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/hermes-persistence.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/hermes-persistence.test.ts @@ -174,7 +174,7 @@ describe("Hermes MemoryCore persistence", () => { agentText: "已记录 Hermes MemOS 测试事实。", toolCalls: [ { - name: "memory_search", + name: "memos_search", input: "{\"query\":\"HERMES_MEMOS_E2E_0428\"}", output: "[]", startedAt: 1_700_000_000_002, @@ -209,7 +209,7 @@ describe("Hermes MemoryCore persistence", () => { expect(timeline.some((trace) => trace.agentText.includes("已记录 Hermes MemOS 测试事实"))).toBe( true, ); - expect(timeline.some((trace) => trace.toolCalls.some((tc) => tc.name === "memory_search"))) + expect(timeline.some((trace) => trace.toolCalls.some((tc) => tc.name === "memos_search"))) .toBe(true); const search = await second.core.searchMemory({ @@ -221,7 +221,7 @@ describe("Hermes MemoryCore persistence", () => { const traceIds = new Set(traces.map((trace) => trace.id)); expect(search.hits.some((hit) => traceIds.has(hit.refId))).toBe(true); - const logs = await second.core.listApiLogs({ toolName: "memory_search", limit: 20 }); + const logs = await second.core.listApiLogs({ toolName: "memos_search", limit: 20 }); expect(logs.total).toBeGreaterThan(0); }); }); diff --git a/apps/memos-local-plugin/tests/unit/adapters/hermes-protocol.test.ts b/apps/memos-local-plugin/tests/unit/adapters/hermes-protocol.test.ts index 310411a1d..c8c3fc093 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/hermes-protocol.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/hermes-protocol.test.ts @@ -133,7 +133,7 @@ describe("hermes protocol surface", () => { ); }); - it("memory_search tool: memory.search routes agent, session and topK to searchMemory", async () => { + it("memos_search tool: memory.search routes agent, session and topK to searchMemory", async () => { const core = stubCore(); const dispatch = makeDispatcher(core); @@ -153,7 +153,7 @@ describe("hermes protocol surface", () => { ); }); - it("memory_timeline tool: memory.timeline routes to timeline", async () => { + it("memos_timeline tool: memory.timeline routes to timeline", async () => { const core = stubCore(); const dispatch = makeDispatcher(core); diff --git a/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts b/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts index d6651dcd1..19bda3bee 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts @@ -775,7 +775,7 @@ describe("renderContextBlock", () => { tierLatencyMs: { tier1: 0, tier2: 0, tier3: 0 }, }); expect(block).toContain(""); - expect(block).toContain("memory_search"); + expect(block).toContain("memos_search"); expect(block).toContain(""); }); }); @@ -1301,6 +1301,101 @@ describe("createOpenClawBridge", () => { expect(bridge.trackedToolCalls()).toBe(0); }); + it("tool_result_persist appends memos_search hint after three same-tool failures", async () => { + const mc = buildCore(); + await mc.init(); + + const bridge = createOpenClawBridge({ + agent: "openclaw", + core: mc, + log: silentLogger(), + }); + const ctx: PluginHookToolContext = { + toolName: "sh", + toolCallId: "call_1", + agentId: "main", + sessionKey: "s-tools", + sessionId: "host-s-tools", + runId: "run-tools", + }; + + const fail = (toolCallId: string) => + bridge.handleToolResultPersist( + { + toolName: "sh", + toolCallId, + message: { + role: "toolResult", + toolName: "sh", + toolCallId, + content: "boom", + isError: true, + }, + }, + { ...ctx, toolCallId }, + ); + + expect(fail("call_1")).toBeUndefined(); + expect(fail("call_2")).toBeUndefined(); + const third = fail("call_3") as { message?: { content?: string } }; + expect(third.message?.content).toContain("failed multiple times in a row"); + expect(third.message?.content).toContain("memos_search"); + + bridge.handleToolResultPersist( + { + toolName: "sh", + toolCallId: "call_4", + message: { + role: "toolResult", + toolName: "sh", + toolCallId: "call_4", + content: "ok", + isError: false, + }, + }, + { ...ctx, toolCallId: "call_4" }, + ); + expect(fail("call_5")).toBeUndefined(); + }); + + it("tool_result_persist appends hint to the final text block", async () => { + const mc = buildCore(); + await mc.init(); + + const bridge = createOpenClawBridge({ + agent: "openclaw", + core: mc, + log: silentLogger(), + }); + const ctx: PluginHookToolContext = { + toolName: "read", + agentId: "main", + sessionKey: "s-tools", + sessionId: "host-s-tools", + runId: "run-array", + }; + const failure = { + toolName: "read", + message: { + role: "toolResult", + content: [ + { type: "text", text: "first part" }, + { type: "text", text: "last part" }, + ], + isError: true, + }, + }; + + bridge.handleToolResultPersist(failure, ctx); + bridge.handleToolResultPersist(failure, ctx); + const third = bridge.handleToolResultPersist(failure, ctx) as { + message?: { content?: Array<{ type: string; text?: string }> }; + }; + expect(third.message?.content?.[0]?.text).toBe("first part"); + expect(third.message?.content?.[1]?.text).toContain("last part"); + expect(third.message?.content?.[1]?.text).toContain("memos_search"); + }); + it("subagent_ended does not create a synthetic parent task", async () => { const mc = buildCore(); await mc.init(); @@ -1509,12 +1604,12 @@ describe("registerOpenClawTools", () => { }); const names = tools.map((t) => t.descriptor.name).sort(); expect(names).toEqual([ - "memory_environment", - "memory_get", - "memory_search", - "memory_timeline", - "skill_get", - "skill_list", + "memos_environment", + "memos_get", + "memos_search", + "memos_skill_get", + "memos_skill_list", + "memos_timeline", ]); for (const t of tools) { expect(typeof t.descriptor.execute).toBe("function"); @@ -1522,7 +1617,7 @@ describe("registerOpenClawTools", () => { } }); - it("memory_search executes against the core and returns well-formed hits", async () => { + it("memos_search executes against the core and returns well-formed hits", async () => { const mc = buildCore(); await mc.init(); @@ -1532,7 +1627,7 @@ describe("registerOpenClawTools", () => { core: mc, log: silentLogger(), }); - const search = tools.find((t) => t.descriptor.name === "memory_search")!; + const search = tools.find((t) => t.descriptor.name === "memos_search")!; const res = (await search.descriptor.execute("toolCall_1", { query: "anything", maxResults: 5, @@ -1552,7 +1647,7 @@ describe("registerOpenClawTools", () => { expect(res.details.hits).toBe(res.hits); }); - it("memory_search maps per-tier topK params and keeps maxResults fallback", async () => { + it("memos_search maps per-tier topK params and keeps maxResults fallback", async () => { const searchMemory = vi.fn(async () => ({ hits: [], injectedContext: "", @@ -1566,7 +1661,7 @@ describe("registerOpenClawTools", () => { core: mc, log: silentLogger(), }); - const search = tools.find((t) => t.descriptor.name === "memory_search")!; + const search = tools.find((t) => t.descriptor.name === "memos_search")!; await search.descriptor.execute("toolCall_1", { query: "anything", @@ -1608,10 +1703,10 @@ describe("registerOpenClawTools", () => { log: silentLogger(), }); - expect(tools.map((t) => t.descriptor.name)).toContain("memory_search"); + expect(tools.map((t) => t.descriptor.name)).toContain("memos_search"); expect(requestedCore).toBe(false); - const search = tools.find((t) => t.descriptor.name === "memory_search")!; + const search = tools.find((t) => t.descriptor.name === "memos_search")!; await search.descriptor.execute("toolCall_1", { query: "anything", maxResults: 5, diff --git a/apps/memos-local-plugin/tests/unit/adapters/openclaw-e2e.test.ts b/apps/memos-local-plugin/tests/unit/adapters/openclaw-e2e.test.ts index 25f8e47d0..0bd8812bd 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/openclaw-e2e.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/openclaw-e2e.test.ts @@ -1,6 +1,6 @@ /** * End-to-end integration of the OpenClaw adapter, exercising the full - * memory_add → memory_search → injection round trip. + * memory_add → memos_search → injection round trip. * * The intent is to fail loudly whenever any of the following silently * regress (which they did, twice, in earlier iterations): @@ -279,11 +279,11 @@ describe("OpenClaw adapter — end-to-end memory chain", () => { expect(block).toContain(""); expect(block).toContain(""); // It must contain *some* real content — either an actual hit - // ("游泳") or the cold-start hint mentioning `memory_search`. The + // ("游泳") or the cold-start hint mentioning `memos_search`. The // failing regression we test against was emitting only metadata // labels (e.g. `[trace] trace · V=0.09`) with no body. const hasUserSwimText = block.includes("游泳"); - const hasReadableHint = block.includes("memory_search") || block.includes("conversation history"); + const hasReadableHint = block.includes("memos_search") || block.includes("conversation history"); expect(hasUserSwimText || hasReadableHint).toBe(true); // Negative assertion — the regression-style metadata-only line // would look like `[trace] trace · V=` but never carry text. @@ -299,7 +299,7 @@ describe("OpenClaw adapter — end-to-end memory chain", () => { } }); - it("memory_search via MemoryCore returns hits readable by the viewer", async () => { + it("memos_search via MemoryCore returns hits readable by the viewer", async () => { // The viewer hits `/api/v1/memory/search` which proxies to // `MemoryCore.searchMemory`. Verify the path returns hits that // include the actual snippet text (not just refIds). diff --git a/apps/memos-local-plugin/tests/unit/agent-contract/contract.test.ts b/apps/memos-local-plugin/tests/unit/agent-contract/contract.test.ts index 9b250dbc0..f1f97cccf 100644 --- a/apps/memos-local-plugin/tests/unit/agent-contract/contract.test.ts +++ b/apps/memos-local-plugin/tests/unit/agent-contract/contract.test.ts @@ -70,7 +70,7 @@ describe("agent-contract", () => { const toolDriven: ToolDrivenCtx = { agent: "openclaw", sessionId: "s1", - tool: "memory_search", + tool: "memos_search", args: { q: "x" }, ts, }; @@ -100,7 +100,7 @@ describe("agent-contract", () => { }; expect(turnStart.userText).toBe("hi"); - expect(toolDriven.tool).toBe("memory_search"); + expect(toolDriven.tool).toBe("memos_search"); expect(repair.failureCount).toBe(3); expect(reasons.length).toBe(5); expect(packet.packetId).toBe("pkt_1"); diff --git a/apps/memos-local-plugin/tests/unit/capture/alpha-scorer.test.ts b/apps/memos-local-plugin/tests/unit/capture/alpha-scorer.test.ts index 1ed644b15..f035372ed 100644 --- a/apps/memos-local-plugin/tests/unit/capture/alpha-scorer.test.ts +++ b/apps/memos-local-plugin/tests/unit/capture/alpha-scorer.test.ts @@ -123,4 +123,29 @@ describe("capture/alpha-scorer", () => { await scoreReflection(llm, { step: step(), reflectionText: "r" }); expect(captured).toEqual([op]); }); + + it("injects downstream preview without breaking JSON scoring", async () => { + let userPrompt = ""; + const llm = fakeLlm({ + completeJson: { + [op]: (input) => { + const messages = input as Array<{ role: string; content: string }>; + userPrompt = messages.find((m) => m.role === "user")?.content ?? ""; + return { alpha: 0.5, usable: true, reason: "ok" }; + }, + }, + }); + const out = await scoreReflection(llm, { + step: step(), + reflectionText: "I checked the first fact before using the downstream evidence.", + downstream: [ + { offset: 1, kind: "text", text: "action: next step used the result" }, + { offset: 2, kind: "tooluse", toolNames: ["shell"], toolOutput: "ok" }, + ], + }); + expect(out.alpha).toBe(0.5); + expect(userPrompt).toContain("[step+1] type=text"); + expect(userPrompt).toContain("[step+2] type=tooluse"); + expect(userPrompt).toContain("tool_output: ok"); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/capture/capture-batch.test.ts b/apps/memos-local-plugin/tests/unit/capture/capture-batch.test.ts index 85879f92b..d86290517 100644 --- a/apps/memos-local-plugin/tests/unit/capture/capture-batch.test.ts +++ b/apps/memos-local-plugin/tests/unit/capture/capture-batch.test.ts @@ -82,6 +82,13 @@ function baseConfig(overrides: Partial = {}): CaptureConfig { llmConcurrency: 2, batchMode: "auto", batchThreshold: 12, + reflectionContextMode: "none", + longEpisodeReflectMode: "per_step_parallel", + downstreamStepCount: 3, + taskContextMaxChars: 800, + downstreamContextMaxChars: 1_200, + downstreamPerStepMaxChars: 400, + synthOutcomeMaxChars: 600, ...overrides, }; } @@ -340,6 +347,74 @@ describe("capture/pipeline (batched ρ+α path)", () => { expect(result.llmCalls.alphaScoring).toBe(3); }); + it("long per-step downstream mode injects up to three following steps", async () => { + const synthPrompts: string[] = []; + const alphaPrompts: string[] = []; + const llm = fakeLlm({ + complete: { + "capture.reflection.synth": (input) => { + const messages = input as Array<{ role: string; content: string }>; + synthPrompts.push(messages.find((m) => m.role === "user")?.content ?? ""); + return "I used this step because it shaped a following decision."; + }, + }, + completeJson: { + [alphaOp]: (input) => { + const messages = input as Array<{ role: string; content: string }>; + alphaPrompts.push(messages.find((m) => m.role === "user")?.content ?? ""); + return { alpha: 0.5, usable: true, reason: "ok" }; + }, + }, + }); + const runner = buildRunner( + { + batchMode: "auto", + batchThreshold: 2, + reflectionContextMode: "task_downstream", + longEpisodeReflectMode: "per_step_downstream", + downstreamStepCount: 3, + }, + llm, + ); + + const ep = episodeSnapshot({ + id: "ep_1", + sessionId: "se_1", + turns: [ + turn("user", "step zero", 1_000), + turn("assistant", "inspect first", 1_010), + turn("user", "step one", 1_020), + turn("assistant", "tool follows", 1_030, { + toolCalls: [{ name: "shell", input: { command: "pwd" }, output: "/tmp/project" }], + }), + turn("user", "step two", 1_050), + turn("assistant", "### Reasoning:\nI reused the tool result.\n\nnext action", 1_060), + turn("user", "step three", 1_070), + turn("assistant", "finish", 1_080), + ], + }); + + const result = await runCapture(runner, ep); + + expect(result.traceIds).toHaveLength(4); + expect(result.llmCalls.batchedReflection).toBe(0); + expect(result.llmCalls.reflectionSynth).toBe(3); + expect(result.llmCalls.alphaScoring).toBe(4); + + const firstPrompt = synthPrompts[0]!; + expect(firstPrompt).toContain("TASK CONTEXT:"); + expect(firstPrompt).toContain("[step+1] type=tooluse"); + expect(firstPrompt).toContain("tool_names: shell"); + expect(firstPrompt).toContain("tool_output: shell: /tmp/project"); + expect(firstPrompt).toContain("[step+2] type=text"); + expect(firstPrompt).toContain("[step+3] type=text"); + + const step3Prompt = alphaPrompts[2]!; + expect(step3Prompt).toContain("[step+1] type=text"); + expect(step3Prompt).not.toContain("[step+2]"); + expect(step3Prompt).not.toContain("[step+3]"); + }); + it("per_episode mode batches even when step count is large", async () => { const scores = Array.from({ length: 5 }, (_, i) => ({ idx: i, diff --git a/apps/memos-local-plugin/tests/unit/capture/capture.test.ts b/apps/memos-local-plugin/tests/unit/capture/capture.test.ts index a45a19550..95d728338 100644 --- a/apps/memos-local-plugin/tests/unit/capture/capture.test.ts +++ b/apps/memos-local-plugin/tests/unit/capture/capture.test.ts @@ -217,6 +217,50 @@ describe("capture/pipeline (end-to-end)", () => { }); } + it("lightweight capture merges one turn into one memory with summary-only embedding", async () => { + const llm = fakeLlm({ + completeJson: { + "capture.summarize": { summary: "looked up sales and reported final answer" }, + }, + }); + const embedder = fakeEmbedder({ dimensions: 8 }); + const runner = buildRunner({ alphaScoring: true, synthReflections: true }, llm, embedder); + const ep = episodeSnapshot({ + id: "ep_1", + sessionId: "se_1", + turns: [ + turn("user", "look up current sales", 1_000), + turn("tool", JSON.stringify({ total: 42 }), 1_100, { + tool: "db_query", + input: { sql: "select total from sales" }, + output: { total: 42 }, + startedAt: 1_050, + endedAt: 1_100, + }), + turn("assistant", "current sales are 42", 1_200), + ], + }); + + const result = await runner.runLightweight({ episode: ep }); + const rows = tmp.repos.traces.list({ episodeId: "ep_1" as EpisodeId }); + + expect(result.traceIds).toHaveLength(1); + expect(result.llmCalls.summarize).toBe(1); + expect(result.llmCalls.reflectionSynth).toBe(0); + expect(result.llmCalls.alphaScoring).toBe(0); + expect(embedder.stats().requests).toBe(1); + expect(rows).toHaveLength(1); + expect(rows[0]!.userText).toBe("look up current sales"); + expect(rows[0]!.agentText).toBe("current sales are 42"); + expect(rows[0]!.toolCalls).toHaveLength(1); + expect(rows[0]!.summary).toBe("looked up sales and reported final answer"); + expect(rows[0]!.tags).toContain("lightweight_memory"); + expect(rows[0]!.vecSummary).toBeInstanceOf(Float32Array); + expect(rows[0]!.vecAction).toBeNull(); + expect(tmp.repos.embeddingRetryQueue.countByStatus("pending")).toBe(0); + expect(seen.map((e) => e.kind)).toEqual(["capture.started", "capture.lite.done"]); + }); + it("writes one trace per step with α=0 when alpha disabled and no reflection present", async () => { const runner = buildRunner({ alphaScoring: false }); const ep = episodeSnapshot({ @@ -379,6 +423,7 @@ describe("capture/pipeline (end-to-end)", () => { const t = tmp.repos.traces.getById(result.traceIds[0]!)!; expect(t.reflection).toContain("shell tool"); expect(t.alpha).toBeCloseTo(0.8, 5); + expect(result.traces[0]?.reflection.reason).toBe("concrete"); }); it("clamps α to 0 when LLM marks reflection unusable", async () => { diff --git a/apps/memos-local-plugin/tests/unit/capture/embedder.test.ts b/apps/memos-local-plugin/tests/unit/capture/embedder.test.ts index e5b957497..5df2be9a5 100644 --- a/apps/memos-local-plugin/tests/unit/capture/embedder.test.ts +++ b/apps/memos-local-plugin/tests/unit/capture/embedder.test.ts @@ -71,6 +71,26 @@ describe("capture/embedder", () => { expect(e.stats().roundTrips).toBe(1); }); + it("summary-only mode embeds one vector per step and leaves action null", async () => { + const e = fakeEmbedder(); + const out = await embedSteps( + e, + [ + step({ userText: "a", agentText: "b" }), + step({ userText: "c", agentText: "d" }), + ], + ["summary a", "summary c"], + { summaryOnly: true }, + ); + expect(e.stats().requests).toBe(2); + expect(e.stats().roundTrips).toBe(1); + expect(out).toHaveLength(2); + expect(out[0]!.summary).toBeInstanceOf(Float32Array); + expect(out[0]!.action).toBeNull(); + expect(out[1]!.summary).toBeInstanceOf(Float32Array); + expect(out[1]!.action).toBeNull(); + }); + it("tool-call-only step still embeds", async () => { const e = fakeEmbedder(); const out = await embedSteps(e, [ diff --git a/apps/memos-local-plugin/tests/unit/capture/reflection-synth.test.ts b/apps/memos-local-plugin/tests/unit/capture/reflection-synth.test.ts index 7d7610d04..6e8bb910d 100644 --- a/apps/memos-local-plugin/tests/unit/capture/reflection-synth.test.ts +++ b/apps/memos-local-plugin/tests/unit/capture/reflection-synth.test.ts @@ -38,6 +38,71 @@ describe("capture/reflection-synth", () => { expect(out.model).toBe("openai_compatible"); }); + it("injects task context and last tool outcome into the prompt", async () => { + let userPrompt = ""; + const llm = fakeLlm({ + complete: { + "capture.reflection.synth": (input) => { + const messages = input as Array<{ role: string; content: string }>; + userPrompt = messages.find((m) => m.role === "user")?.content ?? ""; + return "I checked the working directory because the task needed the project path."; + }, + }, + }); + + await synthesizeReflection( + llm, + step({ + userText: "where am I?", + agentText: "checking pwd", + toolCalls: [{ name: "shell", input: { command: "pwd" }, output: "/tmp/project" }], + }), + { episodeId: "ep_1", phase: "reflect", taskSummary: "Task: inspect current project" }, + ); + + expect(userPrompt).toContain("TASK CONTEXT:"); + expect(userPrompt).toContain("Task: inspect current project"); + expect(userPrompt).toContain("OUTCOME:"); + expect(userPrompt).toContain("/tmp/project"); + }); + + it("injects downstream preview with explicit step offsets and types", async () => { + let userPrompt = ""; + const llm = fakeLlm({ + complete: { + "capture.reflection.synth": (input) => { + const messages = input as Array<{ role: string; content: string }>; + userPrompt = messages.find((m) => m.role === "user")?.content ?? ""; + return "I used the first check because the downstream output confirmed the path."; + }, + }, + }); + + await synthesizeReflection( + llm, + step({ userText: "inspect", agentText: "checking", toolCalls: [] }), + { + downstream: [ + { offset: 1, kind: "text", text: "state: user asked for tests\naction: searched test files" }, + { + offset: 2, + kind: "tooluse", + toolNames: ["shell"], + toolOutput: "tests passed", + reflection: "I ran tests to validate the change.", + }, + ], + }, + ); + + expect(userPrompt).toContain("DOWNSTREAM STEP PREVIEW:"); + expect(userPrompt).toContain("[step+1] type=text"); + expect(userPrompt).toContain("[step+2] type=tooluse"); + expect(userPrompt).toContain("tool_names: shell"); + expect(userPrompt).toContain("tool_output: tests passed"); + expect(userPrompt).toContain("existing_reflection: I ran tests"); + }); + it("returns null on the NO_REFLECTION sentinel", async () => { const llm = fakeLlm({ complete: { "capture.reflection.synth": "NO_REFLECTION" }, diff --git a/apps/memos-local-plugin/tests/unit/config/load.test.ts b/apps/memos-local-plugin/tests/unit/config/load.test.ts index 1aa4126fb..77f2f6b3f 100644 --- a/apps/memos-local-plugin/tests/unit/config/load.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/load.test.ts @@ -81,6 +81,16 @@ viewer: expect(cfg.algorithm.skill.minSupport).toBe(DEFAULT_CONFIG.algorithm.skill.minSupport); }); + it("defaults lightweight memory mode on and accepts explicit opt-out", () => { + const base = resolveConfig({}); + expect(base.algorithm.lightweightMemory.enabled).toBe(true); + + const cfg = resolveConfig({ + algorithm: { lightweightMemory: { enabled: false } }, + }); + expect(cfg.algorithm.lightweightMemory.enabled).toBe(false); + }); + it("does not expose embedding dimensions as user config", () => { const cfg = resolveConfig({ embedding: { diff --git a/apps/memos-local-plugin/tests/unit/config/writer.test.ts b/apps/memos-local-plugin/tests/unit/config/writer.test.ts index 5e1eb19de..fd89ea053 100644 --- a/apps/memos-local-plugin/tests/unit/config/writer.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/writer.test.ts @@ -65,6 +65,17 @@ llm: expect(reloaded.config.algorithm.skill.minSupport).toBe(7); }); + it("patches lightweight memory mode without disturbing other algorithm fields", async () => { + const ctx = await makeTmpHome({ agent: "openclaw" }); + cleanup = ctx.cleanup; + await patchConfig(ctx.home, { + algorithm: { lightweightMemory: { enabled: true } }, + }); + const reloaded = await loadConfig(ctx.home); + expect(reloaded.config.algorithm.lightweightMemory.enabled).toBe(true); + expect(reloaded.config.algorithm.skill.minSupport).toBeGreaterThan(0); + }); + /** * Regression: before commit , patching a nested map slot * whose existing value was a bare-null scalar (`skillEvolver:`), an diff --git a/apps/memos-local-plugin/tests/unit/feedback/_helpers.ts b/apps/memos-local-plugin/tests/unit/feedback/_helpers.ts index c318c10b4..252ebd376 100644 --- a/apps/memos-local-plugin/tests/unit/feedback/_helpers.ts +++ b/apps/memos-local-plugin/tests/unit/feedback/_helpers.ts @@ -34,6 +34,7 @@ export function makeFeedbackConfig( failureThreshold: 3, failureWindow: 5, valueDelta: 0.5, + minLowValueThreshold: 0.01, useLlm: true, attachToPolicy: true, cooldownMs: 60_000, diff --git a/apps/memos-local-plugin/tests/unit/feedback/evidence.test.ts b/apps/memos-local-plugin/tests/unit/feedback/evidence.test.ts index e0d5a8b1b..6fcc82505 100644 --- a/apps/memos-local-plugin/tests/unit/feedback/evidence.test.ts +++ b/apps/memos-local-plugin/tests/unit/feedback/evidence.test.ts @@ -220,4 +220,115 @@ describe("feedback/evidence", () => { expect(capped.userText.startsWith("...")).toBe(true); expect(capTrace(trace, 0)).toBe(trace); // no-op }); + + it("filters out trivial negative values below minLowValueThreshold", () => { + handle = makeTmpDb(); + const h = handle; + const sessionId = "s6"; + const episodeId = "ep6" as EpisodeId; + + // Trivial negative values (should be filtered out) + seedTrace(h, { + episodeId: episodeId as string, + sessionId, + agentText: "slightly not perfect", + value: -0.001, + }); + seedTrace(h, { + episodeId: episodeId as string, + sessionId, + agentText: "almost neutral", + value: -0.005, + }); + + // Genuine failure (should be collected) + seedTrace(h, { + episodeId: episodeId as string, + sessionId, + agentText: "real failure", + value: -0.2, + }); + + const res = gatherRepairEvidence( + { sessionId: sessionId as SessionId }, + { + repos: h.repos, + config: makeFeedbackConfig({ minLowValueThreshold: 0.01 }), + log: rootLogger.child({ channel: "test.evidence" }), + }, + ); + + // Only the genuine failure should be collected + expect(res.lowValue).toHaveLength(1); + expect(res.lowValue[0]!.value).toBe(-0.2); + }); + + it("collects traces with error keywords even if value is above threshold", () => { + handle = makeTmpDb(); + const h = handle; + const sessionId = "s7"; + const episodeId = "ep7" as EpisodeId; + + // Small negative value but has error keyword (should be collected) + seedTrace(h, { + episodeId: episodeId as string, + sessionId, + agentText: "error: connection timeout", + value: -0.005, + }); + + // Small negative value without error keyword (should be filtered) + seedTrace(h, { + episodeId: episodeId as string, + sessionId, + agentText: "task completed but user slightly unhappy", + value: -0.005, + }); + + const res = gatherRepairEvidence( + { sessionId: sessionId as SessionId }, + { + repos: h.repos, + config: makeFeedbackConfig({ minLowValueThreshold: 0.01 }), + log: rootLogger.child({ channel: "test.evidence" }), + }, + ); + + // Only the one with error keyword should be collected + expect(res.lowValue).toHaveLength(1); + expect(res.lowValue[0]!.agentText).toContain("error"); + }); + + it("respects custom minLowValueThreshold config", () => { + handle = makeTmpDb(); + const h = handle; + const sessionId = "s8"; + const episodeId = "ep8" as EpisodeId; + + seedTrace(h, { + episodeId: episodeId as string, + sessionId, + agentText: "minor issue", + value: -0.05, + }); + seedTrace(h, { + episodeId: episodeId as string, + sessionId, + agentText: "moderate failure", + value: -0.15, + }); + + // With threshold 0.1, only -0.15 should be collected + const res = gatherRepairEvidence( + { sessionId: sessionId as SessionId }, + { + repos: h.repos, + config: makeFeedbackConfig({ minLowValueThreshold: 0.1 }), + log: rootLogger.child({ channel: "test.evidence" }), + }, + ); + + expect(res.lowValue).toHaveLength(1); + expect(res.lowValue[0]!.value).toBe(-0.15); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/hub/runtime.test.ts b/apps/memos-local-plugin/tests/unit/hub/runtime.test.ts new file mode 100644 index 000000000..17ce82dc3 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/hub/runtime.test.ts @@ -0,0 +1,564 @@ +import net from "node:net"; + +import { afterEach, describe, expect, it } from "vitest"; + +import type { TraceDTO } from "../../../agent-contract/dto.js"; +import { DEFAULT_CONFIG, type ResolvedConfig } from "../../../core/config/index.js"; +import { HubClientRuntime } from "../../../core/hub/client.js"; +import { createHubRuntime } from "../../../core/hub/runtime.js"; +import { HubServerRuntime } from "../../../core/hub/server.js"; +import { rootLogger } from "../../../core/logger/index.js"; +import { HUB_SHARED_MEMORY_TOMBSTONE_TTL_MS } from "../../../core/storage/repos/hub.js"; +import { makeTmpDb, type TmpDbHandle } from "../../helpers/tmp-db.js"; + +describe("hub runtime", () => { + const handles: TmpDbHandle[] = []; + const servers: HubServerRuntime[] = []; + const clients: HubClientRuntime[] = []; + + afterEach(async () => { + await Promise.all(clients.map((c) => c.stop())); + await Promise.all(servers.map((s) => s.stop())); + while (handles.length) handles.pop()!.cleanup(); + clients.length = 0; + servers.length = 0; + }); + + it("accepts a client join request and activates it after approval", async () => { + const hubDb = makeTmpDb({ agent: "openclaw" }); + const clientDb = makeTmpDb({ agent: "hermes" }); + handles.push(hubDb, clientDb); + + const port = await freePort(); + const teamToken = "test-team-token"; + const hubConfig = configWithHub({ + enabled: true, + role: "hub", + port, + teamName: "Runtime Test", + teamToken, + }); + const hub = new HubServerRuntime({ + repo: hubDb.repos.hub, + config: hubConfig, + log: rootLogger.child({ channel: "test.hub.server" }), + version: "test", + }); + servers.push(hub); + const snapshot = await hub.start(); + + const clientConfig = configWithHub({ + enabled: true, + role: "client", + address: snapshot.url, + teamToken, + nickname: "alice", + }); + const client = new HubClientRuntime({ + repo: clientDb.repos.hub, + config: clientConfig, + log: rootLogger.child({ channel: "test.hub.client" }), + }); + clients.push(client); + + const pendingConnection = await client.start(); + expect(pendingConnection?.lastKnownStatus).toBe("pending"); + expect(pendingConnection?.userToken).toBe(""); + const pending = hubDb.repos.hub.listUsers("pending"); + expect(pending).toHaveLength(1); + expect(pending[0]!.username).toBe("alice"); + + const approved = hub.approveUser(pending[0]!.id); + expect(approved?.token).toBeTruthy(); + + const refreshed = await client.refreshStatus(); + expect(refreshed.connected).toBe(true); + expect(refreshed.user).toMatchObject({ id: pending[0]!.id, username: "alice", status: "active" }); + expect(clientDb.repos.hub.getClientConnection()?.lastKnownStatus).toBe("active"); + + const connected = await client.start(); + expect(connected?.userToken).toBeTruthy(); + expect(connected?.lastKnownStatus).toBe("active"); + + const me = await client.requestJson>("/api/v1/hub/me", { method: "GET" }); + expect(me).toMatchObject({ id: pending[0]!.id, username: "alice", status: "active" }); + }); + + it("falls back to team-token join when a legacy user token is rejected", async () => { + const hubDb = makeTmpDb({ agent: "hermes" }); + const clientDb = makeTmpDb({ agent: "openclaw" }); + handles.push(hubDb, clientDb); + + const port = await freePort(); + const teamToken = "fallback-team-token"; + const hub = new HubServerRuntime({ + repo: hubDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "hub", + port, + teamName: "Fallback Test", + teamToken, + }), + log: rootLogger.child({ channel: "test.hub.server" }), + version: "test", + }); + servers.push(hub); + const snapshot = await hub.start(); + + const client = new HubClientRuntime({ + repo: clientDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "client", + address: snapshot.url, + teamToken, + userToken: "stale-token-from-old-config", + nickname: "openclaw", + }), + log: rootLogger.child({ channel: "test.hub.client" }), + }); + clients.push(client); + + const pendingConnection = await client.start(); + expect(pendingConnection?.lastKnownStatus).toBe("pending"); + expect(pendingConnection?.userToken).toBe(""); + expect(hubDb.repos.hub.listUsers("pending").map((u) => u.username)).toEqual(["openclaw"]); + }); + + it("does not keep showing connected when the configured team token changes", async () => { + const hubDb = makeTmpDb({ agent: "hermes" }); + const clientDb = makeTmpDb({ agent: "openclaw" }); + handles.push(hubDb, clientDb); + + const port = await freePort(); + const teamToken = "correct-team-token"; + const hub = new HubServerRuntime({ + repo: hubDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "hub", + port, + teamName: "Token Rotation Test", + teamToken, + }), + log: rootLogger.child({ channel: "test.hub.server" }), + version: "test", + }); + servers.push(hub); + const snapshot = await hub.start(); + + const client = new HubClientRuntime({ + repo: clientDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "client", + address: snapshot.url, + teamToken, + nickname: "openclaw", + }), + log: rootLogger.child({ channel: "test.hub.client" }), + }); + clients.push(client); + + await client.start(); + const pending = hubDb.repos.hub.listUsers("pending"); + expect(pending).toHaveLength(1); + hub.approveUser(pending[0]!.id); + expect((await client.refreshStatus()).connected).toBe(true); + expect(clientDb.repos.hub.getClientConnection()?.userToken).toBeTruthy(); + + const wrongTokenClient = new HubClientRuntime({ + repo: clientDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "client", + address: snapshot.url, + teamToken: "wrong-team-token", + nickname: "openclaw", + }), + log: rootLogger.child({ channel: "test.hub.client.wrong_token" }), + }); + clients.push(wrongTokenClient); + + await expect(wrongTokenClient.start()).rejects.toThrow("invalid_team_token"); + const connAfterWrongToken = clientDb.repos.hub.getClientConnection(); + expect(connAfterWrongToken?.lastKnownStatus).toBe("invalid_team_token"); + expect(connAfterWrongToken?.userToken).toBe(""); + expect((await wrongTokenClient.refreshStatus()).connected).toBe(false); + }); + + it("syncs personal nickname changes and uses nickname as the join identity", async () => { + const hubDb = makeTmpDb({ agent: "hermes" }); + const clientDb = makeTmpDb({ agent: "openclaw" }); + const secondClientDb = makeTmpDb({ agent: "cursor" }); + handles.push(hubDb, clientDb, secondClientDb); + + const port = await freePort(); + const teamToken = "nickname-team-token"; + const hub = new HubServerRuntime({ + repo: hubDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "hub", + port, + teamName: "Nickname Test", + teamToken, + }), + log: rootLogger.child({ channel: "test.hub.server" }), + version: "test", + }); + servers.push(hub); + const snapshot = await hub.start(); + + const client = new HubClientRuntime({ + repo: clientDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "client", + address: snapshot.url, + teamToken, + nickname: "alice", + }), + log: rootLogger.child({ channel: "test.hub.client" }), + }); + clients.push(client); + + await client.start(); + const pending = hubDb.repos.hub.listUsers("pending"); + expect(pending).toHaveLength(1); + hub.approveUser(pending[0]!.id); + expect((await client.refreshStatus()).connected).toBe(true); + + const renamedClient = new HubClientRuntime({ + repo: clientDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "client", + address: snapshot.url, + teamToken, + nickname: "alice-renamed", + }), + log: rootLogger.child({ channel: "test.hub.client.renamed" }), + }); + clients.push(renamedClient); + + const renamedConnection = await renamedClient.start(); + expect(renamedConnection?.username).toBe("alice-renamed"); + expect(hubDb.repos.hub.getUser(pending[0]!.id)?.username).toBe("alice-renamed"); + expect((await renamedClient.refreshStatus()).user).toMatchObject({ username: "alice-renamed" }); + + const secondClient = new HubClientRuntime({ + repo: secondClientDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "client", + address: snapshot.url, + teamToken, + nickname: "alice-renamed", + }), + log: rootLogger.child({ channel: "test.hub.client.second" }), + }); + clients.push(secondClient); + + const secondConnection = await secondClient.start(); + expect(secondConnection?.lastKnownStatus).toBe("active"); + expect(secondConnection?.userId).toBe(pending[0]!.id); + expect(hubDb.repos.hub.listUsers().filter((u) => u.username === "alice-renamed")).toHaveLength(1); + }); + + it("removes approved users from the hub and invalidates the client status", async () => { + const hubDb = makeTmpDb({ agent: "hermes" }); + const clientDb = makeTmpDb({ agent: "openclaw" }); + handles.push(hubDb, clientDb); + + const port = await freePort(); + const teamToken = "remove-team-token"; + const hub = new HubServerRuntime({ + repo: hubDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "hub", + port, + teamName: "Remove Test", + teamToken, + }), + log: rootLogger.child({ channel: "test.hub.server" }), + version: "test", + }); + servers.push(hub); + const snapshot = await hub.start(); + + const client = new HubClientRuntime({ + repo: clientDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "client", + address: snapshot.url, + teamToken, + nickname: "delete-me", + }), + log: rootLogger.child({ channel: "test.hub.client" }), + }); + clients.push(client); + + await client.start(); + const pending = hubDb.repos.hub.listUsers("pending"); + hub.approveUser(pending[0]!.id); + expect((await client.refreshStatus()).connected).toBe(true); + + const removed = hub.removeUser(pending[0]!.id); + expect(removed?.status).toBe("removed"); + const afterRemove = await client.refreshStatus(); + expect(afterRemove.connected).toBe(false); + expect(afterRemove.user).toMatchObject({ status: "removed" }); + }); + + it("does not create a pending member when the first join uses a wrong team token", async () => { + const hubDb = makeTmpDb({ agent: "hermes" }); + const clientDb = makeTmpDb({ agent: "openclaw" }); + handles.push(hubDb, clientDb); + + const port = await freePort(); + const hub = new HubServerRuntime({ + repo: hubDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "hub", + port, + teamName: "Wrong Token Test", + teamToken: "correct-team-token", + }), + log: rootLogger.child({ channel: "test.hub.server" }), + version: "test", + }); + servers.push(hub); + const snapshot = await hub.start(); + + const client = new HubClientRuntime({ + repo: clientDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "client", + address: snapshot.url, + teamToken: "wrong-team-token", + nickname: "openclaw", + }), + log: rootLogger.child({ channel: "test.hub.client" }), + }); + clients.push(client); + + await expect(client.start()).rejects.toThrow("invalid_team_token"); + expect(hubDb.repos.hub.listUsers("pending")).toHaveLength(0); + expect(clientDb.repos.hub.getClientConnection()).toBeNull(); + }); + + it("keeps client snapshots status-only and hub snapshots admin-only", async () => { + const hubDb = makeTmpDb({ agent: "hermes" }); + const clientDb = makeTmpDb({ agent: "openclaw" }); + handles.push(hubDb, clientDb); + + const port = await freePort(); + const teamToken = "snapshot-team-token"; + const hubRuntime = createHubRuntime({ + repos: hubDb.repos, + config: configWithHub({ + enabled: true, + role: "hub", + port, + teamName: "Snapshot Test", + teamToken, + }), + log: rootLogger.child({ channel: "test.hub.runtime" }), + agent: "hermes", + version: "test", + }); + servers.push({ + stop: () => hubRuntime.stop(), + } as HubServerRuntime); + await hubRuntime.start(); + const hubSnapshot = await hubRuntime.adminSnapshot(); + const url = hubSnapshot.url!; + + const clientRuntime = createHubRuntime({ + repos: clientDb.repos, + config: configWithHub({ + enabled: true, + role: "client", + address: url, + teamToken, + nickname: "openclaw", + }), + log: rootLogger.child({ channel: "test.client.runtime" }), + agent: "openclaw", + version: "test", + }); + await clientRuntime.start(); + + const clientSnapshot = await clientRuntime.adminSnapshot(); + expect(clientSnapshot.role).toBe("client"); + expect(clientSnapshot.status).toBe("pending"); + expect(clientSnapshot.pending).toEqual([]); + expect(clientSnapshot.users).toHaveLength(1); + expect(clientSnapshot.users?.[0]).toMatchObject({ name: "openclaw", connected: false, status: "pending" }); + + const hubSnapshotWithPending = await hubRuntime.adminSnapshot(); + expect(hubSnapshotWithPending.role).toBe("hub"); + expect(hubSnapshotWithPending.pending?.map((u) => u.name)).toEqual(["openclaw"]); + await clientRuntime.stop(); + }); + + it("searches team hub memories from a joined client without importing local traces", async () => { + const hubDb = makeTmpDb({ agent: "hermes" }); + const clientDb = makeTmpDb({ agent: "openclaw" }); + handles.push(hubDb, clientDb); + + const port = await freePort(); + const teamToken = "hub-search-team-token"; + const hubRuntime = createHubRuntime({ + repos: hubDb.repos, + config: configWithHub({ + enabled: true, + role: "hub", + port, + teamName: "Hub Search Test", + teamToken, + }), + log: rootLogger.child({ channel: "test.hub.search.runtime" }), + agent: "hermes", + version: "test", + }); + servers.push({ stop: () => hubRuntime.stop() } as HubServerRuntime); + await hubRuntime.start(); + await hubRuntime.publishTrace({ + id: "tr-book", + episodeId: "ep-book", + sessionId: "sess-book", + ts: Date.now(), + ownerAgentKind: "hermes", + ownerProfileId: "default", + userText: "我喜欢看的书是《百年孤独》", + agentText: "记住了:你喜欢看的书是《百年孤独》。", + summary: "喜欢看的书是《百年孤独》", + toolCalls: [], + value: 0, + alpha: 0, + priority: 0.5, + } as TraceDTO, new Float32Array([0.1, 0.9])); + + const hubSnapshot = await hubRuntime.adminSnapshot(); + const clientRuntime = createHubRuntime({ + repos: clientDb.repos, + config: configWithHub({ + enabled: true, + role: "client", + address: hubSnapshot.url!, + teamToken, + nickname: "openclaw", + }), + log: rootLogger.child({ channel: "test.hub.search.client" }), + agent: "openclaw", + version: "test", + }); + servers.push({ stop: () => clientRuntime.stop() } as HubServerRuntime); + await clientRuntime.start(); + const pending = hubDb.repos.hub.listUsers("pending"); + expect(pending).toHaveLength(1); + await hubRuntime.approveUser(pending[0]!.id); + expect((await clientRuntime.adminSnapshot()).status).toBe("connected"); + + const hits = await clientRuntime.searchMemories("我喜欢看什么书", 5); + expect(hits).toHaveLength(1); + expect(hits[0]!.summary).toContain("百年孤独"); + expect(clientDb.repos.traces.list({ limit: 10 })).toHaveLength(0); + }); + + it("hides shared hub memories on unpublish and resurrects them on re-share", async () => { + const hubDb = makeTmpDb({ agent: "hermes" }); + handles.push(hubDb); + + const port = await freePort(); + const hub = new HubServerRuntime({ + repo: hubDb.repos.hub, + config: configWithHub({ + enabled: true, + role: "hub", + port, + teamName: "Soft Delete Test", + teamToken: "soft-delete-token", + }), + log: rootLogger.child({ channel: "test.hub.soft-delete" }), + version: "test", + }); + servers.push(hub); + const snapshot = await hub.start(); + + const first = hub.publishMemoryAsOwner({ + sourceTraceId: "tr-soft", + sourceAgent: "hermes", + kind: "trace", + summary: "喜欢看的书是《百年孤独》", + content: "User:\n我喜欢看的书是《百年孤独》", + embedding: new Float32Array([0.1, 0.9]), + }); + expect(hub.searchMemories("我喜欢看什么书", 5)).toHaveLength(1); + + hub.unpublishMemoryAsOwner("tr-soft"); + const hidden = hubDb.repos.hub.getSharedMemoryBySource(snapshot.ownerUserId, "tr-soft"); + expect(hidden?.id).toBe(first.id); + expect(hidden?.visible).toBe(false); + expect(hidden?.deletedAt).toBeTypeOf("number"); + expect(hub.searchMemories("我喜欢看什么书", 5)).toHaveLength(0); + + const restored = hub.publishMemoryAsOwner({ + sourceTraceId: "tr-soft", + sourceAgent: "hermes", + kind: "trace", + summary: "喜欢看的书是《百年孤独》", + content: "User:\n我喜欢看的书是《百年孤独》", + embedding: new Float32Array([0.1, 0.9]), + }); + const visible = hubDb.repos.hub.getSharedMemoryBySource(snapshot.ownerUserId, "tr-soft"); + expect(restored.id).toBe(first.id); + expect(visible?.visible).toBe(true); + expect(visible?.deletedAt).toBeNull(); + + hub.publishMemoryAsOwner({ + sourceTraceId: "tr-expired", + sourceAgent: "hermes", + kind: "trace", + summary: "过期共享记忆", + content: "User:\n过期共享记忆", + }); + hubDb.repos.hub.hideSharedMemoryBySource( + snapshot.ownerUserId, + "tr-expired", + Date.now() - HUB_SHARED_MEMORY_TOMBSTONE_TTL_MS - 1, + ); + expect(hubDb.repos.hub.purgeExpiredSharedMemories()).toBe(1); + expect(hubDb.repos.hub.getSharedMemoryBySource(snapshot.ownerUserId, "tr-expired")).toBeNull(); + }); +}); + +function configWithHub(hub: Partial): ResolvedConfig { + const base = JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as ResolvedConfig; + return { + ...base, + hub: { + ...base.hub, + ...hub, + }, + }; +} + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + server.close(() => resolve(port)); + }); + }); +} diff --git a/apps/memos-local-plugin/tests/unit/injection/scheduler.test.ts b/apps/memos-local-plugin/tests/unit/injection/scheduler.test.ts new file mode 100644 index 000000000..8b113b42d --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/injection/scheduler.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { scheduleInjection } from "../../../core/injection/scheduler.js"; +import type { IntentDecision } from "../../../core/session/types.js"; + +const baseIntent: IntentDecision = { + kind: "task", + confidence: 0.9, + reason: "test", + retrieval: { tier1: true, tier2: true, tier3: true }, + signals: ["test"], +}; + +function planFor(intent: IntentDecision, relation?: Parameters[0]["relation"]) { + return scheduleInjection({ + userText: "test", + sessionId: "s1", + episodeId: "ep1", + intent, + relation, + }); +} + +describe("injection/scheduler", () => { + it("skips confident chitchat", () => { + const plan = planFor({ + ...baseIntent, + kind: "chitchat", + confidence: 0.9, + retrieval: { tier1: false, tier2: false, tier3: false }, + }); + + expect(plan).toMatchObject({ + scenarioId: "CHITCHAT", + entry: "turn_start_skip", + prepend: false, + wantTier1: false, + wantTier2: false, + wantTier3: false, + }); + }); + + it("does not skip low-confidence chitchat", () => { + const plan = planFor({ + ...baseIntent, + kind: "chitchat", + confidence: 0.4, + retrieval: { tier1: false, tier2: false, tier3: false }, + }); + + expect(plan.entry).toBe("turn_start"); + expect(plan.scenarioId).toBe("UNKNOWN_SAFE"); + expect(plan.wantTier1).toBe(true); + expect(plan.wantTier2).toBe(true); + expect(plan.wantTier3).toBe(true); + }); + + it("skips meta commands", () => { + const plan = planFor({ + ...baseIntent, + kind: "meta", + confidence: 0.98, + retrieval: { tier1: false, tier2: false, tier3: false }, + }); + + expect(plan.scenarioId).toBe("META"); + expect(plan.entry).toBe("turn_start_skip"); + }); + + it("keeps memory probes on their intent tier gates", () => { + const plan = planFor({ + ...baseIntent, + kind: "memory_probe", + retrieval: { tier1: true, tier2: true, tier3: false }, + }); + + expect(plan).toMatchObject({ + scenarioId: "MEMORY_PROBE", + entry: "turn_start", + wantTier1: true, + wantTier2: true, + wantTier3: false, + }); + }); + + it("keeps unknown intent conservative", () => { + const plan = planFor({ + ...baseIntent, + kind: "unknown", + confidence: 0, + retrieval: { tier1: false, tier2: false, tier3: false }, + }); + + expect(plan).toMatchObject({ + scenarioId: "UNKNOWN_SAFE", + wantTier1: true, + wantTier2: true, + wantTier3: true, + }); + }); + + it("records relation-driven scenarios without changing tier gates", () => { + const plan = planFor(baseIntent, "new_task"); + + expect(plan).toMatchObject({ + scenarioId: "NEW_TASK", + entry: "turn_start", + wantTier1: true, + wantTier2: true, + wantTier3: true, + }); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts b/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts index 0ddf1f9d2..75946dc6f 100644 --- a/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts +++ b/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts @@ -76,8 +76,12 @@ describe("install.sh — CLI surface", () => { expect(script).toContain('OPENCLAW_RUNTIME_ENTRY="./dist/adapters/openclaw/index.js"'); expect(script).toContain('"extensions": ["${OPENCLAW_RUNTIME_ENTRY}"]'); expect(script).toContain('"contracts": {'); - expect(script).toContain('"memory_search"'); - expect(script).toContain("config.plugins.entries[pluginId].hooks.allowConversationAccess = true"); + expect(script).toContain('"memos_search"'); + expect(script).toContain("const MEMOS_TOOL_NAMES = ["); + expect(script).toContain("if (!Array.isArray(config.tools.alsoAllow)) config.tools.alsoAllow = []"); + expect(script).toContain("config.tools.alsoAllow.push(toolName)"); + expect(script).toContain("delete config.plugins.entries[pluginId].hooks"); + expect(script).not.toContain("config.plugins.entries[pluginId].hooks.allowConversationAccess = true"); expect(script).not.toContain('"extensions": ["./adapters/openclaw/index.ts"]'); }); diff --git a/apps/memos-local-plugin/tests/unit/memory/l2/gain.test.ts b/apps/memos-local-plugin/tests/unit/memory/l2/gain.test.ts index 38ad75281..9f5a3ffdd 100644 --- a/apps/memos-local-plugin/tests/unit/memory/l2/gain.test.ts +++ b/apps/memos-local-plugin/tests/unit/memory/l2/gain.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; -import { applyGain, computeGain, nextStatus } from "../../../../core/memory/l2/gain.js"; +import { applyGain, computeGain, nextStatus, smoothGain } from "../../../../core/memory/l2/gain.js"; import type { PolicyId, TraceRow } from "../../../../core/types.js"; function mkTrace(value: number): TraceRow { @@ -30,7 +30,7 @@ function mkTrace(value: number): TraceRow { } describe("memory/l2/gain", () => { - it("computeGain returns V_with − V_without using arithmetic mean for <3 traces", () => { + it("computeGain uses an adaptive shrinkage baseline for low-value trace pools", () => { const g = computeGain( { policyId: "po_1" as PolicyId, @@ -41,10 +41,25 @@ describe("memory/l2/gain", () => { ); expect(g.withMean).toBeCloseTo(0.7, 5); expect(g.withoutMean).toBeCloseTo(0.15, 5); - expect(g.gain).toBeCloseTo(0.55, 5); + expect(g.poolMean).toBeCloseTo(0.425, 5); + expect(g.baseline).toBeCloseTo(0.425, 5); + expect(g.gain).toBeCloseTo(0.35357143, 5); expect(g.withCount).toBe(2); }); + it("keeps the neutral baseline for high-value trace pools", () => { + const g = computeGain( + { + policyId: "po_1" as PolicyId, + withTraces: [mkTrace(0.8), mkTrace(0.7)], + withoutTraces: [mkTrace(0.6), mkTrace(0.55)], + }, + { tauSoftmax: 0.5 }, + ); + expect(g.poolMean).toBeGreaterThan(0.5); + expect(g.baseline).toBeCloseTo(0.5, 5); + }); + it("uses value-weighted mean for the with-set when count ≥ 3", () => { const g = computeGain( { @@ -112,6 +127,8 @@ describe("memory/l2/gain", () => { withCount: 4, withoutCount: 2, weightedWith: 0.55, + poolMean: 0.35, + baseline: 0.35, }, deltaSupport: 2, currentStatus: "candidate", @@ -131,4 +148,13 @@ describe("memory/l2/gain", () => { updatedAt: 1_000, }); }); + + it("smoothGain preserves existing signal after the first update", () => { + expect( + smoothGain({ newGain: -0.15, currentGain: 0.1, alpha: 0.4, isFirst: false }), + ).toBeCloseTo(0, 5); + expect( + smoothGain({ newGain: -0.15, currentGain: 0.1, alpha: 0.4, isFirst: true }), + ).toBeCloseTo(-0.15, 5); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/memory/l2/l2.integration.test.ts b/apps/memos-local-plugin/tests/unit/memory/l2/l2.integration.test.ts index abdfca1f7..d9d062d1f 100644 --- a/apps/memos-local-plugin/tests/unit/memory/l2/l2.integration.test.ts +++ b/apps/memos-local-plugin/tests/unit/memory/l2/l2.integration.test.ts @@ -42,6 +42,7 @@ function cfg(): L2Config { minTraceValue: 0.1, minEpisodesForInduction: 2, inductionTraceCharCap: 2_000, + gainEmaAlpha: 0.4, }; } @@ -726,4 +727,92 @@ describe("memory/l2/integration", () => { ); expect(r3.inductions.some((i) => i.policyId !== null)).toBe(true); }); + + it("recomputes gain from historical trace-policy links and smooths the update", async () => { + ensureEpisode(handle, "ep_hist_a", "s_int"); + ensureEpisode(handle, "ep_hist_b", "s_int"); + const oldWith = mkTrace({ + id: "tr_hist_with_old", + episodeId: "ep_hist_a", + value: 0.7, + ts: NOW as never, + vecSummary: vec([1, 0, 0]), + }); + const oldWithout = mkTrace({ + id: "tr_hist_without_old", + episodeId: "ep_hist_a", + value: 0.6, + ts: (NOW + 1) as never, + vecSummary: vec([0, 1, 0]), + }); + const newWith = mkTrace({ + id: "tr_hist_with_new", + episodeId: "ep_hist_b", + value: 0.3, + ts: (NOW + 2) as never, + vecSummary: vec([1, 0, 0]), + }); + const newWithout = mkTrace({ + id: "tr_hist_without_new", + episodeId: "ep_hist_b", + value: 0.35, + ts: (NOW + 3) as never, + vecSummary: vec([0, 1, 0]), + }); + for (const t of [oldWith, oldWithout, newWith, newWithout]) { + handle.repos.traces.insert(t); + } + handle.repos.policies.insert({ + id: "po_hist" as never, + title: "retry failed tools once with a corrected input", + trigger: "tool call fails but a corrected retry may recover", + procedure: "inspect the error, adjust the input, retry once, then explain fallback", + verification: "the retry resolves the error or the fallback is explicit", + boundary: "do not retry when the error is permanent", + support: 3, + gain: 0.1, + status: "active", + sourceEpisodeIds: ["ep_hist_a" as EpisodeId], + inducedBy: "unit-test", + decisionGuidance: { preference: [], antiPattern: [] }, + vec: vec([1, 0, 0]), + createdAt: NOW as never, + updatedAt: NOW as never, + }); + handle.repos.tracePolicyLinks.link({ + traceId: oldWith.id, + policyId: "po_hist" as never, + episodeId: oldWith.episodeId, + now: NOW, + }); + + await runL2( + { + episodeId: "ep_hist_b" as EpisodeId, + sessionId: "s_int" as SessionId, + traces: [newWith, newWithout], + trigger: "manual", + now: NOW + 10, + }, + { + db: handle.db, + repos: handle.repos, + llm: fakeLlm({ completeJson: {} }), + log: rootLogger.child({ channel: "core.memory.l2" }), + bus: createL2EventBus(), + config: { ...cfg(), minSimilarity: 0.7, gainEmaAlpha: 0.4 }, + thresholds: { minSupport: 3, minGain: 0.15, archiveGain: -0.05 }, + }, + ); + + const updated = handle.repos.policies.getById("po_hist" as never)!; + expect(handle.repos.tracePolicyLinks.getWithTraceIds("po_hist" as never).sort()).toEqual([ + "tr_hist_with_new", + "tr_hist_with_old", + ]); + expect(updated.support).toBe(4); + expect(updated.gain).toBeGreaterThan(0); + expect(updated.gain).toBeLessThan(0.1); + expect(updated.status).toBe("active"); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/memory/l2/subscriber.test.ts b/apps/memos-local-plugin/tests/unit/memory/l2/subscriber.test.ts index cb62a0e33..f5ac75e09 100644 --- a/apps/memos-local-plugin/tests/unit/memory/l2/subscriber.test.ts +++ b/apps/memos-local-plugin/tests/unit/memory/l2/subscriber.test.ts @@ -36,6 +36,7 @@ function cfg(): L2Config { minTraceValue: 0.1, minEpisodesForInduction: 5, // keep induction off for this test inductionTraceCharCap: 2_000, + gainEmaAlpha: 0.4, }; } diff --git a/apps/memos-local-plugin/tests/unit/memory/l3/cluster.test.ts b/apps/memos-local-plugin/tests/unit/memory/l3/cluster.test.ts index 131db21ad..737b602d9 100644 --- a/apps/memos-local-plugin/tests/unit/memory/l3/cluster.test.ts +++ b/apps/memos-local-plugin/tests/unit/memory/l3/cluster.test.ts @@ -226,7 +226,7 @@ describe("memory/l3/cluster", () => { // confirming we landed in the loose fallback for the right // reason and not because of a bug elsewhere. expect(c.cohesion).toBeLessThan(0.6); - expect(c.cohesion).toBeGreaterThan(0.5); + expect(c.cohesion).toBeGreaterThan(0.49); }); it("filters outliers below clusterMinSimilarity", () => { @@ -272,7 +272,7 @@ describe("memory/l3/cluster", () => { // fallback. expect(c.admission).toBe("strict"); expect(c.policies.map((p) => String(p.id))).not.toContain("po_outlier"); - expect(c.cohesion).toBeGreaterThan(0.5); + expect(c.cohesion).toBeGreaterThan(0.49); }); }); }); diff --git a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts index 440b23976..88d5cbbd4 100644 --- a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts +++ b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts @@ -6,6 +6,8 @@ * hand-built `PipelineHandle` so we control clocks + providers. */ +import net from "node:net"; + import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { @@ -20,10 +22,15 @@ import type { TraceDTO } from "../../../agent-contract/dto.js"; import { rootLogger } from "../../../core/logger/index.js"; import { DEFAULT_CONFIG } from "../../../core/config/defaults.js"; import { resolveHome } from "../../../core/config/paths.js"; +import { + __resetHostLlmBridgeForTests, + type HostLlmBridge, +} from "../../../core/llm/index.js"; import { makeTmpDb, type TmpDbHandle } from "../../helpers/tmp-db.js"; import { makeTmpHome, type TmpHomeContext } from "../../helpers/tmp-home.js"; import { fakeEmbedder } from "../../helpers/fake-embedder.js"; import type { MemosError } from "../../../agent-contract/errors.js"; +import type { SkillId, SkillRow, TraceRow } from "../../../core/types.js"; let db: TmpDbHandle | null = null; let pipeline: PipelineHandle | null = null; @@ -55,6 +62,44 @@ function traceKind(trace: TraceDTO): string { : "assistant"); } +function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +function seedCoreSkill(id: string, name: string): void { + const row: SkillRow = { + id: id as SkillId, + ownerAgentKind: "openclaw", + ownerProfileId: "main", + ownerWorkspaceId: null, + name, + status: "active", + invocationGuide: `${name}\n\nFollow the proven procedure.`, + procedureJson: null, + eta: 0.9, + support: 3, + gain: 0.3, + trialsAttempted: 0, + trialsPassed: 0, + sourcePolicyIds: [], + sourceWorldModelIds: [], + evidenceAnchors: [], + vec: null, + createdAt: 1_700_000_000_000 as SkillRow["createdAt"], + updatedAt: 1_700_000_000_000 as SkillRow["updatedAt"], + version: 1, + }; + db!.repos.skills.upsert(row); +} + beforeEach(() => { db = makeTmpDb(); }); @@ -78,6 +123,7 @@ afterEach(async () => { } db?.cleanup(); db = null; + __resetHostLlmBridgeForTests(); }); describe("MemoryCore façade", () => { @@ -99,6 +145,52 @@ describe("MemoryCore façade", () => { expect(h.llm.available).toBe(false); }); + it("reloads the hub runtime when hub config changes without a process restart", async () => { + const home = await makeTmpHome({ + agent: "openclaw", + configYaml: "version: 1\nhub:\n enabled: false\n", + }); + try { + pipeline = createPipeline({ + ...buildDeps(db!), + home: home.home, + config: home.config, + }); + core = createMemoryCore(pipeline, home.home, "test"); + await core.init(); + await expect(core.hubAdminSnapshot!()).resolves.toMatchObject({ enabled: false }); + + const port = await freePort(); + await core.patchConfig({ + hub: { + enabled: true, + role: "hub", + port, + teamName: "Live Reload", + teamToken: "live-reload-secret", + }, + }); + + const snapshot = await core.hubAdminSnapshot!() as Record; + expect(snapshot).toMatchObject({ + enabled: true, + role: "hub", + status: "running", + url: `http://127.0.0.1:${port}`, + }); + const info = await fetch(`http://127.0.0.1:${port}/api/v1/hub/info`); + expect(info.status).toBe(200); + await expect(info.json()).resolves.toMatchObject({ teamName: "Live Reload" }); + } finally { + if (core) { + await core.shutdown(); + core = null; + pipeline = null; + } + await home.cleanup(); + } + }); + it("openSession + closeSession roundtrip", async () => { pipeline = createPipeline(buildDeps(db!)); core = createMemoryCore( @@ -165,6 +257,83 @@ describe("MemoryCore façade", () => { expect(row?.vecSummary?.length).toBe(TEST_EMBED_DIMENSIONS); }); + it("does not require action vectors for lightweight memory traces", async () => { + pipeline = createPipeline(buildDeps(db!)); + core = createMemoryCore( + pipeline, + resolveHome("openclaw", "/tmp/memos-mc-test"), + "test", + ); + await core.init(); + + db!.repos.sessions.upsert({ + id: "se_lightweight", + agent: "openclaw", + ownerAgentKind: "openclaw", + ownerProfileId: "main", + ownerWorkspaceId: null, + startedAt: 1_700_000_000_000, + lastSeenAt: 1_700_000_000_000, + meta: {}, + }); + db!.repos.episodes.insert({ + id: "ep_lightweight", + sessionId: "se_lightweight", + ownerAgentKind: "openclaw", + ownerProfileId: "main", + ownerWorkspaceId: null, + startedAt: 1_700_000_000_000, + endedAt: 1_700_000_000_001, + traceIds: ["tr_lightweight"] as never, + rTask: null, + status: "closed", + meta: { lightweightMemory: true }, + }); + db!.repos.traces.insert({ + id: "tr_lightweight", + episodeId: "ep_lightweight", + sessionId: "se_lightweight", + ownerAgentKind: "openclaw", + ownerProfileId: "main", + ownerWorkspaceId: null, + ts: 1_700_000_000_000, + userText: "What changed in the repo?", + agentText: "The branch adds lightweight memory mode.", + summary: "Repo branch lightweight memory change", + share: null, + toolCalls: [], + agentThinking: null, + reflection: null, + value: 0, + alpha: 0, + rHuman: null, + priority: 0.5, + tags: ["lightweight_memory"], + errorSignatures: [], + vecSummary: new Float32Array(TEST_EMBED_DIMENSIONS), + vecAction: null, + turnId: 1_700_000_000_000, + schemaVersion: 1, + } as TraceRow); + + const before = await core.embeddingMaintenanceStats(); + expect(before.byKind.trace.totalSlots).toBe(1); + expect(before.byKind.trace.ready).toBe(1); + expect(before.byKind.trace.missing).toBe(0); + expect(before.needsRepair).toBe(0); + + const repaired = await core.rebuildEmbeddings({ mode: "repair", limit: 10 }); + expect(repaired.processed).toBe(0); + expect(repaired.updated).toBe(0); + + const rebuilt = await core.rebuildEmbeddings({ mode: "rebuild", limit: 10 }); + expect(rebuilt.processed).toBe(1); + expect(rebuilt.updated).toBe(1); + const row = db!.repos.traces.getById("tr_lightweight" as never); + expect(row?.vecSummary?.length).toBe(TEST_EMBED_DIMENSIONS); + expect(row?.vecAction).toBeNull(); + }); + it("onTurnStart returns a RetrievalResultDTO with tier latencies", async () => { pipeline = createPipeline(buildDeps(db!)); core = createMemoryCore( @@ -184,7 +353,7 @@ describe("MemoryCore façade", () => { expect(res.query.query).toBe("how do I build this project?"); }); - it("isolates private traces by namespace and exposes local shared traces", async () => { + it("scopes shared traces to creator, same framework, or hub team", async () => { pipeline = createPipeline(buildDeps(db!)); core = createMemoryCore( pipeline, @@ -195,6 +364,7 @@ describe("MemoryCore façade", () => { const mainNs = { agentKind: "openclaw", profileId: "main" }; const reviewerNs = { agentKind: "openclaw", profileId: "reviewer" }; + const hermesNs = { agentKind: "hermes", profileId: "default" }; const start = await core.onTurnStart({ agent: "openclaw", @@ -221,12 +391,26 @@ describe("MemoryCore façade", () => { expect(await core.listTraces({ limit: 10 })).toHaveLength(0); await core.openSession({ agent: "openclaw", sessionId: "s-main", namespace: mainNs }); - await core.shareTrace(ownerRows[0]!.id, { scope: "local" }); + await core.shareTrace(ownerRows[0]!.id, { scope: "public" }); await core.openSession({ agent: "openclaw", sessionId: "s-reviewer", namespace: reviewerNs }); const sharedRows = await core.listTraces({ limit: 10 }); expect(sharedRows).toHaveLength(1); - expect(sharedRows[0]?.share?.scope).toBe("local"); + expect(sharedRows[0]?.share?.scope).toBe("public"); + expect(await core.listTraces({ limit: 10, groupByTurn: true })).toHaveLength(1); + + await core.openSession({ agent: "hermes", sessionId: "s-hermes", namespace: hermesNs }); + expect(await core.listTraces({ limit: 10 })).toHaveLength(0); + expect(await core.listTraces({ limit: 10, groupByTurn: true })).toHaveLength(0); + + await core.openSession({ agent: "openclaw", sessionId: "s-main", namespace: mainNs }); + await core.shareTrace(ownerRows[0]!.id, { scope: "hub" }); + + await core.openSession({ agent: "hermes", sessionId: "s-hermes", namespace: hermesNs }); + const hubRows = await core.listTraces({ limit: 10 }); + expect(hubRows).toHaveLength(1); + expect(hubRows[0]?.share?.scope).toBe("hub"); + expect(await core.listTraces({ limit: 10, groupByTurn: true })).toHaveLength(1); }); it("records visible subagent task and result in the parent episode", async () => { @@ -833,6 +1017,28 @@ describe("MemoryCore façade", () => { code: "already_shut_down", }); }); + + it("getSkill resolves colon-qualified skill ids and short aliases", async () => { + pipeline = createPipeline(buildDeps(db!)); + core = createMemoryCore( + pipeline, + resolveHome("openclaw", "/tmp/memos-mc-test"), + "test", + ); + await core.init(); + + seedCoreSkill("skillsbench:skill-a089bcb8e0258209", "skill-a089bcb8e0258209"); + seedCoreSkill("skill-local-only", "local skill"); + + await expect(core.getSkill("skill-a089bcb8e0258209" as SkillId)).resolves.toMatchObject({ + id: "skillsbench:skill-a089bcb8e0258209", + name: "skill-a089bcb8e0258209", + }); + await expect(core.getSkill("skillsbench:skill-local-only" as SkillId)).resolves.toMatchObject({ + id: "skill-local-only", + name: "local skill", + }); + }); }); describe("bootstrapMemoryCore", () => { @@ -869,6 +1075,67 @@ describe("bootstrapMemoryCore", () => { expect(h2.paths.db).toBe(home!.home.dbFile); }); + it("persists lightweight summarizer model status for Hermes overview", async () => { + home = await makeTmpHome({ + agent: "hermes", + configYaml: ` +llm: + provider: host + model: hermes-summary-test +algorithm: + lightweightMemory: + enabled: true +`, + }); + const bridge: HostLlmBridge = { + id: "test-host-llm", + async complete() { + return { + text: JSON.stringify({ summary: "Hermes remembered the overview status fact" }), + model: "hermes-summary-test", + durationMs: 1, + }; + }, + }; + core = await bootstrapMemoryCore({ + agent: "hermes", + home: home.home, + config: home.config, + pkgVersion: "bootstrap-test", + hostLlmBridge: bridge, + now: () => 1_700_000_000_000, + }); + await core.init(); + + const start = await core.onTurnStart({ + agent: "hermes", + sessionId: "hermes-lightweight-status", + userText: "请记住 Hermes 摘要模型状态应该显示已调用", + ts: 1_700_000_000_000, + }); + await core.onTurnEnd({ + agent: "hermes", + sessionId: "hermes-lightweight-status", + episodeId: start.query.episodeId!, + agentText: "已记住。", + toolCalls: [], + ts: 1_700_000_000_100, + }); + + const logs = await core.listApiLogs({ toolName: "system_model_status", limit: 10 }); + const llmRows = logs.logs + .map((row) => JSON.parse(row.outputJson) as { role?: string; status?: string; op?: string }) + .filter((row) => row.role === "llm"); + expect(llmRows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: "ok", + op: "capture.summarize", + }), + ]), + ); + }); + it("init() recovers orphaned open episodes left behind by a previous crash", async () => { // When the host (OpenClaw / Hermes / a daemon) is hard-killed // mid-conversation, no `session.end` event is fired and the open diff --git a/apps/memos-local-plugin/tests/unit/pipeline/orchestrator.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/orchestrator.test.ts index 7a0e195d0..87b9e6d8f 100644 --- a/apps/memos-local-plugin/tests/unit/pipeline/orchestrator.test.ts +++ b/apps/memos-local-plugin/tests/unit/pipeline/orchestrator.test.ts @@ -24,7 +24,10 @@ import type { TurnInputDTO, TurnResultDTO } from "../../../agent-contract/dto.js let dbHandle: TmpDbHandle | null = null; let pipeline: PipelineHandle | null = null; -function buildDeps(h: TmpDbHandle): PipelineDeps { +function buildDeps( + h: TmpDbHandle, + embedder = fakeEmbedder({ dimensions: 384 }), +): PipelineDeps { return { agent: "openclaw", home: resolveHome("openclaw", "/tmp/memos-test-home"), @@ -33,7 +36,7 @@ function buildDeps(h: TmpDbHandle): PipelineDeps { repos: h.repos, llm: null, reflectLlm: null, - embedder: fakeEmbedder({ dimensions: 384 }), + embedder, log: rootLogger.child({ channel: "test.pipeline" }), namespace: { agentKind: "openclaw", profileId: "main" }, now: () => 1_700_000_000_000, @@ -149,6 +152,59 @@ describe("pipeline/orchestrator", () => { unsubscribe(); }); + it("skips retrieval for confident chitchat", async () => { + const embedder = fakeEmbedder({ dimensions: 384 }); + pipeline = createPipeline(buildDeps(dbHandle!, embedder)); + + const packet = await pipeline.onTurnStart({ + agent: "openclaw", + sessionId: "s-chitchat", + userText: "hello", + ts: 1_700_000_000_000, + }); + const stats = pipeline.consumeRetrievalStats(packet.packetId); + + expect(packet.snippets).toHaveLength(0); + expect(packet.rendered).toBe(""); + expect(embedder.stats().requests).toBe(0); + expect(stats?.scenarioId).toBe("CHITCHAT"); + expect(stats?.embedding?.attempted).toBe(false); + }); + + it("uses current-turn intent when appending to an existing episode", async () => { + const embedder = fakeEmbedder({ dimensions: 384 }); + pipeline = createPipeline(buildDeps(dbHandle!, embedder)); + + const first = await pipeline.onTurnStart({ + agent: "openclaw", + sessionId: "s-follow-up", + userText: "fix the broken build", + ts: 1_700_000_000_000, + }); + await pipeline.onTurnEnd({ + agent: "openclaw", + sessionId: "s-follow-up", + episodeId: first.episodeId ?? "ep-ignored", + agentText: "The build is fixed.", + toolCalls: [], + ts: 1_700_000_000_100, + }); + const requestsBefore = embedder.stats().requests; + + const second = await pipeline.onTurnStart({ + agent: "openclaw", + sessionId: "s-follow-up", + userText: "hello", + ts: 1_700_000_000_200, + }); + const stats = pipeline.consumeRetrievalStats(second.packetId); + + expect(second.episodeId).toBe(first.episodeId); + expect(second.snippets).toHaveLength(0); + expect(embedder.stats().requests).toBe(requestsBefore); + expect(stats?.scenarioId).toBe("CHITCHAT"); + }); + it("records tool success + failure through the feedback subscriber", async () => { pipeline = createPipeline(buildDeps(dbHandle!)); await pipeline.onTurnStart({ diff --git a/apps/memos-local-plugin/tests/unit/retrieval/decision-guidance.test.ts b/apps/memos-local-plugin/tests/unit/retrieval/decision-guidance.test.ts index 6c32e84e7..573215130 100644 --- a/apps/memos-local-plugin/tests/unit/retrieval/decision-guidance.test.ts +++ b/apps/memos-local-plugin/tests/unit/retrieval/decision-guidance.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { collectDecisionGuidance } from "../../../core/retrieval/decision-guidance.js"; import type { RankedCandidate } from "../../../core/retrieval/ranker.js"; -import type { RetrievalRepos, SkillCandidate } from "../../../core/retrieval/types.js"; +import type { + EpisodeCandidate, + RetrievalRepos, + SkillCandidate, +} from "../../../core/retrieval/types.js"; const NOW = 1_700_000_000_000 as never; @@ -31,6 +35,28 @@ function rankedSkill( }; } +function rankedEpisode(refId: string): RankedCandidate { + const candidate: EpisodeCandidate = { + tier: "tier2", + refKind: "episode", + refId: refId as never, + cosine: 0.9, + ts: NOW, + vec: null, + sessionId: "s1" as never, + summary: "Episode rollup.", + maxValue: 0.8 as never, + meanPriority: 0.7, + }; + return { + candidate, + relevance: 0.9, + rrf: 0.01, + score: 0.9, + normSq: null, + }; +} + describe("retrieval/decision-guidance", () => { it("uses skill-local decision guidance before source policies", () => { const repos = { @@ -106,4 +132,35 @@ describe("retrieval/decision-guidance", () => { expect(result.policyIdsTouched).toEqual(["policy1"]); expect(result.skillIdsTouched).toEqual([]); }); + + it("uses episode rollup refId to collect policy guidance", () => { + const repos = { + policies: { + list: () => [ + { + id: "policy_ep", + title: "Episode policy", + sourceEpisodeIds: ["ep1"], + decisionGuidance: { + preference: ["Prefer the episode-level lesson."], + antiPattern: ["Avoid the episode-level trap."], + }, + }, + ], + }, + } as unknown as RetrievalRepos; + + const result = collectDecisionGuidance({ + ranked: [rankedEpisode("ep1")], + repos, + }); + + expect(result.preference.map((g) => g.text)).toEqual([ + "Prefer the episode-level lesson.", + ]); + expect(result.antiPattern.map((g) => g.text)).toEqual([ + "Avoid the episode-level trap.", + ]); + expect(result.policyIdsTouched).toEqual(["policy_ep"]); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/retrieval/dedupe-trace-episode.test.ts b/apps/memos-local-plugin/tests/unit/retrieval/dedupe-trace-episode.test.ts new file mode 100644 index 000000000..8988656a4 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/retrieval/dedupe-trace-episode.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { dedupeTraceEpisodeByEpisodeId } from "../../../core/retrieval/dedupe-trace-episode.js"; +import type { RankedCandidate } from "../../../core/retrieval/ranker.js"; +import type { EpisodeCandidate, TraceCandidate } from "../../../core/retrieval/types.js"; + +const NOW = 1_700_000_000_000 as never; + +function rc( + c: C, + score: number, + relevance = score, +): RankedCandidate { + return { + candidate: c as unknown as RankedCandidate["candidate"], + relevance, + rrf: 0.01, + score, + normSq: null, + }; +} + +function trace(id: string, episodeId: string, score: number): RankedCandidate { + return rc( + { + tier: "tier2", + refKind: "trace", + refId: id as never, + cosine: score, + ts: NOW, + vec: null, + value: 0.8 as never, + priority: 0.8, + episodeId: episodeId as never, + sessionId: "s_other" as never, + vecKind: "summary", + userText: "u", + agentText: "a", + summary: "summary", + reflection: null, + tags: [], + }, + score, + ); +} + +function episode(id: string, score: number): RankedCandidate { + return rc( + { + tier: "tier2", + refKind: "episode", + refId: id as never, + cosine: score, + ts: NOW, + vec: null, + sessionId: "s_other" as never, + summary: "rollup", + maxValue: 0.9 as never, + meanPriority: 0.5, + }, + score, + ); +} + +describe("dedupeTraceEpisodeByEpisodeId", () => { + it("keeps only the higher-scored row when trace and episode share episodeId", () => { + const ranked = [trace("t1", "ep_shared", 0.9), episode("ep_shared", 0.7)]; + const { ranked: kept, dedupedByEpisodeCount } = dedupeTraceEpisodeByEpisodeId(ranked); + expect(kept).toHaveLength(1); + expect(kept[0]!.candidate.refKind).toBe("trace"); + expect(dedupedByEpisodeCount).toBe(1); + }); + + it("keeps all trace rows from the same episode when no episode rollup is present", () => { + const ranked = [trace("t1", "ep_shared", 0.9), trace("t2", "ep_shared", 0.7)]; + const { ranked: kept, dedupedByEpisodeCount } = dedupeTraceEpisodeByEpisodeId(ranked); + expect(kept).toHaveLength(2); + expect(kept.map((r) => String(r.candidate.refId))).toEqual(["t1", "t2"]); + expect(dedupedByEpisodeCount).toBe(0); + }); + + it("keeps all traces and drops the episode when the trace side wins", () => { + const ranked = [ + trace("t1", "ep_shared", 0.9), + trace("t2", "ep_shared", 0.7), + episode("ep_shared", 0.6), + ]; + const { ranked: kept, dedupedByEpisodeCount } = dedupeTraceEpisodeByEpisodeId(ranked); + expect(kept.map((r) => String(r.candidate.refId))).toEqual(["t1", "t2"]); + expect(dedupedByEpisodeCount).toBe(1); + }); + + it("keeps only the best episode when the episode side wins", () => { + const ranked = [ + trace("t1", "ep_shared", 0.7), + trace("t2", "ep_shared", 0.6), + episode("ep_shared", 0.9), + ]; + const { ranked: kept, dedupedByEpisodeCount } = dedupeTraceEpisodeByEpisodeId(ranked); + expect(kept).toHaveLength(1); + expect(kept[0]!.candidate.refKind).toBe("episode"); + expect(dedupedByEpisodeCount).toBe(2); + }); + + it("prefers episode on score tie", () => { + const ranked = [trace("t1", "ep_shared", 0.8), episode("ep_shared", 0.8)]; + const { ranked: kept } = dedupeTraceEpisodeByEpisodeId(ranked); + expect(kept).toHaveLength(1); + expect(kept[0]!.candidate.refKind).toBe("episode"); + }); + + it("leaves unrelated ref kinds untouched", () => { + const ranked = [ + trace("t1", "ep_a", 0.9), + episode("ep_b", 0.8), + ]; + const { ranked: kept, dedupedByEpisodeCount } = dedupeTraceEpisodeByEpisodeId(ranked); + expect(kept).toHaveLength(2); + expect(dedupedByEpisodeCount).toBe(0); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/retrieval/injector.test.ts b/apps/memos-local-plugin/tests/unit/retrieval/injector.test.ts index 9a1648e6b..0224a282f 100644 --- a/apps/memos-local-plugin/tests/unit/retrieval/injector.test.ts +++ b/apps/memos-local-plugin/tests/unit/retrieval/injector.test.ts @@ -165,6 +165,7 @@ describe("retrieval/injector", () => { }); expect(packet.rendered).toContain("## Memories"); + expect(packet.rendered).toContain("### Relevant Trace Memories"); expect(packet.rendered).toContain("## Experiences"); expect(packet.rendered).toContain("## Environment Knowledge"); expect(packet.rendered.indexOf("## Memories")).toBeLessThan( @@ -180,7 +181,7 @@ describe("retrieval/injector", () => { expect(packet.rendered).toContain( "Check: Issuer/CUSIP come from the row fields.", ); - expect(packet.rendered).not.toContain("p_exp"); + expect(packet.rendered).not.toContain('refId="p_exp"'); expect(packet.rendered).not.toContain("Type:"); expect(packet.rendered).not.toContain("confidence="); expect(packet.rendered).not.toContain("evidence="); @@ -202,7 +203,7 @@ describe("retrieval/injector", () => { expect(packet.rendered).toContain("User's conversation history"); expect(packet.rendered).toContain("MUST treat"); // Trailing tool reminder so the model knows how to re-query. - expect(packet.rendered).toContain("memory_search"); + expect(packet.rendered).toContain("memos_search"); // Row ids stay on the structured packet, but are not injected into // the model-facing prose unless a tool hint explicitly needs one. expect(packet.snippets[0]?.refId).toBe("sA"); @@ -228,12 +229,77 @@ describe("retrieval/injector", () => { episodeId: "ep_episode_metrics" as never, }); - expect(packet.rendered).toContain("Past similar episode"); + expect(packet.rendered).not.toContain("Past similar episode"); expect(packet.rendered).toContain("install libpq-dev"); + expect(packet.rendered).toContain('memos_timeline(episodeId="e_noisy")'); expect(packet.rendered).not.toMatch(/best V|goal-sim|V=/); }); - it("default skill rendering is summary mode (descriptor + skill_get hint, no full guide)", () => { + it("omits redundant Trigger line when it matches the experience title", () => { + const exp = experience("p_dup"); + exp.title = "Use holdings columns"; + exp.trigger = "Use holdings columns"; + + const { packet } = toPacket({ + ranked: [rc(exp)], + reason: "turn_start", + tierLatencyMs: { tier1: 0, tier2: 0, tier3: 0 }, + now: NOW as never, + sessionId: "sess_exp_dup" as never, + episodeId: "ep_exp_dup" as never, + }); + + expect(packet.rendered).toContain("1. Use holdings columns"); + expect(packet.rendered).toContain("Do:"); + expect(packet.rendered).not.toMatch(/Trigger:\s*Use holdings columns/); + }); + + it("splits memories into past-task and trace subsections with per-item tool hints", () => { + const { packet } = toPacket({ + ranked: [rc(episode("e1")), rc(trace("t1"))], + reason: "turn_start", + tierLatencyMs: { tier1: 0, tier2: 0, tier3: 0 }, + now: NOW as never, + sessionId: "sess_mem_sections" as never, + episodeId: "ep_mem_sections" as never, + }); + + expect(packet.rendered).toContain("## Memories"); + expect(packet.rendered).toContain("### Similar Past Tasks"); + expect(packet.rendered).toContain("### Relevant Trace Memories"); + expect(packet.rendered.indexOf("### Similar Past Tasks")).toBeLessThan( + packet.rendered.indexOf("### Relevant Trace Memories"), + ); + expect(packet.rendered).toContain("Past task ·"); + expect(packet.rendered).toContain("Trace ·"); + expect(packet.rendered).not.toContain("Sub-task ·"); + expect(packet.rendered).toContain('memos_timeline(episodeId="e1")'); + expect(packet.rendered).toContain('memos_get(id="t1", kind="trace")'); + expect(packet.rendered).toContain("`memos_timeline(episodeId, limit?)`"); + expect(packet.rendered).toContain('`memos_get(id, kind="trace")`'); + }); + + it("adds footer tool hints for experiences and world models", () => { + const { packet } = toPacket({ + ranked: [rc(experience("p_footer")), rc(world("w_footer"))], + reason: "turn_start", + tierLatencyMs: { tier1: 0, tier2: 0, tier3: 0 }, + now: NOW as never, + sessionId: "sess_footer" as never, + episodeId: "ep_footer" as never, + }); + + expect(packet.rendered).not.toContain("## Memories"); + expect(packet.rendered).toContain('memos_get(id="p_footer", kind="policy")'); + expect(packet.rendered).toContain( + 'memos_get(id="w_footer", kind="world_model")', + ); + expect(packet.rendered).toContain('`memos_get(id, kind="policy")`'); + expect(packet.rendered).toContain('`memos_get(id, kind="world_model")`'); + expect(packet.rendered).toContain("memos_search"); + }); + + it("default skill rendering is summary mode (descriptor + memos_skill_get hint, no full guide)", () => { // Multi-section guide: blank-line-separated paragraphs. Summary // mode must keep only the first paragraph and drop the procedure. const guide = [ @@ -252,21 +318,25 @@ describe("retrieval/injector", () => { episodeId: "ep_summary" as never, }); const skillSnippet = packet.snippets.find((s) => s.refKind === "skill")!; - // Prompt-facing body omits internal skill metadata. + // Prompt-facing body carries the fields needed to identify candidate skills. expect(skillSnippet.title).toBe("Skill sk_summary"); + expect(skillSnippet.body).toContain("Name: Skill sk_summary"); + expect(skillSnippet.body).toContain( + "Description: Fix Alpine container pip install failures by adding the missing -dev system library.", + ); + // But it still omits internal skill metadata. expect(skillSnippet.body).not.toContain("η=0.85"); expect(skillSnippet.body).not.toContain("status=active"); - // First paragraph survives as the summary line. - expect(skillSnippet.body).toContain("Fix Alpine container pip install"); - // Procedure steps must NOT be inlined (those live behind skill_get). + // Procedure steps must NOT be inlined (those live behind memos_skill_get). expect(skillSnippet.body).not.toContain("apk add"); expect(skillSnippet.body).not.toContain("Inspect the failing pip"); // Body must instruct the agent how to fetch the full procedure on demand. - expect(skillSnippet.body).toContain('skill_get(id="sk_summary")'); + expect(skillSnippet.body).toContain('memos_skill_get(id="sk_summary")'); // Section heading + footer also advertise the call-on-demand workflow. expect(packet.rendered).toContain("Candidate skills"); - expect(packet.rendered).toContain("`skill_get(id)`"); - expect(packet.rendered).not.toContain("`skill_list"); + expect(packet.rendered).toContain("`memos_skill_get(id)`"); + expect(packet.rendered).not.toContain("`memos_skill_list"); + expect(packet.rendered).not.toContain("Name: Skill sk_summary"); }); it("summary mode clamps long first paragraphs to skillSummaryChars", () => { @@ -285,7 +355,7 @@ describe("retrieval/injector", () => { const skillSnippet = packet.snippets.find((s) => s.refKind === "skill")!; // Descriptor + summary + call hint, none of which exceed the cap by much. expect(skillSnippet.body).toMatch(/x{60,80}…/); - expect(skillSnippet.body).toContain('skill_get(id="sk_clamp")'); + expect(skillSnippet.body).toContain('memos_skill_get(id="sk_clamp")'); }); it("full mode inlines the invocation guide (legacy behaviour)", () => { @@ -303,9 +373,9 @@ describe("retrieval/injector", () => { const skillSnippet = packet.snippets.find((s) => s.refKind === "skill")!; expect(skillSnippet.body).toContain("RUN docker compose up -d"); expect(skillSnippet.body).not.toContain("η="); - expect(skillSnippet.body).not.toContain("skill_get(id="); + expect(skillSnippet.body).not.toContain("memos_skill_get(id="); // The footer should not surface the skill call hints in full mode. - expect(packet.rendered).not.toContain("`skill_get(id)`"); + expect(packet.rendered).not.toContain("`memos_skill_get(id)`"); // Subsection headings are level-2 Markdown, nested under the packet's // level-1 "User's conversation history" header. expect(packet.rendered).toContain("## Skills"); @@ -335,7 +405,8 @@ describe("retrieval/injector", () => { sessionId: "sess_t4" as never, episodeId: "ep_t4" as never, }); - expect(packet.snippets[0]!.body.length).toBeLessThanOrEqual(700); + expect(packet.snippets[0]!.body.length).toBeLessThanOrEqual(720); expect(packet.snippets[0]!.body).toContain("[truncated]"); + expect(packet.snippets[0]!.body).toContain('memos_get(id="huge", kind="trace")'); }); }); diff --git a/apps/memos-local-plugin/tests/unit/retrieval/integration.test.ts b/apps/memos-local-plugin/tests/unit/retrieval/integration.test.ts index 3f2ee02a7..fa3eaeee9 100644 --- a/apps/memos-local-plugin/tests/unit/retrieval/integration.test.ts +++ b/apps/memos-local-plugin/tests/unit/retrieval/integration.test.ts @@ -287,7 +287,7 @@ describe("retrieval/integration", () => { reason: "tool_driven", agent: "openclaw", sessionId: "s1" as SessionId, - tool: "memory_search", + tool: "memos_search", args: { query: "docker compose" }, ts: NOW as never, }); @@ -295,6 +295,113 @@ describe("retrieval/integration", () => { expect(res.packet.snippets.every((s) => s.refKind !== "skill")).toBe(true); }); + it("lightweight mode only returns trace memories after summarizer filter succeeds", async () => { + let filterCalls = 0; + const llm: any = { + completeJson: async (_messages: unknown, opts: { op?: string }) => { + filterCalls++; + expect(opts.op).toContain("retrieval.filter"); + return { + value: { selected: [1], sufficient: true }, + servedBy: "fake", + }; + }, + }; + const res = await turnStartRetrieve( + { + ...makeDeps(handle), + llm, + config: { + ...makeDeps(handle).config, + lightweightMemory: true, + llmFilterEnabled: true, + llmFilterMinCandidates: 1, + }, + }, + { + reason: "turn_start", + agent: "openclaw", + // Cross-session: seeded traces live in `s1`, not the active turn session. + sessionId: "s_current" as SessionId, + userText: "run docker compose", + ts: NOW as never, + }, + ); + + expect(res.packet.snippets.length).toBeGreaterThan(0); + expect(res.packet.snippets.every((s) => s.refKind === "trace")).toBe(true); + expect(res.stats.tier1Count).toBe(0); + expect(res.stats.tier3Count).toBe(0); + expect(res.stats.llmFilterOutcome).toBe("llm_filtered"); + expect(res.stats.emptyPacket).toBe(false); + expect(filterCalls).toBe(1); + }); + + it("can defer the local LLM pass for one final merged filter", async () => { + let filterCalls = 0; + const llm: any = { + completeJson: async () => { + filterCalls++; + return { + value: { selected: [1], sufficient: true }, + servedBy: "fake", + }; + }, + }; + const res = await turnStartRetrieve( + { + ...makeDeps(handle), + llm, + config: { + ...makeDeps(handle).config, + llmFilterEnabled: true, + llmFilterMinCandidates: 1, + }, + }, + { + reason: "turn_start", + agent: "openclaw", + sessionId: "s_current" as SessionId, + userText: "run docker compose", + ts: NOW as never, + }, + { skipLlmFilter: true }, + ); + + expect(filterCalls).toBe(0); + expect(res.packet.snippets.length).toBeGreaterThan(0); + expect(res.stats.llmFilterOutcome).toBe("deferred_to_final"); + expect(res.stats.llmFilterKept).toBeGreaterThan(0); + }); + + it("lightweight mode returns no memories when summarizer filter is unavailable", async () => { + const res = await turnStartRetrieve( + { + ...makeDeps(handle), + llm: null, + config: { + ...makeDeps(handle).config, + lightweightMemory: true, + llmFilterEnabled: true, + llmFilterMinCandidates: 1, + }, + }, + { + reason: "turn_start", + agent: "openclaw", + sessionId: "s_current" as SessionId, + userText: "run docker compose", + ts: NOW as never, + }, + ); + + expect(res.stats.tier2Count).toBeGreaterThan(0); + expect(res.stats.llmFilterOutcome).toBe("no_llm"); + expect(res.stats.llmFilterKept).toBe(0); + expect(res.packet.snippets).toEqual([]); + expect(res.stats.emptyPacket).toBe(true); + }); + it("skill_invoke is tier1-heavy", async () => { const res = await skillInvokeRetrieve(makeDeps(handle), { reason: "skill_invoke", diff --git a/apps/memos-local-plugin/tests/unit/retrieval/query-builder.test.ts b/apps/memos-local-plugin/tests/unit/retrieval/query-builder.test.ts index ec2ad7df3..f2efde86c 100644 --- a/apps/memos-local-plugin/tests/unit/retrieval/query-builder.test.ts +++ b/apps/memos-local-plugin/tests/unit/retrieval/query-builder.test.ts @@ -22,17 +22,18 @@ describe("retrieval/query-builder", () => { expect(cq.truncated).toBe(false); }); - it("tool_driven serialises args JSON + tool name", () => { + it("tool_driven uses explicit search query text when present", () => { const cq = buildQuery({ reason: "tool_driven", agent: "openclaw", sessionId: "s1" as unknown as never, - tool: "memory_search", + tool: "memos_search", args: { query: "past docker bugs", limit: 5 }, ts: NOW, }); - expect(cq.text).toContain("tool:memory_search"); - expect(cq.text).toContain('"query":"past docker bugs"'); + expect(cq.text).toContain("past docker bugs"); + expect(cq.text).toContain('"limit":5'); + expect(cq.text).not.toContain("tool:memos_search"); expect(cq.tags).toContain("docker"); }); diff --git a/apps/memos-local-plugin/tests/unit/retrieval/tier2.test.ts b/apps/memos-local-plugin/tests/unit/retrieval/tier2.test.ts index eb90e6917..2afacbf56 100644 --- a/apps/memos-local-plugin/tests/unit/retrieval/tier2.test.ts +++ b/apps/memos-local-plugin/tests/unit/retrieval/tier2.test.ts @@ -127,6 +127,60 @@ describe("retrieval/tier2 (with real sqlite)", () => { expect(ids).toContain("zeroV"); }); + it("excludeSessionId omits traces from that session", async () => { + handle.repos.sessions.upsert({ + id: "s2" as SessionId, + agent: "openclaw", + startedAt: NOW, + lastSeenAt: NOW, + meta: {}, + }); + handle.repos.episodes.upsert({ + id: "ep2" as EpisodeId, + sessionId: "s2" as SessionId, + startedAt: NOW as never, + endedAt: null, + traceIds: [], + rTask: null, + status: "open", + }); + handle.repos.traces.insert({ + id: "otherSess" as TraceId, + episodeId: "ep2" as EpisodeId, + sessionId: "s2" as SessionId, + ts: NOW as never, + userText: "other session docker query", + agentText: "reply", + toolCalls: [], + reflection: "ref", + value: 0.95 as never, + alpha: 0.5 as never, + rHuman: null, + priority: 0.95 as never, + tags: ["docker"], + vecSummary: vec([1, 0, 0]), + vecAction: null, + turnId: 0 as never, + schemaVersion: 1, + }); + + const withoutExclude = await runTier2( + { repos: { traces: handle.repos.traces }, config: cfg, now: () => NOW }, + { queryVec: vec([1, 0, 0]), tags: ["docker"] }, + ); + expect(withoutExclude.traces.map((t) => String(t.refId))).toContain("otherSess"); + + const withExclude = await runTier2( + { repos: { traces: handle.repos.traces }, config: cfg, now: () => NOW }, + { + queryVec: vec([1, 0, 0]), + tags: ["docker"], + excludeSessionId: "s2" as SessionId, + }, + ); + expect(withExclude.traces.map((t) => String(t.refId))).not.toContain("otherSess"); + }); + it("rolls up episodes when ≥2 traces share episode_id", async () => { const out = await runTier2( { diff --git a/apps/memos-local-plugin/tests/unit/reward/reward.integration.test.ts b/apps/memos-local-plugin/tests/unit/reward/reward.integration.test.ts index 4f53856bb..238713a29 100644 --- a/apps/memos-local-plugin/tests/unit/reward/reward.integration.test.ts +++ b/apps/memos-local-plugin/tests/unit/reward/reward.integration.test.ts @@ -19,6 +19,7 @@ import type { FeedbackRow, TraceRow, } from "../../../core/types.js"; +import type { EpisodeSnapshot } from "../../../core/session/types.js"; import type { SessionRow } from "../../../core/storage/repos/sessions.js"; import { fakeLlm } from "../../helpers/fake-llm.js"; import { makeTmpDb, type TmpDbHandle } from "../../helpers/tmp-db.js"; @@ -87,8 +88,12 @@ function seedTrace( episodeId: eid as unknown as TraceRow["episodeId"], sessionId: sid as unknown as TraceRow["sessionId"], ts: NOW as EpochMs, - userText: "", - agentText: partial.agentText ?? "", + userText: + partial.userText ?? + `please deploy my docker image to the registry, verify step ${id}, and report the result`, + agentText: + partial.agentText ?? + "completed the requested deployment step and verified the resulting service", toolCalls: partial.toolCalls ?? [], reflection: partial.reflection ?? null, value: 0, @@ -104,6 +109,36 @@ function seedTrace( handle.repos.traces.insert(row); } +function rewardSnapshot(eid: string, sid: string, traceIds: string[] = []): EpisodeSnapshot { + return { + id: eid as unknown as EpisodeSnapshot["id"], + sessionId: sid as unknown as EpisodeSnapshot["sessionId"], + startedAt: NOW, + endedAt: NOW, + status: "closed", + rTask: null, + traceIds: traceIds as unknown as EpisodeSnapshot["traceIds"], + turnCount: 2, + turns: [ + { + role: "user", + content: + "please review the docker deployment result and explain what went wrong", + ts: NOW, + meta: {}, + }, + { + role: "assistant", + content: + "I made the wrong deployment choice and need to retry with corrected settings.", + ts: NOW, + meta: {}, + }, + ], + meta: {}, + }; +} + function seedFeedback( handle: TmpDbHandle, id: string, @@ -266,6 +301,7 @@ describe("reward/integration", () => { tracesRepo: handle.repos.traces, episodesRepo: handle.repos.episodes, feedbackRepo: handle.repos.feedback, + getEpisodeSnapshot: () => rewardSnapshot(eid, sid), llm: null, bus: createRewardEventBus(), cfg: cfg(), diff --git a/apps/memos-local-plugin/tests/unit/server/http.test.ts b/apps/memos-local-plugin/tests/unit/server/http.test.ts index c784d3773..3a8e70780 100644 --- a/apps/memos-local-plugin/tests/unit/server/http.test.ts +++ b/apps/memos-local-plugin/tests/unit/server/http.test.ts @@ -470,6 +470,8 @@ describe("HTTP server — REST routes", () => { sessionId: undefined, q: "hi", groupByTurn: false, + ownerAgentKind: undefined, + ownerProfileId: undefined, }); }); @@ -484,12 +486,12 @@ describe("HTTP server — REST routes", () => { it("GET /api/v1/api-logs supports multi-tool filtering", async () => { const r = await fetch( - `${handle.url}/api/v1/api-logs?tools=memory_add,memory_search&limit=10&offset=5`, + `${handle.url}/api/v1/api-logs?tools=memory_add,memos_search&limit=10&offset=5`, ); expect(r.status).toBe(200); expect(core.listApiLogs).toHaveBeenCalledWith({ toolName: undefined, - toolNames: ["memory_add", "memory_search"], + toolNames: ["memory_add", "memos_search"], limit: 10, offset: 5, }); diff --git a/apps/memos-local-plugin/tests/unit/storage/end-to-end.test.ts b/apps/memos-local-plugin/tests/unit/storage/end-to-end.test.ts index 91b4644a1..e6cb410f4 100644 --- a/apps/memos-local-plugin/tests/unit/storage/end-to-end.test.ts +++ b/apps/memos-local-plugin/tests/unit/storage/end-to-end.test.ts @@ -47,7 +47,7 @@ describe("storage/end-to-end", () => { ts: 101, userText: "list skills", agentText: "done", - toolCalls: [{ name: "memory_search", input: {}, startedAt: 101, endedAt: 102 }], + toolCalls: [{ name: "memos_search", input: {}, startedAt: 101, endedAt: 102 }], reflection: "quick", value: 0.2, alpha: 0.5, diff --git a/apps/memos-local-plugin/tests/unit/telemetry/sender.test.ts b/apps/memos-local-plugin/tests/unit/telemetry/sender.test.ts index 0b7290c99..428af552d 100644 --- a/apps/memos-local-plugin/tests/unit/telemetry/sender.test.ts +++ b/apps/memos-local-plugin/tests/unit/telemetry/sender.test.ts @@ -134,7 +134,7 @@ describe("Telemetry", () => { const body = JSON.parse((fetch as any).mock.calls[0][1].body); const event = body.events[0]; - expect(event.name).toBe("memory_search"); + expect(event.name).toBe("memos_search"); expect(event.properties.agent_name).toBe("hermes"); expect(event.properties.type).toBe("turn_start"); expect(event.properties.latency_ms).toBe(100); diff --git a/apps/memos-local-plugin/tests/unit/viewer/overview-model-status.test.ts b/apps/memos-local-plugin/tests/unit/viewer/overview-model-status.test.ts new file mode 100644 index 000000000..2aa04512a --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/viewer/overview-model-status.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { + formatModelStatusLine, + modelStatusFromInfo, +} from "../../../viewer/src/views/overview/model-status"; + +describe("overview model status", () => { + it("renders a healthy model as Connected, not the status object", () => { + const status = modelStatusFromInfo({ + available: true, + provider: "openai_compatible", + model: "gpt-4o-mini", + lastOkAt: 1_700_000_000_000, + }); + + expect(status.label).toBe("Connected"); + expect(formatModelStatusLine(status.label)).toBe("Connected"); + }); + + it("never falls back to browser object stringification in the status line", () => { + const status = modelStatusFromInfo({ + available: true, + provider: { name: "openai_compatible" }, + model: "gpt-4o-mini", + lastFallbackAt: 1_700_000_000_000, + lastError: { + at: 1_700_000_000_000, + message: { code: "bad_model", reason: "missing" }, + }, + }); + const line = formatModelStatusLine(status.label, { inherited: true }, { name: "x" }); + + expect(status.label).not.toContain("[object Object]"); + expect(line).not.toContain("[object Object]"); + expect(line).toContain("bad_model"); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/viewer/share.test.ts b/apps/memos-local-plugin/tests/unit/viewer/share.test.ts index 3eb216844..36eac6938 100644 --- a/apps/memos-local-plugin/tests/unit/viewer/share.test.ts +++ b/apps/memos-local-plugin/tests/unit/viewer/share.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { effectiveShareScope } from "../../../viewer/src/utils/share"; +import { + effectiveShareScope, + normalizeShareScope, + SHARE_SCOPE_OPTIONS, +} from "../../../viewer/src/utils/share"; describe("effectiveShareScope", () => { it("shows private when the global sharing switch is off", () => { @@ -16,4 +20,13 @@ describe("effectiveShareScope", () => { expect(effectiveShareScope("private", true)).toBe("private"); expect(effectiveShareScope(undefined, true)).toBe("private"); }); + + it("exposes only the three supported UI scopes", () => { + expect(SHARE_SCOPE_OPTIONS).toEqual(["private", "public", "hub"]); + }); + + it("normalizes the legacy local scope to public", () => { + expect(normalizeShareScope("local")).toBe("public"); + expect(effectiveShareScope("local", true)).toBe("public"); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/viewer/tasks-chat.test.ts b/apps/memos-local-plugin/tests/unit/viewer/tasks-chat.test.ts index 4f27e6080..a4c0ba970 100644 --- a/apps/memos-local-plugin/tests/unit/viewer/tasks-chat.test.ts +++ b/apps/memos-local-plugin/tests/unit/viewer/tasks-chat.test.ts @@ -128,7 +128,7 @@ describe("flattenChat", () => { id: "tr_skill", ts: T0 + 1_000, turnId: T0, - toolCalls: [{ name: "skill_get", input: { id: "sk_1" } }], + toolCalls: [{ name: "memos_skill_get", input: { id: "sk_1" } }], }); const delegate = trace({ id: "tr_delegate", @@ -143,7 +143,7 @@ describe("flattenChat", () => { expect(msgs.map((m) => m.role)).toEqual(["user", "tool", "tool"]); expect(msgs[0]!.text).toBe("今年杭州五一游客多吗"); expect(msgs[0]!.traceId).toBe("tr_delegate"); - expect(msgs[1]!.toolName).toBe("skill_get"); + expect(msgs[1]!.toolName).toBe("memos_skill_get"); expect(msgs[2]!.toolName).toBe("delegate_task"); }); diff --git a/apps/memos-local-plugin/viewer/src/components/Header.tsx b/apps/memos-local-plugin/viewer/src/components/Header.tsx index ca7cf6ba1..accb0d5da 100644 --- a/apps/memos-local-plugin/viewer/src/components/Header.tsx +++ b/apps/memos-local-plugin/viewer/src/components/Header.tsx @@ -57,7 +57,7 @@ export function Header() { const fetchers = [ api .get<{ traces: { id: string; summary?: string; userText?: string }[] }>( - `/api/v1/traces?q=${encodeURIComponent(q)}&limit=${limit}`, + `/api/v1/traces?q=${encodeURIComponent(q)}&limit=${limit}&includeTotal=false`, { signal }, ) .then((r) => diff --git a/apps/memos-local-plugin/viewer/src/components/HubAdminPanel.tsx b/apps/memos-local-plugin/viewer/src/components/HubAdminPanel.tsx index 397f5939a..9dfc65caf 100644 --- a/apps/memos-local-plugin/viewer/src/components/HubAdminPanel.tsx +++ b/apps/memos-local-plugin/viewer/src/components/HubAdminPanel.tsx @@ -1,8 +1,7 @@ /** - * Hub admin panel — inline version of the old `/admin` view, meant to - * be nested inside Settings → Team Sharing when the user enables - * hub membership. Mirrors the legacy viewer's IA where team admin - * lived under Settings, not as a sibling nav item. + * Hub status panel — inline Team Sharing status. Hub mode exposes + * approval/member management; client mode only shows this node's join + * status. * * Data: `GET /api/v1/hub/admin` — the same endpoint the standalone * AdminView used. Rendering is identical, minus the page header. @@ -15,6 +14,9 @@ import { Icon } from "./Icon"; interface AdminPayload { enabled: boolean; role?: "hub" | "client"; + status?: "disabled" | "starting" | "running" | "pending" | "connected" | "error"; + error?: string; + url?: string; pending?: Array<{ id: string; name: string; @@ -26,31 +28,66 @@ interface AdminPayload { name: string; groupName?: string; connected: boolean; + role?: string; + status?: string; + memoryCount?: number; + skillCount?: number; }>; - groups?: Array<{ id: string; name: string; memberCount: number }>; } -type InnerTab = "pending" | "users" | "groups"; +type InnerTab = "pending" | "users"; -export function HubAdminPanel() { +export function HubAdminPanel({ hasUnsavedHubChanges = false }: { hasUnsavedHubChanges?: boolean }) { const [data, setData] = useState(null); const [tab, setTab] = useState("pending"); const [loading, setLoading] = useState(true); + const [busyUserId, setBusyUserId] = useState(null); - useEffect(() => { - const ctrl = new AbortController(); - api - .get("/api/v1/hub/admin", { signal: ctrl.signal }) + const load = (signal?: AbortSignal) => { + setLoading(true); + return api + .get("/api/v1/hub/admin", { signal }) .then(setData) .catch(() => setData({ enabled: false })) .finally(() => setLoading(false)); + }; + + useEffect(() => { + const ctrl = new AbortController(); + void load(ctrl.signal); return () => ctrl.abort(); }, []); + const decide = async (userId: string, action: "approve" | "reject" | "remove") => { + if (action === "remove" && !confirm(t("admin.remove.confirm"))) return; + setBusyUserId(userId); + try { + const route = action === "approve" + ? "approve-user" + : action === "reject" + ? "reject-user" + : "remove-user"; + await api.post(`/api/v1/hub/admin/${route}`, { userId }); + await load(); + } finally { + setBusyUserId(null); + } + }; + if (loading) { return
; } + if (hasUnsavedHubChanges) { + return ( +
+ {t("admin.unsaved.desc")} +
+ ); + } + // When the daemon hasn't connected to a hub yet we just show a // one-line hint — the user is already inside Settings → Team Sharing // at this point, so they can see the form fields right above. @@ -64,15 +101,70 @@ export function HubAdminPanel() { const pending = data.pending ?? []; const users = data.users ?? []; - const groups = data.groups ?? []; + const primaryUser = users[0]; + + if (data.role === "client") { + return ( +
+
+
+ {data.url ? `${data.status ?? "client"} · ${data.url}` : data.status ?? "client"} + {data.error ? ` · ${data.error}` : ""} +
+ +
+ +
+ {primaryUser ? ( +
+
+
{primaryUser.name || t("admin.client.unknownMember")}
+
+ + {clientStatusLabel(primaryUser.status, primaryUser.connected)} + + {primaryUser.role && {primaryUser.role}} +
+
+
+ ) : ( +
+ {t("admin.client.notJoined")} +
+ )} +
+ +
+ {data.status === "pending" + ? t("admin.client.pendingDesc") + : data.status === "connected" + ? t("admin.client.connectedDesc") + : t("admin.client.refreshDesc")} +
+
+ ); + } return (
+
+
+ {data.role === "hub" && data.url ? `${data.status ?? "running"} · ${data.url}` : data.status ?? data.role} + {data.error ? ` · ${data.error}` : ""} +
+ +
+
{[ { v: "pending" as InnerTab, k: "admin.tab.pending" as const, count: pending.length }, { v: "users" as InnerTab, k: "admin.tab.users" as const, count: users.length }, - { v: "groups" as InnerTab, k: "admin.tab.groups" as const, count: groups.length }, ].map((o) => (
- - @@ -130,32 +230,26 @@ export function HubAdminPanel() {
{u.name}
- {u.connected ? "online" : "offline"} + {u.connected ? "online" : u.status || "offline"} + {u.role && {u.role}} {u.groupName && {u.groupName}} + {typeof u.memoryCount === "number" && {u.memoryCount} memories} + {typeof u.skillCount === "number" && {u.skillCount} skills}
-
- )) - )} -
- )} - - {tab === "groups" && ( -
- {groups.length === 0 ? ( -
- {t("common.empty")} -
- ) : ( - groups.map((g) => ( -
-
-
{g.name}
-
- {g.memberCount} members + {u.role !== "admin" && ( +
+
-
+ )}
)) )} @@ -164,3 +258,18 @@ export function HubAdminPanel() {
); } + +function clientStatusLabel(status: string | undefined, connected: boolean): string { + if (connected) return t("admin.client.connected"); + if (status === "pending") return t("admin.client.pending"); + if (status === "rejected") return t("admin.client.rejected"); + if (status === "blocked") return t("admin.client.blocked"); + if (status === "removed") return t("admin.client.removed"); + if (status === "token_expired") return t("admin.client.tokenExpired"); + if (status === "invalid_team_token") return t("admin.client.invalidTeamToken"); + if (status === "missing_team_token") return t("admin.client.missingTeamToken"); + if (status === "hub_changed") return t("admin.client.hubChanged"); + if (status === "not_registered") return t("admin.client.notRegistered"); + if (status === "username_taken") return t("admin.client.usernameTaken"); + return status || t("admin.client.disconnected"); +} diff --git a/apps/memos-local-plugin/viewer/src/components/LightweightModeEmpty.tsx b/apps/memos-local-plugin/viewer/src/components/LightweightModeEmpty.tsx new file mode 100644 index 000000000..5ed64612b --- /dev/null +++ b/apps/memos-local-plugin/viewer/src/components/LightweightModeEmpty.tsx @@ -0,0 +1,18 @@ +import { Icon, type IconName } from "./Icon"; + +export function LightweightModeEmpty({ + icon, + message, +}: { + icon: IconName; + message: string; +}) { + return ( +
+
+ +
+
{message}
+
+ ); +} diff --git a/apps/memos-local-plugin/viewer/src/components/ShareScopePill.tsx b/apps/memos-local-plugin/viewer/src/components/ShareScopePill.tsx index 0c0a15769..139d3a8e5 100644 --- a/apps/memos-local-plugin/viewer/src/components/ShareScopePill.tsx +++ b/apps/memos-local-plugin/viewer/src/components/ShareScopePill.tsx @@ -1,7 +1,7 @@ import { t } from "../stores/i18n"; -import { effectiveShareScope, type ShareScope } from "../utils/share"; +import { effectiveShareScope, type LegacyShareScope } from "../utils/share"; -export function ShareScopePill({ scope }: { scope?: ShareScope | null }) { +export function ShareScopePill({ scope }: { scope?: LegacyShareScope | null }) { const effectiveScope = effectiveShareScope(scope); return ( diff --git a/apps/memos-local-plugin/viewer/src/hooks/useLightweightMemoryMode.ts b/apps/memos-local-plugin/viewer/src/hooks/useLightweightMemoryMode.ts new file mode 100644 index 000000000..e20603896 --- /dev/null +++ b/apps/memos-local-plugin/viewer/src/hooks/useLightweightMemoryMode.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from "preact/hooks"; + +import { api } from "../api/client"; +import { triggerRestart } from "../stores/restart"; + +interface ResolvedConfig { + algorithm?: { + lightweightMemory?: { + enabled?: boolean; + }; + }; +} + +export interface LightweightMemoryModeState { + enabled: boolean; + loading: boolean; + saving: boolean; + error: string | null; + setEnabled: (enabled: boolean) => Promise; +} + +export function useLightweightMemoryMode(): LightweightMemoryModeState { + const [enabled, setEnabledState] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const ctrl = new AbortController(); + api + .get("/api/v1/config", { signal: ctrl.signal }) + .then((cfg) => { + setEnabledState(cfg.algorithm?.lightweightMemory?.enabled === true); + setError(null); + }) + .catch((err) => { + if ((err as Error).name !== "AbortError") { + setEnabledState(false); + setError((err as Error).message); + } + }) + .finally(() => { + if (!ctrl.signal.aborted) setLoading(false); + }); + return () => ctrl.abort(); + }, []); + + const setEnabled = async (next: boolean) => { + if (saving || next === enabled) return; + setSaving(true); + setError(null); + try { + await api.patch("/api/v1/config", { + algorithm: { lightweightMemory: { enabled: next } }, + }); + await triggerRestart(); + setEnabledState(next); + } catch (err) { + const message = (err as Error).message; + setError(message); + throw err; + } finally { + setSaving(false); + } + }; + + return { enabled, loading, saving, error, setEnabled }; +} diff --git a/apps/memos-local-plugin/viewer/src/stores/i18n.ts b/apps/memos-local-plugin/viewer/src/stores/i18n.ts index a35868ce9..28c9b0d0e 100644 --- a/apps/memos-local-plugin/viewer/src/stores/i18n.ts +++ b/apps/memos-local-plugin/viewer/src/stores/i18n.ts @@ -114,8 +114,11 @@ const en = { // Settings extensions. "settings.test": "Test", "settings.hub.admin": "Team members", + "settings.hub.status": "Hub status", "admin.approve": "Approve", "admin.deny": "Deny", + "admin.remove": "Remove", + "admin.remove.confirm": "Remove this member from the Hub? Their shared team content will be removed too.", "settings.test.ok": "Connection OK", "settings.apiKey.saved": "(already saved — leave blank to keep, type to replace)", @@ -393,14 +396,14 @@ const en = { "memories.help.episodeTimeline": "Other memories captured during the same task, in chronological order. Click a step to jump to it.", "memories.help.share": - "Visibility scope. Private stays on this device; Public is reachable via local link; Hub is shared with your team.", + "Visibility scope. Private is visible only to the creating agent; Public is visible to other agents in the same local agent framework; Hub is visible to the team.", "memories.detail.fromTask": "From task {id}", "memories.detail.oneMemory": "memory", "memories.detail.fallbackTitle": "Memory detail", "memories.share.title": "Share memory", "memories.share.scope": "Visibility", - "memories.share.scope.private": "Private (only this device)", - "memories.share.scope.public": "Public (local link)", + "memories.share.scope.private": "Private (creator agent only)", + "memories.share.scope.public": "Public (same agent framework)", "memories.share.scope.hub": "Hub (team)", "memories.share.done": "Sharing updated", "memories.share.removed": "Share removed", @@ -422,6 +425,8 @@ const en = { "policies.empty": "No experiences yet.", "policies.empty.hint": "Experiences appear after a few successful conversations share the same approach.", + "policies.lightweight.empty": + "Only basic summary memories are being saved. Enable memory self-evolution to automatically distill reusable experiences.", "policies.col.trigger": "Trigger", "policies.col.procedure": "Procedure", "policies.col.verification": "Verification", @@ -463,6 +468,8 @@ const en = { "worldModels.empty": "Nothing here yet.", "worldModels.empty.hint": "Environment knowledge builds up once several experiences share the same structure.", + "worldModels.lightweight.empty": + "Only basic summary memories are being saved. Enable memory self-evolution to automatically build environment knowledge.", "worldModels.col.body": "Description", "worldModels.structure.title": "Structured cognition (with evidence)", "worldModels.structure.environment": "Environment topology (ℰ)", @@ -482,6 +489,8 @@ const en = { "tasks.search.placeholder": "Search tasks…", "tasks.empty": "No tasks yet.", "tasks.empty.filtered": "No tasks match the current filter.", + "tasks.lightweight.empty": + "Only basic summary memories are being saved. Enable memory self-evolution to automatically organize conversations into task views.", "tasks.untitled": "Untitled task", "tasks.detail.id": "Task {id}", "tasks.detail.fallbackTitle": "Task detail", @@ -578,6 +587,8 @@ const en = { "skills.empty": "No skills yet.", "skills.empty.hint": "The agent turns a reliable experience into a callable skill once it's proven useful across several similar tasks.", + "skills.lightweight.empty": + "Only basic summary memories are being saved. Enable memory self-evolution to automatically crystallize reusable skills.", "skills.detail.desc": "Invocation guide", "skills.detail.files": "Skill files", "skills.detail.content": "SKILL.md content", @@ -675,14 +686,14 @@ const en = { // Logs. "logs.title": "Logs", "logs.subtitle": - "Structured trail of memory_search and memory_add calls, including the retrieved candidates and what the LLM kept.", + "Structured trail of memos_search and memory_add calls, including local candidates, Hub candidates, and memories kept by the LLM filter.", "logs.filter.tool": "Tool", "logs.filter.level": "Level", "logs.autoRefresh": "Live", "logs.empty": "No log lines in this window.", "logs.empty.title": "No memory calls yet", "logs.empty.hint": - "Rows show up here when the agent runs memory_search or captures a turn.", + "Rows show up here when the agent runs memos_search or captures a turn.", "logs.search.placeholder": "Search logs…", "logs.tag.memoryAdd": "Memory add", "logs.tag.memorySearch": "Memory search", @@ -792,12 +803,30 @@ const en = { // Admin. "admin.title": "Team administration", - "admin.subtitle": "Manage team sharing — users, groups, and pending approvals.", + "admin.subtitle": "Manage team sharing members and pending approvals.", "admin.disabled.title": "Team sharing is disabled", "admin.disabled.desc": "Enable it in Settings → Team Sharing to invite users and approve joins.", + "admin.unsaved.desc": "Team sharing settings have unsaved changes. Save first to reconnect and refresh the live status.", "admin.tab.pending": "Pending", "admin.tab.users": "Users", "admin.tab.groups": "Groups", + "admin.client.unknownMember": "This device", + "admin.client.notJoined": "No join request has been submitted from this device.", + "admin.client.pendingDesc": "Your join request has been sent. Ask the Hub owner to approve it on the Hub machine.", + "admin.client.connectedDesc": "This device is approved and connected to the Hub.", + "admin.client.refreshDesc": "Refresh checks the Hub for the latest approval state.", + "admin.client.connected": "connected", + "admin.client.pending": "waiting for approval", + "admin.client.rejected": "rejected", + "admin.client.blocked": "blocked", + "admin.client.removed": "removed", + "admin.client.tokenExpired": "token expired", + "admin.client.invalidTeamToken": "invalid team token", + "admin.client.missingTeamToken": "team token required", + "admin.client.hubChanged": "hub changed", + "admin.client.notRegistered": "not registered", + "admin.client.usernameTaken": "nickname already used", + "admin.client.disconnected": "not connected", // Settings. "settings.title": "Settings", @@ -825,6 +854,10 @@ const en = { "Ready {ready}/{total}; missing {missing}; dimension mismatch {mismatch}; current dim {dim}.", "settings.embedding.maintenance.unavailable": "Configure an embedding provider before repairing or rebuilding vectors.", + "settings.embedding.batchSize.label": "Items per request", + "settings.embedding.batchSize.option": "{n} items per request", + "settings.embedding.batchSize.hint": + "Larger batches usually rebuild faster, but may hit provider limits or timeouts.", "settings.embedding.repair": "Repair missing/mismatched", "settings.embedding.rebuild": "Rebuild all vectors", "settings.embedding.rebuild.running": "Rebuilding embeddings…", @@ -852,20 +885,29 @@ const en = { "settings.hub.role.hub": "Host a hub", "settings.hub.role.client": "Join a hub", "settings.hub.address": "Hub address", - "settings.hub.userToken": "Your user token", + "settings.hub.port": "Hub port", + "settings.hub.teamName": "Team name", + "settings.hub.nickname": "Personal nickname", "settings.hub.teamToken": "Team token", "settings.hub.help.title": "How to configure team sharing", "settings.hub.help.role": "Host a hub when this machine is the team endpoint; join a hub when you connect to another teammate's hub address.", "settings.hub.help.tokens": - "Team token authorizes the shared workspace. User token identifies your personal member identity and permissions.", + "Team token is the only code members need to join. Joining creates a pending request; after approval the plugin stores the member credential automatically.", + "settings.hub.mode.hub.title": "Hub server mode", + "settings.hub.mode.hub.desc": "This machine hosts the team endpoint. It shows pending requests and approved members.", + "settings.hub.mode.client.title": "Join Hub mode", + "settings.hub.mode.client.desc": "This machine joins another Hub with the Hub address, team token, and your personal nickname. It only shows this device's connection state.", "settings.hub.teamToken.placeholder": "Shared workspace token", - "settings.hub.userToken.placeholder": "Your personal access token", + "settings.hub.nickname.placeholder": "Your display name on this team", "settings.general.lang": "Display language", "settings.general.theme": "Theme", "settings.general.theme.light": "Light", "settings.general.theme.dark": "Dark", "settings.general.theme.auto": "System", + "settings.general.lightweightMemory": "Enable memory self-evolution", + "settings.general.lightweightMemory.desc": + "Automatically distill tasks, experiences, environment knowledge and skills beyond basic summary memory. Uses more model calls and is best for heavy memory workflows. Save and restart to apply.", "settings.general.detailedLogs": "Show detailed debug logs", "settings.general.detailedLogs.desc": "Enable chain view, failure-only filtering, and task, experience, skill, environment and system log categories.", @@ -974,8 +1016,11 @@ const zh: Record = { "settings.test": "测试", "settings.hub.admin": "团队成员", + "settings.hub.status": "Hub 状态", "admin.approve": "通过", "admin.deny": "拒绝", + "admin.remove": "删除", + "admin.remove.confirm": "确认从 Hub 删除该成员?该成员已共享到团队的内容也会一起移除。", "settings.test.ok": "连接成功", "settings.apiKey.saved": "(已保存 — 留空保持不变,输入以替换)", "settings.saveAndRestart": "保存并重启", @@ -1225,14 +1270,14 @@ const zh: Record = { "memories.help.episodeTimeline": "同一任务(一次完整的提问—响应过程)下,按时间顺序展示其他相关的记忆步骤。", "memories.help.share": - "可见范围:私密仅在本机可见;公开可在局域网内通过链接访问;Hub 表示与你的团队共享。", + "可见范围:私密仅创建该记忆的 Agent 可见;公开表示本机同一 Agent 框架内的其他 Agent 可见;Hub 表示团队内可见。", "memories.detail.fromTask": "来自任务 {id}", "memories.detail.oneMemory": "记忆", "memories.detail.fallbackTitle": "记忆详情", "memories.share.title": "共享记忆", "memories.share.scope": "可见范围", - "memories.share.scope.private": "私密(仅本机)", - "memories.share.scope.public": "公开(本地链接)", + "memories.share.scope.private": "私密(仅创建者 Agent)", + "memories.share.scope.public": "公开(同一 Agent 框架)", "memories.share.scope.hub": "Hub(团队)", "memories.share.done": "共享已更新", "memories.share.removed": "已取消共享", @@ -1251,6 +1296,8 @@ const zh: Record = { "policies.filter.archived": "已归档", "policies.empty": "尚未结晶出经验。", "policies.empty.hint": "当几次成功对话用的是同一套做法后,这里会出现相应的经验条目。", + "policies.lightweight.empty": + "当前仅保存基础摘要记忆。启用记忆自进化后,系统会自动提炼可复用经验。", "policies.col.trigger": "触发", "policies.col.procedure": "流程", "policies.col.verification": "验证", @@ -1287,6 +1334,8 @@ const zh: Record = { "worldModels.search.placeholder": "搜索环境认知…", "worldModels.empty": "暂无环境认知。", "worldModels.empty.hint": "当多条经验展现出相同的规律时,会自动凝聚成这里的环境认知。", + "worldModels.lightweight.empty": + "当前仅保存基础摘要记忆。启用记忆自进化后,系统会自动建立环境认知。", "worldModels.col.body": "内容", "worldModels.structure.title": "结构化认知(带证据锚点)", "worldModels.structure.environment": "环境拓扑(ℰ)", @@ -1305,6 +1354,8 @@ const zh: Record = { "tasks.search.placeholder": "搜索任务…", "tasks.empty": "暂无任务。", "tasks.empty.filtered": "当前筛选条件下没有匹配的任务。", + "tasks.lightweight.empty": + "当前仅保存基础摘要记忆。启用记忆自进化后,系统会自动整理任务视图。", "tasks.untitled": "未命名任务", "tasks.detail.id": "任务 {id}", "tasks.detail.fallbackTitle": "任务详情", @@ -1390,6 +1441,8 @@ const zh: Record = { "skills.filter.visibility.private": "私有", "skills.empty": "尚无技能。", "skills.empty.hint": "当一条经验在多个相似任务里都好用,插件会把它沉淀成一个可直接调用的技能。", + "skills.lightweight.empty": + "当前仅保存基础摘要记忆。启用记忆自进化后,系统会自动沉淀可复用技能。", "skills.detail.desc": "调用指南", "skills.detail.files": "技能文件", "skills.detail.content": "SKILL.md 内容", @@ -1484,9 +1537,9 @@ const zh: Record = { "这些工具调用已被记录,但开始/结束时间缺失或相同,因此不会进入响应耗时图。", "logs.title": "日志", - "logs.subtitle": "记忆检索和写入的结构化轨迹:召回候选、Hub 候选、LLM 筛选后保留的记忆。", + "logs.subtitle": "记忆检索和写入的结构化轨迹:本地候选、Hub 候选、LLM 筛选后保留的记忆。", "logs.empty.title": "尚无记忆调用", - "logs.empty.hint": "Agent 触发 memory_search 或写入一轮对话后,这里会出现。", + "logs.empty.hint": "Agent 触发 memos_search 或写入一轮对话后,这里会出现。", "logs.search.placeholder": "搜索日志…", "logs.tag.memoryAdd": "记忆添加", "logs.tag.memorySearch": "记忆检索", @@ -1593,12 +1646,30 @@ const zh: Record = { "import.embeddingRepair.done": "向量修复完成:已更新 {updated},失败 {failed}。", "admin.title": "团队管理", - "admin.subtitle": "管理团队分享的用户、群组与待审批。", + "admin.subtitle": "管理团队分享成员与待审批申请。", "admin.disabled.title": "团队分享尚未启用", "admin.disabled.desc": "可在 设置 → 团队分享 中启用并邀请用户。", + "admin.unsaved.desc": "团队分享配置有未保存修改。请先保存,插件会按新配置重新连接并刷新实时状态。", "admin.tab.pending": "待审批", "admin.tab.users": "用户", "admin.tab.groups": "群组", + "admin.client.unknownMember": "本机", + "admin.client.notJoined": "本机尚未提交加入申请。", + "admin.client.pendingDesc": "加入申请已提交,请在 Hub 所在机器上审批。", + "admin.client.connectedDesc": "本机已通过审批并连接到 Hub。", + "admin.client.refreshDesc": "刷新会向 Hub 查询最新审批状态。", + "admin.client.connected": "已连接", + "admin.client.pending": "等待审批", + "admin.client.rejected": "已拒绝", + "admin.client.blocked": "已拉黑", + "admin.client.removed": "已删除", + "admin.client.tokenExpired": "凭证已失效", + "admin.client.invalidTeamToken": "团队 Token 无效", + "admin.client.missingTeamToken": "需要团队 Token", + "admin.client.hubChanged": "Hub 已变更", + "admin.client.notRegistered": "未注册", + "admin.client.usernameTaken": "个人昵称已被占用", + "admin.client.disconnected": "未连接", "settings.title": "设置", "settings.tab.models": "AI 模型", @@ -1621,6 +1692,9 @@ const zh: Record = { "settings.embedding.maintenance.stats": "可用 {ready}/{total};缺失 {missing};维度不匹配 {mismatch};当前维度 {dim}。", "settings.embedding.maintenance.unavailable": "请先配置嵌入模型,再修复或重建向量。", + "settings.embedding.batchSize.label": "每次请求条数", + "settings.embedding.batchSize.option": "每次请求 {n} 条", + "settings.embedding.batchSize.hint": "每次请求条数越大通常重建越快,但可能触发模型服务限流或超时。", "settings.embedding.repair": "修复缺失/错维", "settings.embedding.rebuild": "全量重建向量", "settings.embedding.rebuild.running": "正在重建向量…", @@ -1645,20 +1719,29 @@ const zh: Record = { "settings.hub.role.hub": "托管 Hub", "settings.hub.role.client": "加入 Hub", "settings.hub.address": "Hub 地址", - "settings.hub.userToken": "个人 Token", + "settings.hub.port": "Hub 端口", + "settings.hub.teamName": "团队名称", + "settings.hub.nickname": "个人昵称", "settings.hub.teamToken": "团队 Token", "settings.hub.help.title": "团队分享配置说明", "settings.hub.help.role": "本机作为团队入口时选择托管 Hub;连接到其他成员的 Hub 地址时选择加入 Hub。", "settings.hub.help.tokens": - "团队 Token 用于授权共享工作区;个人 Token 用于标识你的成员身份和权限边界。", + "成员只需要团队 Token 即可申请加入;提交后会创建待审批申请,审批通过后插件会自动保存成员凭证。", + "settings.hub.mode.hub.title": "Hub 服务端模式", + "settings.hub.mode.hub.desc": "本机作为团队入口,负责接收加入申请、审批成员并展示成员列表。", + "settings.hub.mode.client.title": "加入 Hub 模式", + "settings.hub.mode.client.desc": "本机通过 Hub 地址、团队 Token 和个人昵称加入别人的 Hub,只显示本机连接状态。", "settings.hub.teamToken.placeholder": "共享工作区 Token", - "settings.hub.userToken.placeholder": "你的个人访问 Token", + "settings.hub.nickname.placeholder": "你在团队中显示的名称", "settings.general.lang": "显示语言", "settings.general.theme": "主题", "settings.general.theme.light": "浅色", "settings.general.theme.dark": "深色", "settings.general.theme.auto": "跟随系统", + "settings.general.lightweightMemory": "启用记忆自进化", + "settings.general.lightweightMemory.desc": + "在基础摘要记忆之外,自动沉淀任务、经验、环境认知和技能。会调用更多模型能力,适合重度记忆使用场景。保存并重启后生效。", "settings.general.detailedLogs": "显示详细调试日志", "settings.general.detailedLogs.desc": "开启后显示链路视图、仅看失败筛选,以及任务、经验、技能、环境认知和系统日志分类。", diff --git a/apps/memos-local-plugin/viewer/src/stores/restart.ts b/apps/memos-local-plugin/viewer/src/stores/restart.ts index 5a5d1d643..974a9e222 100644 --- a/apps/memos-local-plugin/viewer/src/stores/restart.ts +++ b/apps/memos-local-plugin/viewer/src/stores/restart.ts @@ -93,6 +93,7 @@ export async function triggerRestart(): Promise { window.location.pathname + "?_t=" + Date.now(); } catch { restartState.value = { phase: "restartFailed" }; + throw new Error("restart failed"); } return; } @@ -109,6 +110,7 @@ export async function triggerRestart(): Promise { window.location.pathname + "?_t=" + Date.now(); } else { restartState.value = { phase: "restartFailed" }; + throw new Error("restart did not complete"); } } diff --git a/apps/memos-local-plugin/viewer/src/styles/components.css b/apps/memos-local-plugin/viewer/src/styles/components.css index 09e283ffc..c232ade54 100644 --- a/apps/memos-local-plugin/viewer/src/styles/components.css +++ b/apps/memos-local-plugin/viewer/src/styles/components.css @@ -1437,7 +1437,7 @@ /* Tool name pills — used on the Logs page */ .pill--tool { background: rgba(255,255,255,.05); color: var(--fg); font-family: var(--font-mono); font-size: var(--fs-xs); } -.pill--tool-memory_search { background: var(--cyan-bg); color: var(--cyan); } +.pill--tool-memos_search { background: var(--cyan-bg); color: var(--cyan); } .pill--tool-memory_add { background: var(--green-bg); color: var(--green); } .pill--tool-skill_generate { background: var(--violet-bg); color: var(--violet); } .pill--tool-skill_evolve { background: var(--violet-bg); color: var(--violet); opacity:.85; } diff --git a/apps/memos-local-plugin/viewer/src/utils/share.ts b/apps/memos-local-plugin/viewer/src/utils/share.ts index d9dd1670a..dd025984f 100644 --- a/apps/memos-local-plugin/viewer/src/utils/share.ts +++ b/apps/memos-local-plugin/viewer/src/utils/share.ts @@ -1,7 +1,10 @@ import { signal } from "@preact/signals"; import { api } from "../api/client"; -export type ShareScope = "private" | "local" | "public" | "hub"; +export type ShareScope = "private" | "public" | "hub"; +export type LegacyShareScope = ShareScope | "local"; + +export const SHARE_SCOPE_OPTIONS = ["private", "public", "hub"] as const satisfies readonly ShareScope[]; interface SharingConfig { hub?: { @@ -19,13 +22,19 @@ let pendingConfigLoad: Promise | null = null; * global team-sharing switch. */ export function effectiveShareScope( - scope: ShareScope | null | undefined, + scope: LegacyShareScope | null | undefined, sharingEnabled = hubSharingEnabled.value, ): ShareScope { - const intendedScope = scope ?? "private"; + const intendedScope = normalizeShareScope(scope); return sharingEnabled ? intendedScope : "private"; } +export function normalizeShareScope(scope: LegacyShareScope | null | undefined): ShareScope { + if (scope === "local") return "public"; + if (scope === "public" || scope === "hub") return scope; + return "private"; +} + export async function loadHubSharingEnabled({ force = false, signal, diff --git a/apps/memos-local-plugin/viewer/src/views/ImportView.tsx b/apps/memos-local-plugin/viewer/src/views/ImportView.tsx index e27727dc3..b2d52858e 100644 --- a/apps/memos-local-plugin/viewer/src/views/ImportView.tsx +++ b/apps/memos-local-plugin/viewer/src/views/ImportView.tsx @@ -297,7 +297,7 @@ function NativeImportCard({ kind }: { kind: NativeImportKind }) { while (offset < knownScan.total && !stopRef.current) { const r = await api.post( `${cfg.endpoint}/run`, - { offset, limit: 25 }, + { offset, limit: 100 }, ); imported += r.imported; skipped += r.skipped; diff --git a/apps/memos-local-plugin/viewer/src/views/LogsView.tsx b/apps/memos-local-plugin/viewer/src/views/LogsView.tsx index abbd4fba8..abac410ed 100644 --- a/apps/memos-local-plugin/viewer/src/views/LogsView.tsx +++ b/apps/memos-local-plugin/viewer/src/views/LogsView.tsx @@ -1,5 +1,5 @@ /** - * Logs view — structured trail of `memory_search` and `memory_add` + * Logs view — structured trail of `memos_search` and `memory_add` * calls. Mirrors the legacy `memos-local-openclaw` v1 logs page so * each row shows the retrieved / filtered candidates (with scores * and origin tags) for search and the per-turn stored items for @@ -24,6 +24,7 @@ import type { ApiLogDTO } from "../api/types"; type ToolFilter = | "" + | "memos_search" | "memory_search" | "memory_add" | "skill_generate" @@ -48,7 +49,7 @@ type ToolFilter = type LogTag = | "" | "memory_add" - | "memory_search" + | "memos_search" | "task" | "skill" | "policy" @@ -64,7 +65,7 @@ type LogTag = const LOG_TAGS: Array<{ v: LogTag; k: string }> = [ { v: "", k: "common.all" }, { v: "memory_add", k: "logs.tag.memoryAdd" }, - { v: "memory_search", k: "logs.tag.memorySearch" }, + { v: "memos_search", k: "logs.tag.memorySearch" }, { v: "task", k: "logs.tag.task" }, { v: "skill", k: "logs.tag.skill" }, { v: "policy", k: "logs.tag.policy" }, @@ -74,7 +75,7 @@ const LOG_TAGS: Array<{ v: LogTag; k: string }> = [ ]; const BASIC_LOG_TAGS = LOG_TAGS.filter((tag) => - tag.v === "" || tag.v === "memory_add" || tag.v === "memory_search" + tag.v === "" || tag.v === "memory_add" || tag.v === "memos_search" ); /** @@ -85,7 +86,7 @@ const BASIC_LOG_TAGS = LOG_TAGS.filter((tag) => const ALLOWED_TOOLS: Record = { "": [], memory_add: ["memory_add"], - memory_search: ["memory_search"], + memos_search: ["memos_search", "memory_search"], task: ["task_done", "task_failed"], skill: ["skill_generate", "skill_evolve"], policy: ["policy_generate", "policy_evolve"], @@ -96,6 +97,7 @@ const ALLOWED_TOOLS: Record = { const BASIC_LOG_TOOLS = [ "memory_add", + "memos_search", "memory_search", ] as const satisfies readonly ToolFilter[]; @@ -120,7 +122,7 @@ type ViewMode = "chain" | "list"; function allowedToolsForTag(tag: LogTag, detailedLogs: boolean): readonly ToolFilter[] { if (detailedLogs) return ALLOWED_TOOLS[tag]; - if (tag === "memory_add" || tag === "memory_search") return ALLOWED_TOOLS[tag]; + if (tag === "memory_add" || tag === "memos_search") return ALLOWED_TOOLS[tag]; return BASIC_LOG_TOOLS; } @@ -154,7 +156,7 @@ export function LogsView() { useEffect(() => { if (detailedLogs) return; - if (tag !== "" && tag !== "memory_add" && tag !== "memory_search") { + if (tag !== "" && tag !== "memory_add" && tag !== "memos_search") { setTag(""); } if (failuresOnly) setFailuresOnly(false); @@ -547,7 +549,7 @@ function LogDetailBody({ input: unknown; output: unknown; }) { - if (log.toolName === "memory_search") { + if (log.toolName === "memos_search" || log.toolName === "memory_search") { return ; } if (log.toolName === "memory_add") { @@ -573,7 +575,7 @@ function LogDetailBody({ return ; } -// ─── memory_search template ───────────────────────────────────────────── +// ─── memos_search template ───────────────────────────────────────────── interface SearchInput { query?: string; @@ -605,6 +607,17 @@ interface RetrievalStatsPayload { channelHits?: Record; queryTokens?: number; queryTags?: string[]; + localReturned?: number; + hubReturned?: number; + hubKept?: number; + finalReturned?: number; + finalFilter?: { + outcome?: string; + kept?: number; + dropped?: number; + sufficient?: boolean | null; + deduped?: number; + }; embedding?: { attempted?: boolean; ok?: boolean; @@ -639,6 +652,7 @@ function MemorySearchDetail({ const hub = out.hubCandidates ?? []; const filtered = out.filtered ?? []; const dropped = out.droppedByLlm ?? []; + const totalCandidates = candidates.length + hub.length; return (
{inp.query && ( @@ -680,7 +694,7 @@ function MemorySearchDetail({ count={filtered.length} rows={filtered} emptyLabel={ - candidates.length > 0 + totalCandidates > 0 ? t("logs.search.noneRelevant") : t("logs.search.noCandidates") } @@ -707,6 +721,11 @@ function RetrievalFunnel({ stats }: { stats: RetrievalStatsPayload }) { const lf = stats.llmFilter ?? {}; const kept = lf.kept; const outcome = lf.outcome ?? "unknown"; + const finalFilter = stats.finalFilter; + const localFilterDeferred = outcome === "deferred_to_final"; + const finalLlmRan = finalFilter?.outcome === "llm_kept_all" || + finalFilter?.outcome === "llm_filtered" || + finalFilter?.outcome === "llm_failed_safe_cutoff"; const fmtNum = (n: number | undefined, digits = 3) => typeof n === "number" && Number.isFinite(n) ? n.toFixed(digits) : "—"; const channelEntries = Object.entries(stats.channelHits ?? {}).filter( @@ -734,9 +753,31 @@ function RetrievalFunnel({ stats }: { stats: RetrievalStatsPayload }) { dropped≥floor {dropped} )} {typeof kept === "number" && ( - llm kept {kept} + + {localFilterDeferred ? "local candidates" : "local llm kept"} {kept} + )} + {typeof stats.hubReturned === "number" && stats.hubReturned > 0 && ( + hub {stats.hubReturned} + )} + {typeof stats.hubKept === "number" && stats.hubReturned !== stats.hubKept && ( + hub kept {stats.hubKept} + )} + {typeof stats.finalReturned === "number" && ( + final {stats.finalReturned} + )} + {finalFilter && ( + + {finalLlmRan ? "final llm kept" : "final kept"} {finalFilter.kept ?? 0} + + )} + {finalFilter?.deduped ? ( + deduped {finalFilter.deduped} + ) : null} outcome {outcome} + {finalFilter?.outcome && finalFilter.outcome !== outcome && ( + final outcome {finalFilter.outcome} + )} {lf.sufficient !== null && lf.sufficient !== undefined && ( sufficient {String(lf.sufficient)} @@ -1311,7 +1352,7 @@ function parseJson(s: string): unknown { * the id. * * Precedence per tool: - * - memory_search → the query + kept/total counts + * - memos_search → the query + final/local+Hub counts * - memory_add → first 3 per-turn summaries (already meaningful) * - skill_* → `output.name` (e.g. "write_python_function_with_types") * - policy_* → `output.title` (e.g. "Write Python function …") @@ -1323,10 +1364,12 @@ function buildSummary(log: ApiLogDTO, input: unknown, output: unknown): string { const inp = (input ?? {}) as Record; const out = (output ?? {}) as Record; - if (log.toolName === "memory_search") { + if (log.toolName === "memos_search" || log.toolName === "memory_search") { const q = (inp.query as string | undefined) ?? "(empty)"; const kept = (out.filtered as unknown[] | undefined)?.length ?? 0; - const totalN = (out.candidates as unknown[] | undefined)?.length ?? 0; + const totalN = + ((out.candidates as unknown[] | undefined)?.length ?? 0) + + ((out.hubCandidates as unknown[] | undefined)?.length ?? 0); return `"${truncate(q, 60)}" — kept ${kept}/${totalN}`; } if (log.toolName === "memory_add") { @@ -1528,7 +1571,7 @@ function buildChainEvent(log: ApiLogDTO): ChainEvent { let stagePhase: string | undefined; let infraKind: ChainEvent["infraKind"]; - if (log.toolName === "memory_search") { + if (log.toolName === "memos_search" || log.toolName === "memory_search") { stage = "retrieval"; sessionId = pickStr(inp.sessionId); episodeId = pickStr(inp.episodeId); diff --git a/apps/memos-local-plugin/viewer/src/views/MemoriesView.tsx b/apps/memos-local-plugin/viewer/src/views/MemoriesView.tsx index f734e33c6..189bb3208 100644 --- a/apps/memos-local-plugin/viewer/src/views/MemoriesView.tsx +++ b/apps/memos-local-plugin/viewer/src/views/MemoriesView.tsx @@ -59,7 +59,12 @@ import { route } from "../stores/router"; import { clearEntryId } from "../stores/cross-link"; import type { TraceDTO } from "../api/types"; import { areAllIdsSelected, toggleIdsInSelection } from "../utils/selection"; -import { loadHubSharingEnabled } from "../utils/share"; +import { + loadHubSharingEnabled, + normalizeShareScope, + SHARE_SCOPE_OPTIONS, + type ShareScope, +} from "../utils/share"; type RoleFilter = "" | "user" | "assistant" | "tool"; @@ -92,7 +97,7 @@ interface MemoryGroup { hasReflection: boolean; ownerAgentKind: string; ownerProfileId: string; - scope: "private" | "local" | "public" | "hub"; + scope: ShareScope; shared: boolean; } @@ -108,6 +113,7 @@ export function MemoriesView() { const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); const [traces, setTraces] = useState([]); const [hasMore, setHasMore] = useState(false); const [total, setTotal] = useState(0); @@ -134,17 +140,26 @@ export function MemoriesView() { qs.set("limit", String(roleFilterActive ? ROLE_FILTER_FETCH_LIMIT : pageSize)); qs.set("offset", String(roleFilterActive ? 0 : opts.page * pageSize)); qs.set("groupByTurn", "true"); + qs.set("includeTotal", "false"); if (opts.q) qs.set("q", opts.q); appendNamespaceParams(qs, namespaceFilter); const res = await api.get(`/api/v1/traces?${qs.toString()}`); + const pageGroupCount = buildGroups(res.traces ?? []).length; setTraces(res.traces); setHasMore(roleFilterActive ? false : res.nextOffset != null); - setTotal(res.total ?? 0); + setTotal( + res.total ?? + opts.page * pageSize + + pageGroupCount + + (res.nextOffset != null ? pageSize : 0), + ); setPage(opts.page); - } catch { + setLoadError(null); + } catch (err) { setTraces([]); setHasMore(false); setTotal(0); + setLoadError((err as Error).message || "Failed to load memories"); } finally { setLoading(false); } @@ -392,7 +407,7 @@ export function MemoriesView() { */ const applyShareGroup = async ( g: MemoryGroup, - scope: "private" | "local" | "public" | "hub" | null, + scope: ShareScope | null, ) => { try { const updates = await Promise.all( @@ -432,6 +447,7 @@ export function MemoriesView() { setQuery(""); setNamespaceFilter(""); setSelected(new Set()); + setLoadError(null); void loadPage({ q: "", page: 0 }); }} > @@ -518,7 +534,17 @@ export function MemoriesView() {
)} - {loading && groups.length === 0 && ( + {!loading && loadError && ( +
+
+ +
+
Failed to load memories
+
{loadError}
+
+ )} + + {loading && groups.length === 0 && !loadError && (
{[0, 1, 2, 3, 4].map((i) => (
@@ -526,7 +552,7 @@ export function MemoriesView() {
)} - {!loading && groups.length === 0 && ( + {!loading && !loadError && groups.length === 0 && (
@@ -578,7 +604,6 @@ export function MemoriesView() { {namespaceLabel({ agentKind: g.ownerAgentKind, profileId: g.ownerProfileId, - count: g.ids.length, })} @@ -936,7 +961,7 @@ function buildGroups(traces: readonly TraceDTO[]): MemoryGroup[] { const ids = bucket.map((t) => t.id); const sumV = bucket.reduce((acc, t) => acc + (t.value ?? 0), 0); const sumA = bucket.reduce((acc, t) => acc + (t.alpha ?? 0), 0); - const scope: "private" | "local" | "public" | "hub" = head.share?.scope ?? "private"; + const scope = normalizeShareScope(head.share?.scope); return { turnKey: key, episodeId: head.episodeId ?? null, @@ -1078,7 +1103,7 @@ function TraceDrawer({ tags?: string[]; }, ) => Promise | void; - onShare: (scope: "private" | "local" | "public" | "hub" | null) => Promise | void; + onShare: (scope: ShareScope | null) => Promise | void; onDelete: () => Promise | void; }) { const head = group.head; @@ -1088,16 +1113,14 @@ function TraceDrawer({ const [userText, setUserText] = useState(head.userText ?? ""); const [agentText, setAgentText] = useState(head.agentText ?? ""); const [tags, setTags] = useState((head.tags ?? []).join(", ")); - const [scope, setScope] = useState<"private" | "local" | "public" | "hub">( - head.share?.scope ?? "public", - ); + const [scope, setScope] = useState(normalizeShareScope(head.share?.scope ?? "public")); useEffect(() => { setSummary(head.summary ?? ""); setUserText(head.userText ?? ""); setAgentText(head.agentText ?? ""); setTags((head.tags ?? []).join(", ")); - setScope(head.share?.scope ?? "public"); + setScope(normalizeShareScope(head.share?.scope ?? "public")); }, [head]); const title = displaySummary.slice(0, 100) || t("memories.detail.fallbackTitle"); @@ -1115,7 +1138,7 @@ function TraceDrawer({ setMode("view"); }; - const submitShare = (s: "private" | "local" | "public" | "hub" | null) => { + const submitShare = (s: ShareScope | null) => { void onShare(s); setMode("view"); }; @@ -1258,7 +1281,7 @@ function TraceDrawer({ -
- {/* - * Refresh — mirrors MemoriesView. Clears search + status - * filter, drops selection, and re-fetches page 0 so the user - * sees freshly-induced policies without a full page reload. - */} - -
-
- - {/* Row 1: search box */} -
- -
- - {/* Row 2: filter chips — own row, matches TasksView / MemoriesView */} -
-
- {statuses.map((s) => ( + {!lightweight.enabled && ( +
+ {/* + * Refresh — mirrors MemoriesView. Clears search + status + * filter, drops selection, and re-fetches page 0 so the user + * sees freshly-induced policies without a full page reload. + */} - ))} -
- +
+ )}
- {loading && rows.length === 0 && ( + {lightweight.loading && (
{[0, 1, 2].map((i) => (
))}
)} - {!loading && rows.length === 0 && ( -
-
-
{t("policies.empty")}
-
{t("policies.empty.hint")}
-
+ + {!lightweight.loading && lightweight.enabled && ( + )} - {rows.length > 0 && ( -
- {rows.map((p) => { - const isSel = selected.has(p.id); - return ( -
setDetail(p)} - > - -
-
{p.title || "(untitled)"}
-
- - {t(`status.${p.status}` as never)} - support {p.support} - gain {p.gain.toFixed(2)} - {(p.preference?.length ?? 0) > 0 && ( - - {t("policies.guidance.prefer")} {p.preference.length} - - )} - {(p.antiPattern?.length ?? 0) > 0 && ( - - {t("policies.guidance.avoid")} {p.antiPattern.length} - - )} - {new Date(p.updatedAt).toLocaleString()} + {!lightweight.loading && !lightweight.enabled && ( + <> + {/* Row 1: search box */} +
+ +
+ + {/* Row 2: filter chips — own row, matches TasksView / MemoriesView */} +
+
+ {statuses.map((s) => ( + + ))} +
+ +
+ + {loading && rows.length === 0 && ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ )} + {!loading && rows.length === 0 && ( +
+
+
{t("policies.empty")}
+
{t("policies.empty.hint")}
+
+ )} + + {rows.length > 0 && ( +
+ {rows.map((p) => { + const isSel = selected.has(p.id); + return ( +
setDetail(p)} + > + +
+
{p.title || "(untitled)"}
+
+ + {t(`status.${p.status}` as never)} + support {p.support} + gain {p.gain.toFixed(2)} + {(p.preference?.length ?? 0) > 0 && ( + + {t("policies.guidance.prefer")} {p.preference.length} + + )} + {(p.antiPattern?.length ?? 0) > 0 && ( + + {t("policies.guidance.avoid")} {p.antiPattern.length} + + )} + {new Date(p.updatedAt).toLocaleString()} +
+
+ {/* + * Lifecycle actions live in the drawer footer (PolicyDrawer). + * The row itself stays clean with just title + meta + chevron, + * matching the other list views. + */} +
+ +
-
- {/* - * Lifecycle actions live in the drawer footer (PolicyDrawer). - * The row itself stays clean with just title + meta + chevron, - * matching the other list views. - */} -
- -
+ ); + })}
- ); - })} -
- )} + )} - {(page > 0 || hasMore) && ( - { - void load({ q: query.trim(), status, page: nextPage }); - }} - /> - )} + {(page > 0 || hasMore) && ( + { + void load({ q: query.trim(), status, page: nextPage }); + }} + /> + )} - {detail && ( - { - setDetail(null); - clearEntryId(); - }} - onUpdated={(updated) => { - setRows((prev) => prev.map((r) => (r.id === updated.id ? updated : r))); - setDetail(updated); - }} - onStatusChange={async (p, next) => { - await setPolicyStatus(p, next); - // refresh the drawer with the new status. - setDetail((cur) => (cur ? { ...cur, status: next } : cur)); - }} - onDelete={(p) => deletePolicy(p)} - /> - )} + {detail && ( + { + setDetail(null); + clearEntryId(); + }} + onUpdated={(updated) => { + setRows((prev) => prev.map((r) => (r.id === updated.id ? updated : r))); + setDetail(updated); + }} + onStatusChange={async (p, next) => { + await setPolicyStatus(p, next); + // refresh the drawer with the new status. + setDetail((cur) => (cur ? { ...cur, status: next } : cur)); + }} + onDelete={(p) => deletePolicy(p)} + /> + )} - {selected.size > 0 && ( -
- - {t("common.selected", { n: selected.size })} - - - -
- -
+ {selected.size > 0 && ( +
+ + {t("common.selected", { n: selected.size })} + + + +
+ +
+ )} + )} {toast && ( @@ -397,9 +438,7 @@ function PolicyDrawer({ const [procedure, setProcedure] = useState(policy.procedure); const [verification, setVerification] = useState(policy.verification); const [boundary, setBoundary] = useState(policy.boundary); - const [scope, setScope] = useState<"private" | "local" | "public" | "hub">( - policy.share?.scope ?? "public", - ); + const [scope, setScope] = useState(normalizeShareScope(policy.share?.scope ?? "public")); const [busy, setBusy] = useState(false); useEffect(() => { @@ -408,7 +447,7 @@ function PolicyDrawer({ setProcedure(policy.procedure); setVerification(policy.verification); setBoundary(policy.boundary); - setScope(policy.share?.scope ?? "public"); + setScope(normalizeShareScope(policy.share?.scope ?? "public")); }, [policy]); const submitEdit = async () => { @@ -431,7 +470,7 @@ function PolicyDrawer({ } }; - const submitShare = async (s: "private" | "local" | "public" | "hub" | null) => { + const submitShare = async (s: ShareScope | null) => { setBusy(true); try { const updated = await api.post( @@ -643,7 +682,7 @@ function PolicyDrawer({ ); } -// ─── Account / password tab ────────────────────────────────────────────── - // ─── General tab (merged Account + General) ───────────────────────── function GeneralTab({ telemetry, logging, + algorithm, onPatchTelemetry, onPatchLogging, + onPatchAlgorithm, }: { telemetry: NonNullable; logging: NonNullable; + algorithm: AlgorithmBlock; onPatchTelemetry: ( p: Partial>, ) => void; onPatchLogging: ( p: Partial>, ) => void; + onPatchAlgorithm: (p: Partial) => void; }) { return (
@@ -843,7 +933,18 @@ function GeneralTab({
- +
+
+
+

{t("settings.general.lightweightMemory")}

+

{t("settings.general.lightweightMemory.desc")}

+
+ onPatchAlgorithm({ lightweightMemory: { enabled: !v } })} + /> +
+
@@ -871,6 +972,8 @@ function GeneralTab({
+ +
); diff --git a/apps/memos-local-plugin/viewer/src/views/SkillsView.tsx b/apps/memos-local-plugin/viewer/src/views/SkillsView.tsx index 312aa4cc3..d41c51bba 100644 --- a/apps/memos-local-plugin/viewer/src/views/SkillsView.tsx +++ b/apps/memos-local-plugin/viewer/src/views/SkillsView.tsx @@ -16,13 +16,20 @@ import { t } from "../stores/i18n"; import { Icon } from "../components/Icon"; import { Pager } from "../components/Pager"; import { ShareScopePill } from "../components/ShareScopePill"; +import { LightweightModeEmpty } from "../components/LightweightModeEmpty"; import { NamespaceSelect, appendNamespaceParams } from "../components/NamespaceSelect"; import { Markdown } from "../components/Markdown"; import { route } from "../stores/router"; import { clearEntryId, linkTo } from "../stores/cross-link"; import type { CoreEvent, SkillDTO } from "../api/types"; import { areAllIdsSelected, toggleIdsInSelection } from "../utils/selection"; -import { loadHubSharingEnabled } from "../utils/share"; +import { + loadHubSharingEnabled, + normalizeShareScope, + SHARE_SCOPE_OPTIONS, + type ShareScope, +} from "../utils/share"; +import { useLightweightMemoryMode } from "../hooks/useLightweightMemoryMode"; interface SkillUsage { sourcePolicies: Array<{ @@ -70,6 +77,7 @@ export function SkillsView() { const [selected, setSelected] = useState>(new Set()); const [refusalNotices, setRefusalNotices] = useState([]); const [showRefusalNotices, setShowRefusalNotices] = useState(false); + const lightweight = useLightweightMemoryMode(); const toggleSel = (id: string) => { setSelected((prev) => { const n = new Set(prev); @@ -109,8 +117,19 @@ export function SkillsView() { } }; useEffect(() => { + if (lightweight.loading || lightweight.enabled) return; void load(0); - }, [status, pageSize, namespaceFilter]); + }, [status, pageSize, namespaceFilter, lightweight.loading, lightweight.enabled]); + + useEffect(() => { + if (!lightweight.enabled) return; + setSkills([]); + setDetail(null); + setSelected(new Set()); + setHasMore(false); + setTotal(0); + setPage(0); + }, [lightweight.enabled]); useEffect(() => { const handle = openSse("/api/v1/events", (_, data) => { @@ -138,6 +157,7 @@ export function SkillsView() { // Deep-link: `#/skills?id=sk_xxx` auto-opens the drawer. useEffect(() => { + if (lightweight.loading || lightweight.enabled) return; const id = route.value.params.id; if (!id) return; const ctrl = new AbortController(); @@ -152,7 +172,7 @@ export function SkillsView() { }) .catch(() => void 0); return () => ctrl.abort(); - }, [route.value.params.id]); + }, [route.value.params.id, lightweight.loading, lightweight.enabled]); const filtered = (skills ?? []).filter((s) => { if (!query) return true; @@ -172,76 +192,44 @@ export function SkillsView() {

{t("skills.title")}

{t("skills.subtitle")}

-
- setShowRefusalNotices((v) => !v)} - onClear={() => { - setRefusalNotices([]); - setShowRefusalNotices(false); - }} - /> - {/* - * Refresh — matches MemoriesView / TasksView / PoliciesView / - * WorldModelsView. Clears search + status filter, drops - * selection, and re-fetches page 0 so the list visibly - * snaps back to "fresh top state". The old implementation - * only re-queried the CURRENT page with the CURRENT filters - * still applied, which looked like a no-op whenever the - * filtered slice hadn't actually changed. - */} - -
-
- -
- -
- -
-
- {[ - { v: "" as StatusFilter, k: "common.all" as const }, - { v: "active" as StatusFilter, k: "status.active" as const }, - { v: "candidate" as StatusFilter, k: "status.candidate" as const }, - { v: "archived" as StatusFilter, k: "status.archived" as const }, - ].map((opt) => ( + {!lightweight.enabled && ( +
+ setShowRefusalNotices((v) => !v)} + onClear={() => { + setRefusalNotices([]); + setShowRefusalNotices(false); + }} + /> + {/* + * Refresh — matches MemoriesView / TasksView / PoliciesView / + * WorldModelsView. Clears search + status filter, drops + * selection, and re-fetches page 0 so the list visibly + * snaps back to "fresh top state". The old implementation + * only re-queried the CURRENT page with the CURRENT filters + * still applied, which looked like a no-op whenever the + * filtered slice hadn't actually changed. + */} - ))} -
- +
+ )}
- {loading && ( + {lightweight.loading && (
{[0, 1, 2].map((i) => (
@@ -249,148 +237,201 @@ export function SkillsView() {
)} - {!loading && filtered.length === 0 && ( -
-
- -
-
{t("skills.empty")}
-
{t("skills.empty.hint")}
-
+ {!lightweight.loading && lightweight.enabled && ( + )} - {filtered.length > 0 && ( -
- {filtered.map((s) => { - const isSel = selected.has(s.id); - return ( -
setDetail(s)} - > -