From b683f8a49be676677ae47090d97fc21a113e6c62 Mon Sep 17 00:00:00 2001 From: jiang Date: Sat, 9 May 2026 16:28:52 +0800 Subject: [PATCH 01/20] chore(memos-local-plugin): rename web/ to viewer/, drop site/ scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two co-located package-layout tidy-ups: 1. Rename `web/` to `viewer/` - `git mv web viewer`, `tests/unit/web` to `tests/unit/viewer`, `tsconfig.web.json` to `tsconfig.viewer.json`. - Updated build/runtime references: `vite.config.ts` (root + alias `@web` to `@viewer`), `bridge.cts` (staticRoot), `package.json` (`files`, scripts `build:viewer` / `viewer:dev`), `adapters/openclaw/index.ts` (resolveViewerStaticRoot candidates), `tsconfig.json` / `tsconfig.build.json` exclude entries, `.gitignore` (`viewer/dist/`), and the install adapter scripts' log lines. - Updated test import paths and code/doc comments across `tests/`, `docs/`, `viewer/README.md`, `viewer/ALGORITHMS.md`, `ARCHITECTURE.md`, `README.md`, etc. - No public surface change: package name, `main`, `exports`, HTTP routes (`/ui/`, `/api/v1/*`) all unchanged. Natural-English uses of "web" (e.g. "web framework", "open web") preserved. 2. Drop unfinished `site/` scaffolding - Remove `site/**`, `tsconfig.site.json`, `tests/unit/site/`. - Strip `siteRoot` and `/site/*` route from `server/middleware/static.ts` and `server/types.ts`. - Clean up `.npmignore`, `CHANGELOG.md`, `templates/README.user.md`, and tsconfig include/exclude entries that referenced `site/`. Verification: - `npm run lint` (tsc --noEmit) — pass - `npm run build` + `npm run build:viewer` — pass; `viewer/dist/` produced - `npm pack --dry-run` — `viewer/dist/*` shipped - 110 viewer/install/http/e2e tests pass - Pre-existing failures in reward / memory / migrator suites are unrelated (verified by re-running on the un-renamed HEAD). --- apps/memos-local-plugin/.gitignore | 3 +- apps/memos-local-plugin/.npmignore | 3 - apps/memos-local-plugin/ARCHITECTURE.md | 29 +-- apps/memos-local-plugin/CHANGELOG.md | 15 +- apps/memos-local-plugin/README.md | 3 +- .../adapters/hermes/install.hermes.sh | 7 +- .../adapters/openclaw/index.ts | 6 +- .../adapters/openclaw/install.openclaw.sh | 6 +- apps/memos-local-plugin/bridge.cts | 4 +- .../docs/ALGORITHM_ALIGNMENT.md | 4 +- .../docs/GRANULARITY-AND-MEMORY-LAYERS.md | 2 +- .../docs/MANUAL_E2E_TESTING.md | 2 +- .../docs/MULTI_AGENT_VIEWER.md | 2 +- apps/memos-local-plugin/docs/README.md | 4 +- ...W_FEEDBACK_EXPERIENCE_OPTIMIZATION_PLAN.md | 2 +- apps/memos-local-plugin/package.json | 16 +- apps/memos-local-plugin/server/README.md | 10 +- .../server/middleware/static.ts | 12 +- apps/memos-local-plugin/server/types.ts | 9 +- apps/memos-local-plugin/site/README.md | 113 --------- .../site/content/index.json | 14 -- .../site/content/releases/2.0.0-alpha.1.md | 59 ----- .../site/content/releases/2.0.0-beta.1.md | 144 ------------ .../site/content/releases/index.json | 18 -- .../site/content/releases/template.md | 43 ---- apps/memos-local-plugin/site/index.html | 15 -- .../site/scripts/build-index.ts | 100 -------- .../site/scripts/check-changelog.ts | 38 --- .../site/scripts/new-release.ts | 41 ---- apps/memos-local-plugin/site/src/app.ts | 37 --- .../site/src/components/Architecture.ts | 64 ----- .../site/src/components/Features.ts | 66 ------ .../site/src/components/Footer.ts | 17 -- .../site/src/components/Header.ts | 21 -- .../site/src/components/Hero.ts | 21 -- .../site/src/components/Releases.ts | 147 ------------ apps/memos-local-plugin/site/src/main.ts | 19 -- .../site/src/pages/.gitkeep | 0 .../site/src/styles/.gitkeep | 0 .../site/src/styles/base.css | 78 ------ .../site/src/styles/components.css | 112 --------- .../site/src/styles/layout.css | 222 ------------------ .../site/src/styles/theme.css | 75 ------ apps/memos-local-plugin/site/src/theme.ts | 37 --- apps/memos-local-plugin/site/vite.config.ts | 26 -- .../templates/README.user.md | 3 +- .../tests/unit/adapters/openclaw-e2e.test.ts | 2 +- .../tests/unit/install/install-sh.test.ts | 8 +- .../tests/unit/server/http.test.ts | 2 +- .../tests/unit/site/releases.test.ts | 85 ------- .../unit/{web => viewer}/api-client.test.ts | 2 +- .../unit/{web => viewer}/markdown.test.ts | 2 +- .../tests/unit/{web => viewer}/router.test.ts | 2 +- .../tests/unit/{web => viewer}/share.test.ts | 2 +- .../unit/{web => viewer}/sse-client.test.ts | 2 +- .../unit/{web => viewer}/tasks-chat.test.ts | 2 +- apps/memos-local-plugin/tsconfig.build.json | 4 +- apps/memos-local-plugin/tsconfig.json | 4 +- apps/memos-local-plugin/tsconfig.site.json | 20 -- ...tsconfig.web.json => tsconfig.viewer.json} | 4 +- .../{web => viewer}/ALGORITHMS.md | 8 +- .../{web => viewer}/README.md | 22 +- .../{web => viewer}/index.html | 0 .../content/docs => viewer/public}/.gitkeep | 0 .../{web => viewer}/public/hermes-logo.svg | 0 .../{web => viewer}/public/logo.svg | 0 .../{web => viewer}/public/openclaw-logo.svg | 0 .../releases => viewer/src/api}/.gitkeep | 0 .../{web => viewer}/src/api/client.ts | 0 .../{web => viewer}/src/api/sse.ts | 0 .../{web => viewer}/src/api/types.ts | 0 .../src/components}/.gitkeep | 0 .../src/components/AgentLogo.tsx | 0 .../{web => viewer}/src/components/App.tsx | 0 .../src/components/AuthGate.tsx | 0 .../src/components/ContentRouter.tsx | 0 .../{web => viewer}/src/components/Header.tsx | 0 .../src/components/HubAdminPanel.tsx | 0 .../{web => viewer}/src/components/Icon.tsx | 0 .../src/components/Markdown.tsx | 0 .../src/components/ModelSetupBanner.tsx | 0 .../{web => viewer}/src/components/Pager.tsx | 0 .../src/components/RestartOverlay.tsx | 0 .../src/components/ShareScopePill.tsx | 0 .../src/components/Sidebar.tsx | 0 .../src/components/ThemeLangFooter.tsx | 0 .../{web => viewer}/src/main.tsx | 0 .../{web => viewer}/src/stores/cross-link.ts | 0 .../{web => viewer}/src/stores/health.ts | 0 .../{web => viewer}/src/stores/i18n.ts | 0 .../{web => viewer}/src/stores/peers.ts | 0 .../{web => viewer}/src/stores/restart.ts | 0 .../{web => viewer}/src/stores/router.ts | 0 .../{web => viewer}/src/stores/theme.ts | 0 .../scripts => viewer/src/styles}/.gitkeep | 0 .../{web => viewer}/src/styles/components.css | 0 .../{web => viewer}/src/styles/layout.css | 0 .../{web => viewer}/src/styles/tokens.css | 0 .../{web => viewer}/src/utils/selection.ts | 0 .../{web => viewer}/src/utils/share.ts | 0 .../components => viewer/src/views}/.gitkeep | 0 .../{web => viewer}/src/views/AdminView.tsx | 0 .../src/views/AnalyticsView.tsx | 0 .../{web => viewer}/src/views/HelpView.tsx | 0 .../{web => viewer}/src/views/ImportView.tsx | 0 .../{web => viewer}/src/views/LogsView.tsx | 0 .../src/views/MemoriesView.tsx | 0 .../src/views/OverviewView.tsx | 0 .../src/views/PoliciesView.tsx | 0 .../src/views/SettingsView.tsx | 0 .../{web => viewer}/src/views/SkillsView.tsx | 0 .../{web => viewer}/src/views/TasksView.tsx | 0 .../src/views/WorldModelsView.tsx | 0 .../src/views/overview/ActivityDashboard.tsx | 0 .../src/views/overview/Sparkline.tsx | 0 .../src/views/overview/event-meta.ts | 0 .../src/views/tasks-chat-data.ts | 0 .../{web => viewer}/src/views/tasks-chat.tsx | 2 +- apps/memos-local-plugin/vite.config.ts | 8 +- apps/memos-local-plugin/web/public/.gitkeep | 0 apps/memos-local-plugin/web/src/api/.gitkeep | 0 .../web/src/components/.gitkeep | 0 .../web/src/styles/.gitkeep | 0 .../memos-local-plugin/web/src/views/.gitkeep | 0 124 files changed, 85 insertions(+), 1765 deletions(-) delete mode 100644 apps/memos-local-plugin/site/README.md delete mode 100644 apps/memos-local-plugin/site/content/index.json delete mode 100644 apps/memos-local-plugin/site/content/releases/2.0.0-alpha.1.md delete mode 100644 apps/memos-local-plugin/site/content/releases/2.0.0-beta.1.md delete mode 100644 apps/memos-local-plugin/site/content/releases/index.json delete mode 100644 apps/memos-local-plugin/site/content/releases/template.md delete mode 100644 apps/memos-local-plugin/site/index.html delete mode 100644 apps/memos-local-plugin/site/scripts/build-index.ts delete mode 100644 apps/memos-local-plugin/site/scripts/check-changelog.ts delete mode 100644 apps/memos-local-plugin/site/scripts/new-release.ts delete mode 100644 apps/memos-local-plugin/site/src/app.ts delete mode 100644 apps/memos-local-plugin/site/src/components/Architecture.ts delete mode 100644 apps/memos-local-plugin/site/src/components/Features.ts delete mode 100644 apps/memos-local-plugin/site/src/components/Footer.ts delete mode 100644 apps/memos-local-plugin/site/src/components/Header.ts delete mode 100644 apps/memos-local-plugin/site/src/components/Hero.ts delete mode 100644 apps/memos-local-plugin/site/src/components/Releases.ts delete mode 100644 apps/memos-local-plugin/site/src/main.ts delete mode 100644 apps/memos-local-plugin/site/src/pages/.gitkeep delete mode 100644 apps/memos-local-plugin/site/src/styles/.gitkeep delete mode 100644 apps/memos-local-plugin/site/src/styles/base.css delete mode 100644 apps/memos-local-plugin/site/src/styles/components.css delete mode 100644 apps/memos-local-plugin/site/src/styles/layout.css delete mode 100644 apps/memos-local-plugin/site/src/styles/theme.css delete mode 100644 apps/memos-local-plugin/site/src/theme.ts delete mode 100644 apps/memos-local-plugin/site/vite.config.ts delete mode 100644 apps/memos-local-plugin/tests/unit/site/releases.test.ts rename apps/memos-local-plugin/tests/unit/{web => viewer}/api-client.test.ts (97%) rename apps/memos-local-plugin/tests/unit/{web => viewer}/markdown.test.ts (86%) rename apps/memos-local-plugin/tests/unit/{web => viewer}/router.test.ts (97%) rename apps/memos-local-plugin/tests/unit/{web => viewer}/share.test.ts (91%) rename apps/memos-local-plugin/tests/unit/{web => viewer}/sse-client.test.ts (98%) rename apps/memos-local-plugin/tests/unit/{web => viewer}/tasks-chat.test.ts (99%) delete mode 100644 apps/memos-local-plugin/tsconfig.site.json rename apps/memos-local-plugin/{tsconfig.web.json => tsconfig.viewer.json} (91%) rename apps/memos-local-plugin/{web => viewer}/ALGORITHMS.md (96%) rename apps/memos-local-plugin/{web => viewer}/README.md (91%) rename apps/memos-local-plugin/{web => viewer}/index.html (100%) rename apps/memos-local-plugin/{site/content/docs => viewer/public}/.gitkeep (100%) rename apps/memos-local-plugin/{web => viewer}/public/hermes-logo.svg (100%) rename apps/memos-local-plugin/{web => viewer}/public/logo.svg (100%) rename apps/memos-local-plugin/{web => viewer}/public/openclaw-logo.svg (100%) rename apps/memos-local-plugin/{site/content/releases => viewer/src/api}/.gitkeep (100%) rename apps/memos-local-plugin/{web => viewer}/src/api/client.ts (100%) rename apps/memos-local-plugin/{web => viewer}/src/api/sse.ts (100%) rename apps/memos-local-plugin/{web => viewer}/src/api/types.ts (100%) rename apps/memos-local-plugin/{site/public/screenshots => viewer/src/components}/.gitkeep (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/AgentLogo.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/App.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/AuthGate.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/ContentRouter.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/Header.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/HubAdminPanel.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/Icon.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/Markdown.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/ModelSetupBanner.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/Pager.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/RestartOverlay.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/ShareScopePill.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/Sidebar.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/components/ThemeLangFooter.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/main.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/stores/cross-link.ts (100%) rename apps/memos-local-plugin/{web => viewer}/src/stores/health.ts (100%) rename apps/memos-local-plugin/{web => viewer}/src/stores/i18n.ts (100%) rename apps/memos-local-plugin/{web => viewer}/src/stores/peers.ts (100%) rename apps/memos-local-plugin/{web => viewer}/src/stores/restart.ts (100%) rename apps/memos-local-plugin/{web => viewer}/src/stores/router.ts (100%) rename apps/memos-local-plugin/{web => viewer}/src/stores/theme.ts (100%) rename apps/memos-local-plugin/{site/scripts => viewer/src/styles}/.gitkeep (100%) rename apps/memos-local-plugin/{web => viewer}/src/styles/components.css (100%) rename apps/memos-local-plugin/{web => viewer}/src/styles/layout.css (100%) rename apps/memos-local-plugin/{web => viewer}/src/styles/tokens.css (100%) rename apps/memos-local-plugin/{web => viewer}/src/utils/selection.ts (100%) rename apps/memos-local-plugin/{web => viewer}/src/utils/share.ts (100%) rename apps/memos-local-plugin/{site/src/components => viewer/src/views}/.gitkeep (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/AdminView.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/AnalyticsView.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/HelpView.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/ImportView.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/LogsView.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/MemoriesView.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/OverviewView.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/PoliciesView.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/SettingsView.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/SkillsView.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/TasksView.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/WorldModelsView.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/overview/ActivityDashboard.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/overview/Sparkline.tsx (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/overview/event-meta.ts (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/tasks-chat-data.ts (100%) rename apps/memos-local-plugin/{web => viewer}/src/views/tasks-chat.tsx (99%) delete mode 100644 apps/memos-local-plugin/web/public/.gitkeep delete mode 100644 apps/memos-local-plugin/web/src/api/.gitkeep delete mode 100644 apps/memos-local-plugin/web/src/components/.gitkeep delete mode 100644 apps/memos-local-plugin/web/src/styles/.gitkeep delete mode 100644 apps/memos-local-plugin/web/src/views/.gitkeep diff --git a/apps/memos-local-plugin/.gitignore b/apps/memos-local-plugin/.gitignore index 20d09eb19..affa637af 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 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/openclaw/index.ts b/apps/memos-local-plugin/adapters/openclaw/index.ts index 053235fd1..8c8b05e63 100644 --- a/apps/memos-local-plugin/adapters/openclaw/index.ts +++ b/apps/memos-local-plugin/adapters/openclaw/index.ts @@ -85,13 +85,13 @@ interface PluginRuntime { /** 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 { 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/bridge.cts b/apps/memos-local-plugin/bridge.cts index c34334af8..f6e2852d8 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -201,7 +201,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 +271,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, }, ); 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/package.json b/apps/memos-local-plugin/package.json index da6caea2b..410bf602a 100644 --- a/apps/memos-local-plugin/package.json +++ b/apps/memos-local-plugin/package.json @@ -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/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/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

+ +
+
+ + + + + + + + From ff01087840dae97c7e7e137f5f7106b445f5369b Mon Sep 17 00:00:00 2001 From: jiang Date: Tue, 12 May 2026 11:21:03 +0800 Subject: [PATCH 04/20] fix(memos-local-plugin): infer embedding dimensions for rebuilds Add embedding maintenance APIs and viewer controls so imported memories and model changes can repair or rebuild vectors against the active provider. Treat remote embedding dimensions as provider-derived state, not user config, and aggregate explicit feedback corrections consistently. --- .../agent-contract/memory-core.ts | 45 +++ .../core/config/defaults.ts | 1 - apps/memos-local-plugin/core/config/index.ts | 10 + apps/memos-local-plugin/core/config/schema.ts | 1 - apps/memos-local-plugin/core/config/writer.ts | 12 + .../core/embedding/embedder.ts | 17 +- .../core/embedding/normalize.ts | 18 +- .../core/pipeline/memory-core.ts | 324 +++++++++++++++++- .../core/reward/human-scorer.ts | 37 +- apps/memos-local-plugin/core/reward/reward.ts | 1 + .../server/routes/embeddings.ts | 23 ++ .../server/routes/registry.ts | 2 + .../tests/e2e/v7-full-chain.e2e.test.ts | 2 +- .../unit/adapters/hermes-persistence.test.ts | 3 +- .../unit/adapters/openclaw-bridge.test.ts | 2 +- .../tests/unit/adapters/openclaw-e2e.test.ts | 2 +- .../tests/unit/bridge/methods.test.ts | 54 +++ .../tests/unit/config/load.test.ts | 24 +- .../tests/unit/embedding/normalize.test.ts | 15 + .../tests/unit/pipeline/memory-core.test.ts | 60 +++- .../tests/unit/pipeline/orchestrator.test.ts | 2 +- .../tests/unit/reward/human-scorer.test.ts | 10 + .../tests/unit/server/http.test.ts | 80 +++++ .../viewer/src/stores/i18n.ts | 29 ++ .../viewer/src/views/ImportView.tsx | 79 +++++ .../viewer/src/views/SettingsView.tsx | 153 ++++++++- 26 files changed, 974 insertions(+), 32 deletions(-) create mode 100644 apps/memos-local-plugin/server/routes/embeddings.ts diff --git a/apps/memos-local-plugin/agent-contract/memory-core.ts b/apps/memos-local-plugin/agent-contract/memory-core.ts index 6c846fb08..aa19dd692 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; @@ -479,6 +516,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/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..1666fa93c 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"; @@ -138,6 +140,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 +284,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: { @@ -3158,8 +3165,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 +3292,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 +3796,8 @@ export function createMemoryCore( metrics, exportBundle, importBundle, + embeddingMaintenanceStats, + rebuildEmbeddings, subscribeEvents, getRecentEvents, subscribeLogs, @@ -3534,6 +3850,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/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/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/registry.ts b/apps/memos-local-plugin/server/routes/registry.ts index c04b535ca..3d975cbac 100644 --- a/apps/memos-local-plugin/server/routes/registry.ts +++ b/apps/memos-local-plugin/server/routes/registry.ts @@ -44,6 +44,7 @@ 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"; export interface RouteContext { req: IncomingMessage; @@ -177,6 +178,7 @@ export function buildRoutes( registerAuthRoutes(routes, deps, options); registerAdminRoutes(routes, deps, options); registerModelsRoutes(routes, deps); + registerEmbeddingRoutes(routes, deps); registerApiLogsRoutes(routes, deps); registerDiagRoutes(routes, deps); return routes; diff --git a/apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts b/apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts index 286ac6482..2c6d7c6a6 100644 --- a/apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts +++ b/apps/memos-local-plugin/tests/e2e/v7-full-chain.e2e.test.ts @@ -363,7 +363,7 @@ function buildPipeline( repos: db.repos, llm: opts.llm, reflectLlm: opts.llm, - embedder: fakeEmbedder({ dimensions: cfg.embedding.dimensions }), + embedder: fakeEmbedder({ dimensions: 384 }), log: rootLogger.child({ channel: "test.e2e.v7" }), now: () => NOW, }; diff --git a/apps/memos-local-plugin/tests/unit/adapters/hermes-persistence.test.ts b/apps/memos-local-plugin/tests/unit/adapters/hermes-persistence.test.ts index 04444708a..dc93b1062 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/hermes-persistence.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/hermes-persistence.test.ts @@ -81,7 +81,6 @@ function semanticFakeEmbedder(dims = 64): Embedder { function testConfig(): ResolvedConfig { const cfg = structuredClone(DEFAULT_CONFIG) as ResolvedConfig; - cfg.embedding.dimensions = 64; cfg.algorithm.capture.alphaScoring = false; cfg.algorithm.capture.synthReflections = false; cfg.algorithm.reward.llmScoring = false; @@ -122,7 +121,7 @@ function buildCore(root: string, version: string): { repos, llm: null, reflectLlm: null, - embedder: semanticFakeEmbedder(config.embedding.dimensions), + embedder: semanticFakeEmbedder(64), log: rootLogger.child({ channel: "test.adapters.hermes.persistence" }), namespace: { agentKind: "hermes", profileId: "default" }, now: () => 1_700_000_000_000, diff --git a/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts b/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts index c1b001d7d..2a37b8fc4 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts @@ -57,7 +57,7 @@ function buildDeps(h: TmpDbHandle): PipelineDeps { repos: h.repos, llm: null, reflectLlm: null, - embedder: fakeEmbedder({ dimensions: DEFAULT_CONFIG.embedding.dimensions }), + embedder: fakeEmbedder({ dimensions: 384 }), log: rootLogger.child({ channel: "test.adapters.openclaw" }), namespace: { agentKind: "openclaw", profileId: "main" }, now: () => 1_700_000_000_000, diff --git a/apps/memos-local-plugin/tests/unit/adapters/openclaw-e2e.test.ts b/apps/memos-local-plugin/tests/unit/adapters/openclaw-e2e.test.ts index c84865586..25f8e47d0 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/openclaw-e2e.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/openclaw-e2e.test.ts @@ -132,7 +132,7 @@ function silentLogger(): HostLogger { }; } -function buildDeps(h: TmpDbHandle, embedder: Embedder | null = semanticFakeEmbedder(DEFAULT_CONFIG.embedding.dimensions)): PipelineDeps { +function buildDeps(h: TmpDbHandle, embedder: Embedder | null = semanticFakeEmbedder(384)): PipelineDeps { return { agent: "openclaw", home: resolveHome("openclaw", "/tmp/memos-e2e-test"), diff --git a/apps/memos-local-plugin/tests/unit/bridge/methods.test.ts b/apps/memos-local-plugin/tests/unit/bridge/methods.test.ts index bfbc8a289..2274a704a 100644 --- a/apps/memos-local-plugin/tests/unit/bridge/methods.test.ts +++ b/apps/memos-local-plugin/tests/unit/bridge/methods.test.ts @@ -149,6 +149,60 @@ function stubCore(overrides: Partial = {}): MemoryCore { skills: [], })), importBundle: vi.fn(async () => ({ imported: 0, skipped: 0 })), + embeddingMaintenanceStats: vi.fn(async () => ({ + dimension: 0, + available: false, + totalSlots: 0, + ready: 0, + missing: 0, + dimMismatch: 0, + needsRepair: 0, + byKind: { + trace: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + policy: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + world_model: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + skill: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + }, + })), + rebuildEmbeddings: vi.fn(async () => ({ + mode: "repair", + processed: 0, + updated: 0, + failed: 0, + offset: 0, + nextOffset: 0, + done: true, + statsBefore: { + dimension: 0, + available: false, + totalSlots: 0, + ready: 0, + missing: 0, + dimMismatch: 0, + needsRepair: 0, + byKind: { + trace: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + policy: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + world_model: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + skill: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + }, + }, + statsAfter: { + dimension: 0, + available: false, + totalSlots: 0, + ready: 0, + missing: 0, + dimMismatch: 0, + needsRepair: 0, + byKind: { + trace: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + policy: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + world_model: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + skill: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + }, + }, + })), subscribeEvents: vi.fn(() => () => {}), getRecentEvents: vi.fn(() => []), subscribeLogs: vi.fn(() => () => {}), diff --git a/apps/memos-local-plugin/tests/unit/config/load.test.ts b/apps/memos-local-plugin/tests/unit/config/load.test.ts index 2fa2bf373..1aa4126fb 100644 --- a/apps/memos-local-plugin/tests/unit/config/load.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/load.test.ts @@ -18,7 +18,7 @@ describe("config/loadConfig", () => { expect(result.fromDisk).toBe(false); expect(result.warnings.some((w) => w.includes("not found"))).toBe(true); expect(result.config.viewer.port).toBe(DEFAULT_CONFIG.viewer.port); - expect(result.config.embedding.dimensions).toBe(DEFAULT_CONFIG.embedding.dimensions); + expect(result.config.embedding.provider).toBe(DEFAULT_CONFIG.embedding.provider); }); it("merges YAML over defaults and preserves unspecified branches", async () => { @@ -80,6 +80,28 @@ viewer: expect(cfg.llm.temperature).toBe(0.7); expect(cfg.algorithm.skill.minSupport).toBe(DEFAULT_CONFIG.algorithm.skill.minSupport); }); + + it("does not expose embedding dimensions as user config", () => { + const cfg = resolveConfig({ + embedding: { + provider: "openai_compatible", + model: "bge-m3", + endpoint: "https://example.test/v1", + }, + }); + expect("dimensions" in cfg.embedding).toBe(false); + }); + + it("ignores legacy/manual embedding dimensions", () => { + const cfg = resolveConfig({ + embedding: { + provider: "openai_compatible", + model: "bge-m3", + dimensions: 1024, + }, + }); + expect("dimensions" in cfg.embedding).toBe(false); + }); }); describe("config/loadConfig MEMOS_HOME override", () => { diff --git a/apps/memos-local-plugin/tests/unit/embedding/normalize.test.ts b/apps/memos-local-plugin/tests/unit/embedding/normalize.test.ts index 0a5397ab0..ff2b901a8 100644 --- a/apps/memos-local-plugin/tests/unit/embedding/normalize.test.ts +++ b/apps/memos-local-plugin/tests/unit/embedding/normalize.test.ts @@ -30,6 +30,11 @@ describe("embedding/normalize", () => { expect(enforceDim(v, 3, { provider: "x", model: "y" })).toEqual([1, 2, 3]); }); + it("enforceDim preserves provider length in auto mode", () => { + const v = [1, 2, 3, 4, 5]; + expect(enforceDim(v, 0, { provider: "x", model: "y" })).toBe(v); + }); + it("enforceDim throws MemosError when provider returns too few dims", () => { try { enforceDim([1, 2], 4, { provider: "x", model: "y" }); @@ -78,6 +83,16 @@ describe("embedding/normalize", () => { expect(out[1]![0]).toBeCloseTo(1.0, 5); }); + it("postProcess infers provider dimensions in auto mode", () => { + const out = postProcess([[1, 0, 0, 0]], { + dimensions: 0, + provider: "p", + model: "m", + normalize: true, + }); + expect(out[0]).toHaveLength(4); + }); + it("postProcess can skip normalization", () => { const out = postProcess([[2, 0]], { dimensions: 2, diff --git a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts index 70a084ca6..440b23976 100644 --- a/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts +++ b/apps/memos-local-plugin/tests/unit/pipeline/memory-core.test.ts @@ -28,6 +28,7 @@ import type { MemosError } from "../../../agent-contract/errors.js"; let db: TmpDbHandle | null = null; let pipeline: PipelineHandle | null = null; let core: MemoryCore | null = null; +const TEST_EMBED_DIMENSIONS = 384; function buildDeps(h: TmpDbHandle): PipelineDeps { return { @@ -38,7 +39,7 @@ function buildDeps(h: TmpDbHandle): PipelineDeps { repos: h.repos, llm: null, reflectLlm: null, - embedder: fakeEmbedder({ dimensions: DEFAULT_CONFIG.embedding.dimensions }), + embedder: fakeEmbedder({ dimensions: TEST_EMBED_DIMENSIONS }), log: rootLogger.child({ channel: "test.memory-core" }), namespace: { agentKind: "openclaw", profileId: "main" }, now: () => 1_700_000_000_000, @@ -94,7 +95,7 @@ describe("MemoryCore façade", () => { expect(h.agent).toBe("openclaw"); expect(h.paths.db.endsWith(".db") || h.paths.db.length > 0).toBe(true); expect(h.embedder.available).toBe(true); - expect(h.embedder.dim).toBe(DEFAULT_CONFIG.embedding.dimensions); + expect(h.embedder.dim).toBe(TEST_EMBED_DIMENSIONS); expect(h.llm.available).toBe(false); }); @@ -111,6 +112,59 @@ describe("MemoryCore façade", () => { await core.closeSession(sid); }); + it("repairs missing and wrong-dimension imported trace embeddings", async () => { + pipeline = createPipeline(buildDeps(db!)); + core = createMemoryCore( + pipeline, + resolveHome("openclaw", "/tmp/memos-mc-test"), + "test", + ); + await core.init(); + + await core.importBundle({ + version: 1, + traces: [ + { + id: "tr_imported", + episodeId: "ep_imported", + sessionId: "se_imported", + ts: 1_700_000_000_000, + userText: "imported memory text", + agentText: "assistant answer", + summary: "imported memory summary", + toolCalls: [], + value: 0, + alpha: 0, + priority: 0, + turnId: 1_700_000_000_000, + }, + ], + }); + + const before = await core.embeddingMaintenanceStats(); + expect(before.byKind.trace.missing).toBe(2); + + const repaired = await core.rebuildEmbeddings({ mode: "repair", limit: 10 }); + expect(repaired.updated).toBe(2); + expect(repaired.statsAfter.needsRepair).toBe(0); + let row = db!.repos.traces.getById("tr_imported" as never); + expect(row?.vecSummary?.length).toBe(TEST_EMBED_DIMENSIONS); + expect(row?.vecAction?.length).toBe(TEST_EMBED_DIMENSIONS); + + db!.repos.traces.updateVector( + "tr_imported" as never, + "vecSummary", + new Float32Array([1]), + ); + const mismatch = await core.embeddingMaintenanceStats(); + expect(mismatch.dimMismatch).toBe(1); + + const fixed = await core.rebuildEmbeddings({ mode: "repair", limit: 10 }); + expect(fixed.statsAfter.dimMismatch).toBe(0); + row = db!.repos.traces.getById("tr_imported" as never); + expect(row?.vecSummary?.length).toBe(TEST_EMBED_DIMENSIONS); + }); + it("onTurnStart returns a RetrievalResultDTO with tier latencies", async () => { pipeline = createPipeline(buildDeps(db!)); core = createMemoryCore( @@ -572,7 +626,7 @@ describe("MemoryCore façade", () => { const scored = db!.repos.traces.getById(end.traceId as never)!; expect(scored.value).toBeCloseTo(1 / 3); expect(scored.rHuman).toBeCloseTo(1 / 3); - expect(scored.priority).toBe(1); + expect(scored.priority).toBeCloseTo(1 / 3); }); it("submitFeedback rejects unknown trace ids before SQLite FK failure", async () => { diff --git a/apps/memos-local-plugin/tests/unit/pipeline/orchestrator.test.ts b/apps/memos-local-plugin/tests/unit/pipeline/orchestrator.test.ts index ad2d38886..7a0e195d0 100644 --- a/apps/memos-local-plugin/tests/unit/pipeline/orchestrator.test.ts +++ b/apps/memos-local-plugin/tests/unit/pipeline/orchestrator.test.ts @@ -33,7 +33,7 @@ function buildDeps(h: TmpDbHandle): PipelineDeps { repos: h.repos, llm: null, reflectLlm: null, - embedder: fakeEmbedder({ dimensions: DEFAULT_CONFIG.embedding.dimensions }), + embedder: fakeEmbedder({ dimensions: 384 }), log: rootLogger.child({ channel: "test.pipeline" }), namespace: { agentKind: "openclaw", profileId: "main" }, now: () => 1_700_000_000_000, diff --git a/apps/memos-local-plugin/tests/unit/reward/human-scorer.test.ts b/apps/memos-local-plugin/tests/unit/reward/human-scorer.test.ts index e156d7888..1a4805eeb 100644 --- a/apps/memos-local-plugin/tests/unit/reward/human-scorer.test.ts +++ b/apps/memos-local-plugin/tests/unit/reward/human-scorer.test.ts @@ -51,6 +51,16 @@ describe("reward/human-scorer", () => { expect(h.rHuman).toBeLessThan(0); }); + it("heuristic: aggregates multiple explicit corrections by magnitude", () => { + const h = heuristicScore([ + makeFeedback({ id: "fb_pos" as never, polarity: "positive", magnitude: 1 }), + makeFeedback({ id: "fb_neg" as never, polarity: "negative", magnitude: 0.5 }), + ]); + expect(h.source).toBe("explicit"); + expect(h.rHuman).toBeCloseTo(1 / 3); + expect(h.axes.userSatisfaction).toBeCloseTo(1 / 3); + }); + it("LLM mode: happy path, uses the LLM and reports llm source", async () => { const llm = fakeLlm({ completeJson: { diff --git a/apps/memos-local-plugin/tests/unit/server/http.test.ts b/apps/memos-local-plugin/tests/unit/server/http.test.ts index 369830656..7a52f2a89 100644 --- a/apps/memos-local-plugin/tests/unit/server/http.test.ts +++ b/apps/memos-local-plugin/tests/unit/server/http.test.ts @@ -149,6 +149,60 @@ function stubCore(): MemoryCore { skills: [], })), importBundle: vi.fn(async () => ({ imported: 0, skipped: 0 })), + embeddingMaintenanceStats: vi.fn(async () => ({ + dimension: 8, + available: true, + totalSlots: 2, + ready: 1, + missing: 1, + dimMismatch: 0, + needsRepair: 1, + byKind: { + trace: { totalSlots: 2, ready: 1, missing: 1, dimMismatch: 0, needsRepair: 1 }, + policy: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + world_model: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + skill: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + }, + })), + rebuildEmbeddings: vi.fn(async () => ({ + mode: "repair", + processed: 1, + updated: 1, + failed: 0, + offset: 0, + nextOffset: 0, + done: true, + statsBefore: { + dimension: 8, + available: true, + totalSlots: 2, + ready: 1, + missing: 1, + dimMismatch: 0, + needsRepair: 1, + byKind: { + trace: { totalSlots: 2, ready: 1, missing: 1, dimMismatch: 0, needsRepair: 1 }, + policy: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + world_model: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + skill: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + }, + }, + statsAfter: { + dimension: 8, + available: true, + totalSlots: 2, + ready: 2, + missing: 0, + dimMismatch: 0, + needsRepair: 0, + byKind: { + trace: { totalSlots: 2, ready: 2, missing: 0, dimMismatch: 0, needsRepair: 0 }, + policy: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + world_model: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + skill: { totalSlots: 0, ready: 0, missing: 0, dimMismatch: 0, needsRepair: 0 }, + }, + }, + })), subscribeEvents: vi.fn(() => () => {}), subscribeLogs: vi.fn(() => () => {}), forwardLog: vi.fn(), @@ -431,6 +485,32 @@ describe("HTTP server — REST routes", () => { expect(body.imported).toBe(0); }); + it("GET /api/v1/embeddings/maintenance returns vector health", async () => { + const r = await fetch(`${handle.url}/api/v1/embeddings/maintenance`); + expect(r.status).toBe(200); + const body = (await r.json()) as { totalSlots: number; needsRepair: number }; + expect(body.totalSlots).toBe(2); + expect(body.needsRepair).toBe(1); + expect(core.embeddingMaintenanceStats).toHaveBeenCalled(); + }); + + it("POST /api/v1/embeddings/rebuild runs a maintenance batch", async () => { + const r = await fetch(`${handle.url}/api/v1/embeddings/rebuild`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "rebuild", offset: 10, limit: 25 }), + }); + expect(r.status).toBe(200); + const body = (await r.json()) as { updated: number; done: boolean }; + expect(body.updated).toBe(1); + expect(body.done).toBe(true); + expect(core.rebuildEmbeddings).toHaveBeenCalledWith({ + mode: "rebuild", + offset: 10, + limit: 25, + }); + }); + it("imports Hermes native MEMORY.md in batches", async () => { const oldHome = process.env.HOME; const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "memos-hermes-native-")); diff --git a/apps/memos-local-plugin/viewer/src/stores/i18n.ts b/apps/memos-local-plugin/viewer/src/stores/i18n.ts index b25cef3dc..41a4976b1 100644 --- a/apps/memos-local-plugin/viewer/src/stores/i18n.ts +++ b/apps/memos-local-plugin/viewer/src/stores/i18n.ts @@ -780,6 +780,11 @@ const en = { "import.native.stat.imported": "Imported", "import.native.stat.skipped": "Skipped", "import.native.stat.processed": "Processed", + "import.embeddingRepair.btn": "Repair embeddings", + "import.embeddingRepair.running": "Repairing missing embeddings…", + "import.embeddingRepair.progress": + "Updated {updated}, failed {failed}, remaining {remaining}.", + "import.embeddingRepair.done": "Embedding repair complete: updated {updated}, failed {failed}.", // Admin. "admin.title": "Team administration", @@ -811,6 +816,17 @@ const en = { "settings.embedding.desc": "Vector embedding model used by retrieval and deduplication.", "settings.embedding.localHint": "Currently using built-in MiniLM-L6-v2 (384-dim, ~23 MB). Select another provider for better retrieval accuracy.", + "settings.embedding.maintenance.title": "Embedding maintenance", + "settings.embedding.maintenance.stats": + "Ready {ready}/{total}; missing {missing}; dimension mismatch {mismatch}; current dim {dim}.", + "settings.embedding.maintenance.unavailable": + "Configure an embedding provider before repairing or rebuilding vectors.", + "settings.embedding.repair": "Repair missing/mismatched", + "settings.embedding.rebuild": "Rebuild all vectors", + "settings.embedding.rebuild.running": "Rebuilding embeddings…", + "settings.embedding.rebuild.progress": + "Updated {updated}, failed {failed}, remaining repairs {remaining}.", + "settings.embedding.rebuild.done": "Embedding rebuild complete: updated {updated}, failed {failed}.", "settings.summarizer.title": "Summarizer", "settings.summarizer.desc": "Model that turns your conversations into short task summaries and the takeaways the agent keeps.", @@ -1563,6 +1579,10 @@ const zh: Record = { "import.native.stat.imported": "已导入", "import.native.stat.skipped": "跳过", "import.native.stat.processed": "已处理", + "import.embeddingRepair.btn": "修复向量", + "import.embeddingRepair.running": "正在修复缺失向量…", + "import.embeddingRepair.progress": "已更新 {updated},失败 {failed},剩余 {remaining}。", + "import.embeddingRepair.done": "向量修复完成:已更新 {updated},失败 {failed}。", "admin.title": "团队管理", "admin.subtitle": "管理团队分享的用户、群组与待审批。", @@ -1589,6 +1609,15 @@ const zh: Record = { "settings.embedding.desc": "用于记忆检索与去重的向量嵌入模型。", "settings.embedding.localHint": "当前使用内置 MiniLM-L6-v2(384 维,约 23 MB)。选择其他 Provider 可获得更精准的检索效果。", + "settings.embedding.maintenance.title": "向量维护", + "settings.embedding.maintenance.stats": + "可用 {ready}/{total};缺失 {missing};维度不匹配 {mismatch};当前维度 {dim}。", + "settings.embedding.maintenance.unavailable": "请先配置嵌入模型,再修复或重建向量。", + "settings.embedding.repair": "修复缺失/错维", + "settings.embedding.rebuild": "全量重建向量", + "settings.embedding.rebuild.running": "正在重建向量…", + "settings.embedding.rebuild.progress": "已更新 {updated},失败 {failed},待修复 {remaining}。", + "settings.embedding.rebuild.done": "向量重建完成:已更新 {updated},失败 {failed}。", "settings.summarizer.title": "摘要模型", "settings.summarizer.desc": "把原始对话压缩成任务摘要和要点的模型。", "settings.summarizer.inherit": diff --git a/apps/memos-local-plugin/viewer/src/views/ImportView.tsx b/apps/memos-local-plugin/viewer/src/views/ImportView.tsx index c829c8302..e27727dc3 100644 --- a/apps/memos-local-plugin/viewer/src/views/ImportView.tsx +++ b/apps/memos-local-plugin/viewer/src/views/ImportView.tsx @@ -43,6 +43,14 @@ interface NativeImportBatchResult { done: boolean; } +interface EmbeddingRepairResult { + updated: number; + failed: number; + done: boolean; + statsAfter: { needsRepair: number }; + error?: string; +} + const NATIVE_IMPORT_CONFIGS = { hermes: { endpoint: "/api/v1/import/hermes-native", @@ -220,6 +228,7 @@ function ImportCard() { {status.text} )} + {status?.kind === "ok" && } ); } @@ -410,6 +419,7 @@ function NativeImportCard({ kind }: { kind: NativeImportKind }) { {status.text} )} + {status?.kind === "ok" && } ); } @@ -572,6 +582,75 @@ function MigrateCard() { {result} )} + {result?.startsWith("Imported ") && } ); } + +function EmbeddingRepairButton() { + const [running, setRunning] = useState(false); + const [status, setStatus] = useState<{ kind: "ok" | "error" | "muted"; text: string } | null>(null); + + const run = async () => { + setRunning(true); + setStatus({ kind: "muted", text: t("import.embeddingRepair.running") }); + let updated = 0; + let failed = 0; + try { + for (;;) { + const r = await api.post( + "/api/v1/embeddings/rebuild", + { mode: "repair", limit: 100 }, + ); + updated += r.updated; + failed += r.failed; + if (r.error) { + setStatus({ kind: "error", text: r.error }); + break; + } + if (r.done) { + setStatus({ + kind: failed > 0 ? "error" : "ok", + text: t("import.embeddingRepair.done", { updated, failed }), + }); + break; + } + setStatus({ + kind: "muted", + text: t("import.embeddingRepair.progress", { + updated, + failed, + remaining: r.statsAfter.needsRepair, + }), + }); + } + } catch (err) { + setStatus({ kind: "error", text: (err as Error).message }); + } finally { + setRunning(false); + } + }; + + return ( +
+ + {status && ( + + {status.text} + + )} +
+ ); +} diff --git a/apps/memos-local-plugin/viewer/src/views/SettingsView.tsx b/apps/memos-local-plugin/viewer/src/views/SettingsView.tsx index 9cc107208..20993af62 100644 --- a/apps/memos-local-plugin/viewer/src/views/SettingsView.tsx +++ b/apps/memos-local-plugin/viewer/src/views/SettingsView.tsx @@ -33,7 +33,7 @@ interface ProviderBlock { interface ResolvedConfig { version?: number; viewer?: { port: number; bindHost?: string }; - embedding?: ProviderBlock & { dimensions?: number }; + embedding?: ProviderBlock; llm?: ProviderBlock; skillEvolver?: ProviderBlock; algorithm?: unknown; @@ -49,6 +49,28 @@ interface ResolvedConfig { logging?: { level?: string; detailedView?: boolean }; } +interface EmbeddingMaintenanceStats { + dimension: number; + available: boolean; + totalSlots: number; + ready: number; + missing: number; + dimMismatch: number; + needsRepair: number; +} + +interface EmbeddingMaintenanceRunResult { + mode: "repair" | "rebuild"; + processed: number; + updated: number; + failed: number; + offset: number; + nextOffset: number; + done: boolean; + statsAfter: EmbeddingMaintenanceStats; + error?: string; +} + const EMBEDDING_PROVIDERS = [ "local", "openai_compatible", @@ -226,10 +248,10 @@ function ModelsTab({ onPatchLlm, onPatchSkillEvolver, }: { - embedding: ProviderBlock & { dimensions?: number }; + embedding: ProviderBlock; llm: ProviderBlock; skillEvolver: ProviderBlock; - onPatchEmbedding: (p: Partial) => void; + onPatchEmbedding: (p: Partial) => void; onPatchLlm: (p: Partial) => void; onPatchSkillEvolver: (p: Partial) => void; }) { @@ -504,10 +526,135 @@ function ModelCard({ )} )} + + {type === "embedding" && } ); } +function EmbeddingMaintenancePanel() { + const [stats, setStats] = useState(null); + const [running, setRunning] = useState<"repair" | "rebuild" | null>(null); + const [status, setStatus] = useState<{ kind: "ok" | "error" | "muted"; text: string } | null>(null); + + const refresh = async () => { + try { + setStats(await api.get("/api/v1/embeddings/maintenance")); + } catch (err) { + setStatus({ kind: "error", text: (err as Error).message }); + } + }; + + useEffect(() => { + void refresh(); + }, []); + + const run = async (mode: "repair" | "rebuild") => { + setRunning(mode); + setStatus({ kind: "muted", text: t("settings.embedding.rebuild.running") }); + let offset = 0; + let updated = 0; + let failed = 0; + try { + for (;;) { + const r = await api.post( + "/api/v1/embeddings/rebuild", + { mode, offset, limit: 100 }, + ); + updated += r.updated; + failed += r.failed; + offset = r.nextOffset; + setStats(r.statsAfter); + setStatus({ + kind: "muted", + text: t("settings.embedding.rebuild.progress", { + updated, + failed, + remaining: r.statsAfter.needsRepair, + }), + }); + if (r.error) { + setStatus({ kind: "error", text: r.error }); + break; + } + if (r.done) { + setStatus({ + kind: failed > 0 ? "error" : "ok", + text: t("settings.embedding.rebuild.done", { updated, failed }), + }); + break; + } + } + } catch (err) { + setStatus({ kind: "error", text: (err as Error).message }); + } finally { + setRunning(null); + void refresh(); + } + }; + + const healthText = stats + ? t("settings.embedding.maintenance.stats", { + ready: stats.ready, + total: stats.totalSlots, + missing: stats.missing, + mismatch: stats.dimMismatch, + dim: stats.dimension, + }) + : t("common.loading"); + const disabled = !!running || stats?.available === false; + + return ( +
+
+
+
+ {t("settings.embedding.maintenance.title")} +
+
+ {healthText} +
+
+
+ + + +
+
+ {stats?.available === false && ( +
+ {t("settings.embedding.maintenance.unavailable")} +
+ )} + {status && ( +
+ {status.text} +
+ )} +
+ ); +} + // ─── Hub tab ───────────────────────────────────────────────────────────── function HubTab({ From 35f6c745207e29423c06802b096281fc453c9734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=81=E5=9C=A8=E5=90=B5=E7=9D=80=E5=90=83=E7=B3=96?= Date: Tue, 12 May 2026 11:34:42 +0800 Subject: [PATCH 05/20] feat(memos-local-plugin): improve Windows installer with auto-detection and interactive UI - Refactor `install.ps1` to auto-detect OpenClaw/Hermes and add an interactive menu. - Use `npm pack` and tarball extraction for robust package deployment. - Automate configuration patching for `openclaw.json` and `config.yaml`. - Update `postinstall.cjs` banner to drop the hardcoded `-Agent` argument. --- apps/memos-local-plugin/install.ps1 | 534 +++++++++++++++--- .../scripts/postinstall.cjs | 2 +- 2 files changed, 454 insertions(+), 82 deletions(-) diff --git a/apps/memos-local-plugin/install.ps1 b/apps/memos-local-plugin/install.ps1 index cc2ee5989..f4cfdee9f 100644 --- a/apps/memos-local-plugin/install.ps1 +++ b/apps/memos-local-plugin/install.ps1 @@ -3,101 +3,473 @@ 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:USERPROFILE\.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 (~/.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 ~/.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 + } + 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:USERPROFILE ".hermes\memos-plugin" + $HomeDir = $Prefix + $ConfigFile = Join-Path $env:USERPROFILE ".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:USERPROFILE ".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:USERPROFILE ".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) 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') 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" + Start-Process -FilePath $TsxBin -ArgumentList "$BridgeCts --agent=hermes --daemon" -WindowStyle Hidden -RedirectStandardOutput $DaemonLog -RedirectStandardError $DaemonLog + + 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/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.", "", From 08513dcb52aac2e47ae9628ef34178abd64b5148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=81=E5=9C=A8=E5=90=B5=E7=9D=80=E5=90=83=E7=B3=96?= Date: Tue, 12 May 2026 14:37:23 +0800 Subject: [PATCH 06/20] fix(memos-local-plugin): fix hermes install path, encoding and log redirect on win --- apps/memos-local-plugin/install.ps1 | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/memos-local-plugin/install.ps1 b/apps/memos-local-plugin/install.ps1 index f4cfdee9f..7b89a50b1 100644 --- a/apps/memos-local-plugin/install.ps1 +++ b/apps/memos-local-plugin/install.ps1 @@ -58,13 +58,13 @@ try { # Agent detection $HasOpenClaw = Test-Path "$env:USERPROFILE\.openclaw" -$HasHermes = Test-Path "$env:USERPROFILE\.hermes" +$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 (~/.hermes)" -ForegroundColor Green } +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?" @@ -88,7 +88,7 @@ switch ($Choice) { } if ($AgentSelection -eq "auto") { - if (-not $HasOpenClaw -and -not $HasHermes) { Stop-Die "Neither ~/.openclaw nor ~/.hermes exists. Install one first." } + 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" } @@ -379,9 +379,9 @@ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); function Install-Hermes { Write-Host "`n=== Hermes Install ===" -ForegroundColor Cyan - $Prefix = Join-Path $env:USERPROFILE ".hermes\memos-plugin" + $Prefix = Join-Path $env:LOCALAPPDATA "hermes\memos-plugin" $HomeDir = $Prefix - $ConfigFile = Join-Path $env:USERPROFILE ".hermes\config.yaml" + $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 @@ -393,7 +393,7 @@ function Install-Hermes { Set-Content -Path (Join-Path $AdapterDir "bridge_path.txt") -Value (Join-Path $Prefix "bridge.cts") -Encoding UTF8 $PythonBin = "" - $VenvPy = Join-Path $env:USERPROFILE ".hermes\hermes-agent\venv\Scripts\python.exe" + $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 } @@ -401,7 +401,7 @@ function Install-Hermes { Write-Success "Python: $PythonBin" $PluginDir = "" - $DefaultPluginDir = Join-Path $env:USERPROFILE ".hermes\hermes-agent\plugins\memory" + $DefaultPluginDir = Join-Path $env:LOCALAPPDATA "hermes\hermes-agent\plugins\memory" if (Test-Path $DefaultPluginDir) { $PluginDir = $DefaultPluginDir } else { # Fallback to python detection @@ -424,14 +424,14 @@ function Install-Hermes { $PyScript = @" import sys, yaml path = sys.argv[1] -with open(path) as f: cfg = yaml.safe_load(f) or {} +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') as f: +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" @@ -455,7 +455,8 @@ memory: if ((Test-Path $TsxBin) -and (Test-Path $BridgeCts)) { $DaemonLog = Join-Path $Prefix "logs\daemon-start.log" - Start-Process -FilePath $TsxBin -ArgumentList "$BridgeCts --agent=hermes --daemon" -WindowStyle Hidden -RedirectStandardOutput $DaemonLog -RedirectStandardError $DaemonLog + $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" From d4efb8105249101c18bf52058d888a1a8451f2fb Mon Sep 17 00:00:00 2001 From: jiang Date: Tue, 12 May 2026 15:03:43 +0800 Subject: [PATCH 07/20] fix(memos-local-plugin): preserve OpenClaw memory context Keep OpenClaw tool observations available for memory capture and strip retrieval metrics from episode prompt injection so recalled context remains answer-focused. --- .../adapters/openclaw/bridge.ts | 250 ++++++++++++++++-- .../core/retrieval/ALGORITHMS.md | 2 +- .../core/retrieval/injector.ts | 15 +- .../core/retrieval/tier2-trace.ts | 6 +- apps/memos-local-plugin/install.sh | 13 +- .../unit/adapters/openclaw-bridge.test.ts | 111 ++++++++ .../tests/unit/install/install-sh.test.ts | 2 +- .../tests/unit/retrieval/injector.test.ts | 24 ++ .../tests/unit/retrieval/tier2.test.ts | 3 +- 9 files changed, 387 insertions(+), 39 deletions(-) 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/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/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/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/install.sh b/apps/memos-local-plugin/install.sh index e8e49f25a..2061ab97e 100755 --- a/apps/memos-local-plugin/install.sh +++ b/apps/memos-local-plugin/install.sh @@ -530,9 +530,16 @@ if (!config.plugins.entries[pluginId] || typeof config.plugins.entries[pluginId] config.plugins.entries[pluginId] = {}; } config.plugins.entries[pluginId].enabled = true; -// Do not write hook capability flags here. Current OpenClaw validates -// plugin entries strictly and rejects unknown hook keys. -if (config.plugins.entries[pluginId].hooks) delete config.plugins.entries[pluginId].hooks; +if ( + !config.plugins.entries[pluginId].hooks || + typeof config.plugins.entries[pluginId].hooks !== 'object' || + Array.isArray(config.plugins.entries[pluginId].hooks) +) { + config.plugins.entries[pluginId].hooks = {}; +} +// OpenClaw gates transcript-bearing hooks for non-bundled plugins. Without +// this, `agent_end` is blocked, so turns are recalled but never captured. +config.plugins.entries[pluginId].hooks.allowConversationAccess = true; if (!config.plugins.installs || typeof config.plugins.installs !== 'object') config.plugins.installs = {}; const installsEntry = { diff --git a/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts b/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts index 2a37b8fc4..d6651dcd1 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts @@ -157,6 +157,39 @@ describe("flattenMessages", () => { expect(toolResult.isError).toBe(false); }); + it("accepts OpenClaw lowercase/alias tool-call content blocks", () => { + const flat = flattenMessages([ + { role: "user", content: "read two files" }, + { + role: "assistant", + toolCallId: "fallback-id", + content: [ + { type: "toolcall", id: "call_1", name: "read", arguments: { path: "a.md" } }, + { type: "tool_use", toolUseId: "call_2", name: "read", input: { path: "b.md" } }, + { type: "functionCall", name: "exec", args: { command: "pwd" } }, + ], + }, + ]); + + const calls = flat.filter((m) => m.role === "tool_call"); + expect(calls).toHaveLength(3); + expect(calls[0]).toMatchObject({ + toolCallId: "call_1", + toolName: "read", + toolInput: { path: "a.md" }, + }); + expect(calls[1]).toMatchObject({ + toolCallId: "call_2", + toolName: "read", + toolInput: { path: "b.md" }, + }); + expect(calls[2]).toMatchObject({ + toolCallId: "fallback-id", + toolName: "exec", + toolInput: { command: "pwd" }, + }); + }); + it("accepts OpenAI-legacy assistant.tool_calls + role: 'tool' for results", () => { // Older bridges and our own historical tests use this shape — keep // it working so we don't regress on multi-host setups. @@ -298,6 +331,7 @@ describe("extractTurn", () => { expect(turn!.toolCalls[0].name).toBe("sh"); expect(turn!.toolCalls[0].input).toEqual({ cmd: "ls" }); expect(turn!.toolCalls[0].output).toContain("a.txt"); + expect(turn!.toolCalls[0].toolCallId).toBe("c1"); expect(turn!.toolCalls[0].thinkingBefore).toBe("running ls"); }); @@ -555,6 +589,30 @@ describe("extractTurn", () => { expect(turn!.toolCalls[0].output).toBeUndefined(); }); + it("keeps parallel same-name tool calls when ids are missing", () => { + const flat = flattenMessages([ + { role: "user", content: "read both files" }, + { + role: "assistant", + content: [ + { type: "toolcall", name: "read", arguments: { path: "a.md" } }, + { type: "toolcall", name: "read", arguments: { path: "b.md" } }, + ], + }, + { role: "toolResult", toolName: "read", content: "A" }, + { role: "toolResult", toolName: "read", content: "B" }, + { role: "assistant", content: [{ type: "text", text: "done" }] }, + ]); + + const turn = extractTurn(flat, 0); + expect(turn!.toolCalls).toHaveLength(2); + expect(turn!.toolCalls.map((tc) => tc.input)).toEqual([ + { path: "a.md" }, + { path: "b.md" }, + ]); + expect(turn!.toolCalls.map((tc) => tc.output)).toEqual(["A", "B"]); + }); + it("returns null when the list has no user message", () => { const flat = flattenMessages([ { role: "assistant", content: [{ type: "text", text: "nothing yet" }] }, @@ -958,6 +1016,59 @@ describe("createOpenClawBridge", () => { expect(events).not.toContain("episode.closed"); }); + it("uses tool hook observations when agent_end transcript omits tool blocks", async () => { + const mc = buildCore(); + await mc.init(); + + const bridge = createOpenClawBridge({ + agent: "openclaw", + core: mc, + log: silentLogger(), + }); + const runCtx = hookCtx({ sessionKey: "s-cached-tools", runId: "run-cached-tools" }); + const toolCtx: PluginHookToolContext = { + toolName: "sh", + toolCallId: "call_cached", + agentId: "main", + sessionKey: "s-cached-tools", + sessionId: "host-s-cached-tools", + runId: "run-cached-tools", + }; + + await bridge.handleBeforePrompt({ prompt: "deploy with hooks", messages: [] }, runCtx); + bridge.handleBeforeToolCall( + { toolName: "sh", params: { cmd: "deploy" }, toolCallId: "call_cached" }, + toolCtx, + ); + await bridge.handleAfterToolCall( + { + toolName: "sh", + params: { cmd: "deploy" }, + toolCallId: "call_cached", + result: "ok", + durationMs: 25, + }, + toolCtx, + ); + await bridge.handleAgentEnd( + { + success: true, + messages: [ + { role: "user", content: "deploy with hooks" }, + { role: "assistant", content: [{ type: "text", text: "done" }] }, + ], + durationMs: 50, + }, + runCtx, + ); + await (pipeline as PipelineHandle).flush(); + + const traces = await mc.listTraces({ groupByTurn: true }); + expect(traces).toHaveLength(2); + expect(traces.some((tr) => tr.toolCalls?.[0]?.name === "sh")).toBe(true); + expect(traces.some((tr) => tr.agentText === "done")).toBe(true); + }); + it("handleAgentEnd works even when before_prompt_build was never called (lazy episode open)", async () => { // V7 §0.1 regression: some hosts skip `before_prompt_build` (e.g. // OpenClaw's `/new` flow replays an old session without re-building diff --git a/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts b/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts index 6776e07c7..0ddf1f9d2 100644 --- a/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts +++ b/apps/memos-local-plugin/tests/unit/install/install-sh.test.ts @@ -77,7 +77,7 @@ describe("install.sh — CLI surface", () => { expect(script).toContain('"extensions": ["${OPENCLAW_RUNTIME_ENTRY}"]'); expect(script).toContain('"contracts": {'); expect(script).toContain('"memory_search"'); - expect(script).toContain("if (config.plugins.entries[pluginId].hooks) delete config.plugins.entries[pluginId].hooks"); + expect(script).toContain("config.plugins.entries[pluginId].hooks.allowConversationAccess = true"); expect(script).not.toContain('"extensions": ["./adapters/openclaw/index.ts"]'); }); diff --git a/apps/memos-local-plugin/tests/unit/retrieval/injector.test.ts b/apps/memos-local-plugin/tests/unit/retrieval/injector.test.ts index b48d81fc2..9a1648e6b 100644 --- a/apps/memos-local-plugin/tests/unit/retrieval/injector.test.ts +++ b/apps/memos-local-plugin/tests/unit/retrieval/injector.test.ts @@ -209,6 +209,30 @@ describe("retrieval/injector", () => { expect(packet.rendered).not.toContain('refId="sA"'); }); + it("strips episode retrieval metrics from prompt-facing memory text", () => { + const noisyEpisode = episode("e_noisy"); + noisyEpisode.summary = [ + "episode 3 steps · best V=0.82 · goal-sim=0.64", + "step 1 (V=0.12)", + " user: install failed", + "step 2 (V=0.82)", + " summary: install libpq-dev before retrying pip", + ].join("\n"); + + const { packet } = toPacket({ + ranked: [rc(noisyEpisode)], + reason: "turn_start", + tierLatencyMs: { tier1: 0, tier2: 0, tier3: 0 }, + now: NOW as never, + sessionId: "sess_episode_metrics" as never, + episodeId: "ep_episode_metrics" as never, + }); + + expect(packet.rendered).toContain("Past similar episode"); + expect(packet.rendered).toContain("install libpq-dev"); + expect(packet.rendered).not.toMatch(/best V|goal-sim|V=/); + }); + it("default skill rendering is summary mode (descriptor + skill_get hint, no full guide)", () => { // Multi-section guide: blank-line-separated paragraphs. Summary // mode must keep only the first paragraph and drop the procedure. diff --git a/apps/memos-local-plugin/tests/unit/retrieval/tier2.test.ts b/apps/memos-local-plugin/tests/unit/retrieval/tier2.test.ts index b8ae8b4f6..eb90e6917 100644 --- a/apps/memos-local-plugin/tests/unit/retrieval/tier2.test.ts +++ b/apps/memos-local-plugin/tests/unit/retrieval/tier2.test.ts @@ -138,7 +138,8 @@ describe("retrieval/tier2 (with real sqlite)", () => { ); if (out.traces.length >= 2) { expect(out.episodes.length).toBeGreaterThanOrEqual(1); - expect(out.episodes[0]!.summary).toContain("episode"); + expect(out.episodes[0]!.summary).toContain("Past similar episode"); + expect(out.episodes[0]!.summary).not.toMatch(/best V|goal-sim|V=/); } }); }); From fe764791ad9b953e962ee9858993e654f4716cb6 Mon Sep 17 00:00:00 2001 From: Matthew Date: Tue, 12 May 2026 15:47:02 +0800 Subject: [PATCH 08/20] fix(memos-local-plugin): harden multi-agent memory handling Co-authored-by: Cursor --- .../hermes/memos_provider/__init__.py | 109 ++++++-- .../agent-contract/memory-core.ts | 38 ++- .../core/pipeline/memory-core.ts | 167 +++++++++--- .../core/pipeline/retrieval-repos.ts | 27 ++ .../core/retrieval/decision-guidance.ts | 109 ++++++-- .../core/retrieval/tier1-skill.ts | 33 +++ .../core/retrieval/types.ts | 3 + .../core/session/episode-manager.ts | 20 ++ .../core/session/manager.ts | 22 ++ .../core/session/persistence.ts | 4 + .../core/storage/repos/episodes.ts | 7 + .../core/storage/repos/traces.ts | 32 +++ apps/memos-local-plugin/core/storage/types.ts | 2 + apps/memos-local-plugin/install.sh | 248 ++++++++++++++++-- apps/memos-local-plugin/server/routes/diag.ts | 20 +- .../server/routes/memory.ts | 4 +- .../server/routes/policies.ts | 54 +++- .../server/routes/session.ts | 12 +- .../memos-local-plugin/server/routes/skill.ts | 38 ++- .../memos-local-plugin/server/routes/trace.ts | 20 +- .../unit/retrieval/decision-guidance.test.ts | 109 ++++++++ .../tests/unit/retrieval/tier1.test.ts | 38 ++- .../web/src/components/NamespaceSelect.tsx | 87 ++++++ .../memos-local-plugin/web/src/stores/i18n.ts | 6 + .../web/src/styles/components.css | 16 ++ .../web/src/views/MemoriesView.tsx | 27 +- .../web/src/views/PoliciesView.tsx | 7 +- .../web/src/views/SkillsView.tsx | 7 +- .../web/src/views/TasksView.tsx | 8 +- .../web/src/views/WorldModelsView.tsx | 10 +- 30 files changed, 1139 insertions(+), 145 deletions(-) create mode 100644 apps/memos-local-plugin/tests/unit/retrieval/decision-guidance.test.ts create mode 100644 apps/memos-local-plugin/web/src/components/NamespaceSelect.tsx 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/agent-contract/memory-core.ts b/apps/memos-local-plugin/agent-contract/memory-core.ts index 6c846fb08..ec6c628dd 100644 --- a/apps/memos-local-plugin/agent-contract/memory-core.ts +++ b/apps/memos-local-plugin/agent-contract/memory-core.ts @@ -204,8 +204,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 +215,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 +235,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 +325,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 +351,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 +369,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 +384,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 +396,7 @@ export interface MemoryCore { turnId?: EpochMs; toolCallId?: string; namespace?: RuntimeNamespace; + includeAllNamespaces?: boolean; }): Promise; archiveSkill(id: SkillId, reason?: string): Promise; /** diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 9c9854b50..4a6c0d5c9 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -1671,11 +1671,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 +1689,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); @@ -2150,11 +2160,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 +2176,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 +2206,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 +2281,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 +2309,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 +2450,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 +2478,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 +2514,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 +2625,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,14 +2676,25 @@ 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 visible = (r: TraceRow) => input?.includeAllNamespaces || visibleToCurrent(r); + const byNamespace = (r: TraceRow) => + (!input?.ownerAgentKind || r.ownerAgentKind === input.ownerAgentKind) && + (!input?.ownerProfileId || r.ownerProfileId === input.ownerProfileId); if (!needle) { - const rows = handle.repos.traces.list({ sessionId: input?.sessionId, limit: 100_000 }).filter(visible); + const rows = handle.repos.traces.list({ + sessionId: input?.sessionId, + ownerAgentKind: input?.ownerAgentKind, + ownerProfileId: input?.ownerProfileId, + limit: 100_000, + }).filter((r) => visible(r) && byNamespace(r)); if (!input?.groupByTurn) return rows.length; const turnKeys = new Set(); for (const r of rows) turnKeys.add(`${r.episodeId ?? "_"}:${r.turnId}`); @@ -2638,7 +2702,11 @@ export function createMemoryCore( } // 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((r) => visible(r) && byNamespace(r)); const matched = rows.filter((r) => { return traceSearchHaystack(r).includes(needle); }); @@ -2652,8 +2720,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)); @@ -2666,11 +2737,15 @@ export function createMemoryCore( if (!needle) { const turnKeys = handle.repos.traces.listTurnKeys({ sessionId: input?.sessionId, + ownerAgentKind: input?.ownerAgentKind, + ownerProfileId: input?.ownerProfileId, limit, offset, }); 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 +2766,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 +2791,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 +2810,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 +2823,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 +2886,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 +2894,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 +2922,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) { 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/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/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/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/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..33bcbdeae 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; @@ -175,6 +191,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; + } 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); @@ -197,6 +221,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; + } 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/install.sh b/apps/memos-local-plugin/install.sh index e8e49f25a..146b8ee02 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 +723,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/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/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/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/session.ts b/apps/memos-local-plugin/server/routes/session.ts index 68545d669..4a0a76c1f 100644 --- a/apps/memos-local-plugin/server/routes/session.ts +++ b/apps/memos-local-plugin/server/routes/session.ts @@ -57,6 +57,8 @@ 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; + const ownerProfileId = ctx.url.searchParams.get("ownerProfileId") || undefined; const q = (ctx.url.searchParams.get("q") || "").trim().toLowerCase(); const rawLimit = numberOrUndefined(ctx.url.searchParams.get("limit")); const rawOffset = numberOrUndefined(ctx.url.searchParams.get("offset")); @@ -66,7 +68,12 @@ export function registerSessionRoutes(routes: Routes, deps: ServerDeps): void { // 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 }); + const total = await deps.core.countEpisodes({ + sessionId, + ownerAgentKind, + ownerProfileId, + includeAllNamespaces: true, + }); if (ctx.url.searchParams.get("shape") === "ids") { const episodeIds = await deps.core.listEpisodes({ sessionId, limit, offset }); return { @@ -81,6 +88,9 @@ export function registerSessionRoutes(routes: Routes, deps: ServerDeps): void { sessionId, limit: q ? 200 : limit, offset: q ? 0 : offset, + ownerAgentKind, + ownerProfileId, + includeAllNamespaces: true, }); if (q) { episodes = episodes.filter( 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/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/tests/unit/retrieval/decision-guidance.test.ts b/apps/memos-local-plugin/tests/unit/retrieval/decision-guidance.test.ts new file mode 100644 index 000000000..6c32e84e7 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/retrieval/decision-guidance.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { collectDecisionGuidance } from "../../../core/retrieval/decision-guidance.js"; +import type { RankedCandidate } from "../../../core/retrieval/ranker.js"; +import type { RetrievalRepos, SkillCandidate } from "../../../core/retrieval/types.js"; + +const NOW = 1_700_000_000_000 as never; + +function rankedSkill( + patch: Partial & Pick, +): RankedCandidate { + const candidate: SkillCandidate = { + tier: "tier1", + refKind: "skill", + refId: patch.refId, + cosine: 0.9, + ts: NOW, + vec: null, + skillName: "Skill", + eta: 0.9, + status: "active", + invocationGuide: "Do the thing.", + ...patch, + }; + return { + candidate, + relevance: 0.9, + rrf: 0.01, + score: 0.9, + normSq: null, + }; +} + +describe("retrieval/decision-guidance", () => { + it("uses skill-local decision guidance before source policies", () => { + const repos = { + policies: { + list: () => { + throw new Error("policy lookup should not be needed"); + }, + }, + } as unknown as RetrievalRepos; + + const result = collectDecisionGuidance({ + ranked: [ + rankedSkill({ + refId: "sk1" as never, + sourcePolicyIds: ["policy1" as never], + decisionGuidance: { + preference: ["Prefer the skill-specific setup."], + antiPattern: ["Avoid the skill-specific trap."], + }, + }), + ], + repos, + }); + + expect(result.preference.map((g) => g.text)).toEqual([ + "Prefer the skill-specific setup.", + ]); + expect(result.antiPattern.map((g) => g.text)).toEqual([ + "Avoid the skill-specific trap.", + ]); + expect(result.preference[0]?.sourceSkillIds).toEqual(["sk1"]); + expect(result.preference[0]?.sourcePolicyIds).toEqual([]); + expect(result.policyIdsTouched).toEqual([]); + expect(result.skillIdsTouched).toEqual(["sk1"]); + }); + + it("falls back to source policy guidance for legacy skills", () => { + const repos = { + policies: { + list: () => [ + { + id: "policy1", + title: "Legacy policy", + sourceEpisodeIds: [], + decisionGuidance: { + preference: ["Prefer the policy fallback."], + antiPattern: ["Avoid the policy fallback."], + }, + }, + ], + }, + } as unknown as RetrievalRepos; + + const result = collectDecisionGuidance({ + ranked: [ + rankedSkill({ + refId: "sk1" as never, + sourcePolicyIds: ["policy1" as never], + decisionGuidance: { preference: [], antiPattern: [] }, + }), + ], + repos, + }); + + expect(result.preference.map((g) => g.text)).toEqual([ + "Prefer the policy fallback.", + ]); + expect(result.antiPattern.map((g) => g.text)).toEqual([ + "Avoid the policy fallback.", + ]); + expect(result.preference[0]?.sourceSkillIds).toEqual([]); + expect(result.preference[0]?.sourcePolicyIds).toEqual(["policy1"]); + expect(result.policyIdsTouched).toEqual(["policy1"]); + expect(result.skillIdsTouched).toEqual([]); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/retrieval/tier1.test.ts b/apps/memos-local-plugin/tests/unit/retrieval/tier1.test.ts index 5bc0b0d51..9c36c1f26 100644 --- a/apps/memos-local-plugin/tests/unit/retrieval/tier1.test.ts +++ b/apps/memos-local-plugin/tests/unit/retrieval/tier1.test.ts @@ -29,7 +29,15 @@ const cfg: RetrievalConfig = { const qv: EmbeddingVector = Float32Array.from([1, 0, 0]); -function makeRepo(rows: Array<{ id: string; status: SkillStatus; eta: number; score: number }>) { +function makeRepo( + rows: Array<{ + id: string; + status: SkillStatus; + eta: number; + score: number; + procedureJson?: unknown; + }>, +) { const repo: RetrievalRepos["skills"] = { searchByVector(_vec, k, opts) { return rows @@ -51,6 +59,7 @@ function makeRepo(rows: Array<{ id: string; status: SkillStatus; eta: number; sc name: r.id, status: r.status, invocationGuide: `run ${r.id}`, + procedureJson: r.procedureJson, eta: r.eta, }; }, @@ -82,4 +91,31 @@ describe("retrieval/tier1", () => { ); expect(kept.length).toBe(0); }); + + it("normalises snake_case skill decision guidance from procedure JSON", async () => { + const repo = makeRepo([ + { + id: "a", + status: "active", + eta: 0.9, + score: 0.95, + procedureJson: { + decision_guidance: { + preference: ["Prefer format-specific parsers."], + anti_pattern: ["Avoid raw binary reads."], + }, + }, + }, + ]); + + const kept = await runTier1( + { repos: { skills: repo }, config: cfg }, + { kind: "embedded", queryVec: qv, rawText: "parse binary document" }, + ); + + expect(kept[0]?.decisionGuidance).toEqual({ + preference: ["Prefer format-specific parsers."], + antiPattern: ["Avoid raw binary reads."], + }); + }); }); diff --git a/apps/memos-local-plugin/web/src/components/NamespaceSelect.tsx b/apps/memos-local-plugin/web/src/components/NamespaceSelect.tsx new file mode 100644 index 000000000..2a5354a46 --- /dev/null +++ b/apps/memos-local-plugin/web/src/components/NamespaceSelect.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from "preact/hooks"; +import { api } from "../api/client"; +import { t } from "../stores/i18n"; + +export interface NamespaceOption { + agentKind: string; + profileId: string; + count: number; +} + +interface NamespaceResponse { + namespaces: NamespaceOption[]; +} + +interface NamespaceSelectProps { + value: string; + onChange: (value: string) => void; +} + +export function NamespaceSelect({ value, onChange }: NamespaceSelectProps) { + const [options, setOptions] = useState([]); + + useEffect(() => { + let cancelled = false; + api + .get("/api/v1/diag/namespace") + .then((res) => { + if (!cancelled) setOptions(res.namespaces ?? []); + }) + .catch(() => { + if (!cancelled) setOptions([]); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( + + ); +} + +export function appendNamespaceParams(qs: URLSearchParams, value: string): void { + const ns = parseNamespaceFilter(value); + if (!ns) return; + qs.set("ownerAgentKind", ns.agentKind); + qs.set("ownerProfileId", ns.profileId); +} + +export function namespaceKey(ns: Pick): string { + return `${ns.agentKind}/${ns.profileId}`; +} + +export function namespaceLabel(ns: Pick): string { + return `${ns.agentKind}/${ns.profileId}`; +} + +export function agentClass(agent: string): string { + return agent === "openclaw" || agent === "hermes" ? agent : "unknown"; +} + +function parseNamespaceFilter(value: string): { agentKind: string; profileId: string } | null { + if (!value) return null; + const [agentKind, profileId] = value.split("/", 2); + if (!agentKind || !profileId) return null; + return { agentKind, profileId }; +} diff --git a/apps/memos-local-plugin/web/src/stores/i18n.ts b/apps/memos-local-plugin/web/src/stores/i18n.ts index b25cef3dc..208f19696 100644 --- a/apps/memos-local-plugin/web/src/stores/i18n.ts +++ b/apps/memos-local-plugin/web/src/stores/i18n.ts @@ -320,6 +320,9 @@ const en = { "memories.filter.role.assistant": "Assistant", "memories.filter.role.tool": "Tool", "memories.filter.role.system": "System", + "memories.filter.namespace": "Instance", + "memories.filter.namespace.all": "All instances", + "memories.filter.namespace.count": "{n} records", "memories.filter.owner": "All agents", "memories.filter.scope.device": "This device", "memories.filter.scope.team": "Team", @@ -1135,6 +1138,9 @@ const zh: Record = { "memories.filter.role.assistant": "助手", "memories.filter.role.tool": "工具", "memories.filter.role.system": "系统", + "memories.filter.namespace": "实例", + "memories.filter.namespace.all": "全部实例", + "memories.filter.namespace.count": "{n} 条记录", "memories.filter.owner": "全部 Agent", "memories.filter.scope.device": "本机", "memories.filter.scope.team": "团队", diff --git a/apps/memos-local-plugin/web/src/styles/components.css b/apps/memos-local-plugin/web/src/styles/components.css index 15b8835e5..09e283ffc 100644 --- a/apps/memos-local-plugin/web/src/styles/components.css +++ b/apps/memos-local-plugin/web/src/styles/components.css @@ -160,6 +160,22 @@ border-color: var(--border-focus); box-shadow: var(--shadow-focus); } +.namespace-select { + display: inline-flex; + align-items: center; + flex: 0 0 auto; +} +.select--namespace { + width: auto; + min-width: 150px; + max-width: 240px; + height: 28px; + padding: 0 32px 0 12px; + border-radius: var(--radius-pill); + color: var(--fg-muted); + font-size: var(--fs-xs); + font-weight: var(--fw-med); +} .textarea { height: auto; padding: 10px var(--sp-3); diff --git a/apps/memos-local-plugin/web/src/views/MemoriesView.tsx b/apps/memos-local-plugin/web/src/views/MemoriesView.tsx index 278982935..f734e33c6 100644 --- a/apps/memos-local-plugin/web/src/views/MemoriesView.tsx +++ b/apps/memos-local-plugin/web/src/views/MemoriesView.tsx @@ -54,6 +54,7 @@ import { Icon } from "../components/Icon"; import { Pager } from "../components/Pager"; import { ShareScopePill } from "../components/ShareScopePill"; import { Markdown } from "../components/Markdown"; +import { NamespaceSelect, agentClass, appendNamespaceParams, namespaceLabel } from "../components/NamespaceSelect"; import { route } from "../stores/router"; import { clearEntryId } from "../stores/cross-link"; import type { TraceDTO } from "../api/types"; @@ -89,6 +90,8 @@ interface MemoryGroup { aggValue: number; aggAlpha: number; hasReflection: boolean; + ownerAgentKind: string; + ownerProfileId: string; scope: "private" | "local" | "public" | "hub"; shared: boolean; } @@ -101,6 +104,7 @@ export function MemoriesView() { // navigate here with a pending query. const [query, setQuery] = useState(() => route.value.params.q ?? ""); const [role, setRole] = useState(""); + const [namespaceFilter, setNamespaceFilter] = useState(""); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [loading, setLoading] = useState(false); @@ -131,6 +135,7 @@ export function MemoriesView() { qs.set("offset", String(roleFilterActive ? 0 : opts.page * pageSize)); qs.set("groupByTurn", "true"); if (opts.q) qs.set("q", opts.q); + appendNamespaceParams(qs, namespaceFilter); const res = await api.get(`/api/v1/traces?${qs.toString()}`); setTraces(res.traces); setHasMore(roleFilterActive ? false : res.nextOffset != null); @@ -153,7 +158,7 @@ export function MemoriesView() { }, 200); return () => clearTimeout(h); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, pageSize, role, route.value.params.id]); + }, [query, pageSize, role, namespaceFilter, route.value.params.id]); useEffect(() => { const id = route.value.params.id; @@ -167,6 +172,7 @@ export function MemoriesView() { const openLinkedMemory = async (id: string, signal: AbortSignal) => { setQuery(""); setRole(""); + setNamespaceFilter(""); setLoading(true); try { const targetTrace = await api.get( @@ -424,6 +430,7 @@ export function MemoriesView() { class="btn btn--ghost btn--sm" onClick={() => { setQuery(""); + setNamespaceFilter(""); setSelected(new Set()); void loadPage({ q: "", page: 0 }); }} @@ -467,6 +474,7 @@ export function MemoriesView() { ))} + {/* @@ -566,6 +574,13 @@ export function MemoriesView() {
{line}
+ + {namespaceLabel({ + agentKind: g.ownerAgentKind, + profileId: g.ownerProfileId, + count: g.ids.length, + })} + {formatTs(g.ts)} {groupScoreLabel(g)} @@ -934,6 +949,8 @@ function buildGroups(traces: readonly TraceDTO[]): MemoryGroup[] { aggValue: bucket.length === 0 ? 0 : sumV / bucket.length, aggAlpha: bucket.length === 0 ? 0 : sumA / bucket.length, hasReflection: bucket.some((t) => Boolean((t.reflection ?? "").trim())), + ownerAgentKind: pickGroupAgent(bucket), + ownerProfileId: pickGroupProfile(bucket), scope, shared: scope !== "private", }; @@ -956,6 +973,14 @@ function flattenToolCallList(g: MemoryGroup): { name: string }[] { return g.traces.flatMap((t) => t.toolCalls ?? []); } +function pickGroupAgent(traces: readonly TraceDTO[]): string { + return traces.find((t) => t.ownerAgentKind && t.ownerAgentKind !== "unknown")?.ownerAgentKind ?? "unknown"; +} + +function pickGroupProfile(traces: readonly TraceDTO[]): string { + return traces.find((t) => t.ownerProfileId && t.ownerProfileId !== "unknown")?.ownerProfileId ?? "default"; +} + function truncateForExport(tc: { input?: unknown; output?: unknown; errorCode?: string }): string { if (tc.errorCode) return `ERROR[${tc.errorCode}]`; const out = tc.output; diff --git a/apps/memos-local-plugin/web/src/views/PoliciesView.tsx b/apps/memos-local-plugin/web/src/views/PoliciesView.tsx index 1005689af..74253b2f7 100644 --- a/apps/memos-local-plugin/web/src/views/PoliciesView.tsx +++ b/apps/memos-local-plugin/web/src/views/PoliciesView.tsx @@ -16,6 +16,7 @@ import { t } from "../stores/i18n"; import { Icon } from "../components/Icon"; import { Pager } from "../components/Pager"; import { ShareScopePill } from "../components/ShareScopePill"; +import { NamespaceSelect, appendNamespaceParams } from "../components/NamespaceSelect"; import { route } from "../stores/router"; import { clearEntryId, linkTo } from "../stores/cross-link"; import type { PolicyDTO } from "../api/types"; @@ -43,6 +44,7 @@ interface ListResponse { export function PoliciesView() { const [query, setQuery] = useState(""); const [status, setStatus] = useState(""); + const [namespaceFilter, setNamespaceFilter] = useState(""); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [page, setPage] = useState(0); @@ -75,6 +77,7 @@ export function PoliciesView() { qs.set("offset", String(opts.page * pageSize)); if (opts.q) qs.set("q", opts.q); if (opts.status) qs.set("status", opts.status); + appendNamespaceParams(qs, namespaceFilter); const res = await api.get(`/api/v1/policies?${qs.toString()}`); setRows(res.policies); setHasMore(res.nextOffset != null); @@ -95,7 +98,7 @@ export function PoliciesView() { }, 200); return () => clearTimeout(h); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, status, pageSize]); + }, [query, status, pageSize, namespaceFilter]); // Deep-link: `#/policies?id=po_xxx` auto-opens the row's drawer. // Lets other views (Skills / WorldModels / Tasks) link straight @@ -174,6 +177,7 @@ export function PoliciesView() { onClick={() => { setQuery(""); setStatus(""); + setNamespaceFilter(""); setSelected(new Set()); void load({ q: "", status: "", page: 0 }); }} @@ -212,6 +216,7 @@ export function PoliciesView() { ))}
+
{loading && rows.length === 0 && ( diff --git a/apps/memos-local-plugin/web/src/views/SkillsView.tsx b/apps/memos-local-plugin/web/src/views/SkillsView.tsx index f080fec3d..312aa4cc3 100644 --- a/apps/memos-local-plugin/web/src/views/SkillsView.tsx +++ b/apps/memos-local-plugin/web/src/views/SkillsView.tsx @@ -16,6 +16,7 @@ import { t } from "../stores/i18n"; import { Icon } from "../components/Icon"; import { Pager } from "../components/Pager"; import { ShareScopePill } from "../components/ShareScopePill"; +import { NamespaceSelect, appendNamespaceParams } from "../components/NamespaceSelect"; import { Markdown } from "../components/Markdown"; import { route } from "../stores/router"; import { clearEntryId, linkTo } from "../stores/cross-link"; @@ -58,6 +59,7 @@ interface SkillRefusalNotice { export function SkillsView() { const [query, setQuery] = useState(""); const [status, setStatus] = useState(""); + const [namespaceFilter, setNamespaceFilter] = useState(""); const [skills, setSkills] = useState(null); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); @@ -90,6 +92,7 @@ export function SkillsView() { qs.set("limit", String(pageSize)); qs.set("offset", String(nextPage * pageSize)); if (status) qs.set("status", status); + appendNamespaceParams(qs, namespaceFilter); const r = await api.get<{ skills: SkillDTO[]; nextOffset?: number; total?: number }>( `/api/v1/skills?${qs.toString()}`, ); @@ -107,7 +110,7 @@ export function SkillsView() { }; useEffect(() => { void load(0); - }, [status, pageSize]); + }, [status, pageSize, namespaceFilter]); useEffect(() => { const handle = openSse("/api/v1/events", (_, data) => { @@ -193,6 +196,7 @@ export function SkillsView() { onClick={() => { setQuery(""); setStatus(""); + setNamespaceFilter(""); setSelected(new Set()); void load(0); }} @@ -234,6 +238,7 @@ export function SkillsView() { ))} + {loading && ( diff --git a/apps/memos-local-plugin/web/src/views/TasksView.tsx b/apps/memos-local-plugin/web/src/views/TasksView.tsx index 8690c6628..9657b77d3 100644 --- a/apps/memos-local-plugin/web/src/views/TasksView.tsx +++ b/apps/memos-local-plugin/web/src/views/TasksView.tsx @@ -12,6 +12,7 @@ import { api } from "../api/client"; import { t } from "../stores/i18n"; import { Icon } from "../components/Icon"; import { Pager } from "../components/Pager"; +import { NamespaceSelect, appendNamespaceParams } from "../components/NamespaceSelect"; import { route } from "../stores/router"; import { clearEntryId, linkTo } from "../stores/cross-link"; import { ChatLog, flattenChat, type TimelineTrace } from "./tasks-chat"; @@ -66,6 +67,7 @@ const DEFAULT_PAGE_SIZE = 20; export function TasksView() { const [query, setQuery] = useState(""); const [status, setStatus] = useState(""); + const [namespaceFilter, setNamespaceFilter] = useState(""); const [rows, setRows] = useState(null); const [loading, setLoading] = useState(false); const [page, setPage] = useState(0); @@ -90,6 +92,7 @@ export function TasksView() { const qs = new URLSearchParams(); qs.set("limit", String(pageSize)); qs.set("offset", String(nextPage * pageSize)); + appendNamespaceParams(qs, namespaceFilter); api .get( `/api/v1/episodes?${qs.toString()}`, @@ -115,7 +118,7 @@ export function TasksView() { const ctrl = loadPage(0); return () => ctrl.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pageSize, route.value.params.id]); + }, [pageSize, namespaceFilter, route.value.params.id]); useEffect(() => { const id = route.value.params.id; @@ -136,6 +139,7 @@ export function TasksView() { const qs = new URLSearchParams(); qs.set("limit", String(pageSizeForLookup)); qs.set("offset", String(targetPage * pageSizeForLookup)); + appendNamespaceParams(qs, namespaceFilter); const res = await api.get( `/api/v1/episodes?${qs.toString()}`, { signal }, @@ -208,6 +212,7 @@ export function TasksView() { onClick={() => { setQuery(""); setStatus(""); + setNamespaceFilter(""); setSelected(new Set()); loadPage(0); }} @@ -250,6 +255,7 @@ export function TasksView() { ))} + {loading && ( diff --git a/apps/memos-local-plugin/web/src/views/WorldModelsView.tsx b/apps/memos-local-plugin/web/src/views/WorldModelsView.tsx index 1b2a8009d..df2ecf492 100644 --- a/apps/memos-local-plugin/web/src/views/WorldModelsView.tsx +++ b/apps/memos-local-plugin/web/src/views/WorldModelsView.tsx @@ -16,6 +16,7 @@ import { t } from "../stores/i18n"; import { Icon } from "../components/Icon"; import { Pager } from "../components/Pager"; import { ShareScopePill } from "../components/ShareScopePill"; +import { NamespaceSelect, appendNamespaceParams } from "../components/NamespaceSelect"; import { route } from "../stores/router"; import { clearEntryId, linkTo } from "../stores/cross-link"; import type { WorldModelDTO } from "../api/types"; @@ -43,6 +44,7 @@ interface ListResponse { export function WorldModelsView() { const [query, setQuery] = useState(""); + const [namespaceFilter, setNamespaceFilter] = useState(""); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [page, setPage] = useState(0); @@ -74,6 +76,7 @@ export function WorldModelsView() { qs.set("limit", String(pageSize)); qs.set("offset", String(opts.page * pageSize)); if (opts.q) qs.set("q", opts.q); + appendNamespaceParams(qs, namespaceFilter); const res = await api.get(`/api/v1/world-models?${qs.toString()}`); setRows(res.worldModels); setHasMore(res.nextOffset != null); @@ -94,7 +97,7 @@ export function WorldModelsView() { }, 200); return () => clearTimeout(h); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, pageSize]); + }, [query, pageSize, namespaceFilter]); // Deep-link: `#/world-models?id=wm_xxx` auto-opens the drawer. useEffect(() => { @@ -144,6 +147,7 @@ export function WorldModelsView() { class="btn btn--ghost btn--sm" onClick={() => { setQuery(""); + setNamespaceFilter(""); setSelected(new Set()); void load({ q: "", page: 0 }); }} @@ -167,6 +171,10 @@ export function WorldModelsView() { +
+ +
+ {loading && rows.length === 0 && (
{[0, 1, 2].map((i) => ( From 15fbf3329792887d0bc3f3acef7b42782be06c28 Mon Sep 17 00:00:00 2001 From: Matthew Date: Tue, 12 May 2026 15:55:48 +0800 Subject: [PATCH 09/20] chore(memos-local-plugin): bump beta package version Co-authored-by: Cursor --- apps/memos-local-plugin/package-lock.json | 4 ++-- apps/memos-local-plugin/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/memos-local-plugin/package-lock.json b/apps/memos-local-plugin/package-lock.json index e2dbd7bc1..872acb50d 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.0-beta.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@memtensor/memos-local-plugin", - "version": "2.0.0-beta.10", + "version": "2.0.0-beta.12", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/apps/memos-local-plugin/package.json b/apps/memos-local-plugin/package.json index da6caea2b..f581a1230 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.0-beta.12", "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", From 2467d921e2b0f6c49df13601f5751624243b8903 Mon Sep 17 00:00:00 2001 From: Matthew Date: Tue, 12 May 2026 16:20:47 +0800 Subject: [PATCH 10/20] chore: remove temporary test file Co-authored-by: Cursor --- .test1.py | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .test1.py diff --git a/.test1.py b/.test1.py deleted file mode 100644 index c3b6dd49e..000000000 --- a/.test1.py +++ /dev/null @@ -1,5 +0,0 @@ -import jieba.analyse - - -res = jieba.analyse.extract_tags("我爱旅游和烧烤", topK=12) -print(res) From 4e706d2d155e48542a39565d80d511631c50df61 Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Tue, 12 May 2026 16:27:59 +0800 Subject: [PATCH 11/20] chore(memos-local-plugin): gitignore telemetry.credentials.json CI generates this file from secrets via `scripts/generate-telemetry-credentials.cjs` immediately before `npm publish` (see `.github/workflows/hermes-plugin-publish.yml`). Local copies are only useful for development against a personal ARMS workspace and must never be committed. The repo-root `.gitignore` has a blanket `!apps/**/*.json` allow-rule that overrides any per-package ignore for .json files; the closer `apps/memos-local-plugin/.gitignore` wins because git's "closest .gitignore wins" rule applies regardless of polarity. Adding the explicit entry here is therefore sufficient. Co-authored-by: Cursor --- apps/memos-local-plugin/.gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/memos-local-plugin/.gitignore b/apps/memos-local-plugin/.gitignore index affa637af..0c52ba684 100644 --- a/apps/memos-local-plugin/.gitignore +++ b/apps/memos-local-plugin/.gitignore @@ -17,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 From 0fc8beaca684a1ed7199fd140a4cdb21e1c85130 Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Tue, 12 May 2026 16:28:41 +0800 Subject: [PATCH 12/20] fix(memos-local-plugin): close v2 ARMS telemetry coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original v2 telemetry rollout (commit 3600f80a) only wired the Hermes-side `bridge.cts`. Three knock-on effects made the `memos_local_hermes_v2` ARMS dashboards drift away from reality: 1. **OpenClaw adapter never emitted any events.** The whole in-process plugin path bypassed the `Telemetry` instance, so `agent_name=openclaw` was silently zero across `plugin_started`, `memory_search`, `memory_ingested`, `feedback_submitted`, and `viewer_opened`. 2. **`daily_active` counted process launches, not days.** The in-memory `dailyPingSent` boolean reset on every `bridge.cts` subprocess spawn (Hermes spawns one per `chat`), so DAU = startup count. 3. **`viewer_opened` only fired on the first `GET /api/v1/overview`** *per process*, then suppressed forever by a closure flag. Background pollers and CLI calls counted as "viewer opens"; restarts re-counted the same operator. 4. **`plugin_error` was declared but never invoked.** The crash visibility the dashboard implied did not exist. Changes ------- - `adapters/openclaw/index.ts`: Construct `Telemetry`, call `bindTelemetry`, emit `trackPluginStarted("openclaw")`, and pass the instance into `startHttpServer` so OpenClaw matches the Hermes surface 1:1. Plugin-root resolution shared with the existing viewer-static-root helper. - `core/telemetry/sender.ts`: Persist the daily-ping date to `/memos-local/.last-daily-ping`. Read on every start; only emit when today's date differs from the file. Read failures degrade gracefully to "first time today" (over-report by ≤1, never under-report). Removed the obsolete in-memory `dailyPingSent` / `dailyPingDate` fields. - `server/routes/telemetry.ts` (new) + `viewer/src/components/App.tsx`: Replace the GET-piggybacking `viewer_opened` with a dedicated `POST /api/v1/telemetry/viewer-opened` invoked once from the SPA's `` mount effect. Endpoint is fire-and-forget on the client and always returns `{ ok: true }`. `server/routes/overview.ts` no longer fires the event. - `core/pipeline/memory-core.ts`: Surface `MemosError.code` (or `unknown`) as the `plugin_error.error_type` when `onTurnStart` / `searchMemory` reject. Never the message — those can contain user/workspace text. - `bridge.cts`: Register `process.on("uncaughtException" | "unhandledRejection")` immediately after binding telemetry. `uncaughtException` exits 1 (preserves supervisor semantics); `unhandledRejection` continues (Node 20+'s "exit by default" is too aggressive for a long-running bridge — log + ARMS event + keep going). New `classifyErrorCode()` reuses Node's `code` (`ENOENT`, `EADDRINUSE`, …), then constructor name, then `unknown`. Only registered on the dedicated bridge process; the OpenClaw adapter must not steal its host's global error hooks. Tests ----- - `tests/unit/telemetry/sender.test.ts` — three new cases covering: first launch emits `daily_active`; second launch same day / same `stateDir` does NOT re-emit; pre-seeded `.last-daily-ping` with yesterday's date triggers a fresh ping and overwrites the file with today's ISO date. - `tests/unit/server/http.test.ts` — POST endpoint invokes `trackViewerOpened`; endpoint stays 200 when telemetry is unbound; GET `/api/v1/overview` no longer triggers the event (regressed deliberately). Verified locally: 945 unit tests pass (+6 vs. baseline). 7 unrelated pre-existing failures in `tests/unit/{reward,memory,storage}/...` are unchanged on this branch and on `upstream/mem-agent-0509` head. Co-authored-by: Cursor --- .../adapters/openclaw/index.ts | 55 ++++++++++++++ apps/memos-local-plugin/bridge.cts | 63 ++++++++++++++++ .../core/pipeline/memory-core.ts | 16 ++++ .../core/telemetry/sender.ts | 35 +++++++-- .../server/routes/overview.ts | 14 ++-- .../server/routes/registry.ts | 2 + .../server/routes/telemetry.ts | 39 ++++++++++ .../tests/unit/server/http.test.ts | 52 +++++++++++++ .../tests/unit/telemetry/sender.test.ts | 74 +++++++++++++++++++ .../viewer/src/components/App.tsx | 9 +++ 10 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 apps/memos-local-plugin/server/routes/telemetry.ts diff --git a/apps/memos-local-plugin/adapters/openclaw/index.ts b/apps/memos-local-plugin/adapters/openclaw/index.ts index 8c8b05e63..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,6 +83,30 @@ 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 @@ -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/bridge.cts b/apps/memos-local-plugin/bridge.cts index f6e2852d8..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]; @@ -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/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 1666fa93c..a650bbee0 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -1345,6 +1345,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 — @@ -2045,6 +2055,12 @@ export function createMemoryCore( }; } catch (err) { ok = false; + if (telemetry) { + telemetry.trackError( + "memory_search", + err instanceof MemosError ? err.code : "unknown", + ); + } throw err; } finally { try { 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/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/registry.ts b/apps/memos-local-plugin/server/routes/registry.ts index 3d975cbac..7adaf8c53 100644 --- a/apps/memos-local-plugin/server/routes/registry.ts +++ b/apps/memos-local-plugin/server/routes/registry.ts @@ -45,6 +45,7 @@ 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; @@ -181,6 +182,7 @@ export function buildRoutes( registerEmbeddingRoutes(routes, deps); registerApiLogsRoutes(routes, deps); registerDiagRoutes(routes, deps); + registerTelemetryRoutes(routes, deps); return routes; } 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/tests/unit/server/http.test.ts b/apps/memos-local-plugin/tests/unit/server/http.test.ts index 7a52f2a89..cb6efa8fa 100644 --- a/apps/memos-local-plugin/tests/unit/server/http.test.ts +++ b/apps/memos-local-plugin/tests/unit/server/http.test.ts @@ -1023,6 +1023,58 @@ describe("HTTP server — REST routes", () => { const r = await fetch(`${handle.url}/api/v1/ping`, { method: "DELETE" }); expect(r.status).toBe(405); }); + + // ─── Telemetry side-channel ──────────────────────────────────── + // Replaces the previous "first GET /overview wins" trigger with + // a dedicated SPA-mount endpoint. See `server/routes/telemetry.ts` + // and `viewer/src/components/App.tsx` for the wiring rationale. + + it("POST /api/v1/telemetry/viewer-opened invokes telemetry.trackViewerOpened", async () => { + const trackViewerOpened = vi.fn(); + const local = await startHttpServer( + { core, telemetry: { trackViewerOpened } }, + { port: 0 }, + ); + try { + const r = await fetch(`${local.url}/api/v1/telemetry/viewer-opened`, { + method: "POST", + }); + expect(r.status).toBe(200); + const body = (await r.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + expect(trackViewerOpened).toHaveBeenCalledTimes(1); + } finally { + await local.close(); + } + }); + + it("POST /api/v1/telemetry/viewer-opened still returns 200 when telemetry is unbound", async () => { + // No telemetry on `deps` — endpoint must remain a no-op success + // so the SPA's fire-and-forget call never surfaces an error. + const r = await fetch(`${handle.url}/api/v1/telemetry/viewer-opened`, { + method: "POST", + }); + expect(r.status).toBe(200); + const body = (await r.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it("GET /api/v1/overview no longer triggers viewer_opened (regressed to manual ping)", async () => { + const trackViewerOpened = vi.fn(); + const local = await startHttpServer( + { core, telemetry: { trackViewerOpened } }, + { port: 0 }, + ); + try { + // Two GETs (the viewer used to poll this) — neither should + // count as a viewer-mount event under the new scheme. + await fetch(`${local.url}/api/v1/overview`); + await fetch(`${local.url}/api/v1/overview`); + expect(trackViewerOpened).not.toHaveBeenCalled(); + } finally { + await local.close(); + } + }); }); describe("HTTP server — static files", () => { diff --git a/apps/memos-local-plugin/tests/unit/telemetry/sender.test.ts b/apps/memos-local-plugin/tests/unit/telemetry/sender.test.ts index 8874ff1dc..0b7290c99 100644 --- a/apps/memos-local-plugin/tests/unit/telemetry/sender.test.ts +++ b/apps/memos-local-plugin/tests/unit/telemetry/sender.test.ts @@ -166,4 +166,78 @@ describe("Telemetry", () => { }); }); }); + + // Regression: previously `dailyPingSent` was an in-memory boolean, + // so every `bridge.cts` subprocess (Hermes spawns one per `chat`) + // re-emitted `daily_active`, making the metric track process + // launches instead of unique active days. The fix persists the + // last ping date to `/memos-local/.last-daily-ping`. + describe("daily_active dedup persistence", () => { + beforeEach(() => { + fs.writeFileSync( + path.join(tmpDir, "telemetry.credentials.json"), + JSON.stringify({ endpoint: "https://arms.test/rum", pid: "p", env: "test" }), + ); + }); + + it("emits daily_active on the first trackPluginStarted", async () => { + const tel = new Telemetry({ enabled: true }, tmpDir, "1.0.0", makeLogger(), tmpDir); + tel.trackPluginStarted("hermes"); + await tel.shutdown(); + + const body = JSON.parse((fetch as any).mock.calls[0][1].body); + const names = body.events.map((e: any) => e.name); + expect(names).toContain("plugin_started"); + expect(names).toContain("daily_active"); + }); + + it("does NOT re-emit daily_active for a second instance the same day", async () => { + const tel1 = new Telemetry({ enabled: true }, tmpDir, "1.0.0", makeLogger(), tmpDir); + tel1.trackPluginStarted("hermes"); + await tel1.shutdown(); + + const tel2 = new Telemetry({ enabled: true }, tmpDir, "1.0.0", makeLogger(), tmpDir); + tel2.trackPluginStarted("hermes"); + await tel2.shutdown(); + + const calls = (fetch as any).mock.calls; + expect(calls).toHaveLength(2); + const body1 = JSON.parse(calls[0][1].body); + const body2 = JSON.parse(calls[1][1].body); + + const names1 = body1.events.map((e: any) => e.name); + const names2 = body2.events.map((e: any) => e.name); + + // First instance: both events. + expect(names1).toContain("plugin_started"); + expect(names1).toContain("daily_active"); + + // Second instance same day: only plugin_started, daily_active + // is suppressed by the on-disk `.last-daily-ping` file. + expect(names2).toContain("plugin_started"); + expect(names2).not.toContain("daily_active"); + }); + + it("re-emits daily_active when the persisted date is yesterday", async () => { + // Pre-seed the dedup file with an older date so the "today" + // check fails and a fresh ping is emitted. + const stateSubdir = path.join(tmpDir, "memos-local"); + fs.mkdirSync(stateSubdir, { recursive: true }); + fs.writeFileSync(path.join(stateSubdir, ".last-daily-ping"), "2024-01-01", "utf-8"); + + const tel = new Telemetry({ enabled: true }, tmpDir, "1.0.0", makeLogger(), tmpDir); + tel.trackPluginStarted("hermes"); + await tel.shutdown(); + + const body = JSON.parse((fetch as any).mock.calls[0][1].body); + const names = body.events.map((e: any) => e.name); + expect(names).toContain("daily_active"); + + // The file should now hold today's ISO date. + const today = new Date().toISOString().slice(0, 10); + expect( + fs.readFileSync(path.join(stateSubdir, ".last-daily-ping"), "utf-8").trim(), + ).toBe(today); + }); + }); }); diff --git a/apps/memos-local-plugin/viewer/src/components/App.tsx b/apps/memos-local-plugin/viewer/src/components/App.tsx index 25a384616..f2508c6c6 100644 --- a/apps/memos-local-plugin/viewer/src/components/App.tsx +++ b/apps/memos-local-plugin/viewer/src/components/App.tsx @@ -19,6 +19,15 @@ import { startHealthPolling } from "../stores/health"; export function App() { useEffect(() => { startHealthPolling(); + // Best-effort ARMS `viewer_opened` ping. Fire-and-forget on the + // first SPA mount per browser tab. The endpoint always returns + // 200 (even when telemetry is opted out / unbound), so failure + // here is purely a network blip — never surface it to the user. + void fetch("/api/v1/telemetry/viewer-opened", { method: "POST" }).catch( + () => { + /* swallowed: telemetry must not affect UX */ + }, + ); }, []); return ( From fda74506e789522d98f715026e2bb5791ffc1fbe Mon Sep 17 00:00:00 2001 From: jiang Date: Tue, 12 May 2026 17:18:51 +0800 Subject: [PATCH 13/20] fix(memos-local-plugin): improve Windows bridge launch --- .../hermes/memos_provider/bridge_client.py | 18 +++++++++++------- apps/memos-local-plugin/install.ps1 | 6 ++++++ 2 files changed, 17 insertions(+), 7 deletions(-) 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/install.ps1 b/apps/memos-local-plugin/install.ps1 index 7b89a50b1..79efa5e4f 100644 --- a/apps/memos-local-plugin/install.ps1 +++ b/apps/memos-local-plugin/install.ps1 @@ -178,6 +178,12 @@ function Deploy-Tarball { } 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" } From 3eed5831d8e35f9c81dba872bc18f378e81d6fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=91=E5=B8=83=E6=9E=97?= <11641432+heiheiyouyou@user.noreply.gitee.com> Date: Tue, 12 May 2026 17:58:19 +0800 Subject: [PATCH 14/20] fix: ghost overview memory --- .../core/pipeline/memory-core.ts | 33 ++++++++++++------- .../core/storage/repos/traces.ts | 18 ++++++++-- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index a650bbee0..bb222de65 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -96,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"; @@ -2651,16 +2652,20 @@ export function createMemoryCore( }): Promise { ensureLive(); const needle = (input?.q ?? "").trim().toLowerCase(); - const visible = (r: TraceRow) => visibleToCurrent(r); + const vis = visibilityWhere(activeNamespace); + 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 }, + vis, + ); + } + return handle.repos.traces.count({ sessionId: input?.sessionId }); } // q substring scan — mirror `listTraces`. Walk all matching // traces from the repo (no limit) and apply the same filter. + const visible = (r: TraceRow) => visibleToCurrent(r); const rows = handle.repos.traces.list({ sessionId: input?.sessionId }).filter(visible); const matched = rows.filter((r) => { return traceSearchHaystack(r).includes(needle); @@ -2686,12 +2691,12 @@ 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 = visibilityWhere(activeNamespace); if (!needle) { - const turnKeys = handle.repos.traces.listTurnKeys({ - sessionId: input?.sessionId, - limit, - offset, - }); + const turnKeys = handle.repos.traces.listTurnKeys( + { sessionId: input?.sessionId, limit, offset }, + vis, + ); const rows = handle.repos.traces.listByTurnKeys(turnKeys); const visibleRows = rows.filter((r) => visibleToCurrent(r)); // The frontend's `buildGroups` preserves first-encounter order @@ -3048,7 +3053,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, diff --git a/apps/memos-local-plugin/core/storage/repos/traces.ts b/apps/memos-local-plugin/core/storage/repos/traces.ts index bacf09469..9f7613527 100644 --- a/apps/memos-local-plugin/core/storage/repos/traces.ts +++ b/apps/memos-local-plugin/core/storage/repos/traces.ts @@ -164,7 +164,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 +178,10 @@ export function makeTracesRepo(db: StorageDb) { fragments.push(`episode_id = @episode_id`); params.episode_id = filter.episodeId; } + 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 +193,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 +207,10 @@ export function makeTracesRepo(db: StorageDb) { fragments.push(`episode_id = @episode_id`); params.episode_id = filter.episodeId; } + 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); From a3fd86cd2a8b2cf913fcbacef9b0afc297dfa9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=91=E5=B8=83=E6=9E=97?= <11641432+heiheiyouyou@user.noreply.gitee.com> Date: Tue, 12 May 2026 19:39:39 +0800 Subject: [PATCH 15/20] fix: fix namespace --- .../core/runtime/namespace.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) 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 { From dc941638ed2f5b7c7ce955a7100b31deea608e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=91=E5=B8=83=E6=9E=97?= <11641432+heiheiyouyou@user.noreply.gitee.com> Date: Tue, 12 May 2026 20:00:54 +0800 Subject: [PATCH 16/20] fix: The reward score is always negative. --- .../core/reward/task-summary.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) 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); From 1e0bc71e88ac1a48e00aacd132a641a74f8e7ee7 Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Tue, 12 May 2026 21:54:34 +0800 Subject: [PATCH 17/20] fix(memos-local-plugin): hide pager when filter empties tasks list When users filter the v2 viewer tasks page by status (all/enabled/done/skipped/failed), the empty state was shown but the pager kept rendering against full-dataset total/hasMore, which misleads users into thinking "there is data on other pages". Root cause: - /api/v1/episodes did not accept a `status` filter, so total/hasMore were always computed against the full session count. - TasksView.tsx only filtered the current 20 rows on the client and rendered the pager based on (page > 0 || hasMore), independent of the filtered length. Fix: - Server: introduce shared deriveStatus + parseTaskStatusFilter (agent-contract/episode-status.ts) and add `status` query support to GET /api/v1/episodes; total/hasMore now reflect the filtered set, with q + status combinable. - Web: send status/q to the server, debounce + add deps to the loader effect, drop the client-side filter, and add a `filtered.length === 0` guard so the pager hides under any active filter that yields no rows. - i18n: new tasks.empty.filtered string (zh/en). - Tests: 11 unit cases for episode-status derivation + 4 server route cases covering status filter, pagination, garbage tolerance, and status+q combinations. Co-authored-by: Cursor --- .../agent-contract/episode-status.ts | 106 +++++++++++ .../server/routes/session.ts | 99 +++++++--- .../agent-contract/episode-status.test.ts | 178 ++++++++++++++++++ .../tests/unit/server/http.test.ts | 64 +++++++ .../memos-local-plugin/web/src/stores/i18n.ts | 6 +- .../web/src/views/TasksView.tsx | 110 ++++++----- 6 files changed, 485 insertions(+), 78 deletions(-) create mode 100644 apps/memos-local-plugin/agent-contract/episode-status.ts create mode 100644 apps/memos-local-plugin/tests/unit/agent-contract/episode-status.test.ts 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/server/routes/session.ts b/apps/memos-local-plugin/server/routes/session.ts index 4a0a76c1f..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,24 +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; + 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, - ownerAgentKind, - ownerProfileId, - includeAllNamespaces: true, - }); + + // 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, @@ -84,27 +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, - ownerAgentKind, - ownerProfileId, - includeAllNamespaces: true, - }); - 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/tests/unit/agent-contract/episode-status.test.ts b/apps/memos-local-plugin/tests/unit/agent-contract/episode-status.test.ts new file mode 100644 index 000000000..d8e0e3784 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/agent-contract/episode-status.test.ts @@ -0,0 +1,178 @@ +/** + * Shared episode-status derivation — unit tests. + * + * Pins the classification rules used by both the HTTP server's + * `GET /api/v1/episodes?status=…` filter and the viewer's task-status + * chip group. The two consumers must agree on every branch, so the + * tests cover each rule in `deriveEpisodeStatus` explicitly. + */ +import { describe, expect, it } from "vitest"; + +import { + ACTIVE_GRACE_WINDOW_MS, + R_NEGATIVE_FLOOR, + deriveEpisodeStatus, + parseTaskStatusFilter, +} from "../../../agent-contract/episode-status.js"; +import type { EpisodeListItemDTO } from "../../../agent-contract/dto.js"; + +function row(overrides: Partial = {}): EpisodeListItemDTO { + return { + id: "e1", + sessionId: "s1", + startedAt: 0, + status: "closed", + turnCount: 0, + ...overrides, + } as EpisodeListItemDTO; +} + +describe("deriveEpisodeStatus", () => { + const NOW = 1_000_000; + + it("returns active for open episodes", () => { + expect(deriveEpisodeStatus(row({ status: "open" }), NOW)).toBe("active"); + }); + + it("returns active inside the recently-finalized grace window", () => { + expect( + deriveEpisodeStatus( + row({ + status: "closed", + closeReason: "finalized", + endedAt: NOW - 1, + }), + NOW, + ), + ).toBe("active"); + }); + + it("falls out of active once the grace window has fully elapsed", () => { + expect( + deriveEpisodeStatus( + row({ + status: "closed", + closeReason: "finalized", + endedAt: NOW - ACTIVE_GRACE_WINDOW_MS - 1, + turnCount: 4, + }), + NOW, + ), + ).toBe("completed"); + }); + + it("classifies clearly-negative rTask as failed", () => { + expect( + deriveEpisodeStatus( + row({ + rTask: R_NEGATIVE_FLOOR - 0.01, + closeReason: "finalized", + endedAt: NOW - ACTIVE_GRACE_WINDOW_MS - 1, + }), + NOW, + ), + ).toBe("failed"); + }); + + it("treats slight negatives above the floor as completed (soft-fail)", () => { + expect( + deriveEpisodeStatus( + row({ + rTask: R_NEGATIVE_FLOOR + 0.01, + closeReason: "finalized", + endedAt: NOW - ACTIVE_GRACE_WINDOW_MS - 1, + }), + NOW, + ), + ).toBe("completed"); + }); + + it("returns skipped when reward pipeline opted out", () => { + expect( + deriveEpisodeStatus( + row({ + status: "closed", + rewardSkipped: true, + closeReason: "finalized", + endedAt: NOW - ACTIVE_GRACE_WINDOW_MS - 1, + }), + NOW, + ), + ).toBe("skipped"); + }); + + it("returns completed when a skill was generated, even with null rTask", () => { + expect( + deriveEpisodeStatus( + row({ + status: "closed", + skillStatus: "generated", + closeReason: "finalized", + endedAt: NOW - ACTIVE_GRACE_WINDOW_MS - 1, + }), + NOW, + ), + ).toBe("completed"); + }); + + it("returns skipped when episode was abandoned and no other signal", () => { + expect( + deriveEpisodeStatus( + row({ + status: "closed", + closeReason: "abandoned", + endedAt: NOW - ACTIVE_GRACE_WINDOW_MS - 1, + }), + NOW, + ), + ).toBe("skipped"); + }); + + it("returns completed when ≥2 user turns even without a reward", () => { + expect( + deriveEpisodeStatus( + row({ + status: "closed", + turnCount: 2, + closeReason: "finalized", + endedAt: NOW - ACTIVE_GRACE_WINDOW_MS - 1, + }), + NOW, + ), + ).toBe("completed"); + }); + + it("falls back to skipped for short, unscored episodes", () => { + expect( + deriveEpisodeStatus( + row({ + status: "closed", + turnCount: 1, + closeReason: "finalized", + endedAt: NOW - ACTIVE_GRACE_WINDOW_MS - 1, + }), + NOW, + ), + ).toBe("skipped"); + }); +}); + +describe("parseTaskStatusFilter", () => { + it("accepts known status slugs verbatim", () => { + for (const slug of ["active", "completed", "skipped", "failed"] as const) { + expect(parseTaskStatusFilter(slug)).toBe(slug); + } + }); + + it("collapses unknown / empty / null to no-filter", () => { + expect(parseTaskStatusFilter(null)).toBe(""); + expect(parseTaskStatusFilter(undefined)).toBe(""); + expect(parseTaskStatusFilter("")).toBe(""); + expect(parseTaskStatusFilter("nonsense")).toBe(""); + expect(parseTaskStatusFilter("FAILED")).toBe(""); + }); + + it("trims surrounding whitespace before matching", () => { + expect(parseTaskStatusFilter(" failed ")).toBe("failed"); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/server/http.test.ts b/apps/memos-local-plugin/tests/unit/server/http.test.ts index 8e3135fdc..cdb88d115 100644 --- a/apps/memos-local-plugin/tests/unit/server/http.test.ts +++ b/apps/memos-local-plugin/tests/unit/server/http.test.ts @@ -298,6 +298,70 @@ describe("HTTP server — REST routes", () => { expect(body.episodeIds).toEqual(["e1", "e2"]); }); + it("GET /api/v1/episodes?status=failed filters by derived status", async () => { + // Mix of failed (rTask <= -0.5) and completed/active rows so the + // server has to actually filter — the viewer used to do this in + // the browser on top of one paginated page, which broke pagination. + (core.listEpisodeRows as any).mockResolvedValueOnce([ + { id: "f1", sessionId: "s1", startedAt: 1, endedAt: 2, status: "closed", rTask: -0.8, turnCount: 2 }, + { id: "c1", sessionId: "s1", startedAt: 3, endedAt: 4, status: "closed", rTask: 0.5, turnCount: 2 }, + { id: "f2", sessionId: "s1", startedAt: 5, endedAt: 6, status: "closed", rTask: -0.7, turnCount: 2 }, + ]); + const r = await fetch(`${handle.url}/api/v1/episodes?status=failed`); + expect(r.status).toBe(200); + const body = (await r.json()) as { episodes: Array<{ id: string }>; total: number }; + expect(body.episodes.map((e) => e.id)).toEqual(["f1", "f2"]); + expect(body.total).toBe(2); + }); + + it("GET /api/v1/episodes?status=failed paginates over the filtered set", async () => { + // 4 failed + 2 completed; pageSize=2 so the test exercises + // limit/offset on the filter result, not on the raw scan window. + (core.listEpisodeRows as any).mockResolvedValue([ + { id: "f1", sessionId: "s1", startedAt: 1, endedAt: 2, status: "closed", rTask: -0.8, turnCount: 2 }, + { id: "c1", sessionId: "s1", startedAt: 3, endedAt: 4, status: "closed", rTask: 0.5, turnCount: 2 }, + { id: "f2", sessionId: "s1", startedAt: 5, endedAt: 6, status: "closed", rTask: -0.7, turnCount: 2 }, + { id: "f3", sessionId: "s1", startedAt: 7, endedAt: 8, status: "closed", rTask: -0.6, turnCount: 2 }, + { id: "c2", sessionId: "s1", startedAt: 9, endedAt: 10, status: "closed", rTask: 0.5, turnCount: 2 }, + { id: "f4", sessionId: "s1", startedAt: 11, endedAt: 12, status: "closed", rTask: -0.7, turnCount: 2 }, + ]); + + const r1 = await fetch(`${handle.url}/api/v1/episodes?status=failed&limit=2&offset=0`); + const b1 = (await r1.json()) as { episodes: Array<{ id: string }>; total: number; nextOffset?: number }; + expect(b1.episodes.map((e) => e.id)).toEqual(["f1", "f2"]); + expect(b1.total).toBe(4); + expect(b1.nextOffset).toBe(2); + + const r2 = await fetch(`${handle.url}/api/v1/episodes?status=failed&limit=2&offset=2`); + const b2 = (await r2.json()) as { episodes: Array<{ id: string }>; total: number; nextOffset?: number }; + expect(b2.episodes.map((e) => e.id)).toEqual(["f3", "f4"]); + expect(b2.total).toBe(4); + expect(b2.nextOffset).toBeUndefined(); + }); + + it("GET /api/v1/episodes?status=garbage falls back to no filter", async () => { + // Unknown status slugs must not 400 — the viewer's chip group + // uses `""` for "all", and any future slug should degrade + // gracefully rather than break the entire list. + const r = await fetch(`${handle.url}/api/v1/episodes?status=garbage`); + expect(r.status).toBe(200); + const body = (await r.json()) as { episodes: Array<{ id: string }>; total: number }; + expect(body.episodes.map((e) => e.id)).toEqual(["e1", "e2"]); + expect(body.total).toBe(2); + }); + + it("GET /api/v1/episodes?status=…&q=… combines status + preview search", async () => { + (core.listEpisodeRows as any).mockResolvedValue([ + { id: "f1", sessionId: "s1", startedAt: 1, endedAt: 2, status: "closed", rTask: -0.8, turnCount: 2, preview: "deploy failed twice" }, + { id: "f2", sessionId: "s1", startedAt: 3, endedAt: 4, status: "closed", rTask: -0.6, turnCount: 2, preview: "another failure" }, + { id: "c1", sessionId: "s1", startedAt: 5, endedAt: 6, status: "closed", rTask: 0.5, turnCount: 2, preview: "deploy succeeded" }, + ]); + const r = await fetch(`${handle.url}/api/v1/episodes?status=failed&q=deploy`); + const body = (await r.json()) as { episodes: Array<{ id: string }>; total: number }; + expect(body.episodes.map((e) => e.id)).toEqual(["f1"]); + expect(body.total).toBe(1); + }); + it("POST /api/v1/feedback accepts explicit polarity", async () => { const r = await fetch(`${handle.url}/api/v1/feedback`, { method: "POST", diff --git a/apps/memos-local-plugin/web/src/stores/i18n.ts b/apps/memos-local-plugin/web/src/stores/i18n.ts index 208f19696..f5cbe7997 100644 --- a/apps/memos-local-plugin/web/src/stores/i18n.ts +++ b/apps/memos-local-plugin/web/src/stores/i18n.ts @@ -480,7 +480,8 @@ const en = { "tasks.title": "Tasks", "tasks.subtitle": "Each task is a focused span of conversation. Click a row to see what was said and whether an experience or skill was generated.", "tasks.search.placeholder": "Search tasks…", - "tasks.empty": "No tasks match this filter.", + "tasks.empty": "No tasks yet.", + "tasks.empty.filtered": "No tasks match the current filter.", "tasks.untitled": "Untitled task", "tasks.detail.id": "Task {id}", "tasks.detail.fallbackTitle": "Task detail", @@ -1286,7 +1287,8 @@ const zh: Record = { "tasks.title": "任务", "tasks.subtitle": "每个任务都是一段聚焦的对话。点进去可以看到说过什么,以及是否生成了经验或技能。", "tasks.search.placeholder": "搜索任务…", - "tasks.empty": "没有匹配的任务。", + "tasks.empty": "暂无任务。", + "tasks.empty.filtered": "当前筛选条件下没有匹配的任务。", "tasks.untitled": "未命名任务", "tasks.detail.id": "任务 {id}", "tasks.detail.fallbackTitle": "任务详情", diff --git a/apps/memos-local-plugin/web/src/views/TasksView.tsx b/apps/memos-local-plugin/web/src/views/TasksView.tsx index 9657b77d3..19bb485e6 100644 --- a/apps/memos-local-plugin/web/src/views/TasksView.tsx +++ b/apps/memos-local-plugin/web/src/views/TasksView.tsx @@ -17,8 +17,18 @@ import { route } from "../stores/router"; import { clearEntryId, linkTo } from "../stores/cross-link"; import { ChatLog, flattenChat, type TimelineTrace } from "./tasks-chat"; import { areAllIdsSelected, toggleIdsInSelection } from "../utils/selection"; +import { + deriveEpisodeStatus, + type DerivedTaskStatus, + type TaskStatusFilter, +} from "../../../agent-contract/episode-status"; -type TaskStatus = "" | "active" | "completed" | "skipped" | "failed"; +/** + * Debounce window for the search box. Enough to coalesce a typical + * keystroke burst into one request without making the chip-group + * filter feel sluggish. + */ +const SEARCH_DEBOUNCE_MS = 250; interface EpisodeRow { id: string; @@ -66,7 +76,11 @@ const DEFAULT_PAGE_SIZE = 20; export function TasksView() { const [query, setQuery] = useState(""); - const [status, setStatus] = useState(""); + // Coalesce search-box keystrokes so we don't slam the server with + // a request per character. The chip-group filter (`status`) feels + // discrete to the user and triggers a request immediately. + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [status, setStatus] = useState(""); const [namespaceFilter, setNamespaceFilter] = useState(""); const [rows, setRows] = useState(null); const [loading, setLoading] = useState(false); @@ -86,12 +100,19 @@ export function TasksView() { }); }; + useEffect(() => { + const handle = window.setTimeout(() => setDebouncedQuery(query), SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(handle); + }, [query]); + const loadPage = (nextPage: number) => { const ctrl = new AbortController(); setLoading(true); const qs = new URLSearchParams(); qs.set("limit", String(pageSize)); qs.set("offset", String(nextPage * pageSize)); + if (status) qs.set("status", status); + if (debouncedQuery) qs.set("q", debouncedQuery); appendNamespaceParams(qs, namespaceFilter); api .get( @@ -118,7 +139,7 @@ export function TasksView() { const ctrl = loadPage(0); return () => ctrl.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pageSize, namespaceFilter, route.value.params.id]); + }, [pageSize, status, debouncedQuery, namespaceFilter, route.value.params.id]); useEffect(() => { const id = route.value.params.id; @@ -173,20 +194,15 @@ export function TasksView() { return () => ctrl.abort(); }, [detail?.id]); - const filtered = (rows ?? []).filter((r) => { - if (query) { - const q = query.toLowerCase(); - const hay = `${r.preview ?? ""} ${r.id}`.toLowerCase(); - if (!hay.includes(q)) return false; - } - if (status) { - const derived = deriveStatus(r); - if (derived !== status) return false; - } - return true; - }); + // The server is now the single source of truth for filtering — the + // viewer used to do a per-page `Array.filter` that left the pager + // showing stale page counts when the chip-group filter zeroed out + // the visible rows. We keep the local alias so the rest of the + // render code stays expressive. + const filtered = rows ?? []; const pageIds = filtered.map((r) => r.id); const isPageSelected = areAllIdsSelected(selected, pageIds); + const filterActive = !!status || debouncedQuery.length > 0; const togglePageSelection = () => { setSelected((prev) => toggleIdsInSelection(prev, pageIds)); @@ -210,11 +226,15 @@ export function TasksView() {
-
{t("tasks.empty")}
+
+ {filterActive ? t("tasks.empty.filtered") : t("tasks.empty")} +
)} @@ -351,7 +373,14 @@ export function TasksView() { )} - {(page > 0 || hasMore) && ( + {/* + * Hide the pager when the current view has nothing to show. + * Without this the pager kept rendering "1/2/3 of 45" while + * the list above said "no matches" — every navigation button + * would just reload the same empty filter result, which made + * users think the data was lost. + */} + {filtered.length > 0 && (page > 0 || hasMore) && ( = 2) return "completed"; - return "skipped"; +/** + * Local adapter around the shared episode-status derivation. The + * server uses the exact same function under the hood when applying + * `?status=…` filtering, so the chip selection above and the pill + * rendered on each row can never disagree. + */ +function deriveStatus(r: EpisodeRow): DerivedTaskStatus { + return deriveEpisodeStatus(r as unknown as Parameters[0]); } /** From 164b2090bce8519cfb585a3d8ccee516065c94fb Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Tue, 12 May 2026 22:11:34 +0800 Subject: [PATCH 18/20] chore(memos-local-plugin): bump version to 2.0.0-beta.13 Release including 0509 (namespace visibility), 0509-magent (multi-agent owner filtering), and the tasks-list pager bug fix integrated on mem-agent-0512. Co-authored-by: Cursor --- apps/memos-local-plugin/package-lock.json | 4 ++-- apps/memos-local-plugin/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/memos-local-plugin/package-lock.json b/apps/memos-local-plugin/package-lock.json index 872acb50d..4e42ceec2 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.12", + "version": "2.0.0-beta.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@memtensor/memos-local-plugin", - "version": "2.0.0-beta.12", + "version": "2.0.0-beta.13", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/apps/memos-local-plugin/package.json b/apps/memos-local-plugin/package.json index bb9959864..9229d77bf 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.12", + "version": "2.0.0-beta.13", "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", From acaa7d85ef3537aed97b25dad1c9e67ceac31aa8 Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Wed, 13 May 2026 12:00:56 +0800 Subject: [PATCH 19/20] chore(memos-local-plugin): release 2.0.1 Published to npm as @memtensor/memos-local-plugin@2.0.1 (latest). Co-authored-by: Cursor --- apps/memos-local-plugin/package-lock.json | 4 ++-- apps/memos-local-plugin/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/memos-local-plugin/package-lock.json b/apps/memos-local-plugin/package-lock.json index 4e42ceec2..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.13", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@memtensor/memos-local-plugin", - "version": "2.0.0-beta.13", + "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 9229d77bf..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.13", + "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", From 507ef0b7fdf01bfec448a95ba86b59a3edc6a02e Mon Sep 17 00:00:00 2001 From: jiang Date: Wed, 13 May 2026 14:56:27 +0800 Subject: [PATCH 20/20] chore: update website --- .../website/docs/index.html | 6 +++++- apps/memos-local-plugin/website/index.html | 21 ++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/memos-local-plugin/website/docs/index.html b/apps/memos-local-plugin/website/docs/index.html index 5e3d9081d..1ed886fb5 100644 --- a/apps/memos-local-plugin/website/docs/index.html +++ b/apps/memos-local-plugin/website/docs/index.html @@ -81,9 +81,13 @@

OpenClaw 与 Hermes 的本地进化记忆系统

快速开始Quick Start

-

安装器支持 macOS / Linux,会自动检查 Node.js 20+、下载 npm 包、重建原生依赖、写入 config.yaml,并按需启动宿主运行时。The installer supports macOS/Linux. It checks Node.js 20+, downloads the npm package, rebuilds native dependencies, writes config.yaml, and starts the host runtime when needed.

+

安装器支持 macOS / Linux / Windows,会自动检查 Node.js 20+、下载 npm 包、重建原生依赖、写入 config.yaml,并按需启动宿主运行时。The installer supports macOS/Linux/Windows. It checks Node.js 20+, downloads the npm package, rebuilds native dependencies, writes config.yaml, and starts the host runtime when needed.

+

macOS / Linux:macOS / Linux:

# Latest from the repository
 curl -fsSL https://raw.githubusercontent.com/MemTensor/MemOS/main/apps/memos-local-plugin/install.sh | bash
+

Windows (PowerShell):Windows (PowerShell):

+
# Latest from the repository
+irm https://raw.githubusercontent.com/MemTensor/MemOS/main/apps/memos-local-plugin/install.ps1 | iex

从源码目录安装或测试指定版本:Install from a source checkout or test a specific version:

cd apps/memos-local-plugin
 bash install.sh --version 2.0.0-beta.11
diff --git a/apps/memos-local-plugin/website/index.html b/apps/memos-local-plugin/website/index.html
index 4a2ea14e1..6a7a9e44f 100644
--- a/apps/memos-local-plugin/website/index.html
+++ b/apps/memos-local-plugin/website/index.html
@@ -595,11 +595,14 @@ 

+
# 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
+
$curl -fsSL https://raw.githubusercontent.com/MemTensor/MemOS/main/apps/memos-local-plugin/install.sh | bash
+ +
@@ -750,6 +753,7 @@

1. 一键安装/升级1.
+
@@ -757,7 +761,13 @@

1. 一键安装/升级1.
$ curl -fsSL https://raw.githubusercontent.com/MemTensor/MemOS/main/apps/memos-local-plugin/install.sh | bash - + +
+ +

@@ -1238,12 +1248,17 @@

让 OpenClaw 与 Hermes
共享进 document.querySelectorAll('[data-install-switcher]').forEach(function(sw){ var buttons=sw.querySelectorAll('.os-btn'); var commands=sw.querySelectorAll('[data-os-cmd]'); + var notes=sw.querySelectorAll('.install-switcher-note'); + var rows=sw.querySelectorAll('.install-switcher-row'); var copyBtn=sw.querySelector('.install-copy-btn'); function setOs(os){ buttons.forEach(function(btn){btn.classList.toggle('active',btn.getAttribute('data-os')===os);}); commands.forEach(function(cmd){cmd.style.display=cmd.getAttribute('data-os-cmd')===os?'':'none';}); + notes.forEach(function(note,idx){note.style.display=(os==='unix'&&idx===0)||(os==='windows'&&idx===1)?'':'none';}); + rows.forEach(function(row,idx){row.style.display=(os==='unix'&&idx===0)||(os==='windows'&&idx===1)?'':'none';}); if(copyBtn){ - copyBtn.setAttribute('data-copy',copyBtn.getAttribute('data-copy-unix')||''); + var copyAttr=os==='windows'?'data-copy-windows':'data-copy-unix'; + copyBtn.setAttribute('data-copy',copyBtn.getAttribute(copyAttr)||''); } } buttons.forEach(function(btn){btn.addEventListener('click',function(){setOs(btn.getAttribute('data-os'));});});