diff --git a/apps/memos-local-plugin/.gitignore b/apps/memos-local-plugin/.gitignore index 20d09eb19..0c52ba684 100644 --- a/apps/memos-local-plugin/.gitignore +++ b/apps/memos-local-plugin/.gitignore @@ -1,7 +1,6 @@ node_modules/ dist/ -web/dist/ -site/dist/ +viewer/dist/ coverage/ # Local builds & artifacts @@ -18,3 +17,11 @@ coverage/ TODO.local.md AGENTS_*.md .test_* + +# ARMS telemetry credentials — generated by CI from secrets before +# `npm publish` (see scripts/generate-telemetry-credentials.cjs and +# .github/workflows/hermes-plugin-publish.yml). Never commit a real +# endpoint/pid: any developer with a local copy must keep it out of +# git. Counters the repo-root `.gitignore`'s `!apps/**/*.json` +# allow-rule. +telemetry.credentials.json diff --git a/apps/memos-local-plugin/.npmignore b/apps/memos-local-plugin/.npmignore index cc10cc5b0..0cc6f635d 100644 --- a/apps/memos-local-plugin/.npmignore +++ b/apps/memos-local-plugin/.npmignore @@ -8,9 +8,6 @@ tests/ .notes/ TODO.local.md -# Site is local-only; never publish dist -site/dist/ - # Editor / OS .DS_Store .vscode/ diff --git a/apps/memos-local-plugin/ARCHITECTURE.md b/apps/memos-local-plugin/ARCHITECTURE.md index 4fac437cd..7814a2d8f 100644 --- a/apps/memos-local-plugin/ARCHITECTURE.md +++ b/apps/memos-local-plugin/ARCHITECTURE.md @@ -3,7 +3,7 @@ This document is the living blueprint for `@memtensor/memos-local-plugin`. It covers the layering, the agent-agnostic core, the contract layer, the per-agent adapters, the runtime services (server + bridge), the viewer, and the supporting -docs/site/test infrastructure. +docs/test infrastructure. > If a module disagrees with this document, fix the document **or** the module. > Don't let them drift. @@ -74,12 +74,13 @@ docs/site/test infrastructure. ┌──────────────────┐ ┌──────────────────┐ │ server/ (HTTP) │ │ bridge.cts │ │ /api · /events │ │ JSON-RPC daemon │ - │ serves web/dist│ │ used by Hermes │ + │ serves viewer/ │ │ used by Hermes │ + │ dist │ │ │ └────────┬─────────┘ └──────────────────┘ │ ▼ ┌──────────────────────────┐ - │ web/ (viewer) │ + │ viewer/ │ │ Overview · Traces · … │ │ Logs · Settings · … │ └──────────────────────────┘ @@ -151,7 +152,6 @@ GET /api/skills list + lifecycle POST /api/feedback explicit user feedback GET /api/retrieval/preview run a tier1+2+3 retrieval against an arbitrary query GET /api/hub/* team-sharing surface -GET /api/changelog lists site/content/releases/*.md (read-only) GET /api/logs/tail channelled, paginated, with `?level=&channel=&limit=` GET /events SSE: every CoreEvent + every log line (after redact) ``` @@ -185,7 +185,7 @@ Python package. Implements Hermes' `MemoryProvider` interface and proxies to - `memos_provider/log_forwarder.py` — forward Python-side logs back over the bridge so everything ends up in the same `logs/` directory. -### 3.7 `web/` +### 3.7 `viewer/` Vite app, served at runtime by `server/static.ts`. Ten views map 1:1 to the algorithm's observable surface: @@ -203,16 +203,7 @@ algorithm's observable surface: | Logs | Channelled, level-filtered, real-time + tail | | Settings | Config editor (writes back to `config.yaml`) | -### 3.8 `site/` - -Local-only static site (Vite, separate config). Hosts: - -- The product landing page. -- User-facing docs (`site/content/docs/*.md`). -- All published release notes (`site/content/releases/.md`), indexed - by `site/scripts/build-index.ts`, gated by `release:check` in CI. - -### 3.9 `templates/` +### 3.8 `templates/` Plain files copied — never edited at runtime — by `install.sh`: @@ -220,9 +211,9 @@ Plain files copied — never edited at runtime — by `install.sh`: - `config.hermes.yaml` - `README.user.md` -### 3.10 `docs/` +### 3.9 `docs/` -Developer-facing docs that are too detailed for the marketing site: +Developer-facing docs: - `ALGORITHM.md` — the V7 spec, restated and indexed against the code. - `DATA-MODEL.md` — every table, every column, every index. @@ -396,9 +387,7 @@ Common helpers: ## 7. Release & versioning - SemVer. -- Every published version requires a `site/content/releases/.md` - (enforced by `npm run release:check`, run in CI). -- `CHANGELOG.md` at the project root is regenerated from those files. +- `CHANGELOG.md` at the project root is hand-maintained per release. - `core/update-check/` lets the running plugin notify users when a newer npm version is available. diff --git a/apps/memos-local-plugin/CHANGELOG.md b/apps/memos-local-plugin/CHANGELOG.md index 5010d3f3d..47fec5eff 100644 --- a/apps/memos-local-plugin/CHANGELOG.md +++ b/apps/memos-local-plugin/CHANGELOG.md @@ -1,13 +1,12 @@ # Changelog -All notable changes to `@memtensor/memos-local-plugin` are documented per -release in [`site/content/releases/`](./site/content/releases/). This file is -regenerated from those release notes by `npm run release:index`. - -> Do **not** edit this file by hand. Edit the per-version markdown in -> `site/content/releases/.md` instead. +Notable changes to `@memtensor/memos-local-plugin`. Maintained by hand; +for the full per-commit history use `git log` or the GitHub releases page. ## Index -- [`2.0.0-beta.1`](./site/content/releases/2.0.0-beta.1.md) — Complete end-to-end implementation: L1/L2/L3/Skill layers, three-tier retrieval, decision repair, crystallization, dual adapters, HTTP/SSE server, Vite viewer & product site. -- [`2.0.0-alpha.1`](./site/content/releases/2.0.0-alpha.1.md) — Project skeleton, agent-contract layer, install.sh entrypoint, viewer/site directory layout. +- `2.0.0-beta.1` — Complete end-to-end implementation: L1/L2/L3/Skill layers, + three-tier retrieval, decision repair, crystallization, dual adapters, + HTTP/SSE server, Vite viewer. +- `2.0.0-alpha.1` — Project skeleton, agent-contract layer, install.sh + entrypoint, viewer directory layout. diff --git a/apps/memos-local-plugin/README.md b/apps/memos-local-plugin/README.md index c38aabef1..d4871a34c 100644 --- a/apps/memos-local-plugin/README.md +++ b/apps/memos-local-plugin/README.md @@ -34,8 +34,7 @@ apps/memos-local-plugin/ ├── adapters/openclaw/ # In-process TS adapter for OpenClaw ├── adapters/hermes/ # Python adapter that talks to bridge.cts ├── templates/ # config.yaml templates copied to the user's home on install -├── web/ # Runtime viewer (Vite, served by server/) -├── site/ # Local-only marketing site + docs + release notes +├── viewer/ # Runtime viewer (Vite, served by server/) ├── docs/ # Developer-facing docs (algorithm, data model, prompts, …) ├── scripts/ # Build / packaging / release helpers └── tests/ # unit / integration / e2e (vitest) diff --git a/apps/memos-local-plugin/adapters/hermes/install.hermes.sh b/apps/memos-local-plugin/adapters/hermes/install.hermes.sh index d3f24a27c..4d431c792 100755 --- a/apps/memos-local-plugin/adapters/hermes/install.hermes.sh +++ b/apps/memos-local-plugin/adapters/hermes/install.hermes.sh @@ -6,8 +6,8 @@ # extras: # # 1. Install node_modules inside $PREFIX (idempotent). -# 2. Build the viewer + site bundles so the HTTP server has static -# assets available. +# 2. Build the viewer bundle so the HTTP server has static assets +# available. # 3. Symlink the Python memos_provider package into the Hermes # plugins directory so `from memos_provider import MemTensorProvider` # resolves from Hermes without extra path munging. @@ -41,7 +41,7 @@ fi # ── 2. viewer bundle ────────────────────────────────────────────────────────── if [[ -x "./node_modules/.bin/vite" ]]; then - log "Building viewer bundle → web/dist/" + log "Building viewer bundle → viewer/dist/" ./node_modules/.bin/vite build --config vite.config.ts >/dev/null else warn "vite not found in node_modules; skipping bundle build" @@ -64,4 +64,3 @@ log "Hermes adapter install complete." log " Plugin code: $PREFIX" log " Runtime data: $HOME_DIR" log " Viewer: http://127.0.0.1:18910/" -log " Site: http://127.0.0.1:18910/site/" 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 f93443c8c..4ea213f35 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py @@ -386,6 +386,24 @@ def _runtime_namespace(self) -> dict[str, Any]: "profileLabel": profile_id, } + def _record_namespace(self) -> dict[str, Any]: + """Namespace used for write-path records. + + Hermes delegation hooks can be global and occasionally arrive through + a provider instance whose `profileId` fell back to `default` while + `agent_identity` still carries the real profile label (for example + coder10). For writes, prefer the concrete non-default label so + subagent outcome traces inherit the parent profile instead of leaking + into hermes/default. + """ + ns = dict(self._runtime_namespace()) + label = (self._agent_identity or ns.get("profileLabel") or "").strip() + profile_id = str(ns.get("profileId") or "").strip() + if profile_id in ("", "default", "hermes") and label and label not in ("default", "hermes"): + ns["profileId"] = label + ns["profileLabel"] = label + return ns + def _register_tool_call_hook(self) -> None: if self._hook_registered: return @@ -818,20 +836,25 @@ def sync_turn( len(thinking), ) ts_ms = int(time.time() * 1000) + is_feedback_turn = _is_verifier_feedback_prompt(user) feedback_submitted = False try: if user and not self._episode_id: self._turn_start(user, session_id=session_id or self._session_id) - self._turn_end( + current_trace_id = self._turn_end( user, assistant, tool_calls, ts_ms, agent_thinking=thinking, ) - if _is_verifier_feedback_prompt(user): - self._submit_verifier_feedback(user, assistant, ts_ms) - feedback_submitted = True + if is_feedback_turn: + feedback_submitted = self._try_submit_verifier_feedback( + user, + assistant, + ts_ms, + trace_id=current_trace_id, + ) except Exception as err: if not self._is_transport_closed(err): logger.warning("MemOS: sync_turn turn.end failed — %s", err) @@ -845,21 +868,36 @@ def sync_turn( self._reconnect_bridge(session_id or self._session_id, timeout=75.0) if user: self._turn_start(user, session_id=session_id or self._session_id) - self._turn_end( + current_trace_id = self._turn_end( user, assistant, tool_calls, ts_ms, agent_thinking=thinking, ) - if _is_verifier_feedback_prompt(user) and not feedback_submitted: - self._submit_verifier_feedback(user, assistant, ts_ms) - feedback_submitted = True + if is_feedback_turn and not feedback_submitted: + feedback_submitted = self._try_submit_verifier_feedback( + user, + assistant, + ts_ms, + trace_id=current_trace_id, + ) except Exception: logger.exception( "MemOS: sync_turn failed after bridge reconnect; " "memory turn was not persisted" ) + if is_feedback_turn and not feedback_submitted: + # turn.end may time out while the bridge continues lite capture in + # the background. Preserve the user's explicit signal at episode + # scope instead of dropping Decision Repair entirely. + self._try_submit_verifier_feedback( + user, + assistant, + ts_ms, + trace_id="", + fallback=True, + ) if user_content: self._last_user_text = user_content @@ -883,12 +921,16 @@ def on_delegation( try: if not self._episode_id and self._last_user_text: self._turn_start(self._last_user_text, session_id=self._session_id) + namespace = self._record_namespace() hook_meta = { "hookKwargs": kwargs, + "namespace": namespace, } self._bridge.request( "subagent.record", { + "agent": "hermes", + "namespace": namespace, "sessionId": self._session_id, "episodeId": self._episode_id or None, "childSessionId": child_session_id or None, @@ -897,6 +939,11 @@ def on_delegation( "toolCalls": self._extract_child_tool_calls(child_session_id), "ts": int(time.time() * 1000), "meta": hook_meta, + "contextHints": { + "agentIdentity": self._agent_identity, + "namespace": namespace, + **self._host_runtime_context(), + }, }, ) except Exception as err: @@ -1684,9 +1731,9 @@ def _turn_end( ts_ms: int, *, agent_thinking: str = "", - ) -> None: + ) -> str: if not self._bridge: - return + return "" # Strip private book-keeping fields before sending. clean_tool_calls = [ {k: v for k, v in tc.items() if k not in {"_id", "_ids"}} for tc in tool_calls @@ -1713,16 +1760,44 @@ def _turn_end( if result and isinstance(result, dict): trace_ids = result.get("traceIds", []) if trace_ids and len(trace_ids) > 0: - self._last_trace_id = trace_ids[-1] # Last trace is the current turn + trace_id = trace_ids[-1] # Last trace is the current turn + self._last_trace_id = trace_id + return trace_id + return "" + + def _try_submit_verifier_feedback( + self, + user_content: str, + assistant_content: str, + ts_ms: int, + *, + trace_id: str = "", + fallback: bool = False, + ) -> bool: + try: + submitted = self._submit_verifier_feedback( + user_content, + assistant_content, + ts_ms, + trace_id=trace_id, + ) + if submitted and fallback: + logger.info("MemOS: submitted verifier feedback without trace binding") + return submitted + except Exception as err: + logger.warning("MemOS: verifier feedback submit failed — %s", err) + return False def _submit_verifier_feedback( self, user_content: str, assistant_content: str, ts_ms: int, - ) -> None: + *, + trace_id: str = "", + ) -> bool: if not self._bridge or not self._episode_id: - return + return False polarity = _feedback_polarity(user_content) magnitude = _feedback_magnitude(user_content, polarity) raw = { @@ -1740,10 +1815,10 @@ def _submit_verifier_feedback( "raw": raw, "ts": ts_ms, } - # Include the last trace ID if available - if self._last_trace_id: - payload["traceId"] = self._last_trace_id - self._bridge.request("feedback.submit", payload) + if trace_id: + payload["traceId"] = trace_id + self._bridge.request("feedback.submit", payload, timeout=75.0) + return True # ─── Discovery entry points ─────────────────────────────────────────────── 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 e3df72c7d..25396ccb9 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 @@ -103,13 +103,15 @@ def __init__( # 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` binary, which handles - # both jobs (strip types + extension rewrite). `tsx` is declared - # as a production dependency in package.json so it's always present - # under node_modules/.bin after `npm install`. - tsx_bin = plugin_root / "node_modules" / ".bin" / "tsx" - if tsx_bin.exists(): - cmd = [node, str(tsx_bin), script, f"--agent={agent}"] + # 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. + tsx_cli = plugin_root / "node_modules" / "tsx" / "dist" / "cli.mjs" + if tsx_cli.exists(): + cmd = [node, str(tsx_cli), script, f"--agent={agent}"] else: # Fallback path: `node --import tsx` reproduces the same loader # inline. Requires tsx to be resolvable as a package from the @@ -123,6 +125,8 @@ def __init__( stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", + errors="replace", bufsize=1, env=env, cwd=str(plugin_root), diff --git a/apps/memos-local-plugin/adapters/openclaw/bridge.ts b/apps/memos-local-plugin/adapters/openclaw/bridge.ts index b55b1c065..aa81f92e8 100644 --- a/apps/memos-local-plugin/adapters/openclaw/bridge.ts +++ b/apps/memos-local-plugin/adapters/openclaw/bridge.ts @@ -70,6 +70,7 @@ import type { const TOOL_RESULT_ROLES = new Set([ "toolResult", // pi-ai canonical + "toolresult", // lower-case gateway/UI normalizer variants "tool", // OpenAI legacy "tool_result", // some Anthropic SDKs / older bridges "tool_response", // older variants @@ -156,13 +157,13 @@ export function flattenMessages(input: unknown[] | undefined): FlatMessage[] { textBuf += (textBuf ? "\n" : "") + b.text; } else if (type === "thinking" && typeof b.thinking === "string") { thinkingBuf += (thinkingBuf ? "\n\n" : "") + b.thinking; - } else if (type === "toolCall") { + } else if (isToolCallBlockType(type)) { inlineToolCalls.push({ role: "tool_call", content: "", toolName: typeof b.name === "string" ? b.name : "unknown", - toolCallId: typeof b.id === "string" ? b.id : undefined, - toolInput: b.arguments, + toolCallId: pickToolCallId(b, m), + toolInput: pickToolInput(b), ts, }); } else if (!type && typeof b.text === "string") { @@ -246,6 +247,63 @@ export function flattenMessages(input: unknown[] | undefined): FlatMessage[] { return out; } +function isToolCallBlockType(type: string): boolean { + const normalized = type.trim().toLowerCase(); + return ( + normalized === "toolcall" || + normalized === "tool_call" || + normalized === "tooluse" || + normalized === "tool_use" || + normalized === "functioncall" || + normalized === "function_call" + ); +} + +function pickToolCallId( + block: Record, + message?: Record, +): string | undefined { + return firstString( + block.id, + block.toolCallId, + block.tool_call_id, + block.callId, + block.call_id, + block.toolUseId, + block.tool_use_id, + message?.toolCallId, + message?.tool_call_id, + ); +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value; + } + return undefined; +} + +function pickToolInput(block: Record): unknown { + if ("arguments" in block) return block.arguments; + if ("args" in block) return block.args; + if ("input" in block) return block.input; + if (typeof block.partialJson === "string") { + try { + return JSON.parse(block.partialJson); + } catch { + return block.partialJson; + } + } + if (typeof block.partialArgs === "string") { + try { + return JSON.parse(block.partialArgs); + } catch { + return block.partialArgs; + } + } + return undefined; +} + /** * Extract the visible text from a `Message.content` value, supporting * both the pi-ai shapes (string OR `(TextContent|ImageContent)[]`) and @@ -526,9 +584,26 @@ export function extractTurn(messages: FlatMessage[], now: number): CapturedTurn const userText = messages[lastUserIdx].content.trim(); const tail = messages.slice(lastUserIdx + 1); - const pendingCalls = new Map & { _id?: string }>(); + type PendingToolCall = Partial & { _id?: string }; + const pendingCalls = new Map(); const toolCalls: ToolCallDTO[] = []; + const enqueuePendingCall = (key: string, stub: PendingToolCall): void => { + const queue = pendingCalls.get(key); + if (queue) { + queue.push(stub); + } else { + pendingCalls.set(key, [stub]); + } + }; + const takePendingCall = (key: string): PendingToolCall | undefined => { + const queue = pendingCalls.get(key); + if (!queue || queue.length === 0) return undefined; + const stub = queue.shift(); + if (queue.length === 0) pendingCalls.delete(key); + return stub; + }; + // Two separate buffers accumulate content not yet assigned to a tool. // // `pendingThinking`: Claude extended-thinking blocks (`ThinkingContent`) @@ -561,7 +636,7 @@ export function extractTurn(messages: FlatMessage[], now: number): CapturedTurn pendingAssistant = []; const key = m.toolCallId ?? m.toolName; - pendingCalls.set(key, { + enqueuePendingCall(key, { _id: m.toolCallId, name: m.toolName, input: m.toolInput, @@ -572,7 +647,7 @@ export function extractTurn(messages: FlatMessage[], now: number): CapturedTurn } if (m.role === "tool_result") { const key = m.toolCallId ?? m.toolName ?? ""; - const stub = pendingCalls.get(key); + const stub = key ? takePendingCall(key) : undefined; const errorCode = stub ? m.errorCode ?? (m.isError ? "tool_error" : undefined) : m.errorCode ?? (m.isError ? "tool_error" : undefined); @@ -581,25 +656,28 @@ export function extractTurn(messages: FlatMessage[], now: number): CapturedTurn input: stub?.input, output: m.content || undefined, errorCode, + toolCallId: stub?._id ?? m.toolCallId, startedAt: stub?.startedAt ?? (m.ts ?? now), endedAt: m.ts ?? now, thinkingBefore: stub?.thinkingBefore, }); - if (key) pendingCalls.delete(key); continue; } } - for (const stub of pendingCalls.values()) { - if (!stub.name) continue; - toolCalls.push({ - name: stub.name, - input: stub.input, - output: undefined, - startedAt: stub.startedAt ?? now, - endedAt: now, - thinkingBefore: stub.thinkingBefore, - }); + for (const queue of pendingCalls.values()) { + for (const stub of queue) { + if (!stub.name) continue; + toolCalls.push({ + name: stub.name, + input: stub.input, + output: undefined, + toolCallId: stub._id, + startedAt: stub.startedAt ?? now, + endedAt: now, + thinkingBefore: stub.thinkingBefore, + }); + } } const agentThinking = pendingThinking.join("\n\n").trim(); @@ -611,6 +689,56 @@ export function extractTurn(messages: FlatMessage[], now: number): CapturedTurn }; } +function mergeToolCalls( + captured: readonly ToolCallDTO[], + observed: readonly ToolCallDTO[], +): ToolCallDTO[] { + if (observed.length === 0) return [...captured]; + const out = captured.map((tc) => ({ ...tc })); + for (const obs of observed) { + const idx = out.findIndex((existing) => toolCallsMatch(existing, obs)); + if (idx >= 0) { + out[idx] = mergeToolCall(out[idx]!, obs); + } else { + out.push({ ...obs }); + } + } + return out.sort((a, b) => { + const at = a.startedAt ?? a.endedAt ?? 0; + const bt = b.startedAt ?? b.endedAt ?? 0; + return at - bt; + }); +} + +function mergeToolCall(existing: ToolCallDTO, observed: ToolCallDTO): ToolCallDTO { + return { + ...observed, + ...existing, + input: existing.input ?? observed.input, + output: existing.output ?? observed.output, + errorCode: existing.errorCode ?? observed.errorCode, + toolCallId: existing.toolCallId ?? observed.toolCallId, + startedAt: existing.startedAt ?? observed.startedAt, + endedAt: existing.endedAt ?? observed.endedAt, + thinkingBefore: existing.thinkingBefore ?? observed.thinkingBefore, + assistantTextBefore: existing.assistantTextBefore ?? observed.assistantTextBefore, + }; +} + +function toolCallsMatch(a: ToolCallDTO, b: ToolCallDTO): boolean { + if (a.toolCallId && b.toolCallId) return a.toolCallId === b.toolCallId; + if (a.toolCallId || b.toolCallId) return false; + return a.name === b.name && stableStringify(a.input) === stableStringify(b.input); +} + +function stableStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + // ─── Session identity ────────────────────────────────────────────────────── /** @@ -781,7 +909,16 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { let episodeBindingSeq = 0; // Per-toolCallId start timestamps so `after_tool_call` can compute duration // when the host doesn't populate `durationMs`. - const toolCallStartedAt = new Map(); + const toolCallStartedAt = new Map; + }>(); + type ObservedToolCall = ToolCallDTO & { runId?: string; order: number }; + const observedToolCallsBySession = new Map(); + let observedToolCallSeq = 0; const spawnedSubagents = new Map(); const pendingSubagentSessions = new Set(); + function rememberObservedToolCall( + sessionId: SessionId, + runId: string | undefined, + tc: ToolCallDTO, + ): void { + const list = observedToolCallsBySession.get(sessionId) ?? []; + list.push({ ...tc, runId, order: ++observedToolCallSeq }); + observedToolCallsBySession.set(sessionId, list.slice(-200)); + } + + function takeObservedToolCalls( + sessionId: SessionId, + runId: string | undefined, + ): ToolCallDTO[] { + const list = observedToolCallsBySession.get(sessionId) ?? []; + if (list.length === 0) return []; + + const matched: ObservedToolCall[] = []; + const rest: ObservedToolCall[] = []; + for (const tc of list) { + const sameRun = runId ? tc.runId === runId || !tc.runId : true; + if (sameRun) matched.push(tc); + else rest.push(tc); + } + + if (rest.length > 0) observedToolCallsBySession.set(sessionId, rest); + else observedToolCallsBySession.delete(sessionId); + + return matched + .slice() + .sort((a, b) => (a.startedAt ?? a.order) - (b.startedAt ?? b.order)) + .map(({ runId: _runId, order: _order, ...tc }) => tc); + } + async function ensureSession( agentId: string | undefined, sessionKey: string | undefined, @@ -1052,8 +1223,12 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { }); return; } + const toolCalls = mergeToolCalls( + turn.toolCalls, + takeObservedToolCalls(sessionId, ctx.runId), + ); const isSubagentAnnouncement = isOpenClawSubagentAnnouncementPrompt(turn.userText); - const hasSubagentSpawn = turn.toolCalls.some((tc) => tc.name === "sessions_spawn"); + const hasSubagentSpawn = toolCalls.some((tc) => tc.name === "sessions_spawn"); // Resolve (or lazily open) the target episode. Three cases: // 1. `before_prompt_build` already ran this turn → we have the @@ -1088,7 +1263,7 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { episodeId, agentText: turn.agentText, agentThinking: turn.agentThinking, - toolCalls: turn.toolCalls, + toolCalls, reflection: turn.reflection, contextHints: { namespace }, ts: now(), @@ -1101,7 +1276,7 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { sessionId, traceId: res.traceId, episodeId: res.episodeId, - tools: turn.toolCalls.length, + tools: toolCalls.length, success: event.success, durationMs: event.durationMs, }); @@ -1120,6 +1295,7 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { await opts.core.closeSession(sessionId); messageCursor.delete(sessionId); forgetSessionBindings(sessionId); + observedToolCallsBySession.delete(sessionId); lastUserTextBySession.delete(sessionId); } } catch (err) { @@ -1131,13 +1307,20 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { } function handleBeforeToolCall( - _event: BeforeToolCallEvent, + event: BeforeToolCallEvent, ctx: PluginHookToolContext, ): void { - if (!ctx.toolCallId) return; + const toolCallId = ctx.toolCallId ?? event.toolCallId; + if (!toolCallId) return; if (isEphemeralSessionKey(ctx.sessionKey)) return; const sessionId = bridgeSessionId(ctx.agentId ?? "main", ctx.sessionKey ?? "default"); - toolCallStartedAt.set(ctx.toolCallId, { ts: now(), sessionId }); + toolCallStartedAt.set(toolCallId, { + ts: now(), + sessionId, + runId: ctx.runId ?? event.runId, + toolName: ctx.toolName ?? event.toolName, + params: event.params, + }); } async function handleAfterToolCall( @@ -1147,8 +1330,9 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { if (isEphemeralSessionKey(ctx.sessionKey)) return; try { const sessionId = bridgeSessionId(ctx.agentId ?? "main", ctx.sessionKey ?? "default"); - const started = ctx.toolCallId ? toolCallStartedAt.get(ctx.toolCallId) : undefined; - if (ctx.toolCallId) toolCallStartedAt.delete(ctx.toolCallId); + const toolCallId = ctx.toolCallId ?? event.toolCallId; + const started = toolCallId ? toolCallStartedAt.get(toolCallId) : undefined; + if (toolCallId) toolCallStartedAt.delete(toolCallId); const endedAt = now(); const durationMs = @@ -1157,11 +1341,22 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { : started ? Math.max(0, endedAt - started.ts) : 0; + const toolName = event.toolName || started?.toolName || ctx.toolName || "unknown"; + const startedAt = started?.ts; + rememberObservedToolCall(sessionId, ctx.runId ?? event.runId ?? started?.runId, { + name: toolName, + input: event.params ?? started?.params, + output: event.result, + errorCode: event.error, + toolCallId, + startedAt, + endedAt, + }); opts.core.recordToolOutcome({ sessionId, episodeId: currentEpisodeId(sessionId), - tool: event.toolName, + tool: toolName, success: !event.error, errorCode: event.error, durationMs, @@ -1211,6 +1406,7 @@ export function createOpenClawBridge(opts: BridgeOptions): BridgeHandle { await opts.core.closeSession(sessionId); messageCursor.delete(sessionId); forgetSessionBindings(sessionId); + observedToolCallsBySession.delete(sessionId); lastUserTextBySession.delete(sessionId); opts.log.debug("memos.session.ended", { sessionId: event.sessionId, diff --git a/apps/memos-local-plugin/adapters/openclaw/index.ts b/apps/memos-local-plugin/adapters/openclaw/index.ts index 053235fd1..acb0bf36a 100644 --- a/apps/memos-local-plugin/adapters/openclaw/index.ts +++ b/apps/memos-local-plugin/adapters/openclaw/index.ts @@ -40,6 +40,7 @@ import { rootLogger, memoryBuffer } from "../../core/logger/index.js"; import type { MemoryCore } from "../../agent-contract/memory-core.js"; import { startHttpServer } from "../../server/http.js"; import type { ServerHandle } from "../../server/types.js"; +import { Telemetry } from "../../core/telemetry/index.js"; // ─── Plugin metadata ─────────────────────────────────────────────────────── @@ -82,16 +83,40 @@ interface PluginRuntime { shutdown: () => Promise; } +/** + * Locate the plugin source root (the directory holding `package.json`, + * `bridge.cts`, etc.). Two layouts to support: built tarball + * (`/dist/adapters/openclaw`) and source/tests + * (`/adapters/openclaw`). Returned path is the one used by + * `Telemetry` to find `telemetry.credentials.json` (CI writes it + * here pre-publish via `scripts/generate-telemetry-credentials.cjs`). + */ +function resolvePluginRoot(): string | undefined { + try { + const thisFile = fileURLToPath(import.meta.url); + const adapterDir = path.dirname(thisFile); // .../adapters/openclaw + const candidates = [ + path.resolve(adapterDir, "..", "..", ".."), + path.resolve(adapterDir, "..", ".."), + ]; + return candidates.find((candidate) => + existsSync(path.join(candidate, "package.json")), + ); + } catch { + return undefined; + } +} + /** Locate the bundled viewer static assets relative to the plugin root. */ function resolveViewerStaticRoot(): string | undefined { // Built packages load from `/dist/adapters`; source tests load - // from `/adapters`. The viewer bundle remains at `web/dist`. + // from `/adapters`. The viewer bundle remains at `viewer/dist`. try { const thisFile = fileURLToPath(import.meta.url); const adapterDir = path.dirname(thisFile); // .../adapters/openclaw const candidates = [ - path.resolve(adapterDir, "..", "..", "..", "web", "dist"), - path.resolve(adapterDir, "..", "..", "web", "dist"), + path.resolve(adapterDir, "..", "..", "..", "viewer", "dist"), + path.resolve(adapterDir, "..", "..", "viewer", "dist"), ]; return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]; } catch { @@ -112,6 +137,35 @@ 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` + // / `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 + // hermes-side `bridge.cts` was emitting events. + // + // Order matters: + // 1. `new Telemetry` reads `config.telemetry` and the credentials + // file under the plugin source root. + // 2. `bindTelemetry` must run before any turn so that + // `memory-core.ts`'s `if (telemetry)` guards see a non-null + // instance on the very first `onTurnStart`. + // 3. `trackPluginStarted` immediately after also fires + // `daily_active` (with persistent dedup; see sender.ts). + // `core.shutdown()` flushes telemetry as part of its `finally` + // block, so we don't need to await `telemetry.shutdown()` here. + const telemetry = new Telemetry( + config.telemetry ?? {}, + home.root, + PLUGIN_VERSION, + rootLogger.child({ channel: "core.telemetry" }), + resolvePluginRoot(), + ); + ( + core as { bindTelemetry?: (t: InstanceType) => void } + ).bindTelemetry?.(telemetry); + telemetry.trackPluginStarted("openclaw"); + const bridge = createOpenClawBridge({ agent: "openclaw", core, @@ -131,6 +185,7 @@ async function createRuntime(api: OpenClawPluginApi): Promise { core, home, logTail: () => memoryBuffer().tail({ limit: 200 }), + telemetry, }, { port: OPENCLAW_VIEWER_PORT, diff --git a/apps/memos-local-plugin/adapters/openclaw/install.openclaw.sh b/apps/memos-local-plugin/adapters/openclaw/install.openclaw.sh index 634c522e0..ec14ca332 100755 --- a/apps/memos-local-plugin/adapters/openclaw/install.openclaw.sh +++ b/apps/memos-local-plugin/adapters/openclaw/install.openclaw.sh @@ -6,8 +6,8 @@ # specific bits: # # 1. Install node_modules inside $PREFIX (one-time, idempotent). -# 2. Build the viewer + site bundles so the HTTP server has static -# assets to serve. +# 2. Build the viewer bundle so the HTTP server has static assets +# to serve. # # We never touch the OpenClaw host process itself — the plugin loads # on demand when the host's plugin manager discovers $PREFIX. @@ -37,7 +37,7 @@ fi # ── 2. viewer bundle ────────────────────────────────────────────────────────── if [[ -x "./node_modules/.bin/vite" ]]; then - log "Building viewer bundle → web/dist/" + log "Building viewer bundle → viewer/dist/" ./node_modules/.bin/vite build --config vite.config.ts >/dev/null else warn "vite not found in node_modules; skipping bundle build" diff --git a/apps/memos-local-plugin/agent-contract/episode-status.ts b/apps/memos-local-plugin/agent-contract/episode-status.ts new file mode 100644 index 000000000..7c0adad46 --- /dev/null +++ b/apps/memos-local-plugin/agent-contract/episode-status.ts @@ -0,0 +1,106 @@ +/** + * Shared episode-status derivation. + * + * Both the viewer (Tasks list filter chips) and the HTTP server + * (`GET /api/v1/episodes?status=…`) need to classify an + * `EpisodeListItemDTO` into a coarse task-level status: one of + * `active | completed | skipped | failed`. Without a shared source of + * truth the two sides drift — e.g. server-side "failed" filtering + * leaves rows the client renders as "completed" — so this module is + * the single derivation point. + * + * Keep this file framework-free: it's imported by the Vite-bundled + * viewer, the Node HTTP server, and unit tests. No DOM, no Node + * built-ins. + */ +import type { EpisodeListItemDTO } from "./dto.ts"; + +/** + * Filter slug accepted by `GET /api/v1/episodes?status=…` and the + * viewer's task-status chip group. + * + * - `""` → no filter (default). + * - `"active"` → ongoing episodes (open or recently finalised). + * - `"completed"`→ closed and credited as useful. + * - `"skipped"` → closed but the reward pipeline opted out. + * - `"failed"` → closed with a clearly-negative R_task. + */ +export type TaskStatusFilter = + | "" + | "active" + | "completed" + | "skipped" + | "failed"; + +/** Concrete derived status (excludes the empty "no filter" sentinel). */ +export type DerivedTaskStatus = Exclude; + +/** + * Reward floor below which an episode counts as "failed". Slight + * negatives or below-threshold positives still read as "completed" in + * the task list — the soft-fail framing (未达沉淀阈值) lives on the + * skill pipeline pill, not the main task status. + */ +export const R_NEGATIVE_FLOOR = -0.5; + +/** + * Recently-finalized grace window: a closed-but-just-ended episode + * may still be reopened by the next user turn, so we keep showing it + * as "active" for two minutes. + */ +export const ACTIVE_GRACE_WINDOW_MS = 2 * 60 * 1000; + +/** + * Derive the coarse task status of an episode row. + * + * The order below is significant — earlier branches win. Keep this + * in lock-step with the legacy plugin's task list and with the + * `pill--` styling on the viewer. + * + * @param row episode list item DTO + * @param now optional override for the current epoch (used in tests + * so the grace window is deterministic). + */ +export function deriveEpisodeStatus( + row: EpisodeListItemDTO, + now: number = Date.now(), +): DerivedTaskStatus { + if (row.status === "open") return "active"; + if (row.closeReason === "finalized" && row.endedAt != null) { + if (now - row.endedAt < ACTIVE_GRACE_WINDOW_MS) return "active"; + } + // Reward-scored episodes are classified by R_task regardless of + // how they were closed (finalized or abandoned). + if (row.rTask != null && row.rTask <= R_NEGATIVE_FLOOR) return "failed"; + if (row.rTask != null) return "completed"; + if (row.rewardSkipped) return "skipped"; + // Skill pipeline produced a skill → the task contributed + // meaningful knowledge even when rTask is null (e.g. plugin + // crashed after skill generation but before rTask was persisted). + if (row.skillStatus === "generated" || row.skillStatus === "upgraded") { + return "completed"; + } + if (row.closeReason === "abandoned") return "skipped"; + if ((row.turnCount ?? 0) >= 2) return "completed"; + return "skipped"; +} + +/** + * Type-guard for the `status` query param. Anything outside the + * accepted set collapses to `""` (no filter), matching the viewer's + * default chip. + */ +export function parseTaskStatusFilter(raw: string | null | undefined): TaskStatusFilter { + if (raw == null) return ""; + const trimmed = raw.trim(); + switch (trimmed) { + case "active": + case "completed": + case "skipped": + case "failed": + return trimmed; + case "": + default: + return ""; + } +} diff --git a/apps/memos-local-plugin/agent-contract/memory-core.ts b/apps/memos-local-plugin/agent-contract/memory-core.ts index 6c846fb08..5c648e5bb 100644 --- a/apps/memos-local-plugin/agent-contract/memory-core.ts +++ b/apps/memos-local-plugin/agent-contract/memory-core.ts @@ -116,6 +116,43 @@ export interface ModelHealth { lastError: { at: number; message: string } | null; } +// ─── Embedding maintenance ─────────────────────────────────────────────────── + +export type EmbeddingMaintenanceMode = "repair" | "rebuild"; + +export interface EmbeddingMaintenanceStats { + dimension: number; + available: boolean; + totalSlots: number; + ready: number; + missing: number; + dimMismatch: number; + needsRepair: number; + byKind: Record< + "trace" | "policy" | "world_model" | "skill", + { + totalSlots: number; + ready: number; + missing: number; + dimMismatch: number; + needsRepair: number; + } + >; +} + +export interface EmbeddingMaintenanceRunResult { + mode: EmbeddingMaintenanceMode; + processed: number; + updated: number; + failed: number; + offset: number; + nextOffset: number; + done: boolean; + statsBefore: EmbeddingMaintenanceStats; + statsAfter: EmbeddingMaintenanceStats; + error?: string; +} + // ─── Subscriptions ──────────────────────────────────────────────────────────── export type Unsubscribe = () => void; @@ -204,8 +241,8 @@ export interface MemoryCore { sharedAt?: number | null; }, ): Promise; - getPolicy(id: string, namespace?: RuntimeNamespace): Promise; - getWorldModel(id: string, namespace?: RuntimeNamespace): Promise; + getPolicy(id: string, namespace?: RuntimeNamespace, opts?: { includeAllNamespaces?: boolean }): Promise; + getWorldModel(id: string, namespace?: RuntimeNamespace, opts?: { includeAllNamespaces?: boolean }): Promise; /** * List L2 policies ("经验") — newest-first. The viewer uses this * for the Experiences panel. @@ -215,11 +252,17 @@ export interface MemoryCore { limit?: number; offset?: number; q?: string; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + includeAllNamespaces?: boolean; }): Promise; /** Total policy rows matching the same filter (no limit/offset). */ countPolicies(input?: { status?: PolicyDTO["status"]; q?: string; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + includeAllNamespaces?: boolean; }): Promise; /** * List L3 world models ("世界环境知识") — newest-first. @@ -229,9 +272,12 @@ export interface MemoryCore { offset?: number; q?: string; namespace?: RuntimeNamespace; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + includeAllNamespaces?: boolean; }): Promise; /** Total world-model rows matching the same filter. */ - countWorldModels(input?: { q?: string }): Promise; + countWorldModels(input?: { q?: string; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean }): Promise; /** Transition a policy through candidate → active → archived. */ setPolicyStatus( id: string, @@ -316,10 +362,13 @@ export interface MemoryCore { sessionId?: SessionId; limit?: number; offset?: number; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + includeAllNamespaces?: boolean; }): Promise; /** Total episode rows matching the same filter (no limit/offset). */ - countEpisodes(input?: { sessionId?: SessionId }): Promise; - timeline(input: { episodeId: EpisodeId; namespace?: RuntimeNamespace }): Promise; + countEpisodes(input?: { sessionId?: SessionId; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean }): Promise; + timeline(input: { episodeId: EpisodeId; namespace?: RuntimeNamespace; includeAllNamespaces?: boolean }): Promise; /** * Reverse-chronological trace listing for the Memories viewer. * @@ -339,7 +388,16 @@ export interface MemoryCore { limit?: number; offset?: number; sessionId?: SessionId; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; q?: string; + /** + * Viewer/admin listing mode. Retrieval still respects namespace + * visibility; the local viewer needs to browse every profile stored in + * the same agent DB so users can switch between Hermes profiles / + * OpenClaw agents. + */ + includeAllNamespaces?: boolean; /** * When true, paginate by distinct `(episodeId, turnId)` groups so * one user turn (query + tool sub-steps + reply) counts as one @@ -348,7 +406,7 @@ export interface MemoryCore { groupByTurn?: boolean; }): Promise; /** Total trace rows matching the same filter (no limit/offset). */ - countTraces(input?: { sessionId?: SessionId; q?: string; groupByTurn?: boolean }): Promise; + countTraces(input?: { sessionId?: SessionId; ownerAgentKind?: AgentKind; ownerProfileId?: string; q?: string; groupByTurn?: boolean; includeAllNamespaces?: boolean }): Promise; /** * Paged listing of the rich api_logs table ({@link ApiLogDTO}). @@ -363,9 +421,9 @@ export interface MemoryCore { }): Promise<{ logs: ApiLogDTO[]; total: number }>; // ── skills ── - listSkills(input?: { status?: SkillDTO["status"]; limit?: number; namespace?: RuntimeNamespace }): Promise; + listSkills(input?: { status?: SkillDTO["status"]; limit?: number; namespace?: RuntimeNamespace; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean }): Promise; /** Total skill rows matching the same filter (no limit). */ - countSkills(input?: { status?: SkillDTO["status"] }): Promise; + countSkills(input?: { status?: SkillDTO["status"]; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean }): Promise; getSkill(id: SkillId, opts?: { recordUse?: boolean; recordTrial?: boolean; @@ -375,6 +433,7 @@ export interface MemoryCore { turnId?: EpochMs; toolCallId?: string; namespace?: RuntimeNamespace; + includeAllNamespaces?: boolean; }): Promise; archiveSkill(id: SkillId, reason?: string): Promise; /** @@ -479,6 +538,14 @@ export interface MemoryCore { skills?: unknown[]; }): Promise<{ imported: number; skipped: number }>; + // ── embedding maintenance ── + embeddingMaintenanceStats(): Promise; + rebuildEmbeddings(input?: { + mode?: EmbeddingMaintenanceMode; + limit?: number; + offset?: number; + }): Promise; + // ── observability ── /** Subscribe to every CoreEvent the algorithm emits. Returns unsubscribe. */ subscribeEvents(handler: (e: CoreEvent) => void): Unsubscribe; diff --git a/apps/memos-local-plugin/bridge.cts b/apps/memos-local-plugin/bridge.cts index c34334af8..506102064 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -170,6 +170,44 @@ async function main(): Promise { (core as { bindTelemetry?: (t: InstanceType) => void }).bindTelemetry?.(telemetry); telemetry.trackPluginStarted(args.agent); + // 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 + // plugin_error events" actively misleading. Both handlers are + // best-effort and re-emit (or `process.exit(1)`) so we don't + // alter the existing crash semantics, only add observability. + // Only registered for `bridge.cts` (the dedicated process); the + // OpenClaw adapter runs inside the host process and must not steal + // its global error hooks. + process.on("uncaughtException", (err) => { + try { + telemetry.trackError("uncaught_exception", classifyErrorCode(err)); + } catch { + /* swallow — telemetry must never widen the crash */ + } + process.stderr.write( + `bridge: uncaughtException: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`, + ); + // Mirror Node's default behaviour so existing supervisors that + // expect non-zero exit on crash keep working. + process.exit(1); + }); + process.on("unhandledRejection", (reason) => { + try { + telemetry.trackError("unhandled_rejection", classifyErrorCode(reason)); + } catch { + /* swallow — telemetry must never widen the crash */ + } + process.stderr.write( + `bridge: unhandledRejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}\n`, + ); + // Don't exit: per-promise rejections are usually recoverable + // (failed flush, dropped SSE client). The default Node 20+ + // behaviour is to exit, but for a long-running bridge that + // would be too aggressive — surface to telemetry + stderr and + // continue. + }); + // Per-agent fixed viewer port. const AGENT_DEFAULT_PORTS = { openclaw: 18799, hermes: 18800 } as const; const viewerPort = AGENT_DEFAULT_PORTS[args.agent]; @@ -201,7 +239,7 @@ async function main(): Promise { { port: viewerPort, host: config.viewer.bindHost, - staticRoot: path.resolve(__dirname, "web/dist"), + staticRoot: path.resolve(__dirname, "viewer/dist"), agent: args.agent, }, ); @@ -271,7 +309,7 @@ async function main(): Promise { { port: viewerPort, host: config.viewer.bindHost, - staticRoot: path.resolve(__dirname, "web/dist"), + staticRoot: path.resolve(__dirname, "viewer/dist"), agent: args.agent, }, ); @@ -339,6 +377,31 @@ function pathToEsmUrl(abs: string): string { return u; } +/** + * Best-effort error classification for ARMS `plugin_error.error_type`. + * + * Priority order: + * 1. `MemosError.code` and Node `errno` (`ENOENT`, `EADDRINUSE`, …) + * — both surface as a `code` string property. + * 2. The constructor name when it's something more specific than + * the generic `Error` (e.g. `TypeError`, `SyntaxError`). + * 3. `unknown` as a sentinel. + * + * Never returns the message — those can carry user paths or query + * fragments and would defeat the redaction the rest of the telemetry + * pipeline guarantees. + */ +function classifyErrorCode(err: unknown): string { + if (err && typeof err === "object" && "code" in err) { + const code = (err as { code: unknown }).code; + if (typeof code === "string" && code.length > 0) return code; + } + if (err instanceof Error && err.name && err.name !== "Error") { + return err.name; + } + return "unknown"; +} + function createBridgeStatusTracker(statusFile: string, daemon: boolean): { snapshot(): BridgeStatusSnapshot; markConnected(): void; diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 0223e4da7..680957819 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -26,7 +26,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = { provider: "local", endpoint: "", model: "Xenova/all-MiniLM-L6-v2", - dimensions: 384, apiKey: "", cache: { enabled: true, diff --git a/apps/memos-local-plugin/core/config/index.ts b/apps/memos-local-plugin/core/config/index.ts index 30699d098..a06aec2bb 100644 --- a/apps/memos-local-plugin/core/config/index.ts +++ b/apps/memos-local-plugin/core/config/index.ts @@ -69,6 +69,7 @@ export async function loadConfig(home: ResolvedHome): Promise export function resolveConfig(raw: unknown, warnings?: string[]): ResolvedConfig { const cleaned = pruneUnknown(raw, DEFAULT_CONFIG, "", warnings); const merged = deepMerge(DEFAULT_CONFIG as Record, cleaned); + stripUnsupportedEmbeddingDimensions(merged); // Apply Typebox defaults + coerce types as much as possible. const completed = Value.Default(ConfigSchema, merged) as ResolvedConfig; @@ -159,6 +160,15 @@ function isPlainObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } +function stripUnsupportedEmbeddingDimensions(merged: Record): void { + const embedding = merged.embedding; + if (!isPlainObject(embedding)) return; + // Vector dimensionality is derived from the model/provider at runtime, + // not a user-facing config field. Ignore legacy/manual YAML values so + // a stale `dimensions: 384` cannot truncate bge-m3's 1024-dim vectors. + delete embedding.dimensions; +} + /** * One-shot helper for adapters that just want a fully resolved config for an * agent (handles both `MEMOS_HOME` overrides and the per-agent default). diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index d302fd89b..675cade70 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -38,7 +38,6 @@ const EmbeddingSchema = Type.Object({ ], { default: "local" }), endpoint: StringWithDefault(""), model: StringWithDefault("Xenova/all-MiniLM-L6-v2"), - dimensions: NumberInRange(384, 1, 8192), apiKey: StringWithDefault(""), cache: Type.Object({ enabled: Bool(true), diff --git a/apps/memos-local-plugin/core/config/writer.ts b/apps/memos-local-plugin/core/config/writer.ts index e71cf3f39..512d2e052 100644 --- a/apps/memos-local-plugin/core/config/writer.ts +++ b/apps/memos-local-plugin/core/config/writer.ts @@ -56,6 +56,7 @@ export async function patchConfig( // Parse (or seed) the YAML document. const doc = existingText ? parseDoc(existingText, home.configFile) : parseDoc(stringifyYaml(DEFAULT_CONFIG), ""); applyPatch(doc, patch); + removeUnsupportedUserConfig(doc); // Validate against schema using the merged JS view. const merged = doc.toJS({ maxAliasCount: -1 }) as Record; @@ -113,6 +114,17 @@ function applyPatch(doc: ReturnType, patch: Record): void { + // Embedding dimensionality is inferred from the provider/model at runtime. + // Keep stale manual values out of config.yaml so they cannot be mistaken + // for supported settings on the next edit. + try { + doc.deleteIn(["embedding", "dimensions"]); + } catch { + /* best-effort cleanup */ + } +} + function isPlainObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } diff --git a/apps/memos-local-plugin/core/embedding/embedder.ts b/apps/memos-local-plugin/core/embedding/embedder.ts index fa01a2489..bd4a5fe92 100644 --- a/apps/memos-local-plugin/core/embedding/embedder.ts +++ b/apps/memos-local-plugin/core/embedding/embedder.ts @@ -72,6 +72,7 @@ export function createEmbedderWithProvider( let failures = 0; let lastOkAt: number | null = null; let lastError: { at: number; message: string } | null = null; + let actualDimensions = config.dimensions; function toInput(i: string | EmbedInput): Required { if (typeof i === "string") return { text: i, role: "document" }; @@ -253,11 +254,19 @@ export function createEmbedderWithProvider( } const normalize = config.normalize ?? true; const processed = postProcess(raw, { - dimensions: config.dimensions, + dimensions: actualDimensions, provider: provider.name, model: config.model, normalize, }); + if (actualDimensions <= 0 && processed[0]) { + actualDimensions = processed[0].length; + logger.info("dimensions.inferred", { + provider: provider.name, + model: config.model, + dimensions: actualDimensions, + }); + } for (let j = 0; j < slice.length; j++) { const vec = processed[j]!; const entry = slice[j]!; @@ -283,7 +292,9 @@ export function createEmbedderWithProvider( const api: Embedder = { provider: provider.name, model: config.model, - dimensions: config.dimensions, + get dimensions() { + return actualDimensions; + }, embedOne, embedMany, stats(): EmbedStats { @@ -311,7 +322,7 @@ export function createEmbedderWithProvider( logger.info("init", { provider: provider.name, model: config.model, - dimensions: config.dimensions, + dimensions: actualDimensions > 0 ? actualDimensions : "auto", cacheEnabled: config.cache.enabled, batchSize: config.batchSize ?? 32, }); diff --git a/apps/memos-local-plugin/core/embedding/normalize.ts b/apps/memos-local-plugin/core/embedding/normalize.ts index bf8a17e94..a6262acd2 100644 --- a/apps/memos-local-plugin/core/embedding/normalize.ts +++ b/apps/memos-local-plugin/core/embedding/normalize.ts @@ -17,6 +17,7 @@ export function toFloat32(v: number[]): EmbeddingVector { /** * Enforce the configured dimensionality. * + * - `expected <= 0` means "auto": preserve the provider's native length. * - If the provider returns *more* dimensions than configured, truncate (the * old project did this so callers could safely switch to a smaller model). * - If fewer, throw. Silently zero-padding would poison downstream cosine. @@ -26,6 +27,7 @@ export function enforceDim( expected: number, ctx: { provider: string; model: string; index?: number }, ): number[] { + if (expected <= 0) return v; if (v.length === expected) return v; if (v.length > expected) return v.slice(0, expected); throw new MemosError( @@ -59,8 +61,22 @@ export function postProcess( }, ): EmbeddingVector[] { const out: EmbeddingVector[] = []; + const inferred = opts.dimensions <= 0 ? (raw[0]?.length ?? 0) : opts.dimensions; for (let i = 0; i < raw.length; i++) { - const dimed = enforceDim(raw[i]!, opts.dimensions, { + if (opts.dimensions <= 0 && raw[i]!.length !== inferred) { + throw new MemosError( + ERROR_CODES.EMBEDDING_UNAVAILABLE, + `Provider ${opts.provider}/${opts.model} returned inconsistent vector dimensions in one batch`, + { + provider: opts.provider, + model: opts.model, + got: raw[i]!.length, + expected: inferred, + index: i, + }, + ); + } + const dimed = enforceDim(raw[i]!, inferred, { provider: opts.provider, model: opts.model, index: i, diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 9c9854b50..042a0deb7 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -48,6 +48,8 @@ import type { CoreEvent } from "../../agent-contract/events.js"; import type { LogRecord } from "../../agent-contract/log-record.js"; import type { CoreHealth, + EmbeddingMaintenanceRunResult, + EmbeddingMaintenanceStats, MemoryCore, Unsubscribe, } from "../../agent-contract/memory-core.js"; @@ -94,6 +96,7 @@ import { normalizeNamespace, ownerFromNamespace, isVisibleTo, + visibilityWhere, } from "../runtime/namespace.js"; import type { RetrievalConfig } from "../retrieval/types.js"; import type { UserFeedback } from "../reward/types.js"; @@ -138,6 +141,10 @@ export interface BootstrapResult { config: ResolvedConfig; } +function initialEmbeddingDimensions(provider: string): number { + return provider === "local" ? 384 : 0; +} + /** * Build a `MemoryCore` from the ground up. Opens SQLite, runs migrations, * constructs the LLM/embedder (if configured) and wires the pipeline. @@ -278,6 +285,7 @@ export async function bootstrapMemoryCoreFull( try { embedder = createEmbedder({ ...(config.embedding as object), + dimensions: initialEmbeddingDimensions(config.embedding.provider), onError: (d: { provider: string; model: string; message: string; code?: string; at?: number }) => recordSystemError("embedding", d), onStatus: (d: { @@ -1338,6 +1346,16 @@ export function createMemoryCore( }; } catch (err) { ok = false; + // Surface terminal failures as a `plugin_error` ARMS event so + // the dashboards can see retrieval availability per build. + // Stable error code (preferred) or `unknown` — never the raw + // message, which can carry user/workspace text. + if (telemetry) { + telemetry.trackError( + "turn_start", + err instanceof MemosError ? err.code : "unknown", + ); + } throw err; } finally { // Log every retrieval — not just adhoc `searchMemory` calls — @@ -1671,11 +1689,16 @@ export function createMemoryCore( .length > 0; if (!childHasEpisode) { try { - await openSession({ agent: outcome.agent, sessionId: childSessionId }); + await openSession({ agent: outcome.agent, sessionId: childSessionId, namespace: ns }); const childTurn = await onTurnStart({ agent: outcome.agent, + namespace: ns, sessionId: childSessionId, userText: `Subagent task: ${task}`, + contextHints: { + ...(outcome.meta ?? {}), + ...namespaceMeta(ns), + }, ts, }); const childEpisodeId = childTurn.query.episodeId; @@ -1684,10 +1707,15 @@ export function createMemoryCore( } childRecorded = await onTurnEnd({ agent: outcome.agent, + namespace: ns, sessionId: childSessionId, episodeId: childEpisodeId, agentText: `Subagent result: ${result}`, toolCalls: childToolCalls, + contextHints: { + ...(outcome.meta ?? {}), + ...namespaceMeta(ns), + }, ts: ts + 1, }); await closeEpisode(childEpisodeId); @@ -2038,6 +2066,12 @@ export function createMemoryCore( }; } catch (err) { ok = false; + if (telemetry) { + telemetry.trackError( + "memory_search", + err instanceof MemosError ? err.code : "unknown", + ); + } throw err; } finally { try { @@ -2150,11 +2184,15 @@ export function createMemoryCore( : null; } - async function getPolicy(id: string, namespace?: RuntimeNamespace): Promise { + async function getPolicy( + id: string, + namespace?: RuntimeNamespace, + opts?: { includeAllNamespaces?: boolean }, + ): Promise { ensureLive(); if (namespace) activeNamespace = namespace; const row = handle.repos.policies.getById(id); - return row && visibleToCurrent(row) ? policyRowToDTO(row) : null; + return row && (opts?.includeAllNamespaces || visibleToCurrent(row)) ? policyRowToDTO(row) : null; } async function listPolicies(input?: { @@ -2162,17 +2200,23 @@ export function createMemoryCore( limit?: number; offset?: number; q?: string; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + includeAllNamespaces?: boolean; }): Promise { ensureLive(); const limit = Math.max(1, Math.min(500, input?.limit ?? 50)); const offset = Math.max(0, input?.offset ?? 0); const needle = (input?.q ?? "").trim().toLowerCase(); + const namespaceFiltered = Boolean(input?.ownerAgentKind || input?.ownerProfileId); const rows = handle.repos.policies.list({ status: input?.status, - limit: limit + offset + (needle ? 200 : 0), + limit: namespaceFiltered ? 100_000 : limit + offset + (needle ? 200 : 0), offset: 0, }); - const visibleRows = rows.filter((r) => visibleToCurrent(r)); + const visibleRows = rows.filter((r) => + (input?.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) + ); const filtered = needle ? visibleRows.filter((r) => (r.title + "\n" + r.trigger + "\n" + r.procedure) @@ -2186,16 +2230,23 @@ export function createMemoryCore( async function countPolicies(input?: { status?: PolicyDTO["status"]; q?: string; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + includeAllNamespaces?: boolean; }): Promise { ensureLive(); const needle = (input?.q ?? "").trim().toLowerCase(); if (!needle) { - return handle.repos.policies.list({ status: input?.status, limit: 100_000 }).filter((r) => visibleToCurrent(r)).length; + return handle.repos.policies.list({ status: input?.status, limit: 100_000 }).filter((r) => + (input?.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) + ).length; } // q is a client-side substring match; mirror `listPolicies` and // walk the full filtered result. Caller passes no limit/offset // so the natural list pages through everything. - const rows = handle.repos.policies.list({ status: input?.status }).filter((r) => visibleToCurrent(r)); + const rows = handle.repos.policies.list({ status: input?.status }).filter((r) => + (input?.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) + ); return rows.filter((r) => (r.title + "\n" + r.trigger + "\n" + r.procedure) .toLowerCase() @@ -2254,17 +2305,23 @@ export function createMemoryCore( return updated ? policyRowToDTO(updated) : null; } - async function getWorldModel(id: string, namespace?: RuntimeNamespace): Promise { + async function getWorldModel( + id: string, + namespace?: RuntimeNamespace, + opts?: { includeAllNamespaces?: boolean }, + ): Promise { ensureLive(); if (namespace) activeNamespace = namespace; const row = handle.repos.worldModel.getById(id); - return row && visibleToCurrent(row) ? worldModelRowToDTO(row) : null; + return row && (opts?.includeAllNamespaces || visibleToCurrent(row)) ? worldModelRowToDTO(row) : null; } - async function countWorldModels(input?: { q?: string }): Promise { + async function countWorldModels(input?: { q?: string; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean }): Promise { ensureLive(); const needle = (input?.q ?? "").trim().toLowerCase(); - const rows = handle.repos.worldModel.list({ limit: 100_000 }).filter((r) => visibleToCurrent(r)); + const rows = handle.repos.worldModel.list({ limit: 100_000 }).filter((r) => + (input?.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) + ); if (!needle) return rows.length; return rows.filter((r) => (r.title + "\n" + r.body).toLowerCase().includes(needle), @@ -2276,17 +2333,23 @@ export function createMemoryCore( offset?: number; q?: string; namespace?: RuntimeNamespace; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + includeAllNamespaces?: boolean; }): Promise { ensureLive(); if (input?.namespace) activeNamespace = input.namespace; const limit = Math.max(1, Math.min(500, input?.limit ?? 50)); const offset = Math.max(0, input?.offset ?? 0); const needle = (input?.q ?? "").trim().toLowerCase(); + const namespaceFiltered = Boolean(input?.ownerAgentKind || input?.ownerProfileId); const rows = handle.repos.worldModel.list({ - limit: limit + offset + (needle ? 200 : 0), + limit: namespaceFiltered ? 100_000 : limit + offset + (needle ? 200 : 0), offset: 0, }); - const visibleRows = rows.filter((r) => visibleToCurrent(r)); + const visibleRows = rows.filter((r) => + (input?.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) + ); const filtered = needle ? visibleRows.filter((r) => (r.title + "\n" + r.body).toLowerCase().includes(needle), @@ -2411,15 +2474,23 @@ export function createMemoryCore( async function countEpisodes(input?: { sessionId?: SessionId; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + includeAllNamespaces?: boolean; }): Promise { ensureLive(); - return handle.repos.episodes.list({ sessionId: input?.sessionId, limit: 100_000 }).filter((r) => visibleToCurrent(r)).length; + return handle.repos.episodes.list({ sessionId: input?.sessionId, limit: 100_000 }).filter((r) => + (input?.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) + ).length; } async function listEpisodeRows(input?: { sessionId?: SessionId; limit?: number; offset?: number; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + includeAllNamespaces?: boolean; }): Promise extends unknown[] ? Awaited> : never> { ensureLive(); @@ -2431,9 +2502,14 @@ export function createMemoryCore( const rows = handle.repos.episodes.list({ sessionId: input?.sessionId, - limit: input?.limit ?? 50, - offset: input?.offset ?? 0, - }).filter((r) => visibleToCurrent(r)); + 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) + ); + const pagedRows = input?.ownerAgentKind || input?.ownerProfileId + ? rows.slice(input?.offset ?? 0, (input?.offset ?? 0) + (input?.limit ?? 50)) + : rows; // Build reverse indexes for the skill-status derivation. Rebuilt // per call rather than cached because the base table volumes are @@ -2462,7 +2538,7 @@ export function createMemoryCore( // For each row, fetch the episode's traces once. We need the rows // for both preview/tags and turn counting: Tasks should count user // turns (`turnId` groups), not step-level L1 traces. - const out = rows.map((r: EpisodeRow) => { + const out = pagedRows.map((r: EpisodeRow) => { const firstTraceId = r.traceIds[0]; const episodeTraces = r.traceIds.length > 0 ? handle.repos.traces.getManyByIds(r.traceIds as TraceId[]) @@ -2573,16 +2649,17 @@ export function createMemoryCore( async function timeline(input: { episodeId: EpisodeId; namespace?: RuntimeNamespace; + includeAllNamespaces?: boolean; }): Promise { ensureLive(); if (input.namespace) activeNamespace = input.namespace; const episode = handle.repos.episodes.getById(input.episodeId); - if (episode && !visibleToCurrent(episode)) return []; + if (episode && !input.includeAllNamespaces && !visibleToCurrent(episode)) return []; const rows = handle.repos.traces.list({ episodeId: input.episodeId, limit: 500, newestFirst: false, - }).filter((r) => visibleToCurrent(r)); + }).filter((r) => input.includeAllNamespaces || visibleToCurrent(r)); return orderTraceRowsForEpisode(rows, episode?.traceIds ?? []).map((row) => traceRowToDTO(row, episode), ); @@ -2623,22 +2700,41 @@ export function createMemoryCore( async function countTraces(input?: { sessionId?: SessionId; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; q?: string; groupByTurn?: boolean; + includeAllNamespaces?: boolean; }): Promise { ensureLive(); const needle = (input?.q ?? "").trim().toLowerCase(); - const visible = (r: TraceRow) => visibleToCurrent(r); + const vis = input?.includeAllNamespaces ? undefined : visibilityWhere(activeNamespace); + const visible = (r: TraceRow) => input?.includeAllNamespaces || visibleToCurrent(r); + if (!needle) { - const rows = handle.repos.traces.list({ sessionId: input?.sessionId, limit: 100_000 }).filter(visible); - if (!input?.groupByTurn) return rows.length; - const turnKeys = new Set(); - for (const r of rows) turnKeys.add(`${r.episodeId ?? "_"}:${r.turnId}`); - return turnKeys.size; + if (input?.groupByTurn) { + return handle.repos.traces.countTurns( + { + sessionId: input?.sessionId, + ownerAgentKind: input?.ownerAgentKind, + ownerProfileId: input?.ownerProfileId, + }, + vis, + ); + } + return handle.repos.traces.count({ + sessionId: input?.sessionId, + ownerAgentKind: input?.ownerAgentKind, + ownerProfileId: input?.ownerProfileId, + }); } // q substring scan — mirror `listTraces`. Walk all matching // traces from the repo (no limit) and apply the same filter. - const rows = handle.repos.traces.list({ sessionId: input?.sessionId }).filter(visible); + const rows = handle.repos.traces.list({ + sessionId: input?.sessionId, + ownerAgentKind: input?.ownerAgentKind, + ownerProfileId: input?.ownerProfileId, + }).filter(visible); const matched = rows.filter((r) => { return traceSearchHaystack(r).includes(needle); }); @@ -2652,8 +2748,11 @@ export function createMemoryCore( limit?: number; offset?: number; sessionId?: SessionId; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; q?: string; groupByTurn?: boolean; + includeAllNamespaces?: boolean; }): Promise { ensureLive(); const limit = Math.max(1, Math.min(500, input?.limit ?? 50)); @@ -2663,14 +2762,22 @@ export function createMemoryCore( if (input?.groupByTurn) { // Group-by-turn: paginate at the (episodeId, turnId) level so each // "memory" on the Memories page corresponds to one user turn. + const vis = input?.includeAllNamespaces ? undefined : visibilityWhere(activeNamespace); if (!needle) { - const turnKeys = handle.repos.traces.listTurnKeys({ - sessionId: input?.sessionId, - limit, - offset, - }); + const turnKeys = handle.repos.traces.listTurnKeys( + { + sessionId: input?.sessionId, + ownerAgentKind: input?.ownerAgentKind, + ownerProfileId: input?.ownerProfileId, + limit, + offset, + }, + vis, + ); const rows = handle.repos.traces.listByTurnKeys(turnKeys); - const visibleRows = rows.filter((r) => visibleToCurrent(r)); + const visibleRows = rows.filter((r) => + (input.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) + ); // The frontend's `buildGroups` preserves first-encounter order // when bucketing traces by turnKey. We need newest turn first // (matching `listTurnKeys` DESC order), with the episode's @@ -2691,7 +2798,11 @@ export function createMemoryCore( return traceRowsToDTOs(visibleRows); } // Search + group: scan, filter, then paginate by distinct turn key. - const allRows = handle.repos.traces.list({ sessionId: input?.sessionId }).filter((r) => visibleToCurrent(r)); + const allRows = handle.repos.traces.list({ + sessionId: input?.sessionId, + ownerAgentKind: input?.ownerAgentKind, + ownerProfileId: input?.ownerProfileId, + }).filter((r) => input?.includeAllNamespaces || visibleToCurrent(r)); const matched = allRows.filter((r) => { return traceSearchHaystack(r).includes(needle); }); @@ -2712,7 +2823,9 @@ export function createMemoryCore( ); // Once a turn matches the search, return the whole turn so the // Memories card uses the same step list as the Tasks timeline. - const rows = handle.repos.traces.listByTurnKeys(orderedKeys).filter((r) => visibleToCurrent(r)); + const rows = handle.repos.traces.listByTurnKeys(orderedKeys).filter((r) => + (input.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) + ); const traceOrder = traceOrderLookup(rows); const traces = rows .sort((a, b) => { @@ -2729,9 +2842,11 @@ export function createMemoryCore( if (!needle) { const rows = handle.repos.traces.list({ sessionId: input?.sessionId, + ownerAgentKind: input?.ownerAgentKind, + ownerProfileId: input?.ownerProfileId, limit: limit + offset + 500, offset: 0, - }).filter((r) => visibleToCurrent(r)); + }).filter((r) => input?.includeAllNamespaces || visibleToCurrent(r)); return traceRowsToDTOs(rows.slice(offset, offset + limit)); } // Substring search: SQLite LIKE would need an index. For the @@ -2740,16 +2855,28 @@ export function createMemoryCore( const batchSize = Math.min(2_000, (limit + offset) * 5); const rows = handle.repos.traces.list({ sessionId: input?.sessionId, + ownerAgentKind: input?.ownerAgentKind, + ownerProfileId: input?.ownerProfileId, limit: batchSize, offset: 0, }); const filtered = rows.filter((r) => { - if (!visibleToCurrent(r)) return false; + if (!input?.includeAllNamespaces && !visibleToCurrent(r)) return false; return traceSearchHaystack(r).includes(needle); }); return traceRowsToDTOs(filtered.slice(offset, offset + limit)); } + function matchesNamespaceFilter( + row: { ownerAgentKind?: AgentKind; ownerProfileId?: string }, + input?: { ownerAgentKind?: AgentKind; ownerProfileId?: string }, + ): boolean { + return ( + (!input?.ownerAgentKind || row.ownerAgentKind === input.ownerAgentKind) && + (!input?.ownerProfileId || row.ownerProfileId === input.ownerProfileId) + ); + } + function traceSearchHaystack(row: TraceRow): string { return [ row.id, @@ -2791,7 +2918,7 @@ export function createMemoryCore( // ─── Skills ── async function listSkills( - input?: { status?: SkillDTO["status"]; limit?: number; namespace?: RuntimeNamespace }, + input?: { status?: SkillDTO["status"]; limit?: number; namespace?: RuntimeNamespace; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean }, ): Promise { ensureLive(); if (input?.namespace) activeNamespace = input.namespace; @@ -2799,14 +2926,21 @@ export function createMemoryCore( status: input?.status, limit: 5_000, }); - return rows.filter((r) => visibleToCurrent(r)).slice(0, input?.limit ?? 50).map(skillRowToDTO); + return rows.filter((r) => + (input?.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) + ).slice(0, input?.limit ?? 50).map(skillRowToDTO); } async function countSkills(input?: { status?: SkillDTO["status"]; + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + includeAllNamespaces?: boolean; }): Promise { ensureLive(); - return handle.repos.skills.list({ status: input?.status, limit: 5_000 }).filter((r) => visibleToCurrent(r)).length; + return handle.repos.skills.list({ status: input?.status, limit: 5_000 }).filter((r) => + (input?.includeAllNamespaces || visibleToCurrent(r)) && matchesNamespaceFilter(r, input) + ).length; } async function getSkill( @@ -2820,12 +2954,13 @@ export function createMemoryCore( turnId?: number; toolCallId?: string; namespace?: RuntimeNamespace; + includeAllNamespaces?: boolean; }, ): Promise { ensureLive(); if (opts?.namespace) activeNamespace = opts.namespace; const row = handle.repos.skills.getById(id); - if (!row || !visibleToCurrent(row)) return null; + if (!row || (!opts?.includeAllNamespaces && !visibleToCurrent(row))) return null; if (opts?.recordUse) { handle.repos.skills.recordUse(id, Date.now()); if (opts.recordTrial) { @@ -3025,7 +3160,11 @@ export function createMemoryCore( // the Overview "memories" metric matches what the Memories page // shows: 1 user turn = 1 memory (regardless of how many tool calls // / sub-steps were captured for that turn). - const totalTurns = handle.repos.traces.countTurns(); + // Apply namespace visibility so the count matches the filtered list. + const totalTurns = handle.repos.traces.countTurns( + {}, + visibilityWhere(activeNamespace), + ); return { total: totalTurns, @@ -3158,8 +3297,9 @@ export function createMemoryCore( 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 are dropped on purpose - // because we have no way to re-embed bundled text here. + // 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, @@ -3284,6 +3424,312 @@ export function createMemoryCore( return { imported, skipped }; } + async function embeddingMaintenanceStats(): Promise { + ensureLive(); + await ensureEmbeddingDimensionKnown(); + return computeEmbeddingMaintenanceStats(); + } + + async function rebuildEmbeddings(input: { + mode?: "repair" | "rebuild"; + limit?: number; + offset?: number; + } = {}): Promise { + ensureLive(); + const mode = input.mode === "rebuild" ? "rebuild" : "repair"; + const limit = clampEmbeddingBatchLimit(input.limit); + const offset = Math.max(0, Math.floor(Number(input.offset ?? 0)) || 0); + await ensureEmbeddingDimensionKnown(); + const statsBefore = computeEmbeddingMaintenanceStats(); + if (!handle.embedder) { + return { + mode, + processed: 0, + updated: 0, + failed: 0, + offset, + nextOffset: offset, + done: true, + statsBefore, + statsAfter: statsBefore, + error: "embedding provider is not configured", + }; + } + + const allSlots = collectEmbeddingSlots(); + const targetSlots = mode === "rebuild" + ? allSlots + : allSlots.filter((slot) => slotNeedsRepair(slot, handle.embedder!.dimensions)); + const batch = mode === "rebuild" + ? targetSlots.slice(offset, offset + limit) + : targetSlots.slice(0, limit); + + let updated = 0; + let failed = 0; + let error: string | undefined; + if (batch.length > 0) { + try { + const vecs = await handle.embedder.embedMany( + batch.map((slot) => ({ text: slot.sourceText || "(empty)", role: "document" as const })), + ); + for (let i = 0; i < batch.length; i++) { + const slot = batch[i]!; + const vec = vecs[i]; + if (!vec) { + failed++; + continue; + } + try { + if (slot.update(vec)) updated++; + else failed++; + } catch { + failed++; + } + } + } catch (err) { + failed = batch.length; + error = err instanceof Error ? err.message : String(err); + } + } + + const statsAfter = computeEmbeddingMaintenanceStats(); + const nextOffset = mode === "rebuild" ? offset + batch.length : 0; + const done = mode === "rebuild" + ? nextOffset >= targetSlots.length || batch.length === 0 + : statsAfter.needsRepair === 0 || batch.length === 0; + return { + mode, + processed: batch.length, + updated, + failed, + offset, + nextOffset, + done, + statsBefore, + statsAfter, + error, + }; + } + + type EmbeddingSlotKind = "trace" | "policy" | "world_model" | "skill"; + type EmbeddingSlot = { + kind: EmbeddingSlotKind; + id: string; + field: "vec_summary" | "vec_action" | "vec"; + vec: Float32Array | null; + sourceText: string; + update: (vec: Float32Array) => boolean; + }; + + function computeEmbeddingMaintenanceStats(): EmbeddingMaintenanceStats { + const configuredDimension = handle.embedder?.dimensions ?? 0; + const allSlots = collectEmbeddingSlots(); + const dimension = configuredDimension > 0 ? configuredDimension : inferStoredEmbeddingDimension(allSlots); + const byKind = emptyEmbeddingStatsByKind(); + for (const slot of allSlots) { + const bucket = byKind[slot.kind]; + bucket.totalSlots++; + if (!slot.vec) { + bucket.missing++; + } else if (dimension > 0 && slot.vec.length !== dimension) { + bucket.dimMismatch++; + } else { + bucket.ready++; + } + } + for (const bucket of Object.values(byKind)) { + bucket.needsRepair = bucket.missing + bucket.dimMismatch; + } + const totalSlots = sumEmbeddingStats(byKind, "totalSlots"); + const ready = sumEmbeddingStats(byKind, "ready"); + const missing = sumEmbeddingStats(byKind, "missing"); + const dimMismatch = sumEmbeddingStats(byKind, "dimMismatch"); + return { + dimension, + available: Boolean(handle.embedder), + totalSlots, + ready, + missing, + dimMismatch, + needsRepair: missing + dimMismatch, + byKind, + }; + } + + async function ensureEmbeddingDimensionKnown(): Promise { + if (!handle.embedder || handle.embedder.dimensions > 0) return; + try { + await handle.embedder.embedOne({ + text: "MemOS embedding dimension probe", + role: "document", + }); + } catch (err) { + log.warn("embedding.dimension_probe_failed", { + err: err instanceof Error ? err.message : String(err), + }); + } + } + + function inferStoredEmbeddingDimension(slots: readonly EmbeddingSlot[]): number { + const counts = new Map(); + for (const slot of slots) { + if (!slot.vec) continue; + counts.set(slot.vec.length, (counts.get(slot.vec.length) ?? 0) + 1); + } + let bestDim = 0; + let bestCount = 0; + for (const [dim, count] of counts) { + if (count > bestCount) { + bestDim = dim; + bestCount = count; + } + } + return bestDim; + } + + function collectEmbeddingSlots(): EmbeddingSlot[] { + const slots: EmbeddingSlot[] = []; + const pageSize = 500; + for (let offset = 0;; offset += pageSize) { + const rows = handle.repos.traces.list({ limit: pageSize, offset, newestFirst: false }); + for (const row of rows) { + slots.push({ + kind: "trace", + id: row.id, + field: "vec_summary", + vec: row.vecSummary, + 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 (rows.length < pageSize) break; + } + + for (let offset = 0;; offset += pageSize) { + const rows = handle.repos.policies.list({ limit: pageSize, offset, newestFirst: false }); + for (const row of rows) { + slots.push({ + kind: "policy", + id: row.id, + field: "vec", + vec: row.vec, + sourceText: policyEmbeddingText(row), + update: (vec) => handle.repos.policies.updateVector(row.id, vec), + }); + } + if (rows.length < pageSize) break; + } + + for (let offset = 0;; offset += pageSize) { + const rows = handle.repos.worldModel.list({ limit: pageSize, offset, newestFirst: false }); + for (const row of rows) { + slots.push({ + kind: "world_model", + id: row.id, + field: "vec", + vec: row.vec, + sourceText: worldModelEmbeddingText(row), + update: (vec) => handle.repos.worldModel.updateVector(row.id, vec), + }); + } + if (rows.length < pageSize) break; + } + + for (let offset = 0;; offset += pageSize) { + const rows = handle.repos.skills.list({ limit: pageSize, offset, newestFirst: false }); + for (const row of rows) { + slots.push({ + kind: "skill", + id: row.id, + field: "vec", + vec: row.vec, + sourceText: skillEmbeddingText(row), + update: (vec) => handle.repos.skills.updateVector(row.id, vec), + }); + } + if (rows.length < pageSize) break; + } + return slots.sort((a, b) => + `${a.kind}:${a.id}:${a.field}`.localeCompare(`${b.kind}:${b.id}:${b.field}`), + ); + } + + function slotNeedsRepair(slot: EmbeddingSlot, dimension: number): boolean { + return !slot.vec || (dimension > 0 && slot.vec.length !== dimension); + } + + function emptyEmbeddingStatsByKind(): EmbeddingMaintenanceStats["byKind"] { + const empty = () => ({ + totalSlots: 0, + ready: 0, + missing: 0, + dimMismatch: 0, + needsRepair: 0, + }); + return { + trace: empty(), + policy: empty(), + world_model: empty(), + skill: empty(), + }; + } + + function sumEmbeddingStats( + byKind: EmbeddingMaintenanceStats["byKind"], + key: "totalSlots" | "ready" | "missing" | "dimMismatch" | "needsRepair", + ): number { + return Object.values(byKind).reduce((sum, bucket) => sum + bucket[key], 0); + } + + function clampEmbeddingBatchLimit(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 100; + return Math.max(1, Math.min(500, Math.floor(n))); + } + + function traceActionEmbeddingText(row: TraceRow): string { + const toolSig = row.toolCalls + .map((tool) => `${tool.name}(${safeJsonForEmbedding(tool.input).slice(0, 300)})`) + .join("; "); + return [row.agentText.trim(), toolSig].filter(Boolean).join("\n---\n") || "(empty)"; + } + + function policyEmbeddingText(row: PolicyRow): string { + return [ + row.title, + row.trigger, + row.procedure, + row.verification, + row.boundary, + ].filter(Boolean).join("\n") || "(empty)"; + } + + function worldModelEmbeddingText(row: WorldModelRow): string { + return [row.title.trim(), row.body.trim()].filter(Boolean).join("\n\n") || "(empty)"; + } + + function skillEmbeddingText(row: SkillRow): string { + return [row.name.trim(), row.invocationGuide.trim()].filter(Boolean).join("\n\n") || "(empty)"; + } + + function safeJsonForEmbedding(value: unknown): string { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + async function getConfig(): Promise> { ensureLive(); // Re-read from disk instead of returning `handle.config` (the @@ -3482,6 +3928,8 @@ export function createMemoryCore( metrics, exportBundle, importBundle, + embeddingMaintenanceStats, + rebuildEmbeddings, subscribeEvents, getRecentEvents, subscribeLogs, @@ -3534,6 +3982,10 @@ function maskSecrets(src: Record): Record { */ function stripEmptySecrets(patch: Record): Record { const out = JSON.parse(JSON.stringify(patch)) as Record; + const embedding = out.embedding; + if (embedding && typeof embedding === "object") { + delete (embedding as Record).dimensions; + } for (const dotted of SECRET_FIELD_PATHS) { const keys = dotted.split("."); let cursor: Record | undefined = out; diff --git a/apps/memos-local-plugin/core/pipeline/retrieval-repos.ts b/apps/memos-local-plugin/core/pipeline/retrieval-repos.ts index 457d30c75..115abf1d4 100644 --- a/apps/memos-local-plugin/core/pipeline/retrieval-repos.ts +++ b/apps/memos-local-plugin/core/pipeline/retrieval-repos.ts @@ -32,6 +32,8 @@ export function wrapRetrievalRepos(repos: Repos, namespace: RuntimeNamespace): R name: row.name, status: row.status, invocationGuide: row.invocationGuide, + procedureJson: row.procedureJson, + decisionGuidance: normaliseSkillDecisionGuidance(row.procedureJson), eta: row.eta, sourcePolicyIds: row.sourcePolicyIds, updatedAt: row.updatedAt, @@ -169,3 +171,28 @@ export function wrapRetrievalRepos(repos: Repos, namespace: RuntimeNamespace): R }, }; } + +function normaliseSkillDecisionGuidance( + procedureJson: unknown, +): { preference: string[]; antiPattern: string[] } { + const proc = (procedureJson ?? {}) as { + decisionGuidance?: { preference?: unknown; antiPattern?: unknown }; + decision_guidance?: { preference?: unknown; anti_pattern?: unknown }; + }; + const dg = proc.decisionGuidance; + const snakeDg = proc.decision_guidance; + return { + preference: + dg && Array.isArray(dg.preference) + ? dg.preference.map((s) => String(s)).filter(Boolean) + : snakeDg && Array.isArray(snakeDg.preference) + ? snakeDg.preference.map((s) => String(s)).filter(Boolean) + : [], + antiPattern: + dg && Array.isArray(dg.antiPattern) + ? dg.antiPattern.map((s) => String(s)).filter(Boolean) + : snakeDg && Array.isArray(snakeDg.anti_pattern) + ? snakeDg.anti_pattern.map((s) => String(s)).filter(Boolean) + : [], + }; +} diff --git a/apps/memos-local-plugin/core/retrieval/ALGORITHMS.md b/apps/memos-local-plugin/core/retrieval/ALGORITHMS.md index 6726af980..0b2cea6e6 100644 --- a/apps/memos-local-plugin/core/retrieval/ALGORITHMS.md +++ b/apps/memos-local-plugin/core/retrieval/ALGORITHMS.md @@ -101,7 +101,7 @@ Tier 2 returns single-trace hits *and* episode-level summaries: 1. Bucket the candidate traces by `episode_id`. 2. For any bucket with ≥ 2 traces, emit an `EpisodeCandidate`: - - `summary` = "episode N steps · best V=x\n· reflection: …\n· user: …" + - `summary` = "Past similar episode\nstep 1\n summary/user/agent/reflection: …" - `maxValue` = max of member traces - `meanPriority` = mean of member priorities 3. Sort episode rollups by `(maxValue, cosine)` desc, keep top `tier2TopK`. diff --git a/apps/memos-local-plugin/core/retrieval/decision-guidance.ts b/apps/memos-local-plugin/core/retrieval/decision-guidance.ts index ec7e21182..85e83fd82 100644 --- a/apps/memos-local-plugin/core/retrieval/decision-guidance.ts +++ b/apps/memos-local-plugin/core/retrieval/decision-guidance.ts @@ -4,11 +4,12 @@ * Inputs: * - Ranked Tier-2 trace candidates (we use their `episodeId` to find * the policies that share evidence with the trace). - * - Ranked Tier-1 skill candidates (later, when skills carry their own - * `procedureJson.decisionGuidance` — for now we still go through - * the source policies via `sourcePolicyIds`). + * - Ranked Tier-1 skill candidates. When a skill carries its own + * `procedureJson.decisionGuidance`, that skill-local guidance is + * authoritative; we only fall back to source policies for legacy + * skills without embedded guidance. * - * Output: a deduped list of `{ preference, antiPattern, sourcePolicyIds }` + * Output: a deduped list of `{ preference, antiPattern, sourcePolicyIds/sourceSkillIds }` * entries, ordered by frequency-of-attachment then alphabetically. * * Why dedupe at this stage and not later: a policy may surface against @@ -41,6 +42,7 @@ export interface GuidanceLine { kind: "preference" | "antiPattern"; text: string; sourcePolicyIds: string[]; + sourceSkillIds: string[]; } /** What the injector needs — small, easy to render. */ @@ -49,12 +51,15 @@ export interface CollectedGuidance { antiPattern: GuidanceLine[]; /** Policy ids consulted (for debug / logs). */ policyIdsTouched: string[]; + /** Skill ids that contributed embedded decision guidance. */ + skillIdsTouched: string[]; } const EMPTY: CollectedGuidance = Object.freeze({ preference: [], antiPattern: [], policyIdsTouched: [], + skillIdsTouched: [], }); export interface CollectInput { @@ -67,11 +72,14 @@ export interface CollectInput { export function collectDecisionGuidance(input: CollectInput): CollectedGuidance { const { ranked, repos, perListCap = 3 } = input; if (ranked.length === 0) return EMPTY; - if (!repos.policies) return EMPTY; // Gather the (episodeId, refKind) pairs we care about. const traceEpisodeIds = new Set(); const policyIds = new Set(); + const skillGuidance = new Map< + string, + { preference: string[]; antiPattern: string[] } + >(); for (const r of ranked) { const c = r.candidate; if (c.tier === "tier2" && c.refKind === "trace") { @@ -79,42 +87,82 @@ export function collectDecisionGuidance(input: CollectInput): CollectedGuidance } else if (c.tier === "tier2" && c.refKind === "experience") { policyIds.add((c as ExperienceCandidate).refId); } else if (c.tier === "tier1") { - for (const id of (c as SkillCandidate).sourcePolicyIds ?? []) { - policyIds.add(id); + const skill = c as SkillCandidate; + if (hasGuidance(skill.decisionGuidance)) { + skillGuidance.set(skill.refId, skill.decisionGuidance); + } else { + for (const id of skill.sourcePolicyIds ?? []) { + policyIds.add(id); + } } } } - if (traceEpisodeIds.size === 0 && policyIds.size === 0) return EMPTY; - - const activePolicies = repos.policies.list({ status: "active" }); - if (activePolicies.length === 0) return EMPTY; + if (traceEpisodeIds.size === 0 && policyIds.size === 0 && skillGuidance.size === 0) { + return EMPTY; + } // Map each policy to {preference[], antiPattern[]} once. const policyGuidance = new Map< string, { preference: string[]; antiPattern: string[]; matchedEpisodes: number } >(); - for (const p of activePolicies) { - let matched = 0; - for (const ep of p.sourceEpisodeIds) { - if (traceEpisodeIds.has(ep)) matched += 1; - } - if (policyIds.has(p.id)) matched += 1; - if (matched === 0) continue; // policy isn't connected to anything we retrieved + if (repos.policies && (traceEpisodeIds.size > 0 || policyIds.size > 0)) { + const activePolicies = repos.policies.list({ status: "active" }); + for (const p of activePolicies) { + let matched = 0; + for (const ep of p.sourceEpisodeIds) { + if (traceEpisodeIds.has(ep)) matched += 1; + } + if (policyIds.has(p.id)) matched += 1; + if (matched === 0) continue; // policy isn't connected to anything we retrieved - const dg = p.decisionGuidance; - if (dg.preference.length === 0 && dg.antiPattern.length === 0) { - continue; // policy has no learned guidance yet + const dg = p.decisionGuidance; + if (dg.preference.length === 0 && dg.antiPattern.length === 0) { + continue; // policy has no learned guidance yet + } + policyGuidance.set(p.id, { ...dg, matchedEpisodes: matched }); } - policyGuidance.set(p.id, { ...dg, matchedEpisodes: matched }); } - if (policyGuidance.size === 0) return EMPTY; + if (policyGuidance.size === 0 && skillGuidance.size === 0) return EMPTY; // Build dedupe maps keyed by normalized text. const prefDedupe = new Map(); const avoidDedupe = new Map(); + for (const [sid, g] of skillGuidance) { + for (const text of g.preference) { + const key = normaliseKey(text); + if (!key) continue; + const existing = prefDedupe.get(key); + if (existing) { + existing.sourceSkillIds.push(sid); + } else { + prefDedupe.set(key, { + kind: "preference", + text: text.trim(), + sourcePolicyIds: [], + sourceSkillIds: [sid], + }); + } + } + for (const text of g.antiPattern) { + const key = normaliseKey(text); + if (!key) continue; + const existing = avoidDedupe.get(key); + if (existing) { + existing.sourceSkillIds.push(sid); + } else { + avoidDedupe.set(key, { + kind: "antiPattern", + text: text.trim(), + sourcePolicyIds: [], + sourceSkillIds: [sid], + }); + } + } + } + for (const [pid, g] of policyGuidance) { for (const text of g.preference) { const key = normaliseKey(text); @@ -127,6 +175,7 @@ export function collectDecisionGuidance(input: CollectInput): CollectedGuidance kind: "preference", text: text.trim(), sourcePolicyIds: [pid], + sourceSkillIds: [], }); } } @@ -141,6 +190,7 @@ export function collectDecisionGuidance(input: CollectInput): CollectedGuidance kind: "antiPattern", text: text.trim(), sourcePolicyIds: [pid], + sourceSkillIds: [], }); } } @@ -148,8 +198,10 @@ export function collectDecisionGuidance(input: CollectInput): CollectedGuidance // Sort: more cross-policy support first, then alphabetic for stability. const sortByFreq = (a: GuidanceLine, b: GuidanceLine) => { - if (a.sourcePolicyIds.length !== b.sourcePolicyIds.length) { - return b.sourcePolicyIds.length - a.sourcePolicyIds.length; + const aSupport = a.sourcePolicyIds.length + a.sourceSkillIds.length; + const bSupport = b.sourcePolicyIds.length + b.sourceSkillIds.length; + if (aSupport !== bSupport) { + return bSupport - aSupport; } return a.text.localeCompare(b.text); }; @@ -158,6 +210,7 @@ export function collectDecisionGuidance(input: CollectInput): CollectedGuidance preference: Array.from(prefDedupe.values()).sort(sortByFreq).slice(0, perListCap), antiPattern: Array.from(avoidDedupe.values()).sort(sortByFreq).slice(0, perListCap), policyIdsTouched: Array.from(policyGuidance.keys()), + skillIdsTouched: Array.from(skillGuidance.keys()), }; } @@ -178,3 +231,9 @@ function normaliseKey(s: string): string { .trim(); return k; } + +function hasGuidance( + dg: { preference: string[]; antiPattern: string[] } | undefined, +): dg is { preference: string[]; antiPattern: string[] } { + return !!dg && (dg.preference.length > 0 || dg.antiPattern.length > 0); +} diff --git a/apps/memos-local-plugin/core/retrieval/injector.ts b/apps/memos-local-plugin/core/retrieval/injector.ts index f4b7729b1..69fa6066b 100644 --- a/apps/memos-local-plugin/core/retrieval/injector.ts +++ b/apps/memos-local-plugin/core/retrieval/injector.ts @@ -267,9 +267,9 @@ function renderTrace(c: TraceCandidate): InjectionSnippet { function renderEpisode(c: EpisodeCandidate): InjectionSnippet { // Episode summary already comes with step-by-step action sequence - // (see tier2-trace.ts::renderEpisodeSummary), so we drop the raw - // V-score prefix and hand the summary through as-is. - const body = truncate(c.summary); + // (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", " "); return { refKind: "episode", @@ -279,6 +279,15 @@ 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", + ) + .replace(/\bstep\s+(\d+)\s+\(V=[+-]?\d+(?:\.\d+)?\)/gi, "step $1"); +} + function renderExperience(c: ExperienceCandidate): InjectionSnippet { const parts = [ c.trigger ? `Trigger: ${c.trigger}` : null, diff --git a/apps/memos-local-plugin/core/retrieval/tier1-skill.ts b/apps/memos-local-plugin/core/retrieval/tier1-skill.ts index ff9335854..ee621440b 100644 --- a/apps/memos-local-plugin/core/retrieval/tier1-skill.ts +++ b/apps/memos-local-plugin/core/retrieval/tier1-skill.ts @@ -170,6 +170,7 @@ export async function runTier1( eta: sk.eta, status: sk.status, invocationGuide: sk.invocationGuide, + decisionGuidance: normaliseDecisionGuidance(sk), sourcePolicyIds: sk.sourcePolicyIds ?? [], updatedAt: sk.updatedAt, channels: state.channels, @@ -201,6 +202,38 @@ export async function runTier1( } } +function normaliseDecisionGuidance(row: { + decisionGuidance?: { preference: string[]; antiPattern: string[] }; + procedureJson?: unknown; +}): { preference: string[]; antiPattern: string[] } { + if (row.decisionGuidance) { + return { + preference: row.decisionGuidance.preference.map(String).filter(Boolean), + antiPattern: row.decisionGuidance.antiPattern.map(String).filter(Boolean), + }; + } + const proc = (row.procedureJson ?? {}) as { + decisionGuidance?: { preference?: unknown; antiPattern?: unknown }; + decision_guidance?: { preference?: unknown; anti_pattern?: unknown }; + }; + const dg = proc.decisionGuidance; + const snakeDg = proc.decision_guidance; + return { + preference: + dg && Array.isArray(dg.preference) + ? dg.preference.map((s) => String(s)).filter(Boolean) + : snakeDg && Array.isArray(snakeDg.preference) + ? snakeDg.preference.map((s) => String(s)).filter(Boolean) + : [], + antiPattern: + dg && Array.isArray(dg.antiPattern) + ? dg.antiPattern.map((s) => String(s)).filter(Boolean) + : snakeDg && Array.isArray(snakeDg.anti_pattern) + ? snakeDg.anti_pattern.map((s) => String(s)).filter(Boolean) + : [], + }; +} + // ─── Helpers ──────────────────────────────────────────────────────────────── function upsertCandidate( diff --git a/apps/memos-local-plugin/core/retrieval/tier2-trace.ts b/apps/memos-local-plugin/core/retrieval/tier2-trace.ts index 17805c95e..acfdef22d 100644 --- a/apps/memos-local-plugin/core/retrieval/tier2-trace.ts +++ b/apps/memos-local-plugin/core/retrieval/tier2-trace.ts @@ -421,11 +421,11 @@ function dedupChannels(channels: readonly ChannelRank[]): ChannelRank[] { return Array.from(best.values()); } -function renderEpisodeSummary(best: TraceCandidate, members: readonly TraceCandidate[]): string { - const header = `episode ${members.length} steps · best V=${best.value.toFixed(2)} · goal-sim=${best.cosine.toFixed(2)}`; +function renderEpisodeSummary(_best: TraceCandidate, members: readonly TraceCandidate[]): string { + const header = "Past similar episode"; const MAX_STEPS = 6; const steps = members.slice(0, MAX_STEPS).map((m, idx) => { - const parts: string[] = [`step ${idx + 1} (V=${m.value.toFixed(2)})`]; + const parts: string[] = [`step ${idx + 1}`]; const s = m.summary?.trim().replace(/\s+/g, " ") ?? ""; if (s) { parts.push(`summary: ${s.slice(0, 160)}`); diff --git a/apps/memos-local-plugin/core/retrieval/types.ts b/apps/memos-local-plugin/core/retrieval/types.ts index e1a486e76..6c8e3f8e3 100644 --- a/apps/memos-local-plugin/core/retrieval/types.ts +++ b/apps/memos-local-plugin/core/retrieval/types.ts @@ -107,6 +107,7 @@ export interface SkillCandidate extends TierCandidateBase { eta: number; status: SkillStatus; invocationGuide: string; + decisionGuidance?: { preference: string[]; antiPattern: string[] }; sourcePolicyIds?: PolicyId[]; updatedAt?: EpochMs; } @@ -381,6 +382,8 @@ export interface RetrievalRepos { name: string; status: SkillStatus; invocationGuide: string; + procedureJson?: unknown; + decisionGuidance?: { preference: string[]; antiPattern: string[] }; eta: number; sourcePolicyIds?: PolicyId[]; updatedAt?: EpochMs; diff --git a/apps/memos-local-plugin/core/reward/human-scorer.ts b/apps/memos-local-plugin/core/reward/human-scorer.ts index f8782d283..c1a4d58a1 100644 --- a/apps/memos-local-plugin/core/reward/human-scorer.ts +++ b/apps/memos-local-plugin/core/reward/human-scorer.ts @@ -151,26 +151,41 @@ export function heuristicScore(feedback: readonly UserFeedback[]): HumanScore { model: null, }; } - const explicit = feedback.find((f) => f.channel === "explicit") ?? feedback[0]!; + const explicit = feedback.filter((f) => f.channel === "explicit"); + const scored = explicit.length > 0 ? explicit : [feedback[0]!]; // polarity → user_satisfaction mapping; we don't try to score goal/process - // without an LLM (would require understanding the task). - const sat = mapPolarity(explicit.polarity, explicit.magnitude); + // without an LLM (would require understanding the task). Multiple explicit + // corrections are aggregated so a later thumbs-down can counter earlier praise. + const sat = aggregatePolarity(scored); const rHuman = clamp(sat, -1, 1); + const source = explicit.length > 0 ? "explicit" : "heuristic"; return { rHuman, axes: { goalAchievement: 0, processQuality: 0, userSatisfaction: sat }, - reason: `heuristic polarity=${explicit.polarity} magnitude=${explicit.magnitude.toFixed(2)}`, - source: explicit.channel === "explicit" ? "explicit" : "heuristic", + reason: `heuristic ${source} feedback_count=${scored.length}`, + source, model: null, }; } -function mapPolarity(polarity: UserFeedback["polarity"], magnitude: number): number { - const base = - polarity === "positive" ? 0.7 : polarity === "negative" ? -0.7 : polarity === "neutral" ? 0 : 0; - // magnitude ∈ [0, 1]; we treat 1 as "strongly held" and scale from ±0.3 → ±1. - const scale = 0.3 + 0.7 * clamp(magnitude, 0, 1); - return clamp(base * scale * (1 / 0.7), -1, 1); +function aggregatePolarity(feedback: readonly UserFeedback[]): number { + let sum = 0; + let weight = 0; + for (const f of feedback) { + if (f.polarity === "neutral") continue; + const magnitude = clamp(f.magnitude, 0, 1); + if (magnitude === 0) continue; + sum += signedMagnitude(f.polarity, magnitude); + weight += magnitude; + } + if (weight === 0) return 0; + return clamp(sum / weight, -1, 1); +} + +function signedMagnitude(polarity: UserFeedback["polarity"], magnitude: number): number { + if (polarity === "positive") return magnitude; + if (polarity === "negative") return -magnitude; + return 0; } // ─── helpers ──────────────────────────────────────────────────────────────── diff --git a/apps/memos-local-plugin/core/reward/reward.ts b/apps/memos-local-plugin/core/reward/reward.ts index 43f0ee8d4..1b6d3354c 100644 --- a/apps/memos-local-plugin/core/reward/reward.ts +++ b/apps/memos-local-plugin/core/reward/reward.ts @@ -234,6 +234,7 @@ export function createRewardRunner(deps: RewardDeps): RewardRunner { deps.tracesRepo.updateScore(u.traceId, { value: u.value, alpha: u.alpha, + rHuman: humanScore.rHuman, priority: u.priority, }); } diff --git a/apps/memos-local-plugin/core/reward/task-summary.ts b/apps/memos-local-plugin/core/reward/task-summary.ts index 6f76450d8..88c16720c 100644 --- a/apps/memos-local-plugin/core/reward/task-summary.ts +++ b/apps/memos-local-plugin/core/reward/task-summary.ts @@ -68,7 +68,7 @@ export function buildTaskSummary(input: SummaryInput): TaskSummary { : traces.map(traceToPair).filter((p) => p !== null) as ExchangePair[]; const pairsText = pairs.length > 0 - ? pairs.map((p, i) => formatPair(p, i)).join("\n\n") + ? pairs.map((p, i) => formatPair(p, i, i === pairs.length - 1)).join("\n\n") : "(no recorded exchanges)"; const agentActions = traces.map(traceOneLiner).filter(Boolean).join("\n"); @@ -88,7 +88,7 @@ export function buildTaskSummary(input: SummaryInput): TaskSummary { oneLine(pairs.length > 0 ? pairs[pairs.length - 1]!.userText : userQuery, 500), ``, `MOST_RECENT_AGENT_REPLY:`, - oneLine(pairs.length > 0 ? pairs[pairs.length - 1]!.agentText : outcome, 800), + clampAgentText(pairs.length > 0 ? pairs[pairs.length - 1]!.agentText : outcome), ].join("\n"); const { text, truncated } = clampText(body, cfg.summaryMaxChars); @@ -167,10 +167,11 @@ function traceToPair(t: TraceRow): ExchangePair | null { return { userText: u, agentText: a, toolHint }; } -function formatPair(p: ExchangePair, idx: number): string { +function formatPair(p: ExchangePair, idx: number, isLast = false): string { const lines: string[] = [`[${idx + 1}] USER: ${oneLine(p.userText, 300)}`]; if (p.toolHint) lines.push(` TOOLS: ${p.toolHint}`); - lines.push(` AGENT: ${oneLine(p.agentText, 400)}`); + const agentSnippet = isLast ? clampAgentText(p.agentText) : oneLine(p.agentText, 400); + lines.push(` AGENT: ${agentSnippet}`); return lines.join("\n"); } @@ -259,6 +260,16 @@ function oneLine(s: string, max: number): string { .slice(0, max); } +const AGENT_TEXT_MAX = 5000; +const AGENT_TEXT_HEAD = 2000; +const AGENT_TEXT_TAIL = 3000; + +function clampAgentText(s: string): string { + const trimmed = s.trim(); + if (trimmed.length <= AGENT_TEXT_MAX) return trimmed; + return trimmed.slice(0, AGENT_TEXT_HEAD) + "\n......\n" + trimmed.slice(trimmed.length - AGENT_TEXT_TAIL); +} + function clampText(text: string, max: number): { text: string; truncated: boolean } { if (text.length <= max) return { text, truncated: false }; const headLen = Math.floor((max - TRUNC_MARKER.length) * 0.55); diff --git a/apps/memos-local-plugin/core/runtime/namespace.ts b/apps/memos-local-plugin/core/runtime/namespace.ts index c2fdaf24d..e58e95bc1 100644 --- a/apps/memos-local-plugin/core/runtime/namespace.ts +++ b/apps/memos-local-plugin/core/runtime/namespace.ts @@ -105,13 +105,10 @@ export function visibilityWhere( const normalized = normalizeNamespace(ns, ns?.agentKind ?? "unknown"); return { sql: - `((` + - `${col("owner_agent_kind")} = @vis_owner_agent_kind AND ` + - `${col("owner_profile_id")} = @vis_owner_profile_id` + - `) OR COALESCE(${col("share_scope")}, 'private') IN ('local', 'public', 'hub'))`, + `(${col("owner_agent_kind")} = @vis_owner_agent_kind` + + ` OR COALESCE(${col("share_scope")}, 'private') IN ('local', 'public', 'hub'))`, params: { vis_owner_agent_kind: normalized.agentKind, - vis_owner_profile_id: normalized.profileId, }, }; } @@ -143,17 +140,11 @@ export function isVisibleTo( ): boolean { const scope = normalizeShareScope(row.share?.scope); if (scope === "local" || scope === "public" || scope === "hub") return true; - if ( - (!row.ownerAgentKind || row.ownerAgentKind === "unknown") && - (!row.ownerProfileId || row.ownerProfileId === DEFAULT_PROFILE_ID) - ) { + if (!row.ownerAgentKind || row.ownerAgentKind === "unknown") { return true; } const normalized = normalizeNamespace(ns, ns.agentKind); - return ( - row.ownerAgentKind === normalized.agentKind && - row.ownerProfileId === normalized.profileId - ); + return row.ownerAgentKind === normalized.agentKind; } export function namespaceMeta(ns: RuntimeNamespace): Record { diff --git a/apps/memos-local-plugin/core/session/episode-manager.ts b/apps/memos-local-plugin/core/session/episode-manager.ts index ef5b58bd4..c38ae082e 100644 --- a/apps/memos-local-plugin/core/session/episode-manager.ts +++ b/apps/memos-local-plugin/core/session/episode-manager.ts @@ -46,6 +46,7 @@ export interface EpisodeManager { addTurn(id: EpisodeId, turn: EpisodeTurnInput): EpisodeTurn; finalize(id: EpisodeId, input?: EpisodeFinalizeInput): EpisodeSnapshot; abandon(id: EpisodeId, reason: string): EpisodeSnapshot; + discardEmpty(id: EpisodeId, reason: string): EpisodeSnapshot | null; attachTraceIds(id: EpisodeId, traceIds: string[]): void; hydrate(snapshot: EpisodeSnapshot): EpisodeSnapshot; patchMeta(id: EpisodeId, metaPatch: Record): EpisodeSnapshot; @@ -286,6 +287,25 @@ export function createEpisodeManager(deps: EpisodeManagerDeps): EpisodeManager { return cloneSnapshot(snap); }, + discardEmpty(id, reason) { + const snap = get(id); + if (!snap) return null; + if (snap.traceIds.length > 0 || snap.turns.some((t) => t.role === "assistant" && t.content.trim())) { + throw new MemosError(ERROR_CODES.CONFLICT, `episode ${id} is not empty`, { + episodeId: id, + status: snap.status, + }); + } + byId.delete(id); + deps.episodesRepo.deleteById(id); + log.info("episode.discarded_empty", { + episodeId: id, + sessionId: snap.sessionId, + reason, + }); + return cloneSnapshot(snap); + }, + reopen(id, reason) { const snap = get(id); if (!snap) { diff --git a/apps/memos-local-plugin/core/session/manager.ts b/apps/memos-local-plugin/core/session/manager.ts index 37a8f1146..8dfd9a3f2 100644 --- a/apps/memos-local-plugin/core/session/manager.ts +++ b/apps/memos-local-plugin/core/session/manager.ts @@ -73,6 +73,7 @@ export interface SessionManager { addTurn(episodeId: EpisodeId, turn: EpisodeTurnInput): EpisodeTurn; finalizeEpisode(episodeId: EpisodeId, input?: EpisodeFinalizeInput): EpisodeSnapshot; abandonEpisode(episodeId: EpisodeId, reason: string): EpisodeSnapshot; + discardEmptyEpisode(episodeId: EpisodeId, reason: string): EpisodeSnapshot | null; /** V7 §0.1 "revision" path — reopen a previously-closed episode. */ reopenEpisode( episodeId: EpisodeId, @@ -175,6 +176,10 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { }); continue; } + if (isDiscardableEmptyEpisode(ep)) { + epm.discardEmpty(ep.id, `session_closed:${reason}`); + continue; + } epm.patchMeta(ep.id, { topicState: "paused", pauseReason: `session_closed:${reason}`, @@ -293,6 +298,13 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { return snap; } + function discardEmptyEpisode(id: EpisodeId, reason: string): EpisodeSnapshot | null { + const before = epm.get(id); + const snap = epm.discardEmpty(id, reason); + if (before) decrementOpenCount(before.sessionId); + return snap; + } + function reopenEpisode( id: EpisodeId, reason: import("./types.js").TurnRelation, @@ -339,6 +351,10 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { }); continue; } + if (isDiscardableEmptyEpisode(ep)) { + epm.discardEmpty(ep.id, `shutdown:${reason}`); + continue; + } epm.patchMeta(ep.id, { topicState: "paused", pauseReason: `shutdown:${reason}`, @@ -371,6 +387,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { addTurn: epm.addTurn, finalizeEpisode, abandonEpisode, + discardEmptyEpisode, reopenEpisode, hydrateEpisode, attachTraceIds: epm.attachTraceIds, @@ -389,6 +406,11 @@ function stringMeta(meta: Record | undefined, key: string): str return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function isDiscardableEmptyEpisode(ep: EpisodeSnapshot): boolean { + if (ep.traceIds.length > 0) return false; + return !ep.turns.some((t) => t.role === "assistant" && t.content.trim().length > 0); +} + // Re-export helpers tests will want to use. export type { IntentDecision } from "./types.js"; export type { AgentKind }; diff --git a/apps/memos-local-plugin/core/session/persistence.ts b/apps/memos-local-plugin/core/session/persistence.ts index 5cc30e162..cf59cdd75 100644 --- a/apps/memos-local-plugin/core/session/persistence.ts +++ b/apps/memos-local-plugin/core/session/persistence.ts @@ -64,6 +64,7 @@ export interface EpisodesRepo { }): void; updateTraceIds(id: EpisodeId, traceIds: string[]): void; updateMeta(id: EpisodeId, metaPatch: Record): void; + deleteById(id: EpisodeId): void; close(id: EpisodeId, endedAt: EpochMs, rTask?: number, meta?: Record): void; /** * Flip a closed episode back to `open` — V7 §0.1 "revision" path. @@ -153,6 +154,9 @@ export function adaptEpisodesRepo(sqlite: SqliteEpisodes): EpisodesRepo { updateMeta(id, metaPatch) { sqlite.updateMeta(id, metaPatch); }, + deleteById(id) { + sqlite.deleteById(id); + }, close(id, endedAt, rTask, meta) { // CRITICAL: never use `episodes.upsert` here. The repo's upsert // is `INSERT OR REPLACE`, which SQLite executes as DELETE + diff --git a/apps/memos-local-plugin/core/storage/repos/episodes.ts b/apps/memos-local-plugin/core/storage/repos/episodes.ts index 1b5bba65d..0ded6c0af 100644 --- a/apps/memos-local-plugin/core/storage/repos/episodes.ts +++ b/apps/memos-local-plugin/core/storage/repos/episodes.ts @@ -45,6 +45,9 @@ export function makeEpisodesRepo(db: StorageDb) { const selectById = db.prepare<{ id: string }, RawEpisodeRow>( `SELECT ${COLUMNS.join(", ")} FROM episodes WHERE id=@id`, ); + const deleteById = db.prepare<{ id: string }>( + `DELETE FROM episodes WHERE id=@id`, + ); const selectOpenForSession = db.prepare<{ session: string }, RawEpisodeRow>( `SELECT ${COLUMNS.join(", ")} FROM episodes WHERE session_id=@session AND status='open' ORDER BY started_at DESC LIMIT 1`, ); @@ -132,6 +135,10 @@ export function makeEpisodesRepo(db: StorageDb) { appendTrace.run({ id, trace_ids_json: toJsonText(kept) }); }, + deleteById(id: EpisodeId): void { + deleteById.run({ id }); + }, + getById(id: EpisodeId): (EpisodeRow & EpisodeMetaRow) | null { const r = selectById.get({ id }); if (!r) return null; diff --git a/apps/memos-local-plugin/core/storage/repos/traces.ts b/apps/memos-local-plugin/core/storage/repos/traces.ts index bacf09469..43656ba19 100644 --- a/apps/memos-local-plugin/core/storage/repos/traces.ts +++ b/apps/memos-local-plugin/core/storage/repos/traces.ts @@ -121,6 +121,14 @@ export function makeTracesRepo(db: StorageDb) { fragments.push(`episode_id = @episode_id`); params.episode_id = filter.episodeId; } + if (filter.ownerAgentKind) { + fragments.push(`owner_agent_kind = @owner_agent_kind`); + params.owner_agent_kind = filter.ownerAgentKind; + } + if (filter.ownerProfileId) { + fragments.push(`owner_profile_id = @owner_profile_id`); + params.owner_profile_id = filter.ownerProfileId; + } if (filter.minAbsValue !== undefined) { fragments.push(`abs(value) >= @min_abs_value`); params.min_abs_value = filter.minAbsValue; @@ -148,6 +156,14 @@ export function makeTracesRepo(db: StorageDb) { fragments.push(`episode_id = @episode_id`); params.episode_id = filter.episodeId; } + if (filter.ownerAgentKind) { + fragments.push(`owner_agent_kind = @owner_agent_kind`); + params.owner_agent_kind = filter.ownerAgentKind; + } + if (filter.ownerProfileId) { + fragments.push(`owner_profile_id = @owner_profile_id`); + params.owner_profile_id = filter.ownerProfileId; + } if (filter.minAbsValue !== undefined) { fragments.push(`abs(value) >= @min_abs_value`); params.min_abs_value = filter.minAbsValue; @@ -164,7 +180,10 @@ export function makeTracesRepo(db: StorageDb) { * where one user query + its tool sub-steps + final reply are * counted as 1. Used by the Memories viewer for accurate pagination. */ - countTurns(filter: Omit = {}): number { + countTurns( + filter: Omit = {}, + visibility?: { sql: string; params: Record }, + ): number { const fragments: string[] = []; const params: Record = {}; if (filter.sessionId) { @@ -175,6 +194,18 @@ export function makeTracesRepo(db: StorageDb) { fragments.push(`episode_id = @episode_id`); params.episode_id = filter.episodeId; } + if (filter.ownerAgentKind) { + fragments.push(`owner_agent_kind = @owner_agent_kind`); + params.owner_agent_kind = filter.ownerAgentKind; + } + if (filter.ownerProfileId) { + fragments.push(`owner_profile_id = @owner_profile_id`); + params.owner_profile_id = filter.ownerProfileId; + } + if (visibility) { + fragments.push(visibility.sql); + Object.assign(params, visibility.params); + } const where = joinWhere(fragments); const sql = `SELECT COUNT(*) AS n FROM (SELECT DISTINCT episode_id, turn_id FROM traces ${where})`; const row = db.prepare(sql).get(params); @@ -186,7 +217,10 @@ export function makeTracesRepo(db: StorageDb) { * turn's most recent trace timestamp DESC. The viewer uses this to * fetch a page of "memories" (1 turn = 1 memory). */ - listTurnKeys(filter: TraceListFilter = {}): Array<{ episodeId: string | null; turnId: number; maxTs: number }> { + listTurnKeys( + filter: TraceListFilter = {}, + visibility?: { sql: string; params: Record }, + ): Array<{ episodeId: string | null; turnId: number; maxTs: number }> { const fragments: string[] = []; const params: Record = {}; if (filter.sessionId) { @@ -197,6 +231,18 @@ export function makeTracesRepo(db: StorageDb) { fragments.push(`episode_id = @episode_id`); params.episode_id = filter.episodeId; } + if (filter.ownerAgentKind) { + fragments.push(`owner_agent_kind = @owner_agent_kind`); + params.owner_agent_kind = filter.ownerAgentKind; + } + if (filter.ownerProfileId) { + fragments.push(`owner_profile_id = @owner_profile_id`); + params.owner_profile_id = filter.ownerProfileId; + } + if (visibility) { + fragments.push(visibility.sql); + Object.assign(params, visibility.params); + } const where = joinWhere(fragments); const limit = Math.max(1, Math.min(500, filter.limit ?? 50)); const offset = Math.max(0, filter.offset ?? 0); diff --git a/apps/memos-local-plugin/core/storage/types.ts b/apps/memos-local-plugin/core/storage/types.ts index 554121ebd..3e02a0d14 100644 --- a/apps/memos-local-plugin/core/storage/types.ts +++ b/apps/memos-local-plugin/core/storage/types.ts @@ -88,6 +88,8 @@ export interface TimeRange { export interface TraceListFilter extends PageOptions, TimeRange { sessionId?: string; episodeId?: EpisodeId; + ownerAgentKind?: string; + ownerProfileId?: string; /** Only traces with |value| >= this (absolute). */ minAbsValue?: number; traceIds?: TraceId[]; diff --git a/apps/memos-local-plugin/core/telemetry/sender.ts b/apps/memos-local-plugin/core/telemetry/sender.ts index 8c7118684..4a72c7bdf 100644 --- a/apps/memos-local-plugin/core/telemetry/sender.ts +++ b/apps/memos-local-plugin/core/telemetry/sender.ts @@ -79,12 +79,11 @@ export class Telemetry { private enabled: boolean; private pluginVersion: string; private log: Logger; - private dailyPingSent = false; - private dailyPingDate = ""; private buffer: ArmsEvent[] = []; private flushTimer: ReturnType | null = null; private sessionId: string; private firstSeenDate: string; + private dailyPingFile: string; private armsEndpoint: string; private armsPid: string; private armsEnv: string; @@ -102,6 +101,7 @@ export class Telemetry { this.distinctId = this.loadOrCreateAnonymousId(stateDir); this.firstSeenDate = this.loadOrCreateFirstSeen(stateDir); this.sessionId = this.loadOrCreateSessionId(stateDir); + this.dailyPingFile = path.join(stateDir, "memos-local", ".last-daily-ping"); const creds = loadTelemetryCredentials(pluginDir); this.armsEndpoint = creds.endpoint; @@ -314,11 +314,36 @@ export class Telemetry { }); } + /** + * Emit `daily_active` at most once per UTC day per home directory. + * + * The de-dup state lives on disk (`/memos-local/.last-daily-ping`) + * so it survives process restarts. Without this, every `bridge.cts` / + * OpenClaw adapter spawn would emit a fresh ping (Hermes spawns one + * subprocess per `hermes chat`), turning `daily_active` into a + * "process started" counter and breaking DAU dashboards. + * + * Read failures are treated as "first time today" — that means at + * worst we over-report by one event after a corrupt file, never + * under-report. Write failures are swallowed; the next launch will + * just send another ping (still better than the silent in-memory + * failure mode the previous implementation had). + */ private maybeSendDailyPing(): void { const today = new Date().toISOString().slice(0, 10); - if (this.dailyPingSent && this.dailyPingDate === today) return; - this.dailyPingSent = true; - this.dailyPingDate = today; + let lastPing = ""; + try { + lastPing = fs.readFileSync(this.dailyPingFile, "utf-8").trim(); + } catch { + // First time today (or first install) — fall through and emit. + } + if (lastPing === today) return; + try { + fs.mkdirSync(path.dirname(this.dailyPingFile), { recursive: true }); + fs.writeFileSync(this.dailyPingFile, today, "utf-8"); + } catch { + // Non-fatal; we'll just send another one next launch. + } this.capture("daily_active", { first_seen_date: this.firstSeenDate }); } diff --git a/apps/memos-local-plugin/docs/ALGORITHM_ALIGNMENT.md b/apps/memos-local-plugin/docs/ALGORITHM_ALIGNMENT.md index 45cc67dbe..cac554260 100644 --- a/apps/memos-local-plugin/docs/ALGORITHM_ALIGNMENT.md +++ b/apps/memos-local-plugin/docs/ALGORITHM_ALIGNMENT.md @@ -127,10 +127,10 @@ | 写入 `decision_repairs` 表 | `core/storage/repos/decision_repairs.ts` | ✅ | | **挂到 PolicyRow** | `feedback.ts::attachRepairToPolicies` 把 `{preference, antiPattern}` 塞进 `policy.boundary` 的 `@repair {…}` JSON 块 | ⚠️ 工作但是 hack — 应该是独立列 | | PolicyDTO 透出 preference / antiPattern | `memory-core.ts::parsePolicyGuidanceBlock` | ✅ | -| PoliciesView 显示 | `web/src/views/PoliciesView.tsx:243-256, 497-518` | ✅ | +| PoliciesView 显示 | `viewer/src/views/PoliciesView.tsx:243-256, 497-518` | ✅ | | **写入 SkillRow.procedureJson.decisionGuidance** | `core/skill/packager.ts::buildProcedure` | ✅ **已实现** — 用 draft.decisionGuidance 替换原硬编码空 | | **Skill crystallize prompt 输入 policy 的 @repair** | `core/llm/prompts/skill-crystallize.ts v2` + `crystallize.ts::packPrompt::parseRepairBlock` | ✅ **已实现** — 把 `@repair {…}` 提到 `repair_hints`,加上 `counter_examples` 一起喂给 LLM | -| **SkillsView 显示 decisionGuidance** | `web/src/views/SkillsView.tsx::SkillDrawer` | ✅ **已实现** — drawer 新增 "Decision guidance (prefer / avoid)" 段,列出双数组 | +| **SkillsView 显示 decisionGuidance** | `viewer/src/views/SkillsView.tsx::SkillDrawer` | ✅ **已实现** — drawer 新增 "Decision guidance (prefer / avoid)" 段,列出双数组 | | **retrieval/injector 把 decision_guidance 注入到 agent prompt** | `core/retrieval/decision-guidance.ts::collectDecisionGuidance` + `injector.ts::renderDecisionGuidance` | ✅ **已实现** — retrieval 拉 active policies, 按 `sourceEpisodeIds` 与召回的 trace 关联, 解析 `@repair` 块, 在注入包尾部渲染 "## Decision guidance" 段 | → **决策修复链路完整闭环**:repair 的 preference / anti-pattern 三处都消费了:① 写入到 Skill 的 `decisionGuidance` 字段并随技能注入;② retrieval 时按 episode-policy 关联从 active policies 临时召回,独立成段注入到 agent prompt;③ PoliciesView + SkillsView 都展示。Agent 现在真正能感知到"吃一堑长一智"的教训。 diff --git a/apps/memos-local-plugin/docs/GRANULARITY-AND-MEMORY-LAYERS.md b/apps/memos-local-plugin/docs/GRANULARITY-AND-MEMORY-LAYERS.md index 8d03b9451..8b2ae7ce1 100644 --- a/apps/memos-local-plugin/docs/GRANULARITY-AND-MEMORY-LAYERS.md +++ b/apps/memos-local-plugin/docs/GRANULARITY-AND-MEMORY-LAYERS.md @@ -24,7 +24,7 @@ 代码中保证这一点的几处关键设计: - `core/capture/step-extractor.ts` 把一个用户消息触发的所有动作拆成多个小步 sub-step,全部带同一个 `turnId` 写入 `traces.turn_id`。 -- 前端 `web/src/views/MemoriesView.tsx::buildGroups` 按 `(episodeId, turnId)` 把多条小步聚合成一张卡片显示。 +- 前端 `viewer/src/views/MemoriesView.tsx::buildGroups` 按 `(episodeId, turnId)` 把多条小步聚合成一张卡片显示。 - 检索路径 `core/retrieval/tier2-trace.ts` 永远以 `traces` 行为最小单位,从不读 `turn_id`。 --- diff --git a/apps/memos-local-plugin/docs/MANUAL_E2E_TESTING.md b/apps/memos-local-plugin/docs/MANUAL_E2E_TESTING.md index a9562e676..0a2f6fbc4 100644 --- a/apps/memos-local-plugin/docs/MANUAL_E2E_TESTING.md +++ b/apps/memos-local-plugin/docs/MANUAL_E2E_TESTING.md @@ -435,5 +435,5 @@ curl -s -b "memos_sess=$SESS" 'http://127.0.0.1:18799/api/v1/config' \ - `core/memory/l3/cluster.ts` ─ L3 cluster 键规则 - `core/skill/subscriber.ts` ─ skill 触发条件 - `core/capture/tagger.ts` ─ 标签识别关键字 -- 前端视图:`web/src/views/{Memories,Tasks,Skills,Policies,WorldModels}View.tsx` +- 前端视图:`viewer/src/views/{Memories,Tasks,Skills,Policies,WorldModels}View.tsx` - 算法设计文档:`../memos-local-openclaw/算法设计_Reflect2Skill_V7_核心详解.md` diff --git a/apps/memos-local-plugin/docs/MULTI_AGENT_VIEWER.md b/apps/memos-local-plugin/docs/MULTI_AGENT_VIEWER.md index a80fbb614..2e05b581f 100644 --- a/apps/memos-local-plugin/docs/MULTI_AGENT_VIEWER.md +++ b/apps/memos-local-plugin/docs/MULTI_AGENT_VIEWER.md @@ -83,7 +83,7 @@ There is no "choose an agent" landing page — :18799 is openclaw, ### Header switcher (the only cross-port surface) The viewer SPA also probes the peer's well-known port once on load -(`web/src/stores/peers.ts`) and surfaces a small pill in the top bar +(`viewer/src/stores/peers.ts`) and surfaces a small pill in the top bar linking to it (when reachable). That's the only piece of cross-port discovery in the whole system, and it's a single-shot `fetch` against `http://127.0.0.1:/api/v1/health`. diff --git a/apps/memos-local-plugin/docs/README.md b/apps/memos-local-plugin/docs/README.md index c6ce82adc..f3858088c 100644 --- a/apps/memos-local-plugin/docs/README.md +++ b/apps/memos-local-plugin/docs/README.md @@ -1,7 +1,7 @@ # docs/ — developer-facing documentation -For *user-facing* docs (getting started, configuration, viewer tour), see -[`site/content/docs/`](../site/content/docs/) instead. +For *user-facing* help (getting started, configuration, viewer tour), open the +viewer's *Help* page at runtime — it ships with the plugin's `viewer/` bundle. ## Document index diff --git a/apps/memos-local-plugin/docs/SKILLFLOW_FEEDBACK_EXPERIENCE_OPTIMIZATION_PLAN.md b/apps/memos-local-plugin/docs/SKILLFLOW_FEEDBACK_EXPERIENCE_OPTIMIZATION_PLAN.md index bacc7b78b..9276f4f54 100644 --- a/apps/memos-local-plugin/docs/SKILLFLOW_FEEDBACK_EXPERIENCE_OPTIMIZATION_PLAN.md +++ b/apps/memos-local-plugin/docs/SKILLFLOW_FEEDBACK_EXPERIENCE_OPTIMIZATION_PLAN.md @@ -493,7 +493,7 @@ after feedback: - `core/types.ts` - `core/storage/repos/policies.ts` - `agent-contract/dto.ts` -- `web/src/api/types.ts` +- `viewer/src/api/types.ts` 新增经验字段: diff --git a/apps/memos-local-plugin/install.ps1 b/apps/memos-local-plugin/install.ps1 index cc2ee5989..79efa5e4f 100644 --- a/apps/memos-local-plugin/install.ps1 +++ b/apps/memos-local-plugin/install.ps1 @@ -3,101 +3,480 @@ install.ps1 — Windows installer for @memtensor/memos-local-plugin. .DESCRIPTION - Mirrors install.sh: - 1. Deploys plugin source to %USERPROFILE%\.\plugins\memos-local-plugin\ - (override with -Prefix). - 2. Creates runtime layout under %USERPROFILE%\.\memos-plugin\ - (override with -HomeDir). - 3. Generates config.yaml from templates\config..yaml unless one - already exists. Use -ForceConfig to overwrite. - 4. Hands off to adapters\\install..ps1 if present. + Replicates the functionality of install.sh for Windows environments. + - Downloads/extracts the tarball + - Configures OpenClaw and/or Hermes + - Patches configuration files + - Restarts services -.PARAMETER Agent - "openclaw" or "hermes". - -.PARAMETER Prefix - Override the code install directory. - -.PARAMETER HomeDir - Override the runtime data directory. - -.PARAMETER ForceConfig - Overwrite an existing config.yaml. - -.PARAMETER Uninstall - Remove the deployed code (runtime data is preserved). +.PARAMETER Version + Specific npm version or local path to a .tgz tarball. #> [CmdletBinding()] param( - [Parameter(Mandatory=$true, Position=0)] - [ValidateSet("openclaw","hermes")] - [string]$Agent, - - [string]$Prefix, - [string]$HomeDir, - [switch]$ForceConfig, - [switch]$Uninstall + [string]$Version, + [switch]$Help ) $ErrorActionPreference = "Stop" + +if ($Help) { + Write-Host "Usage:" + Write-Host " .\install.ps1 # latest from npm" + Write-Host " .\install.ps1 -Version X.Y.Z # specific npm version" + Write-Host " .\install.ps1 -Version .\pkg.tgz # local tarball" + exit 0 +} + +# --- Helpers --- +function Write-Info($msg) { Write-Host " > $msg" -ForegroundColor Cyan } +function Write-Success($msg) { Write-Host " [OK] $msg" -ForegroundColor Green } +function Write-Warn($msg) { Write-Host " [WARN] $msg" -ForegroundColor Yellow } +function Stop-Die($msg) { Write-Host " [ERROR] $msg" -ForegroundColor Red; exit 1 } + +$PluginId = "memos-local-plugin" +$NpmPackage = "@memtensor/memos-local-plugin" +$OpenClawPort = 18799 +$HermesPort = 18800 $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -function Write-Info($msg) { Write-Host "[install] $msg" -ForegroundColor Cyan } -function Write-Warn2($msg) { Write-Host "[install] $msg" -ForegroundColor Yellow } -function Stop-Die($msg) { Write-Host "[install] $msg" -ForegroundColor Red; exit 1 } - -$DefaultPrefix = Join-Path $HOME ".$Agent\plugins\memos-local-plugin" -$DefaultHome = Join-Path $HOME ".$Agent\memos-plugin" -if (-not $Prefix) { $Prefix = $DefaultPrefix } -if (-not $HomeDir) { $HomeDir = $DefaultHome } - -if ($Uninstall) { - Write-Info "Uninstalling code from $Prefix (runtime data at $HomeDir is preserved)" - if (Test-Path $Prefix) { Remove-Item -Recurse -Force $Prefix } - Write-Info "Done." - exit 0 -} - -# 1. deploy package contents -Write-Info "Deploying plugin code -> $Prefix" -New-Item -ItemType Directory -Force -Path $Prefix | Out-Null -$exclude = @("node_modules","tests",".git") -robocopy $ScriptDir $Prefix /MIR /XD $exclude | Out-Null - -# 2. runtime dirs -Write-Info "Ensuring runtime directory layout under $HomeDir" -foreach ($sub in @("data","skills","logs","daemon")) { - New-Item -ItemType Directory -Force -Path (Join-Path $HomeDir $sub) | Out-Null -} - -# 3. config.yaml -$Template = Join-Path $ScriptDir "templates\config.$Agent.yaml" -$Target = Join-Path $HomeDir "config.yaml" -if (-not (Test-Path $Template)) { - Write-Warn2 "Template missing: $Template (skipping config generation)" -} elseif ((Test-Path $Target) -and -not $ForceConfig) { - Write-Info "config.yaml already exists at $Target -- keeping it (use -ForceConfig to overwrite)" -} else { - Write-Info "Writing config.yaml -> $Target" - Copy-Item -Force $Template $Target +Write-Host "" +Write-Host " ==================================================" -ForegroundColor Blue +Write-Host " MemOS Local Plugin Installer (Windows) " -ForegroundColor Blue +Write-Host " ==================================================" -ForegroundColor Blue +Write-Host "" + +# Node check +try { + $NodeVersionStr = (node -v 2>$null) + if (-not $NodeVersionStr) { Stop-Die "Node.js is not installed or not in PATH." } + Write-Success "Node.js $NodeVersionStr" +} catch { + Stop-Die "Node.js is not installed or not in PATH." } -$UserReadme = Join-Path $ScriptDir "templates\README.user.md" -if (Test-Path $UserReadme) { - Copy-Item -Force $UserReadme (Join-Path $HomeDir "README.md") +# Agent detection +$HasOpenClaw = Test-Path "$env:USERPROFILE\.openclaw" +$HasHermes = Test-Path "$env:LOCALAPPDATA\hermes" + +Write-Host "`n Detected agents:" -ForegroundColor White +if ($HasOpenClaw) { Write-Host " - OpenClaw (~/.openclaw)" -ForegroundColor Green } +else { Write-Host " - OpenClaw (not installed)" -ForegroundColor DarkGray } + +if ($HasHermes) { Write-Host " - Hermes (~/AppData/Local/hermes)" -ForegroundColor Green } +else { Write-Host " - Hermes (not installed)" -ForegroundColor DarkGray } + +Write-Host "`n Install into which agent?" +Write-Host " [Enter] Auto-detect" +Write-Host " [1] OpenClaw only" +Write-Host " [2] Hermes only" +Write-Host " [3] Both" +Write-Host " [q] Quit`n" + +$Choice = Read-Host " Choice" +$AgentSelection = "auto" + +switch ($Choice) { + "1" { $AgentSelection = "openclaw" } + "2" { $AgentSelection = "hermes" } + "3" { $AgentSelection = "all" } + "q" { Write-Info "Aborted."; exit 0 } + "Q" { Write-Info "Aborted."; exit 0 } + "" { $AgentSelection = "auto" } + default { Stop-Die "Invalid choice: $Choice" } +} + +if ($AgentSelection -eq "auto") { + if (-not $HasOpenClaw -and -not $HasHermes) { Stop-Die "Neither ~/.openclaw nor ~/AppData/Local/hermes exists. Install one first." } + if ($HasOpenClaw -and $HasHermes) { $AgentSelection = "all" } + elseif ($HasOpenClaw) { $AgentSelection = "openclaw" } + else { $AgentSelection = "hermes" } + Write-Success "Auto-detected: $AgentSelection" } -# 4. adapter-specific step -$Sub = Join-Path $ScriptDir "adapters\$Agent\install.$Agent.ps1" -if (Test-Path $Sub) { - Write-Info "Running adapter installer: $Sub" - & $Sub -Agent $Agent -Prefix $Prefix -HomeDir $HomeDir +# Resolve tarball +$StageDir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ([guid]::NewGuid().ToString())) -Force +$SourceKind = "npm" +$SourceSpec = $NpmPackage +$BuiltTarball = "" + +if ($Version) { + if (Test-Path $Version) { + $SourceKind = "path" + $BuiltTarball = Resolve-Path $Version | Select-Object -ExpandProperty Path + $SourceSpec = $BuiltTarball + Write-Success "Using local tarball: $BuiltTarball" + } else { + $SourceSpec = "$NpmPackage@$Version" + Write-Info "Downloading $SourceSpec from npm..." + } } else { - Write-Warn2 "No adapter installer at $Sub (will be added in a later phase)" + Write-Info "Downloading latest $NpmPackage from npm..." +} + +if (-not $BuiltTarball) { + Push-Location $StageDir + try { + cmd /c "npm pack $SourceSpec --loglevel=error" + $BuiltTarball = (Get-ChildItem -Filter *.tgz | Select-Object -First 1).FullName + } finally { + Pop-Location + } + if (-not $BuiltTarball) { Stop-Die "npm pack failed for $SourceSpec." } + Write-Success "Package downloaded: $(Split-Path $BuiltTarball -Leaf)" +} + +function Deploy-Tarball { + param([string]$Prefix) + Write-Info "Deploying to $Prefix" + + $Preserve = @("node_modules", "data", "logs", "skills", "daemon", "config.yaml", ".auth.json") + + if (Test-Path $Prefix) { + $SavedDir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ([guid]::NewGuid().ToString())) -Force + foreach ($Item in $Preserve) { + $Src = Join-Path $Prefix $Item + if (Test-Path $Src) { + $Dst = Join-Path $SavedDir $Item + New-Item -ItemType Directory -Force -Path (Split-Path $Dst -Parent) -ErrorAction SilentlyContinue | Out-Null + Move-Item -Path $Src -Destination $Dst -Force + } + } + Remove-Item -Recurse -Force $Prefix -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $Prefix | Out-Null + + tar xzf $BuiltTarball -C $Prefix --strip-components=1 + + foreach ($Item in $Preserve) { + $SavedItem = Join-Path $SavedDir $Item + if (Test-Path $SavedItem) { + $Dst = Join-Path $Prefix $Item + if (Test-Path $Dst) { Remove-Item -Recurse -Force $Dst } + Move-Item -Path $SavedItem -Destination $Dst -Force + } + } + Remove-Item -Recurse -Force $SavedDir -ErrorAction SilentlyContinue + } else { + New-Item -ItemType Directory -Force -Path $Prefix | Out-Null + tar xzf $BuiltTarball -C $Prefix --strip-components=1 + } + + if (-not (Test-Path (Join-Path $Prefix "package.json"))) { Stop-Die "Extraction failed" } + Write-Success "Package extracted" + + Write-Info "Installing npm dependencies" + Push-Location $Prefix + try { + $env:MEMOS_SKIP_SETUP = "1" + cmd /c "npm install --omit=dev --no-fund --no-audit --loglevel=error" + + if (Test-Path "node_modules\better-sqlite3") { + Write-Info "Rebuilding better-sqlite3..." + cmd /c "npm rebuild better-sqlite3 --loglevel=error" + } + } finally { + Pop-Location + } + + $SystemNode = Join-Path $env:ProgramFiles "nodejs\node.exe" + $NodeForBridge = if (Test-Path $SystemNode) { $SystemNode } else { (Get-Command "node.exe" -ErrorAction SilentlyContinue).Source } + if ($NodeForBridge) { + Set-Content -Path (Join-Path $Prefix ".memos-node-bin") -Value $NodeForBridge -Encoding UTF8 + } + Write-Success "Dependencies ready" } -Write-Info "Install complete." -Write-Info " Code: $Prefix" -Write-Info " Data: $HomeDir" -Write-Info " Config: $Target" +function Ensure-RuntimeHome { + param([string]$Agent, [string]$HomeDir, [string]$Prefix) + + foreach ($Sub in @("data", "skills", "logs", "daemon")) { + New-Item -ItemType Directory -Force -Path (Join-Path $HomeDir $Sub) -ErrorAction SilentlyContinue | Out-Null + } + + $Template = Join-Path $Prefix "templates\config.$Agent.yaml" + if (-not (Test-Path $Template)) { $Template = Join-Path $ScriptDir "templates\config.$Agent.yaml" } + + if (-not (Test-Path $Template)) { + Write-Warn "Template missing: config.$Agent.yaml" + return + } + + $Target = Join-Path $HomeDir "config.yaml" + if (-not (Test-Path $Target)) { + Copy-Item -Path $Template -Destination $Target + Write-Success "Wrote config.yaml from template" + } else { + Write-Success "config.yaml exists — kept as-is" + } +} + +function Wait-ForViewer { + param([int]$Port, [int]$Timeout = 60) + $Url = "http://127.0.0.1:$Port/" + $Elapsed = 0 + Write-Host " Starting Memory Viewer..." -NoNewline + while ($Elapsed -lt $Timeout) { + try { + $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop + Write-Host "`r `r" -NoNewline + Write-Success "Memory Viewer is ready: $Url" + return $true + } catch { + Start-Sleep -Seconds 1 + $Elapsed++ + } + } + Write-Host "`r `r" -NoNewline + Write-Warn "Memory Viewer not ready after ${Timeout}s" + return $false +} + +function Install-OpenClaw { + Write-Host "`n=== OpenClaw Install ===" -ForegroundColor Cyan + $Prefix = Join-Path $env:USERPROFILE ".openclaw\extensions\$PluginId" + $HomeDir = Join-Path $env:USERPROFILE ".openclaw\memos-plugin" + $ConfigPath = Join-Path $env:USERPROFILE ".openclaw\openclaw.json" + + $OcBin = Get-Command "openclaw" -ErrorAction SilentlyContinue + if ($OcBin) { + Write-Info "Stopping OpenClaw gateway" + cmd /c "openclaw gateway stop" + Start-Sleep -Seconds 1 + } + + Deploy-Tarball -Prefix $Prefix + + $RuntimeEntry = "./dist/adapters/openclaw/index.js" + if (-not (Test-Path (Join-Path $Prefix "dist\adapters\openclaw\index.js"))) { + Stop-Die "OpenClaw runtime entry missing." + } + + Ensure-RuntimeHome -Agent "openclaw" -HomeDir $HomeDir -Prefix $Prefix + + $PackageJson = Get-Content (Join-Path $Prefix "package.json") -Raw | ConvertFrom-Json + $PluginVersion = $PackageJson.version + + $PluginJsonContent = @" +{ + "id": "$PluginId", + "name": "MemOS Local Memory (V7)", + "description": "Reflect2Evolve V7 memory.", + "kind": "memory", + "version": "$PluginVersion", + "homepage": "https://github.com/MemTensor/MemOS", + "extensions": ["$RuntimeEntry"], + "contracts": { + "tools": ["memory_search", "memory_get", "memory_timeline", "skill_list", "memory_environment", "skill_get"] + }, + "configSchema": { + "type": "object", + "additionalProperties": true, + "properties": { + "viewerPort": { "type": "number", "description": "Memory Viewer HTTP port (default $OpenClawPort)" } + } + } +} +"@ + Set-Content -Path (Join-Path $Prefix "openclaw.plugin.json") -Value $PluginJsonContent -Encoding UTF8 + + Write-Info "Patching openclaw.json" + $LegacyIds = @("memos-local-openclaw-plugin") + $LegacyJson = ($LegacyIds -join ',') + $SourceKindStr = if ($SourceKind -eq 'path') { 'path' } else { 'npm' } + + $env:PLUGIN_ID = $PluginId + $env:INSTALL_PATH = $Prefix + $env:SOURCE_KIND = $SourceKindStr + $env:SOURCE_SPEC = $SourceSpec + $env:PLUGIN_VERSION = $PluginVersion + $env:LEGACY_JSON = $LegacyJson + $env:CONFIG_PATH = $ConfigPath + + $NodeScript = @" +const fs = require('fs'); +const { + CONFIG_PATH: configPath, PLUGIN_ID: pluginId, INSTALL_PATH: installPath, + SOURCE_KIND: sourceKind, SOURCE_SPEC: sourceSpec, + PLUGIN_VERSION: pluginVersion, LEGACY_JSON: legacyCsv, +} = process.env; +const legacyIds = (legacyCsv || '').split(',').filter(Boolean); + +let config = {}; +if (fs.existsSync(configPath)) { + const raw = fs.readFileSync(configPath, 'utf8').trim(); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) config = parsed; + } +} + +if (!config.gateway || typeof config.gateway !== 'object' || Array.isArray(config.gateway)) { + config.gateway = {}; +} +if (!config.gateway.mode) config.gateway.mode = 'local'; + +if (!config.plugins || typeof config.plugins !== 'object' || Array.isArray(config.plugins)) { + config.plugins = {}; +} +config.plugins.enabled = true; + +if (!Array.isArray(config.plugins.allow)) config.plugins.allow = []; +if (!config.plugins.allow.includes(pluginId)) config.plugins.allow.push(pluginId); + +for (const legacyId of legacyIds) { + if (config.plugins.entries?.[legacyId]) delete config.plugins.entries[legacyId]; + if (config.plugins.installs?.[legacyId]) delete config.plugins.installs[legacyId]; + if (Array.isArray(config.plugins.allow)) { + config.plugins.allow = config.plugins.allow.filter((x) => x !== legacyId); + } + if (config.plugins.slots && typeof config.plugins.slots === 'object') { + for (const [slot, v] of Object.entries(config.plugins.slots)) { + if (v === legacyId) delete config.plugins.slots[slot]; + } + } +} + +if (!config.plugins.slots || typeof config.plugins.slots !== 'object') config.plugins.slots = {}; +config.plugins.slots.memory = pluginId; + +if (!config.plugins.entries || typeof config.plugins.entries !== 'object') config.plugins.entries = {}; +if (!config.plugins.entries[pluginId] || typeof config.plugins.entries[pluginId] !== 'object') { + config.plugins.entries[pluginId] = {}; +} +config.plugins.entries[pluginId].enabled = true; +if (config.plugins.entries[pluginId].hooks) delete config.plugins.entries[pluginId].hooks; + +if (!config.plugins.installs || typeof config.plugins.installs !== 'object') config.plugins.installs = {}; +const installsEntry = { + source: sourceKind === 'path' ? 'path' : 'npm', + installPath, + version: pluginVersion, + resolvedVersion: pluginVersion, + installedAt: new Date().toISOString(), +}; +if (sourceKind !== 'path') { + installsEntry.spec = sourceSpec; + installsEntry.resolvedName = '@memtensor/memos-local-plugin'; + installsEntry.resolvedSpec = sourceSpec; +} +config.plugins.installs[pluginId] = installsEntry; + +fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); +"@ + + $NodeScriptPath = Join-Path $env:TEMP "patch_openclaw.js" + Set-Content -Path $NodeScriptPath -Value $NodeScript -Encoding UTF8 + node $NodeScriptPath + Write-Success "openclaw.json patched" + + if ($OcBin) { + Write-Info "Starting OpenClaw gateway" + cmd /c "openclaw gateway start" + if (Wait-ForViewer -Port $OpenClawPort) { + Write-Success "OpenClaw install complete" + } else { + Write-Warn "Memory Viewer did not respond." + } + } else { + Write-Warn "openclaw CLI not found. Start gateway manually." + } +} + +function Install-Hermes { + Write-Host "`n=== Hermes Install ===" -ForegroundColor Cyan + $Prefix = Join-Path $env:LOCALAPPDATA "hermes\memos-plugin" + $HomeDir = $Prefix + $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 "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 + + $PythonBin = "" + $VenvPy = Join-Path $env:LOCALAPPDATA "hermes\hermes-agent\venv\Scripts\python.exe" + if (Test-Path $VenvPy) { $PythonBin = $VenvPy } + else { $PythonBin = (Get-Command "python.exe" -ErrorAction SilentlyContinue).Source } + + if (-not $PythonBin) { Stop-Die "Cannot locate Python for Hermes." } + Write-Success "Python: $PythonBin" + + $PluginDir = "" + $DefaultPluginDir = Join-Path $env:LOCALAPPDATA "hermes\hermes-agent\plugins\memory" + if (Test-Path $DefaultPluginDir) { $PluginDir = $DefaultPluginDir } + else { + # Fallback to python detection + $PyCmd = "from pathlib import Path; import sys; import plugins.memory as pm; print(Path(pm.__file__).parent)" + try { + $PluginDir = & $PythonBin -c $PyCmd 2>$null + } catch {} + } + + if (-not $PluginDir -or -not (Test-Path $PluginDir)) { Stop-Die "plugins\memory not found" } + + $Target = Join-Path $PluginDir "memtensor" + if (Test-Path $Target) { Remove-Item -Recurse -Force $Target } + + New-Item -ItemType Junction -Path $Target -Value (Join-Path $AdapterDir "memos_provider") | Out-Null + Copy-Item -Path (Join-Path $AdapterDir "plugin.yaml") -Destination (Join-Path $AdapterDir "memos_provider\plugin.yaml") -ErrorAction SilentlyContinue + Write-Success "Linked -> $Target" + + if (Test-Path $ConfigFile) { + $PyScript = @" +import sys, yaml +path = sys.argv[1] +with open(path, encoding='utf-8') as f: cfg = yaml.safe_load(f) or {} +mem = cfg.get('memory') +if isinstance(mem, dict): + mem['provider'] = 'memtensor' + mem.setdefault('memory_enabled', True) +else: + cfg['memory'] = {'provider': 'memtensor', 'memory_enabled': True} +with open(path, 'w', encoding='utf-8') as f: + yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True, sort_keys=False) +"@ + $PyFile = Join-Path $env:TEMP "patch_config.py" + Set-Content -Path $PyFile -Value $PyScript + & $PythonBin $PyFile $ConfigFile + Write-Success "config.yaml patched" + } else { + $ConfigContent = @" +memory: + memory_enabled: true + user_profile_enabled: true + provider: memtensor +"@ + Set-Content -Path $ConfigFile -Value $ConfigContent -Encoding UTF8 + Write-Success "Created $ConfigFile" + } + + Write-Info "Starting Memory Viewer daemon" + $TsxBin = Join-Path $Prefix "node_modules\.bin\tsx.cmd" + $BridgeCts = Join-Path $Prefix "bridge.cts" + + if ((Test-Path $TsxBin) -and (Test-Path $BridgeCts)) { + $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 (Wait-ForViewer -Port $HermesPort -Timeout 120) { + Write-Success "Memory Viewer daemon running" + } else { + Write-Warn "Memory Viewer did not respond within 120s." + } + } else { + Write-Warn "tsx not found - skipping daemon start." + } +} + +if ($AgentSelection -eq "openclaw" -or $AgentSelection -eq "all") { Install-OpenClaw } +if ($AgentSelection -eq "hermes" -or $AgentSelection -eq "all") { Install-Hermes } + +Write-Host "`n ==================================================" -ForegroundColor Green +Write-Host " Install finished successfully! " -ForegroundColor Green +Write-Host " ==================================================`n" -ForegroundColor Green diff --git a/apps/memos-local-plugin/install.sh b/apps/memos-local-plugin/install.sh index e8e49f25a..75cb7c8cd 100755 --- a/apps/memos-local-plugin/install.sh +++ b/apps/memos-local-plugin/install.sh @@ -91,21 +91,32 @@ OPENCLAW_RUNTIME_ENTRY="./dist/adapters/openclaw/index.js" # memory slot. We never touch the old plugin's data. LEGACY_PLUGIN_IDS=("memos-local-openclaw-plugin") -# ─── Args — one flag, period ────────────────────────────────────────────── +# ─── Args ───────────────────────────────────────────────────────────────── VERSION_ARG="" +AGENT_SELECTION="" while [[ $# -gt 0 ]]; do case "$1" in --version) VERSION_ARG="${2:-}"; shift 2 ;; + --agent|--target) + AGENT_SELECTION="${2:-}" + case "${AGENT_SELECTION}" in + auto|openclaw|hermes|all) ;; + *) die "--agent must be one of: auto, openclaw, hermes, all" ;; + esac + shift 2 + ;; --port) die "--port is no longer supported. Each agent uses a fixed port: \ openclaw → :${OPENCLAW_PORT}, hermes → :${HERMES_PORT}." ;; -h|--help) cat <= 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 = {}; const installsEntry = { @@ -708,27 +727,222 @@ print('OK' if p and p.name == 'memtensor' else 'FAIL') [[ "${verify}" == "OK" ]] && success "Provider verification passed" \ || warn "Provider verification didn't return OK" + step "Installing Hermes profile defaults hook" + "${python_bin}" - <<'PYEOF' || warn "Hermes profile defaults hook install failed" +import site +from pathlib import Path + +site_dirs = site.getsitepackages() +if not site_dirs: + raise SystemExit("no site-packages directory found") +site_dir = Path(site_dirs[0]) +site_dir.mkdir(parents=True, exist_ok=True) + +module_path = site_dir / "memos_hermes_profile_defaults.py" +module_path.write_text( + r''' +"""MemOS profile defaults for Hermes. + +This module is imported from a .pth file in the Hermes Python environment. +It wraps hermes_cli.profiles.create_profile so profiles created after the +MemOS plugin is installed inherit the memtensor memory provider even when the +user runs bare `hermes profile create ` without --clone. +""" + +from __future__ import annotations + +import importlib +import importlib.abc +import importlib.machinery +import sys +from pathlib import Path +from typing import Any + +try: + import yaml +except Exception: # pragma: no cover + yaml = None # type: ignore[assignment] + + +def _patch_config(profile_dir: Any) -> None: + if yaml is None: + return + path = Path(profile_dir) / "config.yaml" + if path.exists(): + with path.open() as f: + cfg = yaml.safe_load(f) or {} + else: + cfg = {} + if not isinstance(cfg, dict): + cfg = {} + + mem = cfg.get("memory") + if not isinstance(mem, dict): + mem = {} + cfg["memory"] = mem + mem["provider"] = "memtensor" + mem.setdefault("memory_enabled", True) + mem.setdefault("user_profile_enabled", True) + + plugins = cfg.get("plugins") + if not isinstance(plugins, dict): + plugins = {} + cfg["plugins"] = plugins + enabled = plugins.get("enabled") + if enabled is True: + enabled = ["memtensor"] + elif isinstance(enabled, list): + enabled = [item for item in enabled if item != "memtensor"] + enabled.append("memtensor") + else: + enabled = ["memtensor"] + plugins["enabled"] = enabled + + disabled = plugins.get("disabled") + if isinstance(disabled, list): + plugins["disabled"] = [item for item in disabled if item != "memtensor"] + + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as f: + yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + + +def _wrap_profiles_module(module: Any) -> None: + if getattr(module, "_memos_profile_defaults_wrapped", False): + return + original = getattr(module, "create_profile", None) + if not callable(original): + return + + def create_profile(*args: Any, **kwargs: Any) -> Any: + profile_dir = original(*args, **kwargs) + try: + _patch_config(profile_dir) + except Exception: + pass + return profile_dir + + module.create_profile = create_profile + module._memos_profile_defaults_wrapped = True + + +class _ProfilesImportHook(importlib.abc.MetaPathFinder): + _target = "hermes_cli.profiles" + + def find_spec(self, fullname: str, path: Any = None, target: Any = None) -> Any: + if fullname != self._target: + return None + for finder in sys.meta_path: + if finder is self: + continue + spec = finder.find_spec(fullname, path, target) if hasattr(finder, "find_spec") else None + if spec and spec.loader: + spec.loader = _ProfilesLoader(spec.loader) + return spec + return None + + +class _ProfilesLoader(importlib.abc.Loader): + def __init__(self, loader: Any) -> None: + self.loader = loader + + def create_module(self, spec: Any) -> Any: + if hasattr(self.loader, "create_module"): + return self.loader.create_module(spec) + return None + + def exec_module(self, module: Any) -> None: + self.loader.exec_module(module) + _wrap_profiles_module(module) + + +existing = sys.modules.get("hermes_cli.profiles") +if existing is not None: + _wrap_profiles_module(existing) +elif not any(isinstance(finder, _ProfilesImportHook) for finder in sys.meta_path): + sys.meta_path.insert(0, _ProfilesImportHook()) +'''.lstrip(), + encoding="utf-8", +) + +pth_path = site_dir / "memos_hermes_profile_defaults.pth" +pth_path.write_text("import memos_hermes_profile_defaults\n", encoding="utf-8") +print(module_path) +print(pth_path) +PYEOF + success "Hermes profile defaults hook installed" + if [[ -f "${config_file}" ]]; then - "${python_bin}" - "${config_file}" <<'PYEOF' || warn "config.yaml auto-patch failed" -import sys, yaml -path = sys.argv[1] -with open(path) as f: cfg = yaml.safe_load(f) or {} -mem = cfg.get("memory") -if isinstance(mem, dict): + local patched_configs + patched_configs="$("${python_bin}" - "${HOME}/.hermes" 2>/dev/null <<'PYEOF' +import sys +from pathlib import Path + +import yaml + +hermes_home = Path(sys.argv[1]) +paths = [hermes_home / "config.yaml"] +profiles_dir = hermes_home / "profiles" +if profiles_dir.is_dir(): + paths.extend(sorted(profiles_dir.glob("*/config.yaml"))) + +patched: list[str] = [] +for path in paths: + if not path.is_file(): + continue + with path.open() as f: + cfg = yaml.safe_load(f) or {} + if not isinstance(cfg, dict): + cfg = {} + + mem = cfg.get("memory") + if not isinstance(mem, dict): + mem = {} + cfg["memory"] = mem mem["provider"] = "memtensor" mem.setdefault("memory_enabled", True) -else: - cfg["memory"] = {"provider": "memtensor", "memory_enabled": True} -with open(path, "w") as f: - yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + mem.setdefault("user_profile_enabled", True) + + plugins = cfg.get("plugins") + if not isinstance(plugins, dict): + plugins = {} + cfg["plugins"] = plugins + enabled = plugins.get("enabled") + if enabled is True: + enabled = ["memtensor"] + elif isinstance(enabled, list): + enabled = [item for item in enabled if item != "memtensor"] + enabled.append("memtensor") + else: + enabled = ["memtensor"] + plugins["enabled"] = enabled + + disabled = plugins.get("disabled") + if isinstance(disabled, list): + plugins["disabled"] = [item for item in disabled if item != "memtensor"] + + with path.open("w") as f: + yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + patched.append(str(path)) + +print("\n".join(patched)) PYEOF - success "config.yaml: memory.provider = memtensor" +)" || warn "Hermes config auto-patch failed" + if [[ -n "${patched_configs}" ]]; then + success "Hermes configs patched:" + while IFS= read -r patched_config; do + [[ -n "${patched_config}" ]] && printf " ${DIM}%s${NC}\n" "${patched_config}" + done <<< "${patched_configs}" + fi else cat > "${config_file}" <<'CFGEOF' memory: memory_enabled: true user_profile_enabled: true provider: memtensor +plugins: + enabled: + - memtensor CFGEOF success "Created ${config_file}" fi diff --git a/apps/memos-local-plugin/package-lock.json b/apps/memos-local-plugin/package-lock.json index e2dbd7bc1..97304d334 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.0-beta.10", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@memtensor/memos-local-plugin", - "version": "2.0.0-beta.10", + "version": "2.0.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/apps/memos-local-plugin/package.json b/apps/memos-local-plugin/package.json index da6caea2b..4ff8d66bb 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.0-beta.11", + "version": "2.0.1", "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", @@ -29,7 +29,7 @@ "adapters/hermes/memos_provider/*.py", "templates/*.yaml", "templates/README.user.md", - "web/dist", + "viewer/dist", "scripts/postinstall.cjs", "install.sh", "install.ps1", @@ -45,16 +45,11 @@ ], "scripts": { "build": "tsc -p tsconfig.build.json && node scripts/copy-runtime-assets.cjs", - "build:web": "vite build --config vite.config.ts", - "build:site": "cd site && vite build", - "build:package": "npm run build && npm run build:web", - "build:all": "npm run build && npm run build:web && npm run build:site", + "build:viewer": "vite build --config vite.config.ts", + "build:package": "npm run build && npm run build:viewer", "prepack": "npm run build:package", "dev": "tsc -p tsconfig.json --watch", - "web:dev": "vite --config vite.config.ts", - "site:dev": "vite --config site/vite.config.ts", - "site:build": "vite build --config site/vite.config.ts", - "site:preview": "vite preview --config site/vite.config.ts", + "viewer:dev": "vite --config vite.config.ts", "bridge": "tsx bridge.cts", "bridge:daemon": "tsx bridge.cts --daemon", "test": "vitest run", @@ -63,9 +58,6 @@ "test:integration": "vitest run tests/integration", "test:e2e": "vitest run tests/e2e", "lint": "tsc -p tsconfig.json --noEmit", - "release:new": "tsx site/scripts/new-release.ts", - "release:index": "tsx site/scripts/build-index.ts", - "release:check": "tsx site/scripts/check-changelog.ts", "postinstall": "node scripts/postinstall.cjs" }, "keywords": [ diff --git a/apps/memos-local-plugin/scripts/postinstall.cjs b/apps/memos-local-plugin/scripts/postinstall.cjs index 98bafa01b..dc031629a 100644 --- a/apps/memos-local-plugin/scripts/postinstall.cjs +++ b/apps/memos-local-plugin/scripts/postinstall.cjs @@ -38,7 +38,7 @@ const banner = [ " bash " + installSh + " openclaw # or: hermes", "", " Windows (PowerShell):", - " powershell -ExecutionPolicy Bypass -File " + installPs1 + " -Agent openclaw", + " powershell -ExecutionPolicy Bypass -File " + installPs1, "", " Re-running the installer is safe; it only generates config.yaml on first run.", "", diff --git a/apps/memos-local-plugin/server/README.md b/apps/memos-local-plugin/server/README.md index 4c221372e..33657d81b 100644 --- a/apps/memos-local-plugin/server/README.md +++ b/apps/memos-local-plugin/server/README.md @@ -2,9 +2,8 @@ This module exposes the `MemoryCore` over HTTP. It's used by: -1. the **Vite viewer** (`web/`) for rendering the local dashboard; -2. the **product site** (`site/`) when installed side-by-side; -3. the **bridge** (`bridge.cts`) when hosts opt into HTTP as an +1. the **Vite viewer** (`viewer/`) for rendering the local dashboard; +2. the **bridge** (`bridge.cts`) when hosts opt into HTTP as an out-of-process transport instead of JSON-RPC-over-stdio. ## Design intent @@ -30,7 +29,7 @@ server/ ├── middleware/ │ ├── io.ts # body reader + JSON writers │ ├── auth.ts # api-key gate (Bearer + X-API-Key) -│ └── static.ts # safe static-file serving (viewer + site) +│ └── static.ts # safe static-file serving (viewer) └── routes/ ├── registry.ts # flat (method + path) → handler map ├── health.ts # /api/v1/health, /api/v1/ping @@ -112,9 +111,6 @@ for any non-`/api/*` path. Directory traversal attempts are caught by resolving the requested path against the root and ensuring containment. `/` and `/viewer` are both rewritten to `index.html`. -If `siteRoot` is set, files under that directory are served at -`/site/*`. The viewer and site thus coexist without path collisions. - ## Testing - `tests/unit/server/http.test.ts` — REST routes + auth gating (14 diff --git a/apps/memos-local-plugin/server/middleware/static.ts b/apps/memos-local-plugin/server/middleware/static.ts index cf67b192e..8332cf90e 100644 --- a/apps/memos-local-plugin/server/middleware/static.ts +++ b/apps/memos-local-plugin/server/middleware/static.ts @@ -1,9 +1,9 @@ /** * Static-asset middleware. * - * Serves the built viewer bundle + (optionally) the product site from - * a configured directory. Directory traversal is blocked by resolving - * every request path against the root and verifying containment. + * Serves the built viewer bundle from a configured directory. + * Directory traversal is blocked by resolving every request path + * against the root and verifying containment. * * Content-Type is derived from the file extension — we keep a small * hard-coded MIME map instead of shelling out to `mime-types`. @@ -43,12 +43,6 @@ export async function serveStatic( pathname: string, opts: ServerOptions, ): Promise { - // Route `/site/*` to `siteRoot` when configured. - if (pathname.startsWith("/site") && opts.siteRoot) { - const relative = pathname === "/site" ? "/index.html" : pathname.replace(/^\/site/, "") || "/index.html"; - return await tryServe(res, opts.siteRoot, relative); - } - if (!opts.staticRoot) return false; const relative = pathname === "/" || pathname === "/viewer" ? "/index.html" : pathname; return await tryServe(res, opts.staticRoot, relative); diff --git a/apps/memos-local-plugin/server/routes/diag.ts b/apps/memos-local-plugin/server/routes/diag.ts index 766356b62..7058f4dac 100644 --- a/apps/memos-local-plugin/server/routes/diag.ts +++ b/apps/memos-local-plugin/server/routes/diag.ts @@ -33,10 +33,10 @@ export function registerDiagRoutes(routes: Routes, deps: ServerDeps): void { const [traces, episodes, policies, worldModels, skills, logs] = await Promise.all([ core.listTraces({ limit: 1, offset: 0 }), - core.listEpisodeRows({ limit: 1, offset: 0 }), - core.listPolicies({ limit: 1, offset: 0 }), + core.listEpisodeRows({ limit: 1, offset: 0, includeAllNamespaces: true }), + core.listPolicies({ limit: 1, offset: 0, includeAllNamespaces: true }), core.listWorldModels({ limit: 1, offset: 0 }), - core.listSkills({ limit: 1 }), + core.listSkills({ limit: 1, includeAllNamespaces: true }), core.listApiLogs({ limit: 1, offset: 0 }), ]); // We use `listXxx` just to get *a* row — the actual per-layer @@ -58,11 +58,11 @@ export function registerDiagRoutes(routes: Routes, deps: ServerDeps): void { routes.set("GET /api/v1/diag/namespace", async () => { const health = await deps.core.health(); const [traces, episodes, policies, worldModels, skills] = await Promise.all([ - deps.core.listTraces({ limit: 200, offset: 0 }), - deps.core.listEpisodeRows({ limit: 200, offset: 0 }), - deps.core.listPolicies({ limit: 200, offset: 0 }), + deps.core.listTraces({ limit: 200, offset: 0, includeAllNamespaces: true }), + deps.core.listEpisodeRows({ limit: 200, offset: 0, includeAllNamespaces: true }), + deps.core.listPolicies({ limit: 200, offset: 0, includeAllNamespaces: true }), deps.core.listWorldModels({ limit: 200, offset: 0 }), - deps.core.listSkills({ limit: 200 }), + deps.core.listSkills({ limit: 200, includeAllNamespaces: true }), ]); const namespaces = new Map(); for (const row of [...traces, ...episodes, ...policies, ...worldModels, ...skills]) { @@ -152,11 +152,11 @@ export function registerDiagRoutes(routes: Routes, deps: ServerDeps): void { async function countEpisodes(core: ServerDeps["core"]): Promise { return walkAll((limit, offset) => - core.listEpisodeRows({ limit, offset }), + core.listEpisodeRows({ limit, offset, includeAllNamespaces: true }), ); } async function countPolicies(core: ServerDeps["core"]): Promise { - return walkAll((limit, offset) => core.listPolicies({ limit, offset })); + return walkAll((limit, offset) => core.listPolicies({ limit, offset, includeAllNamespaces: true })); } async function countWorldModels(core: ServerDeps["core"]): Promise { return walkAll((limit, offset) => @@ -164,7 +164,7 @@ async function countWorldModels(core: ServerDeps["core"]): Promise { ); } async function countSkills(core: ServerDeps["core"]): Promise { - return walkAll((limit, offset) => core.listSkills({ limit })); + return walkAll((limit, offset) => core.listSkills({ limit, includeAllNamespaces: true })); void countSkills; // the offset is unused for listSkills; it returns // everything up to `limit` — fine for our cap. } diff --git a/apps/memos-local-plugin/server/routes/embeddings.ts b/apps/memos-local-plugin/server/routes/embeddings.ts new file mode 100644 index 000000000..e0b8d089a --- /dev/null +++ b/apps/memos-local-plugin/server/routes/embeddings.ts @@ -0,0 +1,23 @@ +/** + * Embedding maintenance endpoints. + * + * The viewer uses these after importing memories or changing embedding + * providers/models so stored vectors are consistent with the current model. + */ +import { parseJson, type Routes } from "./registry.js"; +import type { ServerDeps } from "../types.js"; + +export function registerEmbeddingRoutes(routes: Routes, deps: ServerDeps): void { + routes.set("GET /api/v1/embeddings/maintenance", async () => { + return await deps.core.embeddingMaintenanceStats(); + }); + + routes.set("POST /api/v1/embeddings/rebuild", async (ctx) => { + const body = parseJson<{ + mode?: "repair" | "rebuild"; + limit?: number; + offset?: number; + }>(ctx); + return await deps.core.rebuildEmbeddings(body); + }); +} diff --git a/apps/memos-local-plugin/server/routes/memory.ts b/apps/memos-local-plugin/server/routes/memory.ts index 10572040f..f6ac4d858 100644 --- a/apps/memos-local-plugin/server/routes/memory.ts +++ b/apps/memos-local-plugin/server/routes/memory.ts @@ -78,7 +78,7 @@ export function registerMemoryRoutes( writeError(ctx, 400, "invalid_argument", "id is required"); return; } - const policy = await deps.core.getPolicy(id); + const policy = await deps.core.getPolicy(id, undefined, { includeAllNamespaces: true }); if (policy === null) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; @@ -92,7 +92,7 @@ export function registerMemoryRoutes( writeError(ctx, 400, "invalid_argument", "id is required"); return; } - const wm = await deps.core.getWorldModel(id); + const wm = await deps.core.getWorldModel(id, undefined, { includeAllNamespaces: true }); if (wm === null) { writeError(ctx, 404, "not_found", `world model not found: ${id}`); return; diff --git a/apps/memos-local-plugin/server/routes/overview.ts b/apps/memos-local-plugin/server/routes/overview.ts index 5191af656..d19504e2b 100644 --- a/apps/memos-local-plugin/server/routes/overview.ts +++ b/apps/memos-local-plugin/server/routes/overview.ts @@ -17,12 +17,16 @@ import type { ServerDeps } from "../types.js"; import type { Routes } from "./registry.js"; export function registerOverviewRoutes(routes: Routes, deps: ServerDeps): void { - let viewerTracked = false; routes.set("GET /api/v1/overview", async () => { - if (!viewerTracked) { - viewerTracked = true; - deps.telemetry?.trackViewerOpened(); - } + // `viewer_opened` is now emitted by the SPA itself via + // `POST /api/v1/telemetry/viewer-opened` (see + // `viewer/src/components/App.tsx`). The previous in-memory + // `viewerTracked` flag here was per-process and triggered on any + // GET — including background polling and CLI tooling — so the + // metric drifted on every bridge restart and over-counted + // headless callers. Routing the ping through the viewer's mount + // hook keeps the semantics honest (a browser actually opened + // the page) and is naturally deduped by browser tab lifetime. const [health, episodeIds, skills, policies, worldModels, metrics] = await Promise.all([ deps.core.health(), diff --git a/apps/memos-local-plugin/server/routes/policies.ts b/apps/memos-local-plugin/server/routes/policies.ts index 871ceb000..4de5a8790 100644 --- a/apps/memos-local-plugin/server/routes/policies.ts +++ b/apps/memos-local-plugin/server/routes/policies.ts @@ -37,8 +37,24 @@ export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { const statusRaw = params.get("status"); const status = isValidPolicyStatus(statusRaw) ? statusRaw : undefined; const q = params.get("q") || undefined; - const policies = await deps.core.listPolicies({ status, limit, offset, q }); - const total = await deps.core.countPolicies({ status, q }); + const ownerAgentKind = params.get("ownerAgentKind") || undefined; + const ownerProfileId = params.get("ownerProfileId") || undefined; + const policies = await deps.core.listPolicies({ + status, + limit, + offset, + q, + ownerAgentKind, + ownerProfileId, + includeAllNamespaces: true, + }); + const total = await deps.core.countPolicies({ + status, + q, + ownerAgentKind, + ownerProfileId, + includeAllNamespaces: true, + }); return { policies, limit, @@ -54,7 +70,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); + const policy = await deps.core.getPolicy(id, undefined, { includeAllNamespaces: true }); if (!policy) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; @@ -123,7 +139,7 @@ export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { } let updated = hasContent ? await deps.core.updatePolicy(id, contentPatch) - : await deps.core.getPolicy(id); + : await deps.core.getPolicy(id, undefined, { includeAllNamespaces: true }); if (!updated) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; @@ -226,14 +242,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); + const policy = await deps.core.getPolicy(id, undefined, { includeAllNamespaces: true }); if (!policy) { writeError(ctx, 404, "not_found", `policy not found: ${id}`); return; } const [skills, worldModels] = await Promise.all([ - deps.core.listSkills({ limit: 500 }), - deps.core.listWorldModels({ limit: 500 }), + deps.core.listSkills({ limit: 500, includeAllNamespaces: true }), + deps.core.listWorldModels({ limit: 500, includeAllNamespaces: true }), ]); return { skills: skills @@ -260,8 +276,22 @@ export function registerPoliciesRoutes(routes: Routes, deps: ServerDeps): void { const offset = Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0; const q = params.get("q") || undefined; - const worldModels = await deps.core.listWorldModels({ limit, offset, q }); - const total = await deps.core.countWorldModels({ q }); + const ownerAgentKind = params.get("ownerAgentKind") || undefined; + const ownerProfileId = params.get("ownerProfileId") || undefined; + const worldModels = await deps.core.listWorldModels({ + limit, + offset, + q, + ownerAgentKind, + ownerProfileId, + includeAllNamespaces: true, + }); + const total = await deps.core.countWorldModels({ + q, + ownerAgentKind, + ownerProfileId, + includeAllNamespaces: true, + }); return { worldModels, limit, @@ -277,7 +307,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); + const model = await deps.core.getWorldModel(id, undefined, { includeAllNamespaces: true }); if (!model) { writeError(ctx, 404, "not_found", `world model not found: ${id}`); return; @@ -297,14 +327,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); + const wm = await deps.core.getWorldModel(id, undefined, { includeAllNamespaces: true }); 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); + const p = await deps.core.getPolicy(pid, undefined, { includeAllNamespaces: true }); return p ? { id: p.id, title: p.title, status: p.status, gain: p.gain } : { id: pid, title: null, status: null, gain: null }; diff --git a/apps/memos-local-plugin/server/routes/registry.ts b/apps/memos-local-plugin/server/routes/registry.ts index c04b535ca..7adaf8c53 100644 --- a/apps/memos-local-plugin/server/routes/registry.ts +++ b/apps/memos-local-plugin/server/routes/registry.ts @@ -44,6 +44,8 @@ import { registerAdminRoutes } from "./admin.js"; import { registerModelsRoutes } from "./models.js"; import { registerApiLogsRoutes } from "./api-logs.js"; import { registerDiagRoutes } from "./diag.js"; +import { registerEmbeddingRoutes } from "./embeddings.js"; +import { registerTelemetryRoutes } from "./telemetry.js"; export interface RouteContext { req: IncomingMessage; @@ -177,8 +179,10 @@ export function buildRoutes( registerAuthRoutes(routes, deps, options); registerAdminRoutes(routes, deps, options); registerModelsRoutes(routes, deps); + registerEmbeddingRoutes(routes, deps); registerApiLogsRoutes(routes, deps); registerDiagRoutes(routes, deps); + registerTelemetryRoutes(routes, deps); return routes; } diff --git a/apps/memos-local-plugin/server/routes/session.ts b/apps/memos-local-plugin/server/routes/session.ts index 68545d669..135b55704 100644 --- a/apps/memos-local-plugin/server/routes/session.ts +++ b/apps/memos-local-plugin/server/routes/session.ts @@ -9,11 +9,25 @@ import type { AgentKind, EpisodeId, + EpisodeListItemDTO, SessionId, } from "../../agent-contract/dto.js"; +import { + deriveEpisodeStatus, + parseTaskStatusFilter, +} from "../../agent-contract/episode-status.js"; import type { ServerDeps } from "../types.js"; import { parseJson, writeError, type Routes } from "./registry.js"; +/** + * Upper bound for the in-memory scan window when the request applies + * a status / preview filter. The episode table is small in practice + * (≤ a few thousand rows per workspace), so a single bulk fetch + + * in-memory filter is far simpler than pushing the derivation rules + * down into SQL — and matches what `countEpisodes` already does. + */ +const FILTER_SCAN_LIMIT = 5_000; + export function registerSessionRoutes(routes: Routes, deps: ServerDeps): void { routes.set("POST /api/v1/sessions", async (ctx) => { const { agent, sessionId } = parseJson<{ agent?: AgentKind; sessionId?: SessionId }>(ctx); @@ -57,17 +71,26 @@ export function registerSessionRoutes(routes: Routes, deps: ServerDeps): void { routes.set("GET /api/v1/episodes", async (ctx) => { const sessionId = (ctx.url.searchParams.get("sessionId") as SessionId | null) ?? undefined; + const ownerAgentKind = (ctx.url.searchParams.get("ownerAgentKind") || undefined) as + | AgentKind + | undefined; + const ownerProfileId = ctx.url.searchParams.get("ownerProfileId") || undefined; const q = (ctx.url.searchParams.get("q") || "").trim().toLowerCase(); + const status = parseTaskStatusFilter(ctx.url.searchParams.get("status")); const rawLimit = numberOrUndefined(ctx.url.searchParams.get("limit")); const rawOffset = numberOrUndefined(ctx.url.searchParams.get("offset")); const limit = rawLimit && rawLimit > 0 ? rawLimit : 50; const offset = rawOffset && rawOffset >= 0 ? rawOffset : 0; - // Return the rich row shape — the viewer's task list needs - // session id / status / turn count / preview. The old `ids`-only - // variant is still available under the `episode.list` JSON-RPC - // method and via `?shape=ids`. - const total = await deps.core.countEpisodes({ sessionId }); + + // The legacy `?shape=ids` path is unaffected by `status` / + // preview filtering — JSON-RPC callers ask for raw ids only. if (ctx.url.searchParams.get("shape") === "ids") { + const total = await deps.core.countEpisodes({ + sessionId, + ownerAgentKind, + ownerProfileId, + includeAllNamespaces: true, + }); const episodeIds = await deps.core.listEpisodes({ sessionId, limit, offset }); return { episodeIds, @@ -77,24 +100,58 @@ export function registerSessionRoutes(routes: Routes, deps: ServerDeps): void { nextOffset: episodeIds.length === limit ? offset + limit : undefined, }; } - let episodes = await deps.core.listEpisodeRows({ - sessionId, - limit: q ? 200 : limit, - offset: q ? 0 : offset, - }); - if (q) { - episodes = episodes.filter( - (ep: { preview?: string }) => ep.preview && ep.preview.toLowerCase().includes(q), - ); - const paged = episodes.slice(offset, offset + limit); + + // Filtered path (q OR status): scan a wide window and apply + // both filters in memory, then paginate over the *filtered* set. + // This guarantees `total` / `nextOffset` reflect what the viewer + // actually shows — without it the chip-group filter on the + // Tasks page reported "no matches" while the pager still claimed + // there were more pages worth of data. `ownerAgentKind` / + // `ownerProfileId` are passed straight to core so the multi-agent + // namespace filter still wins before the in-memory derivation. + if (q || status) { + let rows = await deps.core.listEpisodeRows({ + sessionId, + limit: FILTER_SCAN_LIMIT, + offset: 0, + ownerAgentKind, + ownerProfileId, + includeAllNamespaces: true, + }); + if (q) { + rows = rows.filter( + (ep: EpisodeListItemDTO) => !!ep.preview && ep.preview.toLowerCase().includes(q), + ); + } + if (status) { + rows = rows.filter((ep: EpisodeListItemDTO) => deriveEpisodeStatus(ep) === status); + } + const paged = rows.slice(offset, offset + limit); return { episodes: paged, limit, offset, - total: episodes.length, - nextOffset: episodes.length > offset + limit ? offset + limit : undefined, + total: rows.length, + nextOffset: rows.length > offset + limit ? offset + limit : undefined, }; } + + // Default (unfiltered) path: rely on the dedicated count query so + // we don't pay for a 5 k-row scan on every viewer page-flip. + const total = await deps.core.countEpisodes({ + sessionId, + ownerAgentKind, + ownerProfileId, + includeAllNamespaces: true, + }); + const episodes = await deps.core.listEpisodeRows({ + sessionId, + limit, + offset, + ownerAgentKind, + ownerProfileId, + includeAllNamespaces: true, + }); return { episodes, limit, diff --git a/apps/memos-local-plugin/server/routes/skill.ts b/apps/memos-local-plugin/server/routes/skill.ts index 001bd3f70..69f392d37 100644 --- a/apps/memos-local-plugin/server/routes/skill.ts +++ b/apps/memos-local-plugin/server/routes/skill.ts @@ -13,13 +13,22 @@ import { parseJson, writeError, type Routes } from "./registry.js"; export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void { routes.set("GET /api/v1/skills", async (ctx) => { - const status = (ctx.url.searchParams.get("status") as SkillDTO["status"] | null) ?? undefined; - const q = (ctx.url.searchParams.get("q") || "").trim().toLowerCase(); + const params = ctx.url.searchParams; + const status = (params.get("status") as SkillDTO["status"] | null) ?? undefined; + const q = (params.get("q") || "").trim().toLowerCase(); + const ownerAgentKind = params.get("ownerAgentKind") || undefined; + const ownerProfileId = params.get("ownerProfileId") || undefined; // Viewer needs prev/next pagination — ask for one extra page so we // can tell the client whether there's more without a count query. - const pageSize = limitOrUndefined(ctx.url.searchParams.get("limit")) ?? 50; - const offset = Math.max(0, Number(ctx.url.searchParams.get("offset") ?? 0) || 0); - let all = await deps.core.listSkills({ status, limit: q ? 5000 : pageSize + offset + 1 }); + const pageSize = limitOrUndefined(params.get("limit")) ?? 50; + const offset = Math.max(0, Number(params.get("offset") ?? 0) || 0); + let all = await deps.core.listSkills({ + status, + limit: q ? 5000 : pageSize + offset + 1, + ownerAgentKind, + ownerProfileId, + includeAllNamespaces: true, + }); if (q) { all = all.filter( (s) => s.name.toLowerCase().includes(q) || s.invocationGuide.toLowerCase().includes(q), @@ -27,7 +36,12 @@ export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void { } const page = all.slice(offset, offset + pageSize); const hasMore = all.length > offset + pageSize; - const total = q ? all.length : await deps.core.countSkills({ status }); + const total = q ? all.length : await deps.core.countSkills({ + status, + ownerAgentKind, + ownerProfileId, + includeAllNamespaces: true, + }); return { skills: page, limit: pageSize, @@ -43,7 +57,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); + const skill = await deps.core.getSkill(id as SkillId, { includeAllNamespaces: true }); if (skill === null) { writeError(ctx, 404, "not_found", `skill not found: ${id}`); return; @@ -57,7 +71,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); + const skill = await deps.core.getSkill(id as SkillId, { includeAllNamespaces: true }); if (skill === null) { writeError(ctx, 404, "not_found", `skill not found: ${id}`); return; @@ -137,14 +151,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); + const skill = await deps.core.getSkill(id as SkillId, { includeAllNamespaces: true }); 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); + const p = await deps.core.getPolicy(pid, undefined, { includeAllNamespaces: true }); return p ? { id: p.id, title: p.title, status: p.status, gain: p.gain } : { id: pid, title: null, status: null, gain: null }; @@ -152,7 +166,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); + const w = await deps.core.getWorldModel(wid, undefined, { includeAllNamespaces: true }); return w ? { id: w.id, title: w.title } : { id: wid, title: null }; }), ); @@ -268,7 +282,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); + const skill = await deps.core.getSkill(id as SkillId, { includeAllNamespaces: true }); if (!skill) { writeError(ctx, 404, "not_found", `skill not found: ${id}`); return; diff --git a/apps/memos-local-plugin/server/routes/telemetry.ts b/apps/memos-local-plugin/server/routes/telemetry.ts new file mode 100644 index 000000000..f183cd13b --- /dev/null +++ b/apps/memos-local-plugin/server/routes/telemetry.ts @@ -0,0 +1,39 @@ +/** + * Telemetry side-channel — endpoints invoked by the viewer to record + * UI-side events (mounts, navigation) that the backend can't observe + * on its own. + * + * Currently a single endpoint: + * + * POST /api/v1/telemetry/viewer-opened + * Fired once by the viewer's `` `useEffect` on mount. The + * handler delegates to `Telemetry.trackViewerOpened()`, which + * batches into the next ARMS flush. Body is ignored; future + * callers can pass page/source hints without breaking the wire + * format. + * + * Why a dedicated route instead of piggy-backing on + * `GET /api/v1/overview` (the previous behaviour)? + * - The overview endpoint is also polled by background jobs and + * called from non-UI contexts; treating any GET as "user opened + * the viewer" produced both false positives and false negatives. + * - The previous in-memory `viewerTracked` flag was per-process, so + * bridge restarts re-counted the same operator and the metric + * drifted. + * - Tying to the actual SPA mount keeps the semantics honest: + * "someone loaded the viewer in a browser tab". + * + * The endpoint always returns `{ ok: true }` (even if telemetry is + * disabled or no instance is bound), so the viewer can fire-and-forget + * without surfacing failures to the UI. + */ + +import type { ServerDeps } from "../types.js"; +import type { Routes } from "./registry.js"; + +export function registerTelemetryRoutes(routes: Routes, deps: ServerDeps): void { + routes.set("POST /api/v1/telemetry/viewer-opened", async () => { + deps.telemetry?.trackViewerOpened(); + return { ok: true }; + }); +} diff --git a/apps/memos-local-plugin/server/routes/trace.ts b/apps/memos-local-plugin/server/routes/trace.ts index 9f9333bb2..05cf848e6 100644 --- a/apps/memos-local-plugin/server/routes/trace.ts +++ b/apps/memos-local-plugin/server/routes/trace.ts @@ -17,6 +17,8 @@ export function registerTraceRoutes(routes: Routes, deps: ServerDeps): void { * ?limit=50 (max 500) * &offset=0 * &sessionId= (optional filter) + * &ownerAgentKind= (optional namespace filter) + * &ownerProfileId= (optional namespace filter) * &q= (optional case-insensitive summary/text filter) * * Returns: { traces: TraceDTO[], limit, offset, nextOffset? } @@ -32,6 +34,9 @@ export function registerTraceRoutes(routes: Routes, deps: ServerDeps): void { const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 50; const offset = Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0; const sessionId = params.get("sessionId") || undefined; + const namespace = parseNamespace(params.get("namespace")); + const ownerAgentKind = params.get("ownerAgentKind") || namespace?.ownerAgentKind || undefined; + const ownerProfileId = params.get("ownerProfileId") || namespace?.ownerProfileId || undefined; const q = params.get("q") || undefined; // When `groupByTurn=true`, pagination treats each (episodeId, turnId) // pair as one "memory" — matching the viewer's grouped display where @@ -41,13 +46,19 @@ export function registerTraceRoutes(routes: Routes, deps: ServerDeps): void { limit, 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, }); // When grouping, `traces.length === limit` is no longer a reliable // "has more" signal (a single turn can yield many traces). Use the @@ -169,7 +180,14 @@ 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 }); + const traces = await deps.core.timeline({ episodeId: id, includeAllNamespaces: true }); return { episodeId: id, traces }; }); } + +function parseNamespace(value: string | null): { ownerAgentKind: string; ownerProfileId: string } | null { + if (!value) return null; + const [ownerAgentKind, ownerProfileId] = value.split("/", 2).map((part) => part.trim()); + if (!ownerAgentKind || !ownerProfileId) return null; + return { ownerAgentKind, ownerProfileId }; +} diff --git a/apps/memos-local-plugin/server/types.ts b/apps/memos-local-plugin/server/types.ts index 2d9f715d5..8532101ef 100644 --- a/apps/memos-local-plugin/server/types.ts +++ b/apps/memos-local-plugin/server/types.ts @@ -1,12 +1,12 @@ /** * HTTP server types — public surface. * - * The server wraps a `MemoryCore` plus a static site directory and serves: + * The server wraps a `MemoryCore` and serves: * * 1. a JSON REST API under /api/v1, * 2. a live event stream at /api/v1/events (SSE), * 3. a live log stream at /api/v1/logs (SSE), - * 4. static assets for the viewer + product site. + * 4. static assets for the viewer. * * The server is purely a façade — it never talks to the database or * any other subsystem directly. All business logic lives in the core; @@ -23,11 +23,6 @@ export interface ServerOptions { host?: string; /** Root directory whose contents are served as static assets. */ staticRoot?: string; - /** - * Optional site directory (separate from the viewer). If provided, - * served at `/site/*`. If absent, `/site/*` returns 404. - */ - siteRoot?: string; /** Optional shared secret required on every /api/* request via `x-api-key`. */ apiKey?: string; /** Extra headers merged into every response (CORS, security, etc.). */ diff --git a/apps/memos-local-plugin/site/README.md b/apps/memos-local-plugin/site/README.md deleted file mode 100644 index 57a7410c5..000000000 --- a/apps/memos-local-plugin/site/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# `site/` — MemOS Local product site - -A local-first "marketing / docs / release notes" page for MemOS Local. -Intentionally **not** deployed anywhere: it's built to `site/dist/` -and served by the plugin's HTTP server under `/site/` so users have -a first-class introduction without ever touching the network. - -## Why a local site? - -The plugin is highly technical, and operators need something more -approachable than the viewer to orient: - -- What is Reflect2Evolve, in 3 bullets? -- What runs on my machine vs. in the cloud? (Nothing in the cloud.) -- What version am I on, what did it change, what's next? -- Where is the viewer? Where are the configs? - -A static, handcrafted site is the right shape for that — no -framework, no build-step surprises, ~14 kB of JS + 8 kB of CSS. - -## Layout - -``` -site/ -├── index.html # Vite entry, single-page shell -├── vite.config.ts # `root: "."`, outputs `dist/` -├── public/ -│ └── logo.svg # Brand mark -├── src/ -│ ├── main.ts # Bootstrap → renderApp() -│ ├── app.ts # Top-level renderer + interaction wiring -│ ├── theme.ts # auto / light / dark cycling -│ ├── styles/ -│ │ ├── base.css # Reset + typography -│ │ ├── theme.css # Tokens (light/dark/auto) -│ │ ├── layout.css # Header, sections, grids -│ │ └── components.css # Buttons, cards, badges -│ └── components/ -│ ├── Header.ts # Sticky nav + theme toggle -│ ├── Hero.ts # Landing block with CTA → /ui/ -│ ├── Features.ts # Six-card capability grid -│ ├── Architecture.ts # Three-column adapter / core / runtime -│ ├── Releases.ts # Markdown-lite feed of `content/releases/*.md` -│ └── Footer.ts # License/links/year stub -├── content/ -│ ├── index.json # Pre-computed release index (hand-edited) -│ ├── docs/ # Reserved for future doc content -│ └── releases/ -│ ├── template.md # Frontmatter template for new releases -│ └── *.md # One file per release -└── scripts/ # Reserved for release-index automation -``` - -## Philosophy - -- **Vanilla TS only.** No framework. Each "component" is a function - returning an HTML string; the root is injected once via - `innerHTML`. Interactivity is added by `querySelector`/ - `addEventListener` in `app.ts`. -- **Markdown-lite.** `Releases.ts` implements just enough Markdown - (headings, lists, paragraphs, fenced code) to render the release - notes without dragging in a full parser. Frontmatter is parsed - line-by-line — `name: value` only, no YAML escape hatches. -- **Theme coherence.** Same `data-theme` mechanism as the viewer, - separate storage key (`memos.site.theme`) so theme choices don't - bleed between apps. - -## Writing a release - -1. Copy `content/releases/template.md` to `content/releases/.md`. -2. Fill frontmatter (version / date / title / highlight / kind). -3. Write bullets under the standard headings (`## Summary`, `## New`, - `## Changed`, `## Fixed`, `## Breaking`, `## Internals`, - `## Thanks`, `## Commits`). The site only renders the first few - headings — `Commits` etc. are kept for the raw file and tooling. - -The site compiles the frontmatter + body into a card at build time; -no server-side rendering is needed. - -## Build - -```bash -# From apps/memos-local-plugin -npm run build:site # → site/dist/ -``` - -The build is self-contained: it produces `dist/index.html`, a single -JS chunk, a single CSS chunk, and copies `public/` assets. Bundle -weight: - -| Asset | Size | -| ----------- | ------------ | -| HTML | ~0.7 KB | -| CSS | ~8 KB | -| JS | ~12 KB | -| **Total** | **~21 KB** | - -(gzipped: ~8 KB total) - -## Running in dev - -```bash -npm run site:dev # Vite dev server on :5174 -``` - -Useful when editing styles live. The dev server does not proxy the -plugin's HTTP API, since the site is purely static. - -## Serving from the plugin - -The plugin's HTTP server (see `../server/README.md`) serves the -built bundle at `/site/` via the static middleware — with directory- -traversal guards and no cache headers in dev. diff --git a/apps/memos-local-plugin/site/content/index.json b/apps/memos-local-plugin/site/content/index.json deleted file mode 100644 index f5b8ec4dc..000000000 --- a/apps/memos-local-plugin/site/content/index.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$comment": "Auto-generated by site/scripts/build-index.ts. Do not edit by hand.", - "generatedAt": "2026-04-17T00:00:00.000Z", - "releases": [ - { - "version": "2.0.0-alpha.1", - "date": "2026-04-17", - "title": "Initial scaffolding for Reflect2Evolve", - "highlight": "Project skeleton, agent-contract layer, install.sh entrypoint, viewer/site directory layout.", - "kind": "alpha", - "file": "releases/2.0.0-alpha.1.md" - } - ] -} diff --git a/apps/memos-local-plugin/site/content/releases/2.0.0-alpha.1.md b/apps/memos-local-plugin/site/content/releases/2.0.0-alpha.1.md deleted file mode 100644 index cc35a614f..000000000 --- a/apps/memos-local-plugin/site/content/releases/2.0.0-alpha.1.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -version: 2.0.0-alpha.1 -date: 2026-04-17 -title: "Initial scaffolding for Reflect2Evolve" -highlight: "Project skeleton, agent-contract layer, install.sh entrypoint, viewer/site directory layout." -kind: alpha ---- - -## Summary - -First public alpha. Sets up the directory structure, contract layer between -the algorithm core and per-agent adapters, the install workflow that creates -a runtime data directory under `~/./memos-plugin/`, and the dual Vite -projects (`web/` for the runtime viewer, `site/` for the local-only marketing -site + release notes). - -No algorithm code is wired up yet. This release is intentionally a clean -skeleton so subsequent phases can land module-by-module with their own docs + -tests, without churning the top-level shape. - -## New - -- `apps/memos-local-plugin/` package created end-to-end. -- `agent-contract/` layer (events, errors, DTO, JSON-RPC, log-record types). -- `install.sh` and `install.ps1` for OpenClaw and Hermes targets, with a clear - source ↔ runtime separation. -- Templates for `config.openclaw.yaml` and `config.hermes.yaml` (filled in - during Phase 1). -- Dual Vite setup: `web/` (viewer) and `site/` (local docs + release notes). -- Top-level docs: `README.md`, `ARCHITECTURE.md`, `AGENTS.md`, `CHANGELOG.md`. - -## Changed - -- _None._ - -## Fixed - -- _None._ - -## Breaking - -- _None._ Pre-1.x project; no API stability guarantees yet. - -## Internals - -- npm scripts: `build`, `build:web`, `build:site`, `web:dev`, `site:dev`, - `bridge`, `bridge:daemon`, `test`, `test:unit`, `test:integration`, - `test:e2e`, `lint`, `release:new`, `release:index`, `release:check`. -- vitest configured with coverage; `tests/` split into `unit/`, `integration/`, - `e2e/` mirrored against the source layout. - -## Thanks - -- The legacy `memos-local-openclaw` and `memos-local-hermes` projects, which - this rewrite uses purely as reference. - -## Commits - - diff --git a/apps/memos-local-plugin/site/content/releases/2.0.0-beta.1.md b/apps/memos-local-plugin/site/content/releases/2.0.0-beta.1.md deleted file mode 100644 index f1f0468f4..000000000 --- a/apps/memos-local-plugin/site/content/releases/2.0.0-beta.1.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -version: 2.0.0-beta.1 -date: 2026-04-17 -title: "Full Reflect2Evolve algorithm + OpenClaw & Hermes adapters + viewer" -highlight: "Complete end-to-end implementation: L1/L2/L3/Skill layers, three-tier retrieval, decision repair, crystallization, dual adapters, HTTP/SSE server, Vite viewer & product site." -kind: beta ---- - -## Summary - -This release brings `@memtensor/memos-local-plugin` to feature parity with -the V7 algorithm specification (`算法设计_Reflect2Skill_V7_核心详解.md`) and -ships a working end-to-end loop for both OpenClaw and Hermes Agent, with a -local viewer that makes every algorithm event observable. - -Every phase planned in the ALGORITHMS.md hierarchy has landed with its own -documentation (`README.md` + `ALGORITHMS.md` at the directory level) and -its own unit + integration tests. - -## New - -### Algorithm core (`core/`) - -- **Session + Episode managers** with intent classification and lifecycle - event bus. -- **Capture pipeline** (`capture/`): step extractor → normalizer → reflection - extractor → α scorer → embedder → persistence. Listens on - `episode.finalized`. -- **Reward pipeline** (`reward/`): V7 §0.6 + §3.3 — per-episode `R_human` - via LLM rubric (goal/process/satisfaction) with heuristic fallback, - reflection-weighted backprop `V_t = α_t·R + (1-α_t)·γ·V_{t+1}`, and - exponential priority decay. -- **L2 cross-task induction** (`memory/l2/`): signature-keyed candidate - pool, similarity-gated association, gain-scored induced policies with a - candidate → active → retired lifecycle. -- **L3 world-model abstraction** (`memory/l3/`): listens on - `l2.policy.induced`, clusters eligible policies, calls the - `l3.abstraction` prompt with JSON mode, merges into the nearest existing - model or inserts a new one, with per-cluster cooldowns. -- **Skill crystallization** (`skill/`): Beta-posterior η, probationary → - active/retired transitions, LLM `skill.crystallize` draft with a - deterministic heuristic verifier. -- **Decision-repair** (`feedback/`): classifier + evidence gather + - preference/anti-pattern synthesis, wired to the orchestrator so the - repair packet prepends context into the *next* LLM step (never - mid-decision). -- **Three-tier retriever** (`retrieval/`): Tier-1 skill, Tier-2 - trace+episode rollup, Tier-3 world model; RRF fusion + MMR diversity; - five entry points (`turnStart`, `toolDriven`, `skillInvoke`, `subAgent`, - `repair`). -- **Pipeline orchestrator** (`pipeline/`): single `MemoryCore` facade - implementing `agent-contract/memory-core.ts`. Handles session lifecycle, - tool-outcome recording, health, and shutdown. - -### Adapters (`adapters/`) - -- **OpenClaw in-process adapter** — plugin entrypoint, `memory_*` tool - definitions, `beforePromptBuild` / `agentEnd` / tool-outcome hooks, and - local re-declaration of SDK types so the plugin compiles without a - hard dependency on the OpenClaw SDK. -- **Hermes Python adapter** — `memos_provider` package implementing - Hermes' `MemoryProvider` interface as a JSON-RPC 2.0 client over stdio. - Spawns `bridge.cts` on demand, routes events/logs back for the shared - viewer, and handles shutdown gracefully. -- **Shared bridge** — `bridge.cts` + `bridge/methods.ts` + `bridge/stdio.ts` - with exhaustive method coverage of the `MemoryCore` interface. - -### Runtime services - -- **HTTP/SSE server** (`server/`) — standard-library Node.js HTTP with - layered middleware (io, auth, static), a route registry, and two SSE - endpoints for live `CoreEvent` + `LogRecord` streaming. Serves the - viewer (`/`) and the product site (`/site/`) in-process. -- **Viewer** (`web/`) — Preact + Signals, ~51 KB JS / 13 KB CSS. - Ten views: Overview, Events, Logs, Sessions, Memories, Skills, Feedback, - Settings (+ Traces/Retrieval debug). Full light/dark/auto theme, design - tokens, accessible navigation. Every view is a deterministic projection - of one or more REST/SSE endpoints. -- **Product site** (`site/`) — Vanilla TypeScript + HTML, ~13 KB JS / - 8 KB CSS. Landing page + release notes feed auto-loaded from - `site/content/releases/*.md`. - -### Installer & configuration - -- `install.sh` + `install.ps1` — idempotent deploy/update/uninstall with - `--prefix`, `--home`, `--force-config`, and a `MEMOS_SKIP_ADAPTER=1` - escape hatch for CI. -- `adapters/openclaw/install.openclaw.sh` + `adapters/hermes/install.hermes.sh` - — adapter-specific post-install steps (npm install, vite build, symlinks). -- `templates/config.openclaw.yaml` + `templates/config.hermes.yaml` — - slim user-facing configs with sensible defaults; advanced options - document in `docs/CONFIG-ADVANCED.md`. -- `chmod 600` enforced on `config.yaml` and `chmod 700` on the runtime - home directory. - -### Logging & observability - -- Structured JSON logger with channel taxonomy, `AsyncLocalStorage` - context, sensitive-data redaction, and multiple sinks (app, error, - audit, llm, perf, events). -- Audit log is **never deleted** — gzip rotation by month only. -- Every algorithm module emits `CoreEvent`s on a shared bus, aggregated - and forwarded to the viewer over SSE. - -## Changed - -- Top-level `MemoryCore` facade now owns `recordToolOutcome`, exposed - non-breakingly via `agent-contract/memory-core.ts`. -- `core/config/schema.ts` now exposes every advanced parameter through - TypeBox for validation while keeping the user-facing template small. - -## Fixed - -- `@preact/preset-vite` integration (`vite.config.ts` + `tsconfig.web.json`) - for first-class JSX support. -- SSE event streams now call `res.flushHeaders()` so clients see the first - event without buffering. -- HTTP static-file tests call `server.closeIdleConnections()` in teardown - to avoid 3-second keep-alive hangs. -- Python `sys.path` fix so `from memos_provider import MemTensorProvider` - resolves in standalone `unittest` runs. - -## Breaking - -- _None._ Pre-1.x project; no API stability guarantees yet. The - `agent-contract/` surface is additive from 2.0.0-alpha.1. - -## Internals - -- 99 test files, 643 tests (plus 11 Python unit tests). -- Suites: unit, integration, install (tests `install.sh` against a temp - directory), e2e. -- Viewer bundle verified <60 KB JS target; site bundle verified <15 KB JS. -- `npm run release:check` passes (`package.json` version matches the - release note filename). - -## Thanks - -- The legacy `memos-local-openclaw` and `memos-local-hermes` projects, - which this rewrite used as reference. - -## Commits - - diff --git a/apps/memos-local-plugin/site/content/releases/index.json b/apps/memos-local-plugin/site/content/releases/index.json deleted file mode 100644 index 3d0853f52..000000000 --- a/apps/memos-local-plugin/site/content/releases/index.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "version": "2.0.0-beta.1", - "date": "2026-04-17", - "title": "Full Reflect2Evolve algorithm + OpenClaw & Hermes adapters + viewer", - "highlight": "Complete end-to-end implementation: L1/L2/L3/Skill layers, three-tier retrieval, decision repair, crystallization, dual adapters, HTTP/SSE server, Vite viewer & product site.", - "kind": "beta", - "filename": "2.0.0-beta.1.md" - }, - { - "version": "2.0.0-alpha.1", - "date": "2026-04-17", - "title": "Initial scaffolding for Reflect2Evolve", - "highlight": "Project skeleton, agent-contract layer, install.sh entrypoint, viewer/site directory layout.", - "kind": "alpha", - "filename": "2.0.0-alpha.1.md" - } -] diff --git a/apps/memos-local-plugin/site/content/releases/template.md b/apps/memos-local-plugin/site/content/releases/template.md deleted file mode 100644 index fc8370a79..000000000 --- a/apps/memos-local-plugin/site/content/releases/template.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -version: X.Y.Z -date: YYYY-MM-DD -title: "" -highlight: "" -kind: minor # major | minor | patch | alpha | beta | rc ---- - -## Summary - -<1-2 short paragraphs describing the theme of this release.> - -## New - -- - -## Changed - -- - -## Fixed - -- - -## Breaking - - - -## Internals - -- - -## Thanks - -- - -## Commits - - diff --git a/apps/memos-local-plugin/site/index.html b/apps/memos-local-plugin/site/index.html deleted file mode 100644 index 1081993a7..000000000 --- a/apps/memos-local-plugin/site/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - MemOS Local — Reflect2Evolve memory plugin - - - -
- - - diff --git a/apps/memos-local-plugin/site/scripts/build-index.ts b/apps/memos-local-plugin/site/scripts/build-index.ts deleted file mode 100644 index 5ed3085b2..000000000 --- a/apps/memos-local-plugin/site/scripts/build-index.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * build-index — rebuild CHANGELOG.md + site/content/releases/index.json - * from the per-version markdown files under site/content/releases/. - * - * Runs in two steps: - * 1. Scan `site/content/releases/*.md`, parse frontmatter. - * 2. Emit: - * - `site/content/releases/index.json` — sorted list consumed by the - * site's Releases widget (title/date/highlight/kind). - * - `CHANGELOG.md` at the plugin root — human-readable index with a - * link + highlight per release. - * - * Releases without valid frontmatter are skipped with a warning so a - * half-written draft never poisons the index. - */ - -import { readFileSync, writeFileSync, readdirSync } from "node:fs"; -import path from "node:path"; - -interface ReleaseMeta { - version: string; - date: string; - title: string; - highlight: string; - kind: string; - filename: string; -} - -function parseFrontmatter(raw: string, filename: string): ReleaseMeta | null { - const m = raw.match(/^---\s*\n([\s\S]+?)\n---\s*\n?/); - if (!m) { - console.warn(`[release-index] skipping ${filename}: no frontmatter block`); - return null; - } - const meta: Record = {}; - for (const line of m[1].split("\n")) { - const kv = line.match(/^([a-zA-Z][\w-]*):\s*"?([^"]*)"?\s*$/); - if (kv) meta[kv[1]] = kv[2].trim(); - } - if (!meta.version || !meta.date || !meta.title) { - console.warn(`[release-index] skipping ${filename}: missing required fields`); - return null; - } - return { - version: meta.version, - date: meta.date, - title: meta.title, - highlight: meta.highlight ?? "", - kind: meta.kind || "minor", - filename, - }; -} - -function compareVersion(a: ReleaseMeta, b: ReleaseMeta): number { - // Newest first. - if (a.date !== b.date) return a.date < b.date ? 1 : -1; - return a.version < b.version ? 1 : -1; -} - -function main(): void { - // `__dirname` in ESM is not available; resolve relative to CWD which is the plugin root. - const root = process.cwd(); - const dir = path.join(root, "site", "content", "releases"); - const files = readdirSync(dir).filter((f) => f.endsWith(".md") && f !== "template.md"); - - const releases: ReleaseMeta[] = []; - for (const f of files) { - const raw = readFileSync(path.join(dir, f), "utf8"); - const meta = parseFrontmatter(raw, f); - if (meta) releases.push(meta); - } - releases.sort(compareVersion); - - writeFileSync( - path.join(dir, "index.json"), - JSON.stringify(releases, null, 2) + "\n", - ); - - const lines = [ - "# Changelog", - "", - "All notable changes to `@memtensor/memos-local-plugin` are documented per", - "release in [`site/content/releases/`](./site/content/releases/). This file is", - "regenerated from those release notes by `npm run release:index`.", - "", - "> Do **not** edit this file by hand. Edit the per-version markdown in", - "> `site/content/releases/.md` instead.", - "", - "## Index", - "", - ]; - for (const r of releases) { - lines.push(`- [\`${r.version}\`](./site/content/releases/${r.filename}) — ${r.highlight || r.title}`); - } - writeFileSync(path.join(root, "CHANGELOG.md"), lines.join("\n") + "\n"); - - console.log(`[release-index] wrote ${releases.length} entries to CHANGELOG.md + index.json`); -} - -main(); diff --git a/apps/memos-local-plugin/site/scripts/check-changelog.ts b/apps/memos-local-plugin/site/scripts/check-changelog.ts deleted file mode 100644 index 0db552968..000000000 --- a/apps/memos-local-plugin/site/scripts/check-changelog.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * check-changelog — CI guard. - * - * Asserts that `site/content/releases/.md` exists - * and has valid frontmatter. Runs in CI before `npm publish`. Exits 0 - * on success, 1 on failure. - */ - -import { readFileSync, existsSync } from "node:fs"; -import path from "node:path"; - -function main(): void { - const root = process.cwd(); - const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as { - version: string; - }; - const notePath = path.join(root, "site", "content", "releases", `${pkg.version}.md`); - if (!existsSync(notePath)) { - console.error(`[release-check] missing release note for ${pkg.version}: ${notePath}`); - process.exit(1); - } - const raw = readFileSync(notePath, "utf8"); - const m = raw.match(/^---\s*\n([\s\S]+?)\n---/); - if (!m) { - console.error(`[release-check] no frontmatter in ${notePath}`); - process.exit(1); - } - const fm = m[1]; - for (const key of ["version", "date", "title", "highlight"]) { - if (!new RegExp(`^${key}:`, "m").test(fm)) { - console.error(`[release-check] missing frontmatter key '${key}' in ${notePath}`); - process.exit(1); - } - } - console.log(`[release-check] ok: ${notePath}`); -} - -main(); diff --git a/apps/memos-local-plugin/site/scripts/new-release.ts b/apps/memos-local-plugin/site/scripts/new-release.ts deleted file mode 100644 index b23477f06..000000000 --- a/apps/memos-local-plugin/site/scripts/new-release.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * new-release — scaffold a new release-note markdown from template.md. - * - * Usage: - * tsx site/scripts/new-release.ts 2.0.0-rc.1 - * - * Creates `site/content/releases/.md`, prefilled with the - * version + today's date + the template body. It does NOT touch - * `package.json` or run `release:index` — those are explicit steps so - * the author can hand-edit the note first. - */ - -import { readFileSync, writeFileSync, existsSync } from "node:fs"; -import path from "node:path"; - -function main(): void { - const version = process.argv[2]; - if (!version || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) { - console.error("usage: tsx site/scripts/new-release.ts "); - process.exit(1); - } - - const root = process.cwd(); - const dir = path.join(root, "site", "content", "releases"); - const target = path.join(dir, `${version}.md`); - if (existsSync(target)) { - console.error(`release note already exists: ${target}`); - process.exit(1); - } - - const template = readFileSync(path.join(dir, "template.md"), "utf8"); - const today = new Date().toISOString().slice(0, 10); - const filled = template - .replace(/^version:\s*.*$/m, `version: ${version}`) - .replace(/^date:\s*.*$/m, `date: ${today}`); - writeFileSync(target, filled); - console.log(`[release-new] wrote ${target}`); - console.log(`[release-new] next:\n $EDITOR ${target}\n npm run release:index`); -} - -main(); diff --git a/apps/memos-local-plugin/site/src/app.ts b/apps/memos-local-plugin/site/src/app.ts deleted file mode 100644 index e768fd9d5..000000000 --- a/apps/memos-local-plugin/site/src/app.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Vanilla renderer for the site. - * - * The site is three scrollable sections + a release-notes feed. Vite - * resolves Markdown fixtures via `import.meta.glob` (raw) so we can - * display them without a markdown runtime. - */ - -import { renderHeader } from "./components/Header"; -import { renderHero } from "./components/Hero"; -import { renderFeatures } from "./components/Features"; -import { renderArchitecture } from "./components/Architecture"; -import { renderReleases } from "./components/Releases"; -import { renderFooter } from "./components/Footer"; -import { applyStoredTheme, cycleTheme } from "./theme"; - -export function renderApp(root: HTMLElement): void { - applyStoredTheme(); - root.innerHTML = ` -
- ${renderHeader()} -
- ${renderHero()} - ${renderFeatures()} - ${renderArchitecture()} - ${renderReleases()} -
- ${renderFooter()} -
- `; - wireInteractions(root); -} - -function wireInteractions(root: HTMLElement): void { - const toggle = root.querySelector(".theme-toggle"); - toggle?.addEventListener("click", () => cycleTheme()); -} diff --git a/apps/memos-local-plugin/site/src/components/Architecture.ts b/apps/memos-local-plugin/site/src/components/Architecture.ts deleted file mode 100644 index fff54b2e6..000000000 --- a/apps/memos-local-plugin/site/src/components/Architecture.ts +++ /dev/null @@ -1,64 +0,0 @@ -interface ArchColumn { - title: string; - items: string[]; -} - -const COLUMNS: ArchColumn[] = [ - { - title: "Adapters", - items: [ - "OpenClaw plugin (TypeScript)", - "Hermes provider (Python over JSON-RPC)", - "bridge.cts (stdio dispatcher)", - ], - }, - { - title: "Algorithm core", - items: [ - "Capture → L1 trace", - "Reward → reflection-weighted backprop", - "L2 policy induction + retention", - "L3 world-model abstraction", - "Skill crystallization & lifecycle", - "Decision repair loop", - "3-tier retrieval (Skill / Episode / World)", - ], - }, - { - title: "Local runtime", - items: [ - "SQLite + vector embeddings", - "YAML config + secrets redaction", - "Structured logs (audit · llm · perf · events)", - "HTTP REST + SSE server", - "Vite viewer + product site", - ], - }, -]; - -export function renderArchitecture(): string { - return ` -
-
-

Agent-agnostic core, adapter-driven edges.

-

- A single algorithm implementation feeds multiple agents via - narrow contracts. The core has no dependency on any agent's - SDK — adapters translate their events into DTOs and route - retrieval results back. -

-
- ${COLUMNS.map( - (col) => ` -
-

${col.title}

- ${col.items - .map((i) => `
${i}
`) - .join("")} -
`, - ).join("")} -
-
-
- `; -} diff --git a/apps/memos-local-plugin/site/src/components/Features.ts b/apps/memos-local-plugin/site/src/components/Features.ts deleted file mode 100644 index c04d640db..000000000 --- a/apps/memos-local-plugin/site/src/components/Features.ts +++ /dev/null @@ -1,66 +0,0 @@ -interface Feature { - icon: string; - title: string; - body: string; -} - -const FEATURES: Feature[] = [ - { - icon: "L1", - title: "L1 traces, captured once", - body: "Every turn becomes a verbatim, immutable L1 trace with tool-call ordering preserved. Replayable, auditable, never deleted.", - }, - { - icon: "L2", - title: "L2 policies, induced automatically", - body: "Multiple supportive traces crystallize into reusable policies with reflection-weighted value backprop and softmax-derived priorities.", - }, - { - icon: "L3", - title: "L3 world models, cross-task", - body: "Policies roll up into structural world models that the agent can cite to defuse repeated classes of mistakes across sessions.", - }, - { - icon: "★", - title: "Callable Skills", - body: "High-value policies graduate into first-class Skills: named, invocable via Tier-1 retrieval, and retirable from the viewer with one click.", - }, - { - icon: "↻", - title: "Decision Repair", - body: "Failed tool calls and negative feedback auto-trigger targeted retrieval that injects corrective guidance on the next turn.", - }, - { - icon: "⌂", - title: "Fully local", - body: "Everything lives in ~/.memos-plugin//: config.yaml, SQLite DB, logs, embeddings. No cloud dependency unless you configure one.", - }, -]; - -function renderCard(f: Feature): string { - return ` -
- -

${f.title}

-

${f.body}

-
- `; -} - -export function renderFeatures(): string { - return ` -
-
-

Memory, layered the way agents actually think.

-

- Each layer is independent, addressable, and re-ranked. - Together they give your agent context that sharpens over - time without bloating the prompt. -

-
- ${FEATURES.map(renderCard).join("")} -
-
-
- `; -} diff --git a/apps/memos-local-plugin/site/src/components/Footer.ts b/apps/memos-local-plugin/site/src/components/Footer.ts deleted file mode 100644 index 34f8c4284..000000000 --- a/apps/memos-local-plugin/site/src/components/Footer.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function renderFooter(): string { - const year = new Date().getFullYear(); - return ` -
- -
- `; -} diff --git a/apps/memos-local-plugin/site/src/components/Header.ts b/apps/memos-local-plugin/site/src/components/Header.ts deleted file mode 100644 index 86eb31ef9..000000000 --- a/apps/memos-local-plugin/site/src/components/Header.ts +++ /dev/null @@ -1,21 +0,0 @@ -export function renderHeader(): string { - return ` - - `; -} diff --git a/apps/memos-local-plugin/site/src/components/Hero.ts b/apps/memos-local-plugin/site/src/components/Hero.ts deleted file mode 100644 index 7a72a98d6..000000000 --- a/apps/memos-local-plugin/site/src/components/Hero.ts +++ /dev/null @@ -1,21 +0,0 @@ -export function renderHero(): string { - return ` -
-
- Reflect2Evolve · V7 -

Local-first memory that grows with your agent.

-

- MemOS Local turns every coding session into layered memory — - traces, policies, world models, and callable skills — - running on your machine. Decision-repair, reflection-weighted - backprop, and three-tier retrieval, wired into whichever - agent you're using. -

- -
-
- `; -} diff --git a/apps/memos-local-plugin/site/src/components/Releases.ts b/apps/memos-local-plugin/site/src/components/Releases.ts deleted file mode 100644 index cfa2c715a..000000000 --- a/apps/memos-local-plugin/site/src/components/Releases.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Release feed component. - * - * Uses Vite's `import.meta.glob(..., { as: 'raw', eager: true })` to - * compile the `content/releases/*.md` files into the bundle as raw - * strings. We parse the YAML frontmatter ourselves — the site doesn't - * need a Markdown runtime. - */ - -const rawReleases = import.meta.glob( - "../../content/releases/*.md", - { query: "?raw", import: "default", eager: true }, -); - -interface ReleaseMeta { - version: string; - date: string; - title: string; - highlight: string; - kind: string; - body: string; -} - -function parseFrontmatter(raw: string): ReleaseMeta { - const match = raw.match(/^---\s*\n([\s\S]+?)\n---\s*\n?([\s\S]*)$/); - if (!match) { - return { - version: "", - date: "", - title: "(malformed release)", - highlight: "", - kind: "", - body: raw, - }; - } - const [, fm, body] = match; - const meta: Record = {}; - for (const line of fm.split("\n")) { - const kv = line.match(/^([a-zA-Z][\w-]*):\s*"?([^"]*)"?\s*$/); - if (kv) meta[kv[1]] = kv[2].trim(); - } - return { - version: meta.version ?? "", - date: meta.date ?? "", - title: meta.title ?? "", - highlight: meta.highlight ?? "", - kind: meta.kind ?? "minor", - body: body.trim(), - }; -} - -function releases(): ReleaseMeta[] { - return Object.entries(rawReleases) - .filter(([path]) => !/template\.md$/.test(path)) - .map(([, raw]) => parseFrontmatter(raw)) - .sort((a, b) => (a.date < b.date ? 1 : -1)); -} - -function escape(s: string): string { - return s.replace(/[&<>"]/g, (c) => { - if (c === "&") return "&"; - if (c === "<") return "<"; - if (c === ">") return ">"; - return """; - }); -} - -function renderMarkdownLite(md: string): string { - const lines = md.split("\n"); - const out: string[] = []; - let inList = false; - let inCode = false; - let codeBuf: string[] = []; - - for (const raw of lines) { - const line = raw.trimEnd(); - if (line.startsWith("```")) { - if (inCode) { - out.push(`
${escape(codeBuf.join("\n"))}
`); - codeBuf = []; - } - inCode = !inCode; - continue; - } - if (inCode) { - codeBuf.push(line); - continue; - } - if (/^##\s+/.test(line)) { - if (inList) { out.push(""); inList = false; } - out.push(`

${escape(line.replace(/^##\s+/, ""))}

`); - } else if (/^-\s+/.test(line)) { - if (!inList) { out.push("
    "); inList = true; } - out.push(`
  • ${escape(line.replace(/^-\s+/, ""))}
  • `); - } else if (line === "") { - if (inList) { out.push("
"); inList = false; } - out.push(""); - } else if (line.startsWith(" +
+
+
+
+ + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + OpenClaw / Hermes 本地插件 · MIT 开源OpenClaw / Hermes Local Plugin · MIT +
+

+ 让 OpenClaw 与 Hermes
越用越聪明
+ Give OpenClaw and Hermes
Lasting Intelligence
+

+

+ 同一套 Reflect2Evolve 核心,同时接入 OpenClaw 和 Hermes
本地存储 分层记忆 技能结晶 Viewer 可观测
+ One Reflect2Evolve core for both OpenClaw and Hermes.
Local storage, layered memory, skill crystallization, and an observable Viewer.
+

+

宿主适配器隔离,算法核心共享,运行时数据按 agent 分开保存Isolated host adapters, shared algorithm core, per-agent runtime data

+ + + +
+ + + + + + + + + + + + + + + Hub + 团队服务端 · 共享记忆/技能Team Server · Shared Memory/Skills + 🧠 86 + 📋 12 + ⚡ 5 + + + + + OpenClaw + 前端开发 · 234 记忆Frontend · 234 Memories + online + L1/L2/L3 + + + + + + + + + + + Hermes + 后端开发 · 158 记忆Backend · 158 Memories + online + JSON-RPC + + + + + Viewer + 测试工程 · 89 记忆QA/Testing · 89 Memories + online + HTTP/SSE + + + …N + 更多实例More + + + + + + + + + + + + + +
+ +
+
+
+ +
+ + +
+
+
+
# macOS/Linux installer. Auto-detects OpenClaw and Hermes.# macOS/Linux installer. Auto-detects OpenClaw and Hermes.
+
$curl -fsSL https://raw.githubusercontent.com/MemTensor/MemOS/main/apps/memos-local-plugin/install.sh | bash
+ + +
+
+
+ +
+
+ +
+ + +
+
+
+

没有记忆的 Agent,每次都从零开始Without Memory, Every Task Starts from Zero

+

MemOS 为 OpenClaw 与 Hermes 提供同一套本地优先的分层记忆核心。MemOS gives both OpenClaw and Hermes the same local-first layered memory core.

+
+
+
💻

完全本地化Fully Local

记忆、技能、日志和配置都保存在本机运行时目录。Memory, skills, logs, and config stay in the local runtime directory.

+
🧠

全量可视化管理Full Visualization

Viewer 可查看 trace、policy、world model、skill、日志与配置。The Viewer exposes traces, policies, world models, skills, logs, and settings.

+

策略与技能进化Policy & Skill Evolution

高价值 trace 归纳成 L2 policy,成熟策略再结晶为可调用 Skill。High-value traces induce L2 policies, and mature policies crystallize into callable Skills.

+
💰

多模型后端Multiple Model Backends

Embedding 与 LLM 可分别选择本地、OpenAI 兼容、Gemini、Anthropic、Bedrock 等提供方。Embedding and LLM backends can be configured independently across local and cloud providers.

+
🤝

可选团队生态Optional Team Ecosystem

配置中保留 Hub 能力入口;默认关闭,不影响本地记忆主流程。Hub configuration is available as an optional surface and stays out of the local memory path by default.

+
🧬

宿主数据隔离Per-Host Isolation

OpenClaw 与 Hermes 使用独立运行时目录和 Viewer 端口,避免数据归属混淆。OpenClaw and Hermes use separate runtime homes and Viewer ports to keep ownership clear.

+
+
+
+ +
+ + +
+
+
+

核心能力,驱动 Agent 持续进化Core Capabilities for Continuous Evolution

+
+
+
+
+

Reflect2Evolve 分层记忆Reflect2Evolve Layered Memory

+

每个回合先沉淀为 L1 trace,再由反馈信号驱动 L2 policy、L3 world model 和 Skill 生成。算法核心与宿主无关,因此 OpenClaw 和 Hermes 使用同一套演化逻辑。Turns become L1 traces first; feedback then drives L2 policies, L3 world models, and Skills. The algorithm core is host-agnostic, so OpenClaw and Hermes share the same evolution loop.

+
L1 TraceL2 PolicyL3 World ModelSkill反馈回传Reward Backprop
+
+
+
+
Task → Skill Evolution
+
L1 trace: action + observation + reflection + value
+Reward: V_t = alpha_t * R + (1-alpha_t) * gamma * V_{t+1}
+
+L2 policy: trigger + procedure + verification + boundary
+L3 world: project/environment knowledge from L2 + L1
+Skill: invocation guide + procedureJson
+
+✓ Retrieval injects Skill → Trace/Episode → World Model
+
+
+
+
+
+

两个宿主,一个核心Two Hosts, One Core

+

OpenClaw 适配器在 TypeScript 进程内直接调用 core;Hermes 适配器通过 Python MemoryProvider 和 JSON-RPC bridge 访问同一个 core。适配器只处理宿主协议,算法、存储、检索和技能生命周期都留在共享核心里。The OpenClaw adapter calls core in-process from TypeScript; the Hermes adapter reaches the same core through a Python MemoryProvider and JSON-RPC bridge. Adapters handle host protocol only; storage, retrieval, and skill lifecycle stay shared.

+
OpenClaw TSHermes PythonJSON-RPC Bridge
+
+
+
+
Host Adapters
+
OpenClaw:
+  before_prompt_build → core.onTurnStart()
+  agent_end           → core.onTurnEnd()
+
+Hermes:
+  prefetch  → JSON-RPC turn.start
+  sync_turn → JSON-RPC turn.end
+
+✓ Same SQLite schema and retrieval pipeline
+
+
+
+
+
+

全量记忆可视化管理Full Memory Visualization

+

内置 Web 管理面板可查看 trace、policy、world model、skill、日志、配置和导入状态。OpenClaw 默认端口 18799,Hermes 默认端口 18800。The built-in dashboard shows traces, policies, world models, skills, logs, settings, and import state. OpenClaw uses port 18799; Hermes uses 18800.

+
+
+
+
127.0.0.1:18799
+
+
TracesPoliciesWorldSkillsLogsSettings
+
+
总记忆Total
1,284
+
今日Today
+47
+
任务Tasks
12
+
技能Skills
8
+
+
+
user帮我配置 Nginx 反向代理到 3000 端口Set up Nginx proxy to port 30002m
+
asst好的,创建 nginx 配置文件并写入 upstream 配置。Creating nginx config file and writing upstream block.2m
+
user还需要加 SSL 证书Also add SSL cert5m
+
+
+
+
+
+
+
+
+ +
+ + +
+
+
+

从宿主事件到记忆注入的共享架构A Shared Architecture from Host Event to Memory Injection

+

OpenClaw 与 Hermes 的差异只存在于 adapter 层;core、server、viewer、SQLite schema 和算法事件保持一致。OpenClaw and Hermes differ only at the adapter layer; core, server, viewer, SQLite schema, and algorithm events stay shared.

+
+
+
+

架构层次Architecture Layers

+
+
1
Host AdapterHost Adapter

OpenClaw 使用进程内 TS 插件;Hermes 使用 Python provider + bridge。OpenClaw uses an in-process TS plugin; Hermes uses a Python provider plus bridge.

+
2
agent-contract

DTO、事件、错误码和 JSON-RPC 方法名在这里稳定下来。DTOs, events, errors, and JSON-RPC method names live here.

+
3
core

capture、reward、L1/L2/L3、skill、retrieval、storage、logger 都在共享核心里。Capture, reward, L1/L2/L3, skills, retrieval, storage, and logging live in the shared core.

+
4
server / viewer

HTTP + SSE 提供 Viewer 与调试入口,每个宿主有独立端口。HTTP + SSE power the Viewer and diagnostics, with one port per host.

+
+
+
+
+
+ +
+ + +
+
+
+

60 秒上手Up and Running in 60 Seconds

+

macOS / Linux 安装器会自动检测 OpenClaw 与 Hermes,并写入对应运行时目录。The macOS/Linux installer auto-detects OpenClaw and Hermes and writes each runtime home.

+
+
+
+
+

1. 一键安装/升级1. Install

+

安装脚本会下载 npm 包、安装生产依赖、生成 config.yaml,并为 OpenClaw / Hermes 启动各自的 Viewer。
遇到安装问题?查看排查指南 →
The script downloads the npm package, installs production dependencies, writes config.yaml, and starts the per-host Viewer.
Install issues? See troubleshooting guide →

+
+
+
+
+ +
+ + +
+
+
+
# Step 1: 安装插件 & 启动(macOS/Linux)# Step 1: Install plugin & start (macOS/Linux)
+
+ $ + curl -fsSL https://raw.githubusercontent.com/MemTensor/MemOS/main/apps/memos-local-plugin/install.sh | bash + +
+ + +
+
+
+
+
+
+

2. 配置2. Config

+

网页面板:OpenClaw 默认 http://127.0.0.1:18799,Hermes 默认 http://127.0.0.1:18800。运行时配置写在各自的 config.yamlWeb panel: OpenClaw defaults to http://127.0.0.1:18799, Hermes to http://127.0.0.1:18800. Runtime config lives in each host's config.yaml.

+
+
+
+ + +
+
+
+
127.0.0.1:18799
+
+
TracesPoliciesWorldSkillsLogsSettings
+
+
Embedding
+
+ Providerlocal + Cloudoptional + API Keyconfigured only for cloud providers +
+
LLM
+
+ OpenClawhost + Hermesopenai_compatible + API Keyrequired for cloud providers +
+
Runtime
+
+ OpenClaw~/.openclaw/memos-plugin + Hermes~/.hermes/memos-plugin +
+
+ Viewer Port18799 +
+
+
保存即生效Save to apply
+
+
+
+
+
+
version: 1
+viewer:
+  port: 18799  # OpenClaw; Hermes uses 18800
+embedding:
+  provider: local
+  apiKey: ""
+llm:
+  provider: host  # Hermes: openai_compatible or another real provider
+  apiKey: ""
+  model: ""
+hub:
+  enabled: false
+telemetry:
+  enabled: true
+logging:
+  level: info
+
+
+
+
+
+
+
+ +
+ + +
+
+
+

适配你的技术栈Works with Your Preferred Stack

+

Embedding 与 LLM 后端独立配置;无云端 key 时仍可使用本地 embedding 和宿主模型能力。Embedding and LLM backends are configured independently; local embedding and host LLM paths remain available where supported.

+
+
+
OpenAI
Anthropic
Gemini
Bedrock
Cohere
Voyage
Mistral
本地Local
+
+
+
+ + +
+
+

宿主工具与Viewer 能力Host Tools and Viewer Capabilities

+
+
🔍

memory_search

三层检索Three-tier search

+
📄

memory_get

读取 trace / policy / world modelFetch trace / policy / world model

+
📜

memory_timeline

查看 episode 时间线Episode timeline

+
🌎

memory_environment

查询 L3 环境认知Query L3 world models

+

skill_list

列出候选和活跃技能List candidate and active skills

+
📘

skill_get

获取技能调用指南Fetch invocation guide

+
+
+
+ +
+ + +
+
+
+
+ Team + 团队共享中心Team Sharing Hub +
+

多实例协作 —
让团队的 Agent 共同进化
Multi-Instance Collaboration —
Your Team's Agents Evolve Together

+

OpenClaw 与 Hermes 默认各自本地隔离。显式开启 Team Sharing 后,多个实例可以通过 Hub 共享技能和可选 Trace 摘要;私有数据库、配置与日志仍留在本机。OpenClaw and Hermes stay isolated by default. When Team Sharing is explicitly enabled, multiple instances can share skills and optional trace excerpts through a Hub while private DBs, config, and logs remain local.

+
+ +
+
+ + + + + + + + + + + + + + + + + + + Team Hub + 可选开启 · 局域网 / VPN 内共享Optional · LAN / VPN sharing + + Skills + + Trace excerpts + + ACL + hub.enabled=true · teamToken · userToken + + + + + + + + + + OpenClaw + 本地 DB:~/.openclaw/memos-pluginLocal DB: ~/.openclaw/memos-plugin + private + share skill + + + + + + + + + + + + + Hermes + 本地 DB:~/.hermes/memos-pluginLocal DB: ~/.hermes/memos-plugin + private + pull skill + + + + + + + + + + 更多实例More Agents + OpenClaw / Hermes 均可加入OpenClaw / Hermes can join + token gated + + + + + + + + + +
+
+ +
+

团队共享支持的协作方式How Team Sharing Fits the Local Core

+
+
1
默认隔离Isolated by Default

OpenClaw 与 Hermes 的数据库、技能包和日志默认互不共享。OpenClaw and Hermes keep DBs, skill bundles, and logs separate by default.

+
2
显式开启Explicit Opt-In

hub.enabledhub.address 和 token 写入 config.yaml 后才加入团队。Instances join a team only after hub.enabled, hub.address, and tokens are configured.

+
3
技能优先共享Skill-First Sharing

适合共享已结晶 Skill 和可选 Trace 摘要,而不是直接合并私有数据库。Designed for sharing crystallized skills and optional trace excerpts, not merging private databases.

+
4
离线可用Local When Offline

Hub 不可用时,本地记忆、检索和 Skill 生命周期仍按本机流程运行。If the Hub is unavailable, local memory, retrieval, and skill lifecycle continue normally.

+
+
+
+
+ +
+ + +
+
+
+
+ Import + 原生记忆导入Native Memory Import +
+

再续前缘 —
过往的记忆,不会丢失
Reconnect —
Your Past Memories, Never Lost

+

Viewer 提供导入入口:OpenClaw 读取原生 session JSONL,Hermes 读取原生 MEMORY.md;也支持 MemOS JSON bundle 的导入导出。The Viewer exposes import paths for OpenClaw native session JSONL, Hermes native MEMORY.md, and MemOS JSON bundles.

+
+ +
+
+ 🚀 +

按宿主导入Per-Host Import

+

OpenClaw 与 Hermes 只扫描当前 Viewer 所属宿主的数据源。OpenClaw and Hermes scan only the data source for the current Viewer host.

+
+
+ 🧬 +

Bundle 往返Bundle Round-Trip

+

导出文件可重新导入,用于迁移设备或备份本地记忆。Exported bundles can be imported again for device migration or backup.

+
+
+ ⏸️ +

批量处理Batched Processing

+

原生导入按 offset / limit 分批执行,避免一次性处理过多历史数据。Native import runs in offset / limit batches to avoid processing too much history at once.

+
+
+ +

保守写入Conservative Writes

+

导入入口写入 trace bundle;后续价值、策略和技能由核心算法按正常流程处理。Import writes trace bundles; value, policy, and skill evolution remain handled by the core pipeline.

+
+
+
+
+ +
+ + +
+
+
+

相关产品生态Related Product Ecosystem

+

从企业到个人,从云端到本地,围绕 MemOS 构建完整 AI 记忆能力栈。From enterprise to personal use, from cloud to local-first deployments, MemOS anchors the full AI memory stack.

+
+ +
+ + +
+ + ↑ 基于 MemOS 构建 ↑^ Built on MemOS ^ + +
+ + +
+
+
+ +
+ + +
+
+
+
+ + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+

让 OpenClaw 与 Hermes
共享进化记忆
Give OpenClaw and Hermes
Shared Evolving Memory

+

本地优先 · 分层记忆 · 策略归纳 · 技能结晶 · Viewer 可观测 · 宿主数据隔离Local-first · Layered memory · Policy induction · Skill crystallization · Observable Viewer · Per-host isolation

+ +
+
+ + + + + + + +