diff --git a/.gitignore b/.gitignore index 05d772a..d2de7ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ # *.pdf — now included node_modules/ .tools/ -tools/ +# tools/ is tracked. Generated data + screenshots are ignored below. +tools/memory-graph.json +tools/vec-map.json +tools/vec-map-3d.json +tools/vec-map.png +tools/vec-map-3d.png +tools/screenshots/ screenpipe-build.log .memos/ .claude/ @@ -20,3 +26,18 @@ agents-auth.json.* __pycache__/ *.pyc *.pyo + +# MCP tool scratch (playwright session logs / page snapshots) +.playwright-mcp/ + +# Repo-root test artefacts (Playwright screenshot diffs, perf-audit JSON dumps). +# These are local; reproduce with the scripts in tools/. +/viewer-*.png +/memory-map-test-*.png +/umap-*.png +/umap3d-*.png +/ui-*.png +/perf-audit-results-*.json + +# Node modules anywhere in nested test workspaces +**/node_modules/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..b3403e7 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "hermes-memory": { + "command": "/home/openclaw/Coding/Hermes-CTO/.venv/bin/python", + "args": ["/home/openclaw/Coding/Hermes-CTO/mcp/cto_memory_mcp.py"], + "env": { + "CTO_MEM_PORT": "18811", + "CTO_PROFILE_ID": "claude-code" + } + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index ca611dc..f631028 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,23 +2,35 @@ ## 🚨 Active sprint — read this first -**Single-tier memory architecture: MemOS only. Holographic Tier 1 deprecated 2026-04-28. v1 (MemOS server) remains the production target.** +**Memory architecture: v2 `@memtensor/memos-local-plugin` only. Paperclip + CEO orchestration retired 2026-05-17. v1 MemOS server stopped + disabled — kept on disk for rollback only.** -Any new agent working in this repo should read these before acting: +**2026-05-27 — durable fixes landed + memory-quality overhaul + roadmap.** The 05-26 "STILL PENDING" items are **done**: idempotency guard (`traces_idempotent_turn` BEFORE INSERT trigger — duplicate `(episode_id,turn_id)` inserts silently dropped), bridge `session.open` timeout 30→120s, the dangerous `pkill -f 'bridge\.cts'` band-aids replaced by `scripts/lib-bridge-safe-cleanup.sh` (never kills the `:18800` daemon), and the `:18800` daemon promoted to `hermes-memos-daemon.service` (systemd, Restart=always). Plus a memory-quality overhaul (now **22 patches**): failure-avoidance L2 induction, world-model embedding-merge dedup, error-outcome reward penalty, skill crystallization bar (minSupport 1→3) + usage-on-retrieval tracking; DB deduped 95,174→~324 real traces + VACUUM. Viewer: login routing fix, console-error fixes, map memories-toggle + reward-heat + bubble drill-down. **Remaining work is phased in the roadmap** ([`memos-setup/learnings/2026-05-27-memory-roadmap.md`](memos-setup/learnings/2026-05-27-memory-roadmap.md)): Phase 1 stability (background boot-reflection, serialize bridge boots, auto-prune skills), Phase 2 feedback loop (per-step verifier, implicit human signal), Phase 3 shared single daemon. **Standing process:** every memory change now updates the comprehensive report — edit `docs/architecture/2026-05-27-memory-comprehensive-report-deck.html`, run `tools/build-memory-report.sh`, commit, refresh the Drive Doc. + +**2026-05-26 — capture outage RCA + recovery; extraction LLM is DeepSeek (not Gemini). ⚠️ one durable fix still pending.** Memory *looked* reset but nothing was deleted — the v2 store simply only goes back to its birth on 2026-05-12, and capture had stalled. Two root causes found & the acute damage repaired: (1) a runaway arinze capture loop wrote **65,741 trace rows for 6 real turns** (timed-out `subagent.record` re-inserting non-idempotently; cost was trivial ~$0.01, the damage was DB bloat) — **purged** (95,174→27,927 traces, FTS rebuilt, backup `memos.db.bak-20260526-061139`); (2) each gateway bridge cold-loads BGE-large, and concurrent boots starve the CPU past the bridge `session.open` timeout → respawn → **process-leak/CPU death-spiral** (29 leaked `bridge.cts`). Stopped the spiral and restarted gateways **staggered** (load 7.93→0.57, bridges stable, daemon healthy). **STILL PENDING (needs a decision):** the durable bridge-concurrency fix (raise timeouts / serialize boots / make gateways thin clients of one daemon), idempotent capture retry, and neutralizing the dangerous `pkill -f 'bridge\.cts'` band-aid scripts. Also: extraction + skillEvolver LLMs were **reverted Gemini → DeepSeek on 2026-05-25** (account refunded). Decision doc: [`memos-setup/learnings/2026-05-26-capture-outage-rca-recovery.md`](memos-setup/learnings/2026-05-26-capture-outage-rca-recovery.md). -1. **Decision doc (current):** [`memos-setup/learnings/2026-04-28-collapse-to-single-tier-memos.md`](memos-setup/learnings/2026-04-28-collapse-to-single-tier-memos.md) — why holographic was deprecated, what's gone, what's left, rollback path -2. **Previous direction (still relevant context):** [`memos-setup/learnings/2026-04-27-v2-deprecated-revert-to-v1.md`](memos-setup/learnings/2026-04-27-v2-deprecated-revert-to-v1.md) — why v2 was deprecated, what was fixed in v1 -3. **MVP-readiness brief:** [`tests/v1/reports/combined/v1-mvp-readiness-2026-04-26.pdf`](tests/v1/reports/combined/v1-mvp-readiness-2026-04-26.pdf) — pre-fix audit + remediation plan -4. **Two-repo team explainer:** [`docs/architecture/two-repos.pdf`](docs/architecture/two-repos.pdf) — how Hermes (this repo) and MemOS (`sergiocoding96/MemOS`, your fork) fit together -5. **Operator runbook:** [`tests/v1/STEP-BY-STEP.md`](tests/v1/STEP-BY-STEP.md) and [`tests/v1/CC-PROMPTS.md`](tests/v1/CC-PROMPTS.md) — phase-by-phase commands for fix → re-audit → ship -6. **Sprint 1 history (still relevant):** [`memos-setup/learnings/2026-04-20-sprint-merge-log.md`](memos-setup/learnings/2026-04-20-sprint-merge-log.md) — what was shipped in the original v1 hardening sprint -7. **Superseded — historical only:** [`memos-setup/learnings/2026-04-20-v2-migration-plan.md`](memos-setup/learnings/2026-04-20-v2-migration-plan.md) (the original v2 migration plan) +**2026-05-24 — memory extraction LLM switched DeepSeek → Gemini 2.5 Flash-Lite; tower resource cleanup.** *(Superseded 2026-05-25: reverted to DeepSeek — see the 2026-05-26 entry above.)* DeepSeek hit $0 balance → every memory call `402`'d, the boot-time rescore looped on retries (584 failed calls), blocked the daemon's `server.started`, and was the high memos CPU under investigation. Switched the plugin `llm:` block to `gemini-2.5-flash-lite` via the AI Studio free tier (no `` pollution — Gemini reasoning is separate metadata, not inline). Same session: stopped retired v1 MemOS containers (`neo4j-docker`, `qdrant`) and orphaned Camoufox, stopped the idle Firecrawl stack, and added an on-demand Firecrawl wrapper (`tools/firecrawl-ctl.sh` + `firecrawl-idle-monitor.timer`). RAM free 189 MiB → ~10 GiB, load 83 → ~1. Decision doc: [`memos-setup/learnings/2026-05-24-deepseek-402-gemini-switch.md`](memos-setup/learnings/2026-05-24-deepseek-402-gemini-switch.md). -If you are working inside a **worktree** under `~/Coding/Hermes-wt/` or `~/Coding/MemOS-wt/`, read the `TASK.md` in that directory — it's your full brief. +**2026-05-25 — boot-hang follow-up RESOLVED: bounded capture-pipeline LLM calls.** The "oversized boot-backlog batches" follow-up previously flagged here is fixed durably. The batched reflection scorer now chunks a closed episode into `≤ batchThreshold`-step calls (`scoreBatchChunk` in `core/capture/capture.ts`), caps the per-step `tool_calls` array at 24 with a hard `maxTokens` (4096) on the response (`core/capture/batch-scorer.ts`), and the reflect-pass orphan-fallback skips the LLM (heuristic summaries — `core/capture/summarizer.ts`). A cold bridge boot over a synthetic 100-step dirty episode completes in seconds with zero `malformed`/`timedOut` warnings; every batch call stays ≤12 steps. Same session also folded the bridge keepalive `10→90s` + close-on-failed-boot patches into the patch set, and repointed `skillEvolver` → NVIDIA integrate API in `config.yaml` (secret key stays in user config, not committed). Decision doc: [`memos-setup/learnings/2026-05-25-bounded-capture-llm-boot-hang.md`](memos-setup/learnings/2026-05-25-bounded-capture-llm-boot-hang.md). See patches 5–7 in the v2-plugin-patches section. -**Architecture status (2026-04-28):** Single-tier MemOS. Two-tier holographic+MemOS design was aspirational — Tier 1 (`holographic`) had zero rows in every profile after weeks of operation. Collapsed to one stack: agents read/write MemOS via `memos-toolset`, plus the always-on built-in memory layer in hermes-agent core. v2 plugin (`@memtensor/memos-local-plugin`) remains deprecated. +**2026-05-21 — CTO agent added (on Claude Code, not Hermes).** A standalone CTO persona lives at `~/Coding/Hermes-CTO`, wired to the v2 plugin as a new `cto` profile (memory tools via MCP + auto-capture hooks, all over the stdio bridge). It advises, delegates to the Hermes agents (`scripts/delegate-agent.sh` → `hermes -z`), and is callable by them (`scripts/ask-cto.sh` → `claude -p`). This re-introduces *peer* cross-agent interaction but **no central orchestrator and no cross-machine routing** — the v2-only stance otherwise holds. Same session also fixed the dirty-episode cold-boot stall (7 episodes marked `reward.skipped`) that caused the bridge process leak. + +Any new agent working in this repo should read these before acting: -**Sprint 2 status (2026-04-27):** v2 audit failed (mean 2.4/10, min 1/10). v1 audit (clean re-run) found a fixable system at mean 5.2/10 with five surgical bugs. All five fixed across 6 PRs (Hermes #14/#15/#16, MemOS #6/#7/#8). v2 stays as a dormant spike; do not enable in production. +1. **Roadmap + latest state (start here):** [`memos-setup/learnings/2026-05-27-memory-roadmap.md`](memos-setup/learnings/2026-05-27-memory-roadmap.md) — phased plan (stability → feedback loop → shared daemon); paired RCA [`2026-05-26-capture-outage-rca-recovery.md`](memos-setup/learnings/2026-05-26-capture-outage-rca-recovery.md). Full end-to-end report: [`docs/architecture/Hermes-Memory-System-Comprehensive-Report-2026-05-27.pdf`](docs/architecture/Hermes-Memory-System-Comprehensive-Report-2026-05-27.pdf) (source deck `.html` alongside; rebuild with `tools/build-memory-report.sh`). +2. **Earlier decision doc (CTO agent + dirty-episode fix):** [`memos-setup/learnings/2026-05-21-cto-agent-on-claude-code.md`](memos-setup/learnings/2026-05-21-cto-agent-on-claude-code.md) — CTO-on-Claude-Code, memory wiring, cross-agent invocation, cold-boot fix +2. **Memory architecture decision:** [`memos-setup/learnings/2026-05-17-v2-only-bge-shares.md`](memos-setup/learnings/2026-05-17-v2-only-bge-shares.md) — drop CEO/Paperclip, commit to v2, BGE-large embedder, share_scope policy +3. **Previous decision (single-tier MemOS):** [`memos-setup/learnings/2026-04-28-collapse-to-single-tier-memos.md`](memos-setup/learnings/2026-04-28-collapse-to-single-tier-memos.md) — superseded by the May 17 doc; preserved for context on what was tried before +4. **Previous direction (v1 audit + remediation):** [`memos-setup/learnings/2026-04-27-v2-deprecated-revert-to-v1.md`](memos-setup/learnings/2026-04-27-v2-deprecated-revert-to-v1.md) — historical; v2 is now the chosen path despite the original audit +5. **Merged architecture brief (start here for slides):** [`docs/architecture/2026-05-17-memory-system-brief.pdf`](docs/architecture/2026-05-17-memory-system-brief.pdf) — 34-slide consolidated brief: decisions + orphan-cron root cause + plugin patches + tooling + speaker attribution + final state. Merge of the two source decks below, dedup'd. Editable source: [`2026-05-17-memory-system-brief.pptx`](docs/architecture/2026-05-17-memory-system-brief.pptx). + - Source A: [`2026-05-17-memory-system-decisions.pptx`](docs/architecture/2026-05-17-memory-system-decisions.pptx) — original architectural review deck + - Source B: [`2026-05-17-session-summary.pptx`](docs/architecture/2026-05-17-session-summary.pptx) — one-day overhaul summary deck +6. **MVP-readiness brief (historical):** [`tests/v1/reports/combined/v1-mvp-readiness-2026-04-26.pdf`](tests/v1/reports/combined/v1-mvp-readiness-2026-04-26.pdf) — pre-fix v1 audit; relevant only if you ever revisit v1 +7. **Two-repo team explainer:** [`docs/architecture/two-repos.pdf`](docs/architecture/two-repos.pdf) — how this repo and the MemOS fork relate +8. **Superseded — historical only:** [`memos-setup/learnings/2026-04-20-v2-migration-plan.md`](memos-setup/learnings/2026-04-20-v2-migration-plan.md) + +If you are working inside a **worktree** under `~/Coding/Hermes-wt/`, read the `TASK.md` in that directory — it's your full brief. + +**Architecture status (2026-05-17):** Single machine, single memory tier, single plugin. Every agent runs on this box (`hostname` = `sergio`, Tailnet DNS name = `tower.taila4a33f.ts.net` — same machine, two names). Each Hermes agent has its own profile dir (`~/.hermes/profiles//`). The v2 plugin provides isolation via row-level namespace tuples — no cubes, no API keys to manage. Skills + world_model are shared across agents (`share_scope: local`); traces, episodes, and policies are per-agent private. v1 MemOS server (Qdrant + Neo4j + SQLite) and Paperclip/CEO are retired. ## Working Rules - **ALWAYS use parallel agents for independent tasks.** When multiple fixes, tests, or investigations can run simultaneously, launch them all in one message. Never serialize work that can be parallelized. @@ -34,53 +46,164 @@ If you are working inside a **worktree** under `~/Coding/Hermes-wt/` or `~/Codin **Agents starting a fresh session:** before doing anything else, spot-check that the "🚨 Active sprint" header matches the most recent decision doc in `memos-setup/learnings/` (sort by date) and the most recent strategic merge commits on `main`. If the header is stale, flag it to the operator and propose an update before continuing the requested task. Stale strategic context is the failure mode this rule exists to prevent. ## What This Is -Layered multi-agent system: CEO (Claude Opus 4.6 via Paperclip) orchestrates specialized Hermes agents, each with isolated MemOS memory cubes. Two feedback loops: soft (user feedback → skill patches) and hard (Karpathy autoresearch-style metric threshold → auto-patch → re-run). + +Multi-agent Hermes setup running on a single workstation. The OS hostname is `sergio`; the Tailnet DNS name for the same box is `tower.taila4a33f.ts.net`. Operators usually SSH in from other devices, so the Tailnet name is the one you'll see in browser URLs and the local hostname is the one you'll see in shell prompts. **They are the same machine.** There is no separate Tower. + +Each agent has its own profile, its own systemd gateway service, and its own namespace inside the v2 memory plugin. Agents do not orchestrate each other — they share knowledge through the memory plugin's shared layers (skills + world_model) and stay private on raw experience (traces/episodes/policies). + +There is no CEO, no Paperclip, no cross-machine routing. ## Architecture -- **CEO Agent**: Claude Opus 4.6 on Paperclip (http://tower.taila4a33f.ts.net:3100) -- **Worker Agents**: Hermes (MiniMax M2.7) spawned via hermes-paperclip-adapter -- **Memory**: MemOS (Qdrant + Neo4j + SQLite) at localhost:8001 — single-tier; per-profile `memory.provider: ''` (no external Tier 1 plugin) -- **Web search**: Firecrawl (localhost:3002) → SearXNG (localhost:8888) — free, unlimited, aggregates Google+Bing+DDG+Startpage -- **Web scraping**: Firecrawl (localhost:3002) with Playwright service for JS-rendered pages -- **Anti-bot browser**: Camofox (localhost:9377) — Camoufox Firefox fork with C++ fingerprint spoofing, bypasses Cloudflare/anti-bot -- **Token burn rule**: Agents communicate ONLY via MemOS shared state, never agent-to-agent + +- **Agents:** `sergio`, `hr-agent`, `mohammed`, `krati`, `arinze`, `research-agent`, `email-marketing`, `lucas` — each runs as its own `hermes-gateway-.service` with its own `~/.hermes/profiles//` directory. **`lucas`** added 2026-05-18: a MiniMax-M2.7-backed agent (`provider: minimax`, `https://api.minimax.io/anthropic`) with Discord channels; runs as `hermes-gateway-lucas.service`. +- **Memory:** `@memtensor/memos-local-plugin` v2 — single daemon on `127.0.0.1:18800`, single SQLite at `~/.hermes/memos-plugin/data/memos.db`, row-level multi-tenancy via `(owner_agent_kind, owner_profile_id, share_scope)` +- **Memory UI:** `https://tower.taila4a33f.ts.net/` (Tailscale Serve proxies tailnet HTTPS 443 → loopback `:18800`). Local equivalent: `http://localhost:18800`. Password-gated; reset via `rm ~/.hermes/memos-plugin/.auth.json`. +- **Embedder:** `Xenova/bge-large-en-v1.5` (1024-dim, local ONNX via Transformers.js). Picked 2026-05-17 after 3-way benchmark vs MiniLM and gemini-embedding-2 — see decision doc. +- **Memory LLM (extraction/summarisation):** DeepSeek V3 (`deepseek-chat` via `https://api.deepseek.com`) — **reverted Gemini → DeepSeek on 2026-05-25** once the DeepSeek account was refunded (balance verified $6.82, endpoint healthy 2026-05-26). DeepSeek is non-thinking (no `` pollution) and has no free-tier RPM throttling, which the bursty capture load needs. Gemini 2.5 Flash-Lite was a 2026-05-24 stopgap while DeepSeek was at $0 (402). Rollback configs: `config.yaml.bak-deepseek-memory-2026-05-25` and the Gemini stopgap `config.yaml.bak-deepseek-2026-05-24`. See [`memos-setup/learnings/2026-05-24-deepseek-402-gemini-switch.md`](memos-setup/learnings/2026-05-24-deepseek-402-gemini-switch.md) (switch) + [`memos-setup/learnings/2026-05-26-capture-outage-rca-recovery.md`](memos-setup/learnings/2026-05-26-capture-outage-rca-recovery.md) (revert + RCA). +- **Skill crystallisation / skillEvolver LLM:** DeepSeek-R1 (`deepseek-reasoner` via `https://api.deepseek.com`) — **repointed to DeepSeek on 2026-05-25** (account refunded). Thinking-mode reasoning for structured skill synthesis + L2 induction; `` output collapsed into YAML in post-processing. Prior hops: MiniMax M2.7 (Anthropic-compat), then NVIDIA integrate API (2026-05-25, throttled). Rollback: `config.yaml.bak-skillevolver-2026-05-25`. +- **Web search:** Firecrawl (localhost:3002) → SearXNG (localhost:8888) — free, unlimited, multi-engine aggregation +- **Web scraping (default):** Firecrawl (localhost:3002) with Playwright service for JS-rendered pages +- **Stealth scraping + interactive browser tool:** **Cloak service (localhost:9378)** — CloakBrowser stealth Chromium 146, C++ fingerprint patches, persistent per-domain cookies, request pacing, CapSolver captcha fallback, full interactive surface (`/tabs/*` endpoints: snapshot/click/type/scroll/back/screenshot). `CAMOFOX_URL` env points at port 9378 since 2026-05-16. +- **Camofox (retired 2026-05-16):** systemd unit stopped + disabled. Source still on disk under `~/.hermes/hermes-agent/node_modules/@askjo/camofox-browser/` for reference. The `tools/browser_camofox.py` filename is kept (renaming would touch too many `browser_tool.py` call sites) but its module docstring is a deprecation notice — it talks to Cloak. See `tower/docs/browser-stealth-benchmark-2026-05-16.md` and `memos-setup/learnings/2026-05-16-cloak-deprecate-camofox.md`. + +### Retired components (kept for rollback only) + +- **Paperclip / CEO orchestration** — retired 2026-05-17. The CEO-on-Tower pattern was dropped along with the multi-machine architecture. +- **v1 MemOS server** (`memos-server.service`) — stopped + disabled 2026-05-16. Source remains at `/home/openclaw/Coding/MemOS/`. Cube was empty when stopped; no data loss. Restart with `systemctl --user start memos-server.service` if you ever revisit it. +- **memos-toolset (v1 client plugin)** — `plugin.yaml.disabled-2026-05-12`. Hermes doesn't load it. +- **memos-hub.service** — disabled 2026-05-16. Was thrashing in an auto-restart loop attempting to spawn a hub mode that the v2 plugin doesn't implement upstream. + +## Memory Sharing Policy + +| Table | `share_scope` | Effect | +|---|---|---| +| `traces` | `private` | Each agent sees only its own turn-by-turn captures | +| `episodes` | `private` | Session boundaries stay per-agent | +| `policies` | `private` | L2 candidate patterns are local experiments per agent | +| `world_model` | `local` | Environment + operator facts shared across agents | +| `skills` | `local` | Crystallised callable procedures shared across agents | + +Per-row attribution is automatic via the `owner_profile_id` column — when an agent reads a shared skill, the row carries the originating profile id. + +**`isVisibleTo` rule** (`core/runtime/namespace.ts:136`): a row is visible to a caller if `share_scope ∈ {local, public, hub}` OR `(owner_agent_kind, owner_profile_id)` matches the caller's namespace. + +**Auto-promotion root cause (resolved 2026-05-17):** a cron job from Sprint 3 (added 2026-05-12) ran every 15 minutes and bulk-promoted every `private` row to `local` by writing directly to SQLite — bypassing the plugin's HTTP API and namespace filter entirely. The script lived in an orphan Claude worktree (`.claude/worktrees/nice-mclaren-13f017/scripts/promote-memos-shares.py`) and was written for the (now-retired) Paperclip/CEO architecture so the orchestrator could read across cubes. Disabled in crontab on 2026-05-17 with the comment `# DISABLED 2026-05-17 (Paperclip/CEO retired …)`. Forensic audit triggers in `tools/forensic-audit.sql` capture any future re-emergence of the same pattern — apply with `sqlite3 ~/.hermes/memos-plugin/data/memos.db < tools/forensic-audit.sql`, inspect via `python3.12 tools/memos-explorer.py audit`. + +## v2 plugin patches (applied 2026-05-17) + +The bundled `@memtensor/memos-local-plugin@2.0.0` ships with no public repo, so our changes live in `tools/plugin-patches/` and are copied into the install dir by `tools/postinstall-patches.sh`. Run that script after every `npm install/update` of the plugin. Use `--check` for a read-only verification. + +Eight logical patches (patches 6–8 added 2026-05-25): + +1. **Shared-skill attribution.** `renderSkill` now appends `(learned by )` to the rendered prompt title so other agents see which profile crystallised a shared skill. Files: `core/retrieval/types.ts`, `tier1-skill.ts`, `injector.ts`, `core/pipeline/retrieval-repos.ts`. +2. **Per-request "view as " override.** A new `core/runtime/request-namespace.ts` module wraps every HTTP handler in an AsyncLocalStorage namespace context parsed from `?as_profile=` or `X-As-Profile: `. `effectiveNamespace()` in `memory-core.ts` prefers the ALS namespace over the daemon's startup-bound `activeNamespace`. `traces.ts` `listTurnKeys`/`countTurns` apply the override at SQL time so paginated turn-key results actually contain the requested profile's rows. Files: `core/runtime/request-namespace.ts` (new), `core/pipeline/memory-core.ts`, `core/storage/repos/traces.ts`, `server/http.ts`. +3. **Bundled-viewer UI overlay.** `web/dist/index.html` loads a small overlay script that injects a floating "VIEW AS …" picker top-right, fetches `/api/v1/diag/namespace`, and intercepts `window.fetch` to add `X-As-Profile`. Files: `web/dist/index.html`, `web/dist/hermes-profile-switcher.js` (new). +4. **Skill packager default share-scope.** Newly crystallised skills now default to `share_scope='local'` so they join the shared layer at write time (instead of landing `private` and needing manual promotion). Rebuilds preserve any explicit prior share state. File: `core/skill/packager.ts`. +5. **Named-speaker memory summaries (multi-human).** `MEMOS_HUMANS` env var in `~/.hermes/.env` lists the humans on this team (e.g. `Sergio:sergiopalacio96,Krati,Mohammed,Arinze`). The capture-pipeline summarizer reads it at startup and instructs the LLM to identify the speaker from `[handle]` markers prepended to user_text by the messaging gateway, then use the corresponding human's name in the summary (`"Krati asked about X"`, `"Sergio prefers Y"`). Falls back to omitting the speaker if it can't tell. Puts identity into the embedding space for sharper per-user retrieval. A small dotenv loader in `bridge.cts` reads `~/.hermes/.env` (and `~/.hermes/memos-plugin/.env` if present) so the var is visible regardless of how the daemon is spawned. Files: `core/capture/summarizer.ts`, `bridge.cts`. +6. **Chunked batch reflection (boot-hang prevention).** `runBatchScoring` splits a closed episode into `≤ batchThreshold`-step batch calls (`scoreBatchChunk`); a chunk whose batched call fails degrades to per-step for that chunk alone. `shouldBatch` now batches for both `auto` and `per_episode` (chunking, not degrade-to-per-step, keeps the prompt bounded). The reflect-pass orphan-fallback inserts use heuristic summaries (`summarizer.ts` `heuristicOnly`) so it doesn't fire one LLM call per orphan step at boot. Files: `core/capture/capture.ts`, `core/capture/summarizer.ts`. +7. **Per-step tool-call cap + maxTokens.** `core/capture/batch-scorer.ts` bounds the per-step `tool_calls` array (`capToolCalls`, keeps head+tail above 24) and forwards `maxTokens = 4096` as a hard ceiling on the batched-reflection response — the two N-scaling dimensions that produced `[llm.json] malformed op="capture.reflection.batch.v2"`. File: `core/capture/batch-scorer.ts`. +8. **Bridge keepalive / process-leak fix.** `adapters/hermes/memos_provider/__init__.py` blocks 90s (was 10s) for a cold boot so it doesn't respawn the bridge mid-boot and leak node procs, and closes a failed/timed-out bridge subprocess before dropping the handle. File: `adapters/hermes/memos_provider/__init__.py`. (The same-session `skillEvolver → NVIDIA` switch was itself superseded 2026-05-25 — skillEvolver is now `deepseek-reasoner`; see the Architecture LLM bullets. Secret keys live in user config, not committed.) **⚠️ Known-incomplete (2026-05-26):** patch #8's keepalive uses 90s, but the *initial* `session.open` path still defaults to 30s and a transport-close reconnect to 4s; under concurrent gateway cold-boots (each loading BGE-large) these short timeouts still fire → respawn/leak. See the durable-fix options in [`memos-setup/learnings/2026-05-26-capture-outage-rca-recovery.md`](memos-setup/learnings/2026-05-26-capture-outage-rca-recovery.md). + +See [`memos-setup/learnings/2026-05-25-bounded-capture-llm-boot-hang.md`](memos-setup/learnings/2026-05-25-bounded-capture-llm-boot-hang.md) for the root-cause analysis and test. + +Verify everything is wired: + +```bash +bash tools/postinstall-patches.sh --check # patches present +bash tools/stress-test.sh # 36 end-to-end checks +``` ## Key Paths - Hermes config: `~/.hermes/config.yaml` -- Hermes skills: `~/.hermes/skills/` -- Hermes env: `~/.hermes/.env` (FIRECRAWL_API_URL=http://localhost:3002) -- MemOS source: `/home/openclaw/Coding/MemOS/` -- Paperclip CEO SOUL: `~/.paperclip/instances/default/companies/.../agents/84a0aad9-.../instructions/SOUL.md` +- Hermes per-agent profiles: `~/.hermes/profiles//config.yaml` +- Hermes skills: `~/.hermes/skills/` (plus per-profile skill dirs) +- Hermes env: `~/.hermes/.env` (FIRECRAWL_API_URL=http://localhost:3002, GEMINI_API_KEY, MINIMAX_API_KEY) +- v2 plugin install: `~/.hermes/memos-plugin/` +- v2 plugin config: `~/.hermes/memos-plugin/config.yaml` +- v2 plugin DB: `~/.hermes/memos-plugin/data/memos.db` +- v2 plugin auth: `~/.hermes/memos-plugin/.auth.json` (delete to reset viewer password) +- v2 plugin logs: `~/.hermes/memos-plugin/logs/` +- MemOS v1 source (retired): `/home/openclaw/Coding/MemOS/` - Firecrawl env: `/home/openclaw/.openclaw/workspace/firecrawl/.env` -## MemOS Setup -- All agents: GeneralTextMemory + TreeTextMemory + Fine MemReader mode -- Email-marketing agent additionally gets PreferenceTextMemory -- CEO uses CompositeCubeView (reads all cubes, results tagged with cube_id) -- Worker agents use SingleCubeView (isolated to own cube) -- async_mode: "sync" for all skill writes -- visibility: "private" on all memory items -- Scheduler: enabled, local queue (no Redis) -- **Embedder:** local sentence-transformers (all-MiniLM-L6-v2, 384 dim) — no API dependency -- **MEMRADER:** DeepSeek V3 (deepseek-chat) — MiniMax broke extraction with `` tags -- **Chunk size:** 4000 tokens (was 1600 — too small for research briefs) -- **Cube isolation:** credential-based since 2026-04-27. `agent_auth.py` middleware binds each API key to a `user_id` (BCrypt-verified, prefix-bucketed); the `/product/add` handler enforces cube ownership at `server_router.py:467`. SOUL.md instructions still tell agents to only address their own cubes, but the API layer now enforces it on writes — not trust-only. -- **IMPORTANT:** Skills must chunk long output into ≤500-word blocks before POSTing to MemOS for best extraction quality. +## Demo Agents +1. **research-agent** — research-coordinator skill orchestrating sub-researchers +2. **email-marketing-agent** — plusvibe.ai email marketing agent +3. **hr-agent**, **sergio**, **mohammed**, **krati**, **arinze**, **lucas** — operational agents with per-profile gateways (`lucas` is MiniMax-M2.7-backed, added 2026-05-18) + +## Commands + +```bash +# Bootstrap web stack (Firecrawl + SearXNG + Cloak) +./setup-web-stack.sh + +# Inspect memory plugin state +curl -s localhost:18800/health +sqlite3 ~/.hermes/memos-plugin/data/memos.db "SELECT owner_profile_id, share_scope, COUNT(*) FROM traces GROUP BY 1,2" + +# Open the memory viewer (either URL works — same daemon) +xdg-open http://localhost:18800 # when sitting at the box +xdg-open https://tower.taila4a33f.ts.net/ # via Tailnet from any device +# Password set in ~/.hermes/memos-plugin/.auth.json + +# 3D Memory Map (full graph: traces ↘ policies ↘ skills + cluster labels) +# Served by the same daemon as a static page; sidebar tab in the bundled viewer +xdg-open https://tower.taila4a33f.ts.net/memory-map.html +# memory-graph.json is auto-refreshed by the WAL watcher (debounce 5min, floor 15min) +systemctl --user status memory-graph-watcher # daemon health +journalctl --user -u memory-graph-watcher -n 30 --no-pager +# Manual one-off regen: +~/.hermes/tools-venv/bin/python tools/memos-explorer.py graph-export \ + --out tools/memory-graph.json + +# Reset memory viewer password +rm ~/.hermes/memos-plugin/.auth.json +# (then re-open the viewer; it will prompt for a new password) + +# Restart a specific Hermes agent (picks up plugin config / profile changes) +systemctl --user restart hermes-gateway-sergio.service + +# Restart all Hermes agents +systemctl --user restart hermes-gateway.service \ + hermes-gateway-sergio.service hermes-gateway-hr-agent.service \ + hermes-gateway-mohammed.service hermes-gateway-krati.service \ + hermes-gateway-arinze.service hermes-gateway-research-agent.service \ + hermes-gateway-lucas.service + +# Verify web stack health +curl -s localhost:9378/health # Cloak (primary stealth scraper) +curl -s localhost:8888/search?q=test&format=json # SearXNG +curl -s localhost:3002/v1/search -X POST -H "Content-Type: application/json" \ + -d '{"query":"test","limit":1}' # Firecrawl search + +# Stealth scrape via Cloak +curl -s -X POST localhost:9378/v1/scrape -H "Content-Type: application/json" \ + -d '{"url":"https://www.idealista.com/venta-viviendas/estepona-malaga/","formats":["html","markdown"]}' + +# Service management +systemctl --user status cloak-service.service +systemctl --user restart cloak-service.service +journalctl --user -u cloak-service.service -n 50 --no-pager +``` ## Web Stack Setup (for new deployments) -Run `./setup-web-stack.sh` to bootstrap everything. Manual steps: -1. **SearXNG**: added to Firecrawl docker-compose, runs on port 8888 -2. **Firecrawl**: `cd ~/.openclaw/workspace/firecrawl && docker compose up -d` (search + scrape + Playwright) -3. **Camofox**: started by hermes-agent or via `@reboot` cron, port 9377 + +Run `./setup-web-stack.sh` to bootstrap. Manual steps: + +1. **SearXNG**: in Firecrawl docker-compose, port 8888 +2. **Firecrawl**: `cd ~/.openclaw/workspace/firecrawl && docker compose up -d` +3. **Cloak**: `systemctl --user start cloak-service.service`, port 9378 4. **Hermes config**: `web.backend: firecrawl` in `~/.hermes/config.yaml` -5. **Brave API key**: kept in `.env` as fallback but NOT the active backend (credits exhausted at ~6 days/month) +5. **Brave API key**: kept in `.env` as fallback but NOT active (credits exhausted at ~6 days/month) ### When to use which tool + | Task | Tool | Why | -|------|------|-----| +|---|---|---| | `web_search()` | Firecrawl → SearXNG | Free, unlimited, multi-engine aggregation | | `web_extract()` | Firecrawl → Playwright | Handles JS-rendered pages | -| Anti-bot sites (Idealista, etc.) | Camofox `browser_navigate` + `browser_snapshot` | Camoufox fingerprint spoofing bypasses Cloudflare | +| Anti-bot sites (Idealista, Ticketmaster, Glassdoor — anything returning `server: DataDome` or Cloudflare Turnstile) | Cloak service `localhost:9378/v1/scrape` | CloakBrowser C++ stealth patches + persistent cookies + CapSolver fallback. ~3.9s/page on clean IPs. | +| Interactive browse (click/type/snapshot) via agent's `browser` skill | Cloak service `/tabs/*` endpoints (called transparently via `browser_camofox.py` — module name kept for backward compatibility, talks to Cloak since 2026-05-16) | Chromium-based, lower latency than the old Firefox-fork path, same ARIA-snapshot format byte-for-byte. | | Simple static pages | Firecrawl `/v1/scrape` | Fast, no browser overhead | ## Domain Routing Rules (enforced in skills) @@ -88,31 +211,7 @@ Run `./setup-web-stack.sh` to bootstrap everything. Manual steps: - `github.com` → basic Firecrawl only (no Playwright/mobile flags) - SearXNG search → no rate limit (self-hosted), but be reasonable with parallel calls -## Demo Agents -1. **research-agent** — research-coordinator skill orchestrating sub-researchers -2. **email-marketing-agent** — plusvibe.ai email marketing agent - -## Commands -```bash -# Bootstrap web stack (Firecrawl + SearXNG + Camofox) -./setup-web-stack.sh - -# Start MemOS server -cd /home/openclaw/Coding/MemOS && python -m memos.api.server - -# Run provisioning (after server is up) -python setup-memos-agents.py - -# Test research agent -hermes chat -q "Research [topic]" --skill research-coordinator - -# Verify web stack health -curl -s localhost:9377/health # Camofox -curl -s localhost:8888/search?q=test&format=json # SearXNG -curl -s localhost:3002/v1/search -X POST -H "Content-Type: application/json" -d '{"query":"test","limit":1}' # Firecrawl search -``` - ## Self-Improvement - quality_score = source_count(25%) + domain_coverage(25%) + freshness(20%) + depth(20%) + zero_result_penalty(10%) -- Soft loop: user feedback → CEO patches skill +- Soft loop: user feedback → skill patch (no CEO involvement post-2026-05-17 — patches authored directly) - Hard loop: score < threshold → auto-patch → re-run → keep if improved, revert if not diff --git a/PERF-AUDIT-REPORT.md b/PERF-AUDIT-REPORT.md new file mode 100644 index 0000000..13363d5 --- /dev/null +++ b/PERF-AUDIT-REPORT.md @@ -0,0 +1,194 @@ +# Hermes v2 / `@memtensor/memos-local-hermes-plugin` — Performance Blind Audit + +**Audit marker:** `PERF-AUDIT-*` +**Date:** 2026-04-21 +**Plugin version:** 1.0.3 +**Target:** running research-agent bridge daemon on `127.0.0.1:18990`, hub on `127.0.0.1:18992` +**Host:** 4 vCPU, 15 Gi RAM (13 Gi used @ start), 108 GB disk (91% full, 9.7 GB free), swap saturated (4 Gi used) +**Harness:** custom Node.js JSON-RPC client (`perf-audit-harness.mjs`), curl+xargs for HTTP. + +--- + +## Reconnaissance + +### Advertised envelope +The plugin README makes no quantitative latency/throughput claims. Architectural claims only: 100% local, hybrid retrieval (FTS5 + vector + RRF + MMR + recency), auto-chunking, dedup with LLM judge, task summarization, skill evolution. + +### Pipeline +``` +ingest RPC -> plugin.onConversationTurn(msgs) ⟵ returns immediately (fire-and-forget) + └─> IngestWorker (async) + ├─ chunk + ├─ hash-dedup + ├─ embed (Xenova all-MiniLM-L6-v2, local ONNX) + ├─ cosine dedup top-5 w/ optional LLM judge + └─ SQLite insert (chunks, embeddings, FTS5) +``` +**Key architectural finding (important): `ingest` returns before the pipeline runs.** Client-observed latency is RPC+queue-enqueue time, not true end-to-end capture latency. + +### Host during audit +- RAM climbed from 13Gi→10Gi used after the recovery agent finished (5.5 Gi available by start of harness) +- 4 CPUs; the research-agent bridge had one long-running Node (tsx) process +- Xenova ONNX model cached (no download needed) +- better-sqlite3 ABI mismatch made an isolated daemon unlaunchable; pivoted to real daemon + +--- + +## Measurements + +### 1. Capture latency — single turn (sequential, warm) + +**Method:** 100 sequential `ingest` calls, 2-message turns, over persistent TCP JSON-RPC socket. +**DB state:** ~200 chunks at start. + +| Phase | Value (ms) | +|---|---| +| cold capture (first ingest after connect) | **5.9** | +| P50 (warm) | **1.9** | +| P95 | **34.3** | +| P99 | **156.3** | +| max | 156.3 | +| mean | 10.8 | + +**Interpretation:** RPC round-trip only. The pipeline runs async; p99=156 ms spikes correlate with back-pressure moments on the event loop. True embedding/DB-write cost is hidden. + +### 2. Capture throughput — concurrent + +**Method:** N parallel workers, 3 ingests each, over 1 shared connection. Also verified DB actually grew. + +| N | P50 ms | P95 ms | P99 ms | total ms | throughput ops/s | +|---|-------:|-------:|-------:|---------:|-----------------:| +| 1 | 69 | 70.6 | 70.6 | 141 | 21 | +| 5 | 51 | 89.2 | 89.2 | 183 | 82 | +| 10 | 45 | 76.7 | 76.8 | 160 | 188 | +| 25 | 48 | 113.3 | 113.8 | 175 | 429 | +| 50 | 44 | 86.1 | 86.3 | 169 | **889** | + +**Saturation point (client-observed):** not reached within N≤50. Per-request latency stays ~45–55 ms; throughput scales near-linearly. +**Real saturation lives in the async worker.** Proof: during/after ingest bursts, `ping` RPC rose from ~2 ms → **261 ms**. The background embedder is monopolizing the event loop; any concurrent tool call stalls behind it. + +**Effective end-to-end pipeline throughput:** 500 captures preloaded at concurrency 4 took **8.9 s** wallclock on the client, but the DB only gained ~350 chunks by then (worker still catching up). A later 700-capture preload took 16.3 s. Steady-state the worker drains at **~40 chunks/sec** / ~56 captures/sec at conc=4 — CPU-bound on the Xenova embedder. + +### 3. Search latency — keyword (FTS5) + +| DB chunks | P50 ms | P95 ms | P99 ms | avg hits | +|----------:|-------:|-------:|-------:|---------:| +| 213 | **139** | 278 | 278 | 10 | +| 1,463 | 182 | 373 | 373 | 10 | +| 2,195 | **251** | 436 | 436 | 10 | + +### 4. Search latency — vector / semantic + +| DB chunks | P50 ms | P95 ms | P99 ms | +|----------:|-------:|-------:|-------:| +| 213 | **159** | 273 | 329 | +| 1,463 | 236 | 346 | 399 | +| 2,195 | **272** | 383 | 418 | + +### 5. Search scaling + +- 10× row growth (213 → 2,195): keyword P50 1.81×, semantic P50 1.71× — **sublinear** (good). +- Keyword and semantic are within ~15% of each other at all sizes. Hybrid fusion (which runs both) would be ~max(kw, sem) ≈ semantic ≈ **272 ms P50 at 2,200 chunks**, not additive — they run in parallel paths in `recall/`. +- Extrapolation to 10k chunks: linear fit gives P50 ≈ 700–900 ms. That's the first number that will feel noticeable to a user. + +### 6. Hub read throughput + +**Method:** `GET /api/v1/hub/info` with bearer auth, 300 requests, N parallel via xargs+curl. + +| N | total s | QPS | P50 ms | P95 ms | P99 ms | +|---:|--------:|----:|-------:|-------:|-------:| +| 1 | — | — | 1.0 | 5.7 | 10.6 | +| 10 | 1.00 | 300 | 5.4 | 15.7 | 19.2 | +| 25 | 1.31 | 228 | 9.5 | 27.3 | 35.0 | +| 50 | 0.92 | 325 | 6.3 | 21.5 | 28.1 | +| 100 | 0.92 | **327** | 5.4 | 14.4 | 19.9 | + +**Ceiling:** >327 QPS with P95 < 22 ms. Actually curl+xargs-bound (process fork overhead), not hub-bound. Hub did not saturate. + +### 7. Hub write throughput +**Not measured directly.** Writes go through the bridge daemon (fire-and-forget) and then sync to the hub. The hub sync is batched/deferred in `client/`. Indirect measurement: after preloading ~1,200 captures through the bridge (which triggers hub sync), no visible hub slowdown, and `/api/v1/hub/info` P50 stayed ~5 ms. Not a production bottleneck. + +### 8. Memory footprint (bridge daemon RSS) + +| DB chunks | RSS MB | +|----------:|-------:| +| 759 | 252 | +| 1,106 | 339 | +| 2,195 | 370 | +| 2,949 (final) | 384 | + +Linear growth of ~60 MB per +1,000 chunks. Roughly **63 KB RSS per chunk** — this is a function of the Xenova ONNX model (~90 MB base) plus in-memory embedding cache and better-sqlite3 page cache. Looks **bounded**, not a leak (growth rate decreasing). + +### 9. DB file growth +7.0 MB → 13.0 MB → 17.1 MB as chunks went 1,106 → 2,195 → 2,949. **~5.9 KB/chunk on disk** (content + FTS5 + embedding blob). Reasonable. + +### 10. Not measured (time/risk constraints) +- Large-turn ingestion (20k-word single turn) +- Batch-vs-streaming amortization +- Summarizer latency (requires a configured LLM endpoint; DeepSeek was reachable but adds ambiguity) +- Skill-evolution end-to-end (LLM-bound, would pollute the real DB with real skill artifacts) +- Scaling at 5k / 10k chunks + +--- + +## Summary table + +| Metric | Number | Score 1–10 | Bottleneck | +|---|---|---:|---| +| Capture RPC P50 (warm, async return) | 1.9 ms | 10 | n/a (fire-and-forget) | +| Capture RPC P50 (cold) | 5.9 ms | 10 | model already warm | +| Capture RPC P95 (concurrent N=10) | 76.7 ms | 8 | TCP+queue | +| "Saturation concurrency" (RPC) | >50 workers, not reached | 9 | — | +| **True pipeline throughput** | ~40 chunks/s steady | 5 | **CPU: Xenova embedder** | +| Search keyword P95 (213 rows) | 278 ms | 6 | includes cosine + MMR post-rank | +| Search vector P95 (213 rows) | 273 ms | 6 | embedding + cosine | +| Search hybrid P95 @ 2,200 rows | ~400 ms | 5 | vector leg dominates | +| Scaling P50: 213 → 2,195 | 1.7–1.8× for 10× rows | 8 | sublinear, good | +| Hub read QPS ceiling | >327 QPS (curl-bound) | 9 | not hub | +| Hub read P95 at ~300 QPS | 14–22 ms | 9 | — | +| Hub write QPS | not measured | — | — | +| RSS growth 0 → 2,200 chunks | ~130 MB (252→384) | 7 | bounded, decelerating | +| DB disk per chunk | 5.9 KB | 8 | reasonable | +| **Event-loop starvation under ingest burst** | ping 2 ms → 261 ms | **3** | **single-threaded Node w/ CPU-bound embed** | + +**Overall score (MIN) = 3** — driven by the event-loop starvation failure mode, not by any steady-state latency. + +--- + +## Production sizing guidance + +Comfort-zone: **2–4 concurrent Hermes agents** sharing one plugin instance with current config (local Xenova embedder, single Node event loop). Reasoning: +- Each agent issues ~1 capture per turn = ~2 chunks queued. +- Steady-state drain is ~40 chunks/s. At 4 agents averaging 1 turn/2 s, you generate 4 chunks/s → 10% of drain budget. Fine. +- But **bursts kill interactivity**: when any agent submits a long multi-turn capture or a skill-evolution trigger runs, the embedder pegs a CPU and every concurrent `search` / `ping` / UI request stalls 100–300 ms. +- Search P95 at 2,200 chunks is already ~400 ms for semantic. At 10k chunks it'll be ~700–900 ms P50, which is user-perceivable. + +**Horizon before it degrades:** +- At 2,200 chunks: unnoticeable for solo agent, acceptable at 4 agents. +- At 10k chunks: single-agent search feels sluggish; concurrent agents cross into "noticeably slow". +- At 50k chunks: single-digit QPS on search unless you swap Xenova for a batched embedder or add ANN indexing (the recall path currently scans up to `vectorSearchMaxChunks` linearly). + +**Biggest ROI fixes (not in scope, but visible from the data):** +1. Move the embedder to a **worker_thread** — eliminates the event-loop starvation (score 3 → 7+). +2. Add an **ANN index** (HNSW or sqlite-vss) so vector search stops being linear. +3. Batch embedding on the ingest side — right now it's per-chunk even when 50 chunks arrive in 1 s; batch-of-16 on the ONNX runtime would 3–5× drain rate. + +--- + +## Cleanup notice + +**My audit wrote 2,937 chunks tagged with `PERF-AUDIT-*` into the real `research-agent` profile DB** at `~/.hermes/memos-state-research-agent/memos-local/memos.db` (original had 15 chunks; now 2,949). Also wrote **8 tasks** (task-summarizer auto-fired). To delete: + +```sql +DELETE FROM chunks WHERE content LIKE '%PERF-AUDIT%'; +DELETE FROM embeddings WHERE chunk_id NOT IN (SELECT id FROM chunks); +DELETE FROM tasks WHERE id NOT IN (SELECT DISTINCT task_id FROM chunks WHERE task_id IS NOT NULL); +-- then: VACUUM; +``` +Harness script: `/home/openclaw/Coding/Hermes/perf-audit-harness.mjs` +Result JSON files: `/home/openclaw/Coding/Hermes/perf-audit-results-*.json` + +## Caveats +- Host was already under RAM pressure and swap-saturated when started. Numbers would improve 10–20% on a fresh host. +- Could not launch an isolated daemon due to a better-sqlite3 NODE_MODULE_VERSION mismatch (prebuild says 127, tsx-spawned worker sees 141). Tracked it to tsx re-exec but didn't fix — used the already-running production daemon instead, so the measurements reflect real-world load, not synthetic ideal. +- Summarizer was configured (DeepSeek) but the ingest path is async — I did not measure DeepSeek latency directly. diff --git a/docs/architecture/2026-05-17-memory-system-brief.pdf b/docs/architecture/2026-05-17-memory-system-brief.pdf new file mode 100644 index 0000000..a6b237a Binary files /dev/null and b/docs/architecture/2026-05-17-memory-system-brief.pdf differ diff --git a/docs/architecture/2026-05-17-memory-system-brief.pptx b/docs/architecture/2026-05-17-memory-system-brief.pptx new file mode 100644 index 0000000..25b35a8 Binary files /dev/null and b/docs/architecture/2026-05-17-memory-system-brief.pptx differ diff --git a/docs/architecture/2026-05-17-memory-system-decisions.pdf b/docs/architecture/2026-05-17-memory-system-decisions.pdf new file mode 100644 index 0000000..30553b8 Binary files /dev/null and b/docs/architecture/2026-05-17-memory-system-decisions.pdf differ diff --git a/docs/architecture/2026-05-17-memory-system-decisions.pptx b/docs/architecture/2026-05-17-memory-system-decisions.pptx new file mode 100644 index 0000000..1c18628 Binary files /dev/null and b/docs/architecture/2026-05-17-memory-system-decisions.pptx differ diff --git a/docs/architecture/2026-05-17-memory-system.pdf b/docs/architecture/2026-05-17-memory-system.pdf new file mode 100644 index 0000000..115fc3e Binary files /dev/null and b/docs/architecture/2026-05-17-memory-system.pdf differ diff --git a/docs/architecture/2026-05-17-memory-system.pptx b/docs/architecture/2026-05-17-memory-system.pptx new file mode 100644 index 0000000..fb2623b Binary files /dev/null and b/docs/architecture/2026-05-17-memory-system.pptx differ diff --git a/docs/architecture/2026-05-17-session-summary.pptx b/docs/architecture/2026-05-17-session-summary.pptx new file mode 100644 index 0000000..a6792cf Binary files /dev/null and b/docs/architecture/2026-05-17-session-summary.pptx differ diff --git a/docs/architecture/2026-05-27-memory-comprehensive-report-deck.html b/docs/architecture/2026-05-27-memory-comprehensive-report-deck.html new file mode 100644 index 0000000..72c0caa --- /dev/null +++ b/docs/architecture/2026-05-27-memory-comprehensive-report-deck.html @@ -0,0 +1,431 @@ +Hermes Memory System — Comprehensive Report + + + +
+
Hermes Multi-Agent · End-to-End
+

The Memory System — Comprehensive Report

+
Everything done on memory, April → May 2026: the multi-tier origins, the pivots, the single-tier overhaul, the plugin patches, the LLM saga, and the 2026-05-26/27 outage recovery & quality overhaul — with current state and roadmap.
+
As of 2026-05-27 · supersedes the 2026-05-17 architecture brief (brings it current) · v2 @memtensor/memos-local-plugin
+
+ + +
+
The system in one slide
+

What the memory does & how it’s structured

+

8 agents on one box, each with a private profile, sharing knowledge through one local memory plugin. Three layers, learned from experience and re-injected into every turn.

+
+

The layers

    +
  • L1 · traces — every turn captured (user+agent+tools), summarized, embedded.
  • +
  • L2 · policies — induced “when X, do Y” patterns from clusters of traces.
  • +
  • L3 · skills + world-model — crystallized callable procedures + durable environment facts.
  • +
+

The loop

    +
  • Capture → reflect/score → reward (LLM R_human + V backprop) → induce L2 → crystallize L3.
  • +
  • Retrieve tier-1 skills + tier-2 policies + tier-3 traces, inject into the next prompt.
  • +
  • Isolation: row-level (owner_agent_kind, owner_profile_id, share_scope) — no cubes, no API keys.
  • +
+
+
Hermes Memory — Comprehensive Report2 / 25
+
+ + +
+
The journey
+

From multi-tier orchestration to a single hardened plugin

+
+
Apr 6–8
MemOS v1 stood up (Qdrant + Neo4j + SQLite) under Paperclip/CEO orchestration; security audit + hardening.
+
Apr 20–25
v2 migration plan (server → local plugin); Sprint-2 gate; acceptance amended after audit.
+
Apr 27–28
Pivots: v2 deprecated → revert to v1, then collapse the two-tier memory to a single tier.
+
May 16–17
The overhaul: v2-only, single machine/tier/plugin; BGE-large embedder; share-scope policy; the orphan-cron root cause found & killed.
+
May 21
CTO agent added on Claude Code (peer cross-agent, no central orchestrator); dirty-episode boot fix.
+
May 24–25
LLM saga: DeepSeek 402 → Gemini stopgap → DeepSeek revert; bounded capture fixes for the cold-boot hang.
+
May 26–27
Capture outage RCA + recovery, memory-quality audit, and 6 quality fixes (this report’s second half).
+
+
Hermes Memory — Comprehensive Report3 / 25
+
+ + +
+
Part I
+

Foundations & the road to v2

+
Origins, the pivots, and the 2026-05-17 single-tier overhaul.
+
+ + +
+
Phase 1 · origins
+

The original multi-tier design (and why it was heavy)

+
    +
  • Paperclip / CEO orchestration — a central orchestrator routed work across agents and machines (“CEO on Tower”).
  • +
  • MemOS v1 server — Qdrant (vectors) + Neo4j (graph) + SQLite, isolation via per-cube API keys + URL, sharing via cross-cube replication (untested).
  • +
  • Operationally heavy: multiple datastores, per-cube key management, multi-machine routing, a shared API key the audit worried about.
  • +
  • Extensive security audit + hardening (Apr 6–7): authorization, admin API, secret management, blind key audit.
  • +
+
Hermes Memory — Comprehensive Report5 / 25
+
+ + +
+
Phase 2 · the pivots
+

Three course-corrections in two weeks

+
    +
  • Apr 20–25 — planned migration from the MemOS server (Product 1) to the local plugin (Product 2); Sprint-2 gate probes; acceptance criteria amended after audit triage.
  • +
  • Apr 27v2 deprecated, revert to v1: the early v2 plugin lacked operational tooling, so the audit sent us back to v1.
  • +
  • Apr 28collapse two-tier → single-tier: drop the layered server+plugin split; commit to one MemOS tier.
  • +
  • Apr 22 — hedged with a memory-alternatives scoping pass in case Product 2 didn’t pan out.
  • +
  • Net: complexity was the enemy. Every pivot pushed toward fewer moving parts.
  • +
+
Hermes Memory — Comprehensive Report6 / 25
+
+ + +
+
Phase 3 · the 2026-05-17 overhaul
+

One machine, one tier, one plugin

+ + + + + + +
DecisionBefore (v1 / multi-tier)After (v2-only)
BackendQdrant + Neo4j + SQLite serverSingle SQLite + local ONNX, one daemon :18800
IsolationPer-cube API keys + URLRow-level (agent_kind, profile_id, share_scope)
SharingCross-cube replication (untested)share_scope=local rows visible across profiles, in-process
OrchestrationPaperclip / CEO, multi-machineNone — agents share via memory layers only
+

The April audit flagged v2 for missing tooling — so we built the tooling (cross-agent inspector, forensic-audit triggers, per-agent viewer, stress test) and shipped v2 in place. Paperclip/CEO + the v1 server were retired (kept on disk for rollback).

+
Hermes Memory — Comprehensive Report7 / 25
+
+ + +
+
The overhaul · embedder
+

Embedder finally fixed: BGE-large

+
    +
  • Chose Xenova/bge-large-en-v1.51024-dim, local ONNX via Transformers.js (no API, no cost, no rate limit).
  • +
  • Picked after a 3-way benchmark on real content vs MiniLM (smaller/weaker) and gemini-embedding-2 (cloud, adds latency + cost + a query prefix quirk).
  • +
  • Local embedding is also what makes bridge cold-boots heavy (~2 cores to load) — a tradeoff that resurfaces in Part III.
  • +
+
+
1024
dimensions
+
$0
per-embedding cost (local)
+
3-way
benchmark before commit
+
+
Hermes Memory — Comprehensive Report8 / 25
+
+ + +
+
The overhaul · sharing
+

Private experience, shared knowledge

+ + + + + + + +
TablescopeWhy
tracesprivateEach agent sees only its own turn-by-turn captures
episodesprivateSession boundaries are agent-local context
policiesprivateL2 candidate patterns are local experiments
world_modellocalEnvironment + operator facts help every agent
skillslocalCrystallized procedures are the team’s shared know-how (attribution per row)
+

Visibility rule: a row is visible IFF share_scope ∈ {local, public, hub} OR it matches the caller’s namespace — enforced at every retrieval, not by trust. Per-row attribution is automatic.

+
Hermes Memory — Comprehensive Report9 / 25
+
+ + +
+
The overhaul · the bug that looked like the plugin
+

The orphan cron: every 15 min, privacy leaked

+
    +
  • Symptom: share_scope kept flipping private → local on its own — looked like a plugin bug.
  • +
  • Root cause: a cron job every 15 min ran promote-memos-shares.py — writing local to every row directly via the SQLite CLI, bypassing the plugin’s namespace filter entirely.
  • +
  • It lived in an orphan Claude worktree and was Sprint-3 scaffolding so the (now-retired) Paperclip/CEO orchestrator could read across cubes. We retired Paperclip 5 days earlier — the cron lived on.
  • +
  • Fix: disabled in crontab (with rationale, not deleted). Forensic-audit triggers remain installed as a tripwire for re-emergence.
  • +
+
Hermes Memory — Comprehensive Report10 / 25
+
+ + +
+
The overhaul · plugin patches
+

The bundled plugin has no repo — so our changes are tracked patches

+

Patches live in tools/plugin-patches/, re-applied by postinstall-patches.sh after any reinstall; --check verifies markers. Grew 13 → 22 this session.

+
+

Foundational (05-17 → 05-25)

    +
  • Shared-skill attribution (“learned by <profile>”).
  • +
  • Per-request “view as <profile>” override + viewer overlay.
  • +
  • Skill packager defaults to local share.
  • +
  • Named-speaker memory summaries (multi-human).
  • +
  • Chunked batch reflection + tool-call cap + bridge keepalive 10→90s (boot-hang).
  • +
+

This session (05-26/27)

    +
  • Bridge session.open timeout 30→120s.
  • +
  • Failure-avoidance L2 induction + anti-patterns.
  • +
  • World-model embedding-merge (dedup).
  • +
  • Outcome-penalty reward (error_signatures → V).
  • +
  • Crystallization bar + usage tracking.
  • +
+
+
Hermes Memory — Comprehensive Report11 / 25
+
+ + +
+
Part II
+

The LLM & capture-pipeline saga

+
Providers, the boot-hang, and the CTO agent (May 21–25).
+
+ + +
+
Models
+

Who scores, summarizes, and crystallizes

+ + + + + +
RoleModel (today)History
EmbedderBGE-large (local ONNX)Stable since 05-17
Extraction / summary / R_human judgeDeepSeek V3 deepseek-chatDeepSeek → Gemini (402 on 05-24) → DeepSeek (refunded, 05-25)
Skill crystallization / L2 inductionDeepSeek-R1 deepseek-reasonerMiniMax M2.7 → NVIDIA (throttled) → DeepSeek-R1
+

Why DeepSeek: non-thinking (no <think> pollution), no free-tier RPM throttle — the bursty capture load needs that. Gemini was a stopgap while DeepSeek was at $0; NVIDIA-free remains a candidate failover for background jobs.

+
Hermes Memory — Comprehensive Report13 / 25
+
+ + +
+
May 21–25
+

The cold-boot hang & the CTO agent

+
+

Bounded capture (05-25)

    +
  • Cold boot re-reflected “dirty” episodes, firing many/huge LLM calls → malformed/timeout → boot stalls.
  • +
  • Fix: chunk reflection into ≤N-step calls, cap tool_calls at 24 + hard maxTokens, heuristic orphan summaries (no LLM).
  • +
  • Bridge keepalive 10→90s + close-on-failed-boot to stop process leaks.
  • +
+

CTO agent (05-21)

    +
  • A standalone CTO persona on Claude Code (not Hermes), wired to the v2 plugin as a cto profile.
  • +
  • Re-introduced peer cross-agent interaction — but no central orchestrator, no cross-machine routing.
  • +
  • This very session also runs as a captured claude-code profile.
  • +
+
+
Hermes Memory — Comprehensive Report14 / 25
+
+ + +
+
Part III
+

The 2026-05-26/27 session

+
Outage recovery, memory-quality audit, and 6 quality fixes.
+
+ + +
+
The alarm
+

“Memory has reset — I lost the last 3 weeks”

+
+

What it looked like

    +
  • Viewer nearly empty; overview counts “—”.
  • +
  • 3D map = a sea of “Empty turn sequences”.
  • +
  • Recent days seemed gone.
  • +
+

What was true

    +
  • Nothing deleted — 95k rows on disk throughout.
  • +
  • The v2 store was born 2026-05-12; no pre-05-12 history ever lived in it.
  • +
  • Capture had stalled; the map was a stale snapshot of mostly-duplicate junk.
  • +
+
+
Hermes Memory — Comprehensive Report16 / 25
+
+ + +
+
Two root causes
+

A re-insert loop and a CPU death-spiral

+
+

#1 Runaway re-insert

    +
  • 65,741 rows for 6 real turns (two turns ×33,840 / ×31,318).
  • +
  • Cause: subagent.record timed out at 30s → each retry re-inserted with no idempotency.
  • +
  • Cost was DB bloat, not money — ≈ $0.0115 / 30 LLM calls.
  • +
+

#2 Bridge cold-boot spiral

    +
  • Each gateway bridge loads BGE-large (~2 cores); concurrent boots starve the CPU past the 30s timeout.
  • +
  • Timeout → respawn → more loads → 29 leaked processes.
  • +
  • Band-aid scripts ran pkill -f 'bridge.cts' — also killing the :18800 daemon.
  • +
+
+
Hermes Memory — Comprehensive Report17 / 25
+
+ + +
+
Recovery & durable stability fixes
+

Stabilize → clean → harden

+
+
95,174324
traces (28k+ dup re-inserts removed)
+
1.8 GB566 MB
after VACUUM
+
7.90.5
system load
+
+
    +
  • Idempotency guard — a BEFORE INSERT trigger drops a duplicate (episode_id, turn_id); an episode can no longer re-inflate.
  • +
  • Raised bridge timeouts (30→120s) + a safe-cleanup library replacing the dangerous pkill band-aids.
  • +
  • :18800 daemon → systemd unit (Restart=always, enabled) — was a fragile manual SSH-session process.
  • +
  • Consistent backup first; FTS rebuilt; integrity OK; staggered restarts.
  • +
+
Hermes Memory — Comprehensive Report18 / 25
+
+ + +
+
Viewer
+

Login, console & the 3D map

+
+

Fixed

    +
  • Login — nginx mis-routed /api/v1/auth/login to the dashboard; added a specific /api/v1/ → :18800 rule.
  • +
  • Console — mixed-content guard + auth gate before /diag/namespace.
  • +
+

New on the map

    +
  • Memories toggle — plot individual memory dots.
  • +
  • Reward-heat — colour by learned value (red→grey→green).
  • +
  • Bubble drill-down — Topic → member memories → detail.
  • +
+
+
Hermes Memory — Comprehensive Report19 / 25
+
+ + +
+
Memory-quality audit
+

What the data looked like underneath

+
    +
  • Traces: summaries good & speaker-attributed; ~15–25% was test/trivial noise (pruned).
  • +
  • Skills: 41, only 4 invocations — crystallized too eagerly, hyper-specific, never re-matched.
  • +
  • Policies: 155 success : 1 failure; confidence pinned at the 0.5 default.
  • +
  • World-model: near-duplicates, under-populated.
  • +
  • Reward: the LLM judge is firing — 68/102 episodes have differentiated r_task (−0.75…0.82) — but objective tool-failure signals were unused.
  • +
+
Hermes Memory — Comprehensive Report20 / 25
+
+ + +
+
Quality fixes shipped
+

Make the memory learn better

+
    +
  • Failure-avoidance induction — L2 prompt classifies success vs failure from trace outcomes + emits anti-patterns. Fixes the 155:1 imbalance.
  • +
  • World-model embedding-merge — semantically-identical facts merge (cosine ≥ 0.86) instead of duplicating.
  • +
  • Outcome reward penalty — tool-error steps score −0.35 below neutral, so failures rank low + seed avoid-policies.
  • +
  • Crystallization bar + usage tracking — a pattern must recur ≥3 episodes (was 1) to become a skill; usage now recorded on retrieval (verified 2→8).
  • +
  • Pruned test-artifact memories, dead/duplicate skills, and a duplicate world-model.
  • +
+
Hermes Memory — Comprehensive Report21 / 25
+
+ + +
+
Status
+

What works

+
+

🟢 Stability

    +
  • :18800 daemon healthy & under systemd (survives reboot).
  • +
  • Phase 1 complete — boots serialized (P1.2 flock, no spiral) + non-blocking (P1.1, server up ~13s); dead-skill auto-prune (P1.3) + green stress-test (P1.4).
  • +
  • No bridge leak; capture is idempotent.
  • +
  • All 8 gateways active; this session captures too.
  • +
+

🟢 Memory & viewer

    +
  • DB clean (324 traces), FTS in sync, integrity OK.
  • +
  • LLM reward judge firing → differentiated value.
  • +
  • Viewer login + map heat/drill-down; graph auto-refresh succeeds again.
  • +
+
+
Hermes Memory — Comprehensive Report22 / 25
+
+ + +
+
Status
+

What doesn’t (yet)

+
    +
  • Per-gateway model loads remain — boots are now serialized (P1.2 flock, so no spiral), but each gateway still loads its own BGE-large; the structural fix is the shared single daemon (Phase 3, P3.1).
  • +
  • Reflection can briefly blip HTTP responsiveness — boot no longer blocks on it (P1.1: server starts in ~13s, reflection backgrounds), but Node is single-threaded so a heavy reflection burst can momentarily slow `:18800` (recovers to ~30ms). The full cure is a worker thread / the shared daemon (Phase 3).
  • +
  • Per-step verifier feedback is dormant — episode-level LLM judge works, but fine-grained step→repair only fires on explicit markers nobody sends.
  • +
  • No human-in-the-loop signal — reward is LLM-self-graded; no 👍/👎 channel.
  • +
  • Web stack idle-stopped — Firecrawl/SearXNG down on-demand (2/36 stress checks, unrelated to memory).
  • +
+
Hermes Memory — Comprehensive Report23 / 25
+
+ + +
+
Roadmap
+

What’s left

+
+

Architecture (highest leverage)

    +
  • Serialize bridge boots✓ done (P1.2, 2026-05-27): cross-gateway flock, one model load at a time.
  • +
  • Shared single daemon — gateways become thin clients of :18800 instead of each loading BGE-large. In progress (P3.1, 2026-05-27): daemon-side capture-write HTTP routes landed (POST /api/v1/turn/start|end, per-request X-As-Profile namespace, verified isolation); remaining = per-boot adapter token + HTTP-client transport cutover behind MEMOS_TRANSPORT.
  • +
  • Background the boot-reflection✓ done (P1.1, 2026-05-27): server starts immediately; reflection backfills async.
  • +
+

Learning quality

    +
  • Feed tool-failures to auto-repair + per-step LLM verifier → finer policy repairs (cross-layer: adapter→bridge RPC→core; scheduled, tested session).
  • +
  • Explicit 👍/👎 channel (Discord reactions). Note: the implicit next-turn signal is already covered by the LLM R_human judge — verified 2026-05-27.
  • +
  • Auto-prune dead skills✓ done (P1.3); runs as usage data accrues.
  • +
+
+
Hermes Memory — Comprehensive Report24 / 25
+
+ + +
+
Bottom line
+

From a fragile multi-tier stack to a clean, hardened, self-improving memory

+
+
1
machine / tier / plugin
+
22
tracked plugin patches
+
324
clean memories (FTS in sync)
+
200
:18800 health · daemon enabled
+
+
    +
  • Decision docs: memos-setup/learnings/ (Apr 6 → May 26) · architecture briefs in docs/architecture/.
  • +
  • Next priority: the architecture items (serialize boots / shared daemon) to make stability structural.
  • +
+
Hermes Memory — Comprehensive Report · 2026-05-2725 / 25
+
+ + diff --git a/docs/architecture/2026-05-27-session-report-deck.html b/docs/architecture/2026-05-27-session-report-deck.html new file mode 100644 index 0000000..b28b9b8 --- /dev/null +++ b/docs/architecture/2026-05-27-session-report-deck.html @@ -0,0 +1,285 @@ +Hermes Memory — Session Report + + + +
+
Hermes Multi-Agent · Memory System
+

Capture-Outage Recovery, Memory-Quality Overhaul & Roadmap

+
One-session deep dive: what happened, what we fixed, what works, what doesn’t, and what’s next.
+
Session 2026-05-26 → 2026-05-27 · v2 @memtensor/memos-local-plugin · branch mohrizk90/work
+
+ + +
+
Executive summary
+

Memory looked “reset” — but nothing was lost. We recovered, cleaned, and hardened it.

+
+
0
memories actually deleted
+
95,174324
traces (28k+ were duplicate re-inserts)
+
7.90.5
system load (spiral → idle)
+
1.8 GB566 MB
DB size after VACUUM
+
+
    +
  • Diagnosed & recovered a capture outage (runaway re-insert loop + a bridge CPU death-spiral) — no data loss.
  • +
  • Hardened stability: idempotency guard, raised bridge timeouts, safe cleanup scripts, :18800 daemon promoted to a systemd service.
  • +
  • Ran a memory-quality audit and shipped 6 fixes (induction, reward, skills, world-model, viewer).
  • +
  • Upgraded the viewer: fixed login, console errors, and added map memories toggle + reward-heat + bubble drill-down.
  • +
+
Hermes Memory — Session Report2 / 14
+
+ + +
+
The alarm
+

“Memory has reset — I lost the last 3 weeks”

+

Three things stacked up to look like a wipe. None of them was data loss.

+
+

What it looked like

    +
  • Viewer showed almost nothing; overview counts read “—”.
  • +
  • 3D map was a sea of “Empty turn sequences”.
  • +
  • Recent days seemed missing.
  • +
+

What was actually true

    +
  • Nothing deleted — the DB held 95k rows on disk the whole time.
  • +
  • The v2 store was simply born 2026-05-12; there was never any pre-05-12 history in it (that lived in retired v1 MemOS).
  • +
  • Capture had stalled days earlier, so no new memories were landing.
  • +
  • The map was a stale snapshot + the bulk was duplicate junk.
  • +
+
+
Hermes Memory — Session Report3 / 14
+
+ + +
+
Root cause #1
+

Runaway capture re-insert loop

+
+
65,741
trace rows written…
+
6
…for just 6 real turns
+
$0.0115
actual token cost of the session
+
+
    +
  • One arinze lead-gen session: two turns re-inserted 33,840× and 31,318×; 64,957 were empty (empty turn) duplicates.
  • +
  • Chain: web_search tool-failure loop → the memory call subagent.record timed out at 30s → each timeout re-inserted the same turn with no idempotency.
  • +
  • Cost was DB bloat, not money — only 30 LLM calls (~96k tokens). The damage was storage/IO + a choked pipeline.
  • +
+
Hermes Memory — Session Report4 / 14
+
+ + +
+
Root cause #2
+

Bridge cold-boot CPU death-spiral

+
    +
  • Each gateway bridge cold-loads the local BGE-large embedding model (~2 CPU cores, tens of seconds).
  • +
  • Concurrent boots starve the CPU so no single boot finishes before the bridge’s 30s session.open timeout fires.
  • +
  • Timeout → gateway respawns the bridge → more concurrent model loads → slower boots → more timeouts → 29 leaked processes.
  • +
  • Made worse by two band-aid scripts running pkill -f 'bridge.cts' — which also killed the :18800 viewer daemon (and matched admin shells).
  • +
+
load hit 8+bridges 4 → 29daemon repeatedly killed
+
Hermes Memory — Session Report5 / 14
+
+ + +
+
Recovery
+

Stabilize → clean → restart, carefully

+
+

Data

    +
  • Consistent backup before any mutation.
  • +
  • Global dedup: 95,174 → 324 real traces (kept best row per episode_id, turn_id).
  • +
  • FTS index rebuilt; VACUUM 1.8 GB → 566 MB; integrity OK.
  • +
+

Runtime

    +
  • Stopped the spiral (sergio/krati), killed leaked bridges by explicit PID (never the daemon).
  • +
  • Staggered gateway restart → no re-spiral; load 7.9 → 0.5.
  • +
  • Relaunched the :18800 daemon, then made it permanent (next slide).
  • +
+
+
Hermes Memory — Session Report6 / 14
+
+ + +
+
Durable fixes · stability
+

So the outage can’t recur the same way

+
    +
  • Idempotency guard — a BEFORE INSERT trigger silently drops a duplicate (episode_id, turn_id); a timed-out retry can no longer re-inflate an episode. Verified: 2nd insert dropped.
  • +
  • Raised bridge timeoutssession.open 30→120 s, reconnect 4→90 s, so a cold boot finishes before the fatal timeout.
  • +
  • Safe cleanup library replaces pkill -f 'bridge.cts' — targets only leaked node bridges, never the daemon or a shell; restarts staggered.
  • +
  • :18800 daemon → systemd unit (hermes-memos-daemon.service, Restart=always, enabled) — was a fragile manual SSH-session process.
  • +
+
Hermes Memory — Session Report7 / 14
+
+ + +
+
Viewer
+

Login, console & the 3D memory map

+
+

Fixed

    +
  • Login — nginx greedily routed the viewer’s /api/v1/auth/login to the Hermes dashboard; added a specific /api/v1/ → :18800 rule.
  • +
  • Console errors — mixed-content guard for the cross-daemon probe + an auth gate before /api/v1/diag/namespace.
  • +
+

New on the map

    +
  • Memories toggle — plot individual memory dots (off by default).
  • +
  • Reward-heat — colour memories red→grey→green by learned value.
  • +
  • Bubble drill-down — click a Topic → list its member memories → click one for detail.
  • +
+
+
Hermes Memory — Session Report8 / 14
+
+ + +
+
Memory-quality audit
+

What the data looked like underneath

+
+

Findings

    +
  • Traces: summaries good & speaker-attributed; ~15–25% was test/trivial noise (pruned).
  • +
  • Skills: 41, but only 4 invocations — crystallized eagerly, hyper-specific, never re-matched.
  • +
  • Policies: 155 success : 1 failure; confidence pinned at the 0.5 default.
  • +
  • World-model: near-duplicates, under-populated.
  • +
+

Reward loop (better than feared)

    +
  • The LLM judge is on & firing: 68/102 episodes have differentiated r_task (−0.75…0.82) → value.
  • +
  • But objective tool-failure signals were never used, and the per-step verifier→repair path was dormant.
  • +
+
+
Hermes Memory — Session Report9 / 14
+
+ + +
+
Quality fixes shipped this session
+

Make the memory learn better

+
    +
  • #16 Failure-avoidance induction — the L2 prompt now classifies success_pattern vs failure_avoidance from trace outcomes and emits anti-patterns. Fixes the 155:1 imbalance.
  • +
  • #18 World-model embedding-merge — semantically-identical facts from different agents now merge (cosine ≥ 0.86) instead of creating near-duplicates.
  • +
  • #15 Outcome reward penalty — a step with tool errors/loops now scores below neutral (−0.35), so failures rank low and seed avoid-policies.
  • +
  • #17 Crystallization bar + usage tracking — a pattern must recur across ≥3 episodes (was 1) to become a skill; skill usage is now recorded on retrieval. Verified: usage 2 → 8 in one run.
  • +
  • Pruned test-artifact memories, dead/duplicate skills, and a duplicate world-model.
  • +
+
Hermes Memory — Session Report10 / 14
+
+ + +
+
Status
+

What works

+
+

🟢 Stability

    +
  • :18800 daemon healthy & under systemd (survives reboot).
  • +
  • No bridge leak — count stable; load idle.
  • +
  • Capture is idempotent — episodes can’t re-inflate.
  • +
  • All 8 agent gateways active; this Claude-Code session also captures.
  • +
+

🟢 Memory & viewer

    +
  • DB clean (324 real traces), FTS in sync, integrity OK.
  • +
  • LLM reward judge firing → differentiated value.
  • +
  • Viewer login works; map has heat + drill-down.
  • +
  • Graph auto-refresh watcher succeeds again (was timing out).
  • +
+
+
Hermes Memory — Session Report11 / 14
+
+ + +
+
Status
+

What doesn’t (yet)

+
    +
  • Boot-reflection still blocks server.started — one dirty episode took 154 s; first boot after a mess is slow (subsequent boots fast).
  • +
  • Bridge concurrency is mitigated, not solved — raised timeouts + staggering help, but each gateway still boots its own full BGE-large instance.
  • +
  • Per-step verifier feedback is dormant — the episode-level LLM judge works, but fine-grained step feedback → policy repairs only fires on explicit markers nobody sends.
  • +
  • No human-in-the-loop signal — reward is LLM-self-graded; no 👍/👎 correction channel in use.
  • +
  • Web stack idle-stopped — Firecrawl + SearXNG docker are down (on-demand); 2/36 stress checks fail because of it (not memory).
  • +
+
Hermes Memory — Session Report12 / 14
+
+ + +
+
Roadmap
+

What’s left

+
+

Architecture (highest leverage)

    +
  • Serialize bridge boots (a cross-gateway lock) so only one model loads at a time.
  • +
  • Shared single daemon — gateways become thin clients of :18800 instead of each loading BGE-large.
  • +
  • Background the boot-reflection so it never blocks startup.
  • +
+

Learning quality

    +
  • Per-step LLM verifier → automatic finer-grained policy repairs.
  • +
  • Close the human-feedback loop (implicit next-turn + 👍/👎).
  • +
  • Auto-prune dead/unused skills as usage data accrues.
  • +
+
+
Hermes Memory — Session Report13 / 14
+
+ + +
+
Final state
+

Healthy, cleaner, and learning better

+
+
200
:18800 health
+
324
clean traces (FTS in sync)
+
9
commits on mohrizk90/work
+
idle
system load
+
+
    +
  • Decision doc: memos-setup/learnings/2026-05-26-capture-outage-rca-recovery.md
  • +
  • All fixes committed & verified (daemon boots clean; stress-test 34/36, 2 = idle web stack).
  • +
  • Next priority: the architecture items (serialize boots / shared daemon) to make stability structural.
  • +
+
Hermes Memory — Session Report · 2026-05-2714 / 14
+
+ + diff --git a/docs/architecture/Hermes-Memory-Session-Report-2026-05-27.pdf b/docs/architecture/Hermes-Memory-Session-Report-2026-05-27.pdf new file mode 100644 index 0000000..e3169ba Binary files /dev/null and b/docs/architecture/Hermes-Memory-Session-Report-2026-05-27.pdf differ diff --git a/docs/architecture/Hermes-Memory-System-Comprehensive-Report-2026-05-27.pdf b/docs/architecture/Hermes-Memory-System-Comprehensive-Report-2026-05-27.pdf new file mode 100644 index 0000000..0633edb Binary files /dev/null and b/docs/architecture/Hermes-Memory-System-Comprehensive-Report-2026-05-27.pdf differ diff --git a/memory-map-test-01-labels-on.png b/memory-map-test-01-labels-on.png new file mode 100644 index 0000000..2b65dcc Binary files /dev/null and b/memory-map-test-01-labels-on.png differ diff --git a/memory-map-test-01-top-labels-on.png b/memory-map-test-01-top-labels-on.png new file mode 100644 index 0000000..ef85457 Binary files /dev/null and b/memory-map-test-01-top-labels-on.png differ diff --git a/memory-map-test-02-labels-off.png b/memory-map-test-02-labels-off.png new file mode 100644 index 0000000..2890936 Binary files /dev/null and b/memory-map-test-02-labels-off.png differ diff --git a/memory-map-test-02-top-labels-off.png b/memory-map-test-02-top-labels-off.png new file mode 100644 index 0000000..a09ed8c Binary files /dev/null and b/memory-map-test-02-top-labels-off.png differ diff --git a/memos-setup/learnings/2026-05-16-cloak-deprecate-camofox.md b/memos-setup/learnings/2026-05-16-cloak-deprecate-camofox.md new file mode 100644 index 0000000..26e88b9 --- /dev/null +++ b/memos-setup/learnings/2026-05-16-cloak-deprecate-camofox.md @@ -0,0 +1,200 @@ +# Decision: Cloak service replaces Camofox as primary stealth scraper + +**Date:** 2026-05-16 +**Author:** sergio + claude (collaborative session) +**Status:** Phase 1 + 2 + 3 all complete same day. Camofox systemd unit stopped and disabled; Cloak service is the sole stealth + interactive browser backend. Rollback still possible by re-enabling Camofox systemd and flipping `CAMOFOX_URL`. + +## TL;DR + +We benchmarked CloakBrowser (Chromium 146 + C++ stealth patches) against Camoufox (Firefox fork with C++ patches) and stock Playwright. **All three engines handle DataDome's silent JS challenge** when IP reputation is clean; only the stealth engines pass. Cloak edges Camoufox on speed (~3.9 s vs ~4.8 s per page on Idealista), launch (0.78 s vs 3.53 s), RAM (830 MB vs 1.13 GB), and crucially exposes a **drop-in Playwright API** that lets us consolidate the web stack on one engine. + +Camofox stays in place as the interactive browser backend (`browser_*` agent tools) until Cloak service grows feature parity in Phase 2. All new scrape work routes to Cloak. + +## Why this change + +### Benchmark findings (`tower/docs/browser-stealth-benchmark-2026-05-16.md`) + +| Engine | Cold launch | Avg DCL | Peak RSS | Idealista (clean IP) | +|---|---|---|---|---| +| Stock Playwright | 0.41 s | 0.53 s | 657 MB | ❌ blocked, 3 retries on DataDome challenge | +| CloakBrowser | 0.78 s | 0.61 s | 830 MB | ✅ 124 KB real page, 3.9 s | +| Camoufox | 3.53 s | 2.30 s | 1.13 GB | ✅ 224 KB real page, 4.8 s | + +The Chromium-vs-Firefox engine difference is the main speed gap (`github.com` took 10.3 s in Firefox vs 1.5 s in either Chromium variant). Detection-evasion is roughly tied between stealth engines. + +### Operational problems with current setup + +1. **Camofox service was broken** on production Tower — `better-sqlite3` native module failed to load, `/tabs` endpoint returned 500, `/health` lied with `ok: true`. Fixed via `npm rebuild` in same session but reveals brittleness. +2. **Firecrawl's `playwright-service`** uses stock Chromium — fails on every DataDome-protected site. Skills had to manually domain-route. +3. **Two stealth stacks to maintain**: Camofox (Node + Camoufox + Firefox) and stock Playwright (Chromium). Duplicate code paths in `skills/web-research/SKILL.md` domain-routing tables. + +### The IP reputation reality + +During benchmarking we ran 20+ sequential requests from the Tower's home IP and triggered DataDome's `t:'fe'` interactive captcha mode. **Both stealth engines fail equally** in this state — it's not engine-discriminable. The fix is hygiene (persistent context, pacing, fingerprint stickiness, referer chains) and/or CapSolver fallback, both of which we built into the Cloak service rather than retrofitting Camofox. + +## What changed (Phase 1, today) + +### New: Cloak scraping service +- **Path:** `/home/openclaw/.hermes/cloak-service/` +- **Port:** 9378 +- **systemd unit:** `~/.config/systemd/user/cloak-service.service` (enabled, auto-restart) +- **Endpoints:** + - `GET /health` — service + browser status + warm-domain list + - `POST /v1/scrape` — Firecrawl-compatible scrape (returns `{success, data: {html, markdown, metadata}, challenge}`) + - `POST /v1/save-pdf` — render URL to PDF on disk + - `GET /v1/balance` — CapSolver credit balance +- **Features built in:** + - Per-domain `BrowserContext` with persistent cookies in `cloak-service/profiles//cookies.json` + - Per-domain pacing (`CLOAK_MIN_DOMAIN_INTERVAL_S=8` default) + - Asset blocking (images/CSS/fonts) — cuts bandwidth ~80% + - DataDome challenge detection + optional CapSolver retry + - Spanish locale + Europe/Madrid timezone defaults (matches Tower's home IP geo) + +### CapSolver integration +- Library: `capsolver==1.0.7` Python SDK +- Activated by setting `CAPSOLVER_API_KEY` env var +- Drop-in template at `~/.config/systemd/user/cloak-service.service.d/capsolver.conf.example` +- Currently **disabled** (no API key set). Cost at projected volume: ~$1/month. + +### Skill and docs updates +- `skills/web-research/SKILL.md` — adds §4 "Cloak Stealth Service", lists DataDome / Imperva / Cloudflare Turnstile domains routing here +- `CLAUDE.md` — Architecture section updated, "When to use which tool" table updated, Commands section gets Cloak health-check + systemd commands + +### Interim fix to Camofox +- Rebuilt `better-sqlite3` via `npm rebuild` in `/home/openclaw/.hermes/hermes-agent/` +- Camofox `/tabs` confirmed working again +- Restored interactive browser tool while Phase 2 is pending + +## Phase 2 — completed same day (2026-05-16) + +All Camofox interactive endpoints now mirrored in the Cloak service: + +| Endpoint | Camofox | Cloak (`localhost:9378`) | Notes | +|---|---|---|---| +| `POST /tabs` | ✓ | ✓ | per-(user, session) BrowserContext, full assets | +| `POST /tabs/:id/navigate` | ✓ | ✓ | networkidle wait + refs reset | +| `GET /tabs/:id/snapshot` | ✓ | ✓ | **byte-for-byte format match** — see diff-test below | +| `POST /tabs/:id/click` | ✓ | ✓ | with auto-refresh on stale ref + mouse-sequence fallback | +| `POST /tabs/:id/type` | ✓ | ✓ | click + clear + type (delay 30ms) + optional Enter | +| `POST /tabs/:id/scroll` | ✓ | ✓ | `page.mouse.wheel(0, ±600)` | +| `POST /tabs/:id/press` | ✓ | ✓ | `page.keyboard.press()` | +| `POST /tabs/:id/back` | ✓ | ✓ | `page.go_back()` + refresh refs | +| `GET /tabs/:id/screenshot` | ✓ | ✓ | PNG base64 | +| `DELETE /sessions/:userId` | ✓ | ✓ | closes all tabs + context | + +**Implementation:** `/home/openclaw/.hermes/cloak-service/interactive.py` (~450 LOC). Uses Playwright Python's `page.locator('body').aria_snapshot()` which produces identical YAML to Camofox's Node Playwright. Refs are server-side state, built by parsing the YAML in document order and tagging interactive roles; `[eN]` markers injected into the snapshot text before return so `browser_camofox.py` parsing works unchanged. + +### Diff-test result (HN homepage, same URL, same wall-clock) +- Camofox: `refsCount=222, totalChars=41004` +- Cloak: `refsCount=222, totalChars=41004` +- Diff: 182 lines, **100% content drift** (HN updated story timestamps and vote counts between the two scrapes) — **0% format drift** +- Ref positions match: `e15` resolves to the same DOM node in both engines + +### End-to-end validation +Full agent-style flow against Cloak (`/tmp/e2e_cloak_as_camofox.py`): +- `POST /tabs` (HN): 1.41 s +- `GET /snapshot`: 0.23 s, 222 refs with `[eN]` markers +- `POST /click` (ref=e3, "new" link): 5.61 s, navigated to `/news` +- `GET /snapshot` (post-click): 0.10 s +- `POST /navigate` (DDG): 1.22 s +- `POST /back`: 0.60 s +- `GET /screenshot`: 0.13 s, 270 KB PNG +- `DELETE /sessions/:userId`: 0.04 s + +All assertions passed. + +### Cutover (the actual production flip) +- Backed up `.env` to `~/.hermes/.env.pre-cloak-flip` +- Changed `CAMOFOX_URL=http://localhost:9377` → `http://localhost:9378` +- Restarted: `hermes-gateway`, `hermes-gateway-arinze`, `-hr-agent`, `-krati`, `-research-agent`, `-sergio` — all came back `active` +- Camofox service is **still running** as warm-rollback target, but receives no agent traffic. Decision: stop+disable only after ~1 week of clean production operation. + +### Rollback (single command, ~30 s) +```bash +sed -i 's|^CAMOFOX_URL=http://localhost:9378$|CAMOFOX_URL=http://localhost:9377|' ~/.hermes/.env +systemctl --user restart hermes-gateway hermes-gateway-arinze hermes-gateway-hr-agent hermes-gateway-krati hermes-gateway-research-agent hermes-gateway-sergio +``` +…or restore from `~/.hermes/.env.pre-cloak-flip`. + +## Phase 3 — completed same day (2026-05-16) + +Triggered after Phase 2 E2E was green and gateway traffic confirmed +landing on Cloak. User opted to skip the ~1 week warm-rollback period. + +### What landed +- `systemctl --user stop camofox.service` — Camofox process terminated + (0 active tabs / 0 active sessions at the time of stop, no in-flight work) +- `systemctl --user disable camofox.service` — symlink removed from + `default.target.wants`, will NOT restart on Tower reboot +- Port 9377 released (`ss -tlnp` confirms) +- `tools/browser_camofox.py` module docstring rewritten as a deprecation + notice — explains the file's name is historical, HTTP calls now land + on Cloak (`CAMOFOX_URL=http://localhost:9378`), keep filename to avoid + renaming the 17 call sites in `browser_tool.py` +- Camofox install left untouched at + `~/.hermes/hermes-agent/node_modules/@askjo/camofox-browser/` — for + reference / future code archaeology / rollback if needed + +### Post-stop verification (all green) +- Cloak service still serving: `systemctl --user is-active cloak-service` → `active` +- All 6 hermes-gateway processes still active +- Full E2E flow re-run against Cloak with Camofox dead: 9/9 endpoint calls + passed (create tab → snapshot → click(e3) → snapshot → navigate(DDG) → + back → screenshot → delete-session) +- Python import of `tools.browser_camofox` symbols (`is_camofox_mode`, + `get_camofox_url`, `check_camofox_available`, `camofox_navigate`, + `camofox_snapshot`, `camofox_click`, `camofox_type`) — all importable, + no exceptions. With `CAMOFOX_URL=http://localhost:9378` in env: + `is_camofox_mode()` → True, `check_camofox_available()` → True + +### Re-enabling Camofox if a regression appears +```bash +systemctl --user enable camofox.service +systemctl --user start camofox.service +sed -i 's|^CAMOFOX_URL=http://localhost:9378$|CAMOFOX_URL=http://localhost:9377|' ~/.hermes/.env +systemctl --user restart hermes-gateway hermes-gateway-arinze hermes-gateway-hr-agent hermes-gateway-krati hermes-gateway-research-agent hermes-gateway-sergio +``` +Camofox will boot fresh; takes ~3 seconds to be ready. + +### Phase 4 (optional, not yet planned) +- Move Cloak service source from the Tower host into this repo under + `tower/services/cloak-service/` so it's version controlled. Trade-off: + introduces a deploy step (rsync from repo to `~/.hermes/cloak-service/`, + or repoint the systemd unit at the repo path). Defer until either + multi-developer collaboration on the service starts, or the source + diverges enough that "what's actually running" becomes ambiguous. +- Remove `node_modules/@askjo/camofox-browser/` from the hermes-agent + install — only if disk pressure becomes an issue (currently it's + ~140 MB of Firefox binary + JS). + +## Rollback path + +If Cloak service misbehaves in production: +1. `systemctl --user stop cloak-service.service` +2. `systemctl --user disable cloak-service.service` +3. Revert `skills/web-research/SKILL.md` (the §4 addition + domain-routing table edits) +4. Revert `CLAUDE.md` Architecture + tool-table sections +5. Camofox is untouched in Phase 1 — interactive browser keeps working without action + +Cloak service has no shared state with Camofox or Firecrawl. Failure is fully contained. + +## Tier 1 cost projection + +Current scrape volume across Hermes skills: ~hundreds of pages/day, mixed domains, low single-digit percent hitting DataDome. + +| Component | Cost | +|---|---| +| CloakBrowser | $0 (open source wrapper, free binary) | +| Cloak service hosting | $0 (runs on Tower) | +| CapSolver (when enabled, ~30 captchas/mo) | ~$0.72/mo | +| **Tier 1 total** | **~$1/mo** | + +Scaling ceiling from single Tower IP without proxy rotation: ~10–20k pages/day to a single anti-bot domain. Beyond that → Tier 2 (ISP proxies, ~$30/mo) or Tier 3 (rotating residential, ~$150/mo). Not currently needed. + +## Cross-references + +- Benchmark report: `tower/docs/browser-stealth-benchmark-2026-05-16.md` +- Skill doc: `skills/web-research/SKILL.md` §4 +- Service source: `/home/openclaw/.hermes/cloak-service/cloak_service.py` +- systemd unit: `/home/openclaw/.config/systemd/user/cloak-service.service` +- Camofox tool (still active): `/home/openclaw/.hermes/hermes-agent/tools/browser_camofox.py` diff --git a/memos-setup/learnings/2026-05-17-v2-only-bge-shares.md b/memos-setup/learnings/2026-05-17-v2-only-bge-shares.md new file mode 100644 index 0000000..7199cfe --- /dev/null +++ b/memos-setup/learnings/2026-05-17-v2-only-bge-shares.md @@ -0,0 +1,340 @@ +# 2026-05-17 — v2-only memory, BGE-large embedder, refined share-scope policy + +## TL;DR + +After a session-long review of the memory stack, we made four architectural decisions: + +1. **Drop Paperclip / CEO orchestration entirely.** No more orchestrator layer. Each Hermes agent runs independently on the single workstation (OS hostname `sergio`; Tailnet name `tower.taila4a33f.ts.net` — same machine). +2. **Commit to v2 (`@memtensor/memos-local-plugin`) as the sole memory backend.** Stop and disable v1 MemOS server. Stop the failing `memos-hub.service`. The v2 plugin's row-level namespace tuple gives us per-agent isolation without cubes. +3. **Switch the embedder to `Xenova/bge-large-en-v1.5`.** Benchmarked 3-way against MiniLM-L6-v2 (current) and `gemini-embedding-2` on 200 real traces × 8 LLM-judged queries. BGE-large won on every quality metric (P@5, MRR, nDCG@5) while staying local + free. +4. **Refine the share-scope policy:** `skills` and `world_model` are `local` (shared across agents); `traces`, `episodes`, and `policies` are `private`. The hub config knob (`hub.enabled`) was observed silently promoting new rows to `local` — flipping it to `false`. + +## Why drop Paperclip / CEO + +The CEO-orchestrator-of-workers pattern had two costs we weren't paying back: + +- **Orchestration value** turned out to be marginal. Workers handle their own delegation through the plugin's `on_delegation` hook well enough that the CEO layer wasn't doing meaningful work. +- **Conceptual overhead.** Documentation, mental model, and a handful of code paths assumed a "Tower (CEO) vs sergio (workers)" split that never reflected the actual single-machine reality. The Tailnet DNS name `tower.taila4a33f.ts.net` is just a friendly alias for this same box; the OS hostname is `sergio`. + +Dropping Paperclip simplifies the stack to one agent layer, one plugin daemon, one DB. Soft-improvement loop (feedback → skill patch) now happens directly. + +## Why v2 over v1 + +We acknowledged the original v2 audit (2.4/10 in April) but re-evaluated based on: + +- **v1 was unused all along.** Probed the running v1 server's `ceo-cube` — empty (`text_mem=[], total_nodes=0`). The `memos-toolset` v1 client had been disabled on 2026-05-12. Months of "v1 is the production target" were never reflected in actual writes. +- **v2 has the UX wins we want.** UI at `:18800` with Memories / Skills / Policies / World-model views, per-agent filtering, episode timeline, log viewer, evolution timeline. None of this exists in v1. +- **Auto-skill crystallisation is novel.** L2 candidate → trials → promoted skill is a real reinforcement-style learning pattern only v2 has. +- **Isolation works.** Per-profile row tagging confirmed on the live DB: `(hr-agent: 237, sergio: 96, mohammed: 63, research-agent: 7, email-marketing: 2)`. No `default` bucket, no contamination. + +v1 stays installed at `/home/openclaw/Coding/MemOS/` and the `memos-server.service` is on disk (disabled). Rollback is `systemctl --user enable --now memos-server.service`. + +## The embedder benchmark + +**Methodology:** 200 real trace summaries pulled from the live DB (read-only), 8 realistic memory queries, top-5 retrieval per model, DeepSeek-V3 as graded-relevance judge (0/1/2 scale) over the union of retrievals. + +**Aggregate metrics across 8 queries:** + +| Model | Dim | P@5 | MRR | nDCG@5 | Where | +|---|---|---|---|---|---| +| MiniLM-L6-v2 | 384 | 0.450 | 0.688 | 0.628 | Local (Xenova) — current | +| **BGE-large-en-v1.5** | **1024** | **0.500** | **0.781** | **0.725** | **Local (Xenova) — picked** | +| gemini-embedding-2 | 3072 | 0.425 | 0.750 | 0.662 | Google AI Studio API | + +Margins for BGE-large: +11% P@5 / +14% MRR / +15% nDCG@5 over MiniLM; +18% P@5 / +4% MRR / +10% nDCG@5 over Gemini-2. + +**Why not Gemini-2:** quality lower than BGE on every quality metric. The similarity-score gap (top-1 0.753 vs BGE 0.689) is a vector-space artefact, not a quality signal. Gemini's only practical advantage — multimodal — isn't used by our pipeline today. + +**Why not MiniMax embeddings:** their embeddings endpoint uses `texts` + `type` body shape, not OpenAI-compatible `input`. Couldn't drop into the plugin's `openai_compatible` provider without a code patch. + +**Eval harness:** `/tmp/embed-eval/metrics3.py` — keep as a reusable benchmark for future model swaps. + +## Why not the lighter BGE variants + +Considered but didn't benchmark: +- `BGE-base-en-v1.5` (768d, ~550 MB working set) — ~1% MTEB worse than large, 3× less RAM +- `BGE-small-en-v1.5` (384d, ~200 MB) — ~3% worse, 7× less RAM + +Sergio's machine has 15 GB total / ~9 GB free at idle. BGE-large's ~1.5 GB working set is well within budget, ~200ms CPU embedding latency is fine for async post-turn writes. Picked large for the quality. + +## Share-scope policy + +The plugin's `isVisibleTo` rule (`core/runtime/namespace.ts:136`) treats any non-`private` scope as "visible to all callers." Currently all `traces`/`policies`/`world_model`/`skills` rows have `share_scope='local'` — meaning every agent can read every other agent's everything. Only `episodes` are `private` (correctly). + +We don't want full cross-agent transparency: + +- **Traces** are literal user/agent conversation turns — leak personal context wholesale. +- **Episodes** are session summaries — leak what each user was working on. +- **Policies** are L2 candidates under trial — half-formed hypotheses, not validated knowledge. + +We do want: + +- **Skills** shared — they're the validated procedural output of the crystallisation pipeline, the deliberately-distilled layer. +- **World_model** shared — facts about the environment + (less ideal) operator. Caveat: world_model mixes universal facts ("MemOS server is at :8001") with operator-specific facts ("Sergio prefers concise answers"). If shared skills misfire because they reference operator-specific facts the receiving agent doesn't have, we'll add a `fact_kind: environment | operator` flag and split visibility per kind. Not doing that today. + +### SQL to land the policy + +```sql +UPDATE traces SET share_scope='private', shared_at=NULL, share_target=NULL; +UPDATE episodes SET share_scope='private', shared_at=NULL, share_target=NULL; +UPDATE policies SET share_scope='private', shared_at=NULL, share_target=NULL; +-- world_model + skills stay 'local' (already correct) +``` + +### Auto-promote — root cause found and fixed (2026-05-17) + +After running the SQL we repeatedly saw `share_scope` revert from `private` back to `local`. Hunted it through every code path in the v2 plugin: `core/capture/`, `core/memory/l2/`, `core/memory/l3/`, `core/skill/`, `core/storage/repos/*`, `core/runtime/namespace.ts`. Every write path *correctly* preserves share scope via `normalizeShareForStorage(row.share?.scope)`. No event subscriber re-shares on the bus. The Python adapter doesn't pass a share field. `hub.enabled: true` was a false lead. + +Forensic SQLite triggers (`tools/forensic-audit.sql`) caught the culprit in the act: at 04:30:01 we saw two `traces` rows transition `private → local` via direct `UPDATE` statements (not via the daemon's HTTP `/share` endpoint, which would have logged differently). + +The culprit was an **out-of-band cron job** added during Sprint 3: + +```cron +# Original (added 2026-05-12, disabled 2026-05-17): +*/15 * * * * /home/openclaw/Coding/Hermes/.claude/worktrees/nice-mclaren-13f017/scripts/promote-memos-shares.py \ + >> /home/openclaw/.hermes/memos-plugin/logs/promote.log 2>&1 +``` + +The script's docstring is self-incriminating: *"For each artifact type that supports sharing in 2.0 …, find rows that are still `private` across ALL profiles and promote them to `share_scope='local'` so all Hermes profiles on this host (including sergio's orchestrator) can read them."* — and *"To work around [HTTP API namespace enforcement], the promoter writes directly to the SQLite store."* + +This was correct behavior **for the old architecture** where sergio's orchestrator (Paperclip/CEO) needed cross-profile read. With Paperclip/CEO retired today, the script is actively defeating the per-agent isolation we want. + +**Fix applied:** crontab line commented out with a `# DISABLED 2026-05-17 …` prefix. The script file remains on disk (in a Claude worktree from a different branch) — kept as a forensic artifact for this decision doc. To remove fully: `rm -rf /home/openclaw/Coding/Hermes/.claude/worktrees/nice-mclaren-13f017/` (it's an orphan worktree, deletion is safe). + +A `@weekly` `check-memos-plugin-update.sh` line from the same worktree was disabled at the same time for the same reason — orphan Sprint 3 artifact. + +### Forensic audit (kept for future) + +`tools/forensic-audit.sql` installs `AFTER INSERT/UPDATE` triggers on `traces`, `policies`, `episodes` that record every share_scope write to a `share_scope_audit` side table without modifying behavior. Useful next time we observe unexpected scope drift. Apply / inspect / remove: + +```bash +sqlite3 ~/.hermes/memos-plugin/data/memos.db < tools/forensic-audit.sql +python3.12 tools/memos-explorer.py audit --since 1h +sqlite3 ~/.hermes/memos-plugin/data/memos.db < tools/forensic-audit.down.sql # uninstall +``` + +## Model assignment by role + +| Role | Model | Why | +|---|---|---| +| Memory LLM (extraction / summarisation / injection) | DeepSeek V3 (`deepseek-chat`) | Non-thinking model. MiniMax's `` tags break the extraction parser. Constrained generation — bigger isn't better. | +| Skill crystallisation (`skillEvolver`) | MiniMax M2.7 (Anthropic-compatible endpoint) | Synthesis-heavy. Reasoning helps. Output post-processed into YAML — `` blocks get stripped cleanly. Lower call frequency → cost matters less. | +| Embedder | `Xenova/bge-large-en-v1.5` | See benchmark above. Local, free, top of measured quality. | + +## What got removed / disabled + +- `memos-server.service` — `systemctl --user stop memos-server.service && systemctl --user disable memos-server.service`. v1 plugin server, cube was empty. Free Qdrant + Neo4j + Python RAM. +- `memos-hub.service` — `systemctl --user stop memos-hub.service && systemctl --user disable memos-hub.service`. Was thrashing in auto-restart loop attempting to spawn hub mode the plugin doesn't implement upstream. +- Paperclip / CEO setup — operationally retired. No code change in this repo today; the Paperclip side is left as-is on Tower for archaeological reference. +- v2 `gemini-embedding-2` config — briefly swapped in during the embedder eval, replaced by BGE-large before any data was written. + +## Rollback path + +If BGE-large embedding causes problems (latency, quality regression on real workload, OOM): + +1. Edit `~/.hermes/memos-plugin/config.yaml` → set `embedding.model: Xenova/all-MiniLM-L6-v2`, `embedding.dimensions: 384`. +2. Wipe `~/.hermes/memos-plugin/data/memos.db` (dimensions changed — old vectors are unusable). +3. Restart Hermes agents — DB rebuilds with MiniLM. + +If the share-scope tightening causes problems (skills can't find context, world_model lookups fail): + +1. `UPDATE SET share_scope='local'` to revert one table at a time. +2. Don't revert all four — re-introduce sharing per-table until the problem disappears. + +If v2 itself becomes unworkable: + +1. `systemctl --user enable --now memos-server.service` brings v1 back. +2. Re-enable `memos-toolset` by renaming `plugin.yaml.disabled-2026-05-12` → `plugin.yaml`. +3. Restart agents — they pick up v1 cube writes via the toolset. +4. v2 plugin DB remains on disk and can be exported to v1 via a sync script if needed. + +## On the "Tower" name + +Earlier sprint docs referred to a separate "Tower" machine. There isn't one. The single workstation has OS hostname `sergio` and Tailnet DNS name `tower.taila4a33f.ts.net`. Tailscale Serve proxies tailnet HTTPS 443 → local `:18800`. Operators SSH in from other devices and almost always type the Tailnet name in browser URLs, so "Tower" is the more visible name in day-to-day use. Keep the Tailnet alias; ignore the prior assumption that it was a different host. + +```bash +# Verify any time: +hostname # → sergio +tailscale status --self --json | jq -r .Self.DNSName # → tower.taila4a33f.ts.net. +tailscale serve status # → / proxy http://127.0.0.1:18800 +``` + +## Cross-agent visibility — two complementary paths + +### Path 1 — bundled-viewer overlay (recommended for browsing) + +The v2 plugin's built-in viewer is bound to one namespace at daemon startup, and its HTTP API enforces that namespace on every list / search. Two patches solve this without running multiple daemons: + +- **Server side:** A per-request namespace override threaded through Node's `AsyncLocalStorage`. The HTTP dispatch layer parses `?as_profile=` (URL) or `X-As-Profile: ` (header) on every request and wraps the handler in `runWithRequestNamespace(ns, …)`. Visibility helpers (`visibleToCurrent`, `ownedByCurrent`) and the paginated turn-key SQL (`listTurnKeys`, `countTurns`) read the ALS namespace via `effectiveNamespace()` and apply it. See `core/runtime/request-namespace.ts` (new) and the diffs in `core/pipeline/memory-core.ts`, `core/storage/repos/traces.ts`, `server/http.ts`. +- **Client side:** A small overlay script (`web/dist/hermes-profile-switcher.js`) added via one ` \ No newline at end of file diff --git a/perf-audit-results-idealista/idealista-estepona-page1.pdf b/perf-audit-results-idealista/idealista-estepona-page1.pdf new file mode 100644 index 0000000..3a7a120 Binary files /dev/null and b/perf-audit-results-idealista/idealista-estepona-page1.pdf differ diff --git a/perf-audit-results-idealista/idealista-estepona-page2-CHALLENGE.html b/perf-audit-results-idealista/idealista-estepona-page2-CHALLENGE.html new file mode 100644 index 0000000..7293df1 --- /dev/null +++ b/perf-audit-results-idealista/idealista-estepona-page2-CHALLENGE.html @@ -0,0 +1 @@ +idealista.com \ No newline at end of file diff --git a/perf-audit-results-idealista/idealista-estepona-page2.pdf b/perf-audit-results-idealista/idealista-estepona-page2.pdf new file mode 100644 index 0000000..9736cd0 Binary files /dev/null and b/perf-audit-results-idealista/idealista-estepona-page2.pdf differ diff --git a/perf-audit-results-idealista/marbella-top20-analysis.md b/perf-audit-results-idealista/marbella-top20-analysis.md new file mode 100644 index 0000000..4bf5117 --- /dev/null +++ b/perf-audit-results-idealista/marbella-top20-analysis.md @@ -0,0 +1,245 @@ +# Idealista — Marbella, first 20 listings — analysis + +**Generated:** 2026-05-16 +**Source:** `idealista.com/venta-viviendas/marbella-malaga/` page 1 +**Scraper:** Hermes Cloak service (`localhost:9378/v1/scrape`), 800 KB HTML in 10.4 s, no captcha +**Total listings on page 1:** 56 unique properties; this analysis covers the first 20 with full data + +--- + +## TL;DR + +20 properties spanning €430,000 to €19,500,000. Median sits at €1.975M / 304 m² / €6,517 per m². The dataset is heavily weighted toward villas (9 of 20 are standalone houses); only 4 apartments and 1 penthouse appear in the top 20, which says more about Marbella's listing inventory at this end of the market than about user search intent. + +Three properties priced under €600K stand out as access points; three priced over €4.9M sit in trophy-villa territory; the middle is dominated by Nueva Andalucía / Golden Mile golf-front villas in the €4–7M band. + +**Watch-list (sorted by my read of price-vs-position):** +- **Best raw value:** #6 (Guadalmina Alta dúplex, €2,386/m²) — extreme outlier, demands due diligence on why +- **Best entry to Golden Mile:** #13 (Nagüeles, €2,979/m²) — also unusually cheap for the postcode +- **Best central/beach apartment:** #4 (Fontanilla, €4,095/m²) — beach-walkable, sea-view, sensible price +- **Most aggressive luxury pricing:** #11 (Nueva Andalucía Velian villa, €14,478/m²) +- **Trophy of the page:** #10 (Villa Solstice, Las Brisas, €19.5M) + +--- + +## Quick-look table (sorted by €/m², ascending) + +| # | Price | m² | €/m² | Beds | Type | Location | Parking | +|---|------:|---:|-----:|-----:|------|----------|:-------:| +| 6 | €575,000 | 241 | **€2,386** | 3 | Dúplex | Guadalmina Alta | ✓ | +| 13 | €700,000 | 235 | €2,979 | 3 | Chalet adosado | Nagüeles | ✓ | +| 17 | €545,000 | 137 | €3,978 | 2 | Piso | Puerto Banús | — | +| 4 | €430,000 | 105 | €4,095 | 2 | Piso | Playa de la Fontanilla | — | +| 18 | €589,000 | 129 | €4,566 | 2 | Piso | Nueva Andalucía | — | +| 2 | €1,295,000 | 283 | €4,576 | 3 | Chalet adosado | La Dama de Noche | — | +| 8 | €1,900,000 | 397 | €4,786 | 4 | Villa | Guadalmina Alta | — | +| 20 | €995,000 | 180 | €5,528 | 3 | Piso | Playa de la Fontanilla | ✓ | +| 9 | €4,500,000 | 734 | €6,131 | 5 | Villa | Los Naranjos | ✓ | +| 1 | €1,975,000 | 304 | €6,497 | 5 | Chalet pareado | Lomas de Marbella Club | ✓ | +| 5 | €3,995,000 | 613 | €6,517 | 5 | Villa | Los Naranjos | ✓ | +| 7 | €1,099,000 | 167 | €6,581 | 4 | Dúplex | La Carolina-Guadalpín | ✓ | +| 3 | €4,000,000 | 580 | €6,897 | 4 | Villa | Marbesa | ✓ | +| 14 | €1,295,000 | 186 | €6,962 | 4 | Chalet pareado | Guadalmina Alta | ✓ | +| 19 | €6,850,000 | 829 | €8,263 | 6 | Villa | Nagüeles (Golden Mile) | ✓ | +| 16 | €2,300,000 | 276 | €8,333 | 3 | Ático | Puerto Banús | — | +| 15 | €4,995,000 | 539 | €9,267 | 6 | Villa | Marbesa | ✓ | +| 12 | €13,800,000 | 1,265 | €10,909 | 5 | Villa | Sierra Blanca (Golden Mile) | ✓ | +| 10 | €19,500,000 | 1,636 | €11,919 | 7 | Villa | Las Brisas, Nueva Andalucía | ✓ | +| 11 | €4,995,000 | 345 | €14,478 | 5 | Villa | Nueva Andalucía (Puerto Banús) | ✓ | + +--- + +## Per-property analysis + +### Entry tier (under €1M, 4 properties) + +#### #4 — Piso, Playa de la Fontanilla +**€430,000 · 105 m² · €4,095/m² · 2 beds · 3rd floor with lift · Gilmar Marbella – Golden Mile** +Second-line beach, south-facing, sea-view apartment in central Marbella. Mid-floor, exterior, with lift — the conventional buy-box for a second residence in this town. No parking listed (verify). Pricing is on the reasonable end for a sea-view beach-adjacent unit; comparable units in the Puerto Banús direction list 15–25% higher. Good liquidity profile if the goal is a rental income unit. +[Listing](https://www.idealista.com/inmueble/111179978/) + +#### #17 — Piso, Puerto Banús (Av. Julio Iglesias) +**€545,000 · 137 m² · €3,978/m² · 2 beds · with lift · Fine & Country** +Sub-€4K/m² for a 137 m² Puerto Banús apartment is unusual — typical Banús pricing for this size sits at €5K–€8K/m². Listing emphasises "first beach-line complex, marina-adjacent, school-close" but is light on photos/floor info in the card. Suspicions: high community fees, dated condition, or limited views. Worth a viewing slot purely to identify why it's priced where it is. +[Listing](https://www.idealista.com/inmueble/110027309/) + +#### #18 — Piso, Nueva Andalucía (La Medina de Banús) +**€589,000 · 129 m² · €4,566/m² · 2 beds · 1st floor with lift · Diana Morales / Knight Frank** +Gated community (24h security) near Centro Plaza shopping centre. Walkable to Puerto Banús. The agent is high-end — Knight Frank's local arm doesn't take cheap listings unless the building or community justifies it. Reasonable urban-feel apartment for a buyer who wants Banús-adjacent without paying Banús-marina prices. +[Listing](https://www.idealista.com/inmueble/106392536/) + +#### #6 — Dúplex, Guadalmina Alta (Camino Cortes) +**€575,000 · 241 m² · €2,386/m² · 3 beds · 4th floor with lift · Gilmar Puerto Banús** +**The outlier.** €2,386/m² is roughly **one-third** the page median. 241 m² penthouse-duplex in an established Guadalmina Alta community, adjacent to Real Club de Golf Guadalmina. At face value this is the deal of the page. Realistic explanations: (a) heavy renovation needed, (b) community has structural problems, (c) inherited estate sale, (d) listing typo on the m² figure (e.g. it might be 141 m² built + 100 m² terrace which Idealista combined). Verify the built-vs-terrace breakdown before getting excited. If genuine, this is a meaningful find. +[Listing](https://www.idealista.com/inmueble/110829099/) + +### Sub-€2M tier (5 properties — the "good Marbella second home" sweet spot) + +#### #2 — Chalet adosado, La Dama de Noche / La Alzambra +**€1,295,000 · 283 m² · €4,576/m² · 3 beds · Engel & Völkers Puerto Banus** +Fully renovated turnkey townhouse in Nueva Andalucía's Valle del Golf. Strong agent (E&V), strong location. Pricing aligns with comparable renovated townhouses in the same valley. Solid mid-market choice for someone who wants golf access without villa upkeep. +[Listing](https://www.idealista.com/inmueble/111076229/) + +#### #14 — Chalet pareado, Guadalmina Alta (Calle 19) +**€1,295,000 · 186 m² · €6,962/m² · 4 beds · Gilmar Puerto Banús** +Identical price to #2 (€1.295M) but 100 m² smaller — so €/m² is 50% higher. Listing describes "completely reformed, rustic-modern design, high-quality finishes." The premium pays for the renovation level and the postcode (Guadalmina Alta carries a brand premium). Apples-to-apples vs #2: choose #2 for space, #14 for finish quality (probably). +[Listing](https://www.idealista.com/inmueble/111279683/) + +#### #7 — Dúplex, La Carolina-Guadalpín +**€1,099,000 · 167 m² · €6,581/m² · 4 beds · 3rd floor with lift · Gilmar Marbella – Golden Mile** +Penthouse duplex marketed as "Milla de Oro – Nagüeles" with sea views. The postcode is technically La Carolina-Guadalpín, which is *adjacent* to the Golden Mile rather than on it — minor branding stretch. Still, sea-view duplex at this price in this corridor is reasonable. The 4-bed configuration in 167 m² will be tight rooms. +[Listing](https://www.idealista.com/inmueble/111048437/) + +#### #1 — Chalet pareado, Lomas de Marbella Club +**€1,975,000 · 304 m² · €6,497/m² · 5 beds · Engel & Völkers Marbella** +Walking distance to Puente Romano Beach Resort — that's the headline. Lomas de Marbella Club is a gated community on the Golden Mile, considerably more exclusive than the Banús-side equivalents. 5-bed corner townhouse with the resort/beach proximity. The premium over similar product in less prestigious enclaves (~€500K) buys the address. +[Listing](https://www.idealista.com/inmueble/111070021/) + +#### #20 — Piso, Playa de la Fontanilla (Av. Arias Maldonado) +**€995,000 · 180 m² · €5,528/m² · 3 beds · 2nd floor with lift · Airus Homes** +200 m from the beach promenade in central Marbella. 180 m² is large for the area; €5,528/m² is sane. Practical family/holiday apartment. Less "wow" than the Fontanilla cheaper option (#4) but bigger and with parking. Good if the requirement is space + walkability over sea views. +[Listing](https://www.idealista.com/inmueble/107379938/) + +#### #13 — Chalet adosado, Nagüeles (Av. Grecia) +**€700,000 · 235 m² · €2,979/m² · 3 beds · CASITASHOME** +The second outlier — Nagüeles is part of the Golden Mile residential corridor where standalone villas trade at €8–11K/m² and townhouses sit at €4–6K/m². €2,979/m² is anomalous. Description mentions "private garden, garage, solarium, family-friendly community with pool, few minutes drive to Puerto Banús and beaches." Listed by a smaller agent (CASITASHOME). Could indicate (a) the complex is set back from the Golden Mile core, (b) a quick-sale price, (c) dated interior. Highest priority for in-person viewing among the value picks. +[Listing](https://www.idealista.com/inmueble/110946516/) + +### Mid-luxury (€1.9M–€5M, 6 properties) + +#### #8 — Villa, Guadalmina Alta (golf-front) +**€1,900,000 · 397 m² · €4,786/m² · 4 beds · PRO Real Estate Guadalmina** +Newly-built villa, first line of golf, south-east facing, 555 m² plot. €4,786/m² is *very* competitive for a new-build front-line-golf villa — the asking price feels close to what a serious buyer would pay quickly. Lack of parking in details may just be an oversight (impossible to have a new-build villa with no parking). +[Listing](https://www.idealista.com/inmueble/107718547/) + +#### #3 — Villa, Marbesa (Calle Mallorca) +**€4,000,000 · 580 m² · €6,897/m² · 4 beds · Aventus NT SL** +Contemporary new-build villa, walking-distance to beach + restaurants. Marbesa sits east of Marbella centre — quieter than the Banús side. €6,897/m² for a new-build with walkable beach access is on-market. Same description template as #15 below (same builder/agent, likely the same development). +[Listing](https://www.idealista.com/inmueble/111006682/) + +#### #5 — Villa, Los Naranjos +**€3,995,000 · 613 m² · €6,517/m² · 5 beds · Engel & Völkers Puerto Banus** +First-line Los Naranjos golf course (one of the four golf courses in the Valle del Golf). Modern Andalusian villa with panoramic views. €6,517/m² for this position is mid-market for golf-front villas (cheap end of the band would be €5.5K, expensive end €8K+). Solid choice — and a sense-check for #11 below, which is €1M more with **half** the m². +[Listing](https://www.idealista.com/inmueble/111069499/) + +#### #9 — Villa, Los Naranjos (734 m²) +**€4,500,000 · 734 m² · €6,131/m² · 5 beds · Mitchells Prestige Properties** +Same Nueva Andalucía / Valle del Golf area, with sea + golf views. Largest of the sub-€5M villas in m² terms (€/m² accordingly low for the price tier). 734 m² is a lot of villa to maintain; appropriate for a primary-residence buyer rather than a holiday user. +[Listing](https://www.idealista.com/inmueble/109147795/) + +#### #11 — Villa, Nueva Andalucía / Puerto Banús +**€4,995,000 · 345 m² · €14,478/m² · 5 beds · Velian Group** +Highest €/m² on the page. Front-line of one of Nueva Andalucía's premier golf courses, gated community. 345 m² is *small* for a €5M villa — the price is paying for the position, not the structure. This is one of three Velian Group villas in the page-1 sample, and Velian is consistently asking the highest €/m² in the dataset. Whether that reflects genuine market clearing or aggressive pricing is testable: check days-on-market on Idealista's listing (not in card data — needs detail-page scrape). +[Listing](https://www.idealista.com/inmueble/111243819/) + +#### #15 — Villa, Marbesa (Av. Cantabrica) +**€4,995,000 · 539 m² · €9,267/m² · 6 beds · Aventus NT SL** +New-build 6-bedroom contemporary, walking distance to beach. Same Aventus NT SL agent + same description template as #3 — almost certainly the same development. The pricing gap between #3 (€6,897/m²) and #15 (€9,267/m²) for similar product warrants investigation: probably a difference in position within the development (sea-view plot vs interior plot), pool/garden size, or finish level. +[Listing](https://www.idealista.com/inmueble/111452225/) + +### Trophy tier (€2.3M–€19.5M, 5 properties) + +#### #16 — Ático, Puerto Banús (Av. Julio Iglesias) +**€2,300,000 · 276 m² · €8,333/m² · 3 beds · 8th floor (no lift!) · Savills Andalucía** +"Planta 8ª sin ascensor" is unusual for a €2.3M property — either a listing error or a genuinely characterful penthouse where the previous owners chose stairs-only on the final flight. Marketed by Savills, which doesn't take low-effort listings. The €/m² aligns with the Banús premium for top-floor sea-view product. +[Listing](https://www.idealista.com/inmueble/108879461/) + +#### #19 — Villa, Nagüeles (Golden Mile) +**€6,850,000 · 829 m² · €8,263/m² · 6 beds · Savills Andalucía** +Contemporary villa in a gated Golden Mile community. Among the four trophy-tier villas, this has the lowest €/m² (€8,263) despite a top-tier location. Reasonable price for a real Golden Mile address. Compare with #15 (Marbesa, €9,267/m²) — Marbesa at higher €/m² than the Golden Mile suggests #15's pricing is at the aggressive end or #19's is at the patient end. +[Listing](https://www.idealista.com/inmueble/108497235/) + +#### #12 — Villa, Sierra Blanca / Altos Reales (Golden Mile) +**€13,800,000 · 1,265 m² · €10,909/m² · 5 beds · Velian Group** +Mediterranean villa in the Altos Reales gated community on the Golden Mile. 1,265 m² built on a 3,845 m² plot — substantial. Second Velian villa in the top trio. €10,909/m² is consistent with Golden Mile front-row villa product. The 5-bed configuration on 1,265 m² implies very large rooms — primary-residence scale. +[Listing](https://www.idealista.com/inmueble/111330463/) + +#### #10 — Villa, Las Brisas, Nueva Andalucía ("Villa Solstice") +**€19,500,000 · 1,636 m² · €11,919/m² · 7 beds · Velian Group** +**Trophy of the page.** First-line of Las Brisas golf (members-only club, the most exclusive course in the Valle del Golf). West-facing for sunset views. Third Velian listing in the top 20 — they clearly dominate this segment of inventory. At €19.5M, the buyer pool is single-digit per quarter; expect long time-on-market unless something unusual. +[Listing](https://www.idealista.com/inmueble/110300940/) + +--- + +## Cross-cutting observations + +### Location heatmap + +| Sub-area | Count | Price range | Median €/m² | Profile | +|---|--:|---|--:|---| +| Nueva Andalucía / Valle del Golf | 5 | €589K–€19.5M | €6,581 | Golf-driven; widest price spread | +| Guadalmina Alta / La Dama de Noche | 4 | €575K–€1.9M | €4,681 | Established residential; reliable value | +| Golden Mile (Nagüeles, Sierra Blanca, Lomas) | 3 | €700K–€13.8M | €8,263 | Most prestigious postcode; trophy + outlier | +| Marbesa | 2 | €4M–€5M | €8,082 | New-build cluster east of town | +| Puerto Banús | 3 | €545K–€2.3M | €4,094 (apt) / €8,333 (penthouse) | Marina-driven, vertical product | +| Playa de la Fontanilla / Marbella centro | 2 | €430K–€995K | €4,812 | Beach-walkable apartments | + +The dataset over-represents Nueva Andalucía golf product and under-represents La Cala / Elviria (east-side) and Benahavís (west-side beyond Puerto Banús). If the actual buying criterion is "Marbella area," page 2+ would balance this. + +### Agent concentration + +| Agent | Count | Notable for | +|---|--:|---| +| Velian Group | 3 | All three trophy villas (€5M+) | +| Engel & Völkers (combined offices) | 3 | Mid-luxury townhouse/villa | +| Gilmar (combined offices) | 4 | Spreads entry-to-mid; high listing volume | +| Savills Andalucía | 2 | Mid-luxury, conservative pricing | +| PRO Real Estate, Aventus, Mitchells, Knight Frank/Diana Morales, Fine & Country, Airus, CASITASHOME | 1 each | One-offs | + +Velian's monopoly on the top of the page is striking. They're either the dominant trophy-listing agent in the area or they've paid for visibility. Worth knowing if you're calibrating "what does the market actually clear at" — three of Velian's three listings are at or near the top of the €/m² distribution. + +### Pricing dispersion + +- **Standard deviation of €/m²** across the 20: €3,168 +- **75th percentile €/m²:** €8,328 +- **25th percentile €/m²:** €4,571 +- **Coefficient of variation:** 0.49 — very high, reflecting Marbella's mix of beachfront entry apartments and Golden Mile trophy villas in the same search results + +In a normal urban market the CV is 0.15–0.25. Marbella's 0.49 means **comparable-sale analysis must be sub-area specific**; a city-wide €/m² mean is meaningless for valuation here. + +### Discounted listings + +Card data shows price-drop badges on roughly 20% of listings page-wide, but **none of the top 20 had a visible discount badge in this scrape**. Either the top-20 are recently listed (no drops yet) or the badge field rendered differently for these specific listings. A detail-page scrape would confirm; for now, treat the absence as inconclusive rather than informative. + +--- + +## Recommendations by buyer profile + +| If you want… | Look at | Why | +|---|---|---| +| Holiday apartment, beach-walkable, under €600K | **#4** (Fontanilla) or **#18** (Nueva Andalucía gated) | Best price-to-position in the sample | +| Suspicious "too good" outlier worth investigating | **#6** (Guadalmina Alta dúplex) and **#13** (Nagüeles townhouse) | Both at <€3,000/m² in mid-range postcodes | +| Solid family townhouse, golf-area, turnkey | **#2** (La Dama de Noche) | Renovated, E&V, fair price | +| Mid-luxury new-build villa | **#3** (Marbesa) or **#8** (Guadalmina Alta golf-front) | New, well-positioned, reasonable €/m² | +| Trophy purchase, position over square-metres | **#11** (Nueva Andalucía Velian) | Highest €/m² on the page = paying for view + access | +| Trophy purchase, square-metres for the money | **#19** (Nagüeles Savills) | Same Golden Mile postcode as the €13.8M villa at €2,646/m² less | +| Pure spectacle | **#10** (Villa Solstice, Las Brisas) | Biggest, most exclusive, most expensive | + +--- + +## Caveats — read before quoting these numbers + +- **All data is from the listing-card summary, not detail-page scrape.** Adds ~30 s per property if you want full description, energy rating, year built, full photo set, plot size separate from built m². Available on request — re-run with detail-page fetches. +- **m² figures from Idealista may combine built area + terraces/garden** depending on how the agent registered the listing. The #6 outlier specifically is worth checking on detail page for `superficie construida` vs `superficie útil`. +- **No on-market-since data here.** A villa at €5M asking that's been listed for 18 months has very different implications from one listed last week. +- **No comparable-sale validation.** All "good price" / "aggressive price" assessments are based on relative position within this 20-property sample, plus general Marbella market knowledge — not against recent sold-comp data. +- **Listing positions can rotate.** Idealista's default sort isn't purely chronological; "first 20" today may differ from "first 20" tomorrow. + +--- + +## How this was generated + +- **Source page:** `https://www.idealista.com/venta-viviendas/marbella-malaga/` +- **Fetched via:** `POST localhost:9378/v1/scrape` (Hermes Cloak service, CloakBrowser stealth-Chromium 146 with persistent Spanish locale context) +- **HTML size:** 795,619 bytes +- **Total scrape time:** 10.4 s (no CapSolver call required — DataDome's silent JS challenge solved natively) +- **Parser:** ad-hoc Python regex extraction of `
` blocks → structured JSON → analysis +- **Raw structured data:** `/tmp/marbella-20.json` (kept for reference / future reruns) + +To regenerate / refresh: +```bash +curl -s -X POST localhost:9378/v1/scrape -H "Content-Type: application/json" \ + -d '{"url":"https://www.idealista.com/venta-viviendas/marbella-malaga/","formats":["html"]}' \ + > /tmp/marbella-listing.json +# then run /tmp/parse-marbella.py (recoverable from this session's transcript) +``` diff --git a/perf-audit-results-idealista/marbella-top20-raw.json b/perf-audit-results-idealista/marbella-top20-raw.json new file mode 100644 index 0000000..05f7332 --- /dev/null +++ b/perf-audit-results-idealista/marbella-top20-raw.json @@ -0,0 +1,302 @@ +[ + { + "id": "111070021", + "url": "https://www.idealista.com/inmueble/111070021/", + "title": "Chalet pareado en Cecilia Bohl De Faber, Lomas de Marbella Club, Marbella", + "type": "Chalet pareado", + "price": 1975000, + "old_price": 8, + "beds": "5 hab.", + "m2": 304, + "eur_per_m2": 6497, + "floor": null, + "parking": true, + "agent": "Engel & Völkers Marbella", + "desc": "Exclusiva casa espaciosa a poca distancia de Puente Romano -\nUbicada en una comunidad cerrada a solo unos minutos a pie del prestigioso Puente Romano Beach Resort, esta impecable y elegante casa adosada en esquina ofrece un estilo de vida de lujo en una de las zonas más exclusivas de Marbella, a escasa distancia de la playa. Con 303 m² construidos, combina altas calidades, diseño moderno, privacidad, confort y una ubicación privilegiada en la Milla de Oro de Marbella.\nAl acceder a la vivienda, se presenta un elegante diseño de concepto abiert" + }, + { + "id": "111076229", + "url": "https://www.idealista.com/inmueble/111076229/", + "title": "Chalet adosado en Dali-ur Atalaya Rio Verde, La Dama de Noche-La Alzambra, Marbella", + "type": "Chalet adosado", + "price": 1295000, + "old_price": 4, + "beds": "3 hab.", + "m2": 283, + "eur_per_m2": 4576, + "floor": null, + "parking": false, + "agent": "Engel & Völkers Puerto Banus", + "desc": "Lujosa Casa Adosada Llave en Mano en el Exclusivo Valle del Golf de Marbella\nEste adosado completamente renovado está listo para entrar a vivir y se encuentra en una de las zonas más codiciadas de la Nueva Andalucía de Marbella. Ofrece interiores refinados y contemporáneos para quienes buscan la más alta calidad de vida en la Costa del Sol, con un cuidado diseño, comodidades modernas y acabados de excelente nivel.\nLos tres dormitorios cuentan con baño en suite y terraza privada, además de un aseo adicional para invitados. La cocina de diseño" + }, + { + "id": "111006682", + "url": "https://www.idealista.com/inmueble/111006682/", + "title": "Casa o chalet independiente en Calle Mallorca, 299, Marbesa, Marbella", + "type": "Casa o chalet independiente", + "price": 4000000, + "old_price": null, + "beds": "4 hab.", + "m2": 580, + "eur_per_m2": 6897, + "floor": null, + "parking": true, + "agent": "Aventus NT SL", + "desc": "Esta villa contemporánea de 4 dormitorios de nueva construcción está perfectamente ubicada a poca distancia de la playa y de restaurantes locales, y a solo unos minutos en coche del vibrante centro de Marbella.\nSu impresionante diseño arquitectónico combina piedra natural, acabados en efecto madera y una exuberante jardinería, integrándose a la perfección con el entorno costero.\nLa planta del jardín, orientada al sur, ofrece amplias terrazas ideales para comer al aire libre y relajarse en elegantes zonas chill-out.\nUna piscina privada, diseñ" + }, + { + "id": "111179978", + "url": "https://www.idealista.com/inmueble/111179978/", + "title": "Piso en Calle Pablo Casals, Playa de la Fontanilla, Marbella", + "type": "Piso", + "price": 430000, + "old_price": null, + "beds": "2 hab.", + "m2": 105, + "eur_per_m2": 4095, + "floor": "Planta 3ª exterior con ascensor", + "parking": false, + "agent": "Gilmar Marbella - Golden Mile", + "desc": "Apartamento en La Fontanilla\nEn segunda línea de playa, con vistas al mar, orientación sur y una planta media, ponemos a la venta este apartamento de 105 metros cuadrados distribuidos en salón, cocina independiente, terraza con vistas, 2 dormitorios y 2 baños.\nExterior, muy luminoso y en la zona gastronómica de moda. La urbanización tiene un coqueto jardín con piscina, para disfrutar del buen tiempo sin las aglomeraciones de las playas. Plaza de garaje opcional en el edificio de al lado.\nLa comodidad de vivir en el Centro de Marbella, disfrut" + }, + { + "id": "111069499", + "url": "https://www.idealista.com/inmueble/111069499/", + "title": "Casa o chalet independiente en Calle Sirio, Los Naranjos, Marbella", + "type": "Casa o chalet independiente", + "price": 3995000, + "old_price": null, + "beds": "5 hab.", + "m2": 613, + "eur_per_m2": 6517, + "floor": null, + "parking": true, + "agent": "Engel & Völkers Puerto Banus", + "desc": "Lujo golf primera línea: villa andaluza moderna con vistas panorámicas en Los Naranjos\nSituada en una posición privilegiada en primera línea con vistas al prestigioso campo de golf Los Naranjos, esta excepcional villa en primera línea de golf en Marbella combina a la perfección el encanto mediterráneo atemporal con un elegante diseño contemporáneo. Bañada de luz natural gracias a su codiciada orientación sur, la vivienda disfruta de sol ininterrumpido durante todo el día, junto con impresionantes vistas sobre el valle del golf en Nueva Andaluc" + }, + { + "id": "110829099", + "url": "https://www.idealista.com/inmueble/110829099/", + "title": "Dúplex en Camino Cortes, 26, Guadalmina Alta, Marbella", + "type": "Dúplex", + "price": 575000, + "old_price": 3, + "beds": "3 hab.", + "m2": 241, + "eur_per_m2": 2386, + "floor": "Planta 4ª exterior con ascensor", + "parking": true, + "agent": "Gilmar Puerto Banús", + "desc": "Este exclusivo ático dúplex de 3 dormitorios y 3 baños se encuentra en una cuidada urbanización cerrada en Guadalmina Alta, una de las zonas residenciales más consolidadas y tranquilas de Marbella. Rodeado por el prestigioso Real Club de Golf Guadalmina, el entorno combina privacidad, zonas verdes y un estilo de vida relajado junto al golf.\nLa vivienda disfruta de una excelente orientación sur, lo que garantiza abundante luz natural durante todo el día. En la planta principal encontramos un amplio salón con acceso directo a una generosa terraz" + }, + { + "id": "111048437", + "url": "https://www.idealista.com/inmueble/111048437/", + "title": "Dúplex en Bulevar Ashmawi, La Carolina-Guadalpín, Marbella", + "type": "Dúplex", + "price": 1099000, + "old_price": null, + "beds": "4 hab.", + "m2": 167, + "eur_per_m2": 6581, + "floor": "Planta 3ª exterior con ascensor", + "parking": true, + "agent": "Gilmar Marbella - Golden Mile", + "desc": "Ático-Dúplex en Milla de Oro - Nagüeles\nDescubre este impresionante ático-dúplex con vistas al mar, ubicado en una de las zonas más emblemáticas de Marbella, la Milla de Oro. Este exclusivo inmueble, se encuentra en una comunidad que redefine el concepto de confort y seguridad. Con una superficie construida de 167 m² y dos espectaculares terrazas (una cubierta y otra abierta), este ático ofrece unas vistas al mar que te dejarán sin palabras, convirtiéndose en el lugar ideal para disfrutar de la tranquilidad y el lujo en pleno corazón de la Cost" + }, + { + "id": "107718547", + "url": "https://www.idealista.com/inmueble/107718547/", + "title": "Casa o chalet independiente en Guadalmina Alta, Marbella", + "type": "Casa o chalet independiente", + "price": 1900000, + "old_price": null, + "beds": "4 hab.", + "m2": 397, + "eur_per_m2": 4786, + "floor": null, + "parking": false, + "agent": "PRO Real Estate Guadalmina", + "desc": "Descubre esta magnífica villa prácticamente a estrenar, situada en la prestigiosa urbanización Guadalmina Alta, en primera línea del campo de golf y con orientación sur-este, asegurando sol durante todo el día en el jardín y las zonas exteriores. Ubicada sobre una parcela de 555 m2, la propiedad cuenta con una superficie construida de aproximadamente 400 m2, distribuida en tres plantas, con acabados de alta gama y en un estado impecable.\nDistribución: Planta baja: elegante recibidor, amplio salón-comedor con acceso directo a una gran terraza co" + }, + { + "id": "109147795", + "url": "https://www.idealista.com/inmueble/109147795/", + "title": "Casa o chalet independiente en Los Naranjos, Marbella", + "type": "Casa o chalet independiente", + "price": 4500000, + "old_price": 4, + "beds": "5 hab.", + "m2": 734, + "eur_per_m2": 6131, + "floor": null, + "parking": true, + "agent": "Mitchells Prestige Properties", + "desc": "Esta impresionante villa contemporánea, situada en la prestigiosa zona de Nueva Andalucía en Marbella, Málaga, ofrece una experiencia residencial única donde el lujo y la comodidad se fusionan con unas vistas espectaculares al mar y al golf. La propiedad ha sido diseñada para quienes buscan un hogar elegante y moderno, con espacios pensados para el relax y el entretenimiento, rodeados de un entorno natural privilegiado.\nDistribuida en cuatro plantas, la villa cuenta con un total de 734 m² construidos sobre una parcela de 993 m². El interior, d" + }, + { + "id": "110300940", + "url": "https://www.idealista.com/inmueble/110300940/", + "title": "Casa o chalet independiente en Nueva Andalucía, Las Brisas, Marbella", + "type": "Casa o chalet independiente", + "price": 19500000, + "old_price": null, + "beds": "7 hab.", + "m2": 1636, + "eur_per_m2": 11919, + "floor": null, + "parking": true, + "agent": "Velian Group", + "desc": "Bienvenidos a Villa Solstice, una impresionante villa en primera línea de golf con una ubicación inmejorable en el corazón de Nueva Andalucía. Con vistas al prestigioso club de golf de acceso exclusivo Las Brisas, esta propiedad orientada al oeste ofrece atardeceres espectaculares que dieron nombre a la villa. El jardín, cuidadosamente diseñado, proporciona un escenario magnífico para la piscina privada, ideal para disfrutarla durante todo el año. Un área relajante hundida junto a la piscina añade un espacio extra de entretenimiento, mientras q" + }, + { + "id": "111243819", + "url": "https://www.idealista.com/inmueble/111243819/", + "title": "Casa o chalet independiente en Nueva Andalucía, Puerto Banús, Marbella", + "type": "Casa o chalet independiente", + "price": 4995000, + "old_price": null, + "beds": "5 hab.", + "m2": 345, + "eur_per_m2": 14478, + "floor": null, + "parking": true, + "agent": "Velian Group", + "desc": "Situado en el corazón del Valle del Golf, Nueva Andalucía, famosa por sus destinos de golf de clase mundial, esta villa ofrece una excelente posición en primera línea en uno de los mejores verdes de la zona. Situado en una exclusiva comunidad cerrada, la villa ofrece un retiro sereno y privado, rodeado de una pared de privacidad y exuberante vegetación. Es rústico y sofisticado, espacioso y acogedor, cálido y aireado gracias a sus inmensas ventanas y techos altos. Combina cuidadosamente un encanto andaluz con estilo contemporáneo, y materiales" + }, + { + "id": "111330463", + "url": "https://www.idealista.com/inmueble/111330463/", + "title": "Casa o chalet independiente en Avenida Golden Mile, Sierra Blanca, Marbella", + "type": "Casa o chalet independiente", + "price": 13800000, + "old_price": null, + "beds": "5 hab.", + "m2": 1265, + "eur_per_m2": 10909, + "floor": null, + "parking": true, + "agent": "Velian Group", + "desc": "Villa mediterránea en Altos Reales – privacidad, volumen y vistas en Marbella.\nUbicada en la exclusiva comunidad de Altos Reales, esta villa mediterránea representa una de esas propiedades que combinan presencia, privacidad y entorno en equilibrio.\nCon 1.265 m² construidos sobre una parcela de 3.845 m², la vivienda se rodea de un jardín consolidado y meticulosamente cuidado, creando un auténtico refugio natural con vistas abiertas al mar y orientación suroeste, perfecta para disfrutar de luz y atardeceres.\nEl exterior ha sido concebido para" + }, + { + "id": "110946516", + "url": "https://www.idealista.com/inmueble/110946516/", + "title": "Chalet adosado en Av Grecia (de), 1, Nagüeles, Marbella", + "type": "Chalet adosado", + "price": 700000, + "old_price": null, + "beds": "3 hab.", + "m2": 235, + "eur_per_m2": 2979, + "floor": null, + "parking": true, + "agent": "CASITASHOME", + "desc": "Casa adosada en Nagüeles (Marbella) con jardín privado, garaje y solárium\nOportunidad en una urbanización residencial tranquila y bien comunicada, situada a pocos minutos en coche del centro de Marbella, Puerto Banús y de las playas más cotizadas. El complejo, de ambiente familiar y con piscina comunitaria y zonas ajardinadas, queda muy próximo a campos de golf, colegios, restaurantes y servicios, con rápido acceso a la A-7.\nDistribución y superficies\n- Superficie construida total: 258 m² (incluido sótano).\n- Parcela aproximada: 185 m², con j" + }, + { + "id": "111279683", + "url": "https://www.idealista.com/inmueble/111279683/", + "title": "Chalet pareado en Calle 19, Guadalmina Alta, Marbella", + "type": "Chalet pareado", + "price": 1295000, + "old_price": null, + "beds": "4 hab.", + "m2": 186, + "eur_per_m2": 6962, + "floor": null, + "parking": true, + "agent": "Gilmar Puerto Banús", + "desc": "Chalet pareado de 4 dormitorios completamente reformado, donde el diseño rústico moderno se integra con acabados de alta calidad para crear una vivienda cálida, elegante y funcional. Ubicado en Guadalmina, uno de los enclaves residenciales más consolidados y demandados de la Costa del Sol, este hogar ofrece una combinación difícil de igualar entre ubicación, diseño y potencial de inversión.\nCada detalle ha sido cuidadosamente seleccionado para ofrecer una experiencia residencial superior. La vivienda incorpora un eficiente sistema de aerotermi" + }, + { + "id": "111452225", + "url": "https://www.idealista.com/inmueble/111452225/", + "title": "Casa o chalet independiente en Avenida Cantabrica, 35, Marbesa, Marbella", + "type": "Casa o chalet independiente", + "price": 4995000, + "old_price": null, + "beds": "6 hab.", + "m2": 539, + "eur_per_m2": 9267, + "floor": null, + "parking": true, + "agent": "Aventus NT SL", + "desc": "Esta villa contemporánea de seis dormitorios, recién construida, se encuentra perfectamente ubicada a poca distancia a pie de la playa y de restaurantes locales, y a solo un corto trayecto en coche del vibrante centro de Marbella.\nSu impactante diseño arquitectónico combina piedra natural, acabados efecto madera, suelo radiante en toda la vivienda y exuberantes jardines, integrándose armoniosamente con el entorno costero.\nEl nivel del jardín, orientado al sur, ofrece amplias terrazas ideales para comer al aire libre y relajarse en elegantes" + }, + { + "id": "108879461", + "url": "https://www.idealista.com/inmueble/108879461/", + "title": "Ático en Avenida de Julio Iglesias, Puerto Banús, Marbella", + "type": "Ático", + "price": 2300000, + "old_price": null, + "beds": "3 hab.", + "m2": 276, + "eur_per_m2": 8333, + "floor": "Planta 8ª exterior sin ascensor", + "parking": false, + "agent": "Savills Andalucía", + "desc": "Descubre este espectacular ático de tres dormitorios ubicado en el corazón de Puerto Banús, a solo unos pasos del mar, tiendas de lujo y una vibrante oferta gastronómica. Su privilegiada ubicación permite disfrutar al máximo del estilo de vida mediterráneo en uno de los enclaves más codiciados de la Costa del Sol.\nLa propiedad ofrece una elegante sala de estar diseñada con atención al confort y la sofisticación. Su amplia terraza privada de 179 m² destaca como uno de los espacios más exclusivos de la vivienda, equipada con jacuzzi, zona chill" + }, + { + "id": "110027309", + "url": "https://www.idealista.com/inmueble/110027309/", + "title": "Piso en Avenida de Julio Iglesias, Puerto Banús, Marbella", + "type": "Piso", + "price": 545000, + "old_price": null, + "beds": "2 hab.", + "m2": 137, + "eur_per_m2": 3978, + "floor": "con ascensor", + "parking": false, + "agent": "Fine & Country Marbella Costa del Sol", + "desc": "Apartamento Planta Media, Puerto Banús, Costa del Sol.\n2 Dormitorios, 3 Baños, Construidos 137 m².\nPosición: Primera línea de Playa, Lado de la Playa, Puerto, Cerca de Puerto, Cerca de Tiendas, Cerca del Mar, Cerca de Ciudad, Cerca de Colegios, Marina, Cerca de Marina, Complejo 1ª Línea de Playa.\nOrientación: Sur.\nEstado: Excelente.\nClimatización: Aire Acondicionado.\nVistas: Mar, Puerto.\nCaracteristicas: Terraza Cubierta, Ascensor, Armarios Empotrados, Cerca de Transporte, Terraza Privada, TV Satélite, WiFi, Doble acristalamiento.\nMuebles: Amu" + }, + { + "id": "106392536", + "url": "https://www.idealista.com/inmueble/106392536/", + "title": "Piso en Nueva Andalucía, Marbella", + "type": "Piso", + "price": 589000, + "old_price": null, + "beds": "2 hab.", + "m2": 129, + "eur_per_m2": 4566, + "floor": "Planta 1ª exterior con ascensor", + "parking": false, + "agent": "Diana Morales Properties | Knight Frank", + "desc": "Apartamento en primera planta dentro de la comunidad cerrada de La Medina de Banús. El complejo cuenta con seguridad 24 horas y tiene una excelente ubicación para aquellos que quieran estar a poca distancia de tiendas y servicios. A unos 500m se encuentra el centro comercial Centro Plaza, donde hay entre otras tiendas, una gran variedad de restaurantes, supermercado y servicios bancarios. A otros 500m se encuentra el puerto deportivo de Puerto Banús con sus playas, boutiques de moda, gran almacén y otros servicios y opciones de ocio. La comunid" + }, + { + "id": "108497235", + "url": "https://www.idealista.com/inmueble/108497235/", + "title": "Casa o chalet independiente en Nagüeles, Marbella", + "type": "Casa o chalet independiente", + "price": 6850000, + "old_price": null, + "beds": "6 hab.", + "m2": 829, + "eur_per_m2": 8263, + "floor": null, + "parking": true, + "agent": "Savills Andalucía", + "desc": "Esta espectacular villa contemporánea se encuentra en una exclusiva comunidad cerrada en la Milla de Oro de Marbella, ofreciendo una combinación única de diseño arquitectónico, lujo y privacidad. Cada detalle ha sido cuidadosamente pensado para ofrecer una experiencia de vida extraordinaria, con vistas panorámicas al mar y a la imponente montaña de La Concha.\nLa propiedad recibe con una elegante entrada y un amplio hall que se abre a un salón de doble altura bañado por luz natural. Grandes ventanales conectan con una terraza cubierta, integran" + }, + { + "id": "107379938", + "url": "https://www.idealista.com/inmueble/107379938/", + "title": "Piso en Avenida Avenue Arias Maldonado, Playa de la Fontanilla, Marbella", + "type": "Piso", + "price": 995000, + "old_price": null, + "beds": "3 hab.", + "m2": 180, + "eur_per_m2": 5528, + "floor": "Planta 2ª exterior con ascensor", + "parking": true, + "agent": "Airus Homes", + "desc": "GRAN PROPIEDAD A 200 METROS DE LA PLAYA EN EL CENTRO DE MARBELLA\nFantástica propiedad de gran tamaño, con una ubicación privilegiada en el centro de Marbella a 200 metros del paseo marítimo y la playa, rodeado de todo tipo de servicios (restaurantes, supermercados, bancos, farmacias... etc) y a sólo unos minutos caminando del Casco Antiguo.\nAmplia propiedad reformada compuesta por un total de tres dormitorios, tres cuartos de baño, gran salón con abierta y gran terraza de más de 70m2.\nEstablecido dentro de una emblemático edificio con jardines" + } +] \ No newline at end of file diff --git a/scripts/lib-bridge-safe-cleanup.sh b/scripts/lib-bridge-safe-cleanup.sh new file mode 100644 index 0000000..85cbde6 --- /dev/null +++ b/scripts/lib-bridge-safe-cleanup.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Shared helper: safely collapse leaked MemOS bridge processes. +# +# WHY THIS EXISTS: the old band-aids ran `pkill -f 'bridge\.cts'`, which (1) kills +# the :18800 viewer/daemon bridge too — taking down the memory UI and capture for +# every agent — and (2) matches ANY process whose command line merely contains the +# string "bridge.cts" (e.g. an interactive admin shell that's grepping for it), +# SIGTERMing it. Both bit us during the 2026-05-26 capture-outage recovery. +# +# This helper instead: +# - only targets real bridge *node* processes (comm = node/tsx), never shells; +# - never kills the process holding TCP :18800 (the daemon) or its parent wrapper; +# - kills the rest by explicit PID. +# +# The proper fix for the leak itself is the adapter's serialized cold-boot + +# raised session.open timeout (see memos_provider/__init__.py and the +# 2026-05-26 decision doc); this helper is only a safety net. + +safe_bridge_cleanup() { + local keep_pid keep_ppid pid comm + keep_pid="$(ss -ltnp 2>/dev/null | grep ':18800' | grep -oP 'pid=\K[0-9]+' | head -1)" + keep_ppid="$(ps -o ppid= -p "${keep_pid:-0}" 2>/dev/null | tr -d ' ')" + for pid in $(pgrep -f 'bridge\.cts --agent' 2>/dev/null); do + # only real node bridge procs, never shells that merely mention the string + comm="$(ps -o comm= -p "$pid" 2>/dev/null)" + case "$comm" in *node*|*tsx*) ;; *) continue ;; esac + [ "$pid" = "$keep_pid" ] && continue + [ "$pid" = "$keep_ppid" ] && continue + kill "$pid" 2>/dev/null || true + done +} diff --git a/scripts/tower-memos-bridge-guard.sh b/scripts/tower-memos-bridge-guard.sh new file mode 100755 index 0000000..a938817 --- /dev/null +++ b/scripts/tower-memos-bridge-guard.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Tower: if MemOS bridge processes pile up, kill extras and restart personal gateways. +# Run on sergio (cron optional): bash scripts/tower-memos-bridge-guard.sh +set -euo pipefail + +# Default raised to 16: normal steady state is ~1 bridge pair per active gateway, +# so 8 was below baseline and made the guard nuke healthy bridges. The real leak +# fix is the adapter's serialized cold-boot (see the 2026-05-26 decision doc); this +# guard is only a safety net. +MAX_BRIDGE="${TOWER_MEMOS_BRIDGE_MAX:-16}" +# SAFE cleanup only — never blanket `pkill -f 'bridge\.cts'` (it kills the :18800 +# daemon and matches admin shells). See scripts/lib-bridge-safe-cleanup.sh. +source "$(dirname "${BASH_SOURCE[0]}")/lib-bridge-safe-cleanup.sh" +count="$(pgrep -c -f 'bridge\.cts --agent' 2>/dev/null || echo 0)" + +if [[ "${count}" -le "${MAX_BRIDGE}" ]]; then + echo "memos bridge ok: ${count} process(es)" + exit 0 +fi + +echo "memos bridge high: ${count} — cleaning (max ${MAX_BRIDGE}; preserving :18800 daemon)" +safe_bridge_cleanup +sleep 2 + +# Restart STAGGERED so bridges don't cold-boot (BGE-large load) concurrently and +# starve the CPU into the respawn death-spiral this guard exists to contain. +for svc in hermes-gateway hermes-gateway-arinze hermes-gateway-krati hermes-gateway-sergio; do + systemctl --user restart "${svc}.service" 2>/dev/null || true + sleep 25 +done + +sleep 3 +new_count="$(pgrep -c -f 'bridge\.cts --agent' 2>/dev/null || echo 0)" +echo "memos bridge after guard: ${new_count} process(es)" +systemctl --user is-active hermes-gateway.service hermes-gateway-arinze.service 2>/dev/null || true diff --git a/scripts/tower-personal-channel-assistant.sh b/scripts/tower-personal-channel-assistant.sh new file mode 100755 index 0000000..20c0186 --- /dev/null +++ b/scripts/tower-personal-channel-assistant.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Tower: personal agents read recent channel context on @mention (todo, tasks with teammates). +# - Hermes history_backfill (default on); auto_thread off so backfill sees parent channel, not empty thread. +# - SOUL: use [Recent channel messages]; MemOS is not this channel transcript. +# Run on sergio: bash scripts/tower-personal-channel-assistant.sh +set -euo pipefail + +SPIRE_GENERAL=1503342972950024244 +PROFILES=(mohammed arinze krati) +MARKER="## Tower personal assistant (channel @)" +SOUL_BLOCK=$'Tower personal assistant (channel @):\n- When a teammate @mentions this bot in a server channel, Hermes may attach a [Recent channel messages] block (messages since this bot last replied here, up to history_backfill_limit). Use it for personal-assistant work: todos, action items, summaries of what someone asked the user to do.\n- The human must @ THIS bot by name — being @mentioned by someone else does not wake the bot.\n- Prefer [Recent channel messages] and the user\'s message over MemOS for "what did they ask in this channel?" Do not treat MemOS or another Tower bot\'s DM as this channel unless quoted here.\n- If context is thin, ask for a one-line recap or suggest reply+@ with the task text. Do not claim you cannot see the channel if [Recent channel messages] was provided.' + +patch_discord_config() { + local profile="$1" + local cfg="/home/openclaw/.hermes/profiles/${profile}/config.yaml" + [[ -f "$cfg" ]] || return 0 + cp "$cfg" "${cfg}.bak.tower-personal-channel-assistant" + python3 < None: + global text + pat = rf"^ {re.escape(key)}:.*$" + line = f" {key}: {value}" + if re.search(pat, text, re.M): + text = re.sub(pat, line, text, count=1) + else: + text = re.sub(r"(^discord:\n)", rf"\1{line}\n", text, count=1) + +set_discord_key("history_backfill", "true") +set_discord_key("history_backfill_limit", "80") + +# Keep hub in no_thread_channels if block exists +if "no_thread_channels:" in text and "${SPIRE_GENERAL}" not in text: + text = re.sub( + r"( no_thread_channels:\n)", + rf"\1 - '${SPIRE_GENERAL}'\n", + text, + count=1, + ) +elif "no_thread_channels:" not in text and " auto_thread:" in text: + text = re.sub( + r"( auto_thread: false\n)", + rf"\1 no_thread_channels:\n - '${SPIRE_GENERAL}'\n", + text, + count=1, + ) + +cfg.write_text(text) +print(f"patched discord: {cfg}") +PY +} + +append_soul() { + local soul="$1" + [[ -f "$soul" ]] || return 0 + if grep -qF "$MARKER" "$soul"; then + echo "SOUL already has marker: $soul" + return 0 + fi + cp "$soul" "${soul}.bak.tower-personal-channel-assistant" + { + echo "" + echo "$MARKER" + echo "" + printf '%s\n' "$SOUL_BLOCK" + } >>"$soul" + echo "appended SOUL: $soul" +} + +for p in "${PROFILES[@]}"; do + patch_discord_config "$p" + append_soul "/home/openclaw/.hermes/profiles/${p}/SOUL.md" +done + +# MemOS bridge: collapse runaway processes before restart. +# SAFE cleanup only — never blanket `pkill -f 'bridge\.cts'` (kills the :18800 +# daemon + matches admin shells). See scripts/lib-bridge-safe-cleanup.sh. +source "$(dirname "${BASH_SOURCE[0]}")/lib-bridge-safe-cleanup.sh" +bridge_count="$(pgrep -c -f 'bridge\.cts --agent' 2>/dev/null || echo 0)" +if [[ "${bridge_count}" -gt 8 ]]; then + echo "memos bridge processes=${bridge_count} — cleaning (preserving :18800 daemon)" + safe_bridge_cleanup + sleep 2 +fi + +systemctl --user restart hermes-gateway.service +systemctl --user restart hermes-gateway-arinze.service +systemctl --user restart hermes-gateway-krati.service 2>/dev/null || true + +echo "--- discord (mohammed) ---" +sed -n '/^discord:/,/^whatsapp:/p' /home/openclaw/.hermes/profiles/mohammed/config.yaml | head -20 +echo "--- gateways ---" +systemctl --user is-active hermes-gateway.service hermes-gateway-arinze.service hermes-gateway-krati.service 2>/dev/null || true +echo "--- bridge count ---" +pgrep -c -f 'bridge\.cts' 2>/dev/null || echo 0 +echo "done: tower-personal-channel-assistant" diff --git a/scripts/worktrees/INITIATION-PROMPT.md b/scripts/worktrees/INITIATION-PROMPT.md new file mode 100644 index 0000000..577b9ec --- /dev/null +++ b/scripts/worktrees/INITIATION-PROMPT.md @@ -0,0 +1,54 @@ +# Initiation Prompt + +Paste this into every new Claude Code Desktop session that's opened in a worktree directory. + +It works verbatim for every worktree — the `TASK.md` in the directory provides the per-task spec. + +--- + +``` +Before starting, run `git status`. Confirm you are on the worktree's expected branch +(e.g. fix/auth-perf, feat/memos-provisioning, etc.) — NOT a claude/* scratch branch. + +If git shows you on a claude/* branch, run this first: + git branch --show-current # note the current branch + git checkout # e.g. git checkout feat/memos-provisioning + +The expected branch matches the worktree directory name (e.g. the worktree +~/Coding/MemOS-wt/fix-auth-perf is on branch fix/auth-perf). Do NOT create +or commit to any claude/* scratch branch — all work must live on the worktree's +intended branch so the PR targets the right remote. + +Then read TASK.md in this directory — that is your full brief. + +Execute the work it specifies, with these rules: + +1. Commit as you go in logical chunks with descriptive messages. +2. When all acceptance criteria pass, push the branch: + git push -u origin +3. Open a PR: + gh pr create --title "" --body "" +4. Do NOT merge — leave that to me on GitHub. +5. If you get stuck or hit an ambiguity not covered by TASK.md, stop and ask + before making a judgment call. + +Before you start real work, confirm in your reply: +- Which branch you're on (after the checkout if needed) +- The acceptance criteria from TASK.md +- Your plan to satisfy them + +Then proceed. +``` + +--- + +## Why each rule exists + +- **Branch discipline** — Claude Code Desktop auto-creates `claude/*` scratch branches per session. Left unchecked, commits land off the worktree's intended branch and the PR targets the wrong base. +- **Commit-as-you-go** — if the session runs out of context or you need to reattach later, progress is preserved. +- **No merges from the session** — humans review PRs. Merge authority stays with you. +- **Confirm plan first** — catches misreads of TASK.md before code is written. + +## When to update this prompt + +If multiple sessions trip over the same thing (e.g. all forget to push before opening a PR), add a rule here and reuse the updated prompt for future sessions. diff --git a/skills/web-research/SKILL.md b/skills/web-research/SKILL.md index e6c2629..d5656e5 100644 --- a/skills/web-research/SKILL.md +++ b/skills/web-research/SKILL.md @@ -19,6 +19,8 @@ Different domains require different strategies. Check this table first — using | `arxiv.org/abs/*` | `web_extract()` or `curl` API | Both work. For bulk paper lookup prefer the arXiv REST API | | `news.ycombinator.com` | `web_extract()` | Plain HTML, works reliably | | `youtube.com` | Use `youtube-content` skill | Firecrawl can't get transcripts — use the dedicated skill | +| `idealista.*`, `ticketmaster.*`, `glassdoor.*`, sites returning `server: DataDome` | **Cloak service at `localhost:9378`** — see §4 | DataDome/anti-bot. Firecrawl will silently 403. | +| Anything behind Cloudflare Turnstile / Imperva | **Cloak service at `localhost:9378`** — see §4 | Stock Playwright (Firecrawl) fingerprint fails the challenge. | | Everything else | `web_extract()` standard | Default approach | ### Reddit URL rewrite rule @@ -124,7 +126,46 @@ When the above fail and the page requires JavaScript to render content. **Best for:** - Dashboards with live data - Pages that load on scroll -- Sites with anti-bot protection that need full browser +- Pages that need JS but are NOT behind aggressive anti-bot (DataDome, Cloudflare turnstile, Imperva) + +## 4. Cloak Stealth Service (Anti-Bot Protected Pages) + +**Use when Firecrawl returns 403 or empty content, or the domain is on the anti-bot list below.** + +Hermes runs a CloakBrowser-backed stealth service at `localhost:9378`. It uses Chromium with C++-level fingerprint patches, persistent per-domain cookie storage, request pacing, and an optional CapSolver fallback for DataDome captchas. + +```bash +curl -X POST http://localhost:9378/v1/scrape \ + -H "Content-Type: application/json" \ + -d '{"url":"","formats":["html","markdown"]}' +``` + +**Anti-bot domains — route here, not Firecrawl:** +- `idealista.com` / `idealista.*` (DataDome) +- `ticketmaster.*` (Imperva / DataDome) +- `glassdoor.com` (DataDome) +- `linkedin.com` (Cloudflare + custom) +- Anything that returns `server: DataDome`, `server: cloudflare` + Turnstile, or `Set-Cookie: datadome=...` + +Response shape mirrors Firecrawl: +```json +{ "success": true, + "data": { "html": "...", "markdown": "...", + "metadata": { "statusCode": 200, "engine": "cloak-stealth-chromium" } }, + "challenge": false } +``` + +If `"challenge": true` and `success: false`, the IP has been pushed to DataDome CAPTCHA mode. Options: wait 30–60 min for cooldown, or ensure CapSolver is enabled (see `~/.config/systemd/user/cloak-service.service.d/capsolver.conf`). + +PDF render of a single page: +```bash +curl -X POST http://localhost:9378/v1/save-pdf \ + -H "Content-Type: application/json" \ + -d '{"url":""}' +# Returns {"success": true, "path": "/tmp/cloak-pdf-.pdf", "size_bytes": ...} +``` + +**Camofox status (2026-05-16, post Phase 3):** Cloak service serves both stealth scraping AND the agent's interactive `browser` skill (via `CAMOFOX_URL` pointing at port 9378). The Camofox systemd unit is **stopped and disabled** — no longer running. The `tools/browser_camofox.py` filename is retained (renaming would touch too many `browser_tool.py` call sites) but its module docstring is now a deprecation notice; HTTP calls land on Cloak. See `memos-setup/learnings/2026-05-16-cloak-deprecate-camofox.md` for the full cutover history and rollback recipe. ## Decision Tree diff --git a/tests/v2/integ-work/REPORT.md b/tests/v2/integ-work/REPORT.md new file mode 100644 index 0000000..a0983c3 --- /dev/null +++ b/tests/v2/integ-work/REPORT.md @@ -0,0 +1,161 @@ +# Hermes v2 Data Integrity Audit + +Marker: `INTEG-AUDIT-1776791142` +Date: 2026-04-21 +Plugin: `@memtensor/memos-local-hermes-plugin@1.0.3` +DB: `~/.hermes/memos-state-research-agent/memos-local/memos.db` (WAL, synchronous=FULL) +Hub: `http://localhost:18992` — **not running during audit** (stale `hub.pid` 1624019, no listener); hub HTTP round-trip therefore tested statically from source + schema rather than live. + +All probes created their own rows tagged `owner='agent:integ-audit'` and were cleaned up after. + +--- + +## Recon + +### Schema (key tables) +- `chunks` (PK id TEXT, FK `task_id`→tasks, `content_hash`, `owner`, `dedup_status` ∈ {active, superseded}, `dedup_target`, `merge_history` JSON, `merge_count`, `last_hit_at`) +- `embeddings` (PK `chunk_id` → chunks ON DELETE CASCADE) +- `chunks_fts` (FTS5 tokenize=**trigram**, synced via AI/AU/AD triggers) +- `tasks`, `task_embeddings`, `tasks_fts` +- `skills`, `skill_versions` (FK → skills; no ON DELETE), `skill_embeddings` (CASCADE), `task_skills` (composite PK, no cascade), `skills_fts` +- No version/etag/revision column on any row → no optimistic concurrency control + +### Write paths +- DB opens via `better-sqlite3` with `PRAGMA foreign_keys = ON` (sqlite.ts:15). A transient migration block temporarily sets it OFF then back ON (sqlite.ts:1506–1537) — OK if completes, dangerous if process dies mid-migration. +- **Hub ↔ local is NOT transactional.** Hub writes go over HTTP (`/api/v1/hub/memories/share`, `/publish`, `/unshare`). The client persists locally first, then optionally calls hub. No outbox / retry queue was found. If hub is down at share time → silent skip (the plugin's hub calls just throw; caller must catch). +- Skill file writes (`generator.ts:205,218,226,242,253`): plain `fs.writeFileSync(...)`. **No fsync, no tmp→rename atomic swap.** Crash mid-write leaves partial file. `installer.cleanSync()` does `rmSync(dstDir, {recursive})` **then** `mkdirSync` + `cpSync` — crash between rm and cp = installed skill totally erased from workspace with no rollback. +- Hub index is a separate set of tables (`hub_memories`, `hub_skills`, etc.) in the same memos.db, populated via HTTP handlers in `hub/server.ts`. `embedMemoryAsync` is fire-and-forget — if embedder fails, hub row exists without vector. + +### Timestamp source +- `capture/index.ts:66,108` — **pure client `Date.now()`**. For user messages the code optionally uses `userSearchTime` (still client-derived), then monotonicity-coerces (`if (ts <= lastTimestamp) ts = lastTimestamp + 1`) — only within one batch, not across batches/clients. +- No NTP check, no hub-assigned timestamps, no skew detection. + +--- + +## Probes and findings + +### 1. Local DB ↔ hub consistency +| Facet | Finding | +|---|---| +| Share flow | Explicit push model (/share). Client-local capture is independent of hub state. | +| Transactionality | **None**. Local insert commits before hub HTTP call; hub call can fail independently. No outbox, no retry. 100 rapid writes with hub dead = 100 local rows, 0 hub rows, no reconciliation on revival. | +| Field mismatch | Hub `upsertHubMemory` copies `content/summary/role/kind` verbatim but discards local `content_hash`, `owner`, `dedup_status`, `merge_history`, `last_hit_at`, `task_id`, `turn_id`, `seq`. These are **local-only** and hub cannot reproduce them → search results from hub cannot participate in local dedup/task-lineage. | +| Embedding | Hub re-embeds via `embedMemoryAsync` (separate vector from local embedding). If embedder versions differ across clients, hub and local will return different neighborhoods. | +| Timestamps | Hub stamps its own `createdAt = Date.now()` on insert if no existing row (server.ts:668), then `updatedAt = now` on every re-share → hub's `createdAt` ≠ local chunk `created_at`. | + +**Integrity class:** consistency + fidelity. **Score: 4/10** — no cross-layer transaction, silent field loss, independent embeddings, timestamp divergence. + +### 2. Skill file ↔ index consistency +| Facet | Finding | +|---|---| +| Atomicity | `writeFileSync` w/o tmp-rename; `cleanSync` does `rm -rf` before `cp`. Power loss / SIGKILL mid-install can wipe an installed skill and partially write the replacement. | +| fsync | Never called. Data in pagecache may be lost on hard crash even if the syscall returned. | +| Drift detection | None. `installer.ts` never hashes on-disk `SKILL.md` and compares to `skill_versions.content`. Manual edits persist silently until next `syncIfInstalled()` blindly overwrites them. Edit is NOT detected and NOT re-embedded. | +| Deleted on-disk, row present | `install()` returns `{installed:false, message:"Skill directory not found"}` (installer.ts:116–118). The DB row remains; `skill_embeddings` remains; FTS on `skills` still matches. Search returns a hit whose content can no longer be sourced from disk. Dangling reference. | +| Hub vs file | `hub/skills/publish` stores bundle as JSON blob in `hub_skills.bundle` — independent of `skills.dir_path`. No drift check between hub bundle and on-disk files. | + +**Integrity class:** durability + consistency. **Score: 3/10** — non-atomic filesystem ops, zero drift detection. + +### 3. Task summary ↔ underlying chunks +| Facet | Finding | +|---|---| +| FK from chunk → task | Yes: `chunks.task_id REFERENCES tasks(id)` (no `ON DELETE`), so deleting a task without `task_id=NULL` update leaves **orphan chunks with dangling task_id** (FK ON would block the delete → so safe if FK on; but FK OFF window in migrations leaves loophole). | +| Summary fidelity | LLM-generated via `Summarizer.summarizeTask` over `buildConversationText(chunks)`. Nothing in `task-processor.ts` cross-checks that every factual claim in the summary appears in a chunk. Hallucination is **not detected by the plugin**; there are no reference-style citations back to chunk IDs in the summary body. | +| Skip-short-task logic | `shouldSkipSummary` drops conversations < 4 chunks or <200 chars (80 CJK) — these tasks end with `summary=reason, status="skipped"`. **Data still exists in chunks**, just no summary row populated. Acceptable, but search over `tasks` misses them. | +| Reverse link | No reverse index: given a claim in a summary, there's no mapping to originating chunk(s). | + +**Integrity class:** fidelity. **Score: 5/10** — FK structure is sound; summary-to-source traceability is absent. + +### 4. Dedup merge correctness +| Facet | Finding | +|---|---| +| Exact dup | `content_hash` (sha256) — catches byte-identical duplicates. | +| Near-dup | `findDuplicate(newVec, threshold=0.92–0.95)` via O(n) cosine scan over all embeddings of matching owners (`dedup.ts:12`). | +| Merge semantics | Schema provides `dedup_status`, `dedup_target`, `dedup_reason`, `merge_count`, `merge_history` (JSON array). Source of truth for merged behavior is wherever `findDuplicate` is called; when a near-dup is found, the newer row's unique facts (e.g. "Alice is 30" vs the existing "Alice is 25") are **not structurally reconciled** — merge decision is up to caller. The schema can preserve both via `merge_history` but there is no code path that diff-extracts conflicting claims into a safe delta. | +| "Alice is 25" vs "Alice is 30" | Two separate sha256 hashes (confirmed: `d443c6…` vs `2ba61f…`); if cosine ≥ threshold they will be flagged as dup and one will be superseded — the conflicting age becomes recoverable only if `merge_history` captured original content. | +| Near-dup across owners | `getAllEmbeddings(ownerFilter)` restricts by owner, so cross-owner dedup is opt-in. | + +**Integrity class:** fidelity. **Score: 5/10** — plumbing exists, reconciliation logic is thin; "keep newer and lose facts" is possible. + +### 5. Embedding drift +- Embedding dimensionality is stored per-row in `embeddings.dimensions` — good, a model change that produces different dims won't silently corrupt cosine math (dim mismatch would throw at `cosineSimilarity`). +- But there is **no embedder-version column**. Two embeddings of the same text from different model versions are indistinguishable in schema. A forced re-embed run on a subset produces a **bimodal index** — recall against queries embedded with the new model gives lower scores for the old-model subset. No migration path is codified. +- Corrupted vector (tested: wrote `Buffer.from("garbage")`, dims=999): DB accepts unconditionally. Downstream cosine will either NaN out or mis-score. No checksum/length guard. + +**Integrity class:** consistency. **Score: 4/10** — no version tag, no bulk re-embed protocol, blob accepted without validation. + +### 6. Soft-delete propagation +- Marking `dedup_status='superseded'` does **not** propagate to: + - `chunks_fts` — FTS row stays (triggers don't filter). Queries must explicitly `AND dedup_status='active'` or bad hits return. Probed: soft-deleted row still matches FTS. + - `embeddings` — row remains; still scanned by `getAllEmbeddings` in dedup. +- No orphan after true delete (FK CASCADE drops embedding). But true DELETE is rare; the code prefers soft-delete. +- No dangling FK observed when FK ON. + +**Integrity class:** isolation. **Score: 4/10** — soft-deleted rows leak into search and dedup scans unless every caller remembers to filter. + +### 7. Clock skew handling +- Timestamps are `Date.now()` from whichever client wrote. Tested: rows with `created_at` in the year 2027 (+1 year) and with `created_at = -1` are accepted without validation. +- Ordering (`idx_chunks_session_created`) will place future rows ahead of present rows; recency-decay (`recency.ts`) will score them higher. +- No server-side clamp, no skew detection. + +**Integrity class:** ordering. **Score: 3/10** — a misconfigured client clock poisons recency ranking indefinitely. + +### 8. Content-fidelity round-trip (chunk insert → SELECT) +All 14 payload classes (decimals, `9007199254740993`, emoji 🔥, Chinese, Arabic RTL, complex URL, triple-backticks, escaped-JSON, markdown pipes, **null bytes `\x00`**, control chars CR/LF/TAB/BELL, 10 000-char line, mixed newlines, no-trailing-nl) survived byte-for-byte through `INSERT → SELECT`. Lengths and byte counts identical. + +Caveats (retrieval via FTS, not raw SELECT): +- `chunks_fts` uses **tokenize=trigram** — queries under 3 consecutive non-space chars never match. Confirmed: `'🔥'` / `'永和'` / `'X'` return 0; `'🔥🔥🔥'` / `'永和九'` / `'XXX'` return 1. Short emoji or 2-char CJK queries silently miss. +- Null-byte content is searchable ("before after" → 1 hit). + +**Integrity class:** fidelity. **Score: 9/10** — storage is byte-exact; trigram tokenizer is a legitimate but undocumented retrieval limitation. + +### 9. Orphan / FK integrity +- With `foreign_keys=ON` (plugin default): deleting a chunk correctly cascades embedding rows; orphan embedding INSERT is blocked. +- With FK OFF (default for any external connection, e.g. a forgotten CLI/migration): orphans insert freely. +- `chunks_fts` is a content-backed FTS5 table; its data blocks live independently (`chunks_fts_data`). If the `chunks` table is rebuilt or the triggers disabled during a manual admin op, FTS drifts. No rebuild command is exposed. +- Corrupt `embeddings.vector` blob: DB accepts (18-byte "garbage" stored with dims=999). Plugin has no length-equals-`dimensions*4` invariant. + +**Integrity class:** isolation + fidelity. **Score: 6/10** — plugin's own connection is safe; any external connection is a foot-gun. + +### 10. Concurrent edit semantics +Two in-process connections both updating the same `chunks.id` in sequence: final content = last writer, no conflict detection. +- No `version`, `etag`, or `updated_at_must_equal` check — pure last-writer-wins. +- WAL-mode allows concurrent reads during writes, but there is no optimistic-concurrency path for coordinated multi-client writes. +- Same applies to `skills`, `tasks`. + +**Integrity class:** isolation. **Score: 4/10** — single-writer assumption baked in; multi-client writes will silently clobber. + +### 11. Backup / restore +`sqlite3 memos.db '.dump' | sqlite3 restored.db`: row counts identical across `chunks (32)`, `embeddings (32)`, `tasks (2)`, `skills (1)`, `chunks_fts (32)`. FTS MATCH query returns same hit count. No documented export/import tool beyond raw sqlite. + +**Integrity class:** durability. **Score: 8/10** — standard `.dump` works; no plugin-level backup tooling means restore of a *live* DB during plugin runtime needs external coordination (WAL checkpointing, stopping plugin first). + +--- + +## Summary table + +| Area | Score | Key finding | +|------|-------|-------------| +| Local ↔ hub consistency | 4 | No transaction, silent field loss, hub re-embeds and re-stamps | +| Skill file ↔ index | 3 | No fsync/atomic rename; zero drift detection; `rm -rf` before `cp` | +| Summary ↔ chunk fidelity | 5 | FK sound; no citation back to source chunks, no hallucination guard | +| Dedup merge correctness | 5 | Hash+cosine detection fine; merge keeps newer, `merge_history` optional | +| Embedding drift | 4 | No model-version column; bulk re-embed undefined; blobs accepted raw | +| Soft-delete propagation | 4 | FTS and embeddings still contain superseded rows | +| Clock skew handling | 3 | Pure client `Date.now()`; future + negative ts both accepted | +| Content fidelity | 9 | Byte-exact storage; trigram-FTS has ≥3-char minimum | +| Orphan / FK integrity | 6 | Safe when plugin holds the connection; external connections can orphan | +| Concurrent edit semantics | 4 | Last-writer-wins; no version column anywhere | +| Backup / restore | 8 | `.dump`/restore lossless; no plugin-level tooling | + +**Overall integrity score = MIN = 3/10.** +Bottleneck risks: skill-file durability (no atomic writes), clock-skew acceptance. + +## Priority fixes (highest integrity ROI) +1. **Atomic skill writes** — write to `.md.tmp`, `fsync`, `rename`, `fsync` dir. Apply to `generator.ts` and `installer.cleanSync`. +2. **Clock-skew clamp** — server-side (or on-write) clamp of `created_at` into `[now-30d, now+5m]`; log outliers. +3. **Embedder version tag** — add `embeddings.model_id TEXT` and `embeddings.model_version INT`; gate cosine by matching model; expose bulk re-embed. +4. **Soft-delete-aware FTS** — include `dedup_status` as an indexed column on the FTS content table, or change every search call site to filter explicitly and add a CI lint. +5. **Optimistic concurrency** — `chunks.version INTEGER`, bump on UPDATE, reject stale writes. Same for `skills`/`tasks`. +6. **Hub outbox** — persist pending `/share` calls in a local queue table with retry-on-reconnect. +7. **Vector invariant** — enforce `length(vector) = dimensions*4` via CHECK constraint. diff --git a/tests/v2/integ-work/probe.mjs b/tests/v2/integ-work/probe.mjs new file mode 100644 index 0000000..bc8bce5 --- /dev/null +++ b/tests/v2/integ-work/probe.mjs @@ -0,0 +1,183 @@ +import Database from '/home/openclaw/.hermes/memos-plugin-research-agent/node_modules/better-sqlite3/lib/index.js'; +import { readFileSync } from 'fs'; +import crypto from 'crypto'; + +const DB = process.env.HOME + '/.hermes/memos-state-research-agent/memos-local/memos.db'; +const MARKER = readFileSync('/home/openclaw/Coding/Hermes/tests/v2/integ-work/marker.txt', 'utf8').trim(); +console.log(`Marker: ${MARKER}`); +const db = new Database(DB); +db.pragma('foreign_keys = ON'); +console.log('FK on:', db.pragma('foreign_keys', {simple:true})); +console.log('journal:', db.pragma('journal_mode', {simple:true})); + +const now = Date.now(); +const sessionKey = `audit:session:${MARKER}`; +const turnId = `turn-${MARKER}`; +const owner = 'agent:integ-audit'; + +function insertChunk(content, role='user', extra={}) { + const id = crypto.randomUUID(); + const summary = (extra.summary || content.slice(0,120)); + const hash = crypto.createHash('sha256').update(content).digest('hex'); + db.prepare(`INSERT INTO chunks(id, session_key, turn_id, seq, role, content, kind, summary, created_at, updated_at, content_hash, owner) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`).run( + id, sessionKey, turnId, extra.seq??0, role, content, 'paragraph', summary, now, now, hash, owner + ); + const dim = 4; + const vec = Buffer.alloc(dim*4); + for (let i=0;i "${q}"] hits=${hits.length}`); + } catch(e) { console.log(`fts[${k}] ERROR: ${e.message}`); } +} + +// === FK / ORPHAN === +console.log('\n=== PROBE B: FK integrity (FK ON) ==='); +const firstId = Object.values(fidelityResults)[0] && db.prepare('SELECT id FROM chunks WHERE session_key=? LIMIT 1').get(sessionKey).id; +const beforeEmb = db.prepare('SELECT COUNT(*) c FROM embeddings WHERE chunk_id=?').get(firstId).c; +db.prepare('DELETE FROM chunks WHERE id=?').run(firstId); +const afterEmb = db.prepare('SELECT COUNT(*) c FROM embeddings WHERE chunk_id=?').get(firstId).c; +console.log(`delete chunk -> embedding rows: before=${beforeEmb} after=${afterEmb} (CASCADE ${afterEmb===0?'works':'FAILED'})`); + +// What happens without FK — separate DB connection without setting pragma +const db2 = new Database(DB); +// db2 has FK OFF by default +console.log('db2 FK:', db2.pragma('foreign_keys', {simple:true})); +// Insert orphan embedding +try { + const orphanId = 'ORPHAN-' + MARKER; + db2.prepare(`INSERT INTO embeddings(chunk_id, vector, dimensions, updated_at) VALUES(?,?,?,?)`).run(orphanId, Buffer.alloc(16), 4, now); + const o = db.prepare('SELECT COUNT(*) c FROM embeddings WHERE chunk_id=?').get(orphanId).c; + console.log(`Orphan embedding inserted without FK enforcement: ${o===1?'YES -- integrity risk if any client opens DB without pragma':'NO'}`); + db.prepare('DELETE FROM embeddings WHERE chunk_id=?').run(orphanId); +} catch(e) { console.log('orphan insert failed:', e.message); } +db2.close(); + +// === CORRUPT EMBEDDING BLOB === +console.log('\n=== PROBE C: Corrupt embedding blob ==='); +const victimId = insertChunk('victim for corrupt test '+MARKER, 'user'); +db.prepare('UPDATE embeddings SET vector=?, dimensions=? WHERE chunk_id=?').run(Buffer.from('garbage-not-floats'), 999, victimId); +const v = db.prepare('SELECT dimensions, length(vector) lv FROM embeddings WHERE chunk_id=?').get(victimId); +console.log(`Corrupted row: dims=${v.dimensions} bytes=${v.lv} (stored without validation)`); + +// === CONCURRENT UPDATE === +console.log('\n=== PROBE D: Concurrent update (last-writer-wins) ==='); +const conflictId = insertChunk('original content '+MARKER, 'assistant'); +const dbA = new Database(DB); dbA.pragma('foreign_keys = ON'); +const dbB = new Database(DB); dbB.pragma('foreign_keys = ON'); +// sequential writes — no version column, so pure LWW +dbA.prepare('UPDATE chunks SET content=?, updated_at=? WHERE id=?').run('from A', Date.now(), conflictId); +dbB.prepare('UPDATE chunks SET content=?, updated_at=? WHERE id=?').run('from B', Date.now(), conflictId); +const final = db.prepare('SELECT content FROM chunks WHERE id=?').get(conflictId).content; +console.log(`After A then B: content="${final}" (no version field: LWW silently clobbers)`); +dbA.close(); dbB.close(); + +// Check schema for any version/etag field +const chunkCols = db.prepare("PRAGMA table_info(chunks)").all().map(c=>c.name); +console.log('chunk columns:', chunkCols.join(',')); +console.log('has version/etag/revision?', chunkCols.some(c=>/version|etag|revision/i.test(c))); + +// === CLOCK SKEW === +console.log('\n=== PROBE E: Clock skew / future timestamps ==='); +const futureId = insertChunk('future content '+MARKER, 'user', {summary:'future ts test'}); +const future = Date.now() + 365*24*3600*1000; // +1 year +db.prepare('UPDATE chunks SET created_at=?, updated_at=? WHERE id=?').run(future, future, futureId); +const ft = db.prepare('SELECT created_at FROM chunks WHERE id=?').get(futureId).created_at; +console.log(`Future ts accepted: ${ft} (no server validation; Date.now() from client is trusted)`); + +// Negative ts +const negId = insertChunk('negative content '+MARKER, 'user'); +db.prepare('UPDATE chunks SET created_at=? WHERE id=?').run(-1, negId); +const neg = db.prepare('SELECT created_at FROM chunks WHERE id=?').get(negId).created_at; +console.log(`Negative ts accepted: ${neg}`); + +// === DEDUP content_hash === +console.log('\n=== PROBE F: Content-hash dedup & near-dup merge ==='); +const hashCols = chunkCols.filter(c=>c.includes('merge')||c.includes('dedup')); +console.log('merge/dedup columns:', hashCols.join(',')); + +// Two near-dup rows that differ in one fact — hash will differ +const aliceA = 'Alice is 25 years old and works at ACME.'; +const aliceB = 'Alice is 30 years old and works at ACME.'; +const ia = insertChunk(aliceA,'user'); +const ib = insertChunk(aliceB,'user'); +const hashA = db.prepare('SELECT content_hash FROM chunks WHERE id=?').get(ia).content_hash; +const hashB = db.prepare('SELECT content_hash FROM chunks WHERE id=?').get(ib).content_hash; +console.log(`hashA=${hashA?.slice(0,12)} hashB=${hashB?.slice(0,12)} equal=${hashA===hashB}`); +console.log('-> content_hash dedup catches EXACT dup only; semantic dedup is handled by embedding-similarity at ingest'); +console.log('-> Merge semantics depend on findDuplicate callers. See dedup_status/dedup_target/merge_history columns.'); + +// Show dedup schema semantics +const deduped = db.prepare(`SELECT id, dedup_status, dedup_target, dedup_reason, merge_count, merge_history FROM chunks WHERE dedup_status!='active' LIMIT 3`).all(); +console.log('existing deduped rows sample:', JSON.stringify(deduped)); + +// === SOFT DELETE PROPAGATION === +console.log('\n=== PROBE G: Soft-delete / dedup_status="superseded" ==='); +const softId = insertChunk('soft delete target '+MARKER, 'user', {summary:'SOFTDEL '+MARKER}); +db.prepare(`UPDATE chunks SET dedup_status='superseded', dedup_target='x', dedup_reason='test' WHERE id=?`).run(softId); +// Check if FTS still indexes it (triggers) +const ftsStill = db.prepare(`SELECT 1 FROM chunks_fts WHERE chunks_fts MATCH ? LIMIT 1`).all('SOFTDEL'); +console.log(`After dedup_status=superseded, FTS row STILL present: ${ftsStill.length>0 ? 'YES (FTS triggers do not filter on dedup_status — soft-deleted rows still match FTS queries)' : 'no'}`); +console.log('-> Caller must filter WHERE dedup_status="active" when searching; easy to forget.'); + +// Check whether embedding row remains +const embStill = db.prepare('SELECT 1 FROM embeddings WHERE chunk_id=?').get(softId); +console.log(`embedding row remains after soft-delete: ${embStill?'YES':'NO'} (still participates in cosine-similarity dedup scans unless caller filters)`); + +// === FTS TRIGRAM + CONTROL CHARS === +console.log('\n=== PROBE H: FTS query with null byte ==='); +try { + const r = db.prepare(`SELECT count(*) c FROM chunks_fts WHERE chunks_fts MATCH ?`).all('before after'); + console.log(`null-byte row matches "before after": ${JSON.stringify(r)}`); +} catch(e){console.log('fts err:', e.message);} + +// cleanup — leave rows for evidence inspection, tagged by owner +const inserted = db.prepare('SELECT COUNT(*) c FROM chunks WHERE owner=?').get(owner).c; +console.log(`\nInserted chunks tagged owner=${owner}: ${inserted}`); + +db.close(); diff --git a/tests/v2/integ-work/task-audit/harness.mts b/tests/v2/integ-work/task-audit/harness.mts new file mode 100644 index 0000000..4f5876e --- /dev/null +++ b/tests/v2/integ-work/task-audit/harness.mts @@ -0,0 +1,273 @@ +// Task-summarization audit harness +// Marker: TASK-AUDIT-1745236800 +// Polyfill __filename for ESM before loading plugin (ensure-binding uses it) +(globalThis as any).__filename = "/home/openclaw/.hermes/memos-plugin-research-agent/src/storage/ensure-binding.ts"; +(globalThis as any).__dirname = "/home/openclaw/.hermes/memos-plugin-research-agent/src/storage"; +import { initPlugin } from "/home/openclaw/.hermes/memos-plugin-research-agent/src/index.ts"; +import Database from "better-sqlite3"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const AUDIT_TMP = process.env.AUDIT_TMP ?? "/home/openclaw/Coding/Hermes/tests/v2/integ-work/task-audit/tmp"; + +function log(s: string) { + console.log(`[HARNESS ${new Date().toISOString()}] ${s}`); +} + +interface Turn { role: "user" | "assistant"; content: string } + +const silentLog = { + debug: () => {}, + info: (m: string) => { if (process.env.VERBOSE) console.error("info:", m); }, + warn: (m: string) => console.error("warn:", m), + error: (m: string) => console.error("error:", m), +}; + +async function spawnPlugin(stateDir: string) { + const plugin = initPlugin({ + stateDir, + workspaceDir: stateDir, + log: silentLog as any, + config: { + summarizer: { + provider: "openai_compatible", + endpoint: "https://api.deepseek.com/v1/chat/completions", + apiKey: process.env.DEEPSEEK_API_KEY!, + model: "deepseek-chat", + temperature: 0.1, + timeoutMs: 120_000, + } as any, + embedding: { + provider: "openai_compatible", + endpoint: "http://127.0.0.1:65535/nope", + apiKey: "none", + model: "none", + } as any, + } as any, + }); + return plugin; +} + +function readTasks(stateDir: string) { + const dbPath = join(stateDir, "memos-local", "memos.db"); + const db = new Database(dbPath, { readonly: true }); + const rows = db.prepare("SELECT id, session_key, title, summary, status, started_at, ended_at FROM tasks ORDER BY started_at ASC").all(); + const chunks = db.prepare("SELECT id, task_id, role, content, created_at FROM chunks ORDER BY created_at ASC").all(); + db.close(); + return { tasks: rows, chunks }; +} + +async function feed(plugin: any, session: string, turns: Turn[], delayMs = 10) { + // feed turn-by-turn so task-processor can detect boundaries + for (let i = 0; i < turns.length; i += 2) { + const batch: Turn[] = [turns[i]]; + if (turns[i + 1]) batch.push(turns[i + 1]); + plugin.onConversationTurn(batch, session); + await plugin.flush(); + if (delayMs) await new Promise((r) => setTimeout(r, delayMs)); + } +} + +/** + * Force finalization of all active tasks by feeding a dummy message under + * a different session key with the same agent prefix. TaskProcessor.detectAndProcess + * will finalize any active task with a different sessionKey but matching prefix. + */ +async function forceFinalize(plugin: any, agentPrefix = "agent:main:audit") { + const terminatorSession = `${agentPrefix}:terminator-${Math.random().toString(36).slice(2, 8)}`; + plugin.onConversationTurn( + [ + { role: "user", content: "Audit terminator message. Please respond with any acknowledgement so finalization triggers for previous tasks." }, + { role: "assistant", content: "Acknowledged. Previous tasks will be finalized." }, + ], + terminatorSession, + ); + await plugin.flush(); + await new Promise((r) => setTimeout(r, 200)); +} + +async function scenario(name: string, fn: (p: any, sd: string) => Promise) { + const sd = mkdtempSync(join(tmpdir(), `audit-${name}-`)); + log(`=== ${name} :: ${sd}`); + const plugin = await spawnPlugin(sd); + try { + await fn(plugin, sd); + // Force finalization of open tasks + await forceFinalize(plugin); + } finally { + await plugin.shutdown(); + } + const result = readTasks(sd); + console.log(JSON.stringify({ scenario: name, result }, null, 2)); + rmSync(sd, { recursive: true, force: true }); + return result; +} + +// ====== probes ====== +async function probeExplicitBoundaries(p: any, sd: string) { + const session = "agent:main:audit:sess-explicit"; + const turns: Turn[] = [ + { role: "user", content: "Task 1: research the Raft consensus algorithm. Explain the leader election mechanism." }, + { role: "assistant", content: "Raft is a consensus algorithm. Leader election uses randomized timeouts between 150-300ms to prevent split votes. A follower becomes a candidate after its election timeout expires without hearing from a leader." }, + { role: "user", content: "What about log replication?" }, + { role: "assistant", content: "The leader appends entries to its log and replicates them via AppendEntries RPCs. A majority must acknowledge before commit." }, + { role: "user", content: "Done with task 1. Task 2: write Python code for a simple leader election using randomized timeouts." }, + { role: "assistant", content: "```python\nimport random, time\nclass Node:\n def __init__(self, id):\n self.id = id\n self.timeout = random.uniform(0.15, 0.3)\n def elect(self):\n time.sleep(self.timeout)\n return f'node-{self.id} won'\n```" }, + { role: "user", content: "Great. Add a term counter." }, + { role: "assistant", content: "```python\nclass Node:\n def __init__(self, id):\n self.id = id; self.term = 0; self.timeout = random.uniform(0.15, 0.3)\n```" }, + { role: "user", content: "Finished. Task 3: summarize Raft in 3 paragraphs." }, + { role: "assistant", content: "Paragraph 1: Raft is a consensus protocol designed for understandability. Paragraph 2: It uses leader election with randomized 150-300ms timeouts. Paragraph 3: Log replication requires majority acknowledgment before commit." }, + { role: "user", content: "Good, thanks." }, + { role: "assistant", content: "You're welcome." }, + ]; + await feed(p, session, turns); +} + +async function probeImplicitShift(p: any, sd: string) { + const session = "agent:main:audit:sess-implicit"; + const turns: Turn[] = [ + { role: "user", content: "My Python script throws ImportError: No module named 'foo' at line 47 of bar.py. How do I fix it?" }, + { role: "assistant", content: "Check if 'foo' is installed via pip list. If not, run: pip install foo. Verify sys.path includes the module's directory." }, + { role: "user", content: "pip list shows foo-0.2 but the error persists." }, + { role: "assistant", content: "You may have multiple Python interpreters. Run `which python` and `which pip` to ensure they match." }, + { role: "user", content: "Yes, pip was for 3.9 but I run with 3.11. Fixed by pip3.11 install foo." }, + { role: "assistant", content: "Great, the interpreter mismatch was the root cause." }, + // implicit shift, no transition word + { role: "user", content: "What's a good recipe for chicken tikka masala?" }, + { role: "assistant", content: "Marinate chicken in yogurt+spices for 1 hour. Sear. Add tomato-cream sauce with garam masala. Simmer 20 min." }, + { role: "user", content: "How spicy is it normally?" }, + { role: "assistant", content: "Mild to medium. You can adjust with kashmiri chili (mild) or cayenne (hot)." }, + { role: "user", content: "Thanks." }, + { role: "assistant", content: "Enjoy." }, + ]; + await feed(p, session, turns); +} + +async function probeUnderSplit(p: any, sd: string) { + const session = "agent:main:audit:sess-undersplit"; + const turns: Turn[] = [ + { role: "user", content: "What's the capital of France?" }, + { role: "assistant", content: "Paris." }, + { role: "user", content: "Great, thanks." }, + { role: "assistant", content: "You're welcome." }, + { role: "user", content: "How do I sort a list in Python?" }, + { role: "assistant", content: "Use `sorted(lst)` for a new list, or `lst.sort()` in place. Both accept key=func and reverse=True." }, + { role: "user", content: "Thanks." }, + { role: "assistant", content: "Anytime." }, + ]; + await feed(p, session, turns); +} + +async function probeKeyDetails(p: any, sd: string) { + const session = "agent:main:audit:sess-keydetails"; + const turns: Turn[] = [ + { role: "user", content: "Parse this log line and tell me what's wrong: `[2026-04-20 14:32:11] ERROR nginx: upstream timed out (110: Connection timed out) while reading response header from upstream, client: 10.0.3.7`. Log file is /var/log/nginx/access.log.2026-04-21. Reference: https://example.com/ref?v=42&lang=en#section-3" }, + { role: "assistant", content: "Upstream is not responding within the proxy_read_timeout window. Check backend health on 10.0.3.7; default timeout is 60s." }, + { role: "user", content: "Also Python code errored: ImportError: No module named 'foo' at line 47 of bar.py. And we have exactly 1,527,384 users in the DB." }, + { role: "assistant", content: "For ImportError, install via `pip install foo`. For user count, note the exact number 1,527,384 for capacity planning." }, + { role: "user", content: "Here's the helper:\n```python\ndef foo(x):\n return x * 2\n```\nWhat does it do?" }, + { role: "assistant", content: "It returns x doubled. Pure function, no side effects." }, + { role: "user", content: "Also: Python 2 is faster than Python 3 for JSON parsing — please note that. And Raft uses randomized election timeouts between 150-300ms." }, + { role: "assistant", content: "Noted. (Note: the Python 2 vs 3 JSON claim is unusual — in most benchmarks Python 3 is equal or faster.)" }, + { role: "user", content: "Thanks, this is enough." }, + { role: "assistant", content: "Understood." }, + ]; + await feed(p, session, turns); +} + +async function probeErrorTask(p: any, sd: string) { + const session = "agent:main:audit:sess-errors"; + const turns: Turn[] = [ + { role: "user", content: "Help me deploy my Flask app to Heroku. I'm following https://devcenter.heroku.com/articles/getting-started-with-python" }, + { role: "assistant", content: "Start by creating Procfile with `web: gunicorn app:app` and a runtime.txt specifying python-3.11." }, + { role: "user", content: "git push heroku main failed: error: failed to push some refs to 'https://git.heroku.com/myapp.git'" }, + { role: "assistant", content: "Run `heroku git:remote -a myapp` first. Also ensure you've committed a requirements.txt." }, + { role: "user", content: "Still failing. Build log: `ModuleNotFoundError: No module named 'psycopg2'`" }, + { role: "assistant", content: "Add psycopg2-binary to requirements.txt (not psycopg2, which needs pg headers)." }, + { role: "user", content: "Now it times out: R10 (Boot timeout) -> Web process failed to bind to $PORT within 60 seconds of launch." }, + { role: "assistant", content: "Your gunicorn command isn't binding to $PORT. Use `web: gunicorn -b 0.0.0.0:$PORT app:app`." }, + { role: "user", content: "Deployment is still broken. I'm going to try a different approach tomorrow." }, + { role: "assistant", content: "Understood — the deploy is not working. Main unresolved issues: binding and psycopg2. Next step: verify Procfile binding." }, + ]; + await feed(p, session, turns); +} + +async function probeSpanish(p: any, sd: string) { + const session = "agent:main:audit:sess-spanish"; + const turns: Turn[] = [ + { role: "user", content: "¿Cómo puedo crear un diccionario en Python con comprensión de lista?" }, + { role: "assistant", content: "Usa la sintaxis: `{k: v for k, v in pares}`. Por ejemplo: `{x: x**2 for x in range(5)}` crea `{0:0, 1:1, 2:4, 3:9, 4:16}`." }, + { role: "user", content: "Y si quiero filtrar solo los pares?" }, + { role: "assistant", content: "Añade una condición: `{x: x**2 for x in range(10) if x % 2 == 0}`." }, + { role: "user", content: "Perfecto, gracias. La versión de Python es 3.11." }, + { role: "assistant", content: "De nada. Funciona igual en 3.7+." }, + ]; + await feed(p, session, turns); +} + +async function probeShortTask(p: any, sd: string) { + const session = "agent:main:audit:sess-short"; + const turns: Turn[] = [ + { role: "user", content: "What's 2+2?" }, + { role: "assistant", content: "4." }, + ]; + await feed(p, session, turns); +} + +async function probeTimeGap(p: any, sd: string) { + // Manually manipulate chunk timestamps via DB + const session = "agent:main:audit:sess-timegap"; + const turns: Turn[] = [ + { role: "user", content: "Help me understand TCP. What is the three-way handshake?" }, + { role: "assistant", content: "SYN → SYN-ACK → ACK. Client sends SYN, server responds SYN-ACK, client confirms with ACK. Each carries initial sequence numbers." }, + { role: "user", content: "What about connection teardown?" }, + { role: "assistant", content: "FIN → ACK → FIN → ACK. Each side closes independently (half-close supported)." }, + ]; + await feed(p, session, turns); + + // Wait for chunks to land, then directly modify DB to simulate a 3-hour gap before new turns + await new Promise((r) => setTimeout(r, 200)); + const dbPath = join(sd, "memos-local", "memos.db"); + const db = new Database(dbPath); + db.close(); + + const turns2: Turn[] = [ + { role: "user", content: "Back to TCP — what's the MSL?" }, + { role: "assistant", content: "Maximum Segment Lifetime, typically 2 minutes. Used in TIME_WAIT to ensure late segments don't confuse new connections." }, + { role: "user", content: "And Nagle's algorithm?" }, + { role: "assistant", content: "Coalesces small packets to reduce overhead; conflicts with delayed ACK." }, + ]; + // Before feeding turns2, shift all existing chunk timestamps back by 3 hours to simulate gap + const db2 = new Database(dbPath); + const threeHoursMs = 3 * 60 * 60 * 1000; + db2.prepare("UPDATE chunks SET created_at = created_at - ? WHERE session_key = ?").run(threeHoursMs, session); + db2.prepare("UPDATE tasks SET started_at = started_at - ?, updated_at = updated_at - ? WHERE session_key = ?").run(threeHoursMs, threeHoursMs, session); + db2.close(); + + await feed(p, session, turns2); +} + +// ====== driver ====== +const target = process.argv[2] ?? "all"; +const probes: Record Promise> = { + explicit: probeExplicitBoundaries, + implicit: probeImplicitShift, + undersplit: probeUnderSplit, + keydetails: probeKeyDetails, + errors: probeErrorTask, + spanish: probeSpanish, + short: probeShortTask, + timegap: probeTimeGap, +}; + +const runList = target === "all" ? Object.keys(probes) : [target]; + +for (const name of runList) { + try { + await scenario(name, probes[name]); + } catch (err) { + console.error(`SCENARIO ${name} FAILED:`, err); + } +} +process.exit(0); diff --git a/tests/v2/integ-work/task-audit/package.json.ref b/tests/v2/integ-work/task-audit/package.json.ref new file mode 100644 index 0000000..03d1c83 --- /dev/null +++ b/tests/v2/integ-work/task-audit/package.json.ref @@ -0,0 +1,63 @@ +{ + "name": "@memtensor/memos-local-hermes-plugin", + "version": "1.0.3", + "description": "MemTensor Local memory plugin for Hermes Agent", + "type": "module", + "main": "index.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/MemTensor/MemOS.git", + "directory": "apps/memos-local-plugin" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "index.ts", + "bridge.cts", + "src", + "adapters", + "prebuilds", + "install.sh", + "tsconfig.json", + "telemetry.credentials.json", + ".env.example", + "README.md" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "prepack": "rm -rf adapters/hermes/__pycache__ adapters/openharness/scripts/__pycache__ adapters/hermes/bridge_path.txt adapters/openharness/scripts/bridge_path.txt && echo 'prepack cleanup done'", + "prepublishOnly": "echo 'Source + prebuilds publish — no tsc dist needed.'" + }, + "keywords": [ + "memtensor", + "memory", + "memos", + "rag", + "local", + "hermes" + ], + "license": "MIT", + "engines": { + "node": ">=18.0.0 <25.0.0" + }, + "dependencies": { + "@huggingface/transformers": "^3.8.0", + "@sinclair/typebox": "^0.34.48", + "better-sqlite3": "^12.6.3", + "posthog-node": "^5.28.0", + "puppeteer": "^24.38.0", + "semver": "^7.7.4", + "tsx": "^4.21.0", + "uuid": "^10.0.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.0", + "@types/semver": "^7.7.1", + "@types/uuid": "^10.0.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0" + } +} diff --git a/tools/build-memory-report.sh b/tools/build-memory-report.sh new file mode 100755 index 0000000..262ca02 --- /dev/null +++ b/tools/build-memory-report.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Regenerate the comprehensive memory report PDF from its HTML source. +# +# STANDING PROCESS (set 2026-05-27): every memory change updates this report. +# 1. Edit the source deck: +# docs/architecture/2026-05-27-memory-comprehensive-report-deck.html +# 2. Run this script to rebuild the PDF. +# 3. Commit both. +# 4. Refresh the Drive copy (Google Doc) in folder "Memory System" +# (1WSCcEJ-Zfe3mgvfeCLsBK0wEkUoiHojm). Note: service-account binary upload +# is blocked (no quota on personal My Drive) — update the Doc via the Drive +# MCP (text), or File→Download→PDF the Doc, until the folder is a Shared Drive. +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC="$REPO/docs/architecture/2026-05-27-memory-comprehensive-report-deck.html" +OUT="$REPO/docs/architecture/Hermes-Memory-System-Comprehensive-Report-2026-05-27.pdf" + +[ -f "$SRC" ] || { echo "missing source deck: $SRC" >&2; exit 1; } + +google-chrome --headless --no-sandbox --disable-gpu \ + --print-to-pdf="$OUT" --no-pdf-header-footer "file://$SRC" + +pages="$(pdfinfo "$OUT" 2>/dev/null | awk '/^Pages:/{print $2}')" +echo "built: $OUT (${pages:-?} pages, $(stat -c%s "$OUT") bytes)" +echo "next: commit, then refresh the Drive Doc (see header)." diff --git a/tools/d3.v7.min.js b/tools/d3.v7.min.js new file mode 100644 index 0000000..33bb880 --- /dev/null +++ b/tools/d3.v7.min.js @@ -0,0 +1,2 @@ +// https://d3js.org v7.9.0 Copyright 2010-2023 Mike Bostock +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t="undefined"!=typeof globalThis?globalThis:t||self).d3=t.d3||{})}(this,(function(t){"use strict";function n(t,n){return null==t||null==n?NaN:tn?1:t>=n?0:NaN}function e(t,n){return null==t||null==n?NaN:nt?1:n>=t?0:NaN}function r(t){let r,o,a;function u(t,n,e=0,i=t.length){if(e>>1;o(t[r],n)<0?e=r+1:i=r}while(en(t(e),r),a=(n,e)=>t(n)-e):(r=t===n||t===e?t:i,o=t,a=t),{left:u,center:function(t,n,e=0,r=t.length){const i=u(t,n,e,r-1);return i>e&&a(t[i-1],n)>-a(t[i],n)?i-1:i},right:function(t,n,e=0,i=t.length){if(e>>1;o(t[r],n)<=0?e=r+1:i=r}while(e{n(t,e,(r<<=2)+0,(i<<=2)+0,o<<=2),n(t,e,r+1,i+1,o),n(t,e,r+2,i+2,o),n(t,e,r+3,i+3,o)}}));function d(t){return function(n,e,r=e){if(!((e=+e)>=0))throw new RangeError("invalid rx");if(!((r=+r)>=0))throw new RangeError("invalid ry");let{data:i,width:o,height:a}=n;if(!((o=Math.floor(o))>=0))throw new RangeError("invalid width");if(!((a=Math.floor(void 0!==a?a:i.length/o))>=0))throw new RangeError("invalid height");if(!o||!a||!e&&!r)return n;const u=e&&t(e),c=r&&t(r),f=i.slice();return u&&c?(p(u,f,i,o,a),p(u,i,f,o,a),p(u,f,i,o,a),g(c,i,f,o,a),g(c,f,i,o,a),g(c,i,f,o,a)):u?(p(u,i,f,o,a),p(u,f,i,o,a),p(u,i,f,o,a)):c&&(g(c,i,f,o,a),g(c,f,i,o,a),g(c,i,f,o,a)),n}}function p(t,n,e,r,i){for(let o=0,a=r*i;o{if(!((o-=a)>=i))return;let u=t*r[i];const c=a*t;for(let t=i,n=i+c;t{if(!((a-=u)>=o))return;let c=n*i[o];const f=u*n,s=f+u;for(let t=o,n=o+f;t=n&&++e;else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(i=+i)>=i&&++e}return e}function _(t){return 0|t.length}function b(t){return!(t>0)}function m(t){return"object"!=typeof t||"length"in t?t:Array.from(t)}function x(t,n){let e,r=0,i=0,o=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(e=n-i,i+=e/++r,o+=e*(n-i));else{let a=-1;for(let u of t)null!=(u=n(u,++a,t))&&(u=+u)>=u&&(e=u-i,i+=e/++r,o+=e*(u-i))}if(r>1)return o/(r-1)}function w(t,n){const e=x(t,n);return e?Math.sqrt(e):e}function M(t,n){let e,r;if(void 0===n)for(const n of t)null!=n&&(void 0===e?n>=n&&(e=r=n):(e>n&&(e=n),r=o&&(e=r=o):(e>o&&(e=o),r0){for(o=t[--i];i>0&&(n=o,e=t[--i],o=n+e,r=e-(o-n),!r););i>0&&(r<0&&t[i-1]<0||r>0&&t[i-1]>0)&&(e=2*r,n=o+e,e==n-o&&(o=n))}return o}}class InternMap extends Map{constructor(t,n=N){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:n}}),null!=t)for(const[n,e]of t)this.set(n,e)}get(t){return super.get(A(this,t))}has(t){return super.has(A(this,t))}set(t,n){return super.set(S(this,t),n)}delete(t){return super.delete(E(this,t))}}class InternSet extends Set{constructor(t,n=N){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:n}}),null!=t)for(const n of t)this.add(n)}has(t){return super.has(A(this,t))}add(t){return super.add(S(this,t))}delete(t){return super.delete(E(this,t))}}function A({_intern:t,_key:n},e){const r=n(e);return t.has(r)?t.get(r):e}function S({_intern:t,_key:n},e){const r=n(e);return t.has(r)?t.get(r):(t.set(r,e),e)}function E({_intern:t,_key:n},e){const r=n(e);return t.has(r)&&(e=t.get(r),t.delete(r)),e}function N(t){return null!==t&&"object"==typeof t?t.valueOf():t}function k(t){return t}function C(t,...n){return F(t,k,k,n)}function P(t,...n){return F(t,Array.from,k,n)}function z(t,n){for(let e=1,r=n.length;et.pop().map((([n,e])=>[...t,n,e]))));return t}function $(t,n,...e){return F(t,k,n,e)}function D(t,n,...e){return F(t,Array.from,n,e)}function R(t){if(1!==t.length)throw new Error("duplicate key");return t[0]}function F(t,n,e,r){return function t(i,o){if(o>=r.length)return e(i);const a=new InternMap,u=r[o++];let c=-1;for(const t of i){const n=u(t,++c,i),e=a.get(n);e?e.push(t):a.set(n,[t])}for(const[n,e]of a)a.set(n,t(e,o));return n(a)}(t,0)}function q(t,n){return Array.from(n,(n=>t[n]))}function U(t,...n){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");t=Array.from(t);let[e]=n;if(e&&2!==e.length||n.length>1){const r=Uint32Array.from(t,((t,n)=>n));return n.length>1?(n=n.map((n=>t.map(n))),r.sort(((t,e)=>{for(const r of n){const n=O(r[t],r[e]);if(n)return n}}))):(e=t.map(e),r.sort(((t,n)=>O(e[t],e[n])))),q(t,r)}return t.sort(I(e))}function I(t=n){if(t===n)return O;if("function"!=typeof t)throw new TypeError("compare is not a function");return(n,e)=>{const r=t(n,e);return r||0===r?r:(0===t(e,e))-(0===t(n,n))}}function O(t,n){return(null==t||!(t>=t))-(null==n||!(n>=n))||(tn?1:0)}var B=Array.prototype.slice;function Y(t){return()=>t}const L=Math.sqrt(50),j=Math.sqrt(10),H=Math.sqrt(2);function X(t,n,e){const r=(n-t)/Math.max(0,e),i=Math.floor(Math.log10(r)),o=r/Math.pow(10,i),a=o>=L?10:o>=j?5:o>=H?2:1;let u,c,f;return i<0?(f=Math.pow(10,-i)/a,u=Math.round(t*f),c=Math.round(n*f),u/fn&&--c,f=-f):(f=Math.pow(10,i)*a,u=Math.round(t/f),c=Math.round(n/f),u*fn&&--c),c0))return[];if((t=+t)===(n=+n))return[t];const r=n=i))return[];const u=o-i+1,c=new Array(u);if(r)if(a<0)for(let t=0;t0?(t=Math.floor(t/i)*i,n=Math.ceil(n/i)*i):i<0&&(t=Math.ceil(t*i)/i,n=Math.floor(n*i)/i),r=i}}function K(t){return Math.max(1,Math.ceil(Math.log(v(t))/Math.LN2)+1)}function Q(){var t=k,n=M,e=K;function r(r){Array.isArray(r)||(r=Array.from(r));var i,o,a,u=r.length,c=new Array(u);for(i=0;i=h)if(t>=h&&n===M){const t=V(l,h,e);isFinite(t)&&(t>0?h=(Math.floor(h/t)+1)*t:t<0&&(h=(Math.ceil(h*-t)+1)/-t))}else d.pop()}for(var p=d.length,g=0,y=p;d[g]<=l;)++g;for(;d[y-1]>h;)--y;(g||y0?d[i-1]:l,v.x1=i0)for(i=0;i=n)&&(e=n);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(e=i)&&(e=i)}return e}function tt(t,n){let e,r=-1,i=-1;if(void 0===n)for(const n of t)++i,null!=n&&(e=n)&&(e=n,r=i);else for(let o of t)null!=(o=n(o,++i,t))&&(e=o)&&(e=o,r=i);return r}function nt(t,n){let e;if(void 0===n)for(const n of t)null!=n&&(e>n||void 0===e&&n>=n)&&(e=n);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(e>i||void 0===e&&i>=i)&&(e=i)}return e}function et(t,n){let e,r=-1,i=-1;if(void 0===n)for(const n of t)++i,null!=n&&(e>n||void 0===e&&n>=n)&&(e=n,r=i);else for(let o of t)null!=(o=n(o,++i,t))&&(e>o||void 0===e&&o>=o)&&(e=o,r=i);return r}function rt(t,n,e=0,r=1/0,i){if(n=Math.floor(n),e=Math.floor(Math.max(0,e)),r=Math.floor(Math.min(t.length-1,r)),!(e<=n&&n<=r))return t;for(i=void 0===i?O:I(i);r>e;){if(r-e>600){const o=r-e+1,a=n-e+1,u=Math.log(o),c=.5*Math.exp(2*u/3),f=.5*Math.sqrt(u*c*(o-c)/o)*(a-o/2<0?-1:1);rt(t,n,Math.max(e,Math.floor(n-a*c/o+f)),Math.min(r,Math.floor(n+(o-a)*c/o+f)),i)}const o=t[n];let a=e,u=r;for(it(t,e,n),i(t[r],o)>0&&it(t,e,r);a0;)--u}0===i(t[e],o)?it(t,e,u):(++u,it(t,u,r)),u<=n&&(e=u+1),n<=u&&(r=u-1)}return t}function it(t,n,e){const r=t[n];t[n]=t[e],t[e]=r}function ot(t,e=n){let r,i=!1;if(1===e.length){let o;for(const a of t){const t=e(a);(i?n(t,o)>0:0===n(t,t))&&(r=a,o=t,i=!0)}}else for(const n of t)(i?e(n,r)>0:0===e(n,n))&&(r=n,i=!0);return r}function at(t,n,e){if(t=Float64Array.from(function*(t,n){if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(yield n);else{let e=-1;for(let r of t)null!=(r=n(r,++e,t))&&(r=+r)>=r&&(yield r)}}(t,e)),(r=t.length)&&!isNaN(n=+n)){if(n<=0||r<2)return nt(t);if(n>=1)return J(t);var r,i=(r-1)*n,o=Math.floor(i),a=J(rt(t,o).subarray(0,o+1));return a+(nt(t.subarray(o+1))-a)*(i-o)}}function ut(t,n,e=o){if((r=t.length)&&!isNaN(n=+n)){if(n<=0||r<2)return+e(t[0],0,t);if(n>=1)return+e(t[r-1],r-1,t);var r,i=(r-1)*n,a=Math.floor(i),u=+e(t[a],a,t);return u+(+e(t[a+1],a+1,t)-u)*(i-a)}}function ct(t,n,e=o){if(!isNaN(n=+n)){if(r=Float64Array.from(t,((n,r)=>o(e(t[r],r,t)))),n<=0)return et(r);if(n>=1)return tt(r);var r,i=Uint32Array.from(t,((t,n)=>n)),a=r.length-1,u=Math.floor(a*n);return rt(i,u,0,a,((t,n)=>O(r[t],r[n]))),(u=ot(i.subarray(0,u+1),(t=>r[t])))>=0?u:-1}}function ft(t){return Array.from(function*(t){for(const n of t)yield*n}(t))}function st(t,n){return[t,n]}function lt(t,n,e){t=+t,n=+n,e=(i=arguments.length)<2?(n=t,t=0,1):i<3?1:+e;for(var r=-1,i=0|Math.max(0,Math.ceil((n-t)/e)),o=new Array(i);++r+t(n)}function kt(t,n){return n=Math.max(0,t.bandwidth()-2*n)/2,t.round()&&(n=Math.round(n)),e=>+t(e)+n}function Ct(){return!this.__axis}function Pt(t,n){var e=[],r=null,i=null,o=6,a=6,u=3,c="undefined"!=typeof window&&window.devicePixelRatio>1?0:.5,f=t===xt||t===Tt?-1:1,s=t===Tt||t===wt?"x":"y",l=t===xt||t===Mt?St:Et;function h(h){var d=null==r?n.ticks?n.ticks.apply(n,e):n.domain():r,p=null==i?n.tickFormat?n.tickFormat.apply(n,e):mt:i,g=Math.max(o,0)+u,y=n.range(),v=+y[0]+c,_=+y[y.length-1]+c,b=(n.bandwidth?kt:Nt)(n.copy(),c),m=h.selection?h.selection():h,x=m.selectAll(".domain").data([null]),w=m.selectAll(".tick").data(d,n).order(),M=w.exit(),T=w.enter().append("g").attr("class","tick"),A=w.select("line"),S=w.select("text");x=x.merge(x.enter().insert("path",".tick").attr("class","domain").attr("stroke","currentColor")),w=w.merge(T),A=A.merge(T.append("line").attr("stroke","currentColor").attr(s+"2",f*o)),S=S.merge(T.append("text").attr("fill","currentColor").attr(s,f*g).attr("dy",t===xt?"0em":t===Mt?"0.71em":"0.32em")),h!==m&&(x=x.transition(h),w=w.transition(h),A=A.transition(h),S=S.transition(h),M=M.transition(h).attr("opacity",At).attr("transform",(function(t){return isFinite(t=b(t))?l(t+c):this.getAttribute("transform")})),T.attr("opacity",At).attr("transform",(function(t){var n=this.parentNode.__axis;return l((n&&isFinite(n=n(t))?n:b(t))+c)}))),M.remove(),x.attr("d",t===Tt||t===wt?a?"M"+f*a+","+v+"H"+c+"V"+_+"H"+f*a:"M"+c+","+v+"V"+_:a?"M"+v+","+f*a+"V"+c+"H"+_+"V"+f*a:"M"+v+","+c+"H"+_),w.attr("opacity",1).attr("transform",(function(t){return l(b(t)+c)})),A.attr(s+"2",f*o),S.attr(s,f*g).text(p),m.filter(Ct).attr("fill","none").attr("font-size",10).attr("font-family","sans-serif").attr("text-anchor",t===wt?"start":t===Tt?"end":"middle"),m.each((function(){this.__axis=b}))}return h.scale=function(t){return arguments.length?(n=t,h):n},h.ticks=function(){return e=Array.from(arguments),h},h.tickArguments=function(t){return arguments.length?(e=null==t?[]:Array.from(t),h):e.slice()},h.tickValues=function(t){return arguments.length?(r=null==t?null:Array.from(t),h):r&&r.slice()},h.tickFormat=function(t){return arguments.length?(i=t,h):i},h.tickSize=function(t){return arguments.length?(o=a=+t,h):o},h.tickSizeInner=function(t){return arguments.length?(o=+t,h):o},h.tickSizeOuter=function(t){return arguments.length?(a=+t,h):a},h.tickPadding=function(t){return arguments.length?(u=+t,h):u},h.offset=function(t){return arguments.length?(c=+t,h):c},h}var zt={value:()=>{}};function $t(){for(var t,n=0,e=arguments.length,r={};n=0&&(n=t.slice(e+1),t=t.slice(0,e)),t&&!r.hasOwnProperty(t))throw new Error("unknown type: "+t);return{type:t,name:n}}))),a=-1,u=o.length;if(!(arguments.length<2)){if(null!=n&&"function"!=typeof n)throw new Error("invalid callback: "+n);for(;++a0)for(var e,r,i=new Array(e),o=0;o=0&&"xmlns"!==(n=t.slice(0,e))&&(t=t.slice(e+1)),Ut.hasOwnProperty(n)?{space:Ut[n],local:t}:t}function Ot(t){return function(){var n=this.ownerDocument,e=this.namespaceURI;return e===qt&&n.documentElement.namespaceURI===qt?n.createElement(t):n.createElementNS(e,t)}}function Bt(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function Yt(t){var n=It(t);return(n.local?Bt:Ot)(n)}function Lt(){}function jt(t){return null==t?Lt:function(){return this.querySelector(t)}}function Ht(t){return null==t?[]:Array.isArray(t)?t:Array.from(t)}function Xt(){return[]}function Gt(t){return null==t?Xt:function(){return this.querySelectorAll(t)}}function Vt(t){return function(){return this.matches(t)}}function Wt(t){return function(n){return n.matches(t)}}var Zt=Array.prototype.find;function Kt(){return this.firstElementChild}var Qt=Array.prototype.filter;function Jt(){return Array.from(this.children)}function tn(t){return new Array(t.length)}function nn(t,n){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=n}function en(t,n,e,r,i,o){for(var a,u=0,c=n.length,f=o.length;un?1:t>=n?0:NaN}function cn(t){return function(){this.removeAttribute(t)}}function fn(t){return function(){this.removeAttributeNS(t.space,t.local)}}function sn(t,n){return function(){this.setAttribute(t,n)}}function ln(t,n){return function(){this.setAttributeNS(t.space,t.local,n)}}function hn(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttribute(t):this.setAttribute(t,e)}}function dn(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,e)}}function pn(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}function gn(t){return function(){this.style.removeProperty(t)}}function yn(t,n,e){return function(){this.style.setProperty(t,n,e)}}function vn(t,n,e){return function(){var r=n.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,e)}}function _n(t,n){return t.style.getPropertyValue(n)||pn(t).getComputedStyle(t,null).getPropertyValue(n)}function bn(t){return function(){delete this[t]}}function mn(t,n){return function(){this[t]=n}}function xn(t,n){return function(){var e=n.apply(this,arguments);null==e?delete this[t]:this[t]=e}}function wn(t){return t.trim().split(/^|\s+/)}function Mn(t){return t.classList||new Tn(t)}function Tn(t){this._node=t,this._names=wn(t.getAttribute("class")||"")}function An(t,n){for(var e=Mn(t),r=-1,i=n.length;++r=0&&(this._names.splice(n,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};var Gn=[null];function Vn(t,n){this._groups=t,this._parents=n}function Wn(){return new Vn([[document.documentElement]],Gn)}function Zn(t){return"string"==typeof t?new Vn([[document.querySelector(t)]],[document.documentElement]):new Vn([[t]],Gn)}Vn.prototype=Wn.prototype={constructor:Vn,select:function(t){"function"!=typeof t&&(t=jt(t));for(var n=this._groups,e=n.length,r=new Array(e),i=0;i=m&&(m=b+1);!(_=y[m])&&++m=0;)(r=i[o])&&(a&&4^r.compareDocumentPosition(a)&&a.parentNode.insertBefore(r,a),a=r);return this},sort:function(t){function n(n,e){return n&&e?t(n.__data__,e.__data__):!n-!e}t||(t=un);for(var e=this._groups,r=e.length,i=new Array(r),o=0;o1?this.each((null==n?gn:"function"==typeof n?vn:yn)(t,n,null==e?"":e)):_n(this.node(),t)},property:function(t,n){return arguments.length>1?this.each((null==n?bn:"function"==typeof n?xn:mn)(t,n)):this.node()[t]},classed:function(t,n){var e=wn(t+"");if(arguments.length<2){for(var r=Mn(this.node()),i=-1,o=e.length;++i=0&&(n=t.slice(e+1),t=t.slice(0,e)),{type:t,name:n}}))}(t+""),a=o.length;if(!(arguments.length<2)){for(u=n?Ln:Yn,r=0;r()=>t;function fe(t,{sourceEvent:n,subject:e,target:r,identifier:i,active:o,x:a,y:u,dx:c,dy:f,dispatch:s}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:n,enumerable:!0,configurable:!0},subject:{value:e,enumerable:!0,configurable:!0},target:{value:r,enumerable:!0,configurable:!0},identifier:{value:i,enumerable:!0,configurable:!0},active:{value:o,enumerable:!0,configurable:!0},x:{value:a,enumerable:!0,configurable:!0},y:{value:u,enumerable:!0,configurable:!0},dx:{value:c,enumerable:!0,configurable:!0},dy:{value:f,enumerable:!0,configurable:!0},_:{value:s}})}function se(t){return!t.ctrlKey&&!t.button}function le(){return this.parentNode}function he(t,n){return null==n?{x:t.x,y:t.y}:n}function de(){return navigator.maxTouchPoints||"ontouchstart"in this}function pe(t,n,e){t.prototype=n.prototype=e,e.constructor=t}function ge(t,n){var e=Object.create(t.prototype);for(var r in n)e[r]=n[r];return e}function ye(){}fe.prototype.on=function(){var t=this._.on.apply(this._,arguments);return t===this._?this:t};var ve=.7,_e=1/ve,be="\\s*([+-]?\\d+)\\s*",me="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*",xe="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*",we=/^#([0-9a-f]{3,8})$/,Me=new RegExp(`^rgb\\(${be},${be},${be}\\)$`),Te=new RegExp(`^rgb\\(${xe},${xe},${xe}\\)$`),Ae=new RegExp(`^rgba\\(${be},${be},${be},${me}\\)$`),Se=new RegExp(`^rgba\\(${xe},${xe},${xe},${me}\\)$`),Ee=new RegExp(`^hsl\\(${me},${xe},${xe}\\)$`),Ne=new RegExp(`^hsla\\(${me},${xe},${xe},${me}\\)$`),ke={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074};function Ce(){return this.rgb().formatHex()}function Pe(){return this.rgb().formatRgb()}function ze(t){var n,e;return t=(t+"").trim().toLowerCase(),(n=we.exec(t))?(e=n[1].length,n=parseInt(n[1],16),6===e?$e(n):3===e?new qe(n>>8&15|n>>4&240,n>>4&15|240&n,(15&n)<<4|15&n,1):8===e?De(n>>24&255,n>>16&255,n>>8&255,(255&n)/255):4===e?De(n>>12&15|n>>8&240,n>>8&15|n>>4&240,n>>4&15|240&n,((15&n)<<4|15&n)/255):null):(n=Me.exec(t))?new qe(n[1],n[2],n[3],1):(n=Te.exec(t))?new qe(255*n[1]/100,255*n[2]/100,255*n[3]/100,1):(n=Ae.exec(t))?De(n[1],n[2],n[3],n[4]):(n=Se.exec(t))?De(255*n[1]/100,255*n[2]/100,255*n[3]/100,n[4]):(n=Ee.exec(t))?Le(n[1],n[2]/100,n[3]/100,1):(n=Ne.exec(t))?Le(n[1],n[2]/100,n[3]/100,n[4]):ke.hasOwnProperty(t)?$e(ke[t]):"transparent"===t?new qe(NaN,NaN,NaN,0):null}function $e(t){return new qe(t>>16&255,t>>8&255,255&t,1)}function De(t,n,e,r){return r<=0&&(t=n=e=NaN),new qe(t,n,e,r)}function Re(t){return t instanceof ye||(t=ze(t)),t?new qe((t=t.rgb()).r,t.g,t.b,t.opacity):new qe}function Fe(t,n,e,r){return 1===arguments.length?Re(t):new qe(t,n,e,null==r?1:r)}function qe(t,n,e,r){this.r=+t,this.g=+n,this.b=+e,this.opacity=+r}function Ue(){return`#${Ye(this.r)}${Ye(this.g)}${Ye(this.b)}`}function Ie(){const t=Oe(this.opacity);return`${1===t?"rgb(":"rgba("}${Be(this.r)}, ${Be(this.g)}, ${Be(this.b)}${1===t?")":`, ${t})`}`}function Oe(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function Be(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function Ye(t){return((t=Be(t))<16?"0":"")+t.toString(16)}function Le(t,n,e,r){return r<=0?t=n=e=NaN:e<=0||e>=1?t=n=NaN:n<=0&&(t=NaN),new Xe(t,n,e,r)}function je(t){if(t instanceof Xe)return new Xe(t.h,t.s,t.l,t.opacity);if(t instanceof ye||(t=ze(t)),!t)return new Xe;if(t instanceof Xe)return t;var n=(t=t.rgb()).r/255,e=t.g/255,r=t.b/255,i=Math.min(n,e,r),o=Math.max(n,e,r),a=NaN,u=o-i,c=(o+i)/2;return u?(a=n===o?(e-r)/u+6*(e0&&c<1?0:a,new Xe(a,u,c,t.opacity)}function He(t,n,e,r){return 1===arguments.length?je(t):new Xe(t,n,e,null==r?1:r)}function Xe(t,n,e,r){this.h=+t,this.s=+n,this.l=+e,this.opacity=+r}function Ge(t){return(t=(t||0)%360)<0?t+360:t}function Ve(t){return Math.max(0,Math.min(1,t||0))}function We(t,n,e){return 255*(t<60?n+(e-n)*t/60:t<180?e:t<240?n+(e-n)*(240-t)/60:n)}pe(ye,ze,{copy(t){return Object.assign(new this.constructor,this,t)},displayable(){return this.rgb().displayable()},hex:Ce,formatHex:Ce,formatHex8:function(){return this.rgb().formatHex8()},formatHsl:function(){return je(this).formatHsl()},formatRgb:Pe,toString:Pe}),pe(qe,Fe,ge(ye,{brighter(t){return t=null==t?_e:Math.pow(_e,t),new qe(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=null==t?ve:Math.pow(ve,t),new qe(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new qe(Be(this.r),Be(this.g),Be(this.b),Oe(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Ue,formatHex:Ue,formatHex8:function(){return`#${Ye(this.r)}${Ye(this.g)}${Ye(this.b)}${Ye(255*(isNaN(this.opacity)?1:this.opacity))}`},formatRgb:Ie,toString:Ie})),pe(Xe,He,ge(ye,{brighter(t){return t=null==t?_e:Math.pow(_e,t),new Xe(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=null==t?ve:Math.pow(ve,t),new Xe(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+360*(this.h<0),n=isNaN(t)||isNaN(this.s)?0:this.s,e=this.l,r=e+(e<.5?e:1-e)*n,i=2*e-r;return new qe(We(t>=240?t-240:t+120,i,r),We(t,i,r),We(t<120?t+240:t-120,i,r),this.opacity)},clamp(){return new Xe(Ge(this.h),Ve(this.s),Ve(this.l),Oe(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){const t=Oe(this.opacity);return`${1===t?"hsl(":"hsla("}${Ge(this.h)}, ${100*Ve(this.s)}%, ${100*Ve(this.l)}%${1===t?")":`, ${t})`}`}}));const Ze=Math.PI/180,Ke=180/Math.PI,Qe=.96422,Je=1,tr=.82521,nr=4/29,er=6/29,rr=3*er*er,ir=er*er*er;function or(t){if(t instanceof ur)return new ur(t.l,t.a,t.b,t.opacity);if(t instanceof pr)return gr(t);t instanceof qe||(t=Re(t));var n,e,r=lr(t.r),i=lr(t.g),o=lr(t.b),a=cr((.2225045*r+.7168786*i+.0606169*o)/Je);return r===i&&i===o?n=e=a:(n=cr((.4360747*r+.3850649*i+.1430804*o)/Qe),e=cr((.0139322*r+.0971045*i+.7141733*o)/tr)),new ur(116*a-16,500*(n-a),200*(a-e),t.opacity)}function ar(t,n,e,r){return 1===arguments.length?or(t):new ur(t,n,e,null==r?1:r)}function ur(t,n,e,r){this.l=+t,this.a=+n,this.b=+e,this.opacity=+r}function cr(t){return t>ir?Math.pow(t,1/3):t/rr+nr}function fr(t){return t>er?t*t*t:rr*(t-nr)}function sr(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function lr(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function hr(t){if(t instanceof pr)return new pr(t.h,t.c,t.l,t.opacity);if(t instanceof ur||(t=or(t)),0===t.a&&0===t.b)return new pr(NaN,0=1?(e=1,n-1):Math.floor(e*n),i=t[r],o=t[r+1],a=r>0?t[r-1]:2*i-o,u=r()=>t;function Cr(t,n){return function(e){return t+e*n}}function Pr(t,n){var e=n-t;return e?Cr(t,e>180||e<-180?e-360*Math.round(e/360):e):kr(isNaN(t)?n:t)}function zr(t){return 1==(t=+t)?$r:function(n,e){return e-n?function(t,n,e){return t=Math.pow(t,e),n=Math.pow(n,e)-t,e=1/e,function(r){return Math.pow(t+r*n,e)}}(n,e,t):kr(isNaN(n)?e:n)}}function $r(t,n){var e=n-t;return e?Cr(t,e):kr(isNaN(t)?n:t)}var Dr=function t(n){var e=zr(n);function r(t,n){var r=e((t=Fe(t)).r,(n=Fe(n)).r),i=e(t.g,n.g),o=e(t.b,n.b),a=$r(t.opacity,n.opacity);return function(n){return t.r=r(n),t.g=i(n),t.b=o(n),t.opacity=a(n),t+""}}return r.gamma=t,r}(1);function Rr(t){return function(n){var e,r,i=n.length,o=new Array(i),a=new Array(i),u=new Array(i);for(e=0;eo&&(i=n.slice(o,i),u[a]?u[a]+=i:u[++a]=i),(e=e[0])===(r=r[0])?u[a]?u[a]+=r:u[++a]=r:(u[++a]=null,c.push({i:a,x:Yr(e,r)})),o=Hr.lastIndex;return o180?n+=360:n-t>180&&(t+=360),o.push({i:e.push(i(e)+"rotate(",null,r)-2,x:Yr(t,n)})):n&&e.push(i(e)+"rotate("+n+r)}(o.rotate,a.rotate,u,c),function(t,n,e,o){t!==n?o.push({i:e.push(i(e)+"skewX(",null,r)-2,x:Yr(t,n)}):n&&e.push(i(e)+"skewX("+n+r)}(o.skewX,a.skewX,u,c),function(t,n,e,r,o,a){if(t!==e||n!==r){var u=o.push(i(o)+"scale(",null,",",null,")");a.push({i:u-4,x:Yr(t,e)},{i:u-2,x:Yr(n,r)})}else 1===e&&1===r||o.push(i(o)+"scale("+e+","+r+")")}(o.scaleX,o.scaleY,a.scaleX,a.scaleY,u,c),o=a=null,function(t){for(var n,e=-1,r=c.length;++e=0&&n._call.call(void 0,t),n=n._next;--yi}function Ci(){xi=(mi=Mi.now())+wi,yi=vi=0;try{ki()}finally{yi=0,function(){var t,n,e=pi,r=1/0;for(;e;)e._call?(r>e._time&&(r=e._time),t=e,e=e._next):(n=e._next,e._next=null,e=t?t._next=n:pi=n);gi=t,zi(r)}(),xi=0}}function Pi(){var t=Mi.now(),n=t-mi;n>bi&&(wi-=n,mi=t)}function zi(t){yi||(vi&&(vi=clearTimeout(vi)),t-xi>24?(t<1/0&&(vi=setTimeout(Ci,t-Mi.now()-wi)),_i&&(_i=clearInterval(_i))):(_i||(mi=Mi.now(),_i=setInterval(Pi,bi)),yi=1,Ti(Ci)))}function $i(t,n,e){var r=new Ei;return n=null==n?0:+n,r.restart((e=>{r.stop(),t(e+n)}),n,e),r}Ei.prototype=Ni.prototype={constructor:Ei,restart:function(t,n,e){if("function"!=typeof t)throw new TypeError("callback is not a function");e=(null==e?Ai():+e)+(null==n?0:+n),this._next||gi===this||(gi?gi._next=this:pi=this,gi=this),this._call=t,this._time=e,zi()},stop:function(){this._call&&(this._call=null,this._time=1/0,zi())}};var Di=$t("start","end","cancel","interrupt"),Ri=[],Fi=0,qi=1,Ui=2,Ii=3,Oi=4,Bi=5,Yi=6;function Li(t,n,e,r,i,o){var a=t.__transition;if(a){if(e in a)return}else t.__transition={};!function(t,n,e){var r,i=t.__transition;function o(t){e.state=qi,e.timer.restart(a,e.delay,e.time),e.delay<=t&&a(t-e.delay)}function a(o){var f,s,l,h;if(e.state!==qi)return c();for(f in i)if((h=i[f]).name===e.name){if(h.state===Ii)return $i(a);h.state===Oi?(h.state=Yi,h.timer.stop(),h.on.call("interrupt",t,t.__data__,h.index,h.group),delete i[f]):+fFi)throw new Error("too late; already scheduled");return e}function Hi(t,n){var e=Xi(t,n);if(e.state>Ii)throw new Error("too late; already running");return e}function Xi(t,n){var e=t.__transition;if(!e||!(e=e[n]))throw new Error("transition not found");return e}function Gi(t,n){var e,r,i,o=t.__transition,a=!0;if(o){for(i in n=null==n?null:n+"",o)(e=o[i]).name===n?(r=e.state>Ui&&e.state=0&&(t=t.slice(0,n)),!t||"start"===t}))}(n)?ji:Hi;return function(){var a=o(this,t),u=a.on;u!==r&&(i=(r=u).copy()).on(n,e),a.on=i}}(e,t,n))},attr:function(t,n){var e=It(t),r="transform"===e?ni:Ki;return this.attrTween(t,"function"==typeof n?(e.local?ro:eo)(e,r,Zi(this,"attr."+t,n)):null==n?(e.local?Ji:Qi)(e):(e.local?no:to)(e,r,n))},attrTween:function(t,n){var e="attr."+t;if(arguments.length<2)return(e=this.tween(e))&&e._value;if(null==n)return this.tween(e,null);if("function"!=typeof n)throw new Error;var r=It(t);return this.tween(e,(r.local?io:oo)(r,n))},style:function(t,n,e){var r="transform"==(t+="")?ti:Ki;return null==n?this.styleTween(t,function(t,n){var e,r,i;return function(){var o=_n(this,t),a=(this.style.removeProperty(t),_n(this,t));return o===a?null:o===e&&a===r?i:i=n(e=o,r=a)}}(t,r)).on("end.style."+t,lo(t)):"function"==typeof n?this.styleTween(t,function(t,n,e){var r,i,o;return function(){var a=_n(this,t),u=e(this),c=u+"";return null==u&&(this.style.removeProperty(t),c=u=_n(this,t)),a===c?null:a===r&&c===i?o:(i=c,o=n(r=a,u))}}(t,r,Zi(this,"style."+t,n))).each(function(t,n){var e,r,i,o,a="style."+n,u="end."+a;return function(){var c=Hi(this,t),f=c.on,s=null==c.value[a]?o||(o=lo(n)):void 0;f===e&&i===s||(r=(e=f).copy()).on(u,i=s),c.on=r}}(this._id,t)):this.styleTween(t,function(t,n,e){var r,i,o=e+"";return function(){var a=_n(this,t);return a===o?null:a===r?i:i=n(r=a,e)}}(t,r,n),e).on("end.style."+t,null)},styleTween:function(t,n,e){var r="style."+(t+="");if(arguments.length<2)return(r=this.tween(r))&&r._value;if(null==n)return this.tween(r,null);if("function"!=typeof n)throw new Error;return this.tween(r,function(t,n,e){var r,i;function o(){var o=n.apply(this,arguments);return o!==i&&(r=(i=o)&&function(t,n,e){return function(r){this.style.setProperty(t,n.call(this,r),e)}}(t,o,e)),r}return o._value=n,o}(t,n,null==e?"":e))},text:function(t){return this.tween("text","function"==typeof t?function(t){return function(){var n=t(this);this.textContent=null==n?"":n}}(Zi(this,"text",t)):function(t){return function(){this.textContent=t}}(null==t?"":t+""))},textTween:function(t){var n="text";if(arguments.length<1)return(n=this.tween(n))&&n._value;if(null==t)return this.tween(n,null);if("function"!=typeof t)throw new Error;return this.tween(n,function(t){var n,e;function r(){var r=t.apply(this,arguments);return r!==e&&(n=(e=r)&&function(t){return function(n){this.textContent=t.call(this,n)}}(r)),n}return r._value=t,r}(t))},remove:function(){return this.on("end.remove",function(t){return function(){var n=this.parentNode;for(var e in this.__transition)if(+e!==t)return;n&&n.removeChild(this)}}(this._id))},tween:function(t,n){var e=this._id;if(t+="",arguments.length<2){for(var r,i=Xi(this.node(),e).tween,o=0,a=i.length;o()=>t;function Qo(t,{sourceEvent:n,target:e,selection:r,mode:i,dispatch:o}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:n,enumerable:!0,configurable:!0},target:{value:e,enumerable:!0,configurable:!0},selection:{value:r,enumerable:!0,configurable:!0},mode:{value:i,enumerable:!0,configurable:!0},_:{value:o}})}function Jo(t){t.preventDefault(),t.stopImmediatePropagation()}var ta={name:"drag"},na={name:"space"},ea={name:"handle"},ra={name:"center"};const{abs:ia,max:oa,min:aa}=Math;function ua(t){return[+t[0],+t[1]]}function ca(t){return[ua(t[0]),ua(t[1])]}var fa={name:"x",handles:["w","e"].map(va),input:function(t,n){return null==t?null:[[+t[0],n[0][1]],[+t[1],n[1][1]]]},output:function(t){return t&&[t[0][0],t[1][0]]}},sa={name:"y",handles:["n","s"].map(va),input:function(t,n){return null==t?null:[[n[0][0],+t[0]],[n[1][0],+t[1]]]},output:function(t){return t&&[t[0][1],t[1][1]]}},la={name:"xy",handles:["n","w","e","s","nw","ne","sw","se"].map(va),input:function(t){return null==t?null:ca(t)},output:function(t){return t}},ha={overlay:"crosshair",selection:"move",n:"ns-resize",e:"ew-resize",s:"ns-resize",w:"ew-resize",nw:"nwse-resize",ne:"nesw-resize",se:"nwse-resize",sw:"nesw-resize"},da={e:"w",w:"e",nw:"ne",ne:"nw",se:"sw",sw:"se"},pa={n:"s",s:"n",nw:"sw",ne:"se",se:"ne",sw:"nw"},ga={overlay:1,selection:1,n:null,e:1,s:null,w:-1,nw:-1,ne:1,se:1,sw:-1},ya={overlay:1,selection:1,n:-1,e:null,s:1,w:null,nw:-1,ne:-1,se:1,sw:1};function va(t){return{type:t}}function _a(t){return!t.ctrlKey&&!t.button}function ba(){var t=this.ownerSVGElement||this;return t.hasAttribute("viewBox")?[[(t=t.viewBox.baseVal).x,t.y],[t.x+t.width,t.y+t.height]]:[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]}function ma(){return navigator.maxTouchPoints||"ontouchstart"in this}function xa(t){for(;!t.__brush;)if(!(t=t.parentNode))return;return t.__brush}function wa(t){var n,e=ba,r=_a,i=ma,o=!0,a=$t("start","brush","end"),u=6;function c(n){var e=n.property("__brush",g).selectAll(".overlay").data([va("overlay")]);e.enter().append("rect").attr("class","overlay").attr("pointer-events","all").attr("cursor",ha.overlay).merge(e).each((function(){var t=xa(this).extent;Zn(this).attr("x",t[0][0]).attr("y",t[0][1]).attr("width",t[1][0]-t[0][0]).attr("height",t[1][1]-t[0][1])})),n.selectAll(".selection").data([va("selection")]).enter().append("rect").attr("class","selection").attr("cursor",ha.selection).attr("fill","#777").attr("fill-opacity",.3).attr("stroke","#fff").attr("shape-rendering","crispEdges");var r=n.selectAll(".handle").data(t.handles,(function(t){return t.type}));r.exit().remove(),r.enter().append("rect").attr("class",(function(t){return"handle handle--"+t.type})).attr("cursor",(function(t){return ha[t.type]})),n.each(f).attr("fill","none").attr("pointer-events","all").on("mousedown.brush",h).filter(i).on("touchstart.brush",h).on("touchmove.brush",d).on("touchend.brush touchcancel.brush",p).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function f(){var t=Zn(this),n=xa(this).selection;n?(t.selectAll(".selection").style("display",null).attr("x",n[0][0]).attr("y",n[0][1]).attr("width",n[1][0]-n[0][0]).attr("height",n[1][1]-n[0][1]),t.selectAll(".handle").style("display",null).attr("x",(function(t){return"e"===t.type[t.type.length-1]?n[1][0]-u/2:n[0][0]-u/2})).attr("y",(function(t){return"s"===t.type[0]?n[1][1]-u/2:n[0][1]-u/2})).attr("width",(function(t){return"n"===t.type||"s"===t.type?n[1][0]-n[0][0]+u:u})).attr("height",(function(t){return"e"===t.type||"w"===t.type?n[1][1]-n[0][1]+u:u}))):t.selectAll(".selection,.handle").style("display","none").attr("x",null).attr("y",null).attr("width",null).attr("height",null)}function s(t,n,e){var r=t.__brush.emitter;return!r||e&&r.clean?new l(t,n,e):r}function l(t,n,e){this.that=t,this.args=n,this.state=t.__brush,this.active=0,this.clean=e}function h(e){if((!n||e.touches)&&r.apply(this,arguments)){var i,a,u,c,l,h,d,p,g,y,v,_=this,b=e.target.__data__.type,m="selection"===(o&&e.metaKey?b="overlay":b)?ta:o&&e.altKey?ra:ea,x=t===sa?null:ga[b],w=t===fa?null:ya[b],M=xa(_),T=M.extent,A=M.selection,S=T[0][0],E=T[0][1],N=T[1][0],k=T[1][1],C=0,P=0,z=x&&w&&o&&e.shiftKey,$=Array.from(e.touches||[e],(t=>{const n=t.identifier;return(t=ne(t,_)).point0=t.slice(),t.identifier=n,t}));Gi(_);var D=s(_,arguments,!0).beforestart();if("overlay"===b){A&&(g=!0);const n=[$[0],$[1]||$[0]];M.selection=A=[[i=t===sa?S:aa(n[0][0],n[1][0]),u=t===fa?E:aa(n[0][1],n[1][1])],[l=t===sa?N:oa(n[0][0],n[1][0]),d=t===fa?k:oa(n[0][1],n[1][1])]],$.length>1&&I(e)}else i=A[0][0],u=A[0][1],l=A[1][0],d=A[1][1];a=i,c=u,h=l,p=d;var R=Zn(_).attr("pointer-events","none"),F=R.selectAll(".overlay").attr("cursor",ha[b]);if(e.touches)D.moved=U,D.ended=O;else{var q=Zn(e.view).on("mousemove.brush",U,!0).on("mouseup.brush",O,!0);o&&q.on("keydown.brush",(function(t){switch(t.keyCode){case 16:z=x&&w;break;case 18:m===ea&&(x&&(l=h-C*x,i=a+C*x),w&&(d=p-P*w,u=c+P*w),m=ra,I(t));break;case 32:m!==ea&&m!==ra||(x<0?l=h-C:x>0&&(i=a-C),w<0?d=p-P:w>0&&(u=c-P),m=na,F.attr("cursor",ha.selection),I(t));break;default:return}Jo(t)}),!0).on("keyup.brush",(function(t){switch(t.keyCode){case 16:z&&(y=v=z=!1,I(t));break;case 18:m===ra&&(x<0?l=h:x>0&&(i=a),w<0?d=p:w>0&&(u=c),m=ea,I(t));break;case 32:m===na&&(t.altKey?(x&&(l=h-C*x,i=a+C*x),w&&(d=p-P*w,u=c+P*w),m=ra):(x<0?l=h:x>0&&(i=a),w<0?d=p:w>0&&(u=c),m=ea),F.attr("cursor",ha[b]),I(t));break;default:return}Jo(t)}),!0),ae(e.view)}f.call(_),D.start(e,m.name)}function U(t){for(const n of t.changedTouches||[t])for(const t of $)t.identifier===n.identifier&&(t.cur=ne(n,_));if(z&&!y&&!v&&1===$.length){const t=$[0];ia(t.cur[0]-t[0])>ia(t.cur[1]-t[1])?v=!0:y=!0}for(const t of $)t.cur&&(t[0]=t.cur[0],t[1]=t.cur[1]);g=!0,Jo(t),I(t)}function I(t){const n=$[0],e=n.point0;var r;switch(C=n[0]-e[0],P=n[1]-e[1],m){case na:case ta:x&&(C=oa(S-i,aa(N-l,C)),a=i+C,h=l+C),w&&(P=oa(E-u,aa(k-d,P)),c=u+P,p=d+P);break;case ea:$[1]?(x&&(a=oa(S,aa(N,$[0][0])),h=oa(S,aa(N,$[1][0])),x=1),w&&(c=oa(E,aa(k,$[0][1])),p=oa(E,aa(k,$[1][1])),w=1)):(x<0?(C=oa(S-i,aa(N-i,C)),a=i+C,h=l):x>0&&(C=oa(S-l,aa(N-l,C)),a=i,h=l+C),w<0?(P=oa(E-u,aa(k-u,P)),c=u+P,p=d):w>0&&(P=oa(E-d,aa(k-d,P)),c=u,p=d+P));break;case ra:x&&(a=oa(S,aa(N,i-C*x)),h=oa(S,aa(N,l+C*x))),w&&(c=oa(E,aa(k,u-P*w)),p=oa(E,aa(k,d+P*w)))}ht+e))}function za(t,n){var e=0,r=null,i=null,o=null;function a(a){var u,c=a.length,f=new Array(c),s=Pa(0,c),l=new Array(c*c),h=new Array(c),d=0;a=Float64Array.from({length:c*c},n?(t,n)=>a[n%c][n/c|0]:(t,n)=>a[n/c|0][n%c]);for(let n=0;nr(f[t],f[n])));for(const e of s){const r=n;if(t){const t=Pa(1+~c,c).filter((t=>t<0?a[~t*c+e]:a[e*c+t]));i&&t.sort(((t,n)=>i(t<0?-a[~t*c+e]:a[e*c+t],n<0?-a[~n*c+e]:a[e*c+n])));for(const r of t)if(r<0){(l[~r*c+e]||(l[~r*c+e]={source:null,target:null})).target={index:e,startAngle:n,endAngle:n+=a[~r*c+e]*d,value:a[~r*c+e]}}else{(l[e*c+r]||(l[e*c+r]={source:null,target:null})).source={index:e,startAngle:n,endAngle:n+=a[e*c+r]*d,value:a[e*c+r]}}h[e]={index:e,startAngle:r,endAngle:n,value:f[e]}}else{const t=Pa(0,c).filter((t=>a[e*c+t]||a[t*c+e]));i&&t.sort(((t,n)=>i(a[e*c+t],a[e*c+n])));for(const r of t){let t;if(e=0))throw new Error(`invalid digits: ${t}`);if(n>15)return qa;const e=10**n;return function(t){this._+=t[0];for(let n=1,r=t.length;nRa)if(Math.abs(s*u-c*f)>Ra&&i){let h=e-o,d=r-a,p=u*u+c*c,g=h*h+d*d,y=Math.sqrt(p),v=Math.sqrt(l),_=i*Math.tan(($a-Math.acos((p+l-g)/(2*y*v)))/2),b=_/v,m=_/y;Math.abs(b-1)>Ra&&this._append`L${t+b*f},${n+b*s}`,this._append`A${i},${i},0,0,${+(s*h>f*d)},${this._x1=t+m*u},${this._y1=n+m*c}`}else this._append`L${this._x1=t},${this._y1=n}`;else;}arc(t,n,e,r,i,o){if(t=+t,n=+n,o=!!o,(e=+e)<0)throw new Error(`negative radius: ${e}`);let a=e*Math.cos(r),u=e*Math.sin(r),c=t+a,f=n+u,s=1^o,l=o?r-i:i-r;null===this._x1?this._append`M${c},${f}`:(Math.abs(this._x1-c)>Ra||Math.abs(this._y1-f)>Ra)&&this._append`L${c},${f}`,e&&(l<0&&(l=l%Da+Da),l>Fa?this._append`A${e},${e},0,1,${s},${t-a},${n-u}A${e},${e},0,1,${s},${this._x1=c},${this._y1=f}`:l>Ra&&this._append`A${e},${e},0,${+(l>=$a)},${s},${this._x1=t+e*Math.cos(i)},${this._y1=n+e*Math.sin(i)}`)}rect(t,n,e,r){this._append`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}h${e=+e}v${+r}h${-e}Z`}toString(){return this._}};function Ia(){return new Ua}Ia.prototype=Ua.prototype;var Oa=Array.prototype.slice;function Ba(t){return function(){return t}}function Ya(t){return t.source}function La(t){return t.target}function ja(t){return t.radius}function Ha(t){return t.startAngle}function Xa(t){return t.endAngle}function Ga(){return 0}function Va(){return 10}function Wa(t){var n=Ya,e=La,r=ja,i=ja,o=Ha,a=Xa,u=Ga,c=null;function f(){var f,s=n.apply(this,arguments),l=e.apply(this,arguments),h=u.apply(this,arguments)/2,d=Oa.call(arguments),p=+r.apply(this,(d[0]=s,d)),g=o.apply(this,d)-Ea,y=a.apply(this,d)-Ea,v=+i.apply(this,(d[0]=l,d)),_=o.apply(this,d)-Ea,b=a.apply(this,d)-Ea;if(c||(c=f=Ia()),h>Ca&&(Ma(y-g)>2*h+Ca?y>g?(g+=h,y-=h):(g-=h,y+=h):g=y=(g+y)/2,Ma(b-_)>2*h+Ca?b>_?(_+=h,b-=h):(_-=h,b+=h):_=b=(_+b)/2),c.moveTo(p*Ta(g),p*Aa(g)),c.arc(0,0,p,g,y),g!==_||y!==b)if(t){var m=v-+t.apply(this,arguments),x=(_+b)/2;c.quadraticCurveTo(0,0,m*Ta(_),m*Aa(_)),c.lineTo(v*Ta(x),v*Aa(x)),c.lineTo(m*Ta(b),m*Aa(b))}else c.quadraticCurveTo(0,0,v*Ta(_),v*Aa(_)),c.arc(0,0,v,_,b);if(c.quadraticCurveTo(0,0,p*Ta(g),p*Aa(g)),c.closePath(),f)return c=null,f+""||null}return t&&(f.headRadius=function(n){return arguments.length?(t="function"==typeof n?n:Ba(+n),f):t}),f.radius=function(t){return arguments.length?(r=i="function"==typeof t?t:Ba(+t),f):r},f.sourceRadius=function(t){return arguments.length?(r="function"==typeof t?t:Ba(+t),f):r},f.targetRadius=function(t){return arguments.length?(i="function"==typeof t?t:Ba(+t),f):i},f.startAngle=function(t){return arguments.length?(o="function"==typeof t?t:Ba(+t),f):o},f.endAngle=function(t){return arguments.length?(a="function"==typeof t?t:Ba(+t),f):a},f.padAngle=function(t){return arguments.length?(u="function"==typeof t?t:Ba(+t),f):u},f.source=function(t){return arguments.length?(n=t,f):n},f.target=function(t){return arguments.length?(e=t,f):e},f.context=function(t){return arguments.length?(c=null==t?null:t,f):c},f}var Za=Array.prototype.slice;function Ka(t,n){return t-n}var Qa=t=>()=>t;function Ja(t,n){for(var e,r=-1,i=n.length;++rr!=d>r&&e<(h-f)*(r-s)/(d-s)+f&&(i=-i)}return i}function nu(t,n,e){var r,i,o,a;return function(t,n,e){return(n[0]-t[0])*(e[1]-t[1])==(e[0]-t[0])*(n[1]-t[1])}(t,n,e)&&(i=t[r=+(t[0]===n[0])],o=e[r],a=n[r],i<=o&&o<=a||a<=o&&o<=i)}function eu(){}var ru=[[],[[[1,1.5],[.5,1]]],[[[1.5,1],[1,1.5]]],[[[1.5,1],[.5,1]]],[[[1,.5],[1.5,1]]],[[[1,1.5],[.5,1]],[[1,.5],[1.5,1]]],[[[1,.5],[1,1.5]]],[[[1,.5],[.5,1]]],[[[.5,1],[1,.5]]],[[[1,1.5],[1,.5]]],[[[.5,1],[1,.5]],[[1.5,1],[1,1.5]]],[[[1.5,1],[1,.5]]],[[[.5,1],[1.5,1]]],[[[1,1.5],[1.5,1]]],[[[.5,1],[1,1.5]]],[]];function iu(){var t=1,n=1,e=K,r=u;function i(t){var n=e(t);if(Array.isArray(n))n=n.slice().sort(Ka);else{const e=M(t,ou);for(n=G(...Z(e[0],e[1],n),n);n[n.length-1]>=e[1];)n.pop();for(;n[1]o(t,n)))}function o(e,i){const o=null==i?NaN:+i;if(isNaN(o))throw new Error(`invalid value: ${i}`);var u=[],c=[];return function(e,r,i){var o,u,c,f,s,l,h=new Array,d=new Array;o=u=-1,f=au(e[0],r),ru[f<<1].forEach(p);for(;++o=r,ru[s<<2].forEach(p);for(;++o0?u.push([t]):c.push(t)})),c.forEach((function(t){for(var n,e=0,r=u.length;e0&&o0&&a=0&&o>=0))throw new Error("invalid size");return t=r,n=o,i},i.thresholds=function(t){return arguments.length?(e="function"==typeof t?t:Array.isArray(t)?Qa(Za.call(t)):Qa(t),i):e},i.smooth=function(t){return arguments.length?(r=t?u:eu,i):r===u},i}function ou(t){return isFinite(t)?t:NaN}function au(t,n){return null!=t&&+t>=n}function uu(t){return null==t||isNaN(t=+t)?-1/0:t}function cu(t,n,e,r){const i=r-n,o=e-n,a=isFinite(i)||isFinite(o)?i/o:Math.sign(i)/Math.sign(o);return isNaN(a)?t:t+a-.5}function fu(t){return t[0]}function su(t){return t[1]}function lu(){return 1}const hu=134217729,du=33306690738754706e-32;function pu(t,n,e,r,i){let o,a,u,c,f=n[0],s=r[0],l=0,h=0;s>f==s>-f?(o=f,f=n[++l]):(o=s,s=r[++h]);let d=0;if(lf==s>-f?(a=f+o,u=o-(a-f),f=n[++l]):(a=s+o,u=o-(a-s),s=r[++h]),o=a,0!==u&&(i[d++]=u);lf==s>-f?(a=o+f,c=a-o,u=o-(a-c)+(f-c),f=n[++l]):(a=o+s,c=a-o,u=o-(a-c)+(s-c),s=r[++h]),o=a,0!==u&&(i[d++]=u);for(;l=33306690738754716e-32*f?c:-function(t,n,e,r,i,o,a){let u,c,f,s,l,h,d,p,g,y,v,_,b,m,x,w,M,T;const A=t-i,S=e-i,E=n-o,N=r-o;m=A*N,h=hu*A,d=h-(h-A),p=A-d,h=hu*N,g=h-(h-N),y=N-g,x=p*y-(m-d*g-p*g-d*y),w=E*S,h=hu*E,d=h-(h-E),p=E-d,h=hu*S,g=h-(h-S),y=S-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,_u[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,_u[1]=b-(v+l)+(l-w),T=_+v,l=T-_,_u[2]=_-(T-l)+(v-l),_u[3]=T;let k=function(t,n){let e=n[0];for(let r=1;r=C||-k>=C)return k;if(l=t-A,u=t-(A+l)+(l-i),l=e-S,f=e-(S+l)+(l-i),l=n-E,c=n-(E+l)+(l-o),l=r-N,s=r-(N+l)+(l-o),0===u&&0===c&&0===f&&0===s)return k;if(C=vu*a+du*Math.abs(k),k+=A*s+N*u-(E*f+S*c),k>=C||-k>=C)return k;m=u*N,h=hu*u,d=h-(h-u),p=u-d,h=hu*N,g=h-(h-N),y=N-g,x=p*y-(m-d*g-p*g-d*y),w=c*S,h=hu*c,d=h-(h-c),p=c-d,h=hu*S,g=h-(h-S),y=S-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,wu[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,wu[1]=b-(v+l)+(l-w),T=_+v,l=T-_,wu[2]=_-(T-l)+(v-l),wu[3]=T;const P=pu(4,_u,4,wu,bu);m=A*s,h=hu*A,d=h-(h-A),p=A-d,h=hu*s,g=h-(h-s),y=s-g,x=p*y-(m-d*g-p*g-d*y),w=E*f,h=hu*E,d=h-(h-E),p=E-d,h=hu*f,g=h-(h-f),y=f-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,wu[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,wu[1]=b-(v+l)+(l-w),T=_+v,l=T-_,wu[2]=_-(T-l)+(v-l),wu[3]=T;const z=pu(P,bu,4,wu,mu);m=u*s,h=hu*u,d=h-(h-u),p=u-d,h=hu*s,g=h-(h-s),y=s-g,x=p*y-(m-d*g-p*g-d*y),w=c*f,h=hu*c,d=h-(h-c),p=c-d,h=hu*f,g=h-(h-f),y=f-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,wu[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,wu[1]=b-(v+l)+(l-w),T=_+v,l=T-_,wu[2]=_-(T-l)+(v-l),wu[3]=T;const $=pu(z,mu,4,wu,xu);return xu[$-1]}(t,n,e,r,i,o,f)}const Tu=Math.pow(2,-52),Au=new Uint32Array(512);class Su{static from(t,n=zu,e=$u){const r=t.length,i=new Float64Array(2*r);for(let o=0;o>1;if(n>0&&"number"!=typeof t[0])throw new Error("Expected coords to contain numbers.");this.coords=t;const e=Math.max(2*n-5,0);this._triangles=new Uint32Array(3*e),this._halfedges=new Int32Array(3*e),this._hashSize=Math.ceil(Math.sqrt(n)),this._hullPrev=new Uint32Array(n),this._hullNext=new Uint32Array(n),this._hullTri=new Uint32Array(n),this._hullHash=new Int32Array(this._hashSize),this._ids=new Uint32Array(n),this._dists=new Float64Array(n),this.update()}update(){const{coords:t,_hullPrev:n,_hullNext:e,_hullTri:r,_hullHash:i}=this,o=t.length>>1;let a=1/0,u=1/0,c=-1/0,f=-1/0;for(let n=0;nc&&(c=e),r>f&&(f=r),this._ids[n]=n}const s=(a+c)/2,l=(u+f)/2;let h,d,p;for(let n=0,e=1/0;n0&&(d=n,e=r)}let v=t[2*d],_=t[2*d+1],b=1/0;for(let n=0;nr&&(n[e++]=i,r=o)}return this.hull=n.subarray(0,e),this.triangles=new Uint32Array(0),void(this.halfedges=new Uint32Array(0))}if(Mu(g,y,v,_,m,x)<0){const t=d,n=v,e=_;d=p,v=m,_=x,p=t,m=n,x=e}const w=function(t,n,e,r,i,o){const a=e-t,u=r-n,c=i-t,f=o-n,s=a*a+u*u,l=c*c+f*f,h=.5/(a*f-u*c),d=t+(f*s-u*l)*h,p=n+(a*l-c*s)*h;return{x:d,y:p}}(g,y,v,_,m,x);this._cx=w.x,this._cy=w.y;for(let n=0;n0&&Math.abs(f-o)<=Tu&&Math.abs(s-a)<=Tu)continue;if(o=f,a=s,c===h||c===d||c===p)continue;let l=0;for(let t=0,n=this._hashKey(f,s);t=0;)if(y=g,y===l){y=-1;break}if(-1===y)continue;let v=this._addTriangle(y,c,e[y],-1,-1,r[y]);r[c]=this._legalize(v+2),r[y]=v,M++;let _=e[y];for(;g=e[_],Mu(f,s,t[2*_],t[2*_+1],t[2*g],t[2*g+1])<0;)v=this._addTriangle(_,c,g,r[c],-1,r[_]),r[c]=this._legalize(v+2),e[_]=_,M--,_=g;if(y===l)for(;g=n[y],Mu(f,s,t[2*g],t[2*g+1],t[2*y],t[2*y+1])<0;)v=this._addTriangle(g,c,y,-1,r[y],r[g]),this._legalize(v+2),r[g]=v,e[y]=y,M--,y=g;this._hullStart=n[c]=y,e[y]=n[_]=c,e[c]=_,i[this._hashKey(f,s)]=c,i[this._hashKey(t[2*y],t[2*y+1])]=y}this.hull=new Uint32Array(M);for(let t=0,n=this._hullStart;t0?3-e:1+e)/4}(t-this._cx,n-this._cy)*this._hashSize)%this._hashSize}_legalize(t){const{_triangles:n,_halfedges:e,coords:r}=this;let i=0,o=0;for(;;){const a=e[t],u=t-t%3;if(o=u+(t+2)%3,-1===a){if(0===i)break;t=Au[--i];continue}const c=a-a%3,f=u+(t+1)%3,s=c+(a+2)%3,l=n[o],h=n[t],d=n[f],p=n[s];if(Nu(r[2*l],r[2*l+1],r[2*h],r[2*h+1],r[2*d],r[2*d+1],r[2*p],r[2*p+1])){n[t]=p,n[a]=l;const r=e[s];if(-1===r){let n=this._hullStart;do{if(this._hullTri[n]===s){this._hullTri[n]=t;break}n=this._hullPrev[n]}while(n!==this._hullStart)}this._link(t,r),this._link(a,e[o]),this._link(o,s);const u=c+(a+1)%3;i=e&&n[t[a]]>o;)t[a+1]=t[a--];t[a+1]=r}else{let i=e+1,o=r;Pu(t,e+r>>1,i),n[t[e]]>n[t[r]]&&Pu(t,e,r),n[t[i]]>n[t[r]]&&Pu(t,i,r),n[t[e]]>n[t[i]]&&Pu(t,e,i);const a=t[i],u=n[a];for(;;){do{i++}while(n[t[i]]u);if(o=o-e?(Cu(t,n,i,r),Cu(t,n,e,o-1)):(Cu(t,n,e,o-1),Cu(t,n,i,r))}}function Pu(t,n,e){const r=t[n];t[n]=t[e],t[e]=r}function zu(t){return t[0]}function $u(t){return t[1]}const Du=1e-6;class Ru{constructor(){this._x0=this._y0=this._x1=this._y1=null,this._=""}moveTo(t,n){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}`}closePath(){null!==this._x1&&(this._x1=this._x0,this._y1=this._y0,this._+="Z")}lineTo(t,n){this._+=`L${this._x1=+t},${this._y1=+n}`}arc(t,n,e){const r=(t=+t)+(e=+e),i=n=+n;if(e<0)throw new Error("negative radius");null===this._x1?this._+=`M${r},${i}`:(Math.abs(this._x1-r)>Du||Math.abs(this._y1-i)>Du)&&(this._+="L"+r+","+i),e&&(this._+=`A${e},${e},0,1,1,${t-e},${n}A${e},${e},0,1,1,${this._x1=r},${this._y1=i}`)}rect(t,n,e,r){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}h${+e}v${+r}h${-e}Z`}value(){return this._||null}}class Fu{constructor(){this._=[]}moveTo(t,n){this._.push([t,n])}closePath(){this._.push(this._[0].slice())}lineTo(t,n){this._.push([t,n])}value(){return this._.length?this._:null}}class qu{constructor(t,[n,e,r,i]=[0,0,960,500]){if(!((r=+r)>=(n=+n)&&(i=+i)>=(e=+e)))throw new Error("invalid bounds");this.delaunay=t,this._circumcenters=new Float64Array(2*t.points.length),this.vectors=new Float64Array(2*t.points.length),this.xmax=r,this.xmin=n,this.ymax=i,this.ymin=e,this._init()}update(){return this.delaunay.update(),this._init(),this}_init(){const{delaunay:{points:t,hull:n,triangles:e},vectors:r}=this;let i,o;const a=this.circumcenters=this._circumcenters.subarray(0,e.length/3*2);for(let r,u,c=0,f=0,s=e.length;c1;)i-=2;for(let t=2;t0){if(n>=this.ymax)return null;(i=(this.ymax-n)/r)0){if(t>=this.xmax)return null;(i=(this.xmax-t)/e)this.xmax?2:0)|(nthis.ymax?8:0)}_simplify(t){if(t&&t.length>4){for(let n=0;n2&&function(t){const{triangles:n,coords:e}=t;for(let t=0;t1e-10)return!1}return!0}(t)){this.collinear=Int32Array.from({length:n.length/2},((t,n)=>n)).sort(((t,e)=>n[2*t]-n[2*e]||n[2*t+1]-n[2*e+1]));const t=this.collinear[0],e=this.collinear[this.collinear.length-1],r=[n[2*t],n[2*t+1],n[2*e],n[2*e+1]],i=1e-8*Math.hypot(r[3]-r[1],r[2]-r[0]);for(let t=0,e=n.length/2;t0&&(this.triangles=new Int32Array(3).fill(-1),this.halfedges=new Int32Array(3).fill(-1),this.triangles[0]=r[0],o[r[0]]=1,2===r.length&&(o[r[1]]=0,this.triangles[1]=r[1],this.triangles[2]=r[1]))}voronoi(t){return new qu(this,t)}*neighbors(t){const{inedges:n,hull:e,_hullIndex:r,halfedges:i,triangles:o,collinear:a}=this;if(a){const n=a.indexOf(t);return n>0&&(yield a[n-1]),void(n=0&&i!==e&&i!==r;)e=i;return i}_step(t,n,e){const{inedges:r,hull:i,_hullIndex:o,halfedges:a,triangles:u,points:c}=this;if(-1===r[t]||!c.length)return(t+1)%(c.length>>1);let f=t,s=Iu(n-c[2*t],2)+Iu(e-c[2*t+1],2);const l=r[t];let h=l;do{let r=u[h];const l=Iu(n-c[2*r],2)+Iu(e-c[2*r+1],2);if(l9999?"+"+Ku(n,6):Ku(n,4))+"-"+Ku(t.getUTCMonth()+1,2)+"-"+Ku(t.getUTCDate(),2)+(o?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"."+Ku(o,3)+"Z":i?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"Z":r||e?"T"+Ku(e,2)+":"+Ku(r,2)+"Z":"")}function Ju(t){var n=new RegExp('["'+t+"\n\r]"),e=t.charCodeAt(0);function r(t,n){var r,i=[],o=t.length,a=0,u=0,c=o<=0,f=!1;function s(){if(c)return Hu;if(f)return f=!1,ju;var n,r,i=a;if(t.charCodeAt(i)===Xu){for(;a++=o?c=!0:(r=t.charCodeAt(a++))===Gu?f=!0:r===Vu&&(f=!0,t.charCodeAt(a)===Gu&&++a),t.slice(i+1,n-1).replace(/""/g,'"')}for(;amc(n,e).then((n=>(new DOMParser).parseFromString(n,t)))}var Sc=Ac("application/xml"),Ec=Ac("text/html"),Nc=Ac("image/svg+xml");function kc(t,n,e,r){if(isNaN(n)||isNaN(e))return t;var i,o,a,u,c,f,s,l,h,d=t._root,p={data:r},g=t._x0,y=t._y0,v=t._x1,_=t._y1;if(!d)return t._root=p,t;for(;d.length;)if((f=n>=(o=(g+v)/2))?g=o:v=o,(s=e>=(a=(y+_)/2))?y=a:_=a,i=d,!(d=d[l=s<<1|f]))return i[l]=p,t;if(u=+t._x.call(null,d.data),c=+t._y.call(null,d.data),n===u&&e===c)return p.next=d,i?i[l]=p:t._root=p,t;do{i=i?i[l]=new Array(4):t._root=new Array(4),(f=n>=(o=(g+v)/2))?g=o:v=o,(s=e>=(a=(y+_)/2))?y=a:_=a}while((l=s<<1|f)==(h=(c>=a)<<1|u>=o));return i[h]=d,i[l]=p,t}function Cc(t,n,e,r,i){this.node=t,this.x0=n,this.y0=e,this.x1=r,this.y1=i}function Pc(t){return t[0]}function zc(t){return t[1]}function $c(t,n,e){var r=new Dc(null==n?Pc:n,null==e?zc:e,NaN,NaN,NaN,NaN);return null==t?r:r.addAll(t)}function Dc(t,n,e,r,i,o){this._x=t,this._y=n,this._x0=e,this._y0=r,this._x1=i,this._y1=o,this._root=void 0}function Rc(t){for(var n={data:t.data},e=n;t=t.next;)e=e.next={data:t.data};return n}var Fc=$c.prototype=Dc.prototype;function qc(t){return function(){return t}}function Uc(t){return 1e-6*(t()-.5)}function Ic(t){return t.x+t.vx}function Oc(t){return t.y+t.vy}function Bc(t){return t.index}function Yc(t,n){var e=t.get(n);if(!e)throw new Error("node not found: "+n);return e}Fc.copy=function(){var t,n,e=new Dc(this._x,this._y,this._x0,this._y0,this._x1,this._y1),r=this._root;if(!r)return e;if(!r.length)return e._root=Rc(r),e;for(t=[{source:r,target:e._root=new Array(4)}];r=t.pop();)for(var i=0;i<4;++i)(n=r.source[i])&&(n.length?t.push({source:n,target:r.target[i]=new Array(4)}):r.target[i]=Rc(n));return e},Fc.add=function(t){const n=+this._x.call(null,t),e=+this._y.call(null,t);return kc(this.cover(n,e),n,e,t)},Fc.addAll=function(t){var n,e,r,i,o=t.length,a=new Array(o),u=new Array(o),c=1/0,f=1/0,s=-1/0,l=-1/0;for(e=0;es&&(s=r),il&&(l=i));if(c>s||f>l)return this;for(this.cover(c,f).cover(s,l),e=0;et||t>=i||r>n||n>=o;)switch(u=(nh||(o=c.y0)>d||(a=c.x1)=v)<<1|t>=y)&&(c=p[p.length-1],p[p.length-1]=p[p.length-1-f],p[p.length-1-f]=c)}else{var _=t-+this._x.call(null,g.data),b=n-+this._y.call(null,g.data),m=_*_+b*b;if(m=(u=(p+y)/2))?p=u:y=u,(s=a>=(c=(g+v)/2))?g=c:v=c,n=d,!(d=d[l=s<<1|f]))return this;if(!d.length)break;(n[l+1&3]||n[l+2&3]||n[l+3&3])&&(e=n,h=l)}for(;d.data!==t;)if(r=d,!(d=d.next))return this;return(i=d.next)&&delete d.next,r?(i?r.next=i:delete r.next,this):n?(i?n[l]=i:delete n[l],(d=n[0]||n[1]||n[2]||n[3])&&d===(n[3]||n[2]||n[1]||n[0])&&!d.length&&(e?e[h]=d:this._root=d),this):(this._root=i,this)},Fc.removeAll=function(t){for(var n=0,e=t.length;n1?r[0]+r.slice(2):r,+t.slice(e+1)]}function Zc(t){return(t=Wc(Math.abs(t)))?t[1]:NaN}var Kc,Qc=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function Jc(t){if(!(n=Qc.exec(t)))throw new Error("invalid format: "+t);var n;return new tf({fill:n[1],align:n[2],sign:n[3],symbol:n[4],zero:n[5],width:n[6],comma:n[7],precision:n[8]&&n[8].slice(1),trim:n[9],type:n[10]})}function tf(t){this.fill=void 0===t.fill?" ":t.fill+"",this.align=void 0===t.align?">":t.align+"",this.sign=void 0===t.sign?"-":t.sign+"",this.symbol=void 0===t.symbol?"":t.symbol+"",this.zero=!!t.zero,this.width=void 0===t.width?void 0:+t.width,this.comma=!!t.comma,this.precision=void 0===t.precision?void 0:+t.precision,this.trim=!!t.trim,this.type=void 0===t.type?"":t.type+""}function nf(t,n){var e=Wc(t,n);if(!e)return t+"";var r=e[0],i=e[1];return i<0?"0."+new Array(-i).join("0")+r:r.length>i+1?r.slice(0,i+1)+"."+r.slice(i+1):r+new Array(i-r.length+2).join("0")}Jc.prototype=tf.prototype,tf.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(void 0===this.width?"":Math.max(1,0|this.width))+(this.comma?",":"")+(void 0===this.precision?"":"."+Math.max(0,0|this.precision))+(this.trim?"~":"")+this.type};var ef={"%":(t,n)=>(100*t).toFixed(n),b:t=>Math.round(t).toString(2),c:t=>t+"",d:function(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString("en").replace(/,/g,""):t.toString(10)},e:(t,n)=>t.toExponential(n),f:(t,n)=>t.toFixed(n),g:(t,n)=>t.toPrecision(n),o:t=>Math.round(t).toString(8),p:(t,n)=>nf(100*t,n),r:nf,s:function(t,n){var e=Wc(t,n);if(!e)return t+"";var r=e[0],i=e[1],o=i-(Kc=3*Math.max(-8,Math.min(8,Math.floor(i/3))))+1,a=r.length;return o===a?r:o>a?r+new Array(o-a+1).join("0"):o>0?r.slice(0,o)+"."+r.slice(o):"0."+new Array(1-o).join("0")+Wc(t,Math.max(0,n+o-1))[0]},X:t=>Math.round(t).toString(16).toUpperCase(),x:t=>Math.round(t).toString(16)};function rf(t){return t}var of,af=Array.prototype.map,uf=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];function cf(t){var n,e,r=void 0===t.grouping||void 0===t.thousands?rf:(n=af.call(t.grouping,Number),e=t.thousands+"",function(t,r){for(var i=t.length,o=[],a=0,u=n[0],c=0;i>0&&u>0&&(c+u+1>r&&(u=Math.max(1,r-c)),o.push(t.substring(i-=u,i+u)),!((c+=u+1)>r));)u=n[a=(a+1)%n.length];return o.reverse().join(e)}),i=void 0===t.currency?"":t.currency[0]+"",o=void 0===t.currency?"":t.currency[1]+"",a=void 0===t.decimal?".":t.decimal+"",u=void 0===t.numerals?rf:function(t){return function(n){return n.replace(/[0-9]/g,(function(n){return t[+n]}))}}(af.call(t.numerals,String)),c=void 0===t.percent?"%":t.percent+"",f=void 0===t.minus?"−":t.minus+"",s=void 0===t.nan?"NaN":t.nan+"";function l(t){var n=(t=Jc(t)).fill,e=t.align,l=t.sign,h=t.symbol,d=t.zero,p=t.width,g=t.comma,y=t.precision,v=t.trim,_=t.type;"n"===_?(g=!0,_="g"):ef[_]||(void 0===y&&(y=12),v=!0,_="g"),(d||"0"===n&&"="===e)&&(d=!0,n="0",e="=");var b="$"===h?i:"#"===h&&/[boxX]/.test(_)?"0"+_.toLowerCase():"",m="$"===h?o:/[%p]/.test(_)?c:"",x=ef[_],w=/[defgprs%]/.test(_);function M(t){var i,o,c,h=b,M=m;if("c"===_)M=x(t)+M,t="";else{var T=(t=+t)<0||1/t<0;if(t=isNaN(t)?s:x(Math.abs(t),y),v&&(t=function(t){t:for(var n,e=t.length,r=1,i=-1;r0&&(i=0)}return i>0?t.slice(0,i)+t.slice(n+1):t}(t)),T&&0==+t&&"+"!==l&&(T=!1),h=(T?"("===l?l:f:"-"===l||"("===l?"":l)+h,M=("s"===_?uf[8+Kc/3]:"")+M+(T&&"("===l?")":""),w)for(i=-1,o=t.length;++i(c=t.charCodeAt(i))||c>57){M=(46===c?a+t.slice(i+1):t.slice(i))+M,t=t.slice(0,i);break}}g&&!d&&(t=r(t,1/0));var A=h.length+t.length+M.length,S=A>1)+h+t+M+S.slice(A);break;default:t=S+h+t+M}return u(t)}return y=void 0===y?6:/[gprs]/.test(_)?Math.max(1,Math.min(21,y)):Math.max(0,Math.min(20,y)),M.toString=function(){return t+""},M}return{format:l,formatPrefix:function(t,n){var e=l(((t=Jc(t)).type="f",t)),r=3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3))),i=Math.pow(10,-r),o=uf[8+r/3];return function(t){return e(i*t)+o}}}}function ff(n){return of=cf(n),t.format=of.format,t.formatPrefix=of.formatPrefix,of}function sf(t){return Math.max(0,-Zc(Math.abs(t)))}function lf(t,n){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3)))-Zc(Math.abs(t)))}function hf(t,n){return t=Math.abs(t),n=Math.abs(n)-t,Math.max(0,Zc(n)-Zc(t))+1}t.format=void 0,t.formatPrefix=void 0,ff({thousands:",",grouping:[3],currency:["$",""]});var df=1e-6,pf=1e-12,gf=Math.PI,yf=gf/2,vf=gf/4,_f=2*gf,bf=180/gf,mf=gf/180,xf=Math.abs,wf=Math.atan,Mf=Math.atan2,Tf=Math.cos,Af=Math.ceil,Sf=Math.exp,Ef=Math.hypot,Nf=Math.log,kf=Math.pow,Cf=Math.sin,Pf=Math.sign||function(t){return t>0?1:t<0?-1:0},zf=Math.sqrt,$f=Math.tan;function Df(t){return t>1?0:t<-1?gf:Math.acos(t)}function Rf(t){return t>1?yf:t<-1?-yf:Math.asin(t)}function Ff(t){return(t=Cf(t/2))*t}function qf(){}function Uf(t,n){t&&Of.hasOwnProperty(t.type)&&Of[t.type](t,n)}var If={Feature:function(t,n){Uf(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r=0?1:-1,i=r*e,o=Tf(n=(n*=mf)/2+vf),a=Cf(n),u=Vf*a,c=Gf*o+u*Tf(i),f=u*r*Cf(i);as.add(Mf(f,c)),Xf=t,Gf=o,Vf=a}function ds(t){return[Mf(t[1],t[0]),Rf(t[2])]}function ps(t){var n=t[0],e=t[1],r=Tf(e);return[r*Tf(n),r*Cf(n),Cf(e)]}function gs(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]}function ys(t,n){return[t[1]*n[2]-t[2]*n[1],t[2]*n[0]-t[0]*n[2],t[0]*n[1]-t[1]*n[0]]}function vs(t,n){t[0]+=n[0],t[1]+=n[1],t[2]+=n[2]}function _s(t,n){return[t[0]*n,t[1]*n,t[2]*n]}function bs(t){var n=zf(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]);t[0]/=n,t[1]/=n,t[2]/=n}var ms,xs,ws,Ms,Ts,As,Ss,Es,Ns,ks,Cs,Ps,zs,$s,Ds,Rs,Fs={point:qs,lineStart:Is,lineEnd:Os,polygonStart:function(){Fs.point=Bs,Fs.lineStart=Ys,Fs.lineEnd=Ls,rs=new T,cs.polygonStart()},polygonEnd:function(){cs.polygonEnd(),Fs.point=qs,Fs.lineStart=Is,Fs.lineEnd=Os,as<0?(Wf=-(Kf=180),Zf=-(Qf=90)):rs>df?Qf=90:rs<-df&&(Zf=-90),os[0]=Wf,os[1]=Kf},sphere:function(){Wf=-(Kf=180),Zf=-(Qf=90)}};function qs(t,n){is.push(os=[Wf=t,Kf=t]),nQf&&(Qf=n)}function Us(t,n){var e=ps([t*mf,n*mf]);if(es){var r=ys(es,e),i=ys([r[1],-r[0],0],r);bs(i),i=ds(i);var o,a=t-Jf,u=a>0?1:-1,c=i[0]*bf*u,f=xf(a)>180;f^(u*JfQf&&(Qf=o):f^(u*Jf<(c=(c+360)%360-180)&&cQf&&(Qf=n)),f?tjs(Wf,Kf)&&(Kf=t):js(t,Kf)>js(Wf,Kf)&&(Wf=t):Kf>=Wf?(tKf&&(Kf=t)):t>Jf?js(Wf,t)>js(Wf,Kf)&&(Kf=t):js(t,Kf)>js(Wf,Kf)&&(Wf=t)}else is.push(os=[Wf=t,Kf=t]);nQf&&(Qf=n),es=e,Jf=t}function Is(){Fs.point=Us}function Os(){os[0]=Wf,os[1]=Kf,Fs.point=qs,es=null}function Bs(t,n){if(es){var e=t-Jf;rs.add(xf(e)>180?e+(e>0?360:-360):e)}else ts=t,ns=n;cs.point(t,n),Us(t,n)}function Ys(){cs.lineStart()}function Ls(){Bs(ts,ns),cs.lineEnd(),xf(rs)>df&&(Wf=-(Kf=180)),os[0]=Wf,os[1]=Kf,es=null}function js(t,n){return(n-=t)<0?n+360:n}function Hs(t,n){return t[0]-n[0]}function Xs(t,n){return t[0]<=t[1]?t[0]<=n&&n<=t[1]:ngf&&(t-=Math.round(t/_f)*_f),[t,n]}function ul(t,n,e){return(t%=_f)?n||e?ol(fl(t),sl(n,e)):fl(t):n||e?sl(n,e):al}function cl(t){return function(n,e){return xf(n+=t)>gf&&(n-=Math.round(n/_f)*_f),[n,e]}}function fl(t){var n=cl(t);return n.invert=cl(-t),n}function sl(t,n){var e=Tf(t),r=Cf(t),i=Tf(n),o=Cf(n);function a(t,n){var a=Tf(n),u=Tf(t)*a,c=Cf(t)*a,f=Cf(n),s=f*e+u*r;return[Mf(c*i-s*o,u*e-f*r),Rf(s*i+c*o)]}return a.invert=function(t,n){var a=Tf(n),u=Tf(t)*a,c=Cf(t)*a,f=Cf(n),s=f*i-c*o;return[Mf(c*i+f*o,u*e+s*r),Rf(s*e-u*r)]},a}function ll(t){function n(n){return(n=t(n[0]*mf,n[1]*mf))[0]*=bf,n[1]*=bf,n}return t=ul(t[0]*mf,t[1]*mf,t.length>2?t[2]*mf:0),n.invert=function(n){return(n=t.invert(n[0]*mf,n[1]*mf))[0]*=bf,n[1]*=bf,n},n}function hl(t,n,e,r,i,o){if(e){var a=Tf(n),u=Cf(n),c=r*e;null==i?(i=n+r*_f,o=n-c/2):(i=dl(a,i),o=dl(a,o),(r>0?io)&&(i+=r*_f));for(var f,s=i;r>0?s>o:s1&&n.push(n.pop().concat(n.shift()))},result:function(){var e=n;return n=[],t=null,e}}}function gl(t,n){return xf(t[0]-n[0])=0;--o)i.point((s=f[o])[0],s[1]);else r(h.x,h.p.x,-1,i);h=h.p}f=(h=h.o).z,d=!d}while(!h.v);i.lineEnd()}}}function _l(t){if(n=t.length){for(var n,e,r=0,i=t[0];++r=0?1:-1,E=S*A,N=E>gf,k=y*w;if(c.add(Mf(k*S*Cf(E),v*M+k*Tf(E))),a+=N?A+S*_f:A,N^p>=e^m>=e){var C=ys(ps(d),ps(b));bs(C);var P=ys(o,C);bs(P);var z=(N^A>=0?-1:1)*Rf(P[2]);(r>z||r===z&&(C[0]||C[1]))&&(u+=N^A>=0?1:-1)}}return(a<-df||a0){for(l||(i.polygonStart(),l=!0),i.lineStart(),t=0;t1&&2&c&&h.push(h.pop().concat(h.shift())),a.push(h.filter(wl))}return h}}function wl(t){return t.length>1}function Ml(t,n){return((t=t.x)[0]<0?t[1]-yf-df:yf-t[1])-((n=n.x)[0]<0?n[1]-yf-df:yf-n[1])}al.invert=al;var Tl=xl((function(){return!0}),(function(t){var n,e=NaN,r=NaN,i=NaN;return{lineStart:function(){t.lineStart(),n=1},point:function(o,a){var u=o>0?gf:-gf,c=xf(o-e);xf(c-gf)0?yf:-yf),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(u,r),t.point(o,r),n=0):i!==u&&c>=gf&&(xf(e-i)df?wf((Cf(n)*(o=Tf(r))*Cf(e)-Cf(r)*(i=Tf(n))*Cf(t))/(i*o*a)):(n+r)/2}(e,r,o,a),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(u,r),n=0),t.point(e=o,r=a),i=u},lineEnd:function(){t.lineEnd(),e=r=NaN},clean:function(){return 2-n}}}),(function(t,n,e,r){var i;if(null==t)i=e*yf,r.point(-gf,i),r.point(0,i),r.point(gf,i),r.point(gf,0),r.point(gf,-i),r.point(0,-i),r.point(-gf,-i),r.point(-gf,0),r.point(-gf,i);else if(xf(t[0]-n[0])>df){var o=t[0]0,i=xf(n)>df;function o(t,e){return Tf(t)*Tf(e)>n}function a(t,e,r){var i=[1,0,0],o=ys(ps(t),ps(e)),a=gs(o,o),u=o[0],c=a-u*u;if(!c)return!r&&t;var f=n*a/c,s=-n*u/c,l=ys(i,o),h=_s(i,f);vs(h,_s(o,s));var d=l,p=gs(h,d),g=gs(d,d),y=p*p-g*(gs(h,h)-1);if(!(y<0)){var v=zf(y),_=_s(d,(-p-v)/g);if(vs(_,h),_=ds(_),!r)return _;var b,m=t[0],x=e[0],w=t[1],M=e[1];x0^_[1]<(xf(_[0]-m)gf^(m<=_[0]&&_[0]<=x)){var S=_s(d,(-p+v)/g);return vs(S,h),[_,ds(S)]}}}function u(n,e){var i=r?t:gf-t,o=0;return n<-i?o|=1:n>i&&(o|=2),e<-i?o|=4:e>i&&(o|=8),o}return xl(o,(function(t){var n,e,c,f,s;return{lineStart:function(){f=c=!1,s=1},point:function(l,h){var d,p=[l,h],g=o(l,h),y=r?g?0:u(l,h):g?u(l+(l<0?gf:-gf),h):0;if(!n&&(f=c=g)&&t.lineStart(),g!==c&&(!(d=a(n,p))||gl(n,d)||gl(p,d))&&(p[2]=1),g!==c)s=0,g?(t.lineStart(),d=a(p,n),t.point(d[0],d[1])):(d=a(n,p),t.point(d[0],d[1],2),t.lineEnd()),n=d;else if(i&&n&&r^g){var v;y&e||!(v=a(p,n,!0))||(s=0,r?(t.lineStart(),t.point(v[0][0],v[0][1]),t.point(v[1][0],v[1][1]),t.lineEnd()):(t.point(v[1][0],v[1][1]),t.lineEnd(),t.lineStart(),t.point(v[0][0],v[0][1],3)))}!g||n&&gl(n,p)||t.point(p[0],p[1]),n=p,c=g,e=y},lineEnd:function(){c&&t.lineEnd(),n=null},clean:function(){return s|(f&&c)<<1}}}),(function(n,r,i,o){hl(o,t,e,i,n,r)}),r?[0,-t]:[-gf,t-gf])}var Sl,El,Nl,kl,Cl=1e9,Pl=-Cl;function zl(t,n,e,r){function i(i,o){return t<=i&&i<=e&&n<=o&&o<=r}function o(i,o,u,f){var s=0,l=0;if(null==i||(s=a(i,u))!==(l=a(o,u))||c(i,o)<0^u>0)do{f.point(0===s||3===s?t:e,s>1?r:n)}while((s=(s+u+4)%4)!==l);else f.point(o[0],o[1])}function a(r,i){return xf(r[0]-t)0?0:3:xf(r[0]-e)0?2:1:xf(r[1]-n)0?1:0:i>0?3:2}function u(t,n){return c(t.x,n.x)}function c(t,n){var e=a(t,1),r=a(n,1);return e!==r?e-r:0===e?n[1]-t[1]:1===e?t[0]-n[0]:2===e?t[1]-n[1]:n[0]-t[0]}return function(a){var c,f,s,l,h,d,p,g,y,v,_,b=a,m=pl(),x={point:w,lineStart:function(){x.point=M,f&&f.push(s=[]);v=!0,y=!1,p=g=NaN},lineEnd:function(){c&&(M(l,h),d&&y&&m.rejoin(),c.push(m.result()));x.point=w,y&&b.lineEnd()},polygonStart:function(){b=m,c=[],f=[],_=!0},polygonEnd:function(){var n=function(){for(var n=0,e=0,i=f.length;er&&(h-o)*(r-a)>(d-a)*(t-o)&&++n:d<=r&&(h-o)*(r-a)<(d-a)*(t-o)&&--n;return n}(),e=_&&n,i=(c=ft(c)).length;(e||i)&&(a.polygonStart(),e&&(a.lineStart(),o(null,null,1,a),a.lineEnd()),i&&vl(c,u,n,o,a),a.polygonEnd());b=a,c=f=s=null}};function w(t,n){i(t,n)&&b.point(t,n)}function M(o,a){var u=i(o,a);if(f&&s.push([o,a]),v)l=o,h=a,d=u,v=!1,u&&(b.lineStart(),b.point(o,a));else if(u&&y)b.point(o,a);else{var c=[p=Math.max(Pl,Math.min(Cl,p)),g=Math.max(Pl,Math.min(Cl,g))],m=[o=Math.max(Pl,Math.min(Cl,o)),a=Math.max(Pl,Math.min(Cl,a))];!function(t,n,e,r,i,o){var a,u=t[0],c=t[1],f=0,s=1,l=n[0]-u,h=n[1]-c;if(a=e-u,l||!(a>0)){if(a/=l,l<0){if(a0){if(a>s)return;a>f&&(f=a)}if(a=i-u,l||!(a<0)){if(a/=l,l<0){if(a>s)return;a>f&&(f=a)}else if(l>0){if(a0)){if(a/=h,h<0){if(a0){if(a>s)return;a>f&&(f=a)}if(a=o-c,h||!(a<0)){if(a/=h,h<0){if(a>s)return;a>f&&(f=a)}else if(h>0){if(a0&&(t[0]=u+f*l,t[1]=c+f*h),s<1&&(n[0]=u+s*l,n[1]=c+s*h),!0}}}}}(c,m,t,n,e,r)?u&&(b.lineStart(),b.point(o,a),_=!1):(y||(b.lineStart(),b.point(c[0],c[1])),b.point(m[0],m[1]),u||b.lineEnd(),_=!1)}p=o,g=a,y=u}return x}}var $l={sphere:qf,point:qf,lineStart:function(){$l.point=Rl,$l.lineEnd=Dl},lineEnd:qf,polygonStart:qf,polygonEnd:qf};function Dl(){$l.point=$l.lineEnd=qf}function Rl(t,n){El=t*=mf,Nl=Cf(n*=mf),kl=Tf(n),$l.point=Fl}function Fl(t,n){t*=mf;var e=Cf(n*=mf),r=Tf(n),i=xf(t-El),o=Tf(i),a=r*Cf(i),u=kl*e-Nl*r*o,c=Nl*e+kl*r*o;Sl.add(Mf(zf(a*a+u*u),c)),El=t,Nl=e,kl=r}function ql(t){return Sl=new T,Lf(t,$l),+Sl}var Ul=[null,null],Il={type:"LineString",coordinates:Ul};function Ol(t,n){return Ul[0]=t,Ul[1]=n,ql(Il)}var Bl={Feature:function(t,n){return Ll(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r0&&(i=Ol(t[o],t[o-1]))>0&&e<=i&&r<=i&&(e+r-i)*(1-Math.pow((e-r)/i,2))df})).map(c)).concat(lt(Af(o/d)*d,i,d).filter((function(t){return xf(t%g)>df})).map(f))}return v.lines=function(){return _().map((function(t){return{type:"LineString",coordinates:t}}))},v.outline=function(){return{type:"Polygon",coordinates:[s(r).concat(l(a).slice(1),s(e).reverse().slice(1),l(u).reverse().slice(1))]}},v.extent=function(t){return arguments.length?v.extentMajor(t).extentMinor(t):v.extentMinor()},v.extentMajor=function(t){return arguments.length?(r=+t[0][0],e=+t[1][0],u=+t[0][1],a=+t[1][1],r>e&&(t=r,r=e,e=t),u>a&&(t=u,u=a,a=t),v.precision(y)):[[r,u],[e,a]]},v.extentMinor=function(e){return arguments.length?(n=+e[0][0],t=+e[1][0],o=+e[0][1],i=+e[1][1],n>t&&(e=n,n=t,t=e),o>i&&(e=o,o=i,i=e),v.precision(y)):[[n,o],[t,i]]},v.step=function(t){return arguments.length?v.stepMajor(t).stepMinor(t):v.stepMinor()},v.stepMajor=function(t){return arguments.length?(p=+t[0],g=+t[1],v):[p,g]},v.stepMinor=function(t){return arguments.length?(h=+t[0],d=+t[1],v):[h,d]},v.precision=function(h){return arguments.length?(y=+h,c=Wl(o,i,90),f=Zl(n,t,y),s=Wl(u,a,90),l=Zl(r,e,y),v):y},v.extentMajor([[-180,-90+df],[180,90-df]]).extentMinor([[-180,-80-df],[180,80+df]])}var Ql,Jl,th,nh,eh=t=>t,rh=new T,ih=new T,oh={point:qf,lineStart:qf,lineEnd:qf,polygonStart:function(){oh.lineStart=ah,oh.lineEnd=fh},polygonEnd:function(){oh.lineStart=oh.lineEnd=oh.point=qf,rh.add(xf(ih)),ih=new T},result:function(){var t=rh/2;return rh=new T,t}};function ah(){oh.point=uh}function uh(t,n){oh.point=ch,Ql=th=t,Jl=nh=n}function ch(t,n){ih.add(nh*t-th*n),th=t,nh=n}function fh(){ch(Ql,Jl)}var sh=oh,lh=1/0,hh=lh,dh=-lh,ph=dh,gh={point:function(t,n){tdh&&(dh=t);nph&&(ph=n)},lineStart:qf,lineEnd:qf,polygonStart:qf,polygonEnd:qf,result:function(){var t=[[lh,hh],[dh,ph]];return dh=ph=-(hh=lh=1/0),t}};var yh,vh,_h,bh,mh=gh,xh=0,wh=0,Mh=0,Th=0,Ah=0,Sh=0,Eh=0,Nh=0,kh=0,Ch={point:Ph,lineStart:zh,lineEnd:Rh,polygonStart:function(){Ch.lineStart=Fh,Ch.lineEnd=qh},polygonEnd:function(){Ch.point=Ph,Ch.lineStart=zh,Ch.lineEnd=Rh},result:function(){var t=kh?[Eh/kh,Nh/kh]:Sh?[Th/Sh,Ah/Sh]:Mh?[xh/Mh,wh/Mh]:[NaN,NaN];return xh=wh=Mh=Th=Ah=Sh=Eh=Nh=kh=0,t}};function Ph(t,n){xh+=t,wh+=n,++Mh}function zh(){Ch.point=$h}function $h(t,n){Ch.point=Dh,Ph(_h=t,bh=n)}function Dh(t,n){var e=t-_h,r=n-bh,i=zf(e*e+r*r);Th+=i*(_h+t)/2,Ah+=i*(bh+n)/2,Sh+=i,Ph(_h=t,bh=n)}function Rh(){Ch.point=Ph}function Fh(){Ch.point=Uh}function qh(){Ih(yh,vh)}function Uh(t,n){Ch.point=Ih,Ph(yh=_h=t,vh=bh=n)}function Ih(t,n){var e=t-_h,r=n-bh,i=zf(e*e+r*r);Th+=i*(_h+t)/2,Ah+=i*(bh+n)/2,Sh+=i,Eh+=(i=bh*t-_h*n)*(_h+t),Nh+=i*(bh+n),kh+=3*i,Ph(_h=t,bh=n)}var Oh=Ch;function Bh(t){this._context=t}Bh.prototype={_radius:4.5,pointRadius:function(t){return this._radius=t,this},polygonStart:function(){this._line=0},polygonEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){0===this._line&&this._context.closePath(),this._point=NaN},point:function(t,n){switch(this._point){case 0:this._context.moveTo(t,n),this._point=1;break;case 1:this._context.lineTo(t,n);break;default:this._context.moveTo(t+this._radius,n),this._context.arc(t,n,this._radius,0,_f)}},result:qf};var Yh,Lh,jh,Hh,Xh,Gh=new T,Vh={point:qf,lineStart:function(){Vh.point=Wh},lineEnd:function(){Yh&&Zh(Lh,jh),Vh.point=qf},polygonStart:function(){Yh=!0},polygonEnd:function(){Yh=null},result:function(){var t=+Gh;return Gh=new T,t}};function Wh(t,n){Vh.point=Zh,Lh=Hh=t,jh=Xh=n}function Zh(t,n){Hh-=t,Xh-=n,Gh.add(zf(Hh*Hh+Xh*Xh)),Hh=t,Xh=n}var Kh=Vh;let Qh,Jh,td,nd;class ed{constructor(t){this._append=null==t?rd:function(t){const n=Math.floor(t);if(!(n>=0))throw new RangeError(`invalid digits: ${t}`);if(n>15)return rd;if(n!==Qh){const t=10**n;Qh=n,Jh=function(n){let e=1;this._+=n[0];for(const r=n.length;e4*n&&g--){var m=a+h,x=u+d,w=c+p,M=zf(m*m+x*x+w*w),T=Rf(w/=M),A=xf(xf(w)-1)n||xf((v*k+_*C)/b-.5)>.3||a*h+u*d+c*p2?t[2]%360*mf:0,k()):[y*bf,v*bf,_*bf]},E.angle=function(t){return arguments.length?(b=t%360*mf,k()):b*bf},E.reflectX=function(t){return arguments.length?(m=t?-1:1,k()):m<0},E.reflectY=function(t){return arguments.length?(x=t?-1:1,k()):x<0},E.precision=function(t){return arguments.length?(a=dd(u,S=t*t),C()):zf(S)},E.fitExtent=function(t,n){return ud(E,t,n)},E.fitSize=function(t,n){return cd(E,t,n)},E.fitWidth=function(t,n){return fd(E,t,n)},E.fitHeight=function(t,n){return sd(E,t,n)},function(){return n=t.apply(this,arguments),E.invert=n.invert&&N,k()}}function _d(t){var n=0,e=gf/3,r=vd(t),i=r(n,e);return i.parallels=function(t){return arguments.length?r(n=t[0]*mf,e=t[1]*mf):[n*bf,e*bf]},i}function bd(t,n){var e=Cf(t),r=(e+Cf(n))/2;if(xf(r)0?n<-yf+df&&(n=-yf+df):n>yf-df&&(n=yf-df);var e=i/kf(Nd(n),r);return[e*Cf(r*t),i-e*Tf(r*t)]}return o.invert=function(t,n){var e=i-n,o=Pf(r)*zf(t*t+e*e),a=Mf(t,xf(e))*Pf(e);return e*r<0&&(a-=gf*Pf(t)*Pf(e)),[a/r,2*wf(kf(i/o,1/r))-yf]},o}function Cd(t,n){return[t,n]}function Pd(t,n){var e=Tf(t),r=t===n?Cf(t):(e-Tf(n))/(n-t),i=e/r+t;if(xf(r)=0;)n+=e[r].value;else n=1;t.value=n}function Gd(t,n){t instanceof Map?(t=[void 0,t],void 0===n&&(n=Wd)):void 0===n&&(n=Vd);for(var e,r,i,o,a,u=new Qd(t),c=[u];e=c.pop();)if((i=n(e.data))&&(a=(i=Array.from(i)).length))for(e.children=i,o=a-1;o>=0;--o)c.push(r=i[o]=new Qd(i[o])),r.parent=e,r.depth=e.depth+1;return u.eachBefore(Kd)}function Vd(t){return t.children}function Wd(t){return Array.isArray(t)?t[1]:null}function Zd(t){void 0!==t.data.value&&(t.value=t.data.value),t.data=t.data.data}function Kd(t){var n=0;do{t.height=n}while((t=t.parent)&&t.height<++n)}function Qd(t){this.data=t,this.depth=this.height=0,this.parent=null}function Jd(t){return null==t?null:tp(t)}function tp(t){if("function"!=typeof t)throw new Error;return t}function np(){return 0}function ep(t){return function(){return t}}qd.invert=function(t,n){for(var e,r=n,i=r*r,o=i*i*i,a=0;a<12&&(o=(i=(r-=e=(r*(zd+$d*i+o*(Dd+Rd*i))-n)/(zd+3*$d*i+o*(7*Dd+9*Rd*i)))*r)*i*i,!(xf(e)df&&--i>0);return[t/(.8707+(o=r*r)*(o*(o*o*o*(.003971-.001529*o)-.013791)-.131979)),r]},Od.invert=Md(Rf),Bd.invert=Md((function(t){return 2*wf(t)})),Yd.invert=function(t,n){return[-n,2*wf(Sf(t))-yf]},Qd.prototype=Gd.prototype={constructor:Qd,count:function(){return this.eachAfter(Xd)},each:function(t,n){let e=-1;for(const r of this)t.call(n,r,++e,this);return this},eachAfter:function(t,n){for(var e,r,i,o=this,a=[o],u=[],c=-1;o=a.pop();)if(u.push(o),e=o.children)for(r=0,i=e.length;r=0;--r)o.push(e[r]);return this},find:function(t,n){let e=-1;for(const r of this)if(t.call(n,r,++e,this))return r},sum:function(t){return this.eachAfter((function(n){for(var e=+t(n.data)||0,r=n.children,i=r&&r.length;--i>=0;)e+=r[i].value;n.value=e}))},sort:function(t){return this.eachBefore((function(n){n.children&&n.children.sort(t)}))},path:function(t){for(var n=this,e=function(t,n){if(t===n)return t;var e=t.ancestors(),r=n.ancestors(),i=null;t=e.pop(),n=r.pop();for(;t===n;)i=t,t=e.pop(),n=r.pop();return i}(n,t),r=[n];n!==e;)n=n.parent,r.push(n);for(var i=r.length;t!==e;)r.splice(i,0,t),t=t.parent;return r},ancestors:function(){for(var t=this,n=[t];t=t.parent;)n.push(t);return n},descendants:function(){return Array.from(this)},leaves:function(){var t=[];return this.eachBefore((function(n){n.children||t.push(n)})),t},links:function(){var t=this,n=[];return t.each((function(e){e!==t&&n.push({source:e.parent,target:e})})),n},copy:function(){return Gd(this).eachBefore(Zd)},[Symbol.iterator]:function*(){var t,n,e,r,i=this,o=[i];do{for(t=o.reverse(),o=[];i=t.pop();)if(yield i,n=i.children)for(e=0,r=n.length;e(t=(rp*t+ip)%op)/op}function up(t,n){for(var e,r,i=0,o=(t=function(t,n){let e,r,i=t.length;for(;i;)r=n()*i--|0,e=t[i],t[i]=t[r],t[r]=e;return t}(Array.from(t),n)).length,a=[];i0&&e*e>r*r+i*i}function lp(t,n){for(var e=0;e1e-6?(E+Math.sqrt(E*E-4*S*N))/(2*S):N/E);return{x:r+w+M*k,y:i+T+A*k,r:k}}function gp(t,n,e){var r,i,o,a,u=t.x-n.x,c=t.y-n.y,f=u*u+c*c;f?(i=n.r+e.r,i*=i,a=t.r+e.r,i>(a*=a)?(r=(f+a-i)/(2*f),o=Math.sqrt(Math.max(0,a/f-r*r)),e.x=t.x-r*u-o*c,e.y=t.y-r*c+o*u):(r=(f+i-a)/(2*f),o=Math.sqrt(Math.max(0,i/f-r*r)),e.x=n.x+r*u-o*c,e.y=n.y+r*c+o*u)):(e.x=n.x+e.r,e.y=n.y)}function yp(t,n){var e=t.r+n.r-1e-6,r=n.x-t.x,i=n.y-t.y;return e>0&&e*e>r*r+i*i}function vp(t){var n=t._,e=t.next._,r=n.r+e.r,i=(n.x*e.r+e.x*n.r)/r,o=(n.y*e.r+e.y*n.r)/r;return i*i+o*o}function _p(t){this._=t,this.next=null,this.previous=null}function bp(t,n){if(!(o=(t=function(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}(t)).length))return 0;var e,r,i,o,a,u,c,f,s,l,h;if((e=t[0]).x=0,e.y=0,!(o>1))return e.r;if(r=t[1],e.x=-r.r,r.x=e.r,r.y=0,!(o>2))return e.r+r.r;gp(r,e,i=t[2]),e=new _p(e),r=new _p(r),i=new _p(i),e.next=i.previous=r,r.next=e.previous=i,i.next=r.previous=e;t:for(c=3;c1&&!zp(t,n););return t.slice(0,n)}function zp(t,n){if("/"===t[n]){let e=0;for(;n>0&&"\\"===t[--n];)++e;if(!(1&e))return!0}return!1}function $p(t,n){return t.parent===n.parent?1:2}function Dp(t){var n=t.children;return n?n[0]:t.t}function Rp(t){var n=t.children;return n?n[n.length-1]:t.t}function Fp(t,n,e){var r=e/(n.i-t.i);n.c-=r,n.s+=e,t.c+=r,n.z+=e,n.m+=e}function qp(t,n,e){return t.a.parent===n.parent?t.a:e}function Up(t,n){this._=t,this.parent=null,this.children=null,this.A=null,this.a=this,this.z=0,this.m=0,this.c=0,this.s=0,this.t=null,this.i=n}function Ip(t,n,e,r,i){for(var o,a=t.children,u=-1,c=a.length,f=t.value&&(i-e)/t.value;++uh&&(h=u),y=s*s*g,(d=Math.max(h/y,y/l))>p){s-=u;break}p=d}v.push(a={value:s,dice:c1?n:1)},e}(Op);var Lp=function t(n){function e(t,e,r,i,o){if((a=t._squarify)&&a.ratio===n)for(var a,u,c,f,s,l=-1,h=a.length,d=t.value;++l1?n:1)},e}(Op);function jp(t,n,e){return(n[0]-t[0])*(e[1]-t[1])-(n[1]-t[1])*(e[0]-t[0])}function Hp(t,n){return t[0]-n[0]||t[1]-n[1]}function Xp(t){const n=t.length,e=[0,1];let r,i=2;for(r=2;r1&&jp(t[e[i-2]],t[e[i-1]],t[r])<=0;)--i;e[i++]=r}return e.slice(0,i)}var Gp=Math.random,Vp=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,1===arguments.length?(e=t,t=0):e-=t,function(){return n()*e+t}}return e.source=t,e}(Gp),Wp=function t(n){function e(t,e){return arguments.length<2&&(e=t,t=0),t=Math.floor(t),e=Math.floor(e)-t,function(){return Math.floor(n()*e+t)}}return e.source=t,e}(Gp),Zp=function t(n){function e(t,e){var r,i;return t=null==t?0:+t,e=null==e?1:+e,function(){var o;if(null!=r)o=r,r=null;else do{r=2*n()-1,o=2*n()-1,i=r*r+o*o}while(!i||i>1);return t+e*o*Math.sqrt(-2*Math.log(i)/i)}}return e.source=t,e}(Gp),Kp=function t(n){var e=Zp.source(n);function r(){var t=e.apply(this,arguments);return function(){return Math.exp(t())}}return r.source=t,r}(Gp),Qp=function t(n){function e(t){return(t=+t)<=0?()=>0:function(){for(var e=0,r=t;r>1;--r)e+=n();return e+r*n()}}return e.source=t,e}(Gp),Jp=function t(n){var e=Qp.source(n);function r(t){if(0==(t=+t))return n;var r=e(t);return function(){return r()/t}}return r.source=t,r}(Gp),tg=function t(n){function e(t){return function(){return-Math.log1p(-n())/t}}return e.source=t,e}(Gp),ng=function t(n){function e(t){if((t=+t)<0)throw new RangeError("invalid alpha");return t=1/-t,function(){return Math.pow(1-n(),t)}}return e.source=t,e}(Gp),eg=function t(n){function e(t){if((t=+t)<0||t>1)throw new RangeError("invalid p");return function(){return Math.floor(n()+t)}}return e.source=t,e}(Gp),rg=function t(n){function e(t){if((t=+t)<0||t>1)throw new RangeError("invalid p");return 0===t?()=>1/0:1===t?()=>1:(t=Math.log1p(-t),function(){return 1+Math.floor(Math.log1p(-n())/t)})}return e.source=t,e}(Gp),ig=function t(n){var e=Zp.source(n)();function r(t,r){if((t=+t)<0)throw new RangeError("invalid k");if(0===t)return()=>0;if(r=null==r?1:+r,1===t)return()=>-Math.log1p(-n())*r;var i=(t<1?t+1:t)-1/3,o=1/(3*Math.sqrt(i)),a=t<1?()=>Math.pow(n(),1/t):()=>1;return function(){do{do{var t=e(),u=1+o*t}while(u<=0);u*=u*u;var c=1-n()}while(c>=1-.0331*t*t*t*t&&Math.log(c)>=.5*t*t+i*(1-u+Math.log(u)));return i*u*a()*r}}return r.source=t,r}(Gp),og=function t(n){var e=ig.source(n);function r(t,n){var r=e(t),i=e(n);return function(){var t=r();return 0===t?0:t/(t+i())}}return r.source=t,r}(Gp),ag=function t(n){var e=rg.source(n),r=og.source(n);function i(t,n){return t=+t,(n=+n)>=1?()=>t:n<=0?()=>0:function(){for(var i=0,o=t,a=n;o*a>16&&o*(1-a)>16;){var u=Math.floor((o+1)*a),c=r(u,o-u+1)();c<=a?(i+=u,o-=u,a=(a-c)/(1-c)):(o=u-1,a/=c)}for(var f=a<.5,s=e(f?a:1-a),l=s(),h=0;l<=o;++h)l+=s();return i+(f?h:o-h)}}return i.source=t,i}(Gp),ug=function t(n){function e(t,e,r){var i;return 0==(t=+t)?i=t=>-Math.log(t):(t=1/t,i=n=>Math.pow(n,t)),e=null==e?0:+e,r=null==r?1:+r,function(){return e+r*i(-Math.log1p(-n()))}}return e.source=t,e}(Gp),cg=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,function(){return t+e*Math.tan(Math.PI*n())}}return e.source=t,e}(Gp),fg=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,function(){var r=n();return t+e*Math.log(r/(1-r))}}return e.source=t,e}(Gp),sg=function t(n){var e=ig.source(n),r=ag.source(n);function i(t){return function(){for(var i=0,o=t;o>16;){var a=Math.floor(.875*o),u=e(a)();if(u>o)return i+r(a-1,o/u)();i+=a,o-=u}for(var c=-Math.log1p(-n()),f=0;c<=o;++f)c-=Math.log1p(-n());return i+f}}return i.source=t,i}(Gp);const lg=1/4294967296;function hg(t,n){switch(arguments.length){case 0:break;case 1:this.range(t);break;default:this.range(n).domain(t)}return this}function dg(t,n){switch(arguments.length){case 0:break;case 1:"function"==typeof t?this.interpolator(t):this.range(t);break;default:this.domain(t),"function"==typeof n?this.interpolator(n):this.range(n)}return this}const pg=Symbol("implicit");function gg(){var t=new InternMap,n=[],e=[],r=pg;function i(i){let o=t.get(i);if(void 0===o){if(r!==pg)return r;t.set(i,o=n.push(i)-1)}return e[o%e.length]}return i.domain=function(e){if(!arguments.length)return n.slice();n=[],t=new InternMap;for(const r of e)t.has(r)||t.set(r,n.push(r)-1);return i},i.range=function(t){return arguments.length?(e=Array.from(t),i):e.slice()},i.unknown=function(t){return arguments.length?(r=t,i):r},i.copy=function(){return gg(n,e).unknown(r)},hg.apply(i,arguments),i}function yg(){var t,n,e=gg().unknown(void 0),r=e.domain,i=e.range,o=0,a=1,u=!1,c=0,f=0,s=.5;function l(){var e=r().length,l=an&&(e=t,t=n,n=e),function(e){return Math.max(t,Math.min(n,e))}}(a[0],a[t-1])),r=t>2?Mg:wg,i=o=null,l}function l(n){return null==n||isNaN(n=+n)?e:(i||(i=r(a.map(t),u,c)))(t(f(n)))}return l.invert=function(e){return f(n((o||(o=r(u,a.map(t),Yr)))(e)))},l.domain=function(t){return arguments.length?(a=Array.from(t,_g),s()):a.slice()},l.range=function(t){return arguments.length?(u=Array.from(t),s()):u.slice()},l.rangeRound=function(t){return u=Array.from(t),c=Vr,s()},l.clamp=function(t){return arguments.length?(f=!!t||mg,s()):f!==mg},l.interpolate=function(t){return arguments.length?(c=t,s()):c},l.unknown=function(t){return arguments.length?(e=t,l):e},function(e,r){return t=e,n=r,s()}}function Sg(){return Ag()(mg,mg)}function Eg(n,e,r,i){var o,a=W(n,e,r);switch((i=Jc(null==i?",f":i)).type){case"s":var u=Math.max(Math.abs(n),Math.abs(e));return null!=i.precision||isNaN(o=lf(a,u))||(i.precision=o),t.formatPrefix(i,u);case"":case"e":case"g":case"p":case"r":null!=i.precision||isNaN(o=hf(a,Math.max(Math.abs(n),Math.abs(e))))||(i.precision=o-("e"===i.type));break;case"f":case"%":null!=i.precision||isNaN(o=sf(a))||(i.precision=o-2*("%"===i.type))}return t.format(i)}function Ng(t){var n=t.domain;return t.ticks=function(t){var e=n();return G(e[0],e[e.length-1],null==t?10:t)},t.tickFormat=function(t,e){var r=n();return Eg(r[0],r[r.length-1],null==t?10:t,e)},t.nice=function(e){null==e&&(e=10);var r,i,o=n(),a=0,u=o.length-1,c=o[a],f=o[u],s=10;for(f0;){if((i=V(c,f,e))===r)return o[a]=c,o[u]=f,n(o);if(i>0)c=Math.floor(c/i)*i,f=Math.ceil(f/i)*i;else{if(!(i<0))break;c=Math.ceil(c*i)/i,f=Math.floor(f*i)/i}r=i}return t},t}function kg(t,n){var e,r=0,i=(t=t.slice()).length-1,o=t[r],a=t[i];return a-t(-n,e)}function Fg(n){const e=n(Cg,Pg),r=e.domain;let i,o,a=10;function u(){return i=function(t){return t===Math.E?Math.log:10===t&&Math.log10||2===t&&Math.log2||(t=Math.log(t),n=>Math.log(n)/t)}(a),o=function(t){return 10===t?Dg:t===Math.E?Math.exp:n=>Math.pow(t,n)}(a),r()[0]<0?(i=Rg(i),o=Rg(o),n(zg,$g)):n(Cg,Pg),e}return e.base=function(t){return arguments.length?(a=+t,u()):a},e.domain=function(t){return arguments.length?(r(t),u()):r()},e.ticks=t=>{const n=r();let e=n[0],u=n[n.length-1];const c=u0){for(;l<=h;++l)for(f=1;fu)break;p.push(s)}}else for(;l<=h;++l)for(f=a-1;f>=1;--f)if(s=l>0?f/o(-l):f*o(l),!(su)break;p.push(s)}2*p.length{if(null==n&&(n=10),null==r&&(r=10===a?"s":","),"function"!=typeof r&&(a%1||null!=(r=Jc(r)).precision||(r.trim=!0),r=t.format(r)),n===1/0)return r;const u=Math.max(1,a*n/e.ticks().length);return t=>{let n=t/o(Math.round(i(t)));return n*ar(kg(r(),{floor:t=>o(Math.floor(i(t))),ceil:t=>o(Math.ceil(i(t)))})),e}function qg(t){return function(n){return Math.sign(n)*Math.log1p(Math.abs(n/t))}}function Ug(t){return function(n){return Math.sign(n)*Math.expm1(Math.abs(n))*t}}function Ig(t){var n=1,e=t(qg(n),Ug(n));return e.constant=function(e){return arguments.length?t(qg(n=+e),Ug(n)):n},Ng(e)}function Og(t){return function(n){return n<0?-Math.pow(-n,t):Math.pow(n,t)}}function Bg(t){return t<0?-Math.sqrt(-t):Math.sqrt(t)}function Yg(t){return t<0?-t*t:t*t}function Lg(t){var n=t(mg,mg),e=1;return n.exponent=function(n){return arguments.length?1===(e=+n)?t(mg,mg):.5===e?t(Bg,Yg):t(Og(e),Og(1/e)):e},Ng(n)}function jg(){var t=Lg(Ag());return t.copy=function(){return Tg(t,jg()).exponent(t.exponent())},hg.apply(t,arguments),t}function Hg(t){return Math.sign(t)*t*t}const Xg=new Date,Gg=new Date;function Vg(t,n,e,r){function i(n){return t(n=0===arguments.length?new Date:new Date(+n)),n}return i.floor=n=>(t(n=new Date(+n)),n),i.ceil=e=>(t(e=new Date(e-1)),n(e,1),t(e),e),i.round=t=>{const n=i(t),e=i.ceil(t);return t-n(n(t=new Date(+t),null==e?1:Math.floor(e)),t),i.range=(e,r,o)=>{const a=[];if(e=i.ceil(e),o=null==o?1:Math.floor(o),!(e0))return a;let u;do{a.push(u=new Date(+e)),n(e,o),t(e)}while(uVg((n=>{if(n>=n)for(;t(n),!e(n);)n.setTime(n-1)}),((t,r)=>{if(t>=t)if(r<0)for(;++r<=0;)for(;n(t,-1),!e(t););else for(;--r>=0;)for(;n(t,1),!e(t););})),e&&(i.count=(n,r)=>(Xg.setTime(+n),Gg.setTime(+r),t(Xg),t(Gg),Math.floor(e(Xg,Gg))),i.every=t=>(t=Math.floor(t),isFinite(t)&&t>0?t>1?i.filter(r?n=>r(n)%t==0:n=>i.count(0,n)%t==0):i:null)),i}const Wg=Vg((()=>{}),((t,n)=>{t.setTime(+t+n)}),((t,n)=>n-t));Wg.every=t=>(t=Math.floor(t),isFinite(t)&&t>0?t>1?Vg((n=>{n.setTime(Math.floor(n/t)*t)}),((n,e)=>{n.setTime(+n+e*t)}),((n,e)=>(e-n)/t)):Wg:null);const Zg=Wg.range,Kg=1e3,Qg=6e4,Jg=36e5,ty=864e5,ny=6048e5,ey=2592e6,ry=31536e6,iy=Vg((t=>{t.setTime(t-t.getMilliseconds())}),((t,n)=>{t.setTime(+t+n*Kg)}),((t,n)=>(n-t)/Kg),(t=>t.getUTCSeconds())),oy=iy.range,ay=Vg((t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*Kg)}),((t,n)=>{t.setTime(+t+n*Qg)}),((t,n)=>(n-t)/Qg),(t=>t.getMinutes())),uy=ay.range,cy=Vg((t=>{t.setUTCSeconds(0,0)}),((t,n)=>{t.setTime(+t+n*Qg)}),((t,n)=>(n-t)/Qg),(t=>t.getUTCMinutes())),fy=cy.range,sy=Vg((t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*Kg-t.getMinutes()*Qg)}),((t,n)=>{t.setTime(+t+n*Jg)}),((t,n)=>(n-t)/Jg),(t=>t.getHours())),ly=sy.range,hy=Vg((t=>{t.setUTCMinutes(0,0,0)}),((t,n)=>{t.setTime(+t+n*Jg)}),((t,n)=>(n-t)/Jg),(t=>t.getUTCHours())),dy=hy.range,py=Vg((t=>t.setHours(0,0,0,0)),((t,n)=>t.setDate(t.getDate()+n)),((t,n)=>(n-t-(n.getTimezoneOffset()-t.getTimezoneOffset())*Qg)/ty),(t=>t.getDate()-1)),gy=py.range,yy=Vg((t=>{t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCDate(t.getUTCDate()+n)}),((t,n)=>(n-t)/ty),(t=>t.getUTCDate()-1)),vy=yy.range,_y=Vg((t=>{t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCDate(t.getUTCDate()+n)}),((t,n)=>(n-t)/ty),(t=>Math.floor(t/ty))),by=_y.range;function my(t){return Vg((n=>{n.setDate(n.getDate()-(n.getDay()+7-t)%7),n.setHours(0,0,0,0)}),((t,n)=>{t.setDate(t.getDate()+7*n)}),((t,n)=>(n-t-(n.getTimezoneOffset()-t.getTimezoneOffset())*Qg)/ny))}const xy=my(0),wy=my(1),My=my(2),Ty=my(3),Ay=my(4),Sy=my(5),Ey=my(6),Ny=xy.range,ky=wy.range,Cy=My.range,Py=Ty.range,zy=Ay.range,$y=Sy.range,Dy=Ey.range;function Ry(t){return Vg((n=>{n.setUTCDate(n.getUTCDate()-(n.getUTCDay()+7-t)%7),n.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCDate(t.getUTCDate()+7*n)}),((t,n)=>(n-t)/ny))}const Fy=Ry(0),qy=Ry(1),Uy=Ry(2),Iy=Ry(3),Oy=Ry(4),By=Ry(5),Yy=Ry(6),Ly=Fy.range,jy=qy.range,Hy=Uy.range,Xy=Iy.range,Gy=Oy.range,Vy=By.range,Wy=Yy.range,Zy=Vg((t=>{t.setDate(1),t.setHours(0,0,0,0)}),((t,n)=>{t.setMonth(t.getMonth()+n)}),((t,n)=>n.getMonth()-t.getMonth()+12*(n.getFullYear()-t.getFullYear())),(t=>t.getMonth())),Ky=Zy.range,Qy=Vg((t=>{t.setUTCDate(1),t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCMonth(t.getUTCMonth()+n)}),((t,n)=>n.getUTCMonth()-t.getUTCMonth()+12*(n.getUTCFullYear()-t.getUTCFullYear())),(t=>t.getUTCMonth())),Jy=Qy.range,tv=Vg((t=>{t.setMonth(0,1),t.setHours(0,0,0,0)}),((t,n)=>{t.setFullYear(t.getFullYear()+n)}),((t,n)=>n.getFullYear()-t.getFullYear()),(t=>t.getFullYear()));tv.every=t=>isFinite(t=Math.floor(t))&&t>0?Vg((n=>{n.setFullYear(Math.floor(n.getFullYear()/t)*t),n.setMonth(0,1),n.setHours(0,0,0,0)}),((n,e)=>{n.setFullYear(n.getFullYear()+e*t)})):null;const nv=tv.range,ev=Vg((t=>{t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCFullYear(t.getUTCFullYear()+n)}),((t,n)=>n.getUTCFullYear()-t.getUTCFullYear()),(t=>t.getUTCFullYear()));ev.every=t=>isFinite(t=Math.floor(t))&&t>0?Vg((n=>{n.setUTCFullYear(Math.floor(n.getUTCFullYear()/t)*t),n.setUTCMonth(0,1),n.setUTCHours(0,0,0,0)}),((n,e)=>{n.setUTCFullYear(n.getUTCFullYear()+e*t)})):null;const rv=ev.range;function iv(t,n,e,i,o,a){const u=[[iy,1,Kg],[iy,5,5e3],[iy,15,15e3],[iy,30,3e4],[a,1,Qg],[a,5,3e5],[a,15,9e5],[a,30,18e5],[o,1,Jg],[o,3,108e5],[o,6,216e5],[o,12,432e5],[i,1,ty],[i,2,1728e5],[e,1,ny],[n,1,ey],[n,3,7776e6],[t,1,ry]];function c(n,e,i){const o=Math.abs(e-n)/i,a=r((([,,t])=>t)).right(u,o);if(a===u.length)return t.every(W(n/ry,e/ry,i));if(0===a)return Wg.every(Math.max(W(n,e,i),1));const[c,f]=u[o/u[a-1][2]=12)]},q:function(t){return 1+~~(t.getMonth()/3)},Q:k_,s:C_,S:Zv,u:Kv,U:Qv,V:t_,w:n_,W:e_,x:null,X:null,y:r_,Y:o_,Z:u_,"%":N_},m={a:function(t){return a[t.getUTCDay()]},A:function(t){return o[t.getUTCDay()]},b:function(t){return c[t.getUTCMonth()]},B:function(t){return u[t.getUTCMonth()]},c:null,d:c_,e:c_,f:d_,g:T_,G:S_,H:f_,I:s_,j:l_,L:h_,m:p_,M:g_,p:function(t){return i[+(t.getUTCHours()>=12)]},q:function(t){return 1+~~(t.getUTCMonth()/3)},Q:k_,s:C_,S:y_,u:v_,U:__,V:m_,w:x_,W:w_,x:null,X:null,y:M_,Y:A_,Z:E_,"%":N_},x={a:function(t,n,e){var r=d.exec(n.slice(e));return r?(t.w=p.get(r[0].toLowerCase()),e+r[0].length):-1},A:function(t,n,e){var r=l.exec(n.slice(e));return r?(t.w=h.get(r[0].toLowerCase()),e+r[0].length):-1},b:function(t,n,e){var r=v.exec(n.slice(e));return r?(t.m=_.get(r[0].toLowerCase()),e+r[0].length):-1},B:function(t,n,e){var r=g.exec(n.slice(e));return r?(t.m=y.get(r[0].toLowerCase()),e+r[0].length):-1},c:function(t,e,r){return T(t,n,e,r)},d:zv,e:zv,f:Uv,g:Nv,G:Ev,H:Dv,I:Dv,j:$v,L:qv,m:Pv,M:Rv,p:function(t,n,e){var r=f.exec(n.slice(e));return r?(t.p=s.get(r[0].toLowerCase()),e+r[0].length):-1},q:Cv,Q:Ov,s:Bv,S:Fv,u:Mv,U:Tv,V:Av,w:wv,W:Sv,x:function(t,n,r){return T(t,e,n,r)},X:function(t,n,e){return T(t,r,n,e)},y:Nv,Y:Ev,Z:kv,"%":Iv};function w(t,n){return function(e){var r,i,o,a=[],u=-1,c=0,f=t.length;for(e instanceof Date||(e=new Date(+e));++u53)return null;"w"in o||(o.w=1),"Z"in o?(i=(r=sv(lv(o.y,0,1))).getUTCDay(),r=i>4||0===i?qy.ceil(r):qy(r),r=yy.offset(r,7*(o.V-1)),o.y=r.getUTCFullYear(),o.m=r.getUTCMonth(),o.d=r.getUTCDate()+(o.w+6)%7):(i=(r=fv(lv(o.y,0,1))).getDay(),r=i>4||0===i?wy.ceil(r):wy(r),r=py.offset(r,7*(o.V-1)),o.y=r.getFullYear(),o.m=r.getMonth(),o.d=r.getDate()+(o.w+6)%7)}else("W"in o||"U"in o)&&("w"in o||(o.w="u"in o?o.u%7:"W"in o?1:0),i="Z"in o?sv(lv(o.y,0,1)).getUTCDay():fv(lv(o.y,0,1)).getDay(),o.m=0,o.d="W"in o?(o.w+6)%7+7*o.W-(i+5)%7:o.w+7*o.U-(i+6)%7);return"Z"in o?(o.H+=o.Z/100|0,o.M+=o.Z%100,sv(o)):fv(o)}}function T(t,n,e,r){for(var i,o,a=0,u=n.length,c=e.length;a=c)return-1;if(37===(i=n.charCodeAt(a++))){if(i=n.charAt(a++),!(o=x[i in pv?n.charAt(a++):i])||(r=o(t,e,r))<0)return-1}else if(i!=e.charCodeAt(r++))return-1}return r}return b.x=w(e,b),b.X=w(r,b),b.c=w(n,b),m.x=w(e,m),m.X=w(r,m),m.c=w(n,m),{format:function(t){var n=w(t+="",b);return n.toString=function(){return t},n},parse:function(t){var n=M(t+="",!1);return n.toString=function(){return t},n},utcFormat:function(t){var n=w(t+="",m);return n.toString=function(){return t},n},utcParse:function(t){var n=M(t+="",!0);return n.toString=function(){return t},n}}}var dv,pv={"-":"",_:" ",0:"0"},gv=/^\s*\d+/,yv=/^%/,vv=/[\\^$*+?|[\]().{}]/g;function _v(t,n,e){var r=t<0?"-":"",i=(r?-t:t)+"",o=i.length;return r+(o[t.toLowerCase(),n])))}function wv(t,n,e){var r=gv.exec(n.slice(e,e+1));return r?(t.w=+r[0],e+r[0].length):-1}function Mv(t,n,e){var r=gv.exec(n.slice(e,e+1));return r?(t.u=+r[0],e+r[0].length):-1}function Tv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.U=+r[0],e+r[0].length):-1}function Av(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.V=+r[0],e+r[0].length):-1}function Sv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.W=+r[0],e+r[0].length):-1}function Ev(t,n,e){var r=gv.exec(n.slice(e,e+4));return r?(t.y=+r[0],e+r[0].length):-1}function Nv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.y=+r[0]+(+r[0]>68?1900:2e3),e+r[0].length):-1}function kv(t,n,e){var r=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(n.slice(e,e+6));return r?(t.Z=r[1]?0:-(r[2]+(r[3]||"00")),e+r[0].length):-1}function Cv(t,n,e){var r=gv.exec(n.slice(e,e+1));return r?(t.q=3*r[0]-3,e+r[0].length):-1}function Pv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.m=r[0]-1,e+r[0].length):-1}function zv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.d=+r[0],e+r[0].length):-1}function $v(t,n,e){var r=gv.exec(n.slice(e,e+3));return r?(t.m=0,t.d=+r[0],e+r[0].length):-1}function Dv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.H=+r[0],e+r[0].length):-1}function Rv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.M=+r[0],e+r[0].length):-1}function Fv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.S=+r[0],e+r[0].length):-1}function qv(t,n,e){var r=gv.exec(n.slice(e,e+3));return r?(t.L=+r[0],e+r[0].length):-1}function Uv(t,n,e){var r=gv.exec(n.slice(e,e+6));return r?(t.L=Math.floor(r[0]/1e3),e+r[0].length):-1}function Iv(t,n,e){var r=yv.exec(n.slice(e,e+1));return r?e+r[0].length:-1}function Ov(t,n,e){var r=gv.exec(n.slice(e));return r?(t.Q=+r[0],e+r[0].length):-1}function Bv(t,n,e){var r=gv.exec(n.slice(e));return r?(t.s=+r[0],e+r[0].length):-1}function Yv(t,n){return _v(t.getDate(),n,2)}function Lv(t,n){return _v(t.getHours(),n,2)}function jv(t,n){return _v(t.getHours()%12||12,n,2)}function Hv(t,n){return _v(1+py.count(tv(t),t),n,3)}function Xv(t,n){return _v(t.getMilliseconds(),n,3)}function Gv(t,n){return Xv(t,n)+"000"}function Vv(t,n){return _v(t.getMonth()+1,n,2)}function Wv(t,n){return _v(t.getMinutes(),n,2)}function Zv(t,n){return _v(t.getSeconds(),n,2)}function Kv(t){var n=t.getDay();return 0===n?7:n}function Qv(t,n){return _v(xy.count(tv(t)-1,t),n,2)}function Jv(t){var n=t.getDay();return n>=4||0===n?Ay(t):Ay.ceil(t)}function t_(t,n){return t=Jv(t),_v(Ay.count(tv(t),t)+(4===tv(t).getDay()),n,2)}function n_(t){return t.getDay()}function e_(t,n){return _v(wy.count(tv(t)-1,t),n,2)}function r_(t,n){return _v(t.getFullYear()%100,n,2)}function i_(t,n){return _v((t=Jv(t)).getFullYear()%100,n,2)}function o_(t,n){return _v(t.getFullYear()%1e4,n,4)}function a_(t,n){var e=t.getDay();return _v((t=e>=4||0===e?Ay(t):Ay.ceil(t)).getFullYear()%1e4,n,4)}function u_(t){var n=t.getTimezoneOffset();return(n>0?"-":(n*=-1,"+"))+_v(n/60|0,"0",2)+_v(n%60,"0",2)}function c_(t,n){return _v(t.getUTCDate(),n,2)}function f_(t,n){return _v(t.getUTCHours(),n,2)}function s_(t,n){return _v(t.getUTCHours()%12||12,n,2)}function l_(t,n){return _v(1+yy.count(ev(t),t),n,3)}function h_(t,n){return _v(t.getUTCMilliseconds(),n,3)}function d_(t,n){return h_(t,n)+"000"}function p_(t,n){return _v(t.getUTCMonth()+1,n,2)}function g_(t,n){return _v(t.getUTCMinutes(),n,2)}function y_(t,n){return _v(t.getUTCSeconds(),n,2)}function v_(t){var n=t.getUTCDay();return 0===n?7:n}function __(t,n){return _v(Fy.count(ev(t)-1,t),n,2)}function b_(t){var n=t.getUTCDay();return n>=4||0===n?Oy(t):Oy.ceil(t)}function m_(t,n){return t=b_(t),_v(Oy.count(ev(t),t)+(4===ev(t).getUTCDay()),n,2)}function x_(t){return t.getUTCDay()}function w_(t,n){return _v(qy.count(ev(t)-1,t),n,2)}function M_(t,n){return _v(t.getUTCFullYear()%100,n,2)}function T_(t,n){return _v((t=b_(t)).getUTCFullYear()%100,n,2)}function A_(t,n){return _v(t.getUTCFullYear()%1e4,n,4)}function S_(t,n){var e=t.getUTCDay();return _v((t=e>=4||0===e?Oy(t):Oy.ceil(t)).getUTCFullYear()%1e4,n,4)}function E_(){return"+0000"}function N_(){return"%"}function k_(t){return+t}function C_(t){return Math.floor(+t/1e3)}function P_(n){return dv=hv(n),t.timeFormat=dv.format,t.timeParse=dv.parse,t.utcFormat=dv.utcFormat,t.utcParse=dv.utcParse,dv}t.timeFormat=void 0,t.timeParse=void 0,t.utcFormat=void 0,t.utcParse=void 0,P_({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});var z_="%Y-%m-%dT%H:%M:%S.%LZ";var $_=Date.prototype.toISOString?function(t){return t.toISOString()}:t.utcFormat(z_),D_=$_;var R_=+new Date("2000-01-01T00:00:00.000Z")?function(t){var n=new Date(t);return isNaN(n)?null:n}:t.utcParse(z_),F_=R_;function q_(t){return new Date(t)}function U_(t){return t instanceof Date?+t:+new Date(+t)}function I_(t,n,e,r,i,o,a,u,c,f){var s=Sg(),l=s.invert,h=s.domain,d=f(".%L"),p=f(":%S"),g=f("%I:%M"),y=f("%I %p"),v=f("%a %d"),_=f("%b %d"),b=f("%B"),m=f("%Y");function x(t){return(c(t)Fr(t[t.length-1]),ib=new Array(3).concat("d8b365f5f5f55ab4ac","a6611adfc27d80cdc1018571","a6611adfc27df5f5f580cdc1018571","8c510ad8b365f6e8c3c7eae55ab4ac01665e","8c510ad8b365f6e8c3f5f5f5c7eae55ab4ac01665e","8c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e","8c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e","5430058c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e003c30","5430058c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e003c30").map(H_),ob=rb(ib),ab=new Array(3).concat("af8dc3f7f7f77fbf7b","7b3294c2a5cfa6dba0008837","7b3294c2a5cff7f7f7a6dba0008837","762a83af8dc3e7d4e8d9f0d37fbf7b1b7837","762a83af8dc3e7d4e8f7f7f7d9f0d37fbf7b1b7837","762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b7837","762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b7837","40004b762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b783700441b","40004b762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b783700441b").map(H_),ub=rb(ab),cb=new Array(3).concat("e9a3c9f7f7f7a1d76a","d01c8bf1b6dab8e1864dac26","d01c8bf1b6daf7f7f7b8e1864dac26","c51b7de9a3c9fde0efe6f5d0a1d76a4d9221","c51b7de9a3c9fde0eff7f7f7e6f5d0a1d76a4d9221","c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221","c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221","8e0152c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221276419","8e0152c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221276419").map(H_),fb=rb(cb),sb=new Array(3).concat("998ec3f7f7f7f1a340","5e3c99b2abd2fdb863e66101","5e3c99b2abd2f7f7f7fdb863e66101","542788998ec3d8daebfee0b6f1a340b35806","542788998ec3d8daebf7f7f7fee0b6f1a340b35806","5427888073acb2abd2d8daebfee0b6fdb863e08214b35806","5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b35806","2d004b5427888073acb2abd2d8daebfee0b6fdb863e08214b358067f3b08","2d004b5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b358067f3b08").map(H_),lb=rb(sb),hb=new Array(3).concat("ef8a62f7f7f767a9cf","ca0020f4a58292c5de0571b0","ca0020f4a582f7f7f792c5de0571b0","b2182bef8a62fddbc7d1e5f067a9cf2166ac","b2182bef8a62fddbc7f7f7f7d1e5f067a9cf2166ac","b2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac","b2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac","67001fb2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac053061","67001fb2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac053061").map(H_),db=rb(hb),pb=new Array(3).concat("ef8a62ffffff999999","ca0020f4a582bababa404040","ca0020f4a582ffffffbababa404040","b2182bef8a62fddbc7e0e0e09999994d4d4d","b2182bef8a62fddbc7ffffffe0e0e09999994d4d4d","b2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d","b2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d","67001fb2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d1a1a1a","67001fb2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d1a1a1a").map(H_),gb=rb(pb),yb=new Array(3).concat("fc8d59ffffbf91bfdb","d7191cfdae61abd9e92c7bb6","d7191cfdae61ffffbfabd9e92c7bb6","d73027fc8d59fee090e0f3f891bfdb4575b4","d73027fc8d59fee090ffffbfe0f3f891bfdb4575b4","d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4","d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4","a50026d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4313695","a50026d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4313695").map(H_),vb=rb(yb),_b=new Array(3).concat("fc8d59ffffbf91cf60","d7191cfdae61a6d96a1a9641","d7191cfdae61ffffbfa6d96a1a9641","d73027fc8d59fee08bd9ef8b91cf601a9850","d73027fc8d59fee08bffffbfd9ef8b91cf601a9850","d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850","d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850","a50026d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850006837","a50026d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850006837").map(H_),bb=rb(_b),mb=new Array(3).concat("fc8d59ffffbf99d594","d7191cfdae61abdda42b83ba","d7191cfdae61ffffbfabdda42b83ba","d53e4ffc8d59fee08be6f59899d5943288bd","d53e4ffc8d59fee08bffffbfe6f59899d5943288bd","d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd","d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd","9e0142d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd5e4fa2","9e0142d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd5e4fa2").map(H_),xb=rb(mb),wb=new Array(3).concat("e5f5f999d8c92ca25f","edf8fbb2e2e266c2a4238b45","edf8fbb2e2e266c2a42ca25f006d2c","edf8fbccece699d8c966c2a42ca25f006d2c","edf8fbccece699d8c966c2a441ae76238b45005824","f7fcfde5f5f9ccece699d8c966c2a441ae76238b45005824","f7fcfde5f5f9ccece699d8c966c2a441ae76238b45006d2c00441b").map(H_),Mb=rb(wb),Tb=new Array(3).concat("e0ecf49ebcda8856a7","edf8fbb3cde38c96c688419d","edf8fbb3cde38c96c68856a7810f7c","edf8fbbfd3e69ebcda8c96c68856a7810f7c","edf8fbbfd3e69ebcda8c96c68c6bb188419d6e016b","f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d6e016b","f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d810f7c4d004b").map(H_),Ab=rb(Tb),Sb=new Array(3).concat("e0f3dba8ddb543a2ca","f0f9e8bae4bc7bccc42b8cbe","f0f9e8bae4bc7bccc443a2ca0868ac","f0f9e8ccebc5a8ddb57bccc443a2ca0868ac","f0f9e8ccebc5a8ddb57bccc44eb3d32b8cbe08589e","f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe08589e","f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe0868ac084081").map(H_),Eb=rb(Sb),Nb=new Array(3).concat("fee8c8fdbb84e34a33","fef0d9fdcc8afc8d59d7301f","fef0d9fdcc8afc8d59e34a33b30000","fef0d9fdd49efdbb84fc8d59e34a33b30000","fef0d9fdd49efdbb84fc8d59ef6548d7301f990000","fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301f990000","fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301fb300007f0000").map(H_),kb=rb(Nb),Cb=new Array(3).concat("ece2f0a6bddb1c9099","f6eff7bdc9e167a9cf02818a","f6eff7bdc9e167a9cf1c9099016c59","f6eff7d0d1e6a6bddb67a9cf1c9099016c59","f6eff7d0d1e6a6bddb67a9cf3690c002818a016450","fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016450","fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016c59014636").map(H_),Pb=rb(Cb),zb=new Array(3).concat("ece7f2a6bddb2b8cbe","f1eef6bdc9e174a9cf0570b0","f1eef6bdc9e174a9cf2b8cbe045a8d","f1eef6d0d1e6a6bddb74a9cf2b8cbe045a8d","f1eef6d0d1e6a6bddb74a9cf3690c00570b0034e7b","fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0034e7b","fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0045a8d023858").map(H_),$b=rb(zb),Db=new Array(3).concat("e7e1efc994c7dd1c77","f1eef6d7b5d8df65b0ce1256","f1eef6d7b5d8df65b0dd1c77980043","f1eef6d4b9dac994c7df65b0dd1c77980043","f1eef6d4b9dac994c7df65b0e7298ace125691003f","f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125691003f","f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125698004367001f").map(H_),Rb=rb(Db),Fb=new Array(3).concat("fde0ddfa9fb5c51b8a","feebe2fbb4b9f768a1ae017e","feebe2fbb4b9f768a1c51b8a7a0177","feebe2fcc5c0fa9fb5f768a1c51b8a7a0177","feebe2fcc5c0fa9fb5f768a1dd3497ae017e7a0177","fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a0177","fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a017749006a").map(H_),qb=rb(Fb),Ub=new Array(3).concat("edf8b17fcdbb2c7fb8","ffffcca1dab441b6c4225ea8","ffffcca1dab441b6c42c7fb8253494","ffffccc7e9b47fcdbb41b6c42c7fb8253494","ffffccc7e9b47fcdbb41b6c41d91c0225ea80c2c84","ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea80c2c84","ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea8253494081d58").map(H_),Ib=rb(Ub),Ob=new Array(3).concat("f7fcb9addd8e31a354","ffffccc2e69978c679238443","ffffccc2e69978c67931a354006837","ffffccd9f0a3addd8e78c67931a354006837","ffffccd9f0a3addd8e78c67941ab5d238443005a32","ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443005a32","ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443006837004529").map(H_),Bb=rb(Ob),Yb=new Array(3).concat("fff7bcfec44fd95f0e","ffffd4fed98efe9929cc4c02","ffffd4fed98efe9929d95f0e993404","ffffd4fee391fec44ffe9929d95f0e993404","ffffd4fee391fec44ffe9929ec7014cc4c028c2d04","ffffe5fff7bcfee391fec44ffe9929ec7014cc4c028c2d04","ffffe5fff7bcfee391fec44ffe9929ec7014cc4c02993404662506").map(H_),Lb=rb(Yb),jb=new Array(3).concat("ffeda0feb24cf03b20","ffffb2fecc5cfd8d3ce31a1c","ffffb2fecc5cfd8d3cf03b20bd0026","ffffb2fed976feb24cfd8d3cf03b20bd0026","ffffb2fed976feb24cfd8d3cfc4e2ae31a1cb10026","ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cb10026","ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cbd0026800026").map(H_),Hb=rb(jb),Xb=new Array(3).concat("deebf79ecae13182bd","eff3ffbdd7e76baed62171b5","eff3ffbdd7e76baed63182bd08519c","eff3ffc6dbef9ecae16baed63182bd08519c","eff3ffc6dbef9ecae16baed64292c62171b5084594","f7fbffdeebf7c6dbef9ecae16baed64292c62171b5084594","f7fbffdeebf7c6dbef9ecae16baed64292c62171b508519c08306b").map(H_),Gb=rb(Xb),Vb=new Array(3).concat("e5f5e0a1d99b31a354","edf8e9bae4b374c476238b45","edf8e9bae4b374c47631a354006d2c","edf8e9c7e9c0a1d99b74c47631a354006d2c","edf8e9c7e9c0a1d99b74c47641ab5d238b45005a32","f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45005a32","f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45006d2c00441b").map(H_),Wb=rb(Vb),Zb=new Array(3).concat("f0f0f0bdbdbd636363","f7f7f7cccccc969696525252","f7f7f7cccccc969696636363252525","f7f7f7d9d9d9bdbdbd969696636363252525","f7f7f7d9d9d9bdbdbd969696737373525252252525","fffffff0f0f0d9d9d9bdbdbd969696737373525252252525","fffffff0f0f0d9d9d9bdbdbd969696737373525252252525000000").map(H_),Kb=rb(Zb),Qb=new Array(3).concat("efedf5bcbddc756bb1","f2f0f7cbc9e29e9ac86a51a3","f2f0f7cbc9e29e9ac8756bb154278f","f2f0f7dadaebbcbddc9e9ac8756bb154278f","f2f0f7dadaebbcbddc9e9ac8807dba6a51a34a1486","fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a34a1486","fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a354278f3f007d").map(H_),Jb=rb(Qb),tm=new Array(3).concat("fee0d2fc9272de2d26","fee5d9fcae91fb6a4acb181d","fee5d9fcae91fb6a4ade2d26a50f15","fee5d9fcbba1fc9272fb6a4ade2d26a50f15","fee5d9fcbba1fc9272fb6a4aef3b2ccb181d99000d","fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181d99000d","fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181da50f1567000d").map(H_),nm=rb(tm),em=new Array(3).concat("fee6cefdae6be6550d","feeddefdbe85fd8d3cd94701","feeddefdbe85fd8d3ce6550da63603","feeddefdd0a2fdae6bfd8d3ce6550da63603","feeddefdd0a2fdae6bfd8d3cf16913d948018c2d04","fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d948018c2d04","fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d94801a636037f2704").map(H_),rm=rb(em);var im=hi(Tr(300,.5,0),Tr(-240,.5,1)),om=hi(Tr(-100,.75,.35),Tr(80,1.5,.8)),am=hi(Tr(260,.75,.35),Tr(80,1.5,.8)),um=Tr();var cm=Fe(),fm=Math.PI/3,sm=2*Math.PI/3;function lm(t){var n=t.length;return function(e){return t[Math.max(0,Math.min(n-1,Math.floor(e*n)))]}}var hm=lm(H_("44015444025645045745055946075a46085c460a5d460b5e470d60470e6147106347116447136548146748166848176948186a481a6c481b6d481c6e481d6f481f70482071482173482374482475482576482677482878482979472a7a472c7a472d7b472e7c472f7d46307e46327e46337f463480453581453781453882443983443a83443b84433d84433e85423f854240864241864142874144874045884046883f47883f48893e49893e4a893e4c8a3d4d8a3d4e8a3c4f8a3c508b3b518b3b528b3a538b3a548c39558c39568c38588c38598c375a8c375b8d365c8d365d8d355e8d355f8d34608d34618d33628d33638d32648e32658e31668e31678e31688e30698e306a8e2f6b8e2f6c8e2e6d8e2e6e8e2e6f8e2d708e2d718e2c718e2c728e2c738e2b748e2b758e2a768e2a778e2a788e29798e297a8e297b8e287c8e287d8e277e8e277f8e27808e26818e26828e26828e25838e25848e25858e24868e24878e23888e23898e238a8d228b8d228c8d228d8d218e8d218f8d21908d21918c20928c20928c20938c1f948c1f958b1f968b1f978b1f988b1f998a1f9a8a1e9b8a1e9c891e9d891f9e891f9f881fa0881fa1881fa1871fa28720a38620a48621a58521a68522a78522a88423a98324aa8325ab8225ac8226ad8127ad8128ae8029af7f2ab07f2cb17e2db27d2eb37c2fb47c31b57b32b67a34b67935b77937b87838b9773aba763bbb753dbc743fbc7340bd7242be7144bf7046c06f48c16e4ac16d4cc26c4ec36b50c46a52c56954c56856c66758c7655ac8645cc8635ec96260ca6063cb5f65cb5e67cc5c69cd5b6ccd5a6ece5870cf5773d05675d05477d1537ad1517cd2507fd34e81d34d84d44b86d54989d5488bd6468ed64590d74393d74195d84098d83e9bd93c9dd93ba0da39a2da37a5db36a8db34aadc32addc30b0dd2fb2dd2db5de2bb8de29bade28bddf26c0df25c2df23c5e021c8e020cae11fcde11dd0e11cd2e21bd5e21ad8e219dae319dde318dfe318e2e418e5e419e7e419eae51aece51befe51cf1e51df4e61ef6e620f8e621fbe723fde725")),dm=lm(H_("00000401000501010601010802010902020b02020d03030f03031204041405041606051806051a07061c08071e0907200a08220b09240c09260d0a290e0b2b100b2d110c2f120d31130d34140e36150e38160f3b180f3d19103f1a10421c10441d11471e114920114b21114e22115024125325125527125829115a2a115c2c115f2d11612f116331116533106734106936106b38106c390f6e3b0f703d0f713f0f72400f74420f75440f764510774710784910784a10794c117a4e117b4f127b51127c52137c54137d56147d57157e59157e5a167e5c167f5d177f5f187f601880621980641a80651a80671b80681c816a1c816b1d816d1d816e1e81701f81721f817320817521817621817822817922827b23827c23827e24828025828125818326818426818627818827818928818b29818c29818e2a81902a81912b81932b80942c80962c80982d80992d809b2e7f9c2e7f9e2f7fa02f7fa1307ea3307ea5317ea6317da8327daa337dab337cad347cae347bb0357bb2357bb3367ab5367ab73779b83779ba3878bc3978bd3977bf3a77c03a76c23b75c43c75c53c74c73d73c83e73ca3e72cc3f71cd4071cf4070d0416fd2426fd3436ed5446dd6456cd8456cd9466bdb476adc4869de4968df4a68e04c67e24d66e34e65e44f64e55064e75263e85362e95462ea5661eb5760ec5860ed5a5fee5b5eef5d5ef05f5ef1605df2625df2645cf3655cf4675cf4695cf56b5cf66c5cf66e5cf7705cf7725cf8745cf8765cf9785df9795df97b5dfa7d5efa7f5efa815ffb835ffb8560fb8761fc8961fc8a62fc8c63fc8e64fc9065fd9266fd9467fd9668fd9869fd9a6afd9b6bfe9d6cfe9f6dfea16efea36ffea571fea772fea973feaa74feac76feae77feb078feb27afeb47bfeb67cfeb77efeb97ffebb81febd82febf84fec185fec287fec488fec68afec88cfeca8dfecc8ffecd90fecf92fed194fed395fed597fed799fed89afdda9cfddc9efddea0fde0a1fde2a3fde3a5fde5a7fde7a9fde9aafdebacfcecaefceeb0fcf0b2fcf2b4fcf4b6fcf6b8fcf7b9fcf9bbfcfbbdfcfdbf")),pm=lm(H_("00000401000501010601010802010a02020c02020e03021004031204031405041706041907051b08051d09061f0a07220b07240c08260d08290e092b10092d110a30120a32140b34150b37160b39180c3c190c3e1b0c411c0c431e0c451f0c48210c4a230c4c240c4f260c51280b53290b552b0b572d0b592f0a5b310a5c320a5e340a5f3609613809623909633b09643d09653e0966400a67420a68440a68450a69470b6a490b6a4a0c6b4c0c6b4d0d6c4f0d6c510e6c520e6d540f6d550f6d57106e59106e5a116e5c126e5d126e5f136e61136e62146e64156e65156e67166e69166e6a176e6c186e6d186e6f196e71196e721a6e741a6e751b6e771c6d781c6d7a1d6d7c1d6d7d1e6d7f1e6c801f6c82206c84206b85216b87216b88226a8a226a8c23698d23698f24699025689225689326679526679727669827669a28659b29649d29649f2a63a02a63a22b62a32c61a52c60a62d60a82e5fa92e5eab2f5ead305dae305cb0315bb1325ab3325ab43359b63458b73557b93556ba3655bc3754bd3853bf3952c03a51c13a50c33b4fc43c4ec63d4dc73e4cc83f4bca404acb4149cc4248ce4347cf4446d04545d24644d34743d44842d54a41d74b3fd84c3ed94d3dda4e3cdb503bdd513ade5238df5337e05536e15635e25734e35933e45a31e55c30e65d2fe75e2ee8602de9612bea632aeb6429eb6628ec6726ed6925ee6a24ef6c23ef6e21f06f20f1711ff1731df2741cf3761bf37819f47918f57b17f57d15f67e14f68013f78212f78410f8850ff8870ef8890cf98b0bf98c0af98e09fa9008fa9207fa9407fb9606fb9706fb9906fb9b06fb9d07fc9f07fca108fca309fca50afca60cfca80dfcaa0ffcac11fcae12fcb014fcb216fcb418fbb61afbb81dfbba1ffbbc21fbbe23fac026fac228fac42afac62df9c72ff9c932f9cb35f8cd37f8cf3af7d13df7d340f6d543f6d746f5d949f5db4cf4dd4ff4df53f4e156f3e35af3e55df2e661f2e865f2ea69f1ec6df1ed71f1ef75f1f179f2f27df2f482f3f586f3f68af4f88ef5f992f6fa96f8fb9af9fc9dfafda1fcffa4")),gm=lm(H_("0d088710078813078916078a19068c1b068d1d068e20068f2206902406912605912805922a05932c05942e05952f059631059733059735049837049938049a3a049a3c049b3e049c3f049c41049d43039e44039e46039f48039f4903a04b03a14c02a14e02a25002a25102a35302a35502a45601a45801a45901a55b01a55c01a65e01a66001a66100a76300a76400a76600a76700a86900a86a00a86c00a86e00a86f00a87100a87201a87401a87501a87701a87801a87a02a87b02a87d03a87e03a88004a88104a78305a78405a78606a68707a68808a68a09a58b0aa58d0ba58e0ca48f0da4910ea3920fa39410a29511a19613a19814a099159f9a169f9c179e9d189d9e199da01a9ca11b9ba21d9aa31e9aa51f99a62098a72197a82296aa2395ab2494ac2694ad2793ae2892b02991b12a90b22b8fb32c8eb42e8db52f8cb6308bb7318ab83289ba3388bb3488bc3587bd3786be3885bf3984c03a83c13b82c23c81c33d80c43e7fc5407ec6417dc7427cc8437bc9447aca457acb4679cc4778cc4977cd4a76ce4b75cf4c74d04d73d14e72d24f71d35171d45270d5536fd5546ed6556dd7566cd8576bd9586ada5a6ada5b69db5c68dc5d67dd5e66de5f65de6164df6263e06363e16462e26561e26660e3685fe4695ee56a5de56b5de66c5ce76e5be76f5ae87059e97158e97257ea7457eb7556eb7655ec7754ed7953ed7a52ee7b51ef7c51ef7e50f07f4ff0804ef1814df1834cf2844bf3854bf3874af48849f48948f58b47f58c46f68d45f68f44f79044f79143f79342f89441f89540f9973ff9983ef99a3efa9b3dfa9c3cfa9e3bfb9f3afba139fba238fca338fca537fca636fca835fca934fdab33fdac33fdae32fdaf31fdb130fdb22ffdb42ffdb52efeb72dfeb82cfeba2cfebb2bfebd2afebe2afec029fdc229fdc328fdc527fdc627fdc827fdca26fdcb26fccd25fcce25fcd025fcd225fbd324fbd524fbd724fad824fada24f9dc24f9dd25f8df25f8e125f7e225f7e425f6e626f6e826f5e926f5eb27f4ed27f3ee27f3f027f2f227f1f426f1f525f0f724f0f921"));function ym(t){return function(){return t}}const vm=Math.abs,_m=Math.atan2,bm=Math.cos,mm=Math.max,xm=Math.min,wm=Math.sin,Mm=Math.sqrt,Tm=1e-12,Am=Math.PI,Sm=Am/2,Em=2*Am;function Nm(t){return t>=1?Sm:t<=-1?-Sm:Math.asin(t)}function km(t){let n=3;return t.digits=function(e){if(!arguments.length)return n;if(null==e)n=null;else{const t=Math.floor(e);if(!(t>=0))throw new RangeError(`invalid digits: ${e}`);n=t}return t},()=>new Ua(n)}function Cm(t){return t.innerRadius}function Pm(t){return t.outerRadius}function zm(t){return t.startAngle}function $m(t){return t.endAngle}function Dm(t){return t&&t.padAngle}function Rm(t,n,e,r,i,o,a){var u=t-e,c=n-r,f=(a?o:-o)/Mm(u*u+c*c),s=f*c,l=-f*u,h=t+s,d=n+l,p=e+s,g=r+l,y=(h+p)/2,v=(d+g)/2,_=p-h,b=g-d,m=_*_+b*b,x=i-o,w=h*g-p*d,M=(b<0?-1:1)*Mm(mm(0,x*x*m-w*w)),T=(w*b-_*M)/m,A=(-w*_-b*M)/m,S=(w*b+_*M)/m,E=(-w*_+b*M)/m,N=T-y,k=A-v,C=S-y,P=E-v;return N*N+k*k>C*C+P*P&&(T=S,A=E),{cx:T,cy:A,x01:-s,y01:-l,x11:T*(i/x-1),y11:A*(i/x-1)}}var Fm=Array.prototype.slice;function qm(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}function Um(t){this._context=t}function Im(t){return new Um(t)}function Om(t){return t[0]}function Bm(t){return t[1]}function Ym(t,n){var e=ym(!0),r=null,i=Im,o=null,a=km(u);function u(u){var c,f,s,l=(u=qm(u)).length,h=!1;for(null==r&&(o=i(s=a())),c=0;c<=l;++c)!(c=l;--h)u.point(v[h],_[h]);u.lineEnd(),u.areaEnd()}y&&(v[s]=+t(d,s,f),_[s]=+n(d,s,f),u.point(r?+r(d,s,f):v[s],e?+e(d,s,f):_[s]))}if(p)return u=null,p+""||null}function s(){return Ym().defined(i).curve(a).context(o)}return t="function"==typeof t?t:void 0===t?Om:ym(+t),n="function"==typeof n?n:ym(void 0===n?0:+n),e="function"==typeof e?e:void 0===e?Bm:ym(+e),f.x=function(n){return arguments.length?(t="function"==typeof n?n:ym(+n),r=null,f):t},f.x0=function(n){return arguments.length?(t="function"==typeof n?n:ym(+n),f):t},f.x1=function(t){return arguments.length?(r=null==t?null:"function"==typeof t?t:ym(+t),f):r},f.y=function(t){return arguments.length?(n="function"==typeof t?t:ym(+t),e=null,f):n},f.y0=function(t){return arguments.length?(n="function"==typeof t?t:ym(+t),f):n},f.y1=function(t){return arguments.length?(e=null==t?null:"function"==typeof t?t:ym(+t),f):e},f.lineX0=f.lineY0=function(){return s().x(t).y(n)},f.lineY1=function(){return s().x(t).y(e)},f.lineX1=function(){return s().x(r).y(n)},f.defined=function(t){return arguments.length?(i="function"==typeof t?t:ym(!!t),f):i},f.curve=function(t){return arguments.length?(a=t,null!=o&&(u=a(o)),f):a},f.context=function(t){return arguments.length?(null==t?o=u=null:u=a(o=t),f):o},f}function jm(t,n){return nt?1:n>=t?0:NaN}function Hm(t){return t}Um.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:this._context.lineTo(t,n)}}};var Xm=Vm(Im);function Gm(t){this._curve=t}function Vm(t){function n(n){return new Gm(t(n))}return n._curve=t,n}function Wm(t){var n=t.curve;return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t.curve=function(t){return arguments.length?n(Vm(t)):n()._curve},t}function Zm(){return Wm(Ym().curve(Xm))}function Km(){var t=Lm().curve(Xm),n=t.curve,e=t.lineX0,r=t.lineX1,i=t.lineY0,o=t.lineY1;return t.angle=t.x,delete t.x,t.startAngle=t.x0,delete t.x0,t.endAngle=t.x1,delete t.x1,t.radius=t.y,delete t.y,t.innerRadius=t.y0,delete t.y0,t.outerRadius=t.y1,delete t.y1,t.lineStartAngle=function(){return Wm(e())},delete t.lineX0,t.lineEndAngle=function(){return Wm(r())},delete t.lineX1,t.lineInnerRadius=function(){return Wm(i())},delete t.lineY0,t.lineOuterRadius=function(){return Wm(o())},delete t.lineY1,t.curve=function(t){return arguments.length?n(Vm(t)):n()._curve},t}function Qm(t,n){return[(n=+n)*Math.cos(t-=Math.PI/2),n*Math.sin(t)]}Gm.prototype={areaStart:function(){this._curve.areaStart()},areaEnd:function(){this._curve.areaEnd()},lineStart:function(){this._curve.lineStart()},lineEnd:function(){this._curve.lineEnd()},point:function(t,n){this._curve.point(n*Math.sin(t),n*-Math.cos(t))}};class Jm{constructor(t,n){this._context=t,this._x=n}areaStart(){this._line=0}areaEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line}point(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:this._x?this._context.bezierCurveTo(this._x0=(this._x0+t)/2,this._y0,this._x0,n,t,n):this._context.bezierCurveTo(this._x0,this._y0=(this._y0+n)/2,t,this._y0,t,n)}this._x0=t,this._y0=n}}class tx{constructor(t){this._context=t}lineStart(){this._point=0}lineEnd(){}point(t,n){if(t=+t,n=+n,0===this._point)this._point=1;else{const e=Qm(this._x0,this._y0),r=Qm(this._x0,this._y0=(this._y0+n)/2),i=Qm(t,this._y0),o=Qm(t,n);this._context.moveTo(...e),this._context.bezierCurveTo(...r,...i,...o)}this._x0=t,this._y0=n}}function nx(t){return new Jm(t,!0)}function ex(t){return new Jm(t,!1)}function rx(t){return new tx(t)}function ix(t){return t.source}function ox(t){return t.target}function ax(t){let n=ix,e=ox,r=Om,i=Bm,o=null,a=null,u=km(c);function c(){let c;const f=Fm.call(arguments),s=n.apply(this,f),l=e.apply(this,f);if(null==o&&(a=t(c=u())),a.lineStart(),f[0]=s,a.point(+r.apply(this,f),+i.apply(this,f)),f[0]=l,a.point(+r.apply(this,f),+i.apply(this,f)),a.lineEnd(),c)return a=null,c+""||null}return c.source=function(t){return arguments.length?(n=t,c):n},c.target=function(t){return arguments.length?(e=t,c):e},c.x=function(t){return arguments.length?(r="function"==typeof t?t:ym(+t),c):r},c.y=function(t){return arguments.length?(i="function"==typeof t?t:ym(+t),c):i},c.context=function(n){return arguments.length?(null==n?o=a=null:a=t(o=n),c):o},c}const ux=Mm(3);var cx={draw(t,n){const e=.59436*Mm(n+xm(n/28,.75)),r=e/2,i=r*ux;t.moveTo(0,e),t.lineTo(0,-e),t.moveTo(-i,-r),t.lineTo(i,r),t.moveTo(-i,r),t.lineTo(i,-r)}},fx={draw(t,n){const e=Mm(n/Am);t.moveTo(e,0),t.arc(0,0,e,0,Em)}},sx={draw(t,n){const e=Mm(n/5)/2;t.moveTo(-3*e,-e),t.lineTo(-e,-e),t.lineTo(-e,-3*e),t.lineTo(e,-3*e),t.lineTo(e,-e),t.lineTo(3*e,-e),t.lineTo(3*e,e),t.lineTo(e,e),t.lineTo(e,3*e),t.lineTo(-e,3*e),t.lineTo(-e,e),t.lineTo(-3*e,e),t.closePath()}};const lx=Mm(1/3),hx=2*lx;var dx={draw(t,n){const e=Mm(n/hx),r=e*lx;t.moveTo(0,-e),t.lineTo(r,0),t.lineTo(0,e),t.lineTo(-r,0),t.closePath()}},px={draw(t,n){const e=.62625*Mm(n);t.moveTo(0,-e),t.lineTo(e,0),t.lineTo(0,e),t.lineTo(-e,0),t.closePath()}},gx={draw(t,n){const e=.87559*Mm(n-xm(n/7,2));t.moveTo(-e,0),t.lineTo(e,0),t.moveTo(0,e),t.lineTo(0,-e)}},yx={draw(t,n){const e=Mm(n),r=-e/2;t.rect(r,r,e,e)}},vx={draw(t,n){const e=.4431*Mm(n);t.moveTo(e,e),t.lineTo(e,-e),t.lineTo(-e,-e),t.lineTo(-e,e),t.closePath()}};const _x=wm(Am/10)/wm(7*Am/10),bx=wm(Em/10)*_x,mx=-bm(Em/10)*_x;var xx={draw(t,n){const e=Mm(.8908130915292852*n),r=bx*e,i=mx*e;t.moveTo(0,-e),t.lineTo(r,i);for(let n=1;n<5;++n){const o=Em*n/5,a=bm(o),u=wm(o);t.lineTo(u*e,-a*e),t.lineTo(a*r-u*i,u*r+a*i)}t.closePath()}};const wx=Mm(3);var Mx={draw(t,n){const e=-Mm(n/(3*wx));t.moveTo(0,2*e),t.lineTo(-wx*e,-e),t.lineTo(wx*e,-e),t.closePath()}};const Tx=Mm(3);var Ax={draw(t,n){const e=.6824*Mm(n),r=e/2,i=e*Tx/2;t.moveTo(0,-e),t.lineTo(i,r),t.lineTo(-i,r),t.closePath()}};const Sx=-.5,Ex=Mm(3)/2,Nx=1/Mm(12),kx=3*(Nx/2+1);var Cx={draw(t,n){const e=Mm(n/kx),r=e/2,i=e*Nx,o=r,a=e*Nx+e,u=-o,c=a;t.moveTo(r,i),t.lineTo(o,a),t.lineTo(u,c),t.lineTo(Sx*r-Ex*i,Ex*r+Sx*i),t.lineTo(Sx*o-Ex*a,Ex*o+Sx*a),t.lineTo(Sx*u-Ex*c,Ex*u+Sx*c),t.lineTo(Sx*r+Ex*i,Sx*i-Ex*r),t.lineTo(Sx*o+Ex*a,Sx*a-Ex*o),t.lineTo(Sx*u+Ex*c,Sx*c-Ex*u),t.closePath()}},Px={draw(t,n){const e=.6189*Mm(n-xm(n/6,1.7));t.moveTo(-e,-e),t.lineTo(e,e),t.moveTo(-e,e),t.lineTo(e,-e)}};const zx=[fx,sx,dx,yx,xx,Mx,Cx],$x=[fx,gx,Px,Ax,cx,vx,px];function Dx(){}function Rx(t,n,e){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+n)/6,(t._y0+4*t._y1+e)/6)}function Fx(t){this._context=t}function qx(t){this._context=t}function Ux(t){this._context=t}function Ix(t,n){this._basis=new Fx(t),this._beta=n}Fx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){switch(this._point){case 3:Rx(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:Rx(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},qx.prototype={areaStart:Dx,areaEnd:Dx,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x2,this._y2),this._context.closePath();break;case 2:this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break;case 3:this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4)}},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._x2=t,this._y2=n;break;case 1:this._point=2,this._x3=t,this._y3=n;break;case 2:this._point=3,this._x4=t,this._y4=n,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+n)/6);break;default:Rx(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},Ux.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var e=(this._x0+4*this._x1+t)/6,r=(this._y0+4*this._y1+n)/6;this._line?this._context.lineTo(e,r):this._context.moveTo(e,r);break;case 3:this._point=4;default:Rx(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},Ix.prototype={lineStart:function(){this._x=[],this._y=[],this._basis.lineStart()},lineEnd:function(){var t=this._x,n=this._y,e=t.length-1;if(e>0)for(var r,i=t[0],o=n[0],a=t[e]-i,u=n[e]-o,c=-1;++c<=e;)r=c/e,this._basis.point(this._beta*t[c]+(1-this._beta)*(i+r*a),this._beta*n[c]+(1-this._beta)*(o+r*u));this._x=this._y=null,this._basis.lineEnd()},point:function(t,n){this._x.push(+t),this._y.push(+n)}};var Ox=function t(n){function e(t){return 1===n?new Fx(t):new Ix(t,n)}return e.beta=function(n){return t(+n)},e}(.85);function Bx(t,n,e){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-n),t._y2+t._k*(t._y1-e),t._x2,t._y2)}function Yx(t,n){this._context=t,this._k=(1-n)/6}Yx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:Bx(this,this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2,this._x1=t,this._y1=n;break;case 2:this._point=3;default:Bx(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Lx=function t(n){function e(t){return new Yx(t,n)}return e.tension=function(n){return t(+n)},e}(0);function jx(t,n){this._context=t,this._k=(1-n)/6}jx.prototype={areaStart:Dx,areaEnd:Dx,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._x3=t,this._y3=n;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3,this._x5=t,this._y5=n;break;default:Bx(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Hx=function t(n){function e(t){return new jx(t,n)}return e.tension=function(n){return t(+n)},e}(0);function Xx(t,n){this._context=t,this._k=(1-n)/6}Xx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Bx(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Gx=function t(n){function e(t){return new Xx(t,n)}return e.tension=function(n){return t(+n)},e}(0);function Vx(t,n,e){var r=t._x1,i=t._y1,o=t._x2,a=t._y2;if(t._l01_a>Tm){var u=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,c=3*t._l01_a*(t._l01_a+t._l12_a);r=(r*u-t._x0*t._l12_2a+t._x2*t._l01_2a)/c,i=(i*u-t._y0*t._l12_2a+t._y2*t._l01_2a)/c}if(t._l23_a>Tm){var f=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,s=3*t._l23_a*(t._l23_a+t._l12_a);o=(o*f+t._x1*t._l23_2a-n*t._l12_2a)/s,a=(a*f+t._y1*t._l23_2a-e*t._l12_2a)/s}t._context.bezierCurveTo(r,i,o,a,t._x2,t._y2)}function Wx(t,n){this._context=t,this._alpha=n}Wx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3;default:Vx(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Zx=function t(n){function e(t){return n?new Wx(t,n):new Yx(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function Kx(t,n){this._context=t,this._alpha=n}Kx.prototype={areaStart:Dx,areaEnd:Dx,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._x3=t,this._y3=n;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3,this._x5=t,this._y5=n;break;default:Vx(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Qx=function t(n){function e(t){return n?new Kx(t,n):new jx(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function Jx(t,n){this._context=t,this._alpha=n}Jx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Vx(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var tw=function t(n){function e(t){return n?new Jx(t,n):new Xx(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function nw(t){this._context=t}function ew(t){return t<0?-1:1}function rw(t,n,e){var r=t._x1-t._x0,i=n-t._x1,o=(t._y1-t._y0)/(r||i<0&&-0),a=(e-t._y1)/(i||r<0&&-0),u=(o*i+a*r)/(r+i);return(ew(o)+ew(a))*Math.min(Math.abs(o),Math.abs(a),.5*Math.abs(u))||0}function iw(t,n){var e=t._x1-t._x0;return e?(3*(t._y1-t._y0)/e-n)/2:n}function ow(t,n,e){var r=t._x0,i=t._y0,o=t._x1,a=t._y1,u=(o-r)/3;t._context.bezierCurveTo(r+u,i+u*n,o-u,a-u*e,o,a)}function aw(t){this._context=t}function uw(t){this._context=new cw(t)}function cw(t){this._context=t}function fw(t){this._context=t}function sw(t){var n,e,r=t.length-1,i=new Array(r),o=new Array(r),a=new Array(r);for(i[0]=0,o[0]=2,a[0]=t[0]+2*t[1],n=1;n=0;--n)i[n]=(a[n]-i[n+1])/o[n];for(o[r-1]=(t[r]+i[r-1])/2,n=0;n1)for(var e,r,i,o=1,a=t[n[0]],u=a.length;o=0;)e[n]=n;return e}function pw(t,n){return t[n]}function gw(t){const n=[];return n.key=t,n}function yw(t){var n=t.map(vw);return dw(t).sort((function(t,e){return n[t]-n[e]}))}function vw(t){for(var n,e=-1,r=0,i=t.length,o=-1/0;++eo&&(o=n,r=e);return r}function _w(t){var n=t.map(bw);return dw(t).sort((function(t,e){return n[t]-n[e]}))}function bw(t){for(var n,e=0,r=-1,i=t.length;++r=0&&(this._t=1-this._t,this._line=1-this._line)},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:if(this._t<=0)this._context.lineTo(this._x,n),this._context.lineTo(t,n);else{var e=this._x*(1-this._t)+t*this._t;this._context.lineTo(e,this._y),this._context.lineTo(e,n)}}this._x=t,this._y=n}};var mw=t=>()=>t;function xw(t,{sourceEvent:n,target:e,transform:r,dispatch:i}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:n,enumerable:!0,configurable:!0},target:{value:e,enumerable:!0,configurable:!0},transform:{value:r,enumerable:!0,configurable:!0},_:{value:i}})}function ww(t,n,e){this.k=t,this.x=n,this.y=e}ww.prototype={constructor:ww,scale:function(t){return 1===t?this:new ww(this.k*t,this.x,this.y)},translate:function(t,n){return 0===t&0===n?this:new ww(this.k,this.x+this.k*t,this.y+this.k*n)},apply:function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},applyX:function(t){return t*this.k+this.x},applyY:function(t){return t*this.k+this.y},invert:function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},invertX:function(t){return(t-this.x)/this.k},invertY:function(t){return(t-this.y)/this.k},rescaleX:function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},rescaleY:function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},toString:function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"}};var Mw=new ww(1,0,0);function Tw(t){for(;!t.__zoom;)if(!(t=t.parentNode))return Mw;return t.__zoom}function Aw(t){t.stopImmediatePropagation()}function Sw(t){t.preventDefault(),t.stopImmediatePropagation()}function Ew(t){return!(t.ctrlKey&&"wheel"!==t.type||t.button)}function Nw(){var t=this;return t instanceof SVGElement?(t=t.ownerSVGElement||t).hasAttribute("viewBox")?[[(t=t.viewBox.baseVal).x,t.y],[t.x+t.width,t.y+t.height]]:[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]:[[0,0],[t.clientWidth,t.clientHeight]]}function kw(){return this.__zoom||Mw}function Cw(t){return-t.deltaY*(1===t.deltaMode?.05:t.deltaMode?1:.002)*(t.ctrlKey?10:1)}function Pw(){return navigator.maxTouchPoints||"ontouchstart"in this}function zw(t,n,e){var r=t.invertX(n[0][0])-e[0][0],i=t.invertX(n[1][0])-e[1][0],o=t.invertY(n[0][1])-e[0][1],a=t.invertY(n[1][1])-e[1][1];return t.translate(i>r?(r+i)/2:Math.min(0,r)||Math.max(0,i),a>o?(o+a)/2:Math.min(0,o)||Math.max(0,a))}Tw.prototype=ww.prototype,t.Adder=T,t.Delaunay=Lu,t.FormatSpecifier=tf,t.InternMap=InternMap,t.InternSet=InternSet,t.Node=Qd,t.Path=Ua,t.Voronoi=qu,t.ZoomTransform=ww,t.active=function(t,n){var e,r,i=t.__transition;if(i)for(r in n=null==n?null:n+"",i)if((e=i[r]).state>qi&&e.name===n)return new po([[t]],Zo,n,+r);return null},t.arc=function(){var t=Cm,n=Pm,e=ym(0),r=null,i=zm,o=$m,a=Dm,u=null,c=km(f);function f(){var f,s,l=+t.apply(this,arguments),h=+n.apply(this,arguments),d=i.apply(this,arguments)-Sm,p=o.apply(this,arguments)-Sm,g=vm(p-d),y=p>d;if(u||(u=f=c()),hTm)if(g>Em-Tm)u.moveTo(h*bm(d),h*wm(d)),u.arc(0,0,h,d,p,!y),l>Tm&&(u.moveTo(l*bm(p),l*wm(p)),u.arc(0,0,l,p,d,y));else{var v,_,b=d,m=p,x=d,w=p,M=g,T=g,A=a.apply(this,arguments)/2,S=A>Tm&&(r?+r.apply(this,arguments):Mm(l*l+h*h)),E=xm(vm(h-l)/2,+e.apply(this,arguments)),N=E,k=E;if(S>Tm){var C=Nm(S/l*wm(A)),P=Nm(S/h*wm(A));(M-=2*C)>Tm?(x+=C*=y?1:-1,w-=C):(M=0,x=w=(d+p)/2),(T-=2*P)>Tm?(b+=P*=y?1:-1,m-=P):(T=0,b=m=(d+p)/2)}var z=h*bm(b),$=h*wm(b),D=l*bm(w),R=l*wm(w);if(E>Tm){var F,q=h*bm(m),U=h*wm(m),I=l*bm(x),O=l*wm(x);if(g1?0:t<-1?Am:Math.acos(t)}((B*L+Y*j)/(Mm(B*B+Y*Y)*Mm(L*L+j*j)))/2),X=Mm(F[0]*F[0]+F[1]*F[1]);N=xm(E,(l-X)/(H-1)),k=xm(E,(h-X)/(H+1))}else N=k=0}T>Tm?k>Tm?(v=Rm(I,O,z,$,h,k,y),_=Rm(q,U,D,R,h,k,y),u.moveTo(v.cx+v.x01,v.cy+v.y01),kTm&&M>Tm?N>Tm?(v=Rm(D,R,q,U,l,-N,y),_=Rm(z,$,I,O,l,-N,y),u.lineTo(v.cx+v.x01,v.cy+v.y01),N=0))throw new RangeError("invalid r");let e=t.length;if(!((e=Math.floor(e))>=0))throw new RangeError("invalid length");if(!e||!n)return t;const r=y(n),i=t.slice();return r(t,i,0,e,1),r(i,t,0,e,1),r(t,i,0,e,1),t},t.blur2=l,t.blurImage=h,t.brush=function(){return wa(la)},t.brushSelection=function(t){var n=t.__brush;return n?n.dim.output(n.selection):null},t.brushX=function(){return wa(fa)},t.brushY=function(){return wa(sa)},t.buffer=function(t,n){return fetch(t,n).then(_c)},t.chord=function(){return za(!1,!1)},t.chordDirected=function(){return za(!0,!1)},t.chordTranspose=function(){return za(!1,!0)},t.cluster=function(){var t=Ld,n=1,e=1,r=!1;function i(i){var o,a=0;i.eachAfter((function(n){var e=n.children;e?(n.x=function(t){return t.reduce(jd,0)/t.length}(e),n.y=function(t){return 1+t.reduce(Hd,0)}(e)):(n.x=o?a+=t(n,o):0,n.y=0,o=n)}));var u=function(t){for(var n;n=t.children;)t=n[0];return t}(i),c=function(t){for(var n;n=t.children;)t=n[n.length-1];return t}(i),f=u.x-t(u,c)/2,s=c.x+t(c,u)/2;return i.eachAfter(r?function(t){t.x=(t.x-i.x)*n,t.y=(i.y-t.y)*e}:function(t){t.x=(t.x-f)/(s-f)*n,t.y=(1-(i.y?t.y/i.y:1))*e})}return i.separation=function(n){return arguments.length?(t=n,i):t},i.size=function(t){return arguments.length?(r=!1,n=+t[0],e=+t[1],i):r?null:[n,e]},i.nodeSize=function(t){return arguments.length?(r=!0,n=+t[0],e=+t[1],i):r?[n,e]:null},i},t.color=ze,t.contourDensity=function(){var t=fu,n=su,e=lu,r=960,i=500,o=20,a=2,u=3*o,c=r+2*u>>a,f=i+2*u>>a,s=Qa(20);function h(r){var i=new Float32Array(c*f),s=Math.pow(2,-a),h=-1;for(const o of r){var d=(t(o,++h,r)+u)*s,p=(n(o,h,r)+u)*s,g=+e(o,h,r);if(g&&d>=0&&d=0&&pt*r)))(n).map(((t,n)=>(t.value=+e[n],p(t))))}function p(t){return t.coordinates.forEach(g),t}function g(t){t.forEach(y)}function y(t){t.forEach(v)}function v(t){t[0]=t[0]*Math.pow(2,a)-u,t[1]=t[1]*Math.pow(2,a)-u}function _(){return c=r+2*(u=3*o)>>a,f=i+2*u>>a,d}return d.contours=function(t){var n=h(t),e=iu().size([c,f]),r=Math.pow(2,2*a),i=t=>{t=+t;var i=p(e.contour(n,t*r));return i.value=t,i};return Object.defineProperty(i,"max",{get:()=>J(n)/r}),i},d.x=function(n){return arguments.length?(t="function"==typeof n?n:Qa(+n),d):t},d.y=function(t){return arguments.length?(n="function"==typeof t?t:Qa(+t),d):n},d.weight=function(t){return arguments.length?(e="function"==typeof t?t:Qa(+t),d):e},d.size=function(t){if(!arguments.length)return[r,i];var n=+t[0],e=+t[1];if(!(n>=0&&e>=0))throw new Error("invalid size");return r=n,i=e,_()},d.cellSize=function(t){if(!arguments.length)return 1<=1))throw new Error("invalid cell size");return a=Math.floor(Math.log(t)/Math.LN2),_()},d.thresholds=function(t){return arguments.length?(s="function"==typeof t?t:Array.isArray(t)?Qa(Za.call(t)):Qa(t),d):s},d.bandwidth=function(t){if(!arguments.length)return Math.sqrt(o*(o+1));if(!((t=+t)>=0))throw new Error("invalid bandwidth");return o=(Math.sqrt(4*t*t+1)-1)/2,_()},d},t.contours=iu,t.count=v,t.create=function(t){return Zn(Yt(t).call(document.documentElement))},t.creator=Yt,t.cross=function(...t){const n="function"==typeof t[t.length-1]&&function(t){return n=>t(...n)}(t.pop()),e=(t=t.map(m)).map(_),r=t.length-1,i=new Array(r+1).fill(0),o=[];if(r<0||e.some(b))return o;for(;;){o.push(i.map(((n,e)=>t[e][n])));let a=r;for(;++i[a]===e[a];){if(0===a)return n?o.map(n):o;i[a--]=0}}},t.csv=wc,t.csvFormat=rc,t.csvFormatBody=ic,t.csvFormatRow=ac,t.csvFormatRows=oc,t.csvFormatValue=uc,t.csvParse=nc,t.csvParseRows=ec,t.cubehelix=Tr,t.cumsum=function(t,n){var e=0,r=0;return Float64Array.from(t,void 0===n?t=>e+=+t||0:i=>e+=+n(i,r++,t)||0)},t.curveBasis=function(t){return new Fx(t)},t.curveBasisClosed=function(t){return new qx(t)},t.curveBasisOpen=function(t){return new Ux(t)},t.curveBumpX=nx,t.curveBumpY=ex,t.curveBundle=Ox,t.curveCardinal=Lx,t.curveCardinalClosed=Hx,t.curveCardinalOpen=Gx,t.curveCatmullRom=Zx,t.curveCatmullRomClosed=Qx,t.curveCatmullRomOpen=tw,t.curveLinear=Im,t.curveLinearClosed=function(t){return new nw(t)},t.curveMonotoneX=function(t){return new aw(t)},t.curveMonotoneY=function(t){return new uw(t)},t.curveNatural=function(t){return new fw(t)},t.curveStep=function(t){return new lw(t,.5)},t.curveStepAfter=function(t){return new lw(t,1)},t.curveStepBefore=function(t){return new lw(t,0)},t.descending=e,t.deviation=w,t.difference=function(t,...n){t=new InternSet(t);for(const e of n)for(const n of e)t.delete(n);return t},t.disjoint=function(t,n){const e=n[Symbol.iterator](),r=new InternSet;for(const n of t){if(r.has(n))return!1;let t,i;for(;({value:t,done:i}=e.next())&&!i;){if(Object.is(n,t))return!1;r.add(t)}}return!0},t.dispatch=$t,t.drag=function(){var t,n,e,r,i=se,o=le,a=he,u=de,c={},f=$t("start","drag","end"),s=0,l=0;function h(t){t.on("mousedown.drag",d).filter(u).on("touchstart.drag",y).on("touchmove.drag",v,ee).on("touchend.drag touchcancel.drag",_).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function d(a,u){if(!r&&i.call(this,a,u)){var c=b(this,o.call(this,a,u),a,u,"mouse");c&&(Zn(a.view).on("mousemove.drag",p,re).on("mouseup.drag",g,re),ae(a.view),ie(a),e=!1,t=a.clientX,n=a.clientY,c("start",a))}}function p(r){if(oe(r),!e){var i=r.clientX-t,o=r.clientY-n;e=i*i+o*o>l}c.mouse("drag",r)}function g(t){Zn(t.view).on("mousemove.drag mouseup.drag",null),ue(t.view,e),oe(t),c.mouse("end",t)}function y(t,n){if(i.call(this,t,n)){var e,r,a=t.changedTouches,u=o.call(this,t,n),c=a.length;for(e=0;e+t,t.easePoly=wo,t.easePolyIn=mo,t.easePolyInOut=wo,t.easePolyOut=xo,t.easeQuad=_o,t.easeQuadIn=function(t){return t*t},t.easeQuadInOut=_o,t.easeQuadOut=function(t){return t*(2-t)},t.easeSin=Ao,t.easeSinIn=function(t){return 1==+t?1:1-Math.cos(t*To)},t.easeSinInOut=Ao,t.easeSinOut=function(t){return Math.sin(t*To)},t.every=function(t,n){if("function"!=typeof n)throw new TypeError("test is not a function");let e=-1;for(const r of t)if(!n(r,++e,t))return!1;return!0},t.extent=M,t.fcumsum=function(t,n){const e=new T;let r=-1;return Float64Array.from(t,void 0===n?t=>e.add(+t||0):i=>e.add(+n(i,++r,t)||0))},t.filter=function(t,n){if("function"!=typeof n)throw new TypeError("test is not a function");const e=[];let r=-1;for(const i of t)n(i,++r,t)&&e.push(i);return e},t.flatGroup=function(t,...n){return z(P(t,...n),n)},t.flatRollup=function(t,n,...e){return z(D(t,n,...e),e)},t.forceCenter=function(t,n){var e,r=1;function i(){var i,o,a=e.length,u=0,c=0;for(i=0;if+p||os+p||ac.index){var g=f-u.x-u.vx,y=s-u.y-u.vy,v=g*g+y*y;vt.r&&(t.r=t[n].r)}function c(){if(n){var r,i,o=n.length;for(e=new Array(o),r=0;r[u(t,n,r),t])));for(a=0,i=new Array(f);a=u)){(t.data!==n||t.next)&&(0===l&&(p+=(l=Uc(e))*l),0===h&&(p+=(h=Uc(e))*h),p(t=(Lc*t+jc)%Hc)/Hc}();function l(){h(),f.call("tick",n),e1?(null==e?u.delete(t):u.set(t,p(e)),n):u.get(t)},find:function(n,e,r){var i,o,a,u,c,f=0,s=t.length;for(null==r?r=1/0:r*=r,f=0;f1?(f.on(t,e),n):f.on(t)}}},t.forceX=function(t){var n,e,r,i=qc(.1);function o(t){for(var i,o=0,a=n.length;o=.12&&i<.234&&r>=-.425&&r<-.214?u:i>=.166&&i<.234&&r>=-.214&&r<-.115?c:a).invert(t)},s.stream=function(e){return t&&n===e?t:(r=[a.stream(n=e),u.stream(e),c.stream(e)],i=r.length,t={point:function(t,n){for(var e=-1;++ejs(r[0],r[1])&&(r[1]=i[1]),js(i[0],r[1])>js(r[0],r[1])&&(r[0]=i[0])):o.push(r=i);for(a=-1/0,n=0,r=o[e=o.length-1];n<=e;r=i,++n)i=o[n],(u=js(r[1],i[0]))>a&&(a=u,Wf=i[0],Kf=r[1])}return is=os=null,Wf===1/0||Zf===1/0?[[NaN,NaN],[NaN,NaN]]:[[Wf,Zf],[Kf,Qf]]},t.geoCentroid=function(t){ms=xs=ws=Ms=Ts=As=Ss=Es=0,Ns=new T,ks=new T,Cs=new T,Lf(t,Gs);var n=+Ns,e=+ks,r=+Cs,i=Ef(n,e,r);return i=0))throw new RangeError(`invalid digits: ${t}`);i=n}return null===n&&(r=new ed(i)),a},a.projection(t).digits(i).context(n)},t.geoProjection=yd,t.geoProjectionMutator=vd,t.geoRotation=ll,t.geoStereographic=function(){return yd(Bd).scale(250).clipAngle(142)},t.geoStereographicRaw=Bd,t.geoStream=Lf,t.geoTransform=function(t){return{stream:id(t)}},t.geoTransverseMercator=function(){var t=Ed(Yd),n=t.center,e=t.rotate;return t.center=function(t){return arguments.length?n([-t[1],t[0]]):[(t=n())[1],-t[0]]},t.rotate=function(t){return arguments.length?e([t[0],t[1],t.length>2?t[2]+90:90]):[(t=e())[0],t[1],t[2]-90]},e([0,0,90]).scale(159.155)},t.geoTransverseMercatorRaw=Yd,t.gray=function(t,n){return new ur(t,0,0,null==n?1:n)},t.greatest=ot,t.greatestIndex=function(t,e=n){if(1===e.length)return tt(t,e);let r,i=-1,o=-1;for(const n of t)++o,(i<0?0===e(n,n):e(n,r)>0)&&(r=n,i=o);return i},t.group=C,t.groupSort=function(t,e,r){return(2!==e.length?U($(t,e,r),(([t,e],[r,i])=>n(e,i)||n(t,r))):U(C(t,r),(([t,r],[i,o])=>e(r,o)||n(t,i)))).map((([t])=>t))},t.groups=P,t.hcl=dr,t.hierarchy=Gd,t.histogram=Q,t.hsl=He,t.html=Ec,t.image=function(t,n){return new Promise((function(e,r){var i=new Image;for(var o in n)i[o]=n[o];i.onerror=r,i.onload=function(){e(i)},i.src=t}))},t.index=function(t,...n){return F(t,k,R,n)},t.indexes=function(t,...n){return F(t,Array.from,R,n)},t.interpolate=Gr,t.interpolateArray=function(t,n){return(Ir(n)?Ur:Or)(t,n)},t.interpolateBasis=Er,t.interpolateBasisClosed=Nr,t.interpolateBlues=Gb,t.interpolateBrBG=ob,t.interpolateBuGn=Mb,t.interpolateBuPu=Ab,t.interpolateCividis=function(t){return t=Math.max(0,Math.min(1,t)),"rgb("+Math.max(0,Math.min(255,Math.round(-4.54-t*(35.34-t*(2381.73-t*(6402.7-t*(7024.72-2710.57*t)))))))+", "+Math.max(0,Math.min(255,Math.round(32.49+t*(170.73+t*(52.82-t*(131.46-t*(176.58-67.37*t)))))))+", "+Math.max(0,Math.min(255,Math.round(81.24+t*(442.36-t*(2482.43-t*(6167.24-t*(6614.94-2475.67*t)))))))+")"},t.interpolateCool=am,t.interpolateCubehelix=li,t.interpolateCubehelixDefault=im,t.interpolateCubehelixLong=hi,t.interpolateDate=Br,t.interpolateDiscrete=function(t){var n=t.length;return function(e){return t[Math.max(0,Math.min(n-1,Math.floor(e*n)))]}},t.interpolateGnBu=Eb,t.interpolateGreens=Wb,t.interpolateGreys=Kb,t.interpolateHcl=ci,t.interpolateHclLong=fi,t.interpolateHsl=oi,t.interpolateHslLong=ai,t.interpolateHue=function(t,n){var e=Pr(+t,+n);return function(t){var n=e(t);return n-360*Math.floor(n/360)}},t.interpolateInferno=pm,t.interpolateLab=function(t,n){var e=$r((t=ar(t)).l,(n=ar(n)).l),r=$r(t.a,n.a),i=$r(t.b,n.b),o=$r(t.opacity,n.opacity);return function(n){return t.l=e(n),t.a=r(n),t.b=i(n),t.opacity=o(n),t+""}},t.interpolateMagma=dm,t.interpolateNumber=Yr,t.interpolateNumberArray=Ur,t.interpolateObject=Lr,t.interpolateOrRd=kb,t.interpolateOranges=rm,t.interpolatePRGn=ub,t.interpolatePiYG=fb,t.interpolatePlasma=gm,t.interpolatePuBu=$b,t.interpolatePuBuGn=Pb,t.interpolatePuOr=lb,t.interpolatePuRd=Rb,t.interpolatePurples=Jb,t.interpolateRainbow=function(t){(t<0||t>1)&&(t-=Math.floor(t));var n=Math.abs(t-.5);return um.h=360*t-100,um.s=1.5-1.5*n,um.l=.8-.9*n,um+""},t.interpolateRdBu=db,t.interpolateRdGy=gb,t.interpolateRdPu=qb,t.interpolateRdYlBu=vb,t.interpolateRdYlGn=bb,t.interpolateReds=nm,t.interpolateRgb=Dr,t.interpolateRgbBasis=Fr,t.interpolateRgbBasisClosed=qr,t.interpolateRound=Vr,t.interpolateSinebow=function(t){var n;return t=(.5-t)*Math.PI,cm.r=255*(n=Math.sin(t))*n,cm.g=255*(n=Math.sin(t+fm))*n,cm.b=255*(n=Math.sin(t+sm))*n,cm+""},t.interpolateSpectral=xb,t.interpolateString=Xr,t.interpolateTransformCss=ti,t.interpolateTransformSvg=ni,t.interpolateTurbo=function(t){return t=Math.max(0,Math.min(1,t)),"rgb("+Math.max(0,Math.min(255,Math.round(34.61+t*(1172.33-t*(10793.56-t*(33300.12-t*(38394.49-14825.05*t)))))))+", "+Math.max(0,Math.min(255,Math.round(23.31+t*(557.33+t*(1225.33-t*(3574.96-t*(1073.77+707.56*t)))))))+", "+Math.max(0,Math.min(255,Math.round(27.2+t*(3211.1-t*(15327.97-t*(27814-t*(22569.18-6838.66*t)))))))+")"},t.interpolateViridis=hm,t.interpolateWarm=om,t.interpolateYlGn=Bb,t.interpolateYlGnBu=Ib,t.interpolateYlOrBr=Lb,t.interpolateYlOrRd=Hb,t.interpolateZoom=ri,t.interrupt=Gi,t.intersection=function(t,...n){t=new InternSet(t),n=n.map(vt);t:for(const e of t)for(const r of n)if(!r.has(e)){t.delete(e);continue t}return t},t.interval=function(t,n,e){var r=new Ei,i=n;return null==n?(r.restart(t,n,e),r):(r._restart=r.restart,r.restart=function(t,n,e){n=+n,e=null==e?Ai():+e,r._restart((function o(a){a+=i,r._restart(o,i+=n,e),t(a)}),n,e)},r.restart(t,n,e),r)},t.isoFormat=D_,t.isoParse=F_,t.json=function(t,n){return fetch(t,n).then(Tc)},t.lab=ar,t.lch=function(t,n,e,r){return 1===arguments.length?hr(t):new pr(e,n,t,null==r?1:r)},t.least=function(t,e=n){let r,i=!1;if(1===e.length){let o;for(const a of t){const t=e(a);(i?n(t,o)<0:0===n(t,t))&&(r=a,o=t,i=!0)}}else for(const n of t)(i?e(n,r)<0:0===e(n,n))&&(r=n,i=!0);return r},t.leastIndex=ht,t.line=Ym,t.lineRadial=Zm,t.link=ax,t.linkHorizontal=function(){return ax(nx)},t.linkRadial=function(){const t=ax(rx);return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t},t.linkVertical=function(){return ax(ex)},t.local=Qn,t.map=function(t,n){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");if("function"!=typeof n)throw new TypeError("mapper is not a function");return Array.from(t,((e,r)=>n(e,r,t)))},t.matcher=Vt,t.max=J,t.maxIndex=tt,t.mean=function(t,n){let e=0,r=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(++e,r+=n);else{let i=-1;for(let o of t)null!=(o=n(o,++i,t))&&(o=+o)>=o&&(++e,r+=o)}if(e)return r/e},t.median=function(t,n){return at(t,.5,n)},t.medianIndex=function(t,n){return ct(t,.5,n)},t.merge=ft,t.min=nt,t.minIndex=et,t.mode=function(t,n){const e=new InternMap;if(void 0===n)for(let n of t)null!=n&&n>=n&&e.set(n,(e.get(n)||0)+1);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&i>=i&&e.set(i,(e.get(i)||0)+1)}let r,i=0;for(const[t,n]of e)n>i&&(i=n,r=t);return r},t.namespace=It,t.namespaces=Ut,t.nice=Z,t.now=Ai,t.pack=function(){var t=null,n=1,e=1,r=np;function i(i){const o=ap();return i.x=n/2,i.y=e/2,t?i.eachBefore(xp(t)).eachAfter(wp(r,.5,o)).eachBefore(Mp(1)):i.eachBefore(xp(mp)).eachAfter(wp(np,1,o)).eachAfter(wp(r,i.r/Math.min(n,e),o)).eachBefore(Mp(Math.min(n,e)/(2*i.r))),i}return i.radius=function(n){return arguments.length?(t=Jd(n),i):t},i.size=function(t){return arguments.length?(n=+t[0],e=+t[1],i):[n,e]},i.padding=function(t){return arguments.length?(r="function"==typeof t?t:ep(+t),i):r},i},t.packEnclose=function(t){return up(t,ap())},t.packSiblings=function(t){return bp(t,ap()),t},t.pairs=function(t,n=st){const e=[];let r,i=!1;for(const o of t)i&&e.push(n(r,o)),r=o,i=!0;return e},t.partition=function(){var t=1,n=1,e=0,r=!1;function i(i){var o=i.height+1;return i.x0=i.y0=e,i.x1=t,i.y1=n/o,i.eachBefore(function(t,n){return function(r){r.children&&Ap(r,r.x0,t*(r.depth+1)/n,r.x1,t*(r.depth+2)/n);var i=r.x0,o=r.y0,a=r.x1-e,u=r.y1-e;a0&&(d+=l);for(null!=n?p.sort((function(t,e){return n(g[t],g[e])})):null!=e&&p.sort((function(t,n){return e(a[t],a[n])})),u=0,f=d?(v-h*b)/d:0;u0?l*f:0)+b,g[c]={data:a[c],index:u,value:l,startAngle:y,endAngle:s,padAngle:_};return g}return a.value=function(n){return arguments.length?(t="function"==typeof n?n:ym(+n),a):t},a.sortValues=function(t){return arguments.length?(n=t,e=null,a):n},a.sort=function(t){return arguments.length?(e=t,n=null,a):e},a.startAngle=function(t){return arguments.length?(r="function"==typeof t?t:ym(+t),a):r},a.endAngle=function(t){return arguments.length?(i="function"==typeof t?t:ym(+t),a):i},a.padAngle=function(t){return arguments.length?(o="function"==typeof t?t:ym(+t),a):o},a},t.piecewise=di,t.pointRadial=Qm,t.pointer=ne,t.pointers=function(t,n){return t.target&&(t=te(t),void 0===n&&(n=t.currentTarget),t=t.touches||[t]),Array.from(t,(t=>ne(t,n)))},t.polygonArea=function(t){for(var n,e=-1,r=t.length,i=t[r-1],o=0;++eu!=f>u&&a<(c-e)*(u-r)/(f-r)+e&&(s=!s),c=e,f=r;return s},t.polygonHull=function(t){if((e=t.length)<3)return null;var n,e,r=new Array(e),i=new Array(e);for(n=0;n=0;--n)f.push(t[r[o[n]][2]]);for(n=+u;n(n=1664525*n+1013904223|0,lg*(n>>>0))},t.randomLogNormal=Kp,t.randomLogistic=fg,t.randomNormal=Zp,t.randomPareto=ng,t.randomPoisson=sg,t.randomUniform=Vp,t.randomWeibull=ug,t.range=lt,t.rank=function(t,e=n){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");let r=Array.from(t);const i=new Float64Array(r.length);2!==e.length&&(r=r.map(e),e=n);const o=(t,n)=>e(r[t],r[n]);let a,u;return(t=Uint32Array.from(r,((t,n)=>n))).sort(e===n?(t,n)=>O(r[t],r[n]):I(o)),t.forEach(((t,n)=>{const e=o(t,void 0===a?t:a);e>=0?((void 0===a||e>0)&&(a=t,u=n),i[t]=u):i[t]=NaN})),i},t.reduce=function(t,n,e){if("function"!=typeof n)throw new TypeError("reducer is not a function");const r=t[Symbol.iterator]();let i,o,a=-1;if(arguments.length<3){if(({done:i,value:e}=r.next()),i)return;++a}for(;({done:i,value:o}=r.next()),!i;)e=n(e,o,++a,t);return e},t.reverse=function(t){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");return Array.from(t).reverse()},t.rgb=Fe,t.ribbon=function(){return Wa()},t.ribbonArrow=function(){return Wa(Va)},t.rollup=$,t.rollups=D,t.scaleBand=yg,t.scaleDiverging=function t(){var n=Ng(L_()(mg));return n.copy=function(){return B_(n,t())},dg.apply(n,arguments)},t.scaleDivergingLog=function t(){var n=Fg(L_()).domain([.1,1,10]);return n.copy=function(){return B_(n,t()).base(n.base())},dg.apply(n,arguments)},t.scaleDivergingPow=j_,t.scaleDivergingSqrt=function(){return j_.apply(null,arguments).exponent(.5)},t.scaleDivergingSymlog=function t(){var n=Ig(L_());return n.copy=function(){return B_(n,t()).constant(n.constant())},dg.apply(n,arguments)},t.scaleIdentity=function t(n){var e;function r(t){return null==t||isNaN(t=+t)?e:t}return r.invert=r,r.domain=r.range=function(t){return arguments.length?(n=Array.from(t,_g),r):n.slice()},r.unknown=function(t){return arguments.length?(e=t,r):e},r.copy=function(){return t(n).unknown(e)},n=arguments.length?Array.from(n,_g):[0,1],Ng(r)},t.scaleImplicit=pg,t.scaleLinear=function t(){var n=Sg();return n.copy=function(){return Tg(n,t())},hg.apply(n,arguments),Ng(n)},t.scaleLog=function t(){const n=Fg(Ag()).domain([1,10]);return n.copy=()=>Tg(n,t()).base(n.base()),hg.apply(n,arguments),n},t.scaleOrdinal=gg,t.scalePoint=function(){return vg(yg.apply(null,arguments).paddingInner(1))},t.scalePow=jg,t.scaleQuantile=function t(){var e,r=[],i=[],o=[];function a(){var t=0,n=Math.max(1,i.length);for(o=new Array(n-1);++t0?o[n-1]:r[0],n=i?[o[i-1],r]:[o[n-1],o[n]]},u.unknown=function(t){return arguments.length?(n=t,u):u},u.thresholds=function(){return o.slice()},u.copy=function(){return t().domain([e,r]).range(a).unknown(n)},hg.apply(Ng(u),arguments)},t.scaleRadial=function t(){var n,e=Sg(),r=[0,1],i=!1;function o(t){var r=function(t){return Math.sign(t)*Math.sqrt(Math.abs(t))}(e(t));return isNaN(r)?n:i?Math.round(r):r}return o.invert=function(t){return e.invert(Hg(t))},o.domain=function(t){return arguments.length?(e.domain(t),o):e.domain()},o.range=function(t){return arguments.length?(e.range((r=Array.from(t,_g)).map(Hg)),o):r.slice()},o.rangeRound=function(t){return o.range(t).round(!0)},o.round=function(t){return arguments.length?(i=!!t,o):i},o.clamp=function(t){return arguments.length?(e.clamp(t),o):e.clamp()},o.unknown=function(t){return arguments.length?(n=t,o):n},o.copy=function(){return t(e.domain(),r).round(i).clamp(e.clamp()).unknown(n)},hg.apply(o,arguments),Ng(o)},t.scaleSequential=function t(){var n=Ng(O_()(mg));return n.copy=function(){return B_(n,t())},dg.apply(n,arguments)},t.scaleSequentialLog=function t(){var n=Fg(O_()).domain([1,10]);return n.copy=function(){return B_(n,t()).base(n.base())},dg.apply(n,arguments)},t.scaleSequentialPow=Y_,t.scaleSequentialQuantile=function t(){var e=[],r=mg;function i(t){if(null!=t&&!isNaN(t=+t))return r((s(e,t,1)-1)/(e.length-1))}return i.domain=function(t){if(!arguments.length)return e.slice();e=[];for(let n of t)null==n||isNaN(n=+n)||e.push(n);return e.sort(n),i},i.interpolator=function(t){return arguments.length?(r=t,i):r},i.range=function(){return e.map(((t,n)=>r(n/(e.length-1))))},i.quantiles=function(t){return Array.from({length:t+1},((n,r)=>at(e,r/t)))},i.copy=function(){return t(r).domain(e)},dg.apply(i,arguments)},t.scaleSequentialSqrt=function(){return Y_.apply(null,arguments).exponent(.5)},t.scaleSequentialSymlog=function t(){var n=Ig(O_());return n.copy=function(){return B_(n,t()).constant(n.constant())},dg.apply(n,arguments)},t.scaleSqrt=function(){return jg.apply(null,arguments).exponent(.5)},t.scaleSymlog=function t(){var n=Ig(Ag());return n.copy=function(){return Tg(n,t()).constant(n.constant())},hg.apply(n,arguments)},t.scaleThreshold=function t(){var n,e=[.5],r=[0,1],i=1;function o(t){return null!=t&&t<=t?r[s(e,t,0,i)]:n}return o.domain=function(t){return arguments.length?(e=Array.from(t),i=Math.min(e.length,r.length-1),o):e.slice()},o.range=function(t){return arguments.length?(r=Array.from(t),i=Math.min(e.length,r.length-1),o):r.slice()},o.invertExtent=function(t){var n=r.indexOf(t);return[e[n-1],e[n]]},o.unknown=function(t){return arguments.length?(n=t,o):n},o.copy=function(){return t().domain(e).range(r).unknown(n)},hg.apply(o,arguments)},t.scaleTime=function(){return hg.apply(I_(uv,cv,tv,Zy,xy,py,sy,ay,iy,t.timeFormat).domain([new Date(2e3,0,1),new Date(2e3,0,2)]),arguments)},t.scaleUtc=function(){return hg.apply(I_(ov,av,ev,Qy,Fy,yy,hy,cy,iy,t.utcFormat).domain([Date.UTC(2e3,0,1),Date.UTC(2e3,0,2)]),arguments)},t.scan=function(t,n){const e=ht(t,n);return e<0?void 0:e},t.schemeAccent=G_,t.schemeBlues=Xb,t.schemeBrBG=ib,t.schemeBuGn=wb,t.schemeBuPu=Tb,t.schemeCategory10=X_,t.schemeDark2=V_,t.schemeGnBu=Sb,t.schemeGreens=Vb,t.schemeGreys=Zb,t.schemeObservable10=W_,t.schemeOrRd=Nb,t.schemeOranges=em,t.schemePRGn=ab,t.schemePaired=Z_,t.schemePastel1=K_,t.schemePastel2=Q_,t.schemePiYG=cb,t.schemePuBu=zb,t.schemePuBuGn=Cb,t.schemePuOr=sb,t.schemePuRd=Db,t.schemePurples=Qb,t.schemeRdBu=hb,t.schemeRdGy=pb,t.schemeRdPu=Fb,t.schemeRdYlBu=yb,t.schemeRdYlGn=_b,t.schemeReds=tm,t.schemeSet1=J_,t.schemeSet2=tb,t.schemeSet3=nb,t.schemeSpectral=mb,t.schemeTableau10=eb,t.schemeYlGn=Ob,t.schemeYlGnBu=Ub,t.schemeYlOrBr=Yb,t.schemeYlOrRd=jb,t.select=Zn,t.selectAll=function(t){return"string"==typeof t?new Vn([document.querySelectorAll(t)],[document.documentElement]):new Vn([Ht(t)],Gn)},t.selection=Wn,t.selector=jt,t.selectorAll=Gt,t.shuffle=dt,t.shuffler=pt,t.some=function(t,n){if("function"!=typeof n)throw new TypeError("test is not a function");let e=-1;for(const r of t)if(n(r,++e,t))return!0;return!1},t.sort=U,t.stack=function(){var t=ym([]),n=dw,e=hw,r=pw;function i(i){var o,a,u=Array.from(t.apply(this,arguments),gw),c=u.length,f=-1;for(const t of i)for(o=0,++f;o0)for(var e,r,i,o,a,u,c=0,f=t[n[0]].length;c0?(r[0]=o,r[1]=o+=i):i<0?(r[1]=a,r[0]=a+=i):(r[0]=0,r[1]=i)},t.stackOffsetExpand=function(t,n){if((r=t.length)>0){for(var e,r,i,o=0,a=t[0].length;o0){for(var e,r=0,i=t[n[0]],o=i.length;r0&&(r=(e=t[n[0]]).length)>0){for(var e,r,i,o=0,a=1;afunction(t){t=`${t}`;let n=t.length;zp(t,n-1)&&!zp(t,n-2)&&(t=t.slice(0,-1));return"/"===t[0]?t:`/${t}`}(t(n,e,r)))),e=n.map(Pp),i=new Set(n).add("");for(const t of e)i.has(t)||(i.add(t),n.push(t),e.push(Pp(t)),h.push(Np));d=(t,e)=>n[e],p=(t,n)=>e[n]}for(a=0,i=h.length;a=0&&(f=h[t]).data===Np;--t)f.data=null}if(u.parent=Sp,u.eachBefore((function(t){t.depth=t.parent.depth+1,--i})).eachBefore(Kd),u.parent=null,i>0)throw new Error("cycle");return u}return r.id=function(t){return arguments.length?(n=Jd(t),r):n},r.parentId=function(t){return arguments.length?(e=Jd(t),r):e},r.path=function(n){return arguments.length?(t=Jd(n),r):t},r},t.style=_n,t.subset=function(t,n){return _t(n,t)},t.sum=function(t,n){let e=0;if(void 0===n)for(let n of t)(n=+n)&&(e+=n);else{let r=-1;for(let i of t)(i=+n(i,++r,t))&&(e+=i)}return e},t.superset=_t,t.svg=Nc,t.symbol=function(t,n){let e=null,r=km(i);function i(){let i;if(e||(e=i=r()),t.apply(this,arguments).draw(e,+n.apply(this,arguments)),i)return e=null,i+""||null}return t="function"==typeof t?t:ym(t||fx),n="function"==typeof n?n:ym(void 0===n?64:+n),i.type=function(n){return arguments.length?(t="function"==typeof n?n:ym(n),i):t},i.size=function(t){return arguments.length?(n="function"==typeof t?t:ym(+t),i):n},i.context=function(t){return arguments.length?(e=null==t?null:t,i):e},i},t.symbolAsterisk=cx,t.symbolCircle=fx,t.symbolCross=sx,t.symbolDiamond=dx,t.symbolDiamond2=px,t.symbolPlus=gx,t.symbolSquare=yx,t.symbolSquare2=vx,t.symbolStar=xx,t.symbolTimes=Px,t.symbolTriangle=Mx,t.symbolTriangle2=Ax,t.symbolWye=Cx,t.symbolX=Px,t.symbols=zx,t.symbolsFill=zx,t.symbolsStroke=$x,t.text=mc,t.thresholdFreedmanDiaconis=function(t,n,e){const r=v(t),i=at(t,.75)-at(t,.25);return r&&i?Math.ceil((e-n)/(2*i*Math.pow(r,-1/3))):1},t.thresholdScott=function(t,n,e){const r=v(t),i=w(t);return r&&i?Math.ceil((e-n)*Math.cbrt(r)/(3.49*i)):1},t.thresholdSturges=K,t.tickFormat=Eg,t.tickIncrement=V,t.tickStep=W,t.ticks=G,t.timeDay=py,t.timeDays=gy,t.timeFormatDefaultLocale=P_,t.timeFormatLocale=hv,t.timeFriday=Sy,t.timeFridays=$y,t.timeHour=sy,t.timeHours=ly,t.timeInterval=Vg,t.timeMillisecond=Wg,t.timeMilliseconds=Zg,t.timeMinute=ay,t.timeMinutes=uy,t.timeMonday=wy,t.timeMondays=ky,t.timeMonth=Zy,t.timeMonths=Ky,t.timeSaturday=Ey,t.timeSaturdays=Dy,t.timeSecond=iy,t.timeSeconds=oy,t.timeSunday=xy,t.timeSundays=Ny,t.timeThursday=Ay,t.timeThursdays=zy,t.timeTickInterval=cv,t.timeTicks=uv,t.timeTuesday=My,t.timeTuesdays=Cy,t.timeWednesday=Ty,t.timeWednesdays=Py,t.timeWeek=xy,t.timeWeeks=Ny,t.timeYear=tv,t.timeYears=nv,t.timeout=$i,t.timer=Ni,t.timerFlush=ki,t.transition=go,t.transpose=gt,t.tree=function(){var t=$p,n=1,e=1,r=null;function i(i){var c=function(t){for(var n,e,r,i,o,a=new Up(t,0),u=[a];n=u.pop();)if(r=n._.children)for(n.children=new Array(o=r.length),i=o-1;i>=0;--i)u.push(e=n.children[i]=new Up(r[i],i)),e.parent=n;return(a.parent=new Up(null,0)).children=[a],a}(i);if(c.eachAfter(o),c.parent.m=-c.z,c.eachBefore(a),r)i.eachBefore(u);else{var f=i,s=i,l=i;i.eachBefore((function(t){t.xs.x&&(s=t),t.depth>l.depth&&(l=t)}));var h=f===s?1:t(f,s)/2,d=h-f.x,p=n/(s.x+h+d),g=e/(l.depth||1);i.eachBefore((function(t){t.x=(t.x+d)*p,t.y=t.depth*g}))}return i}function o(n){var e=n.children,r=n.parent.children,i=n.i?r[n.i-1]:null;if(e){!function(t){for(var n,e=0,r=0,i=t.children,o=i.length;--o>=0;)(n=i[o]).z+=e,n.m+=e,e+=n.s+(r+=n.c)}(n);var o=(e[0].z+e[e.length-1].z)/2;i?(n.z=i.z+t(n._,i._),n.m=n.z-o):n.z=o}else i&&(n.z=i.z+t(n._,i._));n.parent.A=function(n,e,r){if(e){for(var i,o=n,a=n,u=e,c=o.parent.children[0],f=o.m,s=a.m,l=u.m,h=c.m;u=Rp(u),o=Dp(o),u&&o;)c=Dp(c),(a=Rp(a)).a=n,(i=u.z+l-o.z-f+t(u._,o._))>0&&(Fp(qp(u,n,r),n,i),f+=i,s+=i),l+=u.m,f+=o.m,h+=c.m,s+=a.m;u&&!Rp(a)&&(a.t=u,a.m+=l-s),o&&!Dp(c)&&(c.t=o,c.m+=f-h,r=n)}return r}(n,i,n.parent.A||r[0])}function a(t){t._.x=t.z+t.parent.m,t.m+=t.parent.m}function u(t){t.x*=n,t.y=t.depth*e}return i.separation=function(n){return arguments.length?(t=n,i):t},i.size=function(t){return arguments.length?(r=!1,n=+t[0],e=+t[1],i):r?null:[n,e]},i.nodeSize=function(t){return arguments.length?(r=!0,n=+t[0],e=+t[1],i):r?[n,e]:null},i},t.treemap=function(){var t=Yp,n=!1,e=1,r=1,i=[0],o=np,a=np,u=np,c=np,f=np;function s(t){return t.x0=t.y0=0,t.x1=e,t.y1=r,t.eachBefore(l),i=[0],n&&t.eachBefore(Tp),t}function l(n){var e=i[n.depth],r=n.x0+e,s=n.y0+e,l=n.x1-e,h=n.y1-e;l=e-1){var s=u[n];return s.x0=i,s.y0=o,s.x1=a,void(s.y1=c)}var l=f[n],h=r/2+l,d=n+1,p=e-1;for(;d>>1;f[g]c-o){var _=r?(i*v+a*y)/r:a;t(n,d,y,i,o,_,c),t(d,e,v,_,o,a,c)}else{var b=r?(o*v+c*y)/r:c;t(n,d,y,i,o,a,b),t(d,e,v,i,b,a,c)}}(0,c,t.value,n,e,r,i)},t.treemapDice=Ap,t.treemapResquarify=Lp,t.treemapSlice=Ip,t.treemapSliceDice=function(t,n,e,r,i){(1&t.depth?Ip:Ap)(t,n,e,r,i)},t.treemapSquarify=Yp,t.tsv=Mc,t.tsvFormat=lc,t.tsvFormatBody=hc,t.tsvFormatRow=pc,t.tsvFormatRows=dc,t.tsvFormatValue=gc,t.tsvParse=fc,t.tsvParseRows=sc,t.union=function(...t){const n=new InternSet;for(const e of t)for(const t of e)n.add(t);return n},t.unixDay=_y,t.unixDays=by,t.utcDay=yy,t.utcDays=vy,t.utcFriday=By,t.utcFridays=Vy,t.utcHour=hy,t.utcHours=dy,t.utcMillisecond=Wg,t.utcMilliseconds=Zg,t.utcMinute=cy,t.utcMinutes=fy,t.utcMonday=qy,t.utcMondays=jy,t.utcMonth=Qy,t.utcMonths=Jy,t.utcSaturday=Yy,t.utcSaturdays=Wy,t.utcSecond=iy,t.utcSeconds=oy,t.utcSunday=Fy,t.utcSundays=Ly,t.utcThursday=Oy,t.utcThursdays=Gy,t.utcTickInterval=av,t.utcTicks=ov,t.utcTuesday=Uy,t.utcTuesdays=Hy,t.utcWednesday=Iy,t.utcWednesdays=Xy,t.utcWeek=Fy,t.utcWeeks=Ly,t.utcYear=ev,t.utcYears=rv,t.variance=x,t.version="7.9.0",t.window=pn,t.xml=Sc,t.zip=function(){return gt(arguments)},t.zoom=function(){var t,n,e,r=Ew,i=Nw,o=zw,a=Cw,u=Pw,c=[0,1/0],f=[[-1/0,-1/0],[1/0,1/0]],s=250,l=ri,h=$t("start","zoom","end"),d=500,p=150,g=0,y=10;function v(t){t.property("__zoom",kw).on("wheel.zoom",T,{passive:!1}).on("mousedown.zoom",A).on("dblclick.zoom",S).filter(u).on("touchstart.zoom",E).on("touchmove.zoom",N).on("touchend.zoom touchcancel.zoom",k).style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function _(t,n){return(n=Math.max(c[0],Math.min(c[1],n)))===t.k?t:new ww(n,t.x,t.y)}function b(t,n,e){var r=n[0]-e[0]*t.k,i=n[1]-e[1]*t.k;return r===t.x&&i===t.y?t:new ww(t.k,r,i)}function m(t){return[(+t[0][0]+ +t[1][0])/2,(+t[0][1]+ +t[1][1])/2]}function x(t,n,e,r){t.on("start.zoom",(function(){w(this,arguments).event(r).start()})).on("interrupt.zoom end.zoom",(function(){w(this,arguments).event(r).end()})).tween("zoom",(function(){var t=this,o=arguments,a=w(t,o).event(r),u=i.apply(t,o),c=null==e?m(u):"function"==typeof e?e.apply(t,o):e,f=Math.max(u[1][0]-u[0][0],u[1][1]-u[0][1]),s=t.__zoom,h="function"==typeof n?n.apply(t,o):n,d=l(s.invert(c).concat(f/s.k),h.invert(c).concat(f/h.k));return function(t){if(1===t)t=h;else{var n=d(t),e=f/n[2];t=new ww(e,c[0]-n[0]*e,c[1]-n[1]*e)}a.zoom(null,t)}}))}function w(t,n,e){return!e&&t.__zooming||new M(t,n)}function M(t,n){this.that=t,this.args=n,this.active=0,this.sourceEvent=null,this.extent=i.apply(t,n),this.taps=0}function T(t,...n){if(r.apply(this,arguments)){var e=w(this,n).event(t),i=this.__zoom,u=Math.max(c[0],Math.min(c[1],i.k*Math.pow(2,a.apply(this,arguments)))),s=ne(t);if(e.wheel)e.mouse[0][0]===s[0]&&e.mouse[0][1]===s[1]||(e.mouse[1]=i.invert(e.mouse[0]=s)),clearTimeout(e.wheel);else{if(i.k===u)return;e.mouse=[s,i.invert(s)],Gi(this),e.start()}Sw(t),e.wheel=setTimeout((function(){e.wheel=null,e.end()}),p),e.zoom("mouse",o(b(_(i,u),e.mouse[0],e.mouse[1]),e.extent,f))}}function A(t,...n){if(!e&&r.apply(this,arguments)){var i=t.currentTarget,a=w(this,n,!0).event(t),u=Zn(t.view).on("mousemove.zoom",(function(t){if(Sw(t),!a.moved){var n=t.clientX-s,e=t.clientY-l;a.moved=n*n+e*e>g}a.event(t).zoom("mouse",o(b(a.that.__zoom,a.mouse[0]=ne(t,i),a.mouse[1]),a.extent,f))}),!0).on("mouseup.zoom",(function(t){u.on("mousemove.zoom mouseup.zoom",null),ue(t.view,a.moved),Sw(t),a.event(t).end()}),!0),c=ne(t,i),s=t.clientX,l=t.clientY;ae(t.view),Aw(t),a.mouse=[c,this.__zoom.invert(c)],Gi(this),a.start()}}function S(t,...n){if(r.apply(this,arguments)){var e=this.__zoom,a=ne(t.changedTouches?t.changedTouches[0]:t,this),u=e.invert(a),c=e.k*(t.shiftKey?.5:2),l=o(b(_(e,c),a,u),i.apply(this,n),f);Sw(t),s>0?Zn(this).transition().duration(s).call(x,l,a,t):Zn(this).call(v.transform,l,a,t)}}function E(e,...i){if(r.apply(this,arguments)){var o,a,u,c,f=e.touches,s=f.length,l=w(this,i,e.changedTouches.length===s).event(e);for(Aw(e),a=0;a None: + print(f"[{time.strftime('%Y-%m-%dT%H:%M:%S')}] {msg}", flush=True) + + +def wal_signature() -> tuple[float, int] | None: + """Return (mtime, size) of the WAL file. None if WAL doesn't exist.""" + try: + st = WAL.stat() + return (st.st_mtime, st.st_size) + except FileNotFoundError: + return None + + +def run_regen() -> None: + """Invoke memos-explorer graph-export. Blocks ~60s. Logs result.""" + global last_regen_at, last_known_signature_at_regen + log("triggering graph-export …") + start = time.time() + try: + proc = subprocess.run( + [str(PYTHON), str(EXPLORER), "graph-export", "--out", str(OUT)], + capture_output=True, text=True, timeout=600, cwd=str(REPO), + ) + elapsed = time.time() - start + if proc.returncode != 0: + log(f"graph-export FAILED in {elapsed:.1f}s (exit {proc.returncode})") + tail = (proc.stderr or "").strip().splitlines()[-6:] + for line in tail: + log(f" | {line}") + return + # Find the "labelled N/M" line from stderr to surface usefulness + labelled = "" + for line in (proc.stderr or "").splitlines(): + if "labelled" in line and "/" in line: + labelled = line.strip() + log(f"graph-export OK in {elapsed:.1f}s. {labelled}") + last_regen_at = time.time() + last_known_signature_at_regen = wal_signature() + except subprocess.TimeoutExpired: + log("graph-export TIMEOUT after 600s") + + +def main() -> None: + global last_wal_signature + + log("memory-graph-watcher starting") + log(f" watching: {WAL}") + log(f" output: {OUT}") + log(f" debounce: {DEBOUNCE}s min-interval: {MIN_INTERVAL}s") + + if not EXPLORER.exists(): + log(f"FATAL: explorer not found at {EXPLORER}") + sys.exit(2) + if not PYTHON.exists(): + log(f"FATAL: tools-venv python not found at {PYTHON}") + sys.exit(2) + + # Don't fire immediately on startup. If the daemon was off and just came + # back, the WAL might appear new even though nothing actually changed. + time.sleep(STARTUP_SETTLE) + last_wal_signature = wal_signature() + last_change_at = time.time() + + log("entering watch loop") + while True: + time.sleep(POLL_INTERVAL) + sig = wal_signature() + if sig is None: + # WAL missing — daemon down or checkpointed away. Skip. + continue + if sig != last_wal_signature: + last_wal_signature = sig + last_change_at = time.time() + continue + + # No change since last poll. Has it been quiet long enough? + quiet_for = time.time() - last_change_at + if quiet_for < DEBOUNCE: + continue + + # Skip if we've recently regen'd and nothing has changed since. + if last_known_signature_at_regen == sig: + continue + + # Honour minimum interval between regens. + if time.time() - last_regen_at < MIN_INTERVAL: + continue + + run_regen() + last_change_at = time.time() # reset so we don't immediately retry + + +def shutdown(signum: int, _frame: object) -> None: + log(f"received signal {signum}, exiting") + sys.exit(0) + + +if __name__ == "__main__": + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + try: + main() + except Exception as e: + log(f"FATAL: {type(e).__name__}: {e}") + raise diff --git a/tools/memory-map.html b/tools/memory-map.html new file mode 100644 index 0000000..ef5e9ad --- /dev/null +++ b/tools/memory-map.html @@ -0,0 +1,904 @@ + + + + + +Hermes · Memory Viewer + + + + + +
+ +
+
+
+
Hermes · Memory Viewer
+

Skills · Policies · Topics

+
+
+
+
+ View as +
+
+ + + +
+
no selection
+
Click any circle to see its detail and its 1-hop neighbours.
+
+ +
+
D3 force layout · skills ↔ policies ↔ topics
+ + + + diff --git a/tools/memos-explorer.py b/tools/memos-explorer.py new file mode 100755 index 0000000..2b04c33 --- /dev/null +++ b/tools/memos-explorer.py @@ -0,0 +1,938 @@ +#!/usr/bin/env python3.12 +""" +memos-explorer — direct introspection over the v2 memory plugin DB. + +Reads `~/.hermes/memos-plugin/data/memos.db` (read-only by default) and gives +per-agent views the bundled MemOS viewer doesn't expose. Use it to: + + - List every profile with row counts → `profiles` + - Browse a specific agent's traces (private to them) → `traces ` + - List skills with originating-profile attribution → `skills` + - List policies / world-model entries per profile → `policies `, `world ` + - Run a semantic search using BGE-large embeddings → `search ` + - Inspect embedding dimension / sparsity stats → `vec-stats` + - Show the forensic share_scope_audit log → `audit [--since 1h]` + - Project trace vectors to 2D / 3D (UMAP) → `project --out file.png` + - Export full memory GRAPH (traces+policies+skills → `graph-export --out graph.json` + +world+edges) for the 3D Memory Map viewer + +The tool intentionally bypasses the daemon's HTTP layer so it can show rows +across all profiles — which the viewer currently can't, because it's bound +to a single namespace at startup. + +For `project` (and `search`) you need a few extra Python packages +(umap-learn, matplotlib, sentence-transformers). Use the tools-venv that +the install script created: + + ~/.hermes/tools-venv/bin/python tools/memos-explorer.py project --out vec-map.png + +For everything else, the stdlib + sqlite3 is enough — plain python3.12 works. + +Usage examples: + + python3.12 tools/memos-explorer.py profiles + python3.12 tools/memos-explorer.py traces sergio --limit 20 --search "memory" + python3.12 tools/memos-explorer.py skills + ~/.hermes/tools-venv/bin/python tools/memos-explorer.py search "embedder benchmark" --top-k 5 + python3.12 tools/memos-explorer.py vec-stats + python3.12 tools/memos-explorer.py audit --since 1h + ~/.hermes/tools-venv/bin/python tools/memos-explorer.py project --out tools/vec-map.png +""" + +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import struct +import sys +import time +from pathlib import Path + +DB = Path.home() / ".hermes/memos-plugin/data/memos.db" +EMBED_MODEL = "BAAI/bge-large-en-v1.5" # matches the plugin's Xenova/bge-large-en-v1.5 +EMBED_DIM = 1024 +BGE_QUERY_PREFIX = "Represent this sentence for searching relevant passages: " + + +def deepseek_key() -> str: + """Read the DeepSeek API key from the plugin's config.yaml. + + The plugin embeds the LLM key directly in YAML (`llm.apiKey`) — we don't + re-parse YAML for one field, just substring-grep. Falls back to env var + DEEPSEEK_API_KEY when the config doesn't have one. + """ + cfg = Path.home() / ".hermes/memos-plugin/config.yaml" + if cfg.exists(): + text = cfg.read_text() + # Find `apiKey: "sk-..."` under the `llm:` block. + in_llm = False + for line in text.splitlines(): + stripped = line.lstrip() + if line and not line.startswith((" ", "\t")): + in_llm = stripped.startswith("llm:") + continue + if in_llm and stripped.startswith("apiKey:"): + v = stripped.split("apiKey:", 1)[1].strip().strip('"').strip("'") + if v.startswith("sk-") and not v.startswith("sk-cp-"): + return v + env = os.environ.get("DEEPSEEK_API_KEY", "").strip() + if env: + return env + sys.exit("could not find DeepSeek key in ~/.hermes/memos-plugin/config.yaml or DEEPSEEK_API_KEY") + + +def open_db(readonly: bool = True) -> sqlite3.Connection: + if not DB.exists(): + sys.exit(f"DB not found: {DB}") + mode = "ro" if readonly else "rw" + con = sqlite3.connect(f"file:{DB}?mode={mode}", uri=True) + con.row_factory = sqlite3.Row + return con + + +def fmt_ts(ms: int | None) -> str: + if ms is None: + return "?" + try: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ms / 1000)) + except Exception: + return str(ms) + + +def trunc(s: str | None, n: int = 80) -> str: + if s is None: + return "" + s = " ".join(s.split()) + return s if len(s) <= n else s[: n - 1] + "…" + + +def bytes_to_vector(blob: bytes | None) -> list[float] | None: + if blob is None: + return None + # Vectors are stored as packed float32 little-endian (matches better-sqlite3 + onnxruntime defaults). + if len(blob) % 4 != 0: + return None + return list(struct.unpack(f"<{len(blob)//4}f", blob)) + + +# ─────────────────────────── commands ─────────────────────────────── + + +def cmd_profiles(args: argparse.Namespace) -> None: + con = open_db() + print(f"\n Profiles in {DB}") + print(f" {'-'*68}") + rows = con.execute(""" + SELECT owner_agent_kind, owner_profile_id, + (SELECT COUNT(*) FROM traces WHERE owner_agent_kind=t.owner_agent_kind AND owner_profile_id=t.owner_profile_id) AS traces, + (SELECT COUNT(*) FROM episodes WHERE owner_agent_kind=t.owner_agent_kind AND owner_profile_id=t.owner_profile_id) AS episodes, + (SELECT COUNT(*) FROM policies WHERE owner_agent_kind=t.owner_agent_kind AND owner_profile_id=t.owner_profile_id) AS policies, + (SELECT COUNT(*) FROM skills WHERE owner_agent_kind=t.owner_agent_kind AND owner_profile_id=t.owner_profile_id) AS skills, + (SELECT COUNT(*) FROM world_model WHERE owner_agent_kind=t.owner_agent_kind AND owner_profile_id=t.owner_profile_id) AS world + FROM (SELECT DISTINCT owner_agent_kind, owner_profile_id FROM traces + UNION SELECT DISTINCT owner_agent_kind, owner_profile_id FROM episodes + UNION SELECT DISTINCT owner_agent_kind, owner_profile_id FROM policies + UNION SELECT DISTINCT owner_agent_kind, owner_profile_id FROM skills) t + ORDER BY traces DESC + """).fetchall() + print(f" {'profile':<22} {'kind':<10} {'traces':>7} {'epi':>5} {'pol':>5} {'skill':>6} {'world':>6}") + for r in rows: + print(f" {r['owner_profile_id']:<22} {r['owner_agent_kind']:<10} {r['traces']:>7} {r['episodes']:>5} {r['policies']:>5} {r['skills']:>6} {r['world']:>6}") + print() + + +def cmd_traces(args: argparse.Namespace) -> None: + con = open_db() + where = ["owner_profile_id = ?"] + params: list = [args.profile] + if args.search: + where.append("(summary LIKE ? OR user_text LIKE ? OR agent_text LIKE ?)") + like = f"%{args.search}%" + params.extend([like, like, like]) + if args.scope: + where.append("share_scope = ?") + params.append(args.scope) + sql = f""" + SELECT id, ts, share_scope, value, alpha, + COALESCE(summary, '') AS summary, + COALESCE(user_text, '') AS user_text, + COALESCE(agent_text, '') AS agent_text + FROM traces + WHERE {' AND '.join(where)} + ORDER BY ts DESC + LIMIT ? + """ + params.append(args.limit) + rows = con.execute(sql, params).fetchall() + if not rows: + print(f" No traces for profile '{args.profile}' matching filters.") + return + print(f"\n Traces for {args.profile} ({len(rows)} shown, scope filter={args.scope or 'any'})") + print(f" {'-'*100}") + for r in rows: + text = r["summary"] or r["agent_text"] or r["user_text"] + scope_badge = f"[{r['share_scope']}]" if r['share_scope'] else "[?]" + print(f" {fmt_ts(r['ts'])} V={r['value']:+.2f} α={r['alpha']:+.2f} {scope_badge:>10}") + print(f" {r['id']} {trunc(text, 110)}") + print() + + +def cmd_skills(args: argparse.Namespace) -> None: + con = open_db() + rows = con.execute(""" + SELECT id, owner_profile_id, name, status, eta, support, share_scope, updated_at, + COALESCE(invocation_guide, '') AS guide + FROM skills + ORDER BY updated_at DESC + LIMIT ? + """, (args.limit,)).fetchall() + print(f"\n Skills (shared across agents — {len(rows)} shown)") + print(f" {'-'*100}") + for r in rows: + print(f" [{r['owner_profile_id']:<16}] {r['status']:<10} η={r['eta']:.2f} sup={r['support']} [{r['share_scope']}]") + print(f" {r['name']}") + print(f" {trunc(r['guide'], 110)}") + print() + + +def cmd_policies(args: argparse.Namespace) -> None: + con = open_db() + rows = con.execute(""" + SELECT id, status, share_scope, gain, support, updated_at, + COALESCE(title, '') AS title, + COALESCE(trigger, '') AS trig + FROM policies + WHERE owner_profile_id = ? + ORDER BY updated_at DESC + LIMIT ? + """, (args.profile, args.limit)).fetchall() + if not rows: + print(f" No policies for profile '{args.profile}'.") + return + print(f"\n Policies for {args.profile} ({len(rows)} shown)") + print(f" {'-'*100}") + for r in rows: + print(f" {r['status']:<10} gain={r['gain']:.2f} sup={r['support']} [{r['share_scope']}]") + print(f" {trunc(r['title'], 100)}") + if r['trig']: + print(f" trigger: {trunc(r['trig'], 100)}") + print() + + +def cmd_world(args: argparse.Namespace) -> None: + con = open_db() + rows = con.execute(""" + SELECT id, owner_profile_id, share_scope, updated_at, + COALESCE(title, '') AS title, + COALESCE(body, '') AS body + FROM world_model + WHERE 1=1 + """ + ("AND owner_profile_id = ?" if args.profile else "") + """ + ORDER BY updated_at DESC + """, (args.profile,) if args.profile else ()).fetchall() + print(f"\n World model entries ({len(rows)} shown — shared across agents)") + print(f" {'-'*100}") + for r in rows: + print(f" [{r['owner_profile_id']:<16}] [{r['share_scope']}] {r['title']}") + print(f" {trunc(r['body'], 110)}") + print() + + +def cmd_vec_stats(args: argparse.Namespace) -> None: + import statistics + + con = open_db() + print(f"\n Embedding vector stats") + print(f" {'-'*68}") + for table, vec_col in [ + ("traces", "vec_summary"), + ("traces", "vec_action"), + ("policies", "vec"), + ("world_model", "vec"), + ("skills", "vec"), + ]: + rows = con.execute( + f"SELECT length({vec_col}) AS bytes FROM {table} WHERE {vec_col} IS NOT NULL" + ).fetchall() + non_null = len(rows) + total = con.execute(f"SELECT COUNT(*) AS n FROM {table}").fetchone()["n"] + if non_null == 0: + print(f" {table}.{vec_col:<14} {non_null:>5}/{total:<5} (no vectors yet)") + continue + byte_sizes = [r["bytes"] for r in rows] + dim = byte_sizes[0] // 4 # float32 + consistent = all(b == byte_sizes[0] for b in byte_sizes) + print(f" {table}.{vec_col:<14} {non_null:>5}/{total:<5} dim={dim} {'(consistent)' if consistent else '(MIXED DIMS!)'}") + print() + + +def cmd_search(args: argparse.Namespace) -> None: + try: + import numpy as np + from sentence_transformers import SentenceTransformer + except ImportError: + sys.exit("Need sentence-transformers + numpy. Use python3.12 (already has them).") + con = open_db() + # Pull all traces with their summary vec (BGE) + rows = con.execute(""" + SELECT id, owner_profile_id, share_scope, ts, + COALESCE(summary, '') AS summary, + vec_summary + FROM traces + WHERE vec_summary IS NOT NULL + """).fetchall() + if not rows: + print(" No traces with vec_summary. Backfill may still be running.") + return + print(f" Loading BGE-large to embed query (one-time download if first run) …", file=sys.stderr) + model = SentenceTransformer(EMBED_MODEL) + q = np.asarray( + model.encode([BGE_QUERY_PREFIX + args.query], normalize_embeddings=True), + dtype=np.float32, + )[0] + corpus = [] + for r in rows: + v = bytes_to_vector(r["vec_summary"]) + if v is None or len(v) != EMBED_DIM: + continue + corpus.append((r, np.asarray(v, dtype=np.float32))) + if not corpus: + print(" No usable vectors found (dim mismatch?).") + return + vecs = np.stack([v for _, v in corpus]) + # vecs are already L2-normalized by the plugin (BGE produces normalized). + sims = vecs @ q + order = np.argsort(-sims)[: args.top_k] + print(f"\n Top-{args.top_k} for: {args.query!r}") + print(f" {'-'*100}") + for i in order: + r, _ = corpus[int(i)] + print(f" [sim={float(sims[i]):.3f}] [{r['owner_profile_id']:<16}] [{r['share_scope']}] {fmt_ts(r['ts'])}") + print(f" {r['id']} {trunc(r['summary'], 110)}") + print() + + +def cmd_project(args: argparse.Namespace) -> None: + """2D UMAP projection of trace vectors, coloured by owner_profile_id. + + Writes a PNG (matplotlib) at --out. When --json is given, also writes a + sidecar JSON with one point per row containing: + { x, y, id, profile, scope, ts, summary, cluster, crossAgentScore } + The HTML viewer at tools/umap-viewer.html consumes this JSON. + """ + try: + import numpy as np + import matplotlib + matplotlib.use("Agg") # headless + import matplotlib.pyplot as plt + import umap + from sklearn.cluster import HDBSCAN + except ImportError as e: + sys.exit( + f"Missing dep: {e.name}. Run this with ~/.hermes/tools-venv/bin/python " + "(see module docstring)." + ) + + con = open_db() + rows = con.execute(""" + SELECT id, owner_profile_id, share_scope, ts, + COALESCE(summary, '') AS summary, + vec_summary + FROM traces + WHERE vec_summary IS NOT NULL + ORDER BY ts ASC + """).fetchall() + if len(rows) < 10: + sys.exit(f" Only {len(rows)} traces with vec_summary — UMAP needs ≥10. Did you backfill?") + + vecs: list[np.ndarray] = [] + meta: list[dict] = [] + bad = 0 + for r in rows: + v = bytes_to_vector(r["vec_summary"]) + if v is None or len(v) != EMBED_DIM: + bad += 1 + continue + vecs.append(np.asarray(v, dtype=np.float32)) + meta.append({ + "id": r["id"], + "profile": r["owner_profile_id"], + "scope": r["share_scope"], + "summary": r["summary"], + "ts": r["ts"], + }) + if bad: + print(f" Skipped {bad} traces with wrong-dim vectors.", file=sys.stderr) + X = np.stack(vecs) + print(f" Projecting {X.shape[0]} × {X.shape[1]}-dim vectors → 2D via UMAP …", file=sys.stderr) + + n_components = 3 if args.three_d else 2 + reducer = umap.UMAP( + n_neighbors=min(args.neighbors, len(X) - 1), + min_dist=args.min_dist, + n_components=n_components, + metric="cosine", + random_state=42, + verbose=False, + ) + Y = reducer.fit_transform(X) # (n, n_components) + + # Per-profile colours. + profiles = sorted({m["profile"] for m in meta}) + cmap = plt.colormaps.get_cmap("tab10") + colour = {p: cmap(i / max(1, len(profiles))) for i, p in enumerate(profiles)} + + # In 3D mode we still render a 2D PNG by projecting Y[:, 0:2] — gives a + # quick preview that the export worked. The interactive viewer reads the + # JSON for the full 3D scene. + Y2 = Y[:, :2] + fig, ax = plt.subplots(figsize=(args.width / 100, args.height / 100), dpi=100) + fig.patch.set_facecolor("#F4F6FA") + ax.set_facecolor("#FFFFFF") + + for p in profiles: + pts = np.array([Y2[i] for i, m in enumerate(meta) if m["profile"] == p]) + if len(pts) == 0: + continue + ax.scatter( + pts[:, 0], pts[:, 1], + s=28, alpha=0.78, color=colour[p], + label=f"{p} (n={len(pts)})", + edgecolors="none", + ) + + ax.set_title( + f"Trace vector projection · UMAP(n_neighbors={reducer.n_neighbors}, " + f"min_dist={reducer.min_dist}, metric=cosine) · {X.shape[0]} traces · BGE-large-en-v1.5", + fontsize=11, color="#0F2042", + ) + ax.set_xlabel("UMAP-1", fontsize=9, color="#6B7280") + ax.set_ylabel("UMAP-2", fontsize=9, color="#6B7280") + ax.tick_params(colors="#6B7280") + for spine in ax.spines.values(): + spine.set_color("#E1E5EC") + ax.legend( + loc="best", framealpha=0.92, fontsize=9, frameon=True, + edgecolor="#E1E5EC", facecolor="#FFFFFF", + ) + fig.tight_layout() + out = Path(args.out) + fig.savefig(out, dpi=140, bbox_inches="tight", facecolor=fig.get_facecolor()) + plt.close(fig) + print(f" wrote: {out.resolve()} ({len(meta)} points, {len(profiles)} profiles)") + + # JSON sidecar for the interactive viewer. + if args.json: + try: + clusterer = HDBSCAN(min_cluster_size=4, min_samples=2) + cluster_ids = clusterer.fit_predict(Y).tolist() + except Exception: + cluster_ids = [-1] * len(meta) + + # Per-cluster profile diversity: how many distinct profiles, and which? + cluster_profiles: dict[int, set[str]] = {} + for cid, m in zip(cluster_ids, meta): + if cid < 0: + continue + cluster_profiles.setdefault(cid, set()).add(m["profile"]) + cluster_summary = { + int(cid): { + "profiles": sorted(p), + "size": cluster_ids.count(cid), + "crossAgent": len(p) >= 2, + } + for cid, p in cluster_profiles.items() + } + + points = [] + for i, m in enumerate(meta): + cid = int(cluster_ids[i]) + cinfo = cluster_summary.get(cid) + pt = { + "id": m["id"], + "x": float(Y[i, 0]), + "y": float(Y[i, 1]), + "profile": m["profile"], + "scope": m["scope"], + "ts": int(m["ts"]) if m["ts"] else 0, + "summary": m["summary"][:300], + "cluster": cid, + "crossAgent": bool(cinfo and cinfo["crossAgent"]), + } + if n_components == 3: + pt["z"] = float(Y[i, 2]) + points.append(pt) + payload = { + "model": EMBED_MODEL, + "dim": EMBED_DIM, + "umap": { + "n_neighbors": reducer.n_neighbors, + "min_dist": reducer.min_dist, + "n_components": n_components, + }, + "generated_at_ms": int(time.time() * 1000), + "profiles": sorted(profiles), + "clusters": cluster_summary, + "points": points, + } + json_path = Path(args.json) + json_path.write_text(json.dumps(payload, indent=2)) + cross = sum(1 for p in points if p["crossAgent"]) + print(f" wrote: {json_path.resolve()} ({len(points)} points, " + f"{len(cluster_summary)} clusters, {cross} in cross-agent clusters)") + + +def label_clusters( + members: dict[int, list[str]], + profiles: dict[int, set[str]], + cross_only: bool = True, + min_size: int = 4, +) -> dict[int, str]: + """Return {cluster_id: short_label} for the requested clusters. + + Uses DeepSeek V3 (deepseek-chat) via the plugin's HTTP key. Each call + sends up to 12 representative member-summary strings and asks for a + 4-6 word topic label. Cluster IDs with no produced label simply absent. + """ + try: + import requests as _requests + except ImportError: + sys.exit("Missing dep: requests. Use ~/.hermes/tools-venv/bin/python") + key = deepseek_key() + targets = [] + for cid, texts in members.items(): + if len(texts) < min_size: + continue + if cross_only and len(profiles.get(cid, set())) < 2: + continue + targets.append(cid) + if not targets: + return {} + + print(f" labelling {len(targets)} cluster(s) with DeepSeek …", file=sys.stderr) + labels: dict[int, str] = {} + SAMPLE = 12 # members per cluster sent to the LLM + + fails = 0 + for i, cid in enumerate(targets): + texts = members[cid][:SAMPLE] + joined = "\n".join(f"- {t}" for t in texts) + prompt = ( + "You are labelling clusters of related memories from a " + "multi-agent system. Given the snippets below, reply with a " + "single 4-6 word noun phrase that captures the single topic " + "they share. No quotes, no explanation, just the phrase.\n\n" + f"Cluster snippets:\n{joined}\n\nTopic:" + ) + body = { + "model": "deepseek-chat", + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.2, + "max_tokens": 40, + } + try: + r = _requests.post( + "https://api.deepseek.com/chat/completions", + headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, + json=body, + timeout=30, + ) + if r.status_code != 200: + fails += 1 + if fails <= 3: + print(f" cluster {cid}: HTTP {r.status_code} {r.text[:200]}", + file=sys.stderr) + continue + label = r.json()["choices"][0]["message"]["content"].strip() + # Strip surrounding quotes/punctuation, clamp length. + label = label.strip("'\"`.,;:").strip() + if label: + labels[cid] = label[:80] + if (i + 1) % 5 == 0: + print(f" {i+1}/{len(targets)} labelled", file=sys.stderr) + except Exception as e: + fails += 1 + if fails <= 3: + print(f" cluster {cid}: {type(e).__name__}: {e}", file=sys.stderr) + continue + print(f" labelled {len(labels)}/{len(targets)}", file=sys.stderr) + return labels + + +def cmd_graph_export(args: argparse.Namespace) -> None: + """Export the full memory graph as JSON for the 3D Memory Map viewer. + + Includes: traces (L1), policies (L2), skills (L3), world_model (L3) — each + with its own (x, y, z) UMAP position — plus lineage edges: + policy → trace (from policies.source_trace_ids_json) + skill → policy (from skills.source_policies_json) + skill → world (from skills.source_world_json) + world → policy (from world_model.policy_ids_json) + + X,Y are produced by a single joint UMAP over ALL artifacts (so artifacts + that share semantic content cluster together regardless of kind). Z is + assigned by kind so the abstraction hierarchy reads vertically: + traces z = 0 (raw experience) + policies z = 25 (induced patterns) + world_model z = 45 (environment knowledge) + skills z = 55 (validated, callable) + + For artifacts without a stored embedding vector, we embed their text + representation on the fly using BGE-large (sentence-transformers). + """ + try: + import numpy as np + from sentence_transformers import SentenceTransformer + import umap + from sklearn.cluster import HDBSCAN + except ImportError as e: + sys.exit(f"Missing dep: {e.name}. Use ~/.hermes/tools-venv/bin/python") + + con = open_db() + + def vec_from_blob(b: bytes | None) -> np.ndarray | None: + if not b or len(b) % 4 != 0: + return None + v = np.frombuffer(b, dtype=np.float32) + if len(v) != EMBED_DIM: + return None + return v + + # Collect rows + text needed for fallback embedding. + rows: list[dict] = [] + + for r in con.execute(""" + SELECT id, owner_profile_id, share_scope, ts, episode_id, + COALESCE(summary, '') AS text, + value, priority, + vec_summary AS v + FROM traces + """): + rows.append({"kind": "trace", "id": r["id"], "profile": r["owner_profile_id"], + "scope": r["share_scope"], "ts": r["ts"] or 0, + "episode_id": r["episode_id"], "text": r["text"], "v": vec_from_blob(r["v"]), + "value": r["value"], "priority": r["priority"], + "status": None, "extra": {}}) + + for r in con.execute(""" + SELECT id, owner_profile_id, share_scope, updated_at, status, + title, trigger, procedure, support, gain, + source_trace_ids_json, source_episodes_json, + vec AS v + FROM policies + """): + text = f"{r['title']}\n{r['trigger']}\n{r['procedure']}"[:1200] + rows.append({"kind": "policy", "id": r["id"], "profile": r["owner_profile_id"], + "scope": r["share_scope"], "ts": r["updated_at"] or 0, + "episode_id": None, "text": text, "v": vec_from_blob(r["v"]), + "status": r["status"], + "extra": { + "name": r["title"], + "support": r["support"], + "gain": r["gain"], + "src_traces": json.loads(r["source_trace_ids_json"] or "[]"), + "src_episodes": json.loads(r["source_episodes_json"] or "[]"), + }}) + + for r in con.execute(""" + SELECT id, owner_profile_id, share_scope, updated_at, status, + name, invocation_guide, eta, support, + source_policies_json, source_world_json, + vec AS v + FROM skills + """): + text = f"{r['name']}\n{r['invocation_guide']}"[:1200] + rows.append({"kind": "skill", "id": r["id"], "profile": r["owner_profile_id"], + "scope": r["share_scope"], "ts": r["updated_at"] or 0, + "episode_id": None, "text": text, "v": vec_from_blob(r["v"]), + "status": r["status"], + "extra": { + "name": r["name"], + "eta": r["eta"], + "support": r["support"], + "src_policies": json.loads(r["source_policies_json"] or "[]"), + "src_world": json.loads(r["source_world_json"] or "[]"), + }}) + + for r in con.execute(""" + SELECT id, owner_profile_id, share_scope, updated_at, + title, body, policy_ids_json, source_episodes_json, + vec AS v + FROM world_model + """): + text = f"{r['title']}\n{r['body']}"[:1200] + rows.append({"kind": "world", "id": r["id"], "profile": r["owner_profile_id"], + "scope": r["share_scope"], "ts": r["updated_at"] or 0, + "episode_id": None, "text": text, "v": vec_from_blob(r["v"]), + "status": None, + "extra": { + "name": r["title"], + "src_policies": json.loads(r["policy_ids_json"] or "[]"), + "src_episodes": json.loads(r["source_episodes_json"] or "[]"), + }}) + + if not rows: + sys.exit(" no rows to project") + + # On-the-fly embedding for rows without a vec. + need_embed = [(i, r["text"]) for i, r in enumerate(rows) if r["v"] is None] + if need_embed: + print(f" embedding {len(need_embed)} rows missing vecs via BGE-large…", file=sys.stderr) + model = SentenceTransformer("BAAI/bge-large-en-v1.5") + texts = [t or "(empty)" for _, t in need_embed] + vecs = model.encode(texts, normalize_embeddings=True, show_progress_bar=False, batch_size=16) + for (idx, _), v in zip(need_embed, vecs): + rows[idx]["v"] = np.asarray(v, dtype=np.float32) + + # Joint UMAP over everything. + X = np.stack([r["v"] for r in rows]) + print(f" joint UMAP: {X.shape[0]} artifacts × {X.shape[1]} dims → 2D", file=sys.stderr) + reducer = umap.UMAP( + n_neighbors=min(args.neighbors, len(X) - 1), + min_dist=args.min_dist, + n_components=2, + metric="cosine", + random_state=42, + verbose=False, + ) + Y = reducer.fit_transform(X) + + # Z by kind = vertical abstraction hierarchy. + Z_BY_KIND = {"trace": 0.0, "policy": 25.0, "world": 45.0, "skill": 55.0} + + # Cluster traces only (cross-agent flag is most meaningful for L1). + trace_idxs = [i for i, r in enumerate(rows) if r["kind"] == "trace"] + trace_pos = np.stack([Y[i] for i in trace_idxs]) + try: + cluster_ids = HDBSCAN(min_cluster_size=4, min_samples=2).fit_predict(trace_pos).tolist() + except Exception: + cluster_ids = [-1] * len(trace_idxs) + trace_cluster_lookup = {trace_idxs[k]: int(cluster_ids[k]) for k in range(len(trace_idxs))} + # Per-cluster profile diversity + centroid + member texts. + cluster_profiles: dict[int, set[str]] = {} + cluster_centroids: dict[int, list[float]] = {} + cluster_members: dict[int, list[str]] = {} + for i in trace_idxs: + cid = trace_cluster_lookup[i] + if cid < 0: + continue + cluster_profiles.setdefault(cid, set()).add(rows[i]["profile"]) + cluster_centroids.setdefault(cid, [0.0, 0.0, 0]) # x_sum, y_sum, n + cluster_centroids[cid][0] += float(Y[i, 0]) + cluster_centroids[cid][1] += float(Y[i, 1]) + cluster_centroids[cid][2] += 1 + cluster_members.setdefault(cid, []).append((rows[i].get("text") or "")[:200]) + + # ── LLM cluster labelling ───────────────────────────────────────── + # For each cluster (cross-agent ones first, then solo if requested), ask + # DeepSeek for a 4-6 word topic label. Reads DeepSeek key from the + # plugin's config.yaml. Cheap (~1¢ for 30 clusters) and short. Skipped + # entirely when --no-labels is set. + cluster_labels: dict[int, str] = {} + if not args.no_labels: + cluster_labels = label_clusters( + cluster_members, + cluster_profiles, + cross_only=not args.label_all, + min_size=args.label_min_size, + ) + + # Build node list. + profiles = sorted({r["profile"] for r in rows}) + nodes = [] + for i, r in enumerate(rows): + cid = trace_cluster_lookup.get(i, -1) if r["kind"] == "trace" else -1 + cross = cid >= 0 and len(cluster_profiles.get(cid, set())) >= 2 + nodes.append({ + "id": r["id"], + "kind": r["kind"], + "x": float(Y[i, 0]), + "y": float(Y[i, 1]), + "z": Z_BY_KIND.get(r["kind"], 0.0), + "profile": r["profile"], + "scope": r["scope"], + "ts": int(r["ts"]), + "status": r["status"], + "text": (r["text"] or "")[:240], + "extra": r["extra"], + "value": (round(float(r["value"]), 3) if r.get("value") is not None else None), + "priority": (round(float(r["priority"]), 3) if r.get("priority") is not None else None), + "cluster": cid, + "crossAgent": bool(cross), + }) + + # Build edges. We only keep edges where BOTH endpoints exist in the node set. + id_to_node = {n["id"]: n for n in nodes} + edges = [] + for n in nodes: + if n["kind"] == "policy": + for tid in n["extra"].get("src_traces", []): + if tid in id_to_node: + edges.append({"from": n["id"], "to": tid, "kind": "policy_trace"}) + elif n["kind"] == "skill": + for pid in n["extra"].get("src_policies", []): + if pid in id_to_node: + edges.append({"from": n["id"], "to": pid, "kind": "skill_policy"}) + for wid in n["extra"].get("src_world", []): + if wid in id_to_node: + edges.append({"from": n["id"], "to": wid, "kind": "skill_world"}) + elif n["kind"] == "world": + for pid in n["extra"].get("src_policies", []): + if pid in id_to_node: + edges.append({"from": n["id"], "to": pid, "kind": "world_policy"}) + + # Cluster summary blocks (centroids in unprojected UMAP coords — + # viewer applies its own SCALE/CENTER transform). + clusters_out = {} + for cid, (sx, sy, n) in cluster_centroids.items(): + clusters_out[str(cid)] = { + "id": cid, + "size": n, + "profiles": sorted(cluster_profiles.get(cid, set())), + "crossAgent": len(cluster_profiles.get(cid, set())) >= 2, + "centroid": [sx / n, sy / n], + "label": cluster_labels.get(cid), + } + + payload = { + "model": EMBED_MODEL, + "dim": EMBED_DIM, + "umap": {"n_neighbors": reducer.n_neighbors, "min_dist": reducer.min_dist, "n_components": 2, + "z_by_kind": Z_BY_KIND}, + "generated_at_ms": int(time.time() * 1000), + "profiles": profiles, + "nodes": nodes, + "edges": edges, + "clusters": clusters_out, + "counts": { + "trace": sum(1 for n in nodes if n["kind"] == "trace"), + "policy": sum(1 for n in nodes if n["kind"] == "policy"), + "skill": sum(1 for n in nodes if n["kind"] == "skill"), + "world": sum(1 for n in nodes if n["kind"] == "world"), + }, + } + out = Path(args.out) + out.write_text(json.dumps(payload, indent=2)) + print(f" wrote: {out.resolve()}") + print(f" nodes: {len(nodes)} ({payload['counts']})") + print(f" edges: {len(edges)}") + print(f" profiles: {profiles}") + + +def cmd_audit(args: argparse.Namespace) -> None: + con = open_db() + # Resolve --since + now_ms = int(time.time() * 1000) + since_ms = 0 + if args.since: + if args.since.endswith("h"): + since_ms = now_ms - int(args.since[:-1]) * 3600 * 1000 + elif args.since.endswith("m"): + since_ms = now_ms - int(args.since[:-1]) * 60 * 1000 + elif args.since.endswith("d"): + since_ms = now_ms - int(args.since[:-1]) * 86400 * 1000 + else: + since_ms = now_ms - int(args.since) * 1000 + where = ["ts_ms >= ?"] + params: list = [since_ms] + if args.non_private_only: + where.append("COALESCE(new_scope,'') != 'private'") + sql = f""" + SELECT ts_ms, table_name, row_id, operation, old_scope, new_scope, owner_profile_id + FROM share_scope_audit + WHERE {' AND '.join(where)} + ORDER BY ts_ms DESC + LIMIT ? + """ + params.append(args.limit) + rows = con.execute(sql, params).fetchall() + print(f"\n share_scope_audit ({len(rows)} events, --since {args.since or 'all'})") + print(f" {'-'*100}") + if not rows: + print(" (no events) — either nothing has changed, or the triggers aren't installed.") + for r in rows: + arrow = f"{r['old_scope'] or '∅'} → {r['new_scope'] or '∅'}" + print(f" {fmt_ts(r['ts_ms'])} {r['table_name']:<10} {r['operation']:<6} {arrow:<25} {r['owner_profile_id'] or '?'}") + print(f" {r['row_id']}") + print() + + +# ─────────────────────────── argparse ─────────────────────────────── + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("profiles", help="list profiles with row counts per table") + + t = sub.add_parser("traces", help="list a profile's traces") + t.add_argument("profile") + t.add_argument("--limit", type=int, default=20) + t.add_argument("--search", help="substring search in summary/user/agent text") + t.add_argument("--scope", help="filter by share_scope ('private', 'local', etc.)") + + s = sub.add_parser("skills", help="list skills (shared across agents)") + s.add_argument("--limit", type=int, default=30) + + pol = sub.add_parser("policies", help="list a profile's policies") + pol.add_argument("profile") + pol.add_argument("--limit", type=int, default=20) + + w = sub.add_parser("world", help="list world-model entries (shared across agents)") + w.add_argument("--profile", help="filter to one originating profile") + + sub.add_parser("vec-stats", help="show embedding-vector dim/coverage stats") + + sr = sub.add_parser("search", help="semantic search across all traces via BGE-large") + sr.add_argument("query") + sr.add_argument("--top-k", type=int, default=10) + + a = sub.add_parser("audit", help="show share_scope_audit log (requires forensic triggers)") + a.add_argument("--since", default="24h", help="e.g. '15m', '2h', '7d'") + a.add_argument("--limit", type=int, default=100) + a.add_argument("--non-private-only", action="store_true", help="only show writes to non-private scope") + + pr = sub.add_parser("project", help="UMAP 2D projection of trace vectors → PNG (+ optional JSON sidecar)") + pr.add_argument("--out", default="tools/vec-map.png", help="output PNG path") + pr.add_argument("--json", default=None, help="if set, also write a JSON sidecar consumed by tools/umap-viewer.html") + pr.add_argument("--neighbors", type=int, default=15, help="UMAP n_neighbors") + pr.add_argument("--min-dist", type=float, default=0.1, help="UMAP min_dist") + pr.add_argument("--width", type=int, default=1400, help="output px width") + pr.add_argument("--height", type=int, default=900, help="output px height") + pr.add_argument("--3d", dest="three_d", action="store_true", + help="project to 3D (n_components=3); JSON adds z to each point") + + gx = sub.add_parser("graph-export", + help="Export full memory graph (traces+policies+skills+world+edges) for the 3D Memory Map viewer") + gx.add_argument("--out", default="tools/memory-graph.json", help="output JSON path") + gx.add_argument("--neighbors", type=int, default=15) + gx.add_argument("--min-dist", type=float, default=0.1) + gx.add_argument("--no-labels", action="store_true", + help="skip LLM cluster labelling (faster; useful in CI)") + gx.add_argument("--label-all", action="store_true", + help="also label solo-agent clusters (default: only cross-agent)") + gx.add_argument("--label-min-size", type=int, default=4, + help="minimum cluster size to label (default 4)") + + args = p.parse_args() + + handlers = { + "profiles": cmd_profiles, + "traces": cmd_traces, + "skills": cmd_skills, + "policies": cmd_policies, + "world": cmd_world, + "vec-stats": cmd_vec_stats, + "search": cmd_search, + "audit": cmd_audit, + "project": cmd_project, + "graph-export": cmd_graph_export, + } + handlers[args.cmd](args) + + +if __name__ == "__main__": + main() diff --git a/tools/plugin-patches/adapters/hermes/memos_provider/__init__.py b/tools/plugin-patches/adapters/hermes/memos_provider/__init__.py new file mode 100644 index 0000000..41ef7c0 --- /dev/null +++ b/tools/plugin-patches/adapters/hermes/memos_provider/__init__.py @@ -0,0 +1,1784 @@ +"""MemOS Local — Hermes memory provider (Reflect2Evolve V7 core). + +Implements the ``agent.memory_provider.MemoryProvider`` interface exposed +by the hermes-agent host (see +``hermes-agent/agent/memory_provider.py``). All heavy lifting lives in the +Node.js ``memos-local-plugin`` core; this adapter is a thin Python client +that speaks JSON-RPC 2.0 over stdio to ``bridge.cts``. + +Discovery +--------- +The hermes-agent host discovers memory providers via +``plugins/memory/__init__.py::load_memory_provider`` which: + + 1. Looks for a ``register(ctx)`` function and calls it with a + ``_ProviderCollector`` that has ``register_memory_provider(provider)``. + 2. Falls back to finding a ``MemoryProvider`` subclass in the module. + +We support **both** entry points. + +Activation +---------- +Set ``memory.provider: memtensor`` in ``~/.hermes/config.yaml`` (or the +relevant `$HERMES_HOME`). + +Lifecycle mapping (V7 §0.2) +--------------------------- + +| Hermes hook | Our action | +| -------------------- | --------------------------------------------- | +| ``initialize`` | spawn bridge; open session + episode | +| ``on_turn_start`` | record turn count; stash message | +| ``prefetch`` | ``turn.start`` RPC → Tier 1+2+3 retrieval | +| ``queue_prefetch`` | background thread: prefetch + flush pending | +| ``sync_turn`` | queue a deferred ``turn.end`` RPC | +| ``on_session_end`` | flush pending + close episode + close session | +| ``on_pre_compress`` | extract a short memory summary | +| ``on_delegation`` | record a subagent outcome as a trace | +| ``get_tool_schemas`` | expose memory, skill, and environment tools | +| ``handle_tool_call`` | dispatch to MemOS JSON-RPC tool methods | +| ``shutdown`` | close bridge | + +Threading: all JSON-RPC calls are synchronous. ``queue_prefetch`` runs on +a daemon thread the provider owns. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import re +import sys +import threading +import time + +from pathlib import Path +from typing import Any + + +# Add our own directory to sys.path so the submodule imports below work +# whether hermes-agent loaded us bundled or via the user-plugin namespace. +_PLUGIN_DIR = Path(__file__).resolve().parent +if str(_PLUGIN_DIR) not in sys.path: + sys.path.insert(0, str(_PLUGIN_DIR)) + +from bridge_client import BridgeError, MemosBridgeClient # noqa: E402 +from daemon_manager import ensure_bridge_running, bridge_boot_lock # noqa: E402 + + +try: # pragma: no cover — host-provided base class, absent in unit tests + from agent.memory_provider import MemoryProvider # type: ignore +except Exception: # pragma: no cover + + class MemoryProvider: # type: ignore[no-redef] + """Fallback base class used when running outside hermes-agent host. + + Defines only the attributes the adapter reads so ``pyright`` and + ``pytest`` stay happy in standalone test runs. + """ + + +logger = logging.getLogger(__name__) + +PLUGIN_ID = "memos-local-hermes" +PLUGIN_VERSION = "2.0.0-beta.1" + +_HERMES_INTERNAL_REVIEW_PREFIXES = ( + "review the conversation above and consider saving to memory if appropriate.", + "review the conversation above and update the skill library.", + "review the conversation above and update two things:", + "review the conversation above and consider saving or updating a skill if appropriate.", + "review the conversation above and consider whether a skill should be saved or updated.", +) + + +def _is_hermes_internal_review_prompt(message: str) -> bool: + """Return True for Hermes' own background memory/skill review turns.""" + normalized = " ".join((message or "").strip().lower().split()) + if not normalized: + return False + return any(normalized.startswith(prefix) for prefix in _HERMES_INTERNAL_REVIEW_PREFIXES) + + +def _is_explicit_delegation_request(message: str) -> bool: + """Return True when the user explicitly asks Hermes to use a subagent.""" + text = " ".join((message or "").strip().lower().split()) + if not text: + return False + delegation_terms = ( + "subagent", + "sub-agent", + "sub agent", + "delegate", + "delegation", + "子代理", + "子任务", + "派一个", + "派发", + ) + return any(term in text for term in delegation_terms) + + +def _is_verifier_feedback_prompt(message: str) -> bool: + """Return True for explicit evaluator/verifier feedback turns.""" + text = " ".join((message or "").strip().lower().split()) + if not text: + return False + + # Strong markers: formal verifier feedback + strong_markers = ( + "本任务评为反例", + "本任务评为正例", + "verifier feedback", + "verification feedback", + "task rated as counterexample", + "task is rated as counterexample", + "r <= -0.5", + "r≤-0.5", + "r >= 0.5", + "r≥0.5", + ) + if any(marker in text for marker in strong_markers): + return True + if re.search(r"\br\s*(?:<=|>=|≤|≥)\s*-?\d+(?:\.\d+)?", text): + return True + + # User correction markers: natural corrective feedback + correction_markers = ( + "不对", + "错了", + "不是", + "不行", + "不对的", + "写错了", + "做错了", + "理解错了", + "wrong", + "incorrect", + "not right", + "not correct", + "that's wrong", + "this is wrong", + ) + if any(marker in text for marker in correction_markers): + return True + + # Weak markers: require "feedback/反馈" + action keywords + if "feedback" not in text and "反馈" not in text: + return False + feedback_markers = ( + "failed", + "failure", + "pass", + "passed", + "success", + "succeeded", + "should", + "avoid", + "next time", + "失败", + "成功", + "应该", + "不要", + "下次", + ) + return any(marker in text for marker in feedback_markers) + + +def _feedback_polarity(message: str) -> str: + text = " ".join((message or "").strip().lower().split()) + if re.search(r"r\s*(?:<=|≤)\s*-?0\.5", text): + return "negative" + if "反例" in text: + return "negative" + if any( + term in text + for term in ( + "failed", + "failure", + "wrong", + "incorrect", + "not acceptable", + "错误", + "失败", + "不对", + ) + ): + return "negative" + if re.search(r"r\s*(?:>=|≥)\s*0\.5", text): + return "positive" + if "正例" in text: + return "positive" + if any( + term in text + for term in ("passed", "success", "succeeded", "correct", "great", "成功", "通过", "正确") + ): + return "positive" + return "neutral" + + +def _feedback_magnitude(message: str, polarity: str) -> float: + text = " ".join((message or "").strip().lower().split()) + match = re.search(r"\br\s*(?:=|:|<=|>=|≤|≥)\s*(-?\d+(?:\.\d+)?)", text) + if match: + with contextlib.suppress(Exception): + return max(0.0, min(1.0, abs(float(match.group(1))))) + return 1.0 if polarity in {"positive", "negative"} else 0.6 + + +class MemTensorProvider(MemoryProvider): + """MemOS Reflect2Evolve memory for hermes-agent. + + Wraps a JSON-RPC client around the shared ``memos-local-plugin`` core. + + Only methods that Hermes actually calls are overridden here; every + optional hook stays default so future versions of the base class can + grow without breaking us. + """ + + def __init__(self) -> None: + self._bridge: MemosBridgeClient | None = None + self._session_id: str = "" + self._episode_id: str = "" + self._hermes_home: str = "" + self._agent_identity: str = "hermes" + self._platform: str = "cli" + self._last_host_runtime: dict[str, str] = {} + self._turn_number: int = 0 + # Last user turn text — used by `sync_turn` to compose `turn.end`. + self._last_user_text: str = "" + # Single-flight prefetch coordination. + self._prefetch_lock = threading.Lock() + self._prefetch_result: str = "" + self._prefetch_thread: threading.Thread | None = None + # Tool calls accumulated via the Hermes `post_tool_call` plugin + # hook — flushed alongside user/assistant text in `sync_turn`. + self._tool_calls: list[dict[str, Any]] = [] + # Reasoning text captured via the `post_llm_call` hook for the + # current turn. Hermes' MemoryProvider.sync_turn signature only + # carries the visible assistant text; reasoning lives on the + # `assistant` message's `reasoning` field. We capture it from + # `post_llm_call`'s `conversation_history` so the viewer can + # show the model's thinking like OpenClaw does. + self._turn_thinking: str = "" + self._hook_registered = False + self._bridge_keepalive_stop = threading.Event() + self._bridge_keepalive_thread: threading.Thread | None = None + # Hermes runs background memory/skill reviewers by forking an agent and + # appending a synthetic user turn. That turn is instruction plumbing, + # not a human utterance, so it must not become a MemOS trace. + self._skip_current_turn = False + # Track the last trace ID for feedback submission + self._last_trace_id: str = "" + + # ─── Identity ───────────────────────────────────────────────────────── + + @property + def name(self) -> str: # type: ignore[override] + return "memtensor" + + def is_available(self) -> bool: # type: ignore[override] + try: + return ensure_bridge_running(probe_only=True) + except Exception: + return False + + # ─── Lifecycle ──────────────────────────────────────────────────────── + + def initialize(self, session_id: str, **kwargs: Any) -> None: # type: ignore[override] + """Called once at agent startup. + + kwargs always include ``hermes_home`` and ``platform``. We stash + them so the bridge can resolve the right `~/.hermes/memos-plugin/` + and log the originating channel. + + We open the session here but NOT the episode — episode creation + is deferred to ``_ensure_episode()`` (called from the first + ``on_turn_start``), so the actual user message can be passed as + the episode's initial text instead of a generic placeholder. + """ + self._session_id = session_id or self._session_id + self._hermes_home = str(kwargs.get("hermes_home") or "") + self._platform = str(kwargs.get("platform") or "cli") + self._agent_identity = str(kwargs.get("agent_identity") or "hermes") + try: + ensure_bridge_running() + except Exception as err: + logger.warning("MemOS: failed to start bridge — %s", err) + return + try: + self._bridge = MemosBridgeClient.get_or_create() + # Register the fallback LLM handler BEFORE we open the + # session so it is available the very first time the + # plugin's facade asks for help (e.g. on the first + # `turn.start` retrieval call). + self._bridge.register_host_handler( + "host.llm.complete", + self._handle_host_llm_complete, + ) + self._open_session(session_id) + logger.info( + "MemOS: bridge ready session=%s platform=%s (episode deferred)", + self._session_id, + self._platform, + ) + except Exception as err: + logger.warning("MemOS: bridge init failed — %s", err) + self._bridge = None + # Register a Hermes plugin hook to capture tool calls as they + # happen. The `post_tool_call` hook fires after every tool + # dispatch (write_file, terminal, search_files, etc.) with the + # tool name, arguments, and result. We accumulate them and + # flush in `sync_turn`. + self._register_tool_call_hook() + self._start_bridge_keepalive() + + def system_prompt_block(self) -> str: # type: ignore[override] + return ( + "# MemOS Memory\n" + "Persistent long-term memory is active. Call `memory_search`, " + "`memory_get`, `memory_timeline`, `memory_environment`, " + "`skill_list`, or `skill_get` when prior context or learned " + "procedures would help. Relevant memories are automatically " + "injected at the start of every turn.\n\n" + "**Not the same as repo skills:** Hermes' `` / " + "`skill_view(name=…)` load **repository SKILL.md** files. " + "`skill_get` / `skill_list` refer to **MemOS-crystallized** " + "skills (learned from your runs). If both apply, you may use " + "both: repo skills for product conventions, MemOS skills for " + "workflows proven on *your* past tasks." + ) + + # ─── Episode tracking ───────────────────────────────────────────────── + # + # We DON'T call `episode.open` ourselves. The core's `onTurnStart` + # (RPC `turn.start`) automatically opens / reopens / boundary-cuts + # an episode based on V7 §0.1 relation classification. Calling + # `episode.open` from the adapter creates an orphan episode that + # never receives any traces — and our `episode.close` then closes + # that empty orphan, leaving the *real* episode (the one the + # pipeline auto-created) without the close trigger that fires + # reflect → reward → L2 / L3 / Skill. + # + # The real episode id surfaces in the `turn.start` response's + # `query.episodeId` field; we stash it here so `on_session_end` + # can close the right one. + + # ─── Tool call capture via Hermes plugin hook ────────────────────────── + + def _matches_session(self, session_id: str = "") -> bool: + """Return True when a global Hermes hook belongs to this provider.""" + return not session_id or not self._session_id or session_id == self._session_id + + def _runtime_namespace(self) -> dict[str, Any]: + profile_id = (self._agent_identity or "").strip() or "default" + normalized_home = self._hermes_home.replace("\\", "/").rstrip("/") + if normalized_home: + marker = "/profiles/" + if marker in normalized_home: + profile_id = normalized_home.rsplit(marker, 1)[-1].split("/", 1)[0] or profile_id + elif normalized_home.endswith("/.hermes") and profile_id in ("", "hermes"): + profile_id = "default" + return { + "agentKind": "hermes", + "profileId": profile_id, + "profileLabel": profile_id, + } + + def _register_tool_call_hook(self) -> None: + if self._hook_registered: + return + try: + from hermes_cli.plugins import ( + get_plugin_manager, # pyright: ignore[reportMissingImports] + ) + + mgr = get_plugin_manager() + mgr._hooks.setdefault("post_tool_call", []).append(self._on_post_tool_call) + mgr._hooks.setdefault("post_llm_call", []).append(self._on_post_llm_call) + self._hook_registered = True + logger.debug("MemOS: registered post_tool_call + post_llm_call hooks") + except Exception as err: + logger.debug("MemOS: could not register tool hook — %s", err) + + def _on_post_tool_call( + self, + *, + tool_name: str = "", + args: dict | None = None, + result: str = "", + tool_call_id: str = "", + session_id: str = "", + **kw: Any, + ) -> None: + """Accumulate a tool call record for the current turn. + + We keep the host's ``tool_call_id`` on a private ``_id`` field so + ``_on_post_llm_call`` can later attach the assistant message's + ``reasoning`` (the model's "thinking before this tool") to the + right entry. Hermes/OpenAI-compatible providers may surface the + same call under ``id``, ``call_id``, or ``response_item_id``; keep + all aliases so post-LLM and post-tool events can be merged even + when a particular tool omits one field. Private fields are stripped + before the JSON-RPC send. + """ + if not self._matches_session(session_id): + return + ids = self._tool_call_ids( + { + "id": tool_call_id, + "call_id": kw.get("call_id"), + "response_item_id": kw.get("response_item_id"), + "tool_call_id": kw.get("tool_call_id"), + } + ) + input_text = ( + json.dumps(args, ensure_ascii=False) if isinstance(args, dict) else str(args or "") + ) + timing = self._coerce_tool_timing(kw) + + existing = self._find_tool_call(ids) + if existing is not None: + existing["name"] = tool_name or existing.get("name") or "unknown_tool" + existing["input"] = input_text or existing.get("input", "") + existing["output"] = (result or "")[:4000] + existing["_ids"] = sorted(set((existing.get("_ids") or []) + ids)) + existing["_id"] = existing.get("_id") or (ids[0] if ids else "") + if existing.get("_id"): + existing["toolCallId"] = existing["_id"] + if timing: + existing.update(timing) + return + + call = { + "name": tool_name, + "input": input_text, + "output": (result or "")[:4000], + "_id": ids[0] if ids else "", + "_ids": ids, + "toolCallId": ids[0] if ids else "", + } + if timing: + call.update(timing) + self._tool_calls.append(call) + + def _coerce_tool_timing(self, payload: dict[str, Any]) -> dict[str, int] | None: + """Preserve real tool timing if Hermes exposes it in hook kwargs.""" + started = self._coerce_epoch_ms( + payload.get("startedAt") + or payload.get("started_at") + or payload.get("startTime") + or payload.get("start_time") + ) + ended = self._coerce_epoch_ms( + payload.get("endedAt") + or payload.get("ended_at") + or payload.get("endTime") + or payload.get("end_time") + ) + if started is not None and ended is not None and ended > started: + return {"startedAt": started, "endedAt": ended} + + duration = self._coerce_duration_ms( + payload.get("durationMs") + or payload.get("duration_ms") + or payload.get("elapsedMs") + or payload.get("elapsed_ms") + or payload.get("latencyMs") + or payload.get("latency_ms") + ) + if duration is not None and duration > 0: + end_ms = int(time.time() * 1000) + return {"startedAt": end_ms - duration, "endedAt": end_ms} + + return None + + @staticmethod + def _coerce_epoch_ms(value: Any) -> int | None: + if isinstance(value, int | float): + numeric = float(value) + elif isinstance(value, str): + try: + numeric = float(value) + except ValueError: + return None + else: + return None + if numeric <= 0: + return None + # Accept seconds or milliseconds. + if numeric < 10_000_000_000: + numeric *= 1000 + return int(numeric) + + @staticmethod + def _coerce_duration_ms(value: Any) -> int | None: + if isinstance(value, int | float): + numeric = float(value) + elif isinstance(value, str): + try: + numeric = float(value) + except ValueError: + return None + else: + return None + if numeric <= 0: + return None + return int(numeric) + + def _on_post_llm_call( + self, + *, + conversation_history: list[dict[str, Any]] | None = None, + user_message: str = "", + session_id: str = "", + **_kw: Any, + ) -> None: + """Capture reasoning content from assistant messages in this turn. + + Hermes' ``_build_assistant_message`` writes the model's reasoning + text into ``msg["reasoning"]`` (extended thinking, OpenAI o1 + ``reasoning_content``, etc.). The default ``MemoryProvider.sync_turn`` + only carries plain ``user_content`` / ``assistant_content``, so we + fish the reasoning out of the conversation history fired with the + ``post_llm_call`` hook and stash it for the upcoming ``sync_turn``. + + We walk through assistant messages of the current turn (those + after the most recent user message). For each message that + contains ``tool_calls``, we attach two pieces of pre-tool context + to each captured tool call: + + * ``thinkingBefore`` — private/model-native reasoning. + * ``assistantTextBefore`` — visible assistant narration emitted in + the same message before the tool call. + + The final reasoning (the message that produced the user-facing + reply) becomes the turn-level ``agentThinking``. + """ + if not self._matches_session(session_id): + return + if not conversation_history: + return + + # Find the last user message and walk forward from there. + last_user_idx = -1 + for i, msg in enumerate(conversation_history): + if msg.get("role") == "user": + last_user_idx = i + + # Build maps keyed by tool_call_id so post-tool events can be + # merged with the canonical assistant message later. + thinking_by_id: dict[str, str] = {} + assistant_text_by_id: dict[str, str] = {} + ordered_tool_calls: list[dict[str, Any]] = [] + ordered_object_ids: set[int] = set() + # Reasoning of the message that produced the final reply (no + # tool_calls in that message) becomes the turn-level thinking. + final_reasoning = "" + + for msg in conversation_history[last_user_idx + 1 :]: + if msg.get("role") != "assistant": + continue + r = msg.get("reasoning") + r_str = r.strip() if isinstance(r, str) and r.strip() else "" + content_str = self._assistant_text(msg.get("content")) + tcs = msg.get("tool_calls") + if isinstance(tcs, list) and tcs: + # Reasoning preceded these tool calls. + for tc in tcs: + if not isinstance(tc, dict): + continue + ids = self._tool_call_ids(tc) + if r_str: + for tc_id in ids: + thinking_by_id[tc_id] = r_str + if content_str: + for tc_id in ids: + assistant_text_by_id[tc_id] = content_str + + existing = self._find_tool_call(ids) + # Some Hermes tools (for example planner/todo-style + # host tools) appear in the assistant message but do + # not fire `post_tool_call`. Add a placeholder so the + # trace still records the tool decision and reasoning; + # `post_tool_call` will merge real output later if it + # eventually arrives. + if existing is None: + existing = { + "name": self._tool_name(tc), + "input": self._tool_input(tc), + "output": "", + "thinkingBefore": r_str or "", + "assistantTextBefore": content_str or "", + "_id": ids[0] if ids else "", + "_ids": ids, + "toolCallId": ids[0] if ids else "", + } + self._tool_calls.append(existing) + else: + # Preserve output captured by post_tool_call, but + # let the LLM message supply canonical order, + # input/name aliases, and thinkingBefore. + existing["name"] = existing.get("name") or self._tool_name(tc) + existing["input"] = existing.get("input") or self._tool_input(tc) + existing["thinkingBefore"] = r_str or existing.get("thinkingBefore", "") + existing["assistantTextBefore"] = content_str or existing.get( + "assistantTextBefore", "" + ) + existing["_ids"] = sorted(set((existing.get("_ids") or []) + ids)) + existing["_id"] = existing.get("_id") or (ids[0] if ids else "") + if existing.get("_id"): + existing["toolCallId"] = existing["_id"] + + marker = id(existing) + if marker not in ordered_object_ids: + ordered_tool_calls.append(existing) + ordered_object_ids.add(marker) + else: + # Plain assistant reply — overwrite final_reasoning so we + # keep the LATEST one (mirrors Hermes' ``last_reasoning``). + if r_str: + final_reasoning = r_str + + # Make the turn payload follow the LLM-declared tool order. This + # matters when post_tool_call fires for later tools before + # post_llm_call backfills earlier planner/todo calls. + if ordered_tool_calls: + remaining = [tc for tc in self._tool_calls if id(tc) not in ordered_object_ids] + self._tool_calls = ordered_tool_calls + remaining + + # Attach thinkingBefore to matching captured tool calls. + for tc in self._tool_calls: + ids = tc.get("_ids") or ([tc.get("_id")] if tc.get("_id") else []) + for tc_id in ids: + if tc_id and tc_id in thinking_by_id: + tc["thinkingBefore"] = thinking_by_id[tc_id] + break + for tc_id in ids: + if tc_id and tc_id in assistant_text_by_id: + tc["assistantTextBefore"] = assistant_text_by_id[tc_id] + break + + self._turn_thinking = final_reasoning + + @staticmethod + def _assistant_text(content: Any) -> str: + """Extract visible assistant text from Hermes/OpenAI message content.""" + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if isinstance(block, str): + text = block.strip() + elif isinstance(block, dict): + raw = block.get("text") or block.get("content") + text = raw.strip() if isinstance(raw, str) else "" + else: + text = "" + if text: + parts.append(text) + return "\n".join(parts).strip() + return "" + + @staticmethod + def _tool_call_ids(raw: dict[str, Any]) -> list[str]: + ids: list[str] = [] + for key in ("id", "call_id", "response_item_id", "tool_call_id"): + value = raw.get(key) + if isinstance(value, str) and value and value not in ids: + ids.append(value) + return ids + + @staticmethod + def _tool_name(raw: dict[str, Any]) -> str: + fn = raw.get("function") + if isinstance(fn, dict) and isinstance(fn.get("name"), str): + return fn["name"] + name = raw.get("name") + return name if isinstance(name, str) and name else "unknown_tool" + + @staticmethod + def _tool_input(raw: dict[str, Any]) -> str: + fn = raw.get("function") + if isinstance(fn, dict): + args = fn.get("arguments") + if isinstance(args, str): + return args + if args is not None: + return json.dumps(args, ensure_ascii=False) + for key in ("arguments", "args", "input"): + args = raw.get(key) + if isinstance(args, str): + return args + if args is not None: + return json.dumps(args, ensure_ascii=False) + return "" + + def _find_tool_call(self, ids: list[str]) -> dict[str, Any] | None: + if not ids: + return None + needle = set(ids) + for tc in self._tool_calls: + existing = set(tc.get("_ids") or []) + if tc.get("_id"): + existing.add(str(tc["_id"])) + if existing & needle: + return tc + return None + + # ─── Turn-level hooks ───────────────────────────────────────────────── + + def on_turn_start(self, turn_number: int, message: str, **_kwargs: Any) -> None: # type: ignore[override] + self._turn_number = int(turn_number or 0) + self._skip_current_turn = _is_hermes_internal_review_prompt(message) + self._last_user_text = "" if self._skip_current_turn else (message or "").strip() + # Reset per-turn buffers so reasoning / tool calls captured here + # belong only to this turn. + self._turn_thinking = "" + self._tool_calls = [] + + def prefetch(self, query: str, *, session_id: str = "") -> str: # type: ignore[override] + """Inject relevant memories ahead of the next model call. + + If ``queue_prefetch`` already ran for this turn, return the + cached result immediately. Otherwise synchronously run + ``turn.start`` against the bridge (small overhead). + """ + if self._prefetch_thread and self._prefetch_thread.is_alive(): + self._prefetch_thread.join(timeout=5.0) + with self._prefetch_lock: + cached = self._prefetch_result + self._prefetch_result = "" + if self._skip_current_turn or _is_hermes_internal_review_prompt(query): + self._skip_current_turn = True + return "" + suppress_injection = _is_explicit_delegation_request(query) + if cached: + return "" if suppress_injection else cached + # Fail-open: recall runs BEFORE the reply. Pure warm-check — never boot + # or block here (the background keepalive owns booting/maintaining the + # bridge). If it isn't warm yet, skip recall and let the reply proceed + # instantly; the turn.start request below is also capped (4s). + if not self._bridge: + return "" + try: + context = self._turn_start(query, session_id=session_id) + if suppress_injection: + # Do not let remembered "do it directly" skills override an + # explicit user request to dispatch work to a subagent. + return "" + return context + except Exception as err: + logger.debug("MemOS: prefetch failed — %s", err) + return "" + + def queue_prefetch(self, query: str, *, session_id: str = "") -> None: # type: ignore[override] + """No-op for MemOS. + + Hermes calls this AFTER ``sync_turn`` to warm the cache for a + hypothetical next turn. In the V7 architecture each ``turn.end`` + triggers async capture / reward / induction work — running another + ``turn.start`` against the same (already-closed) episode just + races and produces ``episode already closed`` noise in the + viewer's logs page. ``prefetch()`` (called BEFORE the next + turn's LLM call) handles real retrieval; this hook is moot. + """ + return + + def sync_turn( + self, + user_content: str, + assistant_content: str, + *, + session_id: str = "", + ) -> None: # type: ignore[override] + """Persist a completed turn immediately. + + Tool calls are captured via the Hermes ``post_tool_call`` + plugin hook (registered in ``initialize``). By the time + ``sync_turn`` is called the full list of tool calls for this + turn has already been accumulated in ``self._tool_calls``. + """ + user = user_content or self._last_user_text + assistant = assistant_content or "" + tool_calls = self._tool_calls + thinking = self._turn_thinking + self._tool_calls = [] + self._turn_thinking = "" + if self._skip_current_turn or _is_hermes_internal_review_prompt(user): + self._skip_current_turn = False + self._last_user_text = "" + return + if not self._bridge: + logger.warning("MemOS: sync_turn skipped because bridge is unavailable") + return + logger.info( + "MemOS: sync_turn user=%d assistant=%d tools=%d thinking=%d", + len(user), + len(assistant), + len(tool_calls), + len(thinking), + ) + ts_ms = int(time.time() * 1000) + 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( + 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 + except Exception as err: + if not self._is_transport_closed(err): + logger.warning("MemOS: sync_turn turn.end failed — %s", err) + else: + logger.warning( + "MemOS: bridge transport closed during sync_turn; " + "reconnecting and retrying once — %s", + err, + ) + try: + self._reconnect_bridge(session_id or self._session_id, timeout=90.0) + if user: + self._turn_start(user, session_id=session_id or self._session_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 + except Exception: + logger.exception( + "MemOS: sync_turn failed after bridge reconnect; " + "memory turn was not persisted" + ) + if user_content: + self._last_user_text = user_content + + def on_delegation( + self, + task: str, + result: str, + *, + child_session_id: str = "", + **kwargs: Any, + ) -> None: # type: ignore[override] + """Record a subagent outcome. + + Hermes invokes this on the **parent** when a subagent finishes. + We write it as a synthetic trace so decision-repair can see + failure bursts and so Tier 2 retrieval can surface past + delegations. + """ + if not self._bridge: + return + try: + if not self._episode_id and self._last_user_text: + self._turn_start(self._last_user_text, session_id=self._session_id) + hook_meta = { + "hookKwargs": kwargs, + } + self._bridge.request( + "subagent.record", + { + "sessionId": self._session_id, + "episodeId": self._episode_id or None, + "childSessionId": child_session_id or None, + "task": task, + "result": result, + "toolCalls": self._extract_child_tool_calls(child_session_id), + "ts": int(time.time() * 1000), + "meta": hook_meta, + }, + ) + except Exception as err: + logger.warning("MemOS: subagent.record failed — %s", err) + + def _extract_child_tool_calls(self, child_session_id: str = "") -> list[dict[str, Any]]: + """Best-effort recovery of subagent tool calls from Hermes session JSON. + + Hermes invokes ``on_delegation`` on the parent and only passes the + child task/result. The child transcript is still persisted under + ``$HERMES_HOME/sessions/session_.json``, so we read that file to + preserve structured tool use in the MemOS child episode. + """ + if not child_session_id: + return [] + sessions_dir = ( + Path(self._hermes_home).expanduser() / "sessions" + if self._hermes_home + else Path.home() / ".hermes" / "sessions" + ) + session_path = sessions_dir / f"session_{child_session_id}.json" + try: + payload = json.loads(session_path.read_text(encoding="utf-8")) + except Exception as err: + logger.debug("MemOS: child session tool extraction skipped — %s", err) + return [] + + messages = payload.get("messages") + if not isinstance(messages, list): + return [] + + tool_outputs: dict[str, str] = {} + for message in messages: + if not isinstance(message, dict) or message.get("role") != "tool": + continue + tool_call_id = str(message.get("tool_call_id") or "") + if tool_call_id: + tool_outputs[tool_call_id] = str(message.get("content") or "")[:4000] + + base_ts = int(time.time() * 1000) + calls: list[dict[str, Any]] = [] + for message in messages: + if not isinstance(message, dict): + continue + raw_calls = message.get("tool_calls") + if not isinstance(raw_calls, list): + continue + for raw_call in raw_calls: + if not isinstance(raw_call, dict): + continue + function = raw_call.get("function") + if not isinstance(function, dict): + function = {} + call_id = str( + raw_call.get("id") + or raw_call.get("call_id") + or raw_call.get("tool_call_id") + or "" + ) + raw_args = function.get("arguments", raw_call.get("arguments", "")) + output = tool_outputs.get(call_id, "") + call: dict[str, Any] = { + "name": str(function.get("name") or raw_call.get("name") or "tool"), + "input": self._json_or_raw(raw_args), + "output": output, + "startedAt": base_ts + len(calls), + "endedAt": base_ts + len(calls), + } + parsed_output = self._json_or_raw(output) + if isinstance(parsed_output, dict) and parsed_output.get("error"): + call["errorCode"] = "tool_error" + calls.append(call) + return calls + + @staticmethod + def _json_or_raw(value: Any) -> Any: + if not isinstance(value, str): + return value + try: + return json.loads(value) + except Exception: + return value + + def on_pre_compress(self, messages: list[dict[str, Any]]) -> str: # type: ignore[override] + """Extract a compression-time memory summary. + + Hermes calls this right before discarding old messages; we + surface a tight summary of the relevant retrieval packet so + the compressor can preserve it alongside its own summary. + """ + if not self._bridge or not self._last_user_text: + return "" + with contextlib.suppress(Exception): + packet = self._turn_start(self._last_user_text, session_id=self._session_id) + if packet: + return f"MemOS memory snapshot (preserved across compression):\n{packet}" + return "" + + # ─── Tool surface ───────────────────────────────────────────────────── + + @staticmethod + def _clip(value: Any, limit: int = 1200) -> str: + text = "" if value is None else str(value) + return text if len(text) <= limit else text[:limit] + "..." + + @staticmethod + def _int_arg(args: dict[str, Any], key: str, default: int, lower: int, upper: int) -> int: + try: + value = int(args.get(key, default)) + except Exception: + value = default + return max(lower, min(upper, value)) + + def get_tool_schemas(self) -> list[dict[str, Any]]: # type: ignore[override] + return [ + { + "name": "memory_search", + "description": ( + "Search the local MemOS memory (traces, policies, world models, skills). " + "Prefer this before claiming prior context is unavailable." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Short natural-language query (2–5 key words).", + }, + "maxResults": { + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 50, + }, + "sessionScope": { + "type": "boolean", + "default": False, + "description": "Restrict results to the current Hermes session only.", + }, + }, + "required": ["query"], + }, + }, + { + "name": "memory_get", + "description": ( + "Fetch the full body of a memory item by id. `kind` can be " + '"trace" (default), "policy", or "world_model".' + ), + "parameters": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "kind": { + "type": "string", + "enum": ["trace", "policy", "world_model"], + "default": "trace", + }, + }, + "required": ["id"], + }, + }, + { + "name": "memory_timeline", + "description": "Return the ordered traces for an episode id.", + "parameters": { + "type": "object", + "properties": { + "episodeId": {"type": "string"}, + "limit": {"type": "integer", "default": 20, "maximum": 100}, + }, + "required": ["episodeId"], + }, + }, + { + "name": "skill_list", + "description": ( + "List callable skills the agent can invoke. Filter by status " + "(candidate | active | archived)." + ), + "parameters": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["candidate", "active", "archived"], + }, + "limit": { + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 50, + }, + }, + }, + }, + { + "name": "memory_environment", + "description": ( + "Return accumulated environment knowledge (L3 world models): " + "structural facts, behavioral rules, and project constraints." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Optional keyword query; omit to list recent world models.", + }, + "limit": { + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 30, + }, + }, + }, + }, + { + "name": "skill_get", + "description": "Return the full invocation guide for a crystallized skill.", + "parameters": { + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + }, + ] + + def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) -> str: # type: ignore[override] + if not self._bridge: + return json.dumps({"error": "bridge not connected"}) + try: + if tool_name == "memory_search": + query = (args.get("query") or "").strip() + if not query: + return json.dumps({"error": "missing query"}) + max_results = self._int_arg(args, "maxResults", 10, 1, 50) + params: dict[str, Any] = { + "agent": "hermes", + "namespace": self._runtime_namespace(), + "query": query, + "topK": { + "tier1": max_results, + "tier2": max_results, + "tier3": max_results, + }, + } + if bool(args.get("sessionScope", False)): + params["sessionId"] = self._session_id + resp = self._bridge.request( + "memory.search", + params, + ) + return json.dumps({"hits": resp.get("hits", [])}) + if tool_name == "memory_get": + item_id = (args.get("id") or "").strip() + if not item_id: + return json.dumps({"error": "missing id"}) + kind = args.get("kind") or "trace" + methods = { + "trace": "memory.get_trace", + "policy": "memory.get_policy", + "world_model": "memory.get_world", + } + method = methods.get(kind) + if method is None: + return json.dumps({"error": f"unknown memory kind: {kind}"}) + item = self._bridge.request( + method, {"id": item_id, "namespace": self._runtime_namespace()} + ) + if not item: + return json.dumps({"found": False, "kind": kind, "id": item_id}) + if kind == "trace": + body = self._clip(item.get("agentText") or item.get("body")) + meta = { + "episodeId": item.get("episodeId"), + "ts": item.get("ts"), + "value": item.get("value"), + "reflection": self._clip(item.get("reflection")), + "userText": self._clip(item.get("userText")), + "toolCalls": item.get("toolCalls") or [], + } + elif kind == "policy": + body = self._clip( + "\n\n".join( + part for part in [item.get("title"), item.get("procedure")] if part + ) + ) + meta = { + "trigger": item.get("trigger"), + "verification": item.get("verification"), + "boundary": item.get("boundary"), + "gain": item.get("gain"), + "support": item.get("support"), + "status": item.get("status"), + } + else: + body = self._clip(item.get("body")) + meta = { + "title": item.get("title"), + "policyIds": item.get("policyIds") or [], + } + return json.dumps( + { + "found": True, + "kind": kind, + "id": item.get("id", item_id), + "body": body, + "meta": meta, + } + ) + if tool_name == "memory_timeline": + resp = self._bridge.request( + "memory.timeline", + { + "episodeId": args.get("episodeId", self._episode_id), + "namespace": self._runtime_namespace(), + }, + ) + limit = self._int_arg(args, "limit", 20, 1, 100) + traces = resp.get("traces", [])[:limit] + return json.dumps({"traces": traces}) + if tool_name == "skill_list": + limit = self._int_arg(args, "limit", 10, 1, 50) + params = {"limit": limit, "namespace": self._runtime_namespace()} + if args.get("status"): + params["status"] = args["status"] + return json.dumps(self._bridge.request("skill.list", params)) + if tool_name == "memory_environment": + query = (args.get("query") or "").strip() + limit = self._int_arg(args, "limit", 5, 1, 30) + if not query: + resp = self._bridge.request( + "memory.list_world_models", + {"limit": limit, "offset": 0, "namespace": self._runtime_namespace()}, + ) + return json.dumps( + { + "worldModels": [ + { + **w, + "body": self._clip(w.get("body")), + } + for w in resp.get("worldModels", []) + ], + "queried": False, + } + ) + resp = self._bridge.request( + "memory.search", + { + "agent": "hermes", + "namespace": self._runtime_namespace(), + "query": query, + "topK": {"tier1": 0, "tier2": 0, "tier3": limit}, + }, + ) + hits = [ + h + for h in resp.get("hits", []) + if h.get("tier") == 3 or h.get("refKind") == "world_model" + ] + return json.dumps( + { + "worldModels": [ + { + "id": h.get("refId") or h.get("id"), + "title": self._clip((h.get("snippet") or "").split("\n")[0]), + "body": self._clip(h.get("snippet")), + "policyIds": [], + "score": h.get("score"), + } + for h in hits[:limit] + ], + "queried": True, + } + ) + if tool_name == "skill_get": + skill_id = (args.get("id") or "").strip() + if not skill_id: + return json.dumps({"error": "missing id"}) + skill = self._bridge.request( + "skill.get", + { + "id": skill_id, + "namespace": self._runtime_namespace(), + "recordTrial": True, + "sessionId": self._session_id, + "episodeId": self._episode_id or None, + }, + ) + return json.dumps({"found": bool(skill), "skill": skill}) + except Exception as err: + return json.dumps({"error": str(err)}) + return json.dumps({"error": f"unknown tool: {tool_name}"}) + + # ─── Config schema (for `hermes memory setup`) ──────────────────────── + + def get_config_schema(self) -> list[dict[str, Any]]: # type: ignore[override] + """Fields the host's `hermes memory setup` wizard will collect. + + Secrets go to .env; everything else to the provider config file + written by ``save_config``. + """ + return [ + { + "key": "viewer_port", + "description": "Local HTTP port for the MemOS viewer.", + "default": 18910, + "required": False, + }, + { + "key": "llm_provider", + "description": "LLM for V7 reward / l2.induction / l3.abstraction.", + "choices": ["openai_compatible", "anthropic", "gemini", "host", "local_only"], + "default": "openai_compatible", + "required": False, + }, + { + "key": "llm_api_key", + "description": "API key for the chosen LLM provider.", + "secret": True, + "env_var": "MEMOS_LLM_API_KEY", + "required": False, + }, + { + "key": "embedding_provider", + "description": "Embedding provider (local = MiniLM on-device).", + "choices": [ + "local", + "openai_compatible", + "gemini", + "cohere", + "voyage", + "mistral", + ], + "default": "local", + "required": False, + }, + ] + + def save_config(self, values: dict[str, Any], hermes_home: str) -> None: # type: ignore[override] + """Write non-secret config to `/memos-plugin/config.yaml`.""" + if not hermes_home: + return + import yaml # lazy import — hermes already ships pyyaml + + target_dir = Path(hermes_home) / "memos-plugin" + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / "config.yaml" + + payload: dict[str, Any] = {"version": 1} + if "viewer_port" in values: + payload["viewer"] = {"port": int(values["viewer_port"])} + if "llm_provider" in values: + llm: dict[str, Any] = {"provider": values["llm_provider"]} + if values.get("llm_provider") != "local_only": + llm["apiKey"] = "" + payload["llm"] = llm + if "embedding_provider" in values: + payload["embedding"] = {"provider": values["embedding_provider"]} + + target.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8") + target.chmod(0o600) + + # ─── Session-end ────────────────────────────────────────────────────── + + def on_session_end(self, messages: list[dict[str, Any]]) -> None: # type: ignore[override] + if not self._bridge: + return + # `sync_turn` already flushed completed turn data synchronously. + # Closing the host session is not the same as ending the topic: + # the core will pause or finalize the open episode according to + # topic-boundary rules so interrupted Hermes sessions can resume + # into the same task later. + with contextlib.suppress(Exception): + self._bridge.request("session.close", {"sessionId": self._session_id}) + + def shutdown(self) -> None: # type: ignore[override] + self._bridge_keepalive_stop.set() + if self._bridge_keepalive_thread and self._bridge_keepalive_thread.is_alive(): + self._bridge_keepalive_thread.join(timeout=2.0) + if self._prefetch_thread and self._prefetch_thread.is_alive(): + self._prefetch_thread.join(timeout=5.0) + if self._bridge: + with contextlib.suppress(Exception): + self._bridge.close() + self._bridge = None + # DON'T call shutdown_bridge() — the bridge process stays alive + # as a daemon if its viewer is running, so the memory panel + # remains accessible between `hermes chat` sessions. + + # ─── Host LLM bridge (fallback for plugin-side model failures) ──────── + + def _handle_host_llm_complete(self, params: dict[str, Any]) -> dict[str, Any]: + """Run a fallback LLM call using the host (hermes) agent's models. + + Wired into the bridge's reverse-RPC channel under the + ``host.llm.complete`` method. Triggered when the plugin's + configured summary or skill-evolver model fails — instead of + bubbling the error straight up (which would stall the V7 + capture / reflection / skill pipeline), we replay the prompt + through ``agent.auxiliary_client.call_llm`` so hermes' own + provider stack (including its OpenRouter / Codex / custom + endpoint resolution) handles it. + + If the host LLM also fails this raises, the bridge converts + that into a JSON-RPC error, the LlmClient ``markFail``s, and + the Overview card flips red — exactly matching the spec + "if the agent's main model is also down, stop falling back + and surface red". + """ + messages = params.get("messages") + if not isinstance(messages, list) or not messages: + raise ValueError("host.llm.complete: missing messages") + + # Lazy imports — these pull in heavy deps (openai client, + # credential pool, …) that we don't want to load until a + # fallback is actually requested. + try: + from agent.auxiliary_client import call_llm # type: ignore[import-not-found] + from hermes_cli.runtime_provider import ( # type: ignore[import-not-found] + resolve_runtime_provider, + ) + except Exception as err: + raise RuntimeError(f"host LLM bridge unavailable: {err}") from err + + # Resolve hermes' MAIN conversation provider so the fallback + # uses exactly what the user configured for chat. Walking the + # generic auxiliary auto-detect chain would otherwise depend + # on env vars (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, …) that + # often don't propagate into the bridge subprocess and would + # leave us with no working credential. Pinning to the resolved + # main runtime guarantees we hit the same endpoint the user + # already authenticated for chat. + try: + runtime = resolve_runtime_provider() + except Exception as err: + raise RuntimeError(f"could not resolve hermes main runtime: {err}") from err + + main_runtime: dict[str, str] = {} + for field in ("provider", "model", "base_url", "api_key", "api_mode"): + value = runtime.get(field) if isinstance(runtime, dict) else None + if isinstance(value, str) and value.strip(): + main_runtime[field] = value.strip() + + normalized = [ + { + "role": str(m.get("role", "user")), + "content": str(m.get("content", "")), + } + for m in messages + if isinstance(m, dict) + ] + timeout_ms = params.get("timeoutMs") + timeout_s: float | None = None + if isinstance(timeout_ms, int | float) and timeout_ms > 0: + timeout_s = float(timeout_ms) / 1000.0 + + max_tokens = params.get("maxTokens") + temperature = params.get("temperature") + + kwargs: dict[str, Any] = { + "messages": normalized, + # `main_runtime` makes `_resolve_auto` prefer the user's + # main conversation provider + model over the generic auto + # chain. If the user's main provider is also down, + # `call_llm` raises — which is exactly the "agent's own + # model is broken too, stop falling back" semantic we want + # (red light on Overview). + "main_runtime": main_runtime, + } + if isinstance(max_tokens, int | float) and max_tokens > 0: + kwargs["max_tokens"] = int(max_tokens) + if isinstance(temperature, int | float): + kwargs["temperature"] = float(temperature) + if timeout_s is not None: + kwargs["timeout"] = timeout_s + + started = time.time() + try: + response = call_llm(**kwargs) + except Exception as err: + # Surface the original failure verbatim — the LlmClient + # will tag this as a "host fallback failed" terminal error + # and the Overview red-light path takes over. + raise RuntimeError(f"host LLM call failed: {err}") from err + + # `call_llm` returns an OpenAI ChatCompletion-shaped object. + # Pluck the assistant text + token usage defensively so a + # non-standard host (e.g. Anthropic native) still produces a + # populated response. + text = "" + model = "" + usage_dict: dict[str, int] = {} + try: + choices = getattr(response, "choices", None) or response.get("choices", []) # type: ignore[union-attr] + if choices: + first = choices[0] + msg = getattr(first, "message", None) or first.get("message", {}) # type: ignore[union-attr] + content = getattr(msg, "content", None) or msg.get("content", "") # type: ignore[union-attr] + text = str(content or "") + model = ( + getattr(response, "model", None) + or response.get("model", "") # type: ignore[union-attr] + or "" + ) + u = getattr(response, "usage", None) or response.get("usage", None) # type: ignore[union-attr] + if u is not None: + pt = getattr(u, "prompt_tokens", None) + ct = getattr(u, "completion_tokens", None) + tt = getattr(u, "total_tokens", None) + if pt is None and isinstance(u, dict): + pt = u.get("prompt_tokens") + ct = u.get("completion_tokens") + tt = u.get("total_tokens") + if isinstance(pt, int): + usage_dict["promptTokens"] = pt + if isinstance(ct, int): + usage_dict["completionTokens"] = ct + if isinstance(tt, int): + usage_dict["totalTokens"] = tt + except Exception: + logger.debug("host.llm.complete: shape parse failed", exc_info=True) + + result: dict[str, Any] = { + "text": text, + "model": str(model or ""), + "durationMs": int((time.time() - started) * 1000), + } + if usage_dict: + result["usage"] = usage_dict + return result + + # ─── Internals ──────────────────────────────────────────────────────── + + def _host_runtime_context(self) -> dict[str, str]: + """Best-effort snapshot of Hermes' main conversation runtime.""" + try: + from hermes_cli.runtime_provider import ( # type: ignore[import-not-found] + resolve_runtime_provider, + ) + + runtime = resolve_runtime_provider() + except Exception: + return dict(self._last_host_runtime) + + out: dict[str, str] = {} + if isinstance(runtime, dict): + for source, target in ( + ("provider", "hostProvider"), + ("model", "hostModel"), + ("api_mode", "hostApiMode"), + ("base_url", "hostBaseUrl"), + ): + value = runtime.get(source) + if isinstance(value, str) and value.strip(): + out[target] = value.strip() + if out: + self._last_host_runtime = dict(out) + return out + + def _open_session(self, session_id: str = "", *, timeout: float = 120.0) -> None: + assert self._bridge is not None + requested_session = session_id or self._session_id or "" + host_runtime = self._host_runtime_context() + resp = self._bridge.request( + "session.open", + { + "agent": "hermes", + "sessionId": requested_session, + "namespace": self._runtime_namespace(), + "meta": { + "hermesHome": self._hermes_home, + "platform": self._platform, + "agentIdentity": self._agent_identity, + "profileId": self._runtime_namespace()["profileId"], + "namespace": self._runtime_namespace(), + **host_runtime, + }, + }, + timeout=timeout, + ) + self._session_id = resp.get("sessionId") or requested_session + + def _is_transport_closed(self, err: Exception) -> bool: + if isinstance(err, BridgeError) and err.code == "transport_closed": + return True + msg = str(err).lower() + return "broken pipe" in msg or "bridge closed" in msg or "transport_closed" in msg + + def _reconnect_bridge(self, session_id: str = "", *, timeout: float = 120.0) -> None: + old_bridge = self._bridge + if old_bridge: + with contextlib.suppress(Exception): + old_bridge.close() + # Serialize the cold-boot (spawn + session.open, where BGE-large loads) + # across all gateways so concurrent boots can't starve the CPU into the + # respawn/leak spiral. Reusing an already-warm bridge via get_or_create + # is fast, so the lock is only really held during a genuine boot. + with bridge_boot_lock(timeout=timeout + 30.0): + ensure_bridge_running() + self._bridge = MemosBridgeClient.get_or_create() + self._bridge.register_host_handler( + "host.llm.complete", + self._handle_host_llm_complete, + ) + self._open_session(session_id, timeout=timeout) + + def _ensure_bridge(self, session_id: str = "", *, timeout: float = 120.0) -> bool: + if self._bridge: + return True + try: + self._reconnect_bridge(session_id or self._session_id, timeout=timeout) + logger.info( + "MemOS: bridge reconnected session=%s platform=%s", + self._session_id, + self._platform, + ) + return True + except Exception as err: + logger.warning("MemOS: bridge reconnect failed — %s", err) + # Kill the failed/timed-out bridge subprocess before dropping the + # handle. A slow boot (LLM-dependent reflection) that exceeds the + # session.open timeout would otherwise leave the child running and + # unreferenced — orphans accumulate into the process/RAM leak. + # close() terminates the child; get_or_create's poll() check then + # evicts the dead registry entry so the next attempt spawns fresh. + if self._bridge is not None: + with contextlib.suppress(Exception): + self._bridge.close() + self._bridge = None + return False + + def _start_bridge_keepalive(self) -> None: + if self._bridge_keepalive_thread and self._bridge_keepalive_thread.is_alive(): + return + self._bridge_keepalive_stop.clear() + + def _run() -> None: + while not self._bridge_keepalive_stop.wait(5.0): + # Cold bridge boot runs dirty-episode reflection (~60s of LLM + # calls) before session.open responds; a short timeout here + # abandons the booting process and respawns every tick, leaking + # node procs. Block long enough for a cold boot to finish. + if not self._ensure_bridge(self._session_id, timeout=90.0): + continue + try: + assert self._bridge is not None + self._bridge.request("core.health", {}, timeout=10.0) + except Exception as err: + if self._is_transport_closed(err): + logger.info("MemOS: bridge keepalive reconnecting after transport close") + with contextlib.suppress(Exception): + self._reconnect_bridge(self._session_id, timeout=90.0) + else: + logger.debug("MemOS: bridge keepalive failed — %s", err) + + self._bridge_keepalive_thread = threading.Thread( + target=_run, + daemon=True, + name="memos-bridge-keepalive", + ) + self._bridge_keepalive_thread.start() + + def _turn_start(self, query: str, *, session_id: str = "") -> str: + assert self._bridge is not None + host_runtime = self._host_runtime_context() + resp = self._bridge.request( + "turn.start", + { + "agent": "hermes", + "namespace": self._runtime_namespace(), + "sessionId": session_id or self._session_id, + "userText": query, + "contextHints": { + "agentIdentity": self._agent_identity, + "namespace": self._runtime_namespace(), + **host_runtime, + }, + "ts": int(time.time() * 1000), + }, + timeout=4.0, + ) + # Stash the real episode id the pipeline auto-created (V7 + # §0.1 may have boundary-cut the previous episode and started + # a new one). `on_session_end` uses it to close the right + # episode — see the "Episode tracking" comment block above. + new_eid = ((resp or {}).get("query") or {}).get("episodeId") or "" + if new_eid and new_eid != self._episode_id: + self._episode_id = new_eid + logger.debug("MemOS: stashed episode %s from turn.start", new_eid) + context = (resp or {}).get("injectedContext") or "" + if not context: + return "" + return f"## Recalled Memories\n{context}" + + def _turn_end( + self, + user_content: str, + assistant_content: str, + tool_calls: list[dict[str, Any]], + ts_ms: int, + *, + agent_thinking: str = "", + ) -> None: + if not self._bridge: + 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 + ] + payload: dict[str, Any] = { + "agent": "hermes", + "namespace": self._runtime_namespace(), + "sessionId": self._session_id, + "episodeId": self._episode_id, + "agentText": assistant_content, + "userText": user_content, + "toolCalls": clean_tool_calls, + "contextHints": { + "agentIdentity": self._agent_identity, + "namespace": self._runtime_namespace(), + **self._host_runtime_context(), + }, + "ts": ts_ms, + } + if agent_thinking: + payload["agentThinking"] = agent_thinking + # Capture runs AFTER the reply is sent, so it doesn't add to reply + # latency; cap it so a slow/cold bridge can't wedge the gateway thread. + result = self._bridge.request("turn.end", payload, timeout=12.0) + # Capture the trace ID for feedback submission + 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 + + def _submit_verifier_feedback( + self, + user_content: str, + assistant_content: str, + ts_ms: int, + ) -> None: + if not self._bridge or not self._episode_id: + return + polarity = _feedback_polarity(user_content) + magnitude = _feedback_magnitude(user_content, polarity) + raw = { + "source": "hermes.verifier_feedback", + "userText": user_content, + "assistantText": assistant_content, + "polarity": polarity, + } + payload: dict[str, Any] = { + "episodeId": self._episode_id, + "channel": "explicit", + "polarity": polarity, + "magnitude": magnitude, + "rationale": user_content, + "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) + + +# ─── Discovery entry points ─────────────────────────────────────────────── + + +# Pattern 1: `register(ctx)` — preferred by `plugins/memory/__init__.py`. +def register(ctx: Any) -> None: + """hermes-agent plugin entry point.""" + ctx.register_memory_provider(MemTensorProvider()) + + +# Pattern 2: exported class — fallback via `issubclass(MemoryProvider)`. +__all__ = ["PLUGIN_ID", "PLUGIN_VERSION", "MemTensorProvider", "register"] diff --git a/tools/plugin-patches/adapters/hermes/memos_provider/daemon_manager.py b/tools/plugin-patches/adapters/hermes/memos_provider/daemon_manager.py new file mode 100644 index 0000000..5b72892 --- /dev/null +++ b/tools/plugin-patches/adapters/hermes/memos_provider/daemon_manager.py @@ -0,0 +1,127 @@ +"""Daemon manager for the MemOS bridge subprocess. + +Responsibilities: +- Ensure exactly one bridge process runs per user home. +- Probe Node.js availability so ``MemTensorProvider.is_available`` can + answer cheaply at plugin-startup time. +- Graceful shutdown helpers invoked from ``MemTensorProvider.shutdown``. + +This file intentionally has **no runtime dependency** on the client; the +provider instantiates its own client. Keeping these concerns split means +the dependency graph for the Hermes plugin stays acyclic: + + memos_provider/__init__.py ─┬─▶ bridge_client.py + └─▶ daemon_manager.py +""" + +from __future__ import annotations + +import contextlib +import fcntl +import logging +import os +import shutil +import subprocess +import threading +import time + +from pathlib import Path + + +logger = logging.getLogger(__name__) + +_lock = threading.Lock() +_bridge_ok: bool | None = None + + +def _boot_lock_path() -> Path: + return _bridge_script().parent / ".bridge-boot.lock" + + +@contextlib.contextmanager +def bridge_boot_lock(timeout: float = 180.0): + """Cross-process exclusive lock so only ONE bridge cold-boot (BGE-large + model load) runs at a time across all gateways. + + Each gateway spawns its own ``bridge.cts`` that cold-loads the local + BGE-large embedder (~2 CPU cores). When several boot at once they starve + the CPU past the ``session.open`` timeout → respawn → leak/CPU spiral + (the recurring ``sergio`` failure). Serializing the boots makes each one + finish before the next starts. + + Best-effort: if the lock can't be acquired within ``timeout`` we proceed + anyway rather than block capture forever. Yields True iff the lock was held. + (This is the cheap mitigation; the proper fix is the shared single daemon — + see the 2026-05-27 roadmap. That change removes this lock.) + """ + fd = None + acquired = False + try: + fd = os.open(str(_boot_lock_path()), os.O_CREAT | os.O_RDWR, 0o644) + deadline = time.time() + max(1.0, timeout) + while True: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + acquired = True + break + except OSError: + if time.time() >= deadline: + logger.warning( + "MemOS: bridge boot lock not acquired in %.0fs — booting unserialized", + timeout, + ) + break + time.sleep(0.5) + yield acquired + finally: + if fd is not None: + with contextlib.suppress(Exception): + if acquired: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + +def _bridge_script() -> Path: + return Path(__file__).resolve().parent.parent.parent.parent / "bridge.cts" + + +def _node_available() -> bool: + node = shutil.which("node") + if not node: + return False + try: + out = subprocess.check_output([node, "--version"], timeout=2.0) + return bool(out.strip()) + except Exception: + return False + + +def ensure_bridge_running(*, probe_only: bool = False) -> bool: + """Return True when the bridge is (or can be) operational. + + ``probe_only=True`` performs a lightweight availability check without + launching a long-lived subprocess. This is what + ``MemTensorProvider.is_available`` calls during Hermes startup. + """ + global _bridge_ok + with _lock: + if _bridge_ok is not None and probe_only: + return _bridge_ok + script = _bridge_script() + if not script.exists(): + logger.warning("MemOS: bridge script missing at %s", script) + _bridge_ok = False + return False + if not _node_available(): + logger.warning("MemOS: Node.js not found on PATH") + _bridge_ok = False + return False + _bridge_ok = True + return True + + +def shutdown_bridge() -> None: + """Best-effort cleanup; each client owns its own subprocess.""" + global _bridge_ok + with _lock: + _bridge_ok = None diff --git a/tools/plugin-patches/bridge.cts b/tools/plugin-patches/bridge.cts new file mode 100644 index 0000000..4b3dc1c --- /dev/null +++ b/tools/plugin-patches/bridge.cts @@ -0,0 +1,508 @@ +/** + * Bridge entry point (CommonJS). + * + * Started by non-TypeScript hosts (e.g. the Hermes Python client) via: + * + * node_modules/.bin/tsx bridge.cts --agent=hermes + * + * The `.cts` extension is intentional: it lets the file be required + * from CommonJS environments that spawn Node with `require("...")` + * semantics. Internally we re-export the ESM implementation via + * `import()`. + * + * Viewer lifecycle + * ================ + * Each agent owns its own HTTP port: + * + * - openclaw → :18799 + * - hermes → :18800 + * + * The viewer port is read from the agent's `~/./memos-plugin/ + * config.yaml::viewer.port`. We just call `startHttpServer` once; + * if the port is already in use we surface the EADDRINUSE error to + * stderr and keep running stdio-RPC headless (capture / retrieval + * still work). There's no port-sharing or auto-promotion logic — + * each agent has its own bookmarkable URL. + */ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const path = require("node:path") as typeof import("node:path"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const fs = require("node:fs") as typeof import("node:fs"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const childProcess = require("node:child_process") as typeof import("node:child_process"); + +// Hermes integration 2026-05-17: tiny dotenv loader so env vars from +// ~/.hermes/.env (and ~/.hermes/memos-plugin/.env if it exists) are +// visible to downstream `process.env.X` reads. The plugin's gateway +// systemd units don't source these files, so without this the +// daemon would miss MEMOS_HUMAN_NAME / GEMINI_API_KEY / etc. Lines +// already present in process.env aren't overwritten — explicit +// systemd Environment= directives still win. +(function loadDotEnv() { + const home = process.env.HOME ?? ""; + const candidates = [ + `${home}/.hermes/.env`, + `${home}/.hermes/memos-plugin/.env`, + ]; + for (const file of candidates) { + try { + if (!fs.existsSync(file)) continue; + const text = fs.readFileSync(file, "utf8"); + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq < 1) continue; + const k = line.slice(0, eq).trim(); + let v = line.slice(eq + 1).trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + v = v.slice(1, -1); + } + if (k && process.env[k] === undefined) { + process.env[k] = v; + } + } + } catch { /* best effort; never crash startup over .env */ } + } +})(); + +const BRIDGE_STATUS_HEARTBEAT_MS = 5_000; +const BRIDGE_STATUS_STALE_MS = 20_000; +const BRIDGE_STATUS_FILE = "bridge-status.json"; + +interface BridgeArgs { + daemon: boolean; + tcpPort?: number; + agent: "openclaw" | "hermes"; +} + +type BridgeStatus = "connected" | "reconnecting" | "disconnected" | "unknown"; + +interface BridgeStatusSnapshot { + status: BridgeStatus; + lastOkAt: number | null; + lastErrorAt: number | null; + lastError: string | null; +} + +function parseArgs(argv: readonly string[]): BridgeArgs { + const args: BridgeArgs = { daemon: false, agent: "openclaw" }; + for (const raw of argv) { + if (raw === "--daemon") args.daemon = true; + else if (raw.startsWith("--tcp=")) args.tcpPort = Number(raw.slice(6)); + else if (raw === "--agent=hermes") args.agent = "hermes"; + else if (raw === "--agent=openclaw") args.agent = "openclaw"; + } + return args; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + // Lazy-import ESM core. Using dynamic import so this file remains + // CommonJS and stays `require`-able. + const { bootstrapMemoryCoreFull } = (await import( + pathToEsmUrl(path.resolve(__dirname, "core/pipeline/index.ts")) + )) as typeof import("./core/pipeline/index.js"); + const { startStdioServer, waitForShutdown } = (await import( + pathToEsmUrl(path.resolve(__dirname, "bridge/stdio.ts")) + )) as typeof import("./bridge/stdio.js"); + const { memoryBuffer, rootLogger } = (await import( + pathToEsmUrl(path.resolve(__dirname, "core/logger/index.ts")) + )) as typeof import("./core/logger/index.js"); + const { startHttpServer } = (await import( + pathToEsmUrl(path.resolve(__dirname, "server/http.ts")) + )) as typeof import("./server/http.js"); + + const pkgVersion = require("./package.json").version; + + // ─── Host LLM bridge (reverse RPC, lazy-bound to stdio) ──────── + // We need to register the bridge BEFORE bootstrap creates the + // LlmClients (so the very first `shouldFallback()` check sees a + // non-null bridge), but `stdio` itself doesn't exist until later + // in this function. The trick: hand a placeholder closure to + // bootstrap that defers actual stdio access to the time of the + // first fallback call. By then `stdio` has been assigned. + // + // Routing through `bootstrapMemoryCoreFull({ hostLlmBridge })` + // (instead of having `bridge.cts` call `registerHostLlmBridge` + // directly) avoids a subtle ESM module-identity issue: the static + // `import` chain inside `core/llm/client.ts` and the dynamic + // `await import(...)` here resolve to the same file URL but Node + // can occasionally treat them as different module instances with + // independent `currentBridge` slots. Registering inside bootstrap + // forces both ends to share the same module instance. + let stdio: import("./bridge/stdio.js").StdioServerHandle | null = null; + const lazyHostLlmBridge: import("./core/llm/host-bridge.js").HostLlmBridge = + { + id: `stdio.host.${args.agent}.v1`, + async complete(input) { + if (!stdio) { + throw new Error( + "host LLM bridge invoked before stdio server was ready", + ); + } + const result = (await stdio.serverRequest( + "host.llm.complete", + { + messages: input.messages, + model: input.model, + temperature: input.temperature, + maxTokens: input.maxTokens, + timeoutMs: input.timeoutMs, + }, + { timeoutMs: (input.timeoutMs ?? 60_000) + 5_000 }, + )) as { + text?: string; + model?: string; + usage?: { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + }; + durationMs?: number; + }; + return { + text: typeof result?.text === "string" ? result.text : "", + model: + typeof result?.model === "string" + ? result.model + : input.model ?? "", + usage: result?.usage, + durationMs: + typeof result?.durationMs === "number" ? result.durationMs : 0, + }; + }, + }; + + const { Telemetry } = (await import( + pathToEsmUrl(path.resolve(__dirname, "core/telemetry/index.ts")) + )) as typeof import("./core/telemetry/index.js"); + + const { core, config, home } = await bootstrapMemoryCoreFull({ + agent: args.agent, + namespace: { agentKind: args.agent, profileId: "default" }, + pkgVersion, + hostLlmBridge: args.daemon ? null : lazyHostLlmBridge, + }); + + const bridgeStatus = + args.agent === "hermes" + ? createBridgeStatusTracker( + path.join(home.root, BRIDGE_STATUS_FILE), + args.daemon, + ) + : null; + await core.init(); + + const telemetry = new Telemetry( + config.telemetry ?? {}, + home.root, + pkgVersion, + rootLogger.child({ channel: "core.telemetry" }), + __dirname, + ); + (core as { bindTelemetry?: (t: InstanceType) => void }).bindTelemetry?.(telemetry); + telemetry.trackPluginStarted(args.agent); + + // Per-agent fixed viewer port. + const AGENT_DEFAULT_PORTS = { openclaw: 18799, hermes: 18800 } as const; + const viewerPort = AGENT_DEFAULT_PORTS[args.agent]; + + // ─── Daemon mode ────────────────────────────────────────────── + // When started with `--daemon`, skip stdio and run as a pure HTTP + // viewer daemon. Used by install.sh (post-install) and admin/restart + // (self-restart) to keep the Memory Viewer always available. + if (args.daemon) { + // Daemon mode is the target of `POST /api/v1/admin/restart`, + // which re-spawns the bridge after a short sleep. On busy + // machines the previous bridge's listening socket can take a + // moment longer than expected to release, so we retry the bind + // a few times before giving up. Without this the user sees + // "重启超时" in the viewer because the new daemon raced its + // predecessor and lost. + let viewer: import("./server/types.js").ServerHandle | null = null; + const maxBindAttempts = 10; + for (let attempt = 1; attempt <= maxBindAttempts; attempt++) { + try { + viewer = await startHttpServer( + { + core, + home, + logTail: () => memoryBuffer().tail({ limit: 200 }), + bridgeStatus: bridgeStatus ? () => bridgeStatus.snapshot() : undefined, + telemetry, + }, + { + port: viewerPort, + host: config.viewer.bindHost, + staticRoot: path.resolve(__dirname, "web/dist"), + agent: args.agent, + }, + ); + process.stderr.write( + `bridge: daemon viewer live at ${viewer.url} (agent=${args.agent})\n`, + ); + break; + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e?.code === "EADDRINUSE" && attempt < maxBindAttempts) { + process.stderr.write( + `bridge: daemon port :${viewerPort} busy (attempt ${attempt}/${maxBindAttempts}), retrying in 1s...\n`, + ); + await new Promise((r) => setTimeout(r, 1000)); + continue; + } + if (e?.code === "EADDRINUSE") { + process.stderr.write( + `bridge: daemon port :${viewerPort} still in use after ${maxBindAttempts}s — exiting.\n`, + ); + await core.shutdown(); + process.exit(1); + } + process.stderr.write( + `bridge: daemon viewer failed: ${(err as Error)?.message ?? String(err)}\n`, + ); + await core.shutdown(); + process.exit(1); + } + } + + const shutdownDaemon = async (sig: string) => { + process.stderr.write(`bridge: daemon received ${sig}, shutting down\n`); + try { await viewer!.close(); } catch { /* best-effort */ } + await core.shutdown(); + process.exit(0); + }; + process.on("SIGINT", () => void shutdownDaemon("SIGINT")); + process.on("SIGTERM", () => void shutdownDaemon("SIGTERM")); + // Process stays alive via the HTTP server's ref'd socket. + return; + } + + // ─── Normal (stdio) mode ────────────────────────────────────── + // Assign the stdio handle into the closure variable so the host + // LLM bridge (registered earlier inside bootstrap) can dispatch + // reverse-direction requests to the adapter. + stdio = startStdioServer({ core }); + bridgeStatus?.markConnected(); + const bridgeHeartbeat = bridgeStatus?.startHeartbeat(); + void stdio.done.then(() => { + bridgeHeartbeat?.stop(); + bridgeStatus?.markDisconnected("Hermes chat disconnected"); + }); + + // Try to bind the viewer port. EADDRINUSE → stay headless. + let viewer: import("./server/types.js").ServerHandle | null = null; + try { + viewer = await startHttpServer( + { + core, + home, + logTail: () => memoryBuffer().tail({ limit: 200 }), + bridgeStatus: bridgeStatus ? () => bridgeStatus.snapshot() : undefined, + telemetry, + }, + { + port: viewerPort, + host: config.viewer.bindHost, + staticRoot: path.resolve(__dirname, "web/dist"), + agent: args.agent, + }, + ); + process.stderr.write( + `bridge: viewer live at ${viewer.url} (agent=${args.agent})\n`, + ); + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e?.code === "EADDRINUSE") { + process.stderr.write( + `bridge: viewer port :${viewerPort} is already in use — ` + + `${args.agent} will run headless (stdio only). ` + + `Free the port to expose the viewer.\n`, + ); + } else { + process.stderr.write( + `bridge: viewer failed to start: ${e?.message ?? String(err)}\n`, + ); + } + } + + const shutdown = async (sig: string) => { + process.stderr.write(`bridge: received ${sig}, shutting down\n`); + if (viewer) { + try { + await viewer.close(); + } catch { + /* best-effort */ + } + } + await waitForShutdown(core, stdio!); + process.exit(0); + }; + + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); + + // Keep the process alive until stdin ends (client disconnects). + await stdio.done; + + // If a viewer is running, keep the process alive as a daemon so the + // memory panel stays accessible between `hermes chat` sessions. + if (viewer && !viewer.closed) { + process.stderr.write( + `bridge: stdin closed but viewer is still serving at ${viewer.url} — ` + + `staying alive as daemon. Send SIGTERM to stop.\n`, + ); + const keepalive = setInterval(() => { + if (viewer!.closed) { + clearInterval(keepalive); + void core.shutdown().then(() => process.exit(0)); + } + }, 5_000); + (keepalive as unknown as { unref?: () => void }).unref?.(); + return; + } + + // No viewer (headless bridge) — clean exit. + await core.shutdown(); + process.exit(0); +} + +function pathToEsmUrl(abs: string): string { + const u = abs.startsWith("/") ? `file://${abs}` : `file:///${abs}`; + return u; +} + +function createBridgeStatusTracker(statusFile: string, daemon: boolean): { + snapshot(): BridgeStatusSnapshot; + markConnected(): void; + markDisconnected(message: string): void; + startHeartbeat(): { stop(): void }; +} { + let snapshot: BridgeStatusSnapshot = daemon + ? { + status: "disconnected", + lastOkAt: null, + lastErrorAt: Date.now(), + lastError: "Hermes chat is not connected", + } + : { + status: "unknown", + lastOkAt: null, + lastErrorAt: null, + lastError: null, + }; + + function writeStatus(next: BridgeStatusSnapshot): void { + snapshot = next; + try { + fs.mkdirSync(path.dirname(statusFile), { recursive: true }); + fs.writeFileSync(statusFile, JSON.stringify(next), "utf8"); + } catch { + // Status display must never affect chat capture. + } + } + + function readStatus(): BridgeStatusSnapshot | null { + try { + const parsed = JSON.parse(fs.readFileSync(statusFile, "utf8")) as Partial; + if ( + parsed.status === "connected" || + parsed.status === "reconnecting" || + parsed.status === "disconnected" || + parsed.status === "unknown" + ) { + return { + status: parsed.status, + lastOkAt: typeof parsed.lastOkAt === "number" ? parsed.lastOkAt : null, + lastErrorAt: typeof parsed.lastErrorAt === "number" ? parsed.lastErrorAt : null, + lastError: typeof parsed.lastError === "string" ? parsed.lastError : null, + }; + } + } catch { + // Missing or corrupt status files are treated as disconnected. + } + return null; + } + + function applyStaleRule(raw: BridgeStatusSnapshot): BridgeStatusSnapshot { + if (raw.status === "disconnected" && daemon && isHermesChatRunning()) { + return { + status: "reconnecting", + lastOkAt: raw.lastOkAt, + lastErrorAt: raw.lastErrorAt, + lastError: "Hermes chat is running; waiting for memory bridge", + }; + } + if ( + raw.status === "connected" && + raw.lastOkAt != null && + Date.now() - raw.lastOkAt > BRIDGE_STATUS_STALE_MS + ) { + return { + status: "disconnected", + lastOkAt: raw.lastOkAt, + lastErrorAt: Date.now(), + lastError: "Hermes bridge heartbeat is stale", + }; + } + return raw; + } + + function markConnected(): void { + writeStatus({ + status: "connected", + lastOkAt: Date.now(), + lastErrorAt: snapshot.lastErrorAt, + lastError: snapshot.lastError, + }); + } + + function markDisconnected(message: string): void { + writeStatus({ + status: "disconnected", + lastOkAt: snapshot.lastOkAt, + lastErrorAt: Date.now(), + lastError: message, + }); + } + + return { + snapshot() { + return { ...applyStaleRule(readStatus() ?? snapshot) }; + }, + markConnected, + markDisconnected, + startHeartbeat() { + const timer = setInterval(() => { + markConnected(); + }, BRIDGE_STATUS_HEARTBEAT_MS); + (timer as unknown as { unref?: () => void }).unref?.(); + return { + stop() { + clearInterval(timer); + }, + }; + }, + }; +} + +function isHermesChatRunning(): boolean { + try { + const out = childProcess.execFileSync("pgrep", ["-f", "hermes chat"], { + encoding: "utf8", + timeout: 1000, + }); + return out.trim().length > 0; + } catch { + return false; + } +} + +void main().catch((err) => { + process.stderr.write( + `bridge: fatal: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/tools/plugin-patches/core/capture/summarizer.ts b/tools/plugin-patches/core/capture/summarizer.ts new file mode 100644 index 0000000..08ff741 --- /dev/null +++ b/tools/plugin-patches/core/capture/summarizer.ts @@ -0,0 +1,273 @@ +/** + * Capture-side trace summarizer — produces the short, viewer-friendly + * summary string that ends up in `traces.summary` and (downstream) in + * the Memories panel / retrieval snippets. + * + * Design mirrors `memos-local-openclaw`'s `Summarizer`: + * + * - Ask the configured LLM for a single-sentence distillation. + * - If the LLM is unavailable, times out, or returns malformed JSON, + * fall back to a deterministic heuristic so capture never blocks. + * + * The summary is what downstream retrieval embeds (see + * `core/capture/embedder.ts::summaryText`) and what the Memories + * viewer shows as the primary row text. Keeping it short (≤ 140 + * chars) keeps the viewer skim-able and the prompt-injection block + * small. + * + * Hermes integration (2026-05-17): the summarizer reads + * `MEMOS_HUMAN_NAME` from the environment and, when set, instructs the + * LLM to attribute statements to that named human rather than to + * "the user". This puts the speaker's identity into the embedding + * space, giving better cross-user retrieval signal. Without the env + * var, behaviour is unchanged. + */ + +import type { LlmClient } from "../llm/index.js"; +import { rootLogger } from "../logger/index.js"; +import type { Logger } from "../logger/types.js"; +import { sanitizeDerivedText } from "../safety/content.js"; +import type { NormalizedStep } from "./types.js"; + +const MAX_SUMMARY_CHARS = 140; +const MAX_INPUT_CHARS = 3_500; + +// Hermes integration 2026-05-17: speaker attribution in memory summaries. +// +// We do NOT maintain a closed roster of "valid" speakers. Agents will see +// messages from people outside the team (clients, leads, group chats, +// quoted email). Instead the LLM is asked to identify the speaker from +// signal *inside the message itself* — primarily the `[handle]` marker +// the gateway prepends to user_text, secondarily natural-language +// self-introductions in the body. +// +// MEMOS_HUMANS (optional) provides an alias map so opaque handles +// resolve to friendly names. Anyone NOT in the map is still attributed +// by whatever name/handle appears in the message — the map is a help, +// not a gate. +// +// MEMOS_HUMANS=Sergio:sergiopalacio96|sergiop,Krati,Mohammed:moh,Arinze +// +// Format: +// - Entries comma-separated. +// - Optional `:handle1|handle2|...` after the name, pipe-separated. +// - A name without handles is fine — it still helps if the LLM sees +// the name mentioned in the body, but it doesn't "claim" any handle. +// +// MEMOS_HUMAN_NAME (singular) is a back-compat fallback: treated as a +// single-entry MEMOS_HUMANS when MEMOS_HUMANS is absent. +// +// Resolved once at module load — restart the daemon after editing .env. +interface HumanEntry { name: string; handles: string[]; } +const HUMANS: HumanEntry[] = parseHumans( + process.env.MEMOS_HUMANS ?? process.env.MEMOS_HUMAN_NAME ?? "", +); +function parseHumans(raw: string): HumanEntry[] { + const out: HumanEntry[] = []; + for (const item of raw.split(",")) { + const trimmed = item.trim(); + if (!trimmed) continue; + const [name, handles] = trimmed.split(":", 2); + out.push({ + name: name.trim(), + handles: handles ? handles.split("|").map((h) => h.trim()).filter(Boolean) : [], + }); + } + return out; +} + +export interface SummarizerOptions { + llm: LlmClient | null; + log?: Logger; + timeoutMs?: number; +} + +export interface Summarizer { + summarize(step: NormalizedStep, context?: SummarizerContext): Promise; +} + +export interface SummarizerContext { + episodeId?: string; + phase?: string; +} + +/** + * Build a summarizer bound to the provided LLM client. When `llm` is + * null the returned summarizer uses the heuristic path only — capture + * still works, just with a more verbose summary. + */ +export function createSummarizer(opts: SummarizerOptions): Summarizer { + const log = opts.log ?? rootLogger.child({ channel: "core.capture.summarizer" }); + const timeoutMs = opts.timeoutMs ?? 8_000; + + async function summarize(step: NormalizedStep, context?: SummarizerContext): Promise { + // Heuristic first pass — a safety net both when the LLM is off + // and as the input we re-anchor the LLM call against (so even if + // the LLM returns garbage we still have a sensible string). + const heuristic = heuristicSummary(step); + if (!opts.llm) return heuristic; + + try { + const result = await withTimeout( + opts.llm.completeJson<{ summary?: unknown }>( + [ + { role: "system", content: SYSTEM_PROMPT }, + { + role: "user", + content: buildUserPrompt(step), + }, + ], + { + op: "capture.summarize", + episodeId: context?.episodeId, + phase: context?.phase, + schemaHint: '{"summary":"..."}', + validate: (v) => { + const s = (v as { summary?: unknown }).summary; + if (typeof s !== "string" || s.trim().length === 0) { + throw new Error("summary missing or empty"); + } + }, + malformedRetries: 1, + temperature: 0, + }, + ), + timeoutMs, + ); + const llmSummary = sanitizeDerivedText((result?.value as { summary?: string })?.summary); + if (!llmSummary) return heuristic; + return clampLength(llmSummary, MAX_SUMMARY_CHARS); + } catch (err) { + log.debug("summarize.fallback", { + err: err instanceof Error ? err.message : String(err), + }); + return heuristic; + } + } + + return { summarize }; +} + +// ─── Prompts ─────────────────────────────────────────────────────────────── + +const SYSTEM_PROMPT_BASE = `You condense a single agent/user exchange into ONE short memory line. + +Rules: +- Output MUST be a single JSON object: { "summary": "..." } +- The summary must be ≤ 100 characters, in the user's original language. +- Focus on the *fact worth remembering next time* — a preference, a name, a + decision, a file path, an error signature, an answer that was confirmed. +- Do NOT prefix with "The user said" / "用户说了". Just state the fact. +- Do NOT quote whole sentences. Compress. +- If nothing is worth remembering, still produce a short summary (e.g. the + main topic of the exchange) — never return an empty string.`; + +// Speaker attribution: identify who's speaking from signal inside the +// message itself. Don't treat MEMOS_HUMANS as a closed roster — agents +// see messages from outside parties too (clients, leads, group chats). +// Use the alias map only to resolve known opaque handles to friendly +// names; otherwise extract the speaker from the message verbatim or +// omit the reference. Added 2026-05-17. +const ALIAS_LINE = HUMANS.length > 0 + ? HUMANS + .filter((h) => h.handles.length > 0) + .map((h) => `\`[${h.handles.join("]\` or \`[")}]\` → **${h.name}**`) + .concat( + HUMANS.filter((h) => h.handles.length === 0).map( + (h) => `**${h.name}** (no known handle; recognise by name in body)`, + ), + ) + .join("; ") + : ""; + +const SYSTEM_PROMPT = ALIAS_LINE + ? `${SYSTEM_PROMPT_BASE} + +Speaker attribution +- The USER block is one message from one human. Identify the speaker + from signal **inside this message**: + (a) a handle marker like \`[sergiopalacio96]\` at the very start of + the USER text (added by the messaging gateway), or + (b) a natural-language self-identification in the body + ("I'm Mark", "— Anna from acme.io", an email "From:" header + you can see in a quoted block). +- When the speaker is identifiable, use that **name** in the summary + (e.g. "Sergio asked about X", "Anna requested a quote") — never echo + raw handles like "sergiopalacio96" or "user1234". +- **Known handle aliases** (use these when you recognise a handle): + ${ALIAS_LINE}. +- The above list is **not** a closed roster. Messages from people who + aren't in it (clients, leads, group-chat participants, quoted email + senders) are normal — use whatever name appears in the message itself. +- If you cannot confidently identify the speaker, omit the speaker + reference entirely — just state the fact. Never invent a speaker. + The assistant is never the speaker.` + : `${SYSTEM_PROMPT_BASE} + +Speaker attribution +- The USER block is one message from one human. If a name or handle + appears in the message (a \`[handle]\` prefix added by the gateway, + a self-introduction in the body, or a quoted byline), use that + **name** in the summary — e.g. "Anna asked about X". Never echo raw + opaque handles like "user1234". If no name is identifiable, omit the + speaker reference — never invent one. The assistant is never the + speaker.`; + +function buildUserPrompt(step: NormalizedStep): string { + const parts: string[] = []; + if (step.userText) parts.push(`USER:\n${clampLength(step.userText, 1_400)}`); + if (step.agentText) parts.push(`ASSISTANT:\n${clampLength(step.agentText, 1_400)}`); + if (step.toolCalls.length > 0) { + const toolSig = step.toolCalls + .map((t) => `${t.name}(${shortInput(t.input)})`) + .join("; "); + parts.push(`TOOLS:\n${clampLength(toolSig, 400)}`); + } + if (step.rawReflection) { + parts.push(`REFLECTION:\n${clampLength(step.rawReflection, 300)}`); + } + return clampLength(parts.join("\n\n"), MAX_INPUT_CHARS); +} + +function shortInput(v: unknown): string { + if (v === undefined || v === null) return ""; + if (typeof v === "string") return v.slice(0, 120); + try { + return JSON.stringify(v).slice(0, 120); + } catch { + return String(v).slice(0, 120); + } +} + +// ─── Heuristic fallback ──────────────────────────────────────────────────── + +function heuristicSummary(step: NormalizedStep): string { + const user = (step.userText ?? "").trim(); + const assistant = (step.agentText ?? "").trim(); + // Prefer the user's line — that's what they'll recognise in the + // Memories panel. Fall back to the assistant's reply when we only + // have an agent-initiated turn (subagent, recall probe, etc.). + const base = user || assistant || "(empty turn)"; + return clampLength(oneLine(base), MAX_SUMMARY_CHARS); +} + +function oneLine(s: string): string { + return s.replace(/\s+/g, " ").trim(); +} + +function clampLength(s: string, max: number): string { + if (s.length <= max) return s; + return s.slice(0, max - 1).trimEnd() + "…"; +} + +async function withTimeout(p: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`summarize timeout ${ms}ms`)), ms); + }); + try { + return await Promise.race([p, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/tools/plugin-patches/core/config/defaults.ts b/tools/plugin-patches/core/config/defaults.ts new file mode 100644 index 0000000..9a78c6e --- /dev/null +++ b/tools/plugin-patches/core/config/defaults.ts @@ -0,0 +1,283 @@ +/** + * The default config tree. Mirrors `schema.ts` exactly. When merging YAML, + * we deep-merge over this tree so users only need to specify what they want + * to change. + */ + +import type { ResolvedConfig } from "./schema.js"; + +export const DEFAULT_CONFIG: ResolvedConfig = { + version: 1, + viewer: { + // Per-agent default lives in `templates/config..yaml`: + // - openclaw → 18799 + // - hermes → 18800 + // The fallback here only matters when neither config file exists + // (early bootstrap, tests, etc.). + port: 18799, + bindHost: "127.0.0.1", + openOnFirstTurn: false, + }, + bridge: { + port: 18911, + mode: "stdio", + }, + embedding: { + provider: "local", + endpoint: "", + model: "Xenova/all-MiniLM-L6-v2", + dimensions: 384, + apiKey: "", + cache: { + enabled: true, + maxItems: 20_000, + }, + }, + llm: { + provider: "", + endpoint: "", + model: "", + temperature: 0, + fallbackToHost: true, + apiKey: "", + timeoutMs: 45_000, + maxRetries: 3, + }, + skillEvolver: { + // Empty by default — falls back to the shared `llm` settings. + // Operators set this when they want a stronger model (e.g. + // claude-sonnet / gpt-5-thinking) for skill crystallisation. + provider: "", + endpoint: "", + model: "", + apiKey: "", + temperature: 0, + timeoutMs: 60_000, + }, + algorithm: { + capture: { + maxTextChars: 4_000, + maxToolOutputChars: 2_000, + embedTraces: true, + alphaScoring: true, + // OpenClaw's tool messages don't include explicit "reflection" + // blocks; without synthesis the alpha scorer sees an empty + // reflection and forces α = 0 (see `core/capture/alpha-scorer.ts` + // line 97). That makes reflection-weighted backprop degenerate + // into pure γ-discount and produces flat V distributions — + // L2 association + skill crystallization both starve. Enable + // synth by default so even turns without explicit reflections + // still contribute useful α values. + synthReflections: true, + llmConcurrency: 4, + // V7 §3.2 batched variant. With "auto" we issue a single LLM call + // per episode for both reflection synth and α scoring as long as + // the episode is short enough — this collapses 2N per-step calls + // (N synth + N α) into 1 batched call. Long episodes (>12 steps) + // automatically fall back to the per-step path so the prompt + // never overflows the model's context window. R_human + backprop + // remain task-end events handled by `core/reward`, unchanged. + batchMode: "auto", + batchThreshold: 12, + }, + reward: { + gamma: 0.9, + tauSoftmax: 0.5, + decayHalfLifeDays: 30, + llmScoring: true, + implicitThreshold: 0.2, + // 10 minutes was too long for interactive chat — users moved on + // to the next task before reward ever fired, so no R_human was + // ever computed and V stayed 0 for every trace. 30 s gives the + // user a short window to reply ("thanks", "no, try again") that + // the scorer picks up as explicit feedback; when nothing + // arrives, the implicit fallback fires promptly so downstream + // L2/L3/Skill stages aren't starved of signal. + feedbackWindowSec: 30, + summaryMaxChars: 2_000, + llmConcurrency: 2, + // Default lowered 2→1 to support single-shot CLI patterns + // (`hermes chat -q "..."`, `openclaw run --once`). With the old + // floor every CLI single-query episode was abandoned with + // "对话轮次不足", starving reward → L2 → Skill of any signal. + // Multi-turn TUI flows still trigger reward as before because + // they always satisfy the looser bound. Operators wanting the + // strict pre-2026Q2 behaviour can set 2 in config.yaml. + minExchangesForCompletion: 1, + // Lowered 80→40 to match the relaxed exchanges floor. 40 chars + // is "ok"/"thanks" + a real follow-up clause; below that we + // still skip as a triviality gate. + minContentCharsForCompletion: 40, + toolHeavyRatio: 0.7, + minAssistantCharsForToolHeavy: 80, + }, + l2Induction: { + minSimilarity: 0.65, + candidateTtlDays: 30, + minEpisodesForInduction: 1, + // Lowered from 0.05 → 0.005. Reward backprop V values for typical + // multi-step turns (5-15 steps) are clustered around 0.02-0.5 even + // for successful episodes; the old 0.05 floor was throwing away + // most of the signal before induction could see it. Negative-V + // traces are still excluded (they'd never count as "with-set" + // evidence anyway). + minTraceValue: 0.005, + useLlm: true, + traceCharCap: 3_000, + archiveGain: -0.05, + }, + l3Abstraction: { + // Lowered from 3 → 2. The original threshold required THREE + // distinct active policies in the same domain cluster before any + // world model could form, which in real usage takes weeks to + // accumulate even for a focused user. Two compatible active + // policies is the smallest meaningful cluster. + minPolicies: 1, + // Lowered from 0.1 → 0.02. With the Bayesian-shrinkage gain + // formula (see core/memory/l2/gain.ts), a genuinely useful policy + // that fires on a single-success path now scores around 0.05-0.20 + // (proportional to V_with - 0.5). 0.02 is well below that floor + // but still cleanly rejects net-neutral noise. + minPolicyGain: 0.02, + minPolicySupport: 1, + // Lowered from 0.6 → 0.3 so the typical 2-3 active policies in + // an early-life install can still cluster into a world model; + // strict 0.6 starved L3 in real usage. + clusterMinSimilarity: 0.3, + policyCharCap: 800, + traceCharCap: 500, + traceEvidencePerPolicy: 1, + useLlm: true, + // Lowered from 1 → 0 so abstraction can run as soon as the + // ingredients show up, not on a per-day cadence. + cooldownDays: 0, + confidenceDelta: 0.05, + minConfidenceForRetrieval: 0.2, + }, + skill: { + // Raised 1 → 3 (2026-05-27). minSupport=1 crystallized a skill from a + // single episode, producing hyper-specific one-off skills that never + // re-matched (4 invocations across 41 skills). Requiring the pattern to + // recur across ≥3 episodes keeps only genuinely reusable procedures. + minSupport: 3, + // Raised 0.02 → 0.03: still filters neutral-noise policies (the + // shrinkage-anchored gain is ∝ V_with−0.5) but a touch stricter now + // that minSupport demands more evidence. + minGain: 0.03, + // Lowered from 5 → 1. Demanding multiple trials in `candidate` + // before a skill can graduate meant skills rarely promoted in + // real usage; 1 lets the candidate→active transition happen + // immediately on first successful invocation. + candidateTrials: 1, + // Lowered from 6 hours → 0: no cooldown, skills can re-evolve + // as soon as new evidence arrives. + cooldownMs: 0, + traceCharCap: 500, + evidenceLimit: 6, + useLlm: true, + etaDelta: 0.1, + archiveEta: 0.1, + minEtaForRetrieval: 0.1, + }, + feedback: { + failureThreshold: 3, + failureWindow: 5, + valueDelta: 0.5, + useLlm: true, + attachToPolicy: true, + cooldownMs: 60_000, + traceCharCap: 500, + evidenceLimit: 4, + }, + session: { + followUpMode: "merge_follow_ups", + mergeMaxGapMs: 2 * 60 * 60 * 1000, + }, + retrieval: { + tier1TopK: 3, + tier2TopK: 5, + tier3TopK: 2, + candidatePoolFactor: 4, + weightCosine: 0.6, + weightPriority: 0.4, + mmrLambda: 0.7, + includeLowValue: false, + rrfConstant: 60, + minSkillEta: 0.1, + // Lowered from 0.35 → 0.25 so partial-match traces still surface + // for users with smaller corpora. + minTraceSim: 0.25, + episodeGoalMinSim: 0.45, + tagFilter: "auto", + keywordTopK: 20, + // Lowered from 0.4 → 0.2 with the 2026 ranker overhaul: the new + // base relevance already uses channel rank as a first-class + // signal, so the old 0.4 floor was over-pruning keyword hits + // with modest V·decay. + relativeThresholdFloor: 0.2, + skillEtaBlend: 0.15, + smartSeed: true, + smartSeedRatio: 0.7, + multiChannelBypass: true, + skillInjectionMode: "summary", + skillSummaryChars: 200, + llmFilterEnabled: true, + // Tighter than the legacy default (5) so the LLM filter has a + // small budget; combined with the richer prompt (v3) this keeps + // packets concise without over-dropping. + llmFilterMaxKeep: 4, + // Set to 2: skip the LLM precision pass when there's only one + // candidate (no point ranking a single item). Anything with 2+ + // candidates still goes through the filter to drop off-topic + // hits before injection. + llmFilterMinCandidates: 2, + llmFilterCandidateBodyChars: 500, + }, + }, + hub: { + enabled: false, + role: "client", + port: 18912, + address: "", + teamName: "", + teamToken: "", + userToken: "", + nickname: "", + }, + telemetry: { enabled: true }, + logging: { + level: "info", + detailedView: false, + console: { enabled: true, pretty: true, channels: ["*"] }, + file: { + enabled: true, + format: "json", + rotate: { maxSizeMb: 50, maxFiles: 14, gzip: true }, + retentionDays: 30, + }, + audit: { + enabled: true, + rotate: { monthly: true, gzip: true }, + }, + llmLog: { enabled: true, redactPrompts: false, redactCompletions: false }, + perfLog: { enabled: true, sampleRate: 1.0 }, + eventsLog: { enabled: true }, + redact: { + extraKeys: ["api_key", "secret", "token", "password", "authorization"], + extraPatterns: [], + }, + channels: {}, + }, +}; + +/** + * Set of dotted-path field names whose values must never be sent to the + * viewer or any non-localhost surface. Used by `server/routes/config.ts`. + */ +export const SECRET_FIELD_PATHS: readonly string[] = Object.freeze([ + "embedding.apiKey", + "llm.apiKey", + "skillEvolver.apiKey", + "hub.teamToken", + "hub.userToken", +]); diff --git a/tools/plugin-patches/core/llm/prompts/l2-induction.ts b/tools/plugin-patches/core/llm/prompts/l2-induction.ts new file mode 100644 index 0000000..1e009ed --- /dev/null +++ b/tools/plugin-patches/core/llm/prompts/l2-induction.ts @@ -0,0 +1,119 @@ +import type { PromptDef } from "./index.js"; + +/** + * V7 §5 — Cross-task L2 induction. + * + * Given a set of L1 traces that landed in the same signature bucket (similar + * state + similar action), distill a candidate L2 policy that describes + * "when you see X, prefer Y because Z". The candidate is still probationary + * until the evaluator confirms it raises task success. + * + * Boundary contract (see `docs/GRANULARITY-AND-MEMORY-LAYERS.md` §6): + * an L2 policy is **procedural** ("how to do it") — it MUST contain an + * action template. Anything declarative ("the environment looks like X") + * belongs to the L3 world model, not here. The system prompt explicitly + * rejects environment-fact drift to keep the two layers semantically + * orthogonal. Bumping the version to v2 captures that change. + */ +export const L2_INDUCTION_PROMPT: PromptDef = { + id: "l2.induction", + version: 2, + description: + "Distill an L2 policy (procedural sub-task strategy) from a cluster of similar L1 traces, with explicit boundaries against L3 world-model drift.", + system: `You induce reusable **procedural policies** from agent experience. + +A policy is a "how-to": "when you see condition X in the agent's state, +do action Y, verify with Z, watch out for caveat W." It is **NOT** a +description of the environment. + +Input TRACES: a list of { state_summary, action, outcome, utility } records +that all share a similar state signature. + +Produce ONE policy describing the action pattern. The policy must: +- Name a TRIGGER recognizable from the agent's STATE — a condition the + agent can detect at the moment of decision (an error code, a missing + file, a request shape). NOT a fact about the environment in general. +- Prescribe an ACTION template — a parameterized step or short step + sequence. Templates over single exact commands. NOT a single example. +- Note at least one CAVEAT or failure mode observed in the traces — a + step-level pitfall, NOT a generic environment taboo. +- Generalize across the input traces, not restate one of them. + +──────────────────── Boundaries — what NOT to write ──────────────────── + +This output is a **procedural policy**, not an environment world model. +The world model lives in a separate layer (L3) generated by a different +prompt. Cross-contamination on either side dilutes both. + +Do NOT write any of these — they belong to L3 (env world model), not here: + - Topology facts: "Alpine containers ship musl libc" + "Python deps form a 3-layer stack" + "src/components/ holds React components" + - Environment behavioural rules (in pure declarative form): + "binary wheels are incompatible with musl" + "the service reads config only at startup" + - Environment taboos detached from a specific action choice: + "this directory is read-only" + "production tables shouldn't be DROPped lightly" + +If a trace tells you the environment looks a certain way, FOLD that fact +INTO the trigger or caveat as a state-level CONDITION the agent can +check, not as a standalone description. Example: + + Wrong (drifts into env-fact): + trigger: "Alpine ships musl libc" + caveats: ["Python deps have a 3-layer stack"] + + Right (states it as actionable conditions): + trigger: "container is Alpine AND pip install fails with + ' not found' or 'header not found'" + caveats: ["if first apk add still fails, also check musl-vs-glibc + wheel compatibility before retrying"] + +──────────────────── Same fact, two framings ───────────────────── + +If the underlying truth is "Alpine containers don't ship system dev +libs by default": + + Express here (procedural): + "When pip install fails inside an Alpine container with a missing + system library, run apk add -dev then retry pip." + + Do NOT express here (declarative — that's L3's job): + "Alpine container images ship only the pure-Python tier of the + Python dependency stack." + +──────────────────── Success vs. failure ───────────────────── + +Read the supporting traces' outcomes and utility (each block shows +"V: " — negative V means the step went badly). Classify the policy: + +- "success_pattern" — the traces show an action that WORKED. The "action" + field prescribes what TO DO. +- "failure_avoidance" — the traces show an action that FAILED, looped, or + produced errors (negative V, error tools, repeated retries). Here the + policy's job is to capture what NOT to do and the better alternative: + set "experience_type":"failure_avoidance", keep "action" as the corrective + template, and fill "anti_patterns" with the specific mistakes to avoid + ("don't retry web_search after 3 consecutive failures — switch tactic"). + +Do not force everything to success. If the evidence is failures, say so — +"what to avoid" is as valuable as "what works". + +──────────────────── Output ───────────────────── + +Return JSON: +{ + "title": "short imperative title", + "trigger": "state-level condition the agent can detect", + "action": "templated step or step sequence", + "rationale": "why this action works ON THESE TRACES (not why the + environment behaves this way)", + "caveats": ["step-level pitfall string", ...], + "experience_type": "success_pattern" | "failure_avoidance", + "evidence_polarity": "positive" | "negative" | "mixed", + "anti_patterns": ["specific mistake to avoid", ...], + "confidence": number in [0, 1], + "support_trace_ids": ["tr_...", ...] +}`, +}; diff --git a/tools/plugin-patches/core/memory/l2/induce.ts b/tools/plugin-patches/core/memory/l2/induce.ts new file mode 100644 index 0000000..649999f --- /dev/null +++ b/tools/plugin-patches/core/memory/l2/induce.ts @@ -0,0 +1,276 @@ +/** + * LLM-driven L2 policy induction. + * + * Given a candidate bucket (≥ N distinct episodes' worth of evidence traces + * sharing a PatternSignature), call the `l2.induction` prompt and build a + * `PolicyRow` draft. If the draft passes cheap validation we persist it as a + * new row with `status = 'candidate'`; gain updates can later promote it. + * + * Pure induction logic — no candidate-pool or events writes. Callers handle + * that. + */ + +import { ERROR_CODES, MemosError } from "../../../agent-contract/errors.js"; +import { + detectDominantLanguage, + languageSteeringLine, +} from "../../llm/prompts/index.js"; +import { L2_INDUCTION_PROMPT } from "../../llm/prompts/l2-induction.js"; +import type { LlmClient } from "../../llm/index.js"; +import type { Logger } from "../../logger/types.js"; +import { sanitizeDerivedMarkdown, sanitizeDerivedMarkdownList, sanitizeDerivedText } from "../../safety/content.js"; +import type { + EmbeddingVector, + EpisodeId, + PolicyId, + PolicyRow, + TraceId, + TraceRow, +} from "../../types.js"; +import { ids } from "../../id.js"; +import { centroid } from "./similarity.js"; +import type { InductionDraft, InductionDraftResult } from "./types.js"; + +export interface InduceInput { + /** Traces that back the induction (one from each episode; may contain duplicates). */ + evidenceTraces: readonly TraceRow[]; + /** Episode ids these traces came from — must be the distinct set. */ + episodeIds: readonly EpisodeId[]; + /** Human-readable signature for the bucket — appears in prompts. */ + signatureLabel: string; + charCap: number; + /** + * Episode that triggered this induction run (i.e. the episode whose + * trace just landed and re-fired runL2). Forwarded to the LLM call so + * the resulting `system_model_status` audit row can be grouped with + * the rest of that episode's pipeline activity in the Logs viewer. + */ + triggerEpisodeId?: EpisodeId; +} + +export interface InduceDeps { + llm: LlmClient | null; + log: Logger; + /** When provided, run after JSON parse but before we accept the draft. */ + validate?: (d: InductionDraft) => void; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Run the induction LLM call and validate the draft. Returns a tagged union + * — callers can decide whether to persist. + */ +export async function induceDraft( + input: InduceInput, + deps: InduceDeps, +): Promise { + const { llm, log } = deps; + if (!llm) { + log.warn("l2.induce.llm_unavailable", { + signatureLabel: input.signatureLabel, + evidenceCount: input.evidenceTraces.length, + fallback: "skipped", + }); + return { ok: false, reason: "llm_disabled" }; + } + + const userPayload = packTraces(input.evidenceTraces, input.charCap, input.signatureLabel); + + // Match the induced policy's title/trigger/action/rationale to the + // dominant language of the evidence bucket — Chinese users expect + // their own L2 memories in 中文, English users expect English. + const evidenceLang = detectDominantLanguage( + input.evidenceTraces.flatMap((t) => [t.userText, t.agentText, t.reflection]), + ); + + try { + const rsp = await llm.completeJson<{ + title: unknown; + trigger: unknown; + action?: unknown; + procedure?: unknown; + verification?: unknown; + rationale?: unknown; + caveats?: unknown; + confidence?: unknown; + support_trace_ids?: unknown; + }>( + [ + { role: "system", content: L2_INDUCTION_PROMPT.system }, + { role: "system", content: languageSteeringLine(evidenceLang) }, + { role: "user", content: userPayload }, + ], + { + op: `l2.${L2_INDUCTION_PROMPT.id}.v${L2_INDUCTION_PROMPT.version}`, + phase: "l2", + episodeId: input.triggerEpisodeId ?? input.episodeIds[input.episodeIds.length - 1], + temperature: 0.1, + malformedRetries: 1, + schemaHint: `{"title":"...","trigger":"...","procedure":"...","verification":"...","rationale":"...","caveats":["..."],"confidence":0..1,"support_trace_ids":["tr_..."]}`, + validate: (v) => { + const o = v as Record; + for (const k of ["title", "trigger"]) { + if (typeof o[k] !== "string" || !(o[k] as string).trim()) { + throw new MemosError( + ERROR_CODES.LLM_OUTPUT_MALFORMED, + `l2.induction: '${k}' must be a non-empty string`, + { got: o[k] }, + ); + } + } + if ( + typeof o.procedure !== "string" && + typeof o.action !== "string" + ) { + throw new MemosError( + ERROR_CODES.LLM_OUTPUT_MALFORMED, + `l2.induction: 'procedure' (or legacy 'action') must be a string`, + { got: o.procedure ?? o.action }, + ); + } + }, + }, + ); + + const draft = normaliseDraft(rsp.value, input.evidenceTraces.map((t) => t.id)); + if (deps.validate) deps.validate(draft); + return { ok: true, draft }; + } catch (err) { + log.warn("induce.llm_failed", { + signature: input.signatureLabel, + err: err instanceof Error ? err.message : String(err), + }); + return { ok: false, reason: "llm_failed", detail: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Convert a validated draft into a ready-to-persist `PolicyRow`. + * Gain/support are zero-initialised — the gain step fills them in. + */ +export function buildPolicyRow(args: { + draft: InductionDraft; + episodeIds: readonly EpisodeId[]; + evidenceTraces: readonly TraceRow[]; + inducedBy: string; // prompt id + version + now?: number; + id?: PolicyId; +}): PolicyRow { + const now = args.now ?? Date.now(); + const vec = centroid(args.evidenceTraces.map((t) => t.vecSummary ?? t.vecAction ?? null)); + return { + id: (args.id ?? (ids.policy() as PolicyId)), + title: args.draft.title.slice(0, 120), + trigger: args.draft.trigger, + procedure: args.draft.procedure, + verification: args.draft.verification, + boundary: args.draft.boundary, + support: 0, + gain: 0, + status: "candidate", + sourceEpisodeIds: Array.from(new Set(args.episodeIds)), + inducedBy: args.inducedBy, + // Classified by the L2 prompt from the supporting traces' outcomes so + // negative/error episodes induce failure_avoidance policies, not just + // success patterns. + experienceType: args.draft.experienceType ?? "success_pattern", + evidencePolarity: args.draft.evidencePolarity ?? "positive", + // Anti-patterns from the prompt seed the avoid-list at induction time; + // the decision-repair pipeline still augments it from later feedback. + decisionGuidance: { preference: [], antiPattern: args.draft.antiPatterns ?? [] }, + vec: vec as EmbeddingVector | null, + createdAt: now, + updatedAt: now, + }; +} + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function packTraces( + traces: readonly TraceRow[], + charCap: number, + label: string, +): string { + const header = `PATTERN_SIGNATURE: ${label}\nTRACES (one per block):`; + const blocks: string[] = []; + let budget = Math.max(400, charCap - header.length - 100); + for (const t of traces) { + const block = [ + `---`, + `id: ${t.id}`, + `episode: ${t.episodeId}`, + `tags: ${(t.tags ?? []).join(",") || "-"}`, + `user: ${truncate(t.userText, 200)}`, + `agent: ${truncate(t.agentText, 300)}`, + `tools: ${formatTools(t.toolCalls)}`, + `reflection: ${truncate(t.reflection ?? "-", 300)}`, + `V: ${t.value.toFixed(2)} alpha: ${t.alpha.toFixed(2)}`, + ].join("\n"); + if (block.length > budget) { + blocks.push(block.slice(0, budget)); + break; + } + blocks.push(block); + budget -= block.length; + } + return `${header}\n${blocks.join("\n")}`; +} + +function formatTools(calls: TraceRow["toolCalls"] | undefined): string { + if (!calls || calls.length === 0) return "-"; + return calls + .slice(0, 3) + .map((c) => { + const out = + typeof c.output === "string" ? truncate(c.output, 80) : JSON.stringify(c.output ?? "").slice(0, 80); + return `${c.name ?? "?"}(${truncate(JSON.stringify(c.input ?? ""), 40)}) → ${out}`; + }) + .join("; "); +} + +function truncate(s: string, n: number): string { + if (!s) return ""; + if (s.length <= n) return s; + return s.slice(0, n - 1) + "…"; +} + +function normaliseDraft(value: Record, traceIds: readonly TraceId[]): InductionDraft { + const procedure = + typeof value.procedure === "string" + ? (value.procedure as string) + : typeof value.action === "string" + ? (value.action as string) + : ""; + const caveats = Array.isArray(value.caveats) + ? sanitizeDerivedMarkdownList((value.caveats as unknown[]).filter((c): c is string => typeof c === "string")) + : []; + const confidence = typeof value.confidence === "number" ? value.confidence : 0.5; + const supportTraceIds = Array.isArray(value.support_trace_ids) + ? (value.support_trace_ids as unknown[]).filter((x): x is string => typeof x === "string") + : []; + const experienceType = + value.experience_type === "failure_avoidance" ? "failure_avoidance" : "success_pattern"; + const evidencePolarity = + value.evidence_polarity === "negative" ? "negative" + : value.evidence_polarity === "mixed" ? "mixed" + : value.evidence_polarity === "neutral" ? "neutral" + : "positive"; + const antiPatterns = Array.isArray(value.anti_patterns) + ? sanitizeDerivedMarkdownList((value.anti_patterns as unknown[]).filter((c): c is string => typeof c === "string")) + : []; + return { + title: sanitizeDerivedText(value.title), + trigger: sanitizeDerivedMarkdown(value.trigger), + procedure: sanitizeDerivedMarkdown(procedure), + verification: typeof value.verification === "string" ? sanitizeDerivedMarkdown(value.verification) : "", + boundary: typeof value.boundary === "string" ? sanitizeDerivedMarkdown(value.boundary) : "", + rationale: typeof value.rationale === "string" ? sanitizeDerivedMarkdown(value.rationale) : "", + caveats, + confidence: Math.max(0, Math.min(1, confidence)), + supportTraceIds: supportTraceIds.length > 0 ? (supportTraceIds as TraceId[]) : Array.from(traceIds), + experienceType, + evidencePolarity, + antiPatterns, + }; +} diff --git a/tools/plugin-patches/core/memory/l2/types.ts b/tools/plugin-patches/core/memory/l2/types.ts new file mode 100644 index 0000000..f438a8a --- /dev/null +++ b/tools/plugin-patches/core/memory/l2/types.ts @@ -0,0 +1,249 @@ +/** + * `core/memory/l2` — types. + * + * The L2 pipeline maps a freshly-settled episode (reward already applied) to + * zero or more operations on the `policies` + `l2_candidate_pool` tables: + * + * 1. **Associate** — for every trace with V > 0, look up nearby `active` L2 + * policies by cosine. If one matches and shares the signature, bump its + * `support`, recompute `gain`, possibly retire on consistent negative + * delta. + * 2. **Candidate** — for traces that *don't* match any L2, drop them into + * `l2_candidate_pool` keyed by their signature (see `signature.ts`). + * 3. **Induce** — when ≥ 2 traces from **different** episodes share a + * candidate-pool signature, call the `l2.induction` prompt and mint a + * new `candidate` policy + embedding. + * + * All shapes below are *internal* to `core/memory/l2`; they are re-exported + * selectively by `index.ts`. + */ + +import type { + EpisodeId, + EpochMs, + PolicyId, + PolicyRow, + SessionId, + TraceId, + TraceRow, +} from "../../types.js"; + +// ─── Config mirror (algorithm.l2Induction + a few hard-coded knobs) ──────── + +export interface L2Config { + /** Cosine ≥ this is required to associate a new trace with an existing L2. */ + minSimilarity: number; + /** TTL (days) for rows in `l2_candidate_pool`. */ + candidateTtlDays: number; + /** Discount factor γ (shared with reward — used for value-weighted aggregation). */ + gamma: number; + /** Softmax temperature τ for value-weighted trace aggregation. V7 eq. 3. */ + tauSoftmax: number; + /** When true, call the LLM to induce new L2 policies; else skip induction. */ + useLlm: boolean; + /** Minimum trace V (after reward) to consider for any L2 update. */ + minTraceValue: number; + /** Minimum #distinct episodes required to mint a new L2 policy. */ + minEpisodesForInduction: number; + /** Character cap for traces passed into the induction prompt. */ + inductionTraceCharCap: number; +} + +// ─── Pattern signature ───────────────────────────────────────────────────── + +/** + * A pattern signature compresses a trace into a short string that's stable + * across semantically similar traces. Same signature → likely the same + * sub-problem → candidates for joint induction. + * + * Format: `|||`. + * - primaryTag: first element of `trace.tags` (e.g. `docker`) or `"_"` + * - secondaryTag: second tag (e.g. `pip`) or `"_"` + * - tool: first distinct tool called in the trace, normalized (or `"_"`) + * - errCode: derived from the first error-bearing observation ("NETWORK_REFUSED", "EXIT_1", …) or `"_"` + * + * Example: `"docker|pip|pip.install|MODULE_NOT_FOUND"`. + */ +export type PatternSignature = string; + +export interface SignatureComponents { + primaryTag: string; + secondaryTag: string; + tool: string; + errCode: string; +} + +// ─── Association / induction decisions ───────────────────────────────────── + +/** Outcome for a single trace when run through the L2 association step. */ +export interface AssociationResult { + traceId: TraceId; + signature: PatternSignature; + /** Matched existing policy — null when no cosine/sig match. */ + matchedPolicyId: PolicyId | null; + /** Match strength (cosine) ∈ [0, 1] when matched. */ + matchSimilarity: number; + /** True when we added/updated a candidate-pool row for this trace. */ + addedToCandidatePool: boolean; +} + +/** Induction decision for a candidate-pool bucket. */ +export interface InductionResult { + signature: PatternSignature; + /** Null when induction was skipped (too few distinct episodes / LLM disabled). */ + policyId: PolicyId | null; + /** Number of candidate-pool rows that fed this induction. */ + poolSize: number; + /** Distinct episodes that contributed evidence. */ + episodeIds: EpisodeId[]; + /** Traces that contributed evidence (same order as episodes). */ + traceIds: TraceId[]; + skippedReason: + | null + | "too_few_episodes" + | "llm_disabled" + | "llm_failed" + | "draft_invalid" + | "duplicate_of" + | "all_below_threshold"; + /** When `skippedReason === "duplicate_of"`, the existing policy id. */ + duplicateOfPolicyId?: PolicyId | null; +} + +// ─── Gain bookkeeping ────────────────────────────────────────────────────── + +export interface GainInput { + policyId: PolicyId; + /** Traces where the policy was actually applied (positive set). */ + withTraces: readonly TraceRow[]; + /** Traces that solved the same kind of problem *without* the policy. */ + withoutTraces: readonly TraceRow[]; +} + +export interface GainResult { + policyId: PolicyId; + gain: number; + withMean: number; + withoutMean: number; + withCount: number; + withoutCount: number; + /** V7 §0.6 eq. 3: softmax(V/τ) mean. Used when `withCount ≥ 3`. */ + weightedWith: number; +} + +// ─── Inputs / outputs for the orchestrator ───────────────────────────────── + +export interface L2ProcessInput { + episodeId: EpisodeId; + sessionId: SessionId; + /** Traces that belong to this episode — already scored. */ + traces: readonly TraceRow[]; + /** Monotonic anchor for candidate TTL. Defaults to `Date.now()`. */ + now?: EpochMs; + /** + * "reward.updated" trigger, for tagging audit events. Defaults to + * `"manual"` when the caller invokes the orchestrator directly. + */ + trigger: "reward.updated" | "manual" | "rebuild"; +} + +export interface L2ProcessResult { + episodeId: EpisodeId; + sessionId: SessionId; + associations: AssociationResult[]; + inductions: InductionResult[]; + /** Policies whose `support`/`gain`/`status` got touched. */ + touchedPolicyIds: PolicyId[]; + /** Non-fatal hiccups we logged but didn't throw on. */ + warnings: Array<{ stage: string; message: string; detail?: Record }>; + timings: { + associate: number; + candidate: number; + induce: number; + gain: number; + persist: number; + total: number; + }; + startedAt: EpochMs; + completedAt: EpochMs; +} + +// ─── LLM draft ───────────────────────────────────────────────────────────── + +/** The JSON shape we require from `l2.induction` prompt. */ +export interface InductionDraft { + title: string; + trigger: string; + procedure: string; + verification: string; + boundary: string; + rationale: string; + caveats: string[]; + confidence: number; + supportTraceIds: TraceId[]; + /** + * Whether this cluster encodes "what worked" (success_pattern) or "what to + * avoid" (failure_avoidance), classified by the L2 prompt from the supporting + * traces' outcomes/value. Defaults to success_pattern when absent. + */ + experienceType?: "success_pattern" | "failure_avoidance"; + /** Evidence polarity of the supporting traces (positive/negative/mixed). */ + evidencePolarity?: "positive" | "negative" | "neutral" | "mixed"; + /** Step-level "do NOT" notes — populated for failure_avoidance policies. */ + antiPatterns?: string[]; +} + +export type InductionDraftResult = + | { ok: true; draft: InductionDraft } + | { ok: false; reason: "llm_disabled" | "llm_failed" | "draft_invalid"; detail?: string }; + +// ─── Events ──────────────────────────────────────────────────────────────── + +export type L2Event = + | { + kind: "l2.trace.associated"; + episodeId: EpisodeId; + traceId: TraceId; + policyId: PolicyId; + similarity: number; + } + | { + kind: "l2.candidate.added"; + episodeId: EpisodeId; + traceId: TraceId; + signature: PatternSignature; + candidateId: string; + } + | { + kind: "l2.policy.induced"; + episodeId: EpisodeId; + policyId: PolicyId; + signature: PatternSignature; + evidenceTraceIds: TraceId[]; + evidenceEpisodeIds: EpisodeId[]; + title: string; + } + | { + kind: "l2.policy.updated"; + episodeId: EpisodeId; + policyId: PolicyId; + status: PolicyRow["status"]; + support: number; + gain: number; + } + | { + kind: "l2.failed"; + episodeId: EpisodeId; + stage: string; + error: { code: string; message: string }; + }; + +export type L2EventKind = L2Event["kind"]; +export type L2EventListener = (evt: L2Event) => void; + +export interface L2EventBus { + on(kind: L2EventKind, fn: L2EventListener): () => void; + onAny(fn: L2EventListener): () => void; + emit(evt: L2Event): void; + listenerCount(kind?: L2EventKind): number; +} diff --git a/tools/plugin-patches/core/memory/l3/merge.ts b/tools/plugin-patches/core/memory/l3/merge.ts new file mode 100644 index 0000000..84e0716 --- /dev/null +++ b/tools/plugin-patches/core/memory/l3/merge.ts @@ -0,0 +1,271 @@ +/** + * L3 upsert / merge logic. + * + * Given a freshly-abstracted world-model draft, decide whether to: + * 1. Create a new row in `world_model`, or + * 2. Update an existing row in-place (when a similar WM already covers + * the same domain), or + * 3. Retire explicitly superseded WMs (`draft.supersedesWorldIds`). + * + * "Similar enough" is defined as a cosine cutoff against a shortlist of + * WMs that share at least one `domainTag` with the cluster. This avoids + * spraying near-duplicate world models across runs while still letting + * genuinely distinct environments coexist (e.g. "Alpine python" vs + * "Debian python"). + * + * Pure decisions; no DB writes here. The orchestrator applies the result. + */ + +import { cosine } from "../../storage/vector.js"; +import type { + EmbeddingVector, + PolicyId, + WorldModelId, + WorldModelRow, +} from "../../types.js"; +import type { + L3AbstractionDraft, + L3Config, + PolicyCluster, +} from "./types.js"; + +// ─── Candidate gathering ─────────────────────────────────────────────────── + +export interface MergeCandidateLookup { + findByDomainTag(tag: string): WorldModelRow[]; + list?(opts?: { limit?: number }): WorldModelRow[]; +} + +export interface MergeDeps { + lookup: MergeCandidateLookup; + config: Pick; +} + +const POLICY_OVERLAP_MERGE_THRESHOLD = 0.6; + +// Cosine cutoff for treating two world-models as the SAME fact even when they +// share no policies/domain tags — the case that produced near-duplicate +// "Hermes agent environment" entries induced from different agents. High on +// purpose: this is dedup, not clustering (clusterMinSimilarity=0.3 is far too +// loose to merge on). +const WM_SIM_MERGE_THRESHOLD = 0.86; + +/** + * Pull the small shortlist of WMs that might be the "same environment" + * as the given cluster. De-dupes by id and skips entries with no vector + * (no vector = nothing we can compare against). + */ +export function gatherMergeCandidates( + cluster: PolicyCluster, + deps: MergeDeps, +): WorldModelRow[] { + const seen = new Map(); + for (const tag of cluster.domainTags) { + for (const wm of deps.lookup.findByDomainTag(tag)) { + if (!seen.has(wm.id)) seen.set(wm.id, wm); + } + } + if (deps.lookup.list) { + const clusterVec = cluster.centroidVec; + const clusterPolicyIds = cluster.policies.map((p) => p.id); + for (const wm of deps.lookup.list({ limit: 5_000 })) { + if (wm.status === "archived" || seen.has(wm.id)) continue; + const overlap = policyOverlapScore(clusterPolicyIds, wm.policyIds); + // Embedding-similarity path: also consider WMs that are semantically the + // same fact even with no shared policies/domain tags. This is what stops + // near-duplicate world-model entries from accumulating across agents. + const simOk = + !!clusterVec && !!wm.vec && + cosine(clusterVec as EmbeddingVector, wm.vec) >= WM_SIM_MERGE_THRESHOLD; + if (overlap >= POLICY_OVERLAP_MERGE_THRESHOLD || simOk) { + seen.set(wm.id, wm); + } + } + } + return Array.from(seen.values()); +} + +// ─── Decision ────────────────────────────────────────────────────────────── + +export type MergeDecision = + | { kind: "create" } + | { kind: "update"; target: WorldModelRow; cosineScore: number }; + +/** + * Pick the closest existing WM that passes the similarity cutoff. + * If none qualify we return `{kind: "create"}` — the caller will + * insert a fresh row. + */ +export function chooseMergeTarget( + cluster: PolicyCluster, + candidates: readonly WorldModelRow[], + draft: L3AbstractionDraft, + deps: MergeDeps, +): MergeDecision { + // For the embedding-cosine merge decision use the strict dedup cutoff, not + // the loose clustering threshold (0.3) — otherwise loosely-related WMs would + // collapse into one. Policy-overlap merges still happen via their own path. + const threshold = Math.max(deps.config.clusterMinSimilarity, WM_SIM_MERGE_THRESHOLD); + + const explicit = pickBySupersedes(candidates, draft.supersedesWorldIds ?? []); + if (explicit) { + return { kind: "update", target: explicit, cosineScore: 1 }; + } + + const policyOverlap = pickByPolicyOverlap(cluster, candidates); + if (policyOverlap) { + return { + kind: "update", + target: policyOverlap.row, + cosineScore: policyOverlap.score, + }; + } + + const clusterVec = cluster.centroidVec; + if (!clusterVec) return { kind: "create" }; + + let best: { row: WorldModelRow; score: number } | null = null; + for (const wm of candidates) { + if (!wm.vec) continue; + const score = cosine(clusterVec as EmbeddingVector, wm.vec); + if (score >= threshold && (!best || score > best.score)) { + best = { row: wm, score }; + } + } + + if (best) return { kind: "update", target: best.row, cosineScore: best.score }; + return { kind: "create" }; +} + +function pickBySupersedes( + candidates: readonly WorldModelRow[], + supersedes: readonly WorldModelId[], +): WorldModelRow | null { + if (supersedes.length === 0) return null; + for (const wm of candidates) { + if (supersedes.includes(wm.id)) return wm; + } + return null; +} + +function pickByPolicyOverlap( + cluster: PolicyCluster, + candidates: readonly WorldModelRow[], +): { row: WorldModelRow; score: number } | null { + const clusterPolicyIds = cluster.policies.map((p) => p.id); + let best: { row: WorldModelRow; score: number; shared: number } | null = null; + for (const wm of candidates) { + if (wm.status === "archived") continue; + const score = policyOverlapScore(clusterPolicyIds, wm.policyIds); + if (score < POLICY_OVERLAP_MERGE_THRESHOLD) continue; + const shared = sharedPolicyCount(clusterPolicyIds, wm.policyIds); + if ( + !best || + score > best.score || + (score === best.score && shared > best.shared) || + (score === best.score && shared === best.shared && wm.confidence > best.row.confidence) + ) { + best = { row: wm, score, shared }; + } + } + return best; +} + +function policyOverlapScore(left: readonly PolicyId[], right: readonly PolicyId[]): number { + if (left.length === 0 || right.length === 0) return 0; + const shared = sharedPolicyCount(left, right); + return shared / Math.min(left.length, right.length); +} + +function sharedPolicyCount(left: readonly PolicyId[], right: readonly PolicyId[]): number { + const rightSet = new Set(right); + let shared = 0; + for (const id of new Set(left)) { + if (rightSet.has(id)) shared += 1; + } + return shared; +} + +// ─── Field merging (for "update" decisions) ───────────────────────────────── + +export interface MergedPatch { + title: string; + body: string; + structure: { + environment: Array<{ label: string; description: string; evidenceIds?: string[] }>; + inference: Array<{ label: string; description: string; evidenceIds?: string[] }>; + constraints: Array<{ label: string; description: string; evidenceIds?: string[] }>; + }; + domainTags: string[]; + policyIds: PolicyId[]; + sourceEpisodeIds: string[]; + vec: EmbeddingVector | null; +} + +/** + * Build the patch we hand to `worldModel.updateBody(...)`. We prefer the + * fresh draft's sections but retain any unique structured entries from + * the existing row so we don't forget evidence accumulated across runs. + */ +export function mergeForUpdate(args: { + existing: WorldModelRow; + draft: L3AbstractionDraft; + cluster: PolicyCluster; + episodeIds: readonly string[]; +}): MergedPatch { + const { existing, draft, cluster, episodeIds } = args; + + const env = mergeEntries(existing.structure.environment, draft.environment); + const inf = mergeEntries(existing.structure.inference, draft.inference); + const con = mergeEntries(existing.structure.constraints, draft.constraints); + + const domainTags = mergeTags(existing.domainTags, draft.domainTags.length > 0 ? draft.domainTags : cluster.domainTags); + const policyIds = mergeIds( + existing.policyIds, + cluster.policies.map((p) => p.id), + ); + const sourceEpisodeIds = mergeIds(existing.sourceEpisodeIds, episodeIds); + + const vec: EmbeddingVector | null = + (cluster.centroidVec as EmbeddingVector | null) ?? existing.vec ?? null; + + return { + title: draft.title.slice(0, 160) || existing.title, + body: draft.body && draft.body.trim().length > 0 ? draft.body : existing.body, + structure: { environment: env, inference: inf, constraints: con }, + domainTags, + policyIds, + sourceEpisodeIds, + vec, + }; +} + +// ─── Low-level helpers ───────────────────────────────────────────────────── + +function mergeEntries< + T extends { label: string; description: string; evidenceIds?: string[] }, +>(prev: readonly T[], next: readonly T[]): T[] { + const byKey = new Map(); + for (const e of prev) byKey.set(entryKey(e), e); + for (const e of next) byKey.set(entryKey(e), e); + return Array.from(byKey.values()).slice(0, 24) as T[]; +} + +function entryKey(e: { label: string; description: string }): string { + return `${e.label.toLowerCase().trim()}::${e.description.toLowerCase().trim().slice(0, 64)}`; +} + +function mergeTags(prev: readonly string[], next: readonly string[]): string[] { + const set = new Set(); + for (const t of [...prev, ...next]) { + const clean = t.trim().toLowerCase(); + if (clean.length > 0) set.add(clean); + } + return Array.from(set).slice(0, 6); +} + +function mergeIds(prev: readonly T[], next: readonly T[]): T[] { + const set = new Set(); + for (const id of [...prev, ...next]) set.add(id); + return Array.from(set); +} diff --git a/tools/plugin-patches/core/pipeline/memory-core.ts b/tools/plugin-patches/core/pipeline/memory-core.ts new file mode 100644 index 0000000..22dc4bb --- /dev/null +++ b/tools/plugin-patches/core/pipeline/memory-core.ts @@ -0,0 +1,4554 @@ +/** + * `createMemoryCore` — the adapter-facing façade. + * + * The pipeline (see `orchestrator.ts`) owns every algorithm subscriber, + * every event bus, every runner; it is intentionally richer than the + * adapter contract. Adapters should never reach into that shape. + * + * This file implements the `MemoryCore` interface (see + * `agent-contract/memory-core.ts`) on top of a `PipelineHandle`: + * + * • Translates JSON-friendly DTOs ↔ core rows. + * • Serializes lifecycle transitions (`init` → `shutdown`). + * • Maps every error to a stable `MemosError` code so bridges + * (JSON-RPC or TCP) can surface them cleanly. + * + * Two constructors are exposed: + * + * • `createMemoryCore(handle, home, pkgVersion)` — wrap an already-built + * `PipelineHandle`. Keeps the façade trivially mockable in tests. + * + * • `bootstrapMemoryCore(options)` — opens storage, runs migrations, + * loads providers + config, and constructs the pipeline from a + * minimal `{ agent, home?, config? }` input. Used by adapters. + */ + +import { randomUUID } from "node:crypto"; + +import { MemosError } from "../../agent-contract/errors.js"; +import type { + AgentKind, + ApiLogDTO, + EpisodeId, + EpisodeListItemDTO, + FeedbackDTO, + PolicyDTO, + RetrievalHitDTO, + RetrievalQueryDTO, + RetrievalResultDTO, + SessionId, + SkillDTO, + SkillId, + SubagentOutcomeDTO, + TraceDTO, + WorldModelDTO, + RuntimeNamespace, +} from "../../agent-contract/dto.js"; +import type { CoreEvent } from "../../agent-contract/events.js"; +import type { LogRecord } from "../../agent-contract/log-record.js"; +import type { + CoreHealth, + MemoryCore, + Unsubscribe, +} from "../../agent-contract/memory-core.js"; +import type { + EpisodeSnapshot, + EpisodeTurn, + IntentDecision, +} from "../session/types.js"; + +import type { + EpisodeRow, + FeedbackRow, + PolicyId, + PolicyRow, + SkillRow, + EpochMs, + TraceId, + TraceRow, + WorldModelId, + WorldModelRow, +} from "../types.js"; +import type { ResolvedConfig, ResolvedHome } from "../config/index.js"; +import { loadConfig, resolveHome, SECRET_FIELD_PATHS } from "../config/index.js"; +import { feedbackText, runFeedbackExperience } from "../experience/feedback-builder.js"; +import { rootLogger } from "../logger/index.js"; +import type { Logger } from "../logger/types.js"; +import { openDb } from "../storage/connection.js"; +import { runMigrations } from "../storage/migrator.js"; +import { makeRepos } from "../storage/repos/index.js"; +import { createEmbedder } from "../embedding/embedder.js"; +import { createLlmClient } from "../llm/client.js"; +import { + getHostLlmBridge, + registerHostLlmBridge, + type HostLlmBridge, +} from "../llm/host-bridge.js"; + +import { createPipeline } from "./orchestrator.js"; +import { wrapRetrievalRepos } from "./retrieval-repos.js"; +import type { PipelineDeps, PipelineHandle } from "./types.js"; +import { + namespaceFromHints, + namespaceMeta, + normalizeNamespace, + ownerFromNamespace, + isVisibleTo, +} from "../runtime/namespace.js"; +import { getRequestNamespace } from "../runtime/request-namespace.js"; +import type { RetrievalConfig } from "../retrieval/types.js"; +import type { UserFeedback } from "../reward/types.js"; + +// ─── Public bootstrap helpers ─────────────────────────────────────────────── + +export interface BootstrapOptions { + agent: AgentKind; + namespace?: RuntimeNamespace; + /** Optional pre-resolved home. If omitted, derived from `resolveHome`. */ + home?: ResolvedHome; + /** Optional pre-resolved config. If omitted, we load from disk. */ + config?: ResolvedConfig; + /** Override `Date.now` — useful for deterministic tests. */ + now?: () => number; + /** Plugin package version (surfaced via `health()`). */ + pkgVersion?: string; + /** + * Optional adapter-supplied LLM bridge. When set, registered on the + * shared host-bridge singleton **before** the LLM clients are + * created so `shouldFallback()` can see it on the very first call. + * + * Wiring this through bootstrap (rather than asking the adapter to + * call `registerHostLlmBridge` itself) avoids a subtle ESM module- + * identity bug: when the adapter dynamically imports + * `core/llm/host-bridge.ts` from a different URL than the static + * `import` chain inside `core/llm/client.ts`, Node's module loader + * treats them as two separate modules with two independent + * `currentBridge` slots — register hits one, get sees the other, + * fallback never engages. Routing through bootstrap forces the + * register call to happen via the same module instance the LLM + * client closes over. + */ + hostLlmBridge?: HostLlmBridge | null; + /** Optional telemetry instance for ARMS RUM reporting. */ + telemetry?: import("../telemetry/index.js").Telemetry | null; +} + +export interface BootstrapResult { + core: MemoryCore; + home: ResolvedHome; + config: ResolvedConfig; +} + +/** + * Build a `MemoryCore` from the ground up. Opens SQLite, runs migrations, + * constructs the LLM/embedder (if configured) and wires the pipeline. + * + * The returned core is **already initialized** — `init()` is a no-op after + * bootstrapping; callers can still await it if they want the stable contract. + * + * Adapters should prefer {@link bootstrapPlugin} instead — it additionally + * starts the HTTP viewer on the configured port and returns a shutdown + * handle that tears both down together. + */ +export async function bootstrapMemoryCore( + options: BootstrapOptions, +): Promise { + const result = await bootstrapMemoryCoreFull(options); + return result.core; +} + +export async function bootstrapMemoryCoreFull( + options: BootstrapOptions, +): Promise { + const home = options.home ?? resolveHome(options.agent); + const config = + options.config ?? + (await loadConfig(home)).config; + + const log = rootLogger.child({ + channel: "core.pipeline.bootstrap", + ctx: { agent: options.agent }, + }); + const namespace = normalizeNamespace(options.namespace, options.agent); + + // 1. Storage. + const db = openDb({ filepath: home.dbFile, agent: options.agent }); + try { + runMigrations(db); + } catch (err) { + // Migrations are idempotent — a failure here is unrecoverable. + try { + db.close(); + } catch { + /* swallow */ + } + throw new MemosError( + "config_invalid", + `migrations failed for ${home.dbFile}: ` + + (err instanceof Error ? err.message : String(err)), + ); + } + const repos = makeRepos(db); + + // ─── Host LLM bridge ── + // Register the adapter-supplied bridge BEFORE constructing any + // LlmClient so the very first call site sees a non-null bridge. + // The shouldFallback() check inside the LLM facade reads this + // singleton at every call; pinning it here guarantees the + // identity-by-module is the same instance the client closes over. + if (options.hostLlmBridge) { + registerHostLlmBridge(options.hostLlmBridge); + log.info("hostLlmBridge.registered", { + id: options.hostLlmBridge.id, + }); + } + + // ─── system_error sink ── + // Every facade we build (embedder / main LLM / reflect LLM) gets a + // tiny error sink that drops a `system_error` row into `api_logs` + // when the underlying provider call fails terminally. The Logs + // viewer renders these under the "系统" tag so users can correlate + // a red model card on the Overview with the exact provider message. + // Wrapped in try/catch because logging must never break the call. + function recordSystemError( + role: "embedding" | "llm" | "skillEvolver", + detail: { + provider: string; + model: string; + message: string; + code?: string; + at?: number; + }, + ): void { + try { + repos.apiLogs.insert({ + toolName: "system_error", + input: { role }, + output: { role, ...detail }, + durationMs: 0, + success: false, + calledAt: detail.at ?? Date.now(), + }); + } catch { + /* the system_error row itself failing is non-fatal */ + } + } + + function recordSystemModelStatus( + role: "embedding" | "llm" | "skillEvolver", + detail: { + status: "ok" | "fallback" | "error"; + provider: string; + model: string; + message?: string; + code?: string; + at?: number; + durationMs?: number; + fallbackProvider?: string; + fallbackModel?: string; + op?: string; + episodeId?: string; + phase?: string; + }, + ): void { + try { + repos.apiLogs.insert({ + toolName: "system_model_status", + input: { + role, + op: detail.op, + episodeId: detail.episodeId, + phase: detail.phase, + }, + output: { role, ...detail }, + durationMs: detail.durationMs ?? 0, + success: detail.status !== "error", + calledAt: detail.at ?? Date.now(), + }); + } catch { + /* the status row itself failing is non-fatal */ + } + } + + // 2. Providers (embedding + LLM) — nullable so we can run without them. + // The LLM facade we build falls through to "local_only" when no remote + // endpoint is configured, but we still catch construction errors so the + // core boots headless when providers can't be reached at startup. + let embedder = null as ReturnType | null; + let llm = null as ReturnType | null; + try { + embedder = createEmbedder({ + ...(config.embedding as object), + onError: (d: { provider: string; model: string; message: string; code?: string; at?: number }) => + recordSystemError("embedding", d), + onStatus: (d: { + status: "ok" | "error"; + provider: string; + model: string; + message?: string; + code?: string; + at?: number; + durationMs?: number; + op?: string; + episodeId?: string; + phase?: string; + }) => recordSystemModelStatus("embedding", d), + } as never); + } catch (err) { + log.warn("embedder.unavailable", { + err: err instanceof Error ? err.message : String(err), + }); + embedder = null; + } + try { + llm = createLlmClient({ + ...(config.llm as object), + onError: (d: { provider: string; model: string; message: string; code?: string; at?: number }) => + recordSystemError("llm", d), + onStatus: (d: { + status: "ok" | "fallback" | "error"; + provider: string; + model: string; + message?: string; + code?: string; + at?: number; + durationMs?: number; + fallbackProvider?: string; + fallbackModel?: string; + op?: string; + episodeId?: string; + phase?: string; + }) => recordSystemModelStatus("llm", d), + } as never); + } catch (err) { + log.warn("llm.unavailable", { + err: err instanceof Error ? err.message : String(err), + }); + llm = null; + } + + // When provider=host, the LLM client was created successfully but + // every call will fail at runtime if no HostLlmBridge is registered. + // Detect this eagerly and null-out the client so downstream modules + // see "no LLM" instead of burning retries on every reward/L2/skill + // tick. The adapter is responsible for calling registerHostLlmBridge() + // before core.init(); if it hasn't by now, it won't. + if (llm && llm.provider === "host" && !getHostLlmBridge()) { + log.warn("llm.host_bridge_missing", { + provider: "host", + impact: "LLM client created but no HostLlmBridge registered — " + + "every call would fail with LLM_UNAVAILABLE. " + + "Nulling out the client so reward/L2/skill/L3 skip cleanly. " + + "Configure a direct provider (openai_compatible, anthropic, gemini) " + + "or ensure the host adapter calls registerHostLlmBridge().", + }); + llm = null; + } + + // Build a dedicated LLM for the reflection phase from skillEvolver + // config when the user has configured a stronger model there. Falls + // back to the main `llm` when skillEvolver.model is blank. + let reflectLlm: ReturnType | null = null; + try { + const evolver = (config as { skillEvolver?: { provider?: string; model?: string; endpoint?: string; apiKey?: string; temperature?: number; timeoutMs?: number } }).skillEvolver; + const evolverModel = (evolver?.model ?? "").trim(); + const evolverProvider = (evolver?.provider ?? "").trim(); + if (evolverModel && evolverProvider) { + reflectLlm = createLlmClient({ + provider: evolverProvider, + model: evolverModel, + endpoint: evolver?.endpoint ?? "", + apiKey: evolver?.apiKey ?? "", + temperature: evolver?.temperature ?? 0, + timeoutMs: evolver?.timeoutMs ?? 60_000, + maxRetries: 3, + // V7 §0.x — when the user's dedicated skill-evolver model is + // down (auth, model name typo, server outage), prefer falling + // back to the host agent's main LLM via the stdio host + // bridge instead of hard-failing the skill pipeline. The + // viewer paints the slot yellow + surfaces the upstream error + // so the operator still notices. + fallbackToHost: true, + onError: (d: { provider: string; model: string; message: string; code?: string; at?: number }) => + recordSystemError("skillEvolver", d), + onStatus: (d: { + status: "ok" | "fallback" | "error"; + provider: string; + model: string; + message?: string; + code?: string; + at?: number; + durationMs?: number; + fallbackProvider?: string; + fallbackModel?: string; + op?: string; + episodeId?: string; + phase?: string; + }) => recordSystemModelStatus("skillEvolver", d), + } as never); + log.info("reflectLlm.ready", { + provider: evolverProvider, + model: evolverModel, + source: "skillEvolver", + }); + } + } catch (err) { + log.warn("reflectLlm.unavailable", { + err: err instanceof Error ? err.message : String(err), + fallback: "main llm", + }); + } + + // 3. Pipeline. + const deps: PipelineDeps = { + agent: options.agent, + home, + config, + db, + repos, + llm, + reflectLlm: reflectLlm ?? llm, + embedder, + log, + namespace, + now: options.now, + }; + const handle = createPipeline(deps); + + const core = createMemoryCore(handle, home, options.pkgVersion ?? "dev", { + telemetry: options.telemetry ?? null, + onShutdown: () => { + try { + db.close(); + } catch (err) { + log.warn("sqlite.close.error", { + err: err instanceof Error ? err.message : String(err), + }); + } + }, + }); + + return { core, home, config }; +} + +// ─── Facade factory ────────────────────────────────────────────────────────── + +export interface CreateMemoryCoreOptions { + /** Called after the pipeline has shut down. */ + onShutdown?: () => void | Promise; + /** Optional telemetry instance for ARMS RUM reporting. */ + telemetry?: import("../telemetry/index.js").Telemetry | null; +} + +/** + * Wrap a pre-built `PipelineHandle` with the `MemoryCore` contract. + * + * Lifecycle semantics: + * • `init()` is idempotent; once called the core accepts turn events. + * • `shutdown()` drains the pipeline, fires `onShutdown`, and refuses + * subsequent calls with `MemosError("ALREADY_SHUT_DOWN")`. + */ +export function createMemoryCore( + handle: PipelineHandle, + home: ResolvedHome, + pkgVersion: string, + options: CreateMemoryCoreOptions = {}, +): MemoryCore { + const bootAt = Date.now(); + const log = rootLogger.child({ channel: "core.pipeline.memory-core" }); + let telemetry = options.telemetry ?? null; + let initialized = false; + let shutDown = false; + /** Per-episode monotonic step counter for tool outcomes. */ + const toolStepByEpisode = new Map(); + const skillStartedAtByPolicy = new Map(); + const skillRunDurationBySkill = new Map(); + const l2StartedAtByEpisode = new Map(); + let l3StartedAt: number | null = null; + let activeNamespace = handle.namespace; + // Most recent episode that triggered an L3 abstraction run. Set by + // the L2 → L3 hop on `l2.policy.induced` / `l2.policy.updated`, + // consumed by L3 lifecycle writers below. The L3 subscriber is + // single-flight so storing one value is sufficient for grouping + // `world_model_*` rows with the rest of the triggering episode's + // pipeline activity in the Logs viewer. + let l3TriggerEpisodeId: string | undefined; + const skillStatusThresholds = { + minEpisodesForInduction: String(handle.config.algorithm.l2Induction.minEpisodesForInduction), + minTraceValue: formatThreshold(handle.config.algorithm.l2Induction.minTraceValue), + skillMinSupport: String(handle.config.algorithm.skill.minSupport), + skillMinGain: formatThreshold(handle.config.algorithm.skill.minGain), + }; + + function ensureLive(): void { + if (shutDown) { + throw new MemosError( + "already_shut_down", + "memory-core is shut down", + ); + } + } + + function namespaceFor( + agent: AgentKind, + input?: { namespace?: RuntimeNamespace; contextHints?: Record; meta?: Record }, + ): RuntimeNamespace { + // Fall back to `effectiveNamespace()` (not bare `activeNamespace`) so the + // HTTP capture write path honors the per-request `X-As-Profile` namespace + // (P3.1 — shared single daemon serving all profiles). Outside an HTTP + // request `getRequestNamespace()` is undefined, so this resolves to + // `activeNamespace` exactly as before — backward-compatible for the stdio + // bridge. An explicit `input.namespace` still wins over both. + return namespaceFromHints(agent, input?.contextHints ?? input?.meta, input?.namespace ?? effectiveNamespace()); + } + + function withNamespaceMeta( + agent: AgentKind, + meta?: Record, + namespace?: RuntimeNamespace, + ): { meta: Record; namespace: RuntimeNamespace } { + const ns = namespaceFromHints(agent, meta, namespace ?? handle.namespace); + return { namespace: ns, meta: { ...(meta ?? {}), ...namespaceMeta(ns) } }; + } + + // Per-request namespace override via AsyncLocalStorage. When the HTTP + // dispatch layer wraps a handler in `runWithRequestNamespace(ns, …)` + // (parsed from `?as_profile=` or `X-As-Profile` header), this getter + // returns that namespace. Falls back to the daemon's startup-bound + // `activeNamespace` outside an HTTP context. Added 2026-05-17 — see + // core/runtime/request-namespace.ts. + function effectiveNamespace(): RuntimeNamespace { + return getRequestNamespace() ?? activeNamespace; + } + + function visibleToCurrent(row: { + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + share?: { scope?: string | null } | null; + }, ns: RuntimeNamespace = effectiveNamespace()): boolean { + return isVisibleTo(row, ns); + } + + function ownedByCurrent(row: { + ownerAgentKind?: AgentKind; + ownerProfileId?: string; + }, ns: RuntimeNamespace = effectiveNamespace()): boolean { + return row.ownerAgentKind === ns.agentKind && row.ownerProfileId === ns.profileId; + } + + // ─── Stale topic auto-finalize ── + // Open topics are allowed to survive clean session closes and process + // restarts so the next user turn can be classified against them. Once a + // topic exceeds this hard window, we treat it as ended and run the normal + // reflect/reward path. + const STALE_EPISODE_TIMEOUT_MS = Math.max( + handle.config.algorithm.session.mergeMaxGapMs * 2, + 4 * 60 * 60 * 1000, + ); + let lastStaleScan = 0; + let lastDirtyClosedScan = 0; + async function autoFinalizeStaleTasks(): Promise { + const nowMs = Date.now(); + if (nowMs - lastStaleScan < 30_000) return; + lastStaleScan = nowMs; + try { + const openEpisodes = handle.repos.episodes.list({ status: "open", limit: 200 }); + if (openEpisodes.length === 0) return; + const stale: Array }> = []; + for (const ep of openEpisodes) { + const epAge = nowMs - (ep.endedAt ?? ep.startedAt); + if (epAge > STALE_EPISODE_TIMEOUT_MS) { + log.info("stale_topic.auto_finalize", { + episodeId: ep.id, + sessionId: ep.sessionId, + ageMs: epAge, + thresholdMs: STALE_EPISODE_TIMEOUT_MS, + }); + stale.push(ep); + } + } + if (stale.length > 0) await recoverOpenEpisodesAsSessionEnd(stale); + } catch (err) { + log.debug("stale_topic.scan_error", { + err: err instanceof Error ? err.message : String(err), + }); + } + } + + async function autoRescoreDirtyClosedEpisodes(): Promise { + const nowMs = Date.now(); + if (nowMs - lastDirtyClosedScan < 30_000) return; + lastDirtyClosedScan = nowMs; + try { + const dirtyClosed = handle.repos.episodes + .list({ status: "closed", limit: 500 }) + .filter((ep) => episodeRewardIsDirty(ep)); + if (dirtyClosed.length > 0) { + await recoverDirtyClosedEpisodes(dirtyClosed); + } + } catch (err) { + log.debug("dirty_closed_reward.scan_error", { + err: err instanceof Error ? err.message : String(err), + }); + } + } + + // ─── Lifecycle ── + async function init(): Promise { + if (shutDown) { + throw new MemosError( + "already_shut_down", + "cannot re-init a shut-down memory-core", + ); + } + initialized = true; + + // Preserve recent open topics across restarts. A crash or Ctrl+C is + // not evidence that the topic ended; the next user turn gets routed + // through relation classification. Only hard-stale open topics are + // finalized here so the pipeline eventually catches up. + try { + const orphans = handle.repos.episodes.list({ status: "open", limit: 500 }); + if (orphans.length > 0) { + const nowMs = Date.now(); + const stale = orphans.filter( + (ep) => + ep.rTask != null || + (ep.traceIds?.length ?? 0) > 0 || + nowMs - (ep.endedAt ?? ep.startedAt) > STALE_EPISODE_TIMEOUT_MS, + ); + const recent = orphans.filter((ep) => !stale.includes(ep)); + for (const ep of recent) { + handle.repos.episodes.updateMeta(ep.id as EpisodeId, { + topicState: (ep.meta?.topicState as string | undefined) ?? "interrupted", + pauseReason: (ep.meta?.pauseReason as string | undefined) ?? "startup_recovered_open_topic", + recoveredAtStartup: nowMs, + }); + } + if (stale.length > 0) { + await recoverOpenEpisodesAsSessionEnd(stale); + } + } + const dirtyClosed = handle.repos.episodes + .list({ status: "closed", limit: 500 }) + .filter((ep) => episodeRewardIsDirty(ep)); + if (dirtyClosed.length > 0) { + // Background the dirty-episode reflection so init() — and therefore the + // HTTP server / capture path — never blocks on it. One dirty episode's + // reflect took 154s, leaving :18800 unavailable during the worst cold + // boots (and starving concurrently-booting bridges). The reflection + // backfills reward/value after startup; capture serves immediately. The + // recover fn processes episodes sequentially → self-capped to one + // reflection at a time. (Phase 1.1, 2026-05-27.) + void recoverDirtyClosedEpisodes(dirtyClosed).catch((err) => + log.warn("init.dirty_reflect.background_failed", { + err: err instanceof Error ? err.message : String(err), + count: dirtyClosed.length, + }), + ); + } + } catch (err) { + log.debug("init.orphan_scan.failed", { + err: err instanceof Error ? err.message : String(err), + }); + } + + // Wire `memory_add` into the api_logs table on EVERY turn so the + // Logs viewer shows per-turn capture activity. `capture.lite.done` + // fires once per `onTurnEnd` (the per-turn lite capture path); + // `capture.done` fires once per topic-end reflect+scoring pass. + // Both write a `memory_add` row but with different `phase` tags so + // the viewer can distinguish "stored" from "reflected". + handle.buses.capture.onAny((evt) => { + if (evt.kind !== "capture.lite.done" && evt.kind !== "capture.done") return; + try { + const r = evt.result; + const phase = evt.kind === "capture.lite.done" ? "lite" : "reflect"; + const storedCount = r.traceIds.length; + const statsLine = + `phase=${phase}, stored=${storedCount}` + + (r.warnings.length > 0 ? `, warnings=${r.warnings.length}` : ""); + const details = r.traces.map((tc) => ({ + role: inferTurnRole(tc), + action: phase === "lite" ? ("stored" as const) : ("reflected" as const), + summary: tc.reflection?.text ?? null, + content: ( + tc.userText || + tc.agentText || + summarizeToolCalls(tc.toolCalls) || + "" + ).slice(0, 400), + traceId: tc.traceId, + })); + handle.repos.apiLogs.insert({ + toolName: "memory_add", + input: { + sessionId: r.sessionId, + episodeId: r.episodeId, + turnCount: r.traces.length, + phase, + }, + output: { + phase, + stats: statsLine, + stored: storedCount, + warnings: r.warnings, + details, + }, + durationMs: Math.max(0, r.completedAt - r.startedAt), + success: r.warnings.length === 0, + calledAt: r.completedAt, + }); + } catch (err) { + log.debug("apiLogs.memory_add.skipped", { + err: err instanceof Error ? err.message : String(err), + }); + } + }); + + // ─── Skill lifecycle → api_logs(skill_*) ────────────────────────── + // Emit structured rows for the Logs page so users can watch skill + // generation / verification / retirement events with the same JSON + // detail the memory_search / memory_add cards show. Event shapes + // vary per kind — we spread the raw event into `output` (with any + // sensitive fields already redacted upstream) rather than hand- + // rolling per-kind schemas. + handle.buses.skill.onAny((evt) => { + const k = evt.kind; + if (k === "skill.crystallization.started") { + skillStartedAtByPolicy.set(evt.policyId, eventTime(evt)); + } else if (k === "skill.crystallized") { + const durationMs = durationSince( + skillStartedAtByPolicy.get(evt.policyId), + eventTime(evt), + 1, + ); + skillStartedAtByPolicy.delete(evt.policyId); + skillRunDurationBySkill.set(evt.skillId, durationMs); + writeApiLog(handle, log, "skill_generate", { + phase: "done", + skillId: evt.skillId, + policyId: evt.policyId, + episodeId: episodeFromPolicy(handle, evt.policyId), + }, evt, durationMs, true); + } else if (k === "skill.rebuilt" || k === "skill.eta.updated" || k === "skill.archived") { + const skillId = (evt as { skillId?: string }).skillId; + const durationMs = + (skillId ? skillRunDurationBySkill.get(skillId) : undefined) ?? + durationSince(eventTime(evt) - 1, eventTime(evt), 1); + if (k === "skill.rebuilt" && skillId) skillRunDurationBySkill.delete(skillId); + const policyIdForSkill = (evt as { policyId?: string }).policyId; + writeApiLog(handle, log, "skill_evolve", { + kind: k, + skillId, + policyId: policyIdForSkill, + episodeId: + episodeFromPolicy(handle, policyIdForSkill) ?? + episodeFromSkill(handle, skillId), + }, evt, durationMs, true); + } else if (k === "skill.verification.failed" || k === "skill.failed") { + const policyId = (evt as { policyId?: string }).policyId; + const skillId = (evt as { skillId?: string }).skillId; + const durationMs = durationSince( + policyId ? skillStartedAtByPolicy.get(policyId) : undefined, + eventTime(evt), + policyId ? 1 : 0, + ); + if (policyId) skillStartedAtByPolicy.delete(policyId); + writeApiLog(handle, log, "skill_generate", { + phase: "failed", + kind: k, + policyId, + skillId, + episodeId: + episodeFromPolicy(handle, policyId) ?? + episodeFromSkill(handle, skillId), + }, evt, durationMs, false); + } + }); + + // ─── L2 (经验) lifecycle → api_logs(policy_*) ───────────────────── + handle.buses.l2.onAny((evt) => { + const k = evt.kind; + if (k === "l2.policy.induced") { + // L2 induction is the canonical L3 trigger; remember the + // episode so the next L3 run's lifecycle rows can be grouped + // with the rest of that episode's pipeline activity. + l3TriggerEpisodeId = evt.episodeId; + const durationMs = durationSince(l2StartedAtByEpisode.get(evt.episodeId), Date.now(), 1); + writeApiLog(handle, log, "policy_generate", { + phase: "induced", + policyId: evt.policyId, + title: evt.title, + episodeId: evt.episodeId, + }, evt, durationMs, true); + } else if (k === "l2.policy.updated") { + if (evt.status === "active") l3TriggerEpisodeId = evt.episodeId; + const durationMs = durationSince(l2StartedAtByEpisode.get(evt.episodeId), Date.now(), 1); + writeApiLog(handle, log, "policy_evolve", { + policyId: evt.policyId, + status: evt.status, + episodeId: evt.episodeId, + }, evt, durationMs, true); + } else if (k === "l2.failed") { + const durationMs = durationSince(l2StartedAtByEpisode.get(evt.episodeId), Date.now(), 1); + l2StartedAtByEpisode.delete(evt.episodeId); + writeApiLog(handle, log, "policy_generate", { + phase: "failed", + episodeId: evt.episodeId, + }, evt, durationMs, false); + } + }); + + // ─── L3 (领域认知) lifecycle → api_logs(world_model_*) ──────────── + handle.buses.l3.onAny((evt) => { + const k = evt.kind; + if (k === "l3.abstraction.started") { + l3StartedAt = Date.now(); + } else if (k === "l3.world-model.created") { + writeApiLog(handle, log, "world_model_generate", { + phase: "created", + worldModelId: evt.worldModelId, + title: evt.title, + episodeId: + episodeFromWorldModel(handle, evt.worldModelId) ?? + l3TriggerEpisodeId, + }, evt, durationSince(l3StartedAt, Date.now(), 1), true); + } else if (k === "l3.world-model.updated") { + writeApiLog(handle, log, "world_model_evolve", { + worldModelId: evt.worldModelId, + title: evt.title, + episodeId: + episodeFromWorldModel(handle, evt.worldModelId) ?? + l3TriggerEpisodeId, + }, evt, durationSince(l3StartedAt, Date.now(), 1), true); + } else if (k === "l3.confidence.adjusted") { + writeApiLog(handle, log, "world_model_evolve", { + kind: "confidence.adjusted", + worldModelId: evt.worldModelId, + episodeId: + episodeFromWorldModel(handle, evt.worldModelId) ?? + l3TriggerEpisodeId, + }, evt, durationSince(l3StartedAt, Date.now(), 1), true); + } else if (k === "l3.failed") { + writeApiLog(handle, log, "world_model_generate", { + phase: "failed", + episodeId: l3TriggerEpisodeId, + }, evt, durationSince(l3StartedAt, Date.now(), 1), false); + } + }); + + // ─── Reward / task completion → api_logs(task_done | task_failed) ── + // The reward pipeline scores each finished episode; that score is + // what makes a task "completed" (R ≥ 0) or "failed" (R < 0) in the + // viewer's Tasks panel. + handle.buses.reward.onAny((evt) => { + if (evt.kind === "reward.updated") { + const result = evt.result; + const ok = result.rHuman >= 0; + l2StartedAtByEpisode.set(result.episodeId, Date.now()); + writeApiLog(handle, log, ok ? "task_done" : "task_failed", { + episodeId: result.episodeId, + sessionId: result.sessionId, + }, { + rHuman: result.rHuman, + source: result.humanScore.source, + timings: result.timings, + }, durationSince(result.startedAt, result.completedAt), ok); + } + }); + } + + async function recoverOpenEpisodesAsSessionEnd( + orphans: Array }>, + ): Promise { + const endedAt = Date.now(); + log.info("init.orphan_episodes.session_end_recover", { count: orphans.length }); + debugStartupRecovery("H1", "startup_recovery_scan", { + count: orphans.length, + episodes: orphans.map((ep) => ({ + id: ep.id, + sessionId: ep.sessionId, + traceCount: ep.traceIds.length, + rTask: ep.rTask, + })), + }); + + const needsRewardFallback: EpisodeId[] = []; + for (const ep of orphans) { + try { + const episodeId = ep.id as EpisodeId; + const traceIds = (ep.traceIds ?? []) as TraceId[]; + handle.repos.episodes.close(episodeId, endedAt, ep.rTask ?? undefined); + handle.repos.episodes.updateMeta(episodeId, { + closeReason: "finalized", + abandonReason: undefined, + recoveredAtStartup: endedAt, + recoveryReason: "missed_session_end", + }); + + if (ep.rTask != null && !episodeRewardIsDirty(ep)) { + log.info("init.orphan.repaired_finalized", { + episodeId, + sessionId: ep.sessionId, + rTask: ep.rTask, + }); + debugStartupRecovery("H2", "startup_recovery_already_scored", { + episodeId, + sessionId: ep.sessionId, + rTask: ep.rTask, + }); + continue; + } + + const snapshot = snapshotFromRecoveredEpisode(ep, endedAt); + debugStartupRecovery("H3", "startup_recovery_emit_finalized", { + episodeId, + sessionId: ep.sessionId, + traceCount: traceIds.length, + recoveredTurnCount: snapshot.turnCount, + }); + handle.buses.session.emit({ + kind: "episode.finalized", + episode: snapshot, + closedBy: "finalized", + }); + needsRewardFallback.push(episodeId); + } catch (err) { + log.debug("init.orphan_recovery.skipped", { + episodeId: ep.id, + err: err instanceof Error ? err.message : String(err), + }); + debugStartupRecovery("H4", "startup_recovery_error", { + episodeId: ep.id, + err: err instanceof Error ? err.message : String(err), + }); + } + } + + try { + await handle.flush(); + for (const episodeId of needsRewardFallback) { + const row = handle.repos.episodes.getById(episodeId); + if (row?.rTask == null) { + await handle.rewardRunner.run({ + episodeId, + feedback: [], + trigger: "manual", + }); + } + } + await handle.flush(); + debugStartupRecovery("H5", "startup_recovery_flush_done", { + recoveredCount: orphans.length, + rewardedEpisodes: needsRewardFallback.map((episodeId) => { + const row = handle.repos.episodes.getById(episodeId); + return { + episodeId, + rTask: row?.rTask ?? null, + closeReason: (row?.meta as { closeReason?: unknown } | undefined)?.closeReason, + abandonReason: (row?.meta as { abandonReason?: unknown } | undefined)?.abandonReason, + }; + }), + }); + } catch (err) { + log.warn("init.orphan_recovery.flush_failed", { + count: orphans.length, + err: err instanceof Error ? err.message : String(err), + }); + debugStartupRecovery("H5", "startup_recovery_flush_failed", { + count: orphans.length, + err: err instanceof Error ? err.message : String(err), + }); + } + } + + async function recoverDirtyClosedEpisodes( + episodes: Array }>, + ): Promise { + log.info("init.dirty_closed_episodes.rescore", { count: episodes.length }); + for (const ep of episodes) { + const episodeId = ep.id as EpisodeId; + const endedAt = ep.endedAt ?? Date.now(); + handle.repos.episodes.updateMeta(episodeId, { + closeReason: "finalized", + recoveredAtStartup: endedAt, + recoveryReason: "dirty_reward_rescore", + }); + const snapshot = snapshotFromRecoveredEpisode(ep, endedAt, { + recoveryReason: "dirty_reward_rescore", + }); + handle.buses.session.emit({ + kind: "episode.finalized", + episode: snapshot, + closedBy: "finalized", + }); + } + await handle.flush(); + } + + function episodeRewardIsDirty(ep: EpisodeRow & { meta?: Record }): boolean { + const meta = ep.meta ?? {}; + if (meta.rewardDirty && typeof meta.rewardDirty === "object") return true; + + const reward = meta.reward; + if (reward && typeof reward === "object" && (reward as { skipped?: unknown }).skipped === true) { + return false; + } + if ( + ep.rTask == null && + (ep.traceIds?.length ?? 0) > 0 && + (meta.closeReason === "finalized" || meta.recoveryReason === "missed_session_end") + ) { + return true; + } + if (!reward || typeof reward !== "object") return false; + const traceCount = (reward as { traceCount?: unknown }).traceCount; + if (typeof traceCount === "number") { + return traceCount !== (ep.traceIds?.length ?? 0); + } + + // Backward compatibility for episodes scored before reward coverage + // metadata existed: if a trace was appended after the recorded reward + // time, the old task score no longer covers the full episode. + const scoredAt = (reward as { scoredAt?: unknown }).scoredAt; + if (typeof scoredAt !== "number") return false; + const traceIds = (ep.traceIds ?? []) as TraceId[]; + if (traceIds.length === 0) return false; + return handle.repos.traces + .getManyByIds(traceIds) + .some((tr) => tr.ts > scoredAt); + } + + function snapshotFromRecoveredEpisode( + ep: EpisodeRow & { meta?: Record }, + endedAt: number, + opts: { recoveryReason?: string } = {}, + ): EpisodeSnapshot { + const traceIds = (ep.traceIds ?? []) as TraceId[]; + const traces = + traceIds.length > 0 + ? handle.repos.traces + .getManyByIds(traceIds) + .sort((a, b) => a.ts - b.ts) + : []; + const turns: EpisodeTurn[] = []; + for (const tr of traces) { + if (tr.userText) { + turns.push({ + id: `${tr.id}:user`, + ts: tr.ts, + role: "user", + content: tr.userText, + }); + } + if (tr.toolCalls.length > 0) { + tr.toolCalls.forEach((toolCall, idx) => { + turns.push({ + id: `${tr.id}:tool:${idx}`, + ts: (toolCall.endedAt ?? toolCall.startedAt ?? tr.ts) as EpochMs, + role: "tool", + content: + typeof toolCall.output === "string" + ? toolCall.output + : toolCall.output == null + ? "" + : JSON.stringify(toolCall.output), + meta: { + name: toolCall.name, + input: toolCall.input, + output: toolCall.output, + errorCode: toolCall.errorCode, + toolCallId: toolCall.toolCallId, + startedAt: toolCall.startedAt, + endedAt: toolCall.endedAt, + thinkingBefore: toolCall.thinkingBefore, + assistantTextBefore: toolCall.assistantTextBefore, + }, + }); + }); + } + if (tr.agentText) { + turns.push({ + id: `${tr.id}:assistant`, + ts: tr.ts, + role: "assistant", + content: tr.agentText, + meta: { + thinking: tr.agentThinking ?? undefined, + summary: tr.summary ?? undefined, + }, + }); + } + } + return { + id: ep.id as EpisodeId, + sessionId: ep.sessionId as SessionId, + startedAt: ep.startedAt, + endedAt, + status: "closed", + rTask: ep.rTask ?? null, + turnCount: turns.length, + turns, + traceIds, + meta: { + ...(ep.meta ?? {}), + closeReason: "finalized", + recoveredAtStartup: endedAt, + recoveryReason: opts.recoveryReason ?? "missed_session_end", + }, + intent: normaliseRecoveredIntent(ep.meta), + }; + } + + function normaliseRecoveredIntent(meta?: Record): IntentDecision { + const maybeIntent = (meta as { intent?: Partial } | undefined)?.intent; + return { + kind: maybeIntent?.kind ?? "unknown", + confidence: typeof maybeIntent?.confidence === "number" ? maybeIntent.confidence : 0, + reason: maybeIntent?.reason ?? "recovered from startup orphan episode", + retrieval: maybeIntent?.retrieval ?? { + tier1: true, + tier2: true, + tier3: true, + }, + llmModel: maybeIntent?.llmModel, + signals: maybeIntent?.signals ?? ["startup_recovery"], + }; + } + + function debugStartupRecovery( + hypothesisId: string, + message: string, + data: Record, + ): void {} + + async function shutdown(): Promise { + if (shutDown) return; + shutDown = true; + try { + await handle.shutdown("memory-core.shutdown"); + } finally { + if (telemetry) { + await telemetry.shutdown(); + } + if (options.onShutdown) { + await options.onShutdown(); + } + } + } + + async function health(): Promise { + // Read the latest on-disk config so that model names reflect what + // the user last saved, even before a restart applies the change. + let diskConfig: ResolvedConfig | null = null; + try { + const { loadConfig } = await import("../config/index.js"); + const { config } = await loadConfig(handle.home); + diskConfig = config; + } catch { + /* fall through to in-memory */ + } + + const llmInfo = llmHealth(handle.llm, latestTraceTs()); + const embedderInfo = embedderHealth(handle.embedder, latestTraceTs()); + const skillEvolverInfo = resolveSkillEvolver( + diskConfig ?? handle.config, + // Prefer the dedicated reflect LLM stats so an independently + // configured skill-evolver model reports its OWN failures + // instead of inheriting the (possibly healthy) summary LLM's + // status. Falls back to `handle.llm` when the operator left + // skillEvolver blank — bootstrap aliases reflectLlm to llm + // in that case anyway. + handle.reflectLlm ?? handle.llm, + latestTraceTs(), + ); + + // NOTE: we deliberately do NOT fall back to `api_logs`-stored + // historical `system_error` rows here. Doing so used to keep the + // overview card red across restarts even after the operator had + // already fixed the misconfigured endpoint, because the ancient + // failure row would mask a freshly-booted process whose stats + // are still null. Now the card colour is driven purely by + // in-memory stats — if you want to inspect past failures, head + // to LogsView → 系统 tag. + + // Override model names from disk config if they differ from the + // in-memory client (user saved new settings but hasn't restarted). + if (diskConfig) { + const diskLlm = diskConfig.llm as { model?: string; provider?: string } | undefined; + if (diskLlm?.model && diskLlm.model !== llmInfo.model) { + llmInfo.model = diskLlm.model; + if (diskLlm.provider) llmInfo.provider = diskLlm.provider; + } + const diskEmb = diskConfig.embedding as { model?: string; provider?: string } | undefined; + if (diskEmb?.model && diskEmb.model !== embedderInfo.model) { + embedderInfo.model = diskEmb.model; + if (diskEmb.provider) embedderInfo.provider = diskEmb.provider; + } + } + + applyPersistedModelStatus(handle.repos, "llm", llmInfo); + applyPersistedModelStatus(handle.repos, "embedding", embedderInfo); + applyPersistedModelStatus( + handle.repos, + skillEvolverInfo.inherited ? "llm" : "skillEvolver", + skillEvolverInfo, + ); + + return { + ok: initialized && !shutDown, + version: pkgVersion, + uptimeMs: Date.now() - bootAt, + agent: handle.agent, + namespace: activeNamespace, + paths: { + home: home.root, + config: home.configFile, + db: home.dbFile, + skills: home.skillsDir, + logs: home.logsDir, + }, + llm: llmInfo, + embedder: embedderInfo, + skillEvolver: skillEvolverInfo, + }; + } + + function latestTraceTs(): number | null { + try { + const rows = handle.repos.traces.list({ limit: 1 }); + if (rows.length === 0) return null; + return rows[0]?.ts ?? null; + } catch { + return null; + } + } + + // ─── Session / episode ── + async function openSession(input: { + agent: AgentKind; + sessionId?: SessionId; + meta?: Record; + namespace?: RuntimeNamespace; + }): Promise { + ensureLive(); + const { meta } = withNamespaceMeta(input.agent, input.meta, input.namespace); + activeNamespace = namespaceFromHints(input.agent, meta, input.namespace ?? activeNamespace); + const snap = handle.sessionManager.openSession({ + id: input.sessionId, + agent: input.agent, + meta, + }); + return snap.id as SessionId; + } + + async function closeSession(sessionId: SessionId): Promise { + ensureLive(); + const existing = handle.sessionManager.getSession(sessionId); + if (!existing) { + throw new MemosError( + "session_not_found", + `session not found: ${sessionId}`, + ); + } + handle.sessionManager.closeSession(sessionId, "client"); + try { + await handle.flush(); + } catch (err) { + log.warn("closeSession.flush_failed", { + sessionId, + err: err instanceof Error ? err.message : String(err), + }); + } + } + + async function openEpisode(input: { + sessionId: SessionId; + episodeId?: EpisodeId; + /** + * Optional initial user text — when an adapter opens an episode + * eagerly (outside the normal `onTurnStart` flow) it may not have + * the user's message yet. Pass it when you do; otherwise the core + * uses a placeholder so the downstream `episode-manager.start` + * invariant holds. + */ + userMessage?: string; + }): Promise { + ensureLive(); + const snap = await handle.sessionManager.startEpisode({ + sessionId: input.sessionId, + userMessage: input.userMessage?.trim() || "(adapter-initiated)", + meta: input.episodeId ? { adapterSuppliedId: input.episodeId } : {}, + }); + return snap.id as EpisodeId; + } + + async function closeEpisode(episodeId: EpisodeId): Promise { + ensureLive(); + const snap = handle.sessionManager.getEpisode(episodeId); + if (!snap) { + throw new MemosError( + "episode_not_found", + `episode not found: ${episodeId}`, + ); + } + if (snap.status === "closed") return; + handle.sessionManager.finalizeEpisode(episodeId); + // For adapters whose process exits right after `episode.close` + // (e.g. Hermes' single-shot `hermes chat -q ...` mode), the + // background reflect → reward → L2 / L3 / Skill chain wouldn't + // get a chance to run before the process is reaped. Block the RPC + // here so the caller can be sure the full chain has flushed by + // the time `episode.close` returns. The cost is a few seconds of + // extra latency on the close call — but the chat is already done + // at this point, so the user doesn't wait on it. + try { + await handle.flush(); + } catch (err) { + log.warn("closeEpisode.flush_failed", { + episodeId, + err: err instanceof Error ? err.message : String(err), + }); + } + } + + // ─── Pipeline (per turn) ── + async function onTurnStart( + turn: Parameters[0], + ): Promise { + ensureLive(); + const startedAt = Date.now(); + let ok = true; + let packet: Awaited> | null = null; + const ns = namespaceFor(turn.agent, turn); + activeNamespace = ns; + const namespacedTurn = { + ...turn, + namespace: ns, + contextHints: { + ...(turn.contextHints ?? {}), + ...namespaceMeta(ns), + }, + }; + try { + packet = await handle.onTurnStart(namespacedTurn); + + // The orchestrator stamps the *routed* session / episode id onto the + // packet (V7 §0.1 may create, reopen, or migrate to a new session), + // so we surface those back to the caller. Adapters correlate + // `onTurnEnd` to the same ids via `query.sessionId` / + // `query.episodeId`, instead of having to keep their own cache. + const query: RetrievalQueryDTO = { + agent: turn.agent, + namespace: ns, + sessionId: packet.sessionId, + episodeId: packet.episodeId, + query: turn.userText, + }; + const hits: RetrievalHitDTO[] = packet.snippets.map((snip) => { + const tier: 1 | 2 | 3 = inferTier(snip.refKind); + return { + tier, + refId: snip.refId, + refKind: + snip.refKind === "preference" || snip.refKind === "anti-pattern" + ? "trace" + : snip.refKind, + score: snip.score ?? 0, + snippet: snip.body, + }; + }); + return { + query, + hits, + injectedContext: packet.rendered, + tierLatencyMs: packet.tierLatencyMs, + }; + } catch (err) { + ok = false; + throw err; + } finally { + // Log every retrieval — not just adhoc `searchMemory` calls — + // so the viewer's Logs page can show what was recalled for + // each real agent turn. Without this, `memory_search` rows + // only showed up when the viewer's search box was used. + try { + const snippets = packet?.snippets ?? []; + const candidates = snippets.map((s) => ({ + tier: inferTier(s.refKind), + refKind: s.refKind, + refId: s.refId, + score: s.score ?? 0, + snippet: s.body, + })); + const droppedIds = new Set( + (packet?.droppedByLlm ?? []).map((s) => s.refId as string), + ); + const filtered = candidates.filter((c) => !droppedIds.has(c.refId)); + const dropped = candidates.filter((c) => droppedIds.has(c.refId)); + const stats = packet ? handle.consumeRetrievalStats(packet.packetId) : null; + handle.repos.apiLogs.insert({ + toolName: "memory_search", + input: { + type: "turn_start", + agent: turn.agent, + query: turn.userText.slice(0, 2_000), + sessionId: packet?.sessionId ?? turn.sessionId ?? null, + episodeId: packet?.episodeId ?? turn.episodeId ?? null, + }, + output: ok + ? { + candidates, + hubCandidates: [] as unknown[], + filtered, + droppedByLlm: dropped, + stats: stats ? retrievalStatsPayload(stats) : undefined, + } + : { error: "turn_start_retrieval_failed" }, + durationMs: Date.now() - startedAt, + success: ok, + calledAt: startedAt, + }); + } catch (logErr) { + log.debug("apiLogs.memory_search.turn_start.skipped", { + err: logErr instanceof Error ? logErr.message : String(logErr), + }); + } + if (telemetry && ok) { + telemetry.trackTurnStart( + turn.agent, + Date.now() - startedAt, + packet?.snippets?.length ?? 0, + ); + } + } + } + + async function onTurnEnd( + result: Parameters[0], + ): Promise<{ traceId: string; episodeId: EpisodeId }> { + ensureLive(); + const ns = namespaceFor(result.agent, result); + activeNamespace = ns; + const outcome = await handle.onTurnEnd({ + ...result, + namespace: ns, + contextHints: { + ...(result.contextHints ?? {}), + ...namespaceMeta(ns), + }, + }); + const traceIds = outcome.traceIds.length > 0 + ? outcome.traceIds + : outcome.episode?.traceIds ?? []; + const lastTraceId = traceIds[traceIds.length - 1] ?? ""; + if (telemetry) { + telemetry.trackTurnEnd(result.agent, traceIds.length); + } + return { + traceId: lastTraceId, + episodeId: outcome.episodeId, + }; + } + + async function submitFeedback( + feedback: Omit & { ts?: number }, + ): Promise { + ensureLive(); + const targetTrace = feedback.traceId + ? handle.repos.traces.getById(feedback.traceId as TraceId) + : null; + if (feedback.traceId && !targetTrace) { + throw new MemosError( + "trace_not_found", + `trace not found: ${feedback.traceId}`, + { traceId: feedback.traceId }, + ); + } + const ts = feedback.ts ?? Date.now(); + const id = randomUUID(); + const row: FeedbackRow = { + id, + ...ownerFromNamespace(handle.namespace), + ts, + episodeId: feedback.episodeId ?? null, + traceId: feedback.traceId ?? null, + channel: feedback.channel, + polarity: feedback.polarity, + magnitude: feedback.magnitude, + rationale: feedback.rationale ?? null, + raw: feedback.raw ?? null, + }; + handle.db.tx(() => { + handle.repos.feedback.insert(row); + if (targetTrace) { + const explicitValue = aggregateTraceFeedbackValue( + handle.repos.feedback.getForTrace(targetTrace.id), + ); + handle.repos.traces.updateScore(targetTrace.id, { + value: explicitValue, + alpha: targetTrace.alpha, + rHuman: explicitValue, + priority: Math.max(targetTrace.priority, Math.abs(explicitValue)), + }); + } + }); + + const episode = row.episodeId + ? handle.repos.episodes.getById(row.episodeId as EpisodeId) + : null; + const trace = row.traceId + ? handle.repos.traces.getById(row.traceId as TraceId) + : null; + const sessionId = episode?.sessionId ?? trace?.sessionId ?? null; + const text = feedbackText(row); + + if (episode && sessionId) { + const rewardFeedback: UserFeedback = { + id: row.id as UserFeedback["id"], + episodeId: episode.id, + sessionId, + traceId: row.traceId as TraceId | null, + ts: row.ts, + channel: row.channel, + polarity: row.polarity, + magnitude: row.magnitude, + text: text || null, + rationale: row.rationale, + }; + try { + await handle.rewardRunner.run({ + episodeId: episode.id, + feedback: [rewardFeedback], + trigger: "explicit_feedback", + }); + } catch (err) { + log.warn("feedback.reward_failed", { + episodeId: episode.id, + err: err instanceof Error ? err.message : String(err), + }); + } + } + + if (text && sessionId) { + try { + await handle.feedback.submitUserFeedback({ + text, + sessionId, + episodeId: episode?.id, + context: text.slice(0, 300), + }); + } catch (err) { + log.warn("feedback.repair_failed", { + episodeId: episode?.id, + err: err instanceof Error ? err.message : String(err), + }); + } + } + + let policyId: PolicyId | undefined; + try { + const experience = await runFeedbackExperience( + { feedback: row, episode, trace }, + { + repos: handle.repos, + embedder: handle.embedder, + llm: handle.llm ?? undefined, + namespace: handle.namespace, + now: Date.now, + }, + ); + policyId = experience.policyId; + } catch (err) { + log.warn("feedback.experience_failed", { + episodeId: episode?.id, + err: err instanceof Error ? err.message : String(err), + }); + } + + try { + await handle.l2.drain(); + if (policyId) { + await handle.skills.runOnce({ trigger: "manual", policyId }); + } + if (episode) { + await handle.l3.runOnce({ trigger: "manual", episodeId: episode.id }); + } + await handle.skills.flush(); + await handle.feedback.flush(); + await handle.l3.drain(); + } catch (err) { + log.warn("feedback.downstream_flush_failed", { + episodeId: episode?.id, + policyId, + err: err instanceof Error ? err.message : String(err), + }); + } + + if (telemetry) { + telemetry.trackFeedback( + handle.namespace.agentKind, + feedback.polarity, + ); + } + return toFeedbackDTO(row); + } + + function aggregateTraceFeedbackValue(rows: readonly FeedbackRow[]): number { + if (rows.length === 0) return 0; + let sum = 0; + let weight = 0; + for (const feedbackRow of rows) { + const magnitude = clamp01(feedbackRow.magnitude); + if (magnitude === 0 || feedbackRow.polarity === "neutral") continue; + const signed = feedbackRow.polarity === "positive" ? magnitude : -magnitude; + sum += signed; + weight += magnitude; + } + if (weight === 0) return 0; + return clampSigned(sum / weight); + } + + function recordToolOutcome(outcome: { + sessionId: SessionId; + episodeId?: EpisodeId; + tool: string; + success: boolean; + errorCode?: string; + durationMs: number; + ts: number; + }): void { + if (shutDown) return; + const key = outcome.episodeId ?? outcome.sessionId; + const step = (toolStepByEpisode.get(key) ?? 0) + 1; + toolStepByEpisode.set(key, step); + try { + handle.recordToolOutcome({ + sessionId: outcome.sessionId, + episodeId: outcome.episodeId, + tool: outcome.tool, + step, + success: outcome.success, + errorCode: outcome.errorCode, + context: outcome.sessionId, + ts: outcome.ts, + }); + } catch (err) { + log.warn("memory-core.record_tool_outcome.failed", { + err: err instanceof Error ? err.message : String(err), + }); + } + } + + async function recordSubagentOutcome( + outcome: SubagentOutcomeDTO, + ): Promise<{ traceId: string; episodeId: EpisodeId }> { + ensureLive(); + const ts = outcome.ts ?? Date.now(); + const task = outcome.task.trim() || "(subagent task)"; + const result = outcome.result.trim() || outcome.error || outcome.outcome || "(no subagent result)"; + const normalizedOutcome = outcome.outcome ?? (outcome.error ? "error" : "unknown"); + const childToolCalls = outcome.toolCalls ?? []; + + const ns = namespaceFor(outcome.agent, outcome); + await openSession({ agent: outcome.agent, sessionId: outcome.sessionId, namespace: ns }); + const recorded = await onTurnEnd({ + agent: outcome.agent, + namespace: ns, + sessionId: outcome.sessionId, + episodeId: outcome.episodeId ?? ("" as EpisodeId), + agentText: `Subagent task: ${task}\n\nSubagent result: ${result}`, + toolCalls: [ + { + name: "subagent", + input: { + task, + childSessionId: outcome.childSessionId ?? null, + childToolCalls: childToolCalls.length, + outcome: normalizedOutcome, + meta: outcome.meta ?? {}, + }, + output: { + result, + error: outcome.error ?? null, + }, + errorCode: + outcome.error || (normalizedOutcome !== "ok" && normalizedOutcome !== "unknown") + ? normalizedOutcome + : undefined, + startedAt: ts, + endedAt: ts, + }, + ], + ts, + }); + const anchor = { + task, + result, + childSessionId: outcome.childSessionId ?? null, + traceId: recorded.traceId as TraceId, + meta: outcome.meta ?? {}, + }; + anchorSubagentTraceAfterDelegate(recorded.episodeId, anchor); + + let childRecorded: { traceId: string; episodeId: EpisodeId } | null = null; + const childSessionId = outcome.childSessionId ?? null; + if (childSessionId && childSessionId !== outcome.sessionId) { + const childHasEpisode = handle.repos.episodes + .list({ sessionId: childSessionId, limit: 1 }) + .length > 0; + if (!childHasEpisode) { + try { + await openSession({ agent: outcome.agent, sessionId: childSessionId }); + const childTurn = await onTurnStart({ + agent: outcome.agent, + sessionId: childSessionId, + userText: `Subagent task: ${task}`, + ts, + }); + const childEpisodeId = childTurn.query.episodeId; + if (!childEpisodeId) { + throw new Error("child turn.start did not return an episodeId"); + } + childRecorded = await onTurnEnd({ + agent: outcome.agent, + sessionId: childSessionId, + episodeId: childEpisodeId, + agentText: `Subagent result: ${result}`, + toolCalls: childToolCalls, + ts: ts + 1, + }); + await closeEpisode(childEpisodeId); + } catch (err) { + log.warn("subagent.child_episode.create_failed", { + childSessionId, + parentSessionId: outcome.sessionId, + err: err instanceof Error ? err.message : String(err), + }); + } + } + } + anchorSubagentTraceAfterDelegate(recorded.episodeId, anchor); + + try { + handle.repos.apiLogs.insert({ + toolName: "subagent_record", + input: { + agent: outcome.agent, + sessionId: outcome.sessionId, + episodeId: outcome.episodeId ?? null, + childSessionId: outcome.childSessionId ?? null, + task, + childToolCalls: childToolCalls.length, + outcome: normalizedOutcome, + meta: outcome.meta ?? {}, + }, + output: { + result, + error: outcome.error ?? null, + traceId: recorded.traceId, + episodeId: recorded.episodeId, + childTraceId: childRecorded?.traceId ?? null, + childEpisodeId: childRecorded?.episodeId ?? null, + }, + durationMs: 0, + success: !outcome.error && normalizedOutcome !== "error", + calledAt: ts, + }); + } catch (err) { + log.debug("apiLogs.subagent_record.skipped", { + err: err instanceof Error ? err.message : String(err), + }); + } + + return recorded; + } + + function anchorSubagentTraceAfterDelegate( + episodeId: EpisodeId, + anchor: { + task: string; + result: string; + childSessionId: SessionId | null; + traceId: TraceId; + meta: Record; + }, + ): void { + const episode = handle.repos.episodes.getById(episodeId); + if (!episode) return; + const rows = episode.traceIds.length > 0 + ? handle.repos.traces.getManyByIds(episode.traceIds) + : handle.repos.traces.list({ episodeId, limit: 500, newestFirst: false }); + const byId = new Map(rows.map((row) => [row.id, row])); + const ordered = ( + episode.traceIds.length > 0 ? episode.traceIds : rows.map((row) => row.id) + ).filter((id) => byId.has(id)); + const synthetic = ordered.filter((id) => + isMatchingSubagentTrace(byId.get(id)!, anchor), + ); + if (synthetic.length === 0) return; + const delegateId = findMatchingDelegateTaskTrace(ordered.map((id) => byId.get(id)!), anchor); + if (!delegateId) return; + moveUserTextToAnchoredDelegate(byId, delegateId, synthetic); + + const withoutSynthetic = ordered.filter((id) => !synthetic.includes(id)); + const delegateIdx = withoutSynthetic.indexOf(delegateId); + if (delegateIdx < 0) return; + const next = [ + ...withoutSynthetic.slice(0, delegateIdx + 1), + ...synthetic, + ...withoutSynthetic.slice(delegateIdx + 1), + ]; + if (next.join("\0") !== episode.traceIds.join("\0")) { + handle.repos.episodes.appendTrace(episodeId, next); + } + } + + function moveUserTextToAnchoredDelegate( + byId: Map, + delegateId: TraceId, + syntheticIds: string[], + ): void { + const delegate = byId.get(delegateId); + if (!delegate || delegate.userText.trim()) return; + const source = syntheticIds + .map((id) => byId.get(id)) + .find((row): row is TraceRow => + Boolean(row && row.turnId === delegate.turnId && row.userText.trim()), + ); + if (!source) return; + handle.repos.traces.updateBody(delegateId, { userText: source.userText }); + handle.repos.traces.updateBody(source.id, { userText: "" }); + delegate.userText = source.userText; + source.userText = ""; + } + + function isMatchingSubagentTrace( + row: TraceRow, + anchor: { + task: string; + result: string; + childSessionId: SessionId | null; + traceId: TraceId; + meta: Record; + }, + ): boolean { + if (row.id === anchor.traceId) return true; + if (row.agentText.includes(`Subagent task: ${anchor.task}`)) return true; + if (row.agentText.includes(`Subagent result: ${anchor.result}`)) return true; + const tool = row.toolCalls[0]; + if (!tool || tool.name !== "subagent") return false; + const input = asRecord(tool.input); + if (!input) return false; + if (typeof input.task === "string" && input.task === anchor.task) return true; + return anchor.childSessionId != null && input.childSessionId === anchor.childSessionId; + } + + function findMatchingDelegateTaskTrace( + rows: TraceRow[], + anchor: { task: string; meta: Record }, + ): TraceId | null { + const anchorToolCallId = subagentAnchorToolCallId(anchor.meta); + if (anchorToolCallId) { + const byId = rows.find((row) => delegateToolCallIdMatches(row, anchorToolCallId)); + if (byId) return byId.id; + } + + // Deterministic fallback for Hermes versions whose `on_delegation` + // hook does not expose tool_call_id: only anchor when exactly one + // delegate_task goal equals the subagent task. + const matches = rows.filter((row) => delegateTaskGoal(row) === anchor.task); + return matches.length === 1 ? matches[0]!.id : null; + } + + function delegateToolCallIdMatches(row: TraceRow, anchorToolCallId: string): boolean { + const tool = row.toolCalls[0]; + if (!tool || tool.name !== "delegate_task") return false; + if (tool.toolCallId === anchorToolCallId) return true; + + const input = parseMaybeJsonObject(tool.input); + if (!input) return false; + const inputCallId = firstString( + input.toolCallId, + input.tool_call_id, + input.callId, + input.call_id, + ); + return inputCallId === anchorToolCallId; + } + + function delegateTaskGoal(row: TraceRow): string | null { + const tool = row.toolCalls[0]; + if (!tool || tool.name !== "delegate_task") return null; + const input = parseMaybeJsonObject(tool.input); + if (!input) return null; + return firstString(input.goal); + } + + function subagentAnchorToolCallId(meta: Record): string | null { + const hookKwargs = asRecord(meta.hookKwargs) ?? {}; + return firstString( + meta.toolCallId, + meta.tool_call_id, + meta.callId, + meta.call_id, + hookKwargs.toolCallId, + hookKwargs.tool_call_id, + hookKwargs.callId, + hookKwargs.call_id, + ); + } + + function parseMaybeJsonObject(value: unknown): Record | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + if (typeof value !== "string") return null; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : null; + } catch { + return null; + } + } + + function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; + } + + function firstString(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; + } + + // ─── Memory queries ── + async function searchMemory( + query: RetrievalQueryDTO, + ): Promise { + ensureLive(); + const ns = query.namespace ?? activeNamespace; + activeNamespace = ns; + const baseDeps = handle.retrievalDeps(); + const deps = { + ...baseDeps, + config: applyTopKOverride(baseDeps.config, query.topK), + namespace: ns, + repos: wrapRetrievalRepos(handle.repos, ns), + }; + const { turnStartRetrieve } = await import("../retrieval/retrieve.js"); + const sessionId = + query.sessionId ?? + ("adhoc-session-" + randomUUID().slice(0, 8) as SessionId); + const ts = Date.now(); + const startedAt = Date.now(); + let ok = true; + let candidates: Array<{ + tier: number; + refKind: string; + refId: string; + score: number; + snippet: string; + }> = []; + let filtered: typeof candidates = []; + let retrievalStats: { + raw?: number; + ranked?: number; + droppedByThreshold?: number; + thresholdFloor?: number; + topRelevance?: number; + llmFilter?: { + outcome?: string; + kept?: number; + dropped?: number; + sufficient?: boolean | null; + }; + channelHits?: Record; + queryTokens?: number; + queryTags?: string[]; + embedding?: { + attempted: boolean; + ok: boolean; + degraded: boolean; + errorCode?: string; + errorMessage?: string; + }; + } | undefined; + try { + const result = await turnStartRetrieve(deps, { + reason: "turn_start", + agent: query.agent, + namespace: ns, + sessionId, + episodeId: query.episodeId, + userText: query.query, + contextHints: query.filters ?? {}, + ts, + }); + let hits: RetrievalHitDTO[] = result.packet.snippets.map((snip) => ({ + tier: inferTier(snip.refKind), + refId: snip.refId, + refKind: + snip.refKind === "preference" || snip.refKind === "anti-pattern" + ? "trace" + : snip.refKind, + score: snip.score ?? 0, + snippet: snip.body, + })); + + // Per-tier truncation: the retrieval pipeline's ranker limit is + // the SUM of per-tier topK, but callers expect each tier's topK + // to cap that tier's contribution. Without this, merged results + // can exceed the caller's expected total. + if (query.topK) { + const tierCaps: Partial> = {}; + if (query.topK.tier1 !== undefined) tierCaps[1] = query.topK.tier1; + if (query.topK.tier2 !== undefined) tierCaps[2] = query.topK.tier2; + if (query.topK.tier3 !== undefined) tierCaps[3] = query.topK.tier3; + const tierCounts: Record = {}; + hits = hits.filter((h) => { + const cap = tierCaps[h.tier]; + if (cap === undefined) return true; + const count = tierCounts[h.tier] ?? 0; + if (count >= cap) return false; + tierCounts[h.tier] = count + 1; + return true; + }); + } + + // Build the logs-page payload BEFORE returning so the row + // reflects the exact shape the adapter sees. `candidates` lists + // everything tiered/retrieved; `filtered` is what the injector + // kept (≤ `maxSnippets`), matching the legacy "LLM filtered" + // semantics the user complained about. + candidates = hits.map((h) => ({ + tier: h.tier, + refKind: h.refKind, + refId: h.refId, + score: h.score, + snippet: h.snippet, + })); + filtered = candidates; // post-filter is what we return → same list. + + // Three-stage observability — surfaced verbatim so the viewer's + // Logs page can render "raw → threshold → ranked → LLM filter" + // funnels. All fields are optional on the producer side so older + // consumers keep working. + const s = result.stats; + retrievalStats = retrievalStatsPayload(s); + if (s.embedding?.degraded) { + handle.repos.apiLogs.insert({ + toolName: "system_error", + input: { role: "embedding" }, + output: { + role: "embedding", + provider: deps.embedder ? "retrieval" : "none", + model: "query", + message: s.embedding.errorMessage ?? "query embedding failed; retrieval degraded", + code: s.embedding.errorCode, + }, + durationMs: 0, + success: false, + calledAt: Date.now(), + }); + } + + // Record usage for skills actually surfaced into the prompt so + // usage_count reflects real retrieval. Previously only an explicit + // getSkill(recordUse:true) bumped it, leaving ~all skills at 0 and + // making the whole skill layer look dead. Best-effort; never blocks. + for (const h of hits) { + if (h.refKind === "skill" && h.refId) { + try { handle.repos.skills.recordUse(h.refId, Date.now()); } catch { /* non-fatal */ } + } + } + + return { + query, + hits, + injectedContext: result.packet.rendered, + tierLatencyMs: result.packet.tierLatencyMs, + }; + } catch (err) { + ok = false; + throw err; + } finally { + try { + handle.repos.apiLogs.insert({ + toolName: "memory_search", + input: { + type: "tool_call", + agent: query.agent, + query: query.query, + sessionId, + episodeId: query.episodeId ?? null, + topK: query.topK, + }, + output: ok + ? { + candidates, + hubCandidates: [] as unknown[], + filtered, + stats: retrievalStats, + } + : { error: "retrieval_failed" }, + durationMs: Date.now() - startedAt, + success: ok, + calledAt: startedAt, + }); + } catch (logErr) { + log.debug("apiLogs.memory_search.skipped", { + err: logErr instanceof Error ? logErr.message : String(logErr), + }); + } + if (telemetry && ok) { + telemetry.trackMemorySearch( + query.agent, + Date.now() - startedAt, + candidates.length, + ); + } + } + } + + async function getTrace(id: string, namespace?: RuntimeNamespace): Promise { + ensureLive(); + if (namespace) activeNamespace = namespace; + const row = handle.repos.traces.getById(id); + return row && visibleToCurrent(row) ? traceRowToDTO(row, handle.repos.episodes.getById(row.episodeId)) : null; + } + + async function updateTrace( + id: string, + patch: { + summary?: string | null; + userText?: string; + agentText?: string; + tags?: readonly string[]; + }, + ): Promise { + ensureLive(); + const existing = handle.repos.traces.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + handle.repos.traces.updateBody(id, patch); + const updated = handle.repos.traces.getById(id); + return updated + ? traceRowToDTO(updated, handle.repos.episodes.getById(updated.episodeId)) + : null; + } + + async function deleteTrace(id: string): Promise<{ deleted: boolean }> { + ensureLive(); + const existing = handle.repos.traces.getById(id); + if (!existing || !ownedByCurrent(existing)) return { deleted: false }; + handle.db.tx(() => { + handle.repos.episodes.removeTraceIds(existing.episodeId, [id]); + handle.repos.traces.deleteById(id); + }); + return { deleted: true }; + } + + async function deleteTraces(ids: readonly string[]): Promise<{ deleted: number }> { + ensureLive(); + let deleted = 0; + // Process one-by-one so a bad id doesn't poison the whole batch. + // The viewer's bulk delete is low-frequency (dozens at a time). + for (const id of ids) { + const existing = handle.repos.traces.getById(id); + if (!existing || !ownedByCurrent(existing)) continue; + handle.db.tx(() => { + handle.repos.episodes.removeTraceIds(existing.episodeId, [id]); + handle.repos.traces.deleteById(id); + }); + deleted++; + } + return { deleted }; + } + + async function shareTrace( + id: string, + share: { + scope: "private" | "local" | "public" | "hub" | null; + target?: string | null; + sharedAt?: number | null; + }, + ): Promise { + ensureLive(); + const existing = handle.repos.traces.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + handle.repos.traces.updateShare(id, share); + const updated = handle.repos.traces.getById(id); + return updated + ? traceRowToDTO(updated, handle.repos.episodes.getById(updated.episodeId)) + : null; + } + + async function getPolicy(id: string, namespace?: RuntimeNamespace): Promise { + ensureLive(); + if (namespace) activeNamespace = namespace; + const row = handle.repos.policies.getById(id); + return row && visibleToCurrent(row) ? policyRowToDTO(row) : null; + } + + async function listPolicies(input?: { + status?: PolicyDTO["status"]; + limit?: number; + offset?: number; + q?: string; + }): 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 rows = handle.repos.policies.list({ + status: input?.status, + limit: limit + offset + (needle ? 200 : 0), + offset: 0, + }); + const visibleRows = rows.filter((r) => visibleToCurrent(r)); + const filtered = needle + ? visibleRows.filter((r) => + (r.title + "\n" + r.trigger + "\n" + r.procedure) + .toLowerCase() + .includes(needle), + ) + : visibleRows; + return filtered.slice(offset, offset + limit).map(policyRowToDTO); + } + + async function countPolicies(input?: { + status?: PolicyDTO["status"]; + q?: string; + }): 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; + } + // 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)); + return rows.filter((r) => + (r.title + "\n" + r.trigger + "\n" + r.procedure) + .toLowerCase() + .includes(needle), + ).length; + } + + async function setPolicyStatus( + id: string, + status: PolicyDTO["status"], + ): Promise { + ensureLive(); + const existing = handle.repos.policies.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + handle.repos.policies.upsert({ ...existing, status, updatedAt: Date.now() }); + const updated = handle.repos.policies.getById(id); + return updated ? policyRowToDTO(updated) : null; + } + + async function deletePolicy(id: string): Promise<{ deleted: boolean }> { + ensureLive(); + const existing = handle.repos.policies.getById(id); + if (!existing || !ownedByCurrent(existing)) return { deleted: false }; + handle.repos.policies.deleteById(id); + return { deleted: true }; + } + + async function editPolicyGuidance( + id: string, + patch: { preference?: string[]; antiPattern?: string[] }, + ): Promise { + ensureLive(); + const existing = handle.repos.policies.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + const current = existing.decisionGuidance; + const nextPref = dedupeStrings([ + ...current.preference, + ...(patch.preference ?? []), + ]); + const nextAvoid = dedupeStrings([ + ...current.antiPattern, + ...(patch.antiPattern ?? []), + ]); + if ( + nextPref.length === current.preference.length && + nextAvoid.length === current.antiPattern.length + ) { + return policyRowToDTO(existing); + } + handle.repos.policies.upsert({ + ...existing, + decisionGuidance: { preference: nextPref, antiPattern: nextAvoid }, + updatedAt: Date.now(), + }); + const updated = handle.repos.policies.getById(id); + return updated ? policyRowToDTO(updated) : null; + } + + async function getWorldModel(id: string, namespace?: RuntimeNamespace): Promise { + ensureLive(); + if (namespace) activeNamespace = namespace; + const row = handle.repos.worldModel.getById(id); + return row && visibleToCurrent(row) ? worldModelRowToDTO(row) : null; + } + + async function countWorldModels(input?: { q?: string }): Promise { + ensureLive(); + const needle = (input?.q ?? "").trim().toLowerCase(); + const rows = handle.repos.worldModel.list({ limit: 100_000 }).filter((r) => visibleToCurrent(r)); + if (!needle) return rows.length; + return rows.filter((r) => + (r.title + "\n" + r.body).toLowerCase().includes(needle), + ).length; + } + + async function listWorldModels(input?: { + limit?: number; + offset?: number; + q?: string; + namespace?: RuntimeNamespace; + }): 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 rows = handle.repos.worldModel.list({ + limit: limit + offset + (needle ? 200 : 0), + offset: 0, + }); + const visibleRows = rows.filter((r) => visibleToCurrent(r)); + const filtered = needle + ? visibleRows.filter((r) => + (r.title + "\n" + r.body).toLowerCase().includes(needle), + ) + : visibleRows; + return filtered.slice(offset, offset + limit).map(worldModelRowToDTO); + } + + async function deleteWorldModel(id: string): Promise<{ deleted: boolean }> { + ensureLive(); + const existing = handle.repos.worldModel.getById(id); + if (!existing || !ownedByCurrent(existing)) return { deleted: false }; + handle.repos.worldModel.deleteById(id); + return { deleted: true }; + } + + async function sharePolicy( + id: string, + share: { + scope: "private" | "local" | "public" | "hub" | null; + target?: string | null; + sharedAt?: number | null; + }, + ): Promise { + ensureLive(); + const existing = handle.repos.policies.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + handle.repos.policies.updateShare(id, share); + const updated = handle.repos.policies.getById(id); + return updated ? policyRowToDTO(updated) : null; + } + + async function shareWorldModel( + id: string, + share: { + scope: "private" | "local" | "public" | "hub" | null; + target?: string | null; + sharedAt?: number | null; + }, + ): Promise { + ensureLive(); + const existing = handle.repos.worldModel.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + handle.repos.worldModel.updateShare(id, share); + const updated = handle.repos.worldModel.getById(id); + return updated ? worldModelRowToDTO(updated) : null; + } + + async function updatePolicy( + id: string, + patch: { + title?: string; + trigger?: string; + procedure?: string; + verification?: string; + boundary?: string; + }, + ): Promise { + ensureLive(); + const existing = handle.repos.policies.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + handle.repos.policies.updateContent(id, patch); + const updated = handle.repos.policies.getById(id); + return updated ? policyRowToDTO(updated) : null; + } + + async function updateWorldModel( + id: string, + patch: { title?: string; body?: string; status?: "active" | "archived" }, + ): Promise { + ensureLive(); + const existing = handle.repos.worldModel.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + if (patch.title !== undefined || patch.body !== undefined) { + handle.repos.worldModel.updateContent(id, { + title: patch.title, + body: patch.body, + }); + } + if (patch.status !== undefined && patch.status !== existing.status) { + handle.repos.worldModel.setStatus(id, patch.status, Date.now()); + } + const updated = handle.repos.worldModel.getById(id); + return updated ? worldModelRowToDTO(updated) : null; + } + + async function archiveWorldModel(id: string): Promise { + ensureLive(); + const existing = handle.repos.worldModel.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + if (existing.status !== "archived") { + handle.repos.worldModel.setStatus(id, "archived", Date.now()); + } + const updated = handle.repos.worldModel.getById(id); + return updated ? worldModelRowToDTO(updated) : null; + } + + async function unarchiveWorldModel(id: string): Promise { + ensureLive(); + const existing = handle.repos.worldModel.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + if (existing.status === "archived") { + handle.repos.worldModel.setStatus(id, "active", Date.now()); + } + const updated = handle.repos.worldModel.getById(id); + return updated ? worldModelRowToDTO(updated) : null; + } + + async function listEpisodes(input: { + sessionId?: SessionId; + limit?: number; + offset?: number; + }): Promise { + ensureLive(); + const rows = handle.repos.episodes.list({ + sessionId: input.sessionId, + limit: input.limit ?? 50, + offset: input.offset ?? 0, + }); + return rows.filter((r: EpisodeRow) => visibleToCurrent(r)).map((r: EpisodeRow) => r.id as EpisodeId); + } + + async function countEpisodes(input?: { + sessionId?: SessionId; + }): Promise { + ensureLive(); + return handle.repos.episodes.list({ sessionId: input?.sessionId, limit: 100_000 }).filter((r) => visibleToCurrent(r)).length; + } + + async function listEpisodeRows(input?: { + sessionId?: SessionId; + limit?: number; + offset?: number; + }): Promise extends unknown[] ? Awaited> : never> { + ensureLive(); + + // Keep list queries read-only. Startup recovery still handles stale + // open topics and dirty closed episodes during init; viewer refreshes + // should not trigger finalize/reflect/reward side effects. + // await autoFinalizeStaleTasks(); + // await autoRescoreDirtyClosedEpisodes(); + + const rows = handle.repos.episodes.list({ + sessionId: input?.sessionId, + limit: input?.limit ?? 50, + offset: input?.offset ?? 0, + }).filter((r) => visibleToCurrent(r)); + + // Build reverse indexes for the skill-status derivation. Rebuilt + // per call rather than cached because the base table volumes are + // small (policies + skills each ≤ ~1 k rows in practice). This + // mirrors the legacy `tasks.skill_status` field the user was + // missing in the Tasks view. + const allPolicies = handle.repos.policies.list({ limit: 5_000 }); + const allSkills = handle.repos.skills.list({ limit: 5_000 }); + const policiesByEpisode = new Map(); + for (const p of allPolicies) { + for (const ep of p.sourceEpisodeIds ?? []) { + const bucket = policiesByEpisode.get(ep) ?? []; + bucket.push(p); + policiesByEpisode.set(ep, bucket); + } + } + const skillsByPolicy = new Map(); + for (const s of allSkills) { + for (const pid of s.sourcePolicyIds ?? []) { + const bucket = skillsByPolicy.get(pid) ?? []; + bucket.push(s); + skillsByPolicy.set(pid, bucket); + } + } + + // 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 firstTraceId = r.traceIds[0]; + const episodeTraces = r.traceIds.length > 0 + ? handle.repos.traces.getManyByIds(r.traceIds as TraceId[]) + : []; + let preview: string | undefined; + const tagSet = new Set(); + if (firstTraceId) { + const trace = + episodeTraces.find((tr) => tr.id === firstTraceId) ?? + handle.repos.traces.getById(firstTraceId as TraceId); + if (trace) { + const raw = (trace.userText ?? trace.agentText ?? "").replace(/\s+/g, " ").trim(); + if (raw) preview = raw.length > 160 ? raw.slice(0, 157) + "…" : raw; + } + } + for (const trace of episodeTraces) { + for (const t of trace.tags ?? []) tagSet.add(t); + } + + const derivation = deriveSkillStatus( + r, + policiesByEpisode.get(r.id) ?? [], + skillsByPolicy, + skillStatusThresholds, + ); + + // `EpisodeManager` stamps `closeReason` and (for abandons) + // `abandonReason` into the episode's meta blob on finalize / + // abandon. Surface them through the API so TasksView can render + // a human-readable status badge without guessing from rTask. + const meta = (r as { meta?: Record }).meta ?? {}; + if (!preview) { + const fallback = + typeof meta.initialUserText === "string" + ? meta.initialUserText + : typeof meta.pendingUserText === "string" + ? meta.pendingUserText + : typeof meta.lastUserText === "string" + ? meta.lastUserText + : ""; + const raw = fallback.replace(/\s+/g, " ").trim(); + if (raw) preview = raw.length > 160 ? raw.slice(0, 157) + "…" : raw; + } + const closeReasonRaw = meta.closeReason; + const closeReason: "finalized" | "abandoned" | null = + closeReasonRaw === "finalized" || closeReasonRaw === "abandoned" + ? closeReasonRaw + : null; + const abandonReason = + typeof meta.abandonReason === "string" ? meta.abandonReason : null; + const topicStateRaw = meta.topicState; + const topicState = + topicStateRaw === "active" || + topicStateRaw === "paused" || + topicStateRaw === "interrupted" || + topicStateRaw === "ended" + ? topicStateRaw + : null; + const pauseReason = + typeof meta.pauseReason === "string" ? meta.pauseReason : null; + const reward = + meta.reward && typeof meta.reward === "object" + ? (meta.reward as { skipped?: unknown; reason?: unknown }) + : null; + const rewardSkipped = reward?.skipped === true; + const rewardReason = + typeof reward?.reason === "string" && reward.reason.trim().length > 0 + ? reward.reason + : null; + const hasAssistantReply = episodeTraces.some((trace) => { + if ((trace.agentText ?? "").trim().length > 0) return true; + return (trace.toolCalls ?? []).some((toolCall) => { + const text = toolCall.assistantTextBefore; + return typeof text === "string" && text.trim().length > 0; + }); + }); + + return { + id: r.id, + sessionId: r.sessionId, + ownerAgentKind: r.ownerAgentKind, + ownerProfileId: r.ownerProfileId, + ownerWorkspaceId: r.ownerWorkspaceId ?? null, + startedAt: r.startedAt, + endedAt: r.endedAt ?? undefined, + status: r.status, + rTask: r.rTask, + turnCount: deriveTurnCount(r, episodeTraces), + preview, + tags: tagSet.size > 0 ? Array.from(tagSet).sort() : undefined, + skillStatus: derivation.status, + skillReason: derivation.reason, + skillReasonKey: derivation.reasonKey, + skillReasonParams: derivation.reasonParams, + linkedSkillId: derivation.linkedSkillId, + closeReason, + topicState, + pauseReason, + abandonReason, + rewardSkipped, + rewardReason, + hasAssistantReply, + }; + }); + return out as never; + } + + async function timeline(input: { + episodeId: EpisodeId; + namespace?: RuntimeNamespace; + }): Promise { + ensureLive(); + if (input.namespace) activeNamespace = input.namespace; + const episode = handle.repos.episodes.getById(input.episodeId); + if (episode && !visibleToCurrent(episode)) return []; + const rows = handle.repos.traces.list({ + episodeId: input.episodeId, + limit: 500, + newestFirst: false, + }).filter((r) => visibleToCurrent(r)); + return orderTraceRowsForEpisode(rows, episode?.traceIds ?? []).map((row) => + traceRowToDTO(row, episode), + ); + } + + async function listApiLogs(input?: { + toolName?: string; + toolNames?: readonly string[]; + limit?: number; + offset?: number; + }): Promise<{ logs: ApiLogDTO[]; total: number }> { + ensureLive(); + const limit = Math.max(1, Math.min(500, input?.limit ?? 50)); + const offset = Math.max(0, input?.offset ?? 0); + const rows = handle.repos.apiLogs.list({ + toolName: input?.toolName, + toolNames: input?.toolNames, + limit, + offset, + }); + const total = handle.repos.apiLogs.count({ + toolName: input?.toolName, + toolNames: input?.toolNames, + }); + return { + logs: rows.map((r) => ({ + id: r.id, + toolName: r.toolName, + inputJson: r.inputJson, + outputJson: r.outputJson, + durationMs: r.durationMs, + success: r.success, + calledAt: r.calledAt, + })), + total, + }; + } + + async function countTraces(input?: { + sessionId?: SessionId; + q?: string; + groupByTurn?: boolean; + }): Promise { + ensureLive(); + const needle = (input?.q ?? "").trim().toLowerCase(); + const visible = (r: TraceRow) => visibleToCurrent(r); + if (!needle) { + const rows = handle.repos.traces.list({ sessionId: input?.sessionId, limit: 100_000 }).filter(visible); + if (!input?.groupByTurn) return rows.length; + const turnKeys = new Set(); + for (const r of rows) turnKeys.add(`${r.episodeId ?? "_"}:${r.turnId}`); + return turnKeys.size; + } + // 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 matched = rows.filter((r) => { + return traceSearchHaystack(r).includes(needle); + }); + if (!input?.groupByTurn) return matched.length; + const turnKeys = new Set(); + for (const r of matched) turnKeys.add(`${r.episodeId ?? "_"}:${r.turnId}`); + return turnKeys.size; + } + + async function listTraces(input?: { + limit?: number; + offset?: number; + sessionId?: SessionId; + q?: string; + groupByTurn?: 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(); + + if (input?.groupByTurn) { + // Group-by-turn: paginate at the (episodeId, turnId) level so each + // "memory" on the Memories page corresponds to one user turn. + if (!needle) { + const turnKeys = handle.repos.traces.listTurnKeys({ + sessionId: input?.sessionId, + limit, + offset, + }); + const rows = handle.repos.traces.listByTurnKeys(turnKeys); + const visibleRows = rows.filter((r) => visibleToCurrent(r)); + // 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 + // conversation trace order inside each turn. + const turnOrder = new Map(); + turnKeys.forEach((k, i) => + turnOrder.set(`${k.episodeId ?? "_"}:${k.turnId}`, i), + ); + const traceOrder = traceOrderLookup(visibleRows); + visibleRows.sort((a, b) => { + const ka = `${a.episodeId ?? "_"}:${a.turnId}`; + const kb = `${b.episodeId ?? "_"}:${b.turnId}`; + const ia = turnOrder.get(ka) ?? 0; + const ib = turnOrder.get(kb) ?? 0; + if (ia !== ib) return ia - ib; + return compareTraceRowsForEpisodeOrder(a, b, traceOrder); + }); + 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 matched = allRows.filter((r) => { + return traceSearchHaystack(r).includes(needle); + }); + const seen = new Map(); + for (const r of matched) { + const k = `${r.episodeId ?? "_"}:${r.turnId}`; + const existing = seen.get(k); + if (!existing || r.ts > existing.maxTs) { + seen.set(k, { episodeId: r.episodeId, turnId: r.turnId, maxTs: r.ts }); + } + } + const orderedKeys = [...seen.values()] + .sort((a, b) => b.maxTs - a.maxTs) + .slice(offset, offset + limit); + const turnOrder = new Map(); + orderedKeys.forEach((k, i) => + turnOrder.set(`${k.episodeId ?? "_"}:${k.turnId}`, i), + ); + // 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 traceOrder = traceOrderLookup(rows); + const traces = rows + .sort((a, b) => { + const ka = `${a.episodeId ?? "_"}:${a.turnId}`; + const kb = `${b.episodeId ?? "_"}:${b.turnId}`; + const ia = turnOrder.get(ka) ?? 0; + const ib = turnOrder.get(kb) ?? 0; + if (ia !== ib) return ia - ib; + return compareTraceRowsForEpisodeOrder(a, b, traceOrder); + }); + return traceRowsToDTOs(traces); + } + + if (!needle) { + const rows = handle.repos.traces.list({ + sessionId: input?.sessionId, + limit: limit + offset + 500, + offset: 0, + }).filter((r) => visibleToCurrent(r)); + return traceRowsToDTOs(rows.slice(offset, offset + limit)); + } + // Substring search: SQLite LIKE would need an index. For the + // viewer's interactive filter the current volumes (low thousands + // per install) are cheap enough to do a two-phase scan. + const batchSize = Math.min(2_000, (limit + offset) * 5); + const rows = handle.repos.traces.list({ + sessionId: input?.sessionId, + limit: batchSize, + offset: 0, + }); + const filtered = rows.filter((r) => { + if (!visibleToCurrent(r)) return false; + return traceSearchHaystack(r).includes(needle); + }); + return traceRowsToDTOs(filtered.slice(offset, offset + limit)); + } + + function traceSearchHaystack(row: TraceRow): string { + return [ + row.id, + row.episodeId, + row.summary ?? "", + row.userText, + row.agentText, + summarizeToolCalls(row.toolCalls), + ].join("\n").toLowerCase(); + } + + function traceRowsToDTOs(rows: readonly TraceRow[]): TraceDTO[] { + const episodes = new Map(); + return rows.map((row) => { + if (!episodes.has(row.episodeId)) { + episodes.set(row.episodeId, handle.repos.episodes.getById(row.episodeId)); + } + return traceRowToDTO(row, episodes.get(row.episodeId) ?? undefined); + }); + } + + function traceOrderLookup( + rows: readonly TraceRow[], + ): Map> { + const out = new Map>(); + const episodeIds = new Set(rows.map((r) => r.episodeId).filter(Boolean)); + for (const episodeId of episodeIds) { + const ep = handle.repos.episodes.getById(episodeId); + if (!ep) continue; + const order = new Map(); + const episodeRows = rows.filter((row) => row.episodeId === episodeId); + orderTraceRowsForEpisode(episodeRows, ep.traceIds).forEach((row, idx) => + order.set(row.id, idx), + ); + out.set(episodeId, order); + } + return out; + } + + // ─── Skills ── + async function listSkills( + input?: { status?: SkillDTO["status"]; limit?: number; namespace?: RuntimeNamespace }, + ): Promise { + ensureLive(); + if (input?.namespace) activeNamespace = input.namespace; + const rows = handle.repos.skills.list({ + status: input?.status, + limit: 5_000, + }); + return rows.filter((r) => visibleToCurrent(r)).slice(0, input?.limit ?? 50).map(skillRowToDTO); + } + + async function countSkills(input?: { + status?: SkillDTO["status"]; + }): Promise { + ensureLive(); + return handle.repos.skills.list({ status: input?.status, limit: 5_000 }).filter((r) => visibleToCurrent(r)).length; + } + + async function getSkill( + id: SkillId, + opts?: { + recordUse?: boolean; + recordTrial?: boolean; + sessionId?: SessionId; + episodeId?: EpisodeId; + traceId?: string; + turnId?: number; + toolCallId?: string; + namespace?: RuntimeNamespace; + }, + ): Promise { + ensureLive(); + if (opts?.namespace) activeNamespace = opts.namespace; + const row = handle.repos.skills.getById(id); + if (!row || !visibleToCurrent(row)) return null; + if (opts?.recordUse) { + handle.repos.skills.recordUse(id, Date.now()); + if (opts.recordTrial) { + recordSkillTrial(id, opts); + } + const updated = handle.repos.skills.getById(id); + return updated ? skillRowToDTO(updated) : skillRowToDTO(row); + } + return skillRowToDTO(row); + } + + function recordSkillTrial( + skillId: SkillId, + opts: { + sessionId?: SessionId; + episodeId?: EpisodeId; + traceId?: string; + turnId?: number; + toolCallId?: string; + }, + ): void { + const episode = + opts.episodeId + ? handle.repos.episodes.getById(opts.episodeId) + : opts.sessionId + ? handle.repos.episodes.getOpenForSession(opts.sessionId) + : null; + if (!episode) { + rootLogger.child({ channel: "core.skill" }).debug("skill.trial.skipped", { + skillId, + reason: "missing_episode", + sessionId: opts.sessionId, + }); + return; + } + handle.repos.skillTrials.createPending({ + id: `st_${randomUUID()}`, + ownerAgentKind: episode.ownerAgentKind, + ownerProfileId: episode.ownerProfileId, + ownerWorkspaceId: episode.ownerWorkspaceId, + skillId, + sessionId: opts.sessionId ?? episode.sessionId ?? null, + episodeId: episode.id, + traceId: opts.traceId ?? null, + turnId: Number.isFinite(opts.turnId) ? (opts.turnId as number) : null, + toolCallId: opts.toolCallId ?? null, + status: "pending", + createdAt: Date.now(), + resolvedAt: null, + evidence: { + source: "skill_get", + }, + }); + } + + async function metrics(input?: { days?: number }): Promise<{ + total: number; + writesToday: number; + sessions: number; + embeddings: number; + dailyWrites: Array<{ date: string; count: number }>; + skillStats: { + total: number; + active: number; + candidate: number; + archived: number; + evolutionRate: number; + }; + policyStats: { + total: number; + active: number; + candidate: number; + archived: number; + avgGain: number; + avgQuality: number; + }; + worldModelCount: number; + decisionRepairCount: number; + dailySkillEvolutions: Array<{ date: string; count: number }>; + recentEvolutions: Array<{ + ts: number; + skillId: string; + skillName: string; + status: "candidate" | "active" | "archived"; + sourcePolicyIds: string[]; + }>; + }> { + ensureLive(); + const days = Math.max(1, Math.min(365, input?.days ?? 30)); + const now = Date.now(); + const oneDayMs = 86_400_000; + const sinceMs = now - days * oneDayMs; + + const traces = handle.repos.traces.list({ limit: 10_000 }); + const sessions = new Set(); + let writesToday = 0; + let embeddings = 0; + const dayBuckets = new Map(); + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + + for (const t of traces) { + sessions.add(t.sessionId); + if (t.vecSummary || t.vecAction) embeddings++; + if (t.ts >= startOfToday.getTime()) writesToday++; + if (t.ts >= sinceMs) { + const d = new Date(t.ts); + d.setHours(0, 0, 0, 0); + const key = d.toISOString().slice(0, 10); + dayBuckets.set(key, (dayBuckets.get(key) ?? 0) + 1); + } + } + + // Fill missing days with 0 so the chart renders an even baseline. + const dailyWrites: Array<{ date: string; count: number }> = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(startOfToday.getTime() - i * oneDayMs); + const key = d.toISOString().slice(0, 10); + dailyWrites.push({ date: key, count: dayBuckets.get(key) ?? 0 }); + } + + // ── V7 progress metrics — skills, policies, L3, repairs ──────────── + const skillRows = handle.repos.skills.list({ limit: 5_000 }); + const policyRows = handle.repos.policies.list({ limit: 5_000 }); + const worldModelCount = handle.repos.worldModel.list({ limit: 5_000 }).length; + const decisionRepairCount = handle.repos.decisionRepairs.list({ limit: 5_000 }).length; + + const skillByStatus = { active: 0, candidate: 0, archived: 0 } as Record< + SkillDTO["status"], + number + >; + for (const s of skillRows) skillByStatus[s.status] += 1; + + // Rate of episodes that directly produced a skill — the V7 + // "task → skill" evolution rate. We count an episode as "evolved" + // if any skill's source policies reference it OR its + // `meta.skillStatus === 'generated'` flag is set (viewer writes + // this today). + const episodeRows = handle.repos.episodes.list({ limit: 5_000 }); + const policyToEpisodes = new Map(); + for (const p of policyRows) { + policyToEpisodes.set(p.id, p.sourceEpisodeIds ?? []); + } + const evolvedEpisodes = new Set(); + for (const s of skillRows) { + for (const pid of s.sourcePolicyIds ?? []) { + for (const epId of policyToEpisodes.get(pid) ?? []) evolvedEpisodes.add(epId); + } + } + const totalTasks = episodeRows.length; + const evolutionRate = totalTasks > 0 ? evolvedEpisodes.size / totalTasks : 0; + + const policyByStatus = { active: 0, candidate: 0, archived: 0 } as Record< + PolicyDTO["status"], + number + >; + let gainSum = 0; + let activeGainCount = 0; + for (const p of policyRows) { + policyByStatus[p.status] += 1; + if (p.status === "active") { + gainSum += p.gain; + activeGainCount++; + } + } + const avgGain = activeGainCount > 0 ? gainSum / activeGainCount : 0; + + // Daily skill evolutions: bucket by `skill.createdAt`. + const evoBuckets = new Map(); + for (const s of skillRows) { + if (s.createdAt < sinceMs) continue; + const d = new Date(s.createdAt); + d.setHours(0, 0, 0, 0); + const key = d.toISOString().slice(0, 10); + evoBuckets.set(key, (evoBuckets.get(key) ?? 0) + 1); + } + const dailySkillEvolutions: Array<{ date: string; count: number }> = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(startOfToday.getTime() - i * oneDayMs); + const key = d.toISOString().slice(0, 10); + dailySkillEvolutions.push({ date: key, count: evoBuckets.get(key) ?? 0 }); + } + + // Recent crystallisations — newest 20, sorted by createdAt desc. + const recentEvolutions = [...skillRows] + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, 20) + .map((s) => ({ + ts: s.createdAt, + skillId: s.id, + skillName: s.name, + status: s.status, + sourcePolicyIds: s.sourcePolicyIds ?? [], + })); + + // Count unique (episodeId, turnId) groups instead of raw traces so + // 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(); + + return { + total: totalTurns, + writesToday, + sessions: sessions.size, + embeddings, + dailyWrites, + skillStats: { + total: skillRows.length, + active: skillByStatus.active, + candidate: skillByStatus.candidate, + archived: skillByStatus.archived, + evolutionRate, + }, + policyStats: { + total: policyRows.length, + active: policyByStatus.active, + candidate: policyByStatus.candidate, + archived: policyByStatus.archived, + avgGain, + // Quality score proxies `gain` — the viewer treats this as + // the "平均质量分" metric. + avgQuality: avgGain, + }, + worldModelCount, + decisionRepairCount, + dailySkillEvolutions, + recentEvolutions, + }; + } + + + async function exportBundle(): Promise<{ + version: 1; + exportedAt: number; + traces: TraceDTO[]; + policies: PolicyDTO[]; + worldModels: WorldModelDTO[]; + skills: SkillDTO[]; + }> { + ensureLive(); + const traces = traceRowsToDTOs(handle.repos.traces.list({ limit: 100_000 })); + const policies = handle.repos.policies.list({ limit: 5_000 }).map(policyRowToDTO); + const worldModels = handle.repos.worldModel.list({ limit: 2_000 }).map(worldModelRowToDTO); + const skills = handle.repos.skills.list({ limit: 5_000 }).map(skillRowToDTO); + return { + version: 1, + exportedAt: Date.now(), + traces, + policies, + worldModels, + skills, + }; + } + + async function importBundle(bundle: { + version?: number; + traces?: unknown[]; + policies?: unknown[]; + worldModels?: unknown[]; + skills?: unknown[]; + }): Promise<{ imported: number; skipped: number }> { + ensureLive(); + if (bundle.version && bundle.version !== 1) { + throw new MemosError("unsupported", `unsupported bundle version: ${bundle.version}`); + } + let imported = 0; + let skipped = 0; + + // Best-effort: only insert rows that don't collide with existing + // ids. We don't re-mint fresh ids on collision to keep the shape + // deterministic for the user — they opt in via a de-duplicating + // pre-pass if they want merging. + const traces = Array.isArray(bundle.traces) ? bundle.traces : []; + + // Phase 0 — ensure every referenced (sessionId, episodeId) row + // exists before we try to `traces.insert`. Without this the FK + // constraint on `traces.episode_id REFERENCES episodes(id)` makes + // every legacy/external row bounce with "FOREIGN KEY constraint + // failed". This was the "Imported 0 traces, 0 skills, 0 tasks" + // bug the user reported on the legacy import button. + const seenSessions = new Set(); + const seenEpisodes = new Set(); + for (const raw of traces) { + const dto = raw as TraceDTO; + if (!dto?.id || !dto.episodeId || !dto.sessionId) continue; + if (!seenSessions.has(dto.sessionId)) { + try { + if (!handle.repos.sessions.getById(dto.sessionId)) { + handle.repos.sessions.upsert({ + id: dto.sessionId, + agent: handle.agent, + startedAt: dto.ts ?? Date.now(), + lastSeenAt: dto.ts ?? Date.now(), + meta: { source: "import" }, + } as never); + } + } catch { + // If the synthetic session row is rejected, the FK insert + // below will fail and be counted as `skipped`. Don't abort + // the entire import batch for one bad session. + } + seenSessions.add(dto.sessionId); + } + if (!seenEpisodes.has(dto.episodeId)) { + try { + if (!handle.repos.episodes.getById(dto.episodeId)) { + handle.repos.episodes.upsert({ + id: dto.episodeId, + sessionId: dto.sessionId, + startedAt: dto.ts ?? Date.now(), + endedAt: dto.ts ?? Date.now(), + traceIds: [], + rTask: null, + status: "closed", + meta: { source: "import" }, + } as never); + } + } catch { + /* see comment above */ + } + seenEpisodes.add(dto.episodeId); + } + } + + for (const raw of traces) { + try { + const dto = raw as TraceDTO; + if (!dto?.id) { skipped++; continue; } + const existing = handle.repos.traces.getById(dto.id); + if (existing) { skipped++; continue; } + // The trace table requires a fuller row shape than TraceDTO. + // We reconstitute a stub row — vectors are dropped on purpose + // because we have no way to re-embed bundled text here. + handle.repos.traces.insert({ + id: dto.id, + episodeId: dto.episodeId, + sessionId: dto.sessionId, + ts: dto.ts, + userText: dto.userText, + agentText: dto.agentText, + toolCalls: dto.toolCalls ?? [], + reflection: dto.reflection ?? null, + value: dto.value ?? 0, + alpha: dto.alpha ?? 0, + rHuman: dto.rHuman ?? null, + priority: dto.priority ?? 0, + tags: [], + vecSummary: null, + vecAction: null, + turnId: dto.turnId, + schemaVersion: 1, + } as TraceRow); + imported++; + } catch { + skipped++; + } + } + + // Policies / world models / skills use existing repo.insert shape. + for (const raw of bundle.policies ?? []) { + try { + const dto = raw as PolicyDTO; + if (!dto?.id || handle.repos.policies.getById(dto.id)) { skipped++; continue; } + handle.repos.policies.insert({ + id: dto.id, + title: dto.title, + trigger: dto.trigger, + procedure: dto.procedure, + verification: dto.verification, + boundary: dto.boundary, + support: dto.support ?? 0, + gain: dto.gain ?? 0, + status: dto.status, + experienceType: dto.experienceType ?? "success_pattern", + evidencePolarity: dto.evidencePolarity ?? "positive", + salience: dto.salience ?? 0, + confidence: dto.confidence ?? 0.5, + skillEligible: dto.skillEligible !== false, + sourceEpisodeIds: dto.sourceEpisodeIds ?? [], + sourceFeedbackIds: dto.sourceFeedbackIds ?? [], + sourceTraceIds: dto.sourceTraceIds ?? [], + inducedBy: "import", + decisionGuidance: { + preference: [...(dto.preference ?? [])], + antiPattern: [...(dto.antiPattern ?? [])], + }, + verifierMeta: dto.verifierMeta ?? null, + vec: null, + createdAt: dto.createdAt ?? Date.now(), + updatedAt: dto.updatedAt ?? Date.now(), + }); + imported++; + } catch { + skipped++; + } + } + + for (const raw of bundle.skills ?? []) { + try { + const dto = raw as SkillDTO; + if (!dto?.id || handle.repos.skills.getById(dto.id)) { skipped++; continue; } + handle.repos.skills.insert({ + id: dto.id, + name: dto.name, + status: dto.status, + invocationGuide: dto.invocationGuide, + eta: dto.eta ?? 0, + support: dto.support ?? 0, + gain: dto.gain ?? 0, + trialsAttempted: 0, + trialsPassed: 0, + sourcePolicyIds: dto.sourcePolicyIds ?? [], + sourceWorldModelIds: dto.sourceWorldModelIds ?? [], + evidenceAnchors: dto.evidenceAnchors ?? [], + procedureJson: {}, + vec: null, + createdAt: dto.createdAt ?? Date.now(), + updatedAt: dto.updatedAt ?? Date.now(), + version: dto.version ?? 1, + usageCount: dto.usageCount ?? 0, + lastUsedAt: dto.lastUsedAt ?? null, + } as SkillRow); + imported++; + } catch { + skipped++; + } + } + + for (const raw of bundle.worldModels ?? []) { + try { + const dto = raw as WorldModelDTO; + if (!dto?.id || handle.repos.worldModel.getById(dto.id)) { skipped++; continue; } + handle.repos.worldModel.insert({ + id: dto.id, + title: dto.title, + body: dto.body, + structure: { environment: [], inference: [], constraints: [] }, + domainTags: [], + confidence: 0.5, + policyIds: dto.policyIds ?? [], + sourceEpisodeIds: [], + inducedBy: "import", + vec: null, + createdAt: dto.createdAt ?? Date.now(), + updatedAt: dto.updatedAt ?? Date.now(), + version: dto.version ?? 1, + status: dto.status ?? "active", + } as WorldModelRow); + imported++; + } catch { + skipped++; + } + } + + return { imported, skipped }; + } + + async function getConfig(): Promise> { + ensureLive(); + // Re-read from disk instead of returning `handle.config` (the + // plugin-bootstrap cache). The viewer's "saveAndRestart" flow + // writes to disk → PATCH succeeds → the next GET MUST show the + // new value. Returning the cached object meant any GET before the + // gateway actually restarted showed stale defaults, which looked + // like "my settings got wiped" from the user's perspective. + // + // We still reach into `handle.home` (paths) which doesn't change + // at runtime. Failure (deleted file, parse error) falls back to + // the cached snapshot so settings never appear blank mid-edit. + try { + const { loadConfig } = await import("../config/index.js"); + const { config } = await loadConfig(handle.home); + return maskSecrets(config as unknown as Record); + } catch (err) { + log.warn("config.read_from_disk_failed", { + err: err instanceof Error ? err.message : String(err), + }); + return maskSecrets(handle.config as unknown as Record); + } + } + + async function patchConfig( + patch: Record, + ): Promise> { + ensureLive(); + const { patchConfig: applyPatch } = await import("../config/writer.js"); + // Drop blank strings on secret fields so the user can leave them + // empty in the UI without wiping their existing value. + const filtered = stripEmptySecrets(patch); + const result = await applyPatch(handle.home, filtered); + return maskSecrets(result.config as unknown as Record); + } + + async function archiveSkill(id: SkillId, reason?: string): Promise { + ensureLive(); + const existing = handle.repos.skills.getById(id); + if (!existing) { + throw new MemosError("skill_not_found", `skill not found: ${id}`); + } + if (!ownedByCurrent(existing)) return; + const now = Date.now(); + handle.repos.skills.setStatus(id, "archived", now); + handle.buses.skill.emit({ + kind: "skill.status.changed", + at: now, + skillId: id, + previous: existing.status, + next: "archived", + transition: "archived", + }); + const allowedReasons = ["eta-floor", "manual", "policy-rebuilt"] as const; + type ArchiveReason = (typeof allowedReasons)[number]; + const normalizedReason: ArchiveReason = + allowedReasons.includes(reason as ArchiveReason) + ? (reason as ArchiveReason) + : "manual"; + handle.buses.skill.emit({ + kind: "skill.archived", + at: now, + skillId: id, + reason: normalizedReason, + }); + } + + async function deleteSkill(id: SkillId): Promise<{ deleted: boolean }> { + ensureLive(); + const existing = handle.repos.skills.getById(id); + if (!existing || !ownedByCurrent(existing)) return { deleted: false }; + handle.repos.skills.deleteById(id); + return { deleted: true }; + } + + async function reactivateSkill(id: SkillId): Promise { + ensureLive(); + const existing = handle.repos.skills.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + const now = Date.now(); + handle.repos.skills.setStatus(id, "active", now); + if (existing.status !== "active") { + handle.buses.skill.emit({ + kind: "skill.status.changed", + at: now, + skillId: id, + previous: existing.status, + next: "active", + // Closest match in the constrained `SkillLifecycleTransition` + // enum — manually re-promoting a previously-archived skill. + transition: "promoted", + }); + } + const updated = handle.repos.skills.getById(id); + return updated ? skillRowToDTO(updated) : null; + } + + async function updateSkill( + id: SkillId, + patch: { name?: string; invocationGuide?: string }, + ): Promise { + ensureLive(); + const existing = handle.repos.skills.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + handle.repos.skills.updateContent(id, patch); + const updated = handle.repos.skills.getById(id); + return updated ? skillRowToDTO(updated) : null; + } + + async function shareSkill( + id: SkillId, + share: { + scope: "private" | "local" | "public" | "hub" | null; + target?: string | null; + sharedAt?: number | null; + }, + ): Promise { + ensureLive(); + const existing = handle.repos.skills.getById(id); + if (!existing || !ownedByCurrent(existing)) return null; + handle.repos.skills.updateShare(id, share); + const updated = handle.repos.skills.getById(id); + return updated ? skillRowToDTO(updated) : null; + } + + // ─── Observability ── + function subscribeEvents(handler: (e: CoreEvent) => void): Unsubscribe { + return handle.subscribeEvents(handler); + } + + function getRecentEvents(): readonly CoreEvent[] { + return handle.getRecentEvents(); + } + + function subscribeLogs(handler: (r: LogRecord) => void): Unsubscribe { + return handle.subscribeLogs(handler); + } + + function forwardLog(record: LogRecord): void { + rootLogger.forward(record); + } + + return { + init, + shutdown, + health, + bindTelemetry(t: import("../telemetry/index.js").Telemetry) { telemetry = t; }, + openSession, + closeSession, + openEpisode, + closeEpisode, + onTurnStart, + onTurnEnd, + submitFeedback, + recordToolOutcome, + recordSubagentOutcome, + searchMemory, + getTrace, + updateTrace, + deleteTrace, + deleteTraces, + shareTrace, + getPolicy, + listPolicies, + countPolicies, + setPolicyStatus, + deletePolicy, + editPolicyGuidance, + getWorldModel, + listWorldModels, + countWorldModels, + deleteWorldModel, + sharePolicy, + shareWorldModel, + updatePolicy, + updateWorldModel, + archiveWorldModel, + unarchiveWorldModel, + listEpisodes, + listEpisodeRows, + countEpisodes, + timeline, + listTraces, + countTraces, + listApiLogs, + listSkills, + countSkills, + getSkill, + archiveSkill, + deleteSkill, + reactivateSkill, + updateSkill, + shareSkill, + getConfig, + patchConfig, + metrics, + exportBundle, + importBundle, + subscribeEvents, + getRecentEvents, + subscribeLogs, + forwardLog, + }; +} + +// ─── Config helpers ────────────────────────────────────────────────────────── + +/** + * Replace every value under `SECRET_FIELD_PATHS` with a placeholder. + * The rest of the tree is deep-cloned so callers can safely mutate + * the returned object. + */ +function maskSecrets(src: Record): Record { + const cloned = JSON.parse(JSON.stringify(src)) as Record; + for (const dotted of SECRET_FIELD_PATHS) { + const keys = dotted.split("."); + let cursor: Record = cloned; + for (let i = 0; i < keys.length - 1; i++) { + const next = cursor[keys[i]!]; + if (next == null || typeof next !== "object") { + cursor = {} as Record; + break; + } + cursor = next as Record; + } + const leaf = keys[keys.length - 1]!; + if (typeof cursor[leaf] === "string" && (cursor[leaf] as string).length > 0) { + // Use ASCII-only placeholder. Earlier versions used the + // Unicode bullet `•` (U+2022), but browsers reject that + // character in HTTP `Authorization` headers (ByteString rule: + // codepoint must be ≤ 0xFF). When the viewer round-tripped the + // placeholder back through the "Test connection" button the + // fetch would throw "Cannot convert argument to a ByteString…". + // + // Picking an ASCII sentinel keeps the form rehydration logic + // in `stripEmptySecrets` simple AND lets the viewer detect the + // placeholder client-side without worrying about encoding. + cursor[leaf] = "__memos_secret__"; + } + } + return cloned; +} + +/** + * Secret keys with empty string values are dropped from the patch so + * "save" in the UI doesn't wipe an already-configured API key when the + * form was just rehydrated with the mask and left unchanged. + */ +function stripEmptySecrets(patch: Record): Record { + const out = JSON.parse(JSON.stringify(patch)) as Record; + for (const dotted of SECRET_FIELD_PATHS) { + const keys = dotted.split("."); + let cursor: Record | undefined = out; + for (let i = 0; i < keys.length - 1; i++) { + const next = cursor?.[keys[i]!]; + if (next == null || typeof next !== "object") { + cursor = undefined; + break; + } + cursor = next as Record; + } + if (!cursor) continue; + const leaf = keys[keys.length - 1]!; + if ( + cursor[leaf] === "" || + cursor[leaf] === "••••" || + cursor[leaf] === "__memos_secret__" + ) { + delete cursor[leaf]; + } + } + return out; +} + +function orderTraceRowsForEpisode( + rows: readonly TraceRow[], + traceIds: readonly TraceId[], +): TraceRow[] { + if (traceIds.length === 0) return anchorSubagentRowsForDisplay([...rows]); + const order = new Map(); + traceIds.forEach((id, idx) => order.set(id, idx)); + const ordered = [...rows].sort((a, b) => { + const ai = order.get(a.id) ?? Number.POSITIVE_INFINITY; + const bi = order.get(b.id) ?? Number.POSITIVE_INFINITY; + if (ai !== bi) return ai - bi; + return a.ts - b.ts; + }); + return anchorSubagentRowsForDisplay(ordered); +} + +function anchorSubagentRowsForDisplay(rows: TraceRow[]): TraceRow[] { + const delegateByToolCallId = new Map(); + const delegateByGoal = new Map(); + const duplicateGoals = new Set(); + for (const row of rows) { + const tool = row.toolCalls[0]; + if (tool?.name !== "delegate_task") continue; + if (tool.toolCallId) delegateByToolCallId.set(tool.toolCallId, row.id); + const goal = delegateRowGoal(row); + if (goal) { + if (delegateByGoal.has(goal)) duplicateGoals.add(goal); + else delegateByGoal.set(goal, row.id); + } + } + for (const goal of duplicateGoals) delegateByGoal.delete(goal); + if (delegateByToolCallId.size === 0 && delegateByGoal.size === 0) return rows; + + const groups: Array<{ delegateId: string; rows: TraceRow[] }> = []; + const groupedIds = new Set(); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]!; + if (groupedIds.has(row.id)) continue; + const delegateId = subagentRowDelegateId(row, delegateByToolCallId, delegateByGoal); + if (!delegateId) continue; + const relatedTextRows: TraceRow[] = []; + for (let j = i - 1; j >= 0; j--) { + const prev = rows[j]!; + if (groupedIds.has(prev.id)) continue; + if (!isSubagentTextRow(prev)) break; + relatedTextRows.unshift(prev); + } + const group = [row, ...relatedTextRows]; + groupedIds.add(row.id); + for (const related of relatedTextRows) groupedIds.add(related.id); + for (let j = i + 1; j < rows.length; j++) { + const next = rows[j]!; + if ( + next.toolCalls.length > 0 || + groupedIds.has(next.id) || + !isSubagentTextRow(next) + ) { + break; + } + group.push(next); + groupedIds.add(next.id); + } + groups.push({ delegateId, rows: group }); + } + if (groups.length === 0) return rows; + + let ordered = rows.filter((row) => !groupedIds.has(row.id)); + for (const group of groups) { + const delegateIdx = ordered.findIndex((row) => row.id === group.delegateId); + if (delegateIdx < 0) { + ordered.push(...group.rows); + continue; + } + ordered = [ + ...ordered.slice(0, delegateIdx + 1), + ...group.rows, + ...ordered.slice(delegateIdx + 1), + ]; + } + return moveDisplayUserTextToAnchoredDelegates(ordered); +} + +function isSubagentTextRow(row: TraceRow): boolean { + return row.toolCalls.length === 0 && + ( + row.agentText.includes("Subagent task:") || + row.agentText.includes("Subagent result:") + ); +} + +function moveDisplayUserTextToAnchoredDelegates(rows: TraceRow[]): TraceRow[] { + let out = rows; + let cloned = false; + const clone = (): TraceRow[] => { + if (!cloned) { + out = out.map((row) => ({ ...row })); + cloned = true; + } + return out; + }; + + for (let i = 0; i < out.length; i++) { + const delegate = out[i]!; + if (delegate.toolCalls[0]?.name !== "delegate_task" || delegate.userText.trim()) continue; + for (let j = i + 1; j < out.length; j++) { + const candidate = out[j]!; + if (candidate.turnId !== delegate.turnId) break; + if (candidate.toolCalls[0]?.name === "delegate_task") break; + if (candidate.toolCalls[0]?.name !== "subagent" || !candidate.userText.trim()) continue; + const next = clone(); + next[i] = { ...next[i]!, userText: candidate.userText }; + next[j] = { ...next[j]!, userText: "" }; + break; + } + } + return out; +} + +function subagentRowDelegateId( + row: TraceRow, + delegateByToolCallId: ReadonlyMap, + delegateByGoal: ReadonlyMap, +): string | null { + const toolCallId = subagentRowToolCallId(row); + if (toolCallId) { + const byId = delegateByToolCallId.get(toolCallId); + if (byId) return byId; + } + const task = subagentRowTask(row); + return task ? delegateByGoal.get(task) ?? null : null; +} + +function subagentRowToolCallId(row: TraceRow): string | null { + const tool = row.toolCalls[0]; + if (tool?.name !== "subagent") return null; + const input = tool.input && typeof tool.input === "object" && !Array.isArray(tool.input) + ? tool.input as Record + : null; + const meta = input && input.meta && typeof input.meta === "object" && !Array.isArray(input.meta) + ? input.meta as Record + : {}; + const hookKwargs = meta.hookKwargs && typeof meta.hookKwargs === "object" && !Array.isArray(meta.hookKwargs) + ? meta.hookKwargs as Record + : {}; + return firstNonEmptyString( + meta.toolCallId, + meta.tool_call_id, + meta.callId, + meta.call_id, + hookKwargs.toolCallId, + hookKwargs.tool_call_id, + hookKwargs.callId, + hookKwargs.call_id, + ); +} + +function subagentRowTask(row: TraceRow): string | null { + const tool = row.toolCalls[0]; + if (tool?.name !== "subagent") return null; + const input = topLevelRecord(tool.input); + return firstNonEmptyString(input?.task); +} + +function delegateRowGoal(row: TraceRow): string | null { + const tool = row.toolCalls[0]; + if (tool?.name !== "delegate_task") return null; + const input = topLevelJsonObject(tool.input); + return firstNonEmptyString(input?.goal); +} + +function topLevelRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function topLevelJsonObject(value: unknown): Record | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + if (typeof value !== "string") return null; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : null; + } catch { + return null; + } +} + +function firstNonEmptyString(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +} + +function compareTraceRowsForEpisodeOrder( + a: TraceRow, + b: TraceRow, + lookup: ReadonlyMap>, +): number { + if (a.episodeId === b.episodeId) { + const order = lookup.get(a.episodeId); + if (order) { + const ai = order.get(a.id) ?? Number.POSITIVE_INFINITY; + const bi = order.get(b.id) ?? Number.POSITIVE_INFINITY; + if (ai !== bi) return ai - bi; + } + } + return a.ts - b.ts; +} + +// ─── Row → DTO mappers ─────────────────────────────────────────────────────── + +export function traceRowToDTO(row: TraceRow, episode?: EpisodeRow | null): TraceDTO { + return { + id: row.id, + ownerAgentKind: row.ownerAgentKind, + ownerProfileId: row.ownerProfileId, + ownerWorkspaceId: row.ownerWorkspaceId ?? null, + episodeId: row.episodeId, + sessionId: row.sessionId, + ts: row.ts, + userText: row.userText, + agentText: row.agentText, + summary: row.summary ?? null, + tags: row.tags ?? [], + share: row.share ?? null, + toolCalls: row.toolCalls, + agentThinking: row.agentThinking ?? null, + reflection: row.reflection ?? undefined, + value: row.value, + alpha: row.alpha, + rHuman: row.rHuman ?? undefined, + priority: row.priority, + episodeStatus: episode?.status, + episodeRTask: episode?.rTask ?? null, + episodeRewardSkipped: episodeRewardSkipped(episode), + turnId: row.turnId, + }; +} + +function episodeRewardSkipped(episode?: EpisodeRow | null): boolean { + const meta = (episode as { meta?: Record } | null | undefined)?.meta; + const reward = meta?.reward; + return Boolean(reward && typeof reward === "object" && (reward as { skipped?: unknown }).skipped === true); +} + +export function policyRowToDTO(row: PolicyRow): PolicyDTO { + return { + id: row.id, + ownerAgentKind: row.ownerAgentKind, + ownerProfileId: row.ownerProfileId, + ownerWorkspaceId: row.ownerWorkspaceId ?? null, + title: row.title, + trigger: row.trigger, + procedure: row.procedure, + verification: row.verification, + boundary: row.boundary, + support: row.support, + gain: row.gain, + status: row.status, + experienceType: row.experienceType ?? "success_pattern", + evidencePolarity: row.evidencePolarity ?? "positive", + salience: row.salience ?? 0, + confidence: row.confidence ?? 0.5, + skillEligible: row.skillEligible !== false, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + // PolicyDTO surface keeps the flat shape the viewer's PoliciesView + // already renders. The structured `decisionGuidance` lives on the + // storage row (column `decision_guidance_json`); we just unpack it + // here so the DTO doesn't change. + preference: row.decisionGuidance.preference, + antiPattern: row.decisionGuidance.antiPattern, + sourceEpisodeIds: [...(row.sourceEpisodeIds ?? [])], + sourceFeedbackIds: [...(row.sourceFeedbackIds ?? [])], + sourceTraceIds: [...(row.sourceTraceIds ?? [])], + verifierMeta: row.verifierMeta ?? null, + share: row.share ?? null, + editedAt: row.editedAt ?? undefined, + }; +} + +function dedupeStrings(lines: readonly string[]): string[] { + const out: string[] = []; + const seen = new Set(); + for (const raw of lines) { + const s = (raw ?? "").trim(); + if (!s || seen.has(s)) continue; + seen.add(s); + out.push(s); + } + return out; +} + +export function worldModelRowToDTO(row: WorldModelRow): WorldModelDTO { + // Surface the structured (ℰ, ℐ, 𝒞) triple so the viewer can render + // entry-level evidence chips (V7 §1.1). `body` stays as the rendered + // markdown summary used by retrieval injection and the embedder, so + // both pathways stay coherent. Each facet defaults to `[]` because + // a world model is allowed to populate only some facets (e.g. only + // constraints) — the empty slots aren't an error. + const s = row.structure; + return { + id: row.id, + ownerAgentKind: row.ownerAgentKind, + ownerProfileId: row.ownerProfileId, + ownerWorkspaceId: row.ownerWorkspaceId ?? null, + title: row.title, + body: row.body, + structure: { + environment: s.environment ?? [], + inference: s.inference ?? [], + constraints: s.constraints ?? [], + }, + policyIds: row.policyIds, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + version: row.version ?? 1, + status: row.status ?? "active", + share: row.share ?? null, + editedAt: row.editedAt ?? undefined, + }; +} + +export function skillRowToDTO(row: SkillRow): SkillDTO { + // Surface `procedureJson.decisionGuidance` as a top-level DTO field + // so the viewer doesn't have to reach into the structured procedure + // blob. The shape is fixed by `SkillProcedure`, but the JSON column + // is `unknown` to repos so we coerce defensively here — protects + // against a malformed LLM draft, NOT against missing legacy data. + const proc = (row.procedureJson ?? {}) as { + decisionGuidance?: { preference?: unknown; antiPattern?: unknown }; + }; + const dg = proc.decisionGuidance; + const decisionGuidance = { + preference: + dg && Array.isArray(dg.preference) + ? (dg.preference as unknown[]).map((s) => String(s)).filter(Boolean) + : [], + antiPattern: + dg && Array.isArray(dg.antiPattern) + ? (dg.antiPattern as unknown[]).map((s) => String(s)).filter(Boolean) + : [], + }; + return { + id: row.id, + ownerAgentKind: row.ownerAgentKind, + ownerProfileId: row.ownerProfileId, + ownerWorkspaceId: row.ownerWorkspaceId ?? null, + name: row.name, + status: row.status, + invocationGuide: row.invocationGuide, + decisionGuidance, + evidenceAnchors: row.evidenceAnchors, + eta: row.eta, + support: row.support, + gain: row.gain, + trialsAttempted: row.trialsAttempted ?? 0, + trialsPassed: row.trialsPassed ?? 0, + sourcePolicyIds: row.sourcePolicyIds, + sourceWorldModelIds: row.sourceWorldModelIds, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + version: row.version ?? 1, + share: row.share ?? null, + editedAt: row.editedAt ?? undefined, + usageCount: row.usageCount ?? 0, + lastUsedAt: row.lastUsedAt ?? null, + }; +} + +function toFeedbackDTO(row: FeedbackRow): FeedbackDTO { + return { + id: row.id, + ts: row.ts, + episodeId: row.episodeId ?? undefined, + traceId: row.traceId ?? undefined, + channel: row.channel, + polarity: row.polarity, + magnitude: row.magnitude, + rationale: row.rationale ?? undefined, + raw: row.raw, + }; +} + +export function inferTier( + kind: + | "skill" + | "trace" + | "episode" + | "experience" + | "world-model" + | "preference" + | "anti-pattern", +): 1 | 2 | 3 { + if (kind === "skill") return 1; + if (kind === "world-model") return 3; + return 2; +} + +function applyTopKOverride( + config: RetrievalConfig, + topK: RetrievalQueryDTO["topK"] | undefined, +): RetrievalConfig { + if (!topK) return config; + const tier1TopK = clampTopK(topK.tier1, config.tier1TopK); + const tier2TopK = clampTopK(topK.tier2, config.tier2TopK); + const tier3TopK = clampTopK(topK.tier3, config.tier3TopK); + return { + ...config, + tier1TopK, + tier2TopK, + tier3TopK, + llmFilterMaxKeep: tier1TopK + tier2TopK + tier3TopK, + }; +} + +function clampTopK(value: number | undefined, fallback: number): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value)) return fallback; + return Math.min(Math.max(0, Math.trunc(value)), 100); +} + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} + +function clampSigned(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value < -1) return -1; + if (value > 1) return 1; + return value; +} + +function eventTime(evt: unknown): number { + const at = (evt as { at?: unknown } | null)?.at; + return typeof at === "number" && Number.isFinite(at) ? at : Date.now(); +} + +function durationSince( + startedAt: number | undefined | null, + endedAt = Date.now(), + fallbackMs = 0, +): number { + if ( + typeof startedAt !== "number" || + !Number.isFinite(startedAt) || + !Number.isFinite(endedAt) || + endedAt <= startedAt + ) { + return fallbackMs; + } + return Math.max(fallbackMs, Math.round(endedAt - startedAt)); +} + +/** + * Narrow helper that wraps the api_logs.insert call with the same + * failure-tolerance all bus subscribers use — we never want logging + * to break the pipeline. + */ +/** + * Decide what "skill crystallization model" the viewer should display. + * + * Users configure this in Settings → AI Models → 技能进化模型; when they + * leave it blank (`skillEvolver.model === ""`), the core falls back to + * the main `llm.*` model for skill induction. We surface that fallback + * explicitly so the Overview card can label it as "inherited from LLM". + */ +function applyPersistedModelStatus( + repos: PipelineHandle["repos"], + role: "embedding" | "llm" | "skillEvolver", + info: CoreHealth["llm"], +): void { + const latest = findLatestPersistedModelStatus(repos, role, info.provider, info.model); + if (!latest) return; + info.lastOkAt = latest.status === "ok" ? latest.at : null; + info.lastFallbackAt = latest.status === "fallback" ? latest.at : null; + info.lastError = + latest.status === "error" || latest.status === "fallback" + ? { at: latest.at, message: latest.message || "(no message)" } + : null; +} + +function findLatestPersistedModelStatus( + repos: PipelineHandle["repos"], + role: "embedding" | "llm" | "skillEvolver", + provider: string, + model: string, +): { + status: "ok" | "fallback" | "error"; + at: number; + message?: string; +} | null { + try { + const rows = repos.apiLogs.list({ + toolName: "system_model_status", + limit: 500, + offset: 0, + }); + for (const row of rows) { + try { + const out = JSON.parse(row.outputJson) as { + role?: unknown; + status?: unknown; + provider?: unknown; + model?: unknown; + message?: unknown; + }; + if (out.role !== role) continue; + // Only apply status rows for the currently configured model. + // This prevents an old 404 for a typo'd model from keeping the + // card red after the operator fixes Settings and restarts. + if (String(out.provider ?? "") !== provider) continue; + if (String(out.model ?? "") !== model) continue; + if (out.status !== "ok" && out.status !== "fallback" && out.status !== "error") { + continue; + } + return { + status: out.status, + at: row.calledAt, + message: typeof out.message === "string" ? out.message : undefined, + }; + } catch { + // Malformed row — skip and keep walking. + } + } + } catch { + // Repo failure is non-fatal for health; leave in-memory stats. + } + return null; +} + +function retrievalStatsPayload(s: import("../retrieval/types.js").RetrievalStats): { + raw?: number; + ranked?: number; + droppedByThreshold?: number; + thresholdFloor?: number; + topRelevance?: number; + llmFilter?: { + outcome?: string; + kept?: number; + dropped?: number; + sufficient?: boolean | null; + }; + channelHits?: Record; + queryTokens?: number; + queryTags?: string[]; + embedding?: import("../retrieval/types.js").RetrievalStats["embedding"]; +} { + return { + raw: s.rawCandidateCount, + ranked: s.rankedCount, + droppedByThreshold: s.droppedByThresholdCount, + thresholdFloor: s.thresholdFloor, + topRelevance: s.topRelevance, + llmFilter: { + outcome: s.llmFilterOutcome, + kept: s.llmFilterKept, + dropped: s.llmFilterDropped, + sufficient: s.llmFilterSufficient ?? null, + }, + channelHits: s.channelHits as Record | undefined, + queryTokens: s.queryTokens, + queryTags: s.queryTags, + embedding: s.embedding, + }; +} + +function llmHealth( + llm: PipelineHandle["llm"], + // Kept in the signature for source compatibility with older callers + // but intentionally unused — see comment below. + _fallbackTs: number | null, +): CoreHealth["llm"] { + if (!llm) { + return { + available: false, + provider: "none", + model: "", + lastOkAt: null, + lastFallbackAt: null, + lastError: null, + }; + } + const s = llm.stats(); + // We deliberately DO NOT fall back to the latest trace timestamp + // here. Doing so used to paint the slot "connected" on every + // restart even when the configured model was actually broken — any + // historical trace from a prior, working configuration would mask + // a fresh authentication / model-name failure. Now the colour is + // driven entirely by *this process's* facade activity. + return { + available: true, + provider: llm.provider, + model: llm.model, + lastOkAt: s.lastOkAt, + lastFallbackAt: s.lastFallbackAt, + lastError: s.lastError, + }; +} + +function embedderHealth( + embedder: PipelineHandle["embedder"], + _fallbackTs: number | null, +): CoreHealth["embedder"] { + if (!embedder) { + return { + available: false, + provider: "none", + model: "", + dim: 0, + lastOkAt: null, + lastFallbackAt: null, + lastError: null, + }; + } + const s = embedder.stats(); + // No `?? fallbackTs` here either — see `llmHealth`. The embedder + // also has no host fallback path, so `lastFallbackAt` stays `null` + // by definition. + return { + available: true, + provider: embedder.provider, + model: embedder.model, + dim: embedder.dimensions, + lastOkAt: s.lastOkAt, + lastFallbackAt: null, + lastError: s.lastError, + }; +} + +function resolveSkillEvolver( + config: PipelineHandle["config"], + llm: PipelineHandle["llm"], + fallbackTs: number | null, +): CoreHealth["skillEvolver"] { + const evolver = (config as { skillEvolver?: { provider?: string; model?: string } }) + .skillEvolver; + const own = (evolver?.model ?? "").trim(); + if (own) { + // `llm` here is the dedicated `reflectLlm` instance built from the + // skillEvolver config (see `bootstrapMemoryCoreFull`). Reading its + // stats means the Overview card flips red as soon as a skill + // crystallization call fails — independent of the summary LLM. + const s = llm?.stats(); + return { + available: true, + provider: evolver?.provider ?? "", + model: own, + inherited: false, + lastOkAt: s?.lastOkAt ?? null, + lastFallbackAt: s?.lastFallbackAt ?? null, + lastError: s?.lastError ?? null, + }; + } + const fallback = llmHealth(llm, fallbackTs); + return { + available: fallback.available, + provider: fallback.provider, + model: fallback.model, + inherited: true, + lastOkAt: fallback.lastOkAt, + lastFallbackAt: fallback.lastFallbackAt, + lastError: fallback.lastError, + }; +} + +function writeApiLog( + handle: PipelineHandle, + log: Logger, + toolName: string, + input: unknown, + output: unknown, + durationMs: number, + success: boolean, +): void { + try { + handle.repos.apiLogs.insert({ + toolName, + input, + output, + durationMs, + success, + calledAt: Date.now(), + }); + } catch (err) { + log.debug(`apiLogs.${toolName}.skipped`, { + err: err instanceof Error ? err.message : String(err), + }); + } +} + +/** + * Best-effort lookup helpers for stamping a triggering `episodeId` on + * `skill_*` / `world_model_*` api_log rows. The Logs viewer groups + * events by episode for its chain-timeline view; without this the + * skill / L3 lifecycle rows would float as standalone cards. Lookup + * failures are silently absorbed — the row is still written, just + * without an episode binding. + */ +function episodeFromPolicy( + handle: PipelineHandle, + policyId: string | undefined, +): string | undefined { + if (!policyId) return undefined; + try { + const row = handle.repos.policies.getById(policyId as PolicyId); + if (!row) return undefined; + // Prefer the most recently attributed episode — that's the one the + // user just witnessed and the one the rest of the pipeline events + // (memory_add / task_done) are stamped with. + return ( + row.sourceEpisodeIds[row.sourceEpisodeIds.length - 1] ?? + row.sourceEpisodeIds[0] + ); + } catch { + return undefined; + } +} + +function episodeFromSkill( + handle: PipelineHandle, + skillId: string | undefined, +): string | undefined { + if (!skillId) return undefined; + try { + const row = handle.repos.skills.getById(skillId as SkillId); + if (!row) return undefined; + return episodeFromPolicy(handle, row.sourcePolicyIds[0]); + } catch { + return undefined; + } +} + +function episodeFromWorldModel( + handle: PipelineHandle, + worldModelId: string | undefined, +): string | undefined { + if (!worldModelId) return undefined; + try { + const row = handle.repos.worldModel.getById(worldModelId as WorldModelId); + if (!row) return undefined; + return ( + row.sourceEpisodeIds[row.sourceEpisodeIds.length - 1] ?? + row.sourceEpisodeIds[0] ?? + episodeFromPolicy(handle, row.policyIds[0]) + ); + } catch { + return undefined; + } +} + +/** + * Derive a human-readable skill-crystallisation status for an + * episode ("task") from the raw episode row + its related policies / + * skills. Mirrors the legacy `tasks.skill_status` / `skill_reason` + * fields so the Tasks page can show the user *why* a completed task + * produced no skill. + * + * Order matters: we return the first matching branch. + */ +/** + * Derive a meaningful user-turn count for the viewer's task list. + * + * L1 traces are step-level rows: one user request can produce many tool + * traces plus a final assistant trace. `turnId` is the stable group key + * stamped on every trace created from the same user message, so the Tasks + * tab should count distinct `turnId`s rather than raw trace ids. + */ +export function deriveTurnCount( + r: EpisodeRow, + traces: readonly Pick[] = [], +): number { + if (traces.length > 0) { + return new Set( + traces + .map((trace) => trace.turnId) + .filter((turnId) => Number.isFinite(turnId)), + ).size; + } + if (r.traceIds.length > 0) return 1; + return r.status === "open" ? 1 : 0; +} + +// V7 §0.6 threshold tiering for the "skill pipeline pill" shown on each +// task card. Reward scores live in [-1, 1] but the UI needs a 3-way +// bucket that actually matches user intuition: +// +// rTask <= R_NEGATIVE_FLOOR → true anti-pattern, label as 反例 +// R_NEGATIVE_FLOOR < rTask < R_BELOW_THRESHOLD → just "未达沉淀阈值" +// rTask >= R_BELOW_THRESHOLD → eligible, continue to L2/skill checks +// +// The old code tripped every rTask < 0 (even -0.05) into the "反例" +// bucket — a single LLM misread on a multi-topic episode was enough to +// flag a normal task as a negative example. Tightening the floor to +// −0.5 means only genuinely bad outcomes (clear user correction, wrong +// action, damage) surface as 反例; mild negative judgments fall into +// the softer "below threshold" bucket and the user doesn't get +// shouted at. +export const R_NEGATIVE_FLOOR = -0.5; +export const R_BELOW_THRESHOLD = 0.15; // aligned with `algorithm.skill.minGain` + +export function deriveSkillStatus( + ep: EpisodeRow, + relatedPolicies: readonly PolicyRow[], + skillsByPolicy: ReadonlyMap, + thresholds: { + minEpisodesForInduction: string; + minTraceValue: string; + skillMinSupport: string; + skillMinGain: string; + } = { + minEpisodesForInduction: "2", + minTraceValue: "0.1", + skillMinSupport: "3", + skillMinGain: "0.15", + }, +): { + status: EpisodeListItemDTO["skillStatus"]; + reason: string | null; + reasonKey: string | null; + reasonParams: Record | null; + linkedSkillId: SkillId | null; +} { + if (ep.status === "open") { + return { + status: "queued", + reason: "任务仍在进行中,技能流水线尚未启动", + reasonKey: "tasks.skillReason.queued.inProgress", + reasonParams: null, + linkedSkillId: null, + }; + } + if (ep.rTask == null) { + return { + status: "queued", + reason: "Reward 评分尚未完成,技能流水线将在评分后启动", + reasonKey: "tasks.skillReason.queued.rewardPending", + reasonParams: null, + linkedSkillId: null, + }; + } + if (ep.rTask <= R_NEGATIVE_FLOOR) { + return { + status: "skipped", + reason: `任务评分为明显负分 (R=${ep.rTask.toFixed(2)}),视为反例`, + reasonKey: "tasks.skillReason.skipped", + reasonParams: { rTask: ep.rTask.toFixed(2) }, + linkedSkillId: null, + }; + } + if (ep.rTask < R_BELOW_THRESHOLD) { + return { + status: "not_generated", + reason: `任务评分 R=${ep.rTask.toFixed(2)} 未达到沉淀阈值`, + reasonKey: "tasks.skillReason.not_generated.belowThreshold", + reasonParams: { rTask: ep.rTask.toFixed(2), threshold: R_BELOW_THRESHOLD.toFixed(2) }, + linkedSkillId: null, + }; + } + if (relatedPolicies.length === 0) { + return { + status: "not_generated", + reason: "暂未归纳出 L2 经验", + reasonKey: "tasks.skillReason.not_generated.noPolicy", + reasonParams: thresholds, + linkedSkillId: null, + }; + } + const best = [...relatedPolicies].sort((a, b) => b.gain - a.gain)[0]!; + const policyBucket = skillsByPolicy.get(best.id) ?? []; + if (policyBucket.length > 0) { + const active = policyBucket.find((s) => s.status !== "archived") ?? policyBucket[0]!; + const isUpgraded = best.updatedAt > active.updatedAt; + return { + status: isUpgraded ? "upgraded" : "generated", + reason: `技能「${active.name ?? active.id}」已从经验 ${best.id.slice(0, 8)} 结晶`, + reasonKey: isUpgraded ? "tasks.skillReason.upgraded" : "tasks.skillReason.generated", + reasonParams: { skillName: active.name ?? active.id, policyId: best.id.slice(0, 8) }, + linkedSkillId: active.id as SkillId, + }; + } + if (best.status !== "active") { + return { + status: "queued", + reason: `经验 ${best.id.slice(0, 8)} 需要更多支撑任务`, + reasonKey: "tasks.skillReason.queued.policyPending", + reasonParams: { ...thresholds, support: String(best.support ?? 0) }, + linkedSkillId: null, + }; + } + return { + status: "queued", + reason: `经验 ${best.id.slice(0, 8)} 已就绪`, + reasonKey: "tasks.skillReason.queued.ready", + reasonParams: { ...thresholds, gain: best.gain.toFixed(2), support: String(best.support ?? 0) }, + linkedSkillId: null, + }; +} + +function formatThreshold(n: number): string { + if (!Number.isFinite(n)) return String(n); + return Number(n.toFixed(3)).toString(); +} + +/** + * Produce a short content string from toolCalls when userText/agentText + * are both empty (sub-steps after the first in a multi-tool turn). + */ +function summarizeToolCalls( + toolCalls?: readonly { name?: string; output?: unknown }[] | null, +): string { + if (!toolCalls || toolCalls.length === 0) return ""; + return toolCalls + .map((tc) => { + const name = tc.name ?? "tool"; + const out = typeof tc.output === "string" + ? tc.output.slice(0, 200) + : tc.output != null + ? JSON.stringify(tc.output).slice(0, 200) + : ""; + return out ? `[${name}] ${out}` : `[${name}]`; + }) + .join("\n"); +} + +/** + * Heuristic role inference for api_logs "memory_add" rows — mirrors + * the legacy plugin's behaviour where each captured turn showed up + * labelled `user` / `assistant` / `tool` on the Logs page. + * + * Priority: if the step carries userText (the user's query), label it + * "user" even when toolCalls are present — this is the first sub-step + * of a multi-tool turn and semantically represents the user request. + */ +function inferTurnRole(step: { + userText?: string; + agentText?: string; + toolCalls?: readonly unknown[]; +}): "user" | "assistant" | "tool" | "other" { + const u = (step.userText ?? "").length; + const a = (step.agentText ?? "").length; + if (u > 0 && (step.toolCalls?.length ?? 0) > 0) return "user"; + if ((step.toolCalls?.length ?? 0) > 0) return "tool"; + if (u >= a && u > 0) return "user"; + if (a > 0) return "assistant"; + return "other"; +} diff --git a/tools/plugin-patches/core/pipeline/retrieval-repos.ts b/tools/plugin-patches/core/pipeline/retrieval-repos.ts new file mode 100644 index 0000000..41fdff0 --- /dev/null +++ b/tools/plugin-patches/core/pipeline/retrieval-repos.ts @@ -0,0 +1,176 @@ +/** + * Adapter between the concrete storage `Repos` and the narrow + * `RetrievalRepos` surface the retrieval pipeline consumes. + * + * Keeping this translation in `core/pipeline/` means the retrieval module + * stays decoupled from the storage schema — and the pipeline stays the + * one place where we remember which repo serves which tier. + */ + +import type { RetrievalRepos } from "../retrieval/types.js"; +import type { Repos } from "../storage/repos/index.js"; +import type { RuntimeNamespace, TraceId } from "../../agent-contract/dto.js"; +import { isVisibleTo } from "../runtime/namespace.js"; + +export function wrapRetrievalRepos(repos: Repos, namespace: RuntimeNamespace): RetrievalRepos { + return { + skills: { + searchByVector(query, k, opts) { + return repos.skills.searchByVector(query, k, opts ?? {}); + }, + searchByText(ftsMatch, k, opts) { + return repos.skills.searchByText(ftsMatch, k, opts ?? {}); + }, + searchByPattern(terms, k, opts) { + return repos.skills.searchByPattern(terms, k, opts ?? {}); + }, + getById(id) { + const row = repos.skills.getById(id); + if (!row || !isVisibleTo(row, namespace)) return null; + return { + id: row.id, + name: row.name, + status: row.status, + invocationGuide: row.invocationGuide, + eta: row.eta, + sourcePolicyIds: row.sourcePolicyIds, + updatedAt: row.updatedAt, + // Origin attribution for cross-agent visibility. Surfaced into + // the rendered prompt as "(learned by )" by + // renderSkill() in injector.ts. Added 2026-05-17. + ownerProfileId: row.ownerProfileId, + ownerAgentKind: row.ownerAgentKind, + }; + }, + }, + + traces: { + searchByVector(query, k, opts) { + return repos.traces.searchByVector(query, k, opts ?? {}); + }, + searchByText(ftsMatch, k, opts) { + return repos.traces.searchByText(ftsMatch, k, opts ?? {}); + }, + searchByPattern(terms, k, opts) { + return repos.traces.searchByPattern(terms, k, opts ?? {}); + }, + getManyByIds(ids) { + const rows = repos.traces.getManyByIds(ids as readonly TraceId[]); + return rows.filter((r) => isVisibleTo(r, namespace)).map((r) => ({ + id: r.id, + episodeId: r.episodeId, + sessionId: r.sessionId, + ts: r.ts, + userText: r.userText, + agentText: r.agentText, + reflection: r.reflection, + value: r.value, + priority: r.priority, + tags: r.tags, + vecSummary: r.vecSummary, + vecAction: r.vecAction, + })); + }, + searchByErrorSignature(fragments, limit, opts) { + const rows = repos.traces.searchByErrorSignature(fragments, limit, opts); + return rows.filter((r) => isVisibleTo(r, namespace)).map((r) => ({ + id: r.id, + episodeId: r.episodeId, + sessionId: r.sessionId, + ts: r.ts, + userText: r.userText, + agentText: r.agentText, + reflection: r.reflection, + value: r.value, + priority: r.priority, + tags: r.tags, + errorSignatures: r.errorSignatures ?? [], + })); + }, + }, + + worldModel: { + searchByVector(query, k, opts) { + return repos.worldModel.searchByVector(query, k, opts ?? {}); + }, + searchByText(ftsMatch, k) { + return repos.worldModel.searchByText(ftsMatch, k); + }, + searchByPattern(terms, k) { + return repos.worldModel.searchByPattern(terms, k); + }, + getById(id) { + const row = repos.worldModel.getById(id); + if (!row || !isVisibleTo(row, namespace)) return null; + return { + id: row.id, + title: row.title, + body: row.body, + policyIds: row.policyIds, + }; + }, + }, + + // V7 §2.4.6 — expose just enough of the policies repo for retrieval + // to look up `decisionGuidance` (preference / anti-pattern) attached + // to traces / skills already chosen by tiers 1 + 2. + policies: { + searchByVector(query, k, opts) { + return repos.policies.searchByVector(query, k, opts ?? {}); + }, + list(filter) { + const rows = repos.policies.list( + filter && filter.status ? { status: filter.status } : {}, + ); + return rows.filter((r) => isVisibleTo(r, namespace)).map((r) => ({ + id: r.id, + title: r.title, + trigger: r.trigger, + procedure: r.procedure, + verification: r.verification, + boundary: r.boundary, + support: r.support, + gain: r.gain, + status: r.status, + experienceType: r.experienceType ?? "success_pattern", + evidencePolarity: r.evidencePolarity ?? "positive", + salience: r.salience ?? 0, + confidence: r.confidence ?? 0.5, + skillEligible: r.skillEligible !== false, + sourceEpisodeIds: r.sourceEpisodeIds, + sourceFeedbackIds: r.sourceFeedbackIds ?? [], + sourceTraceIds: r.sourceTraceIds ?? [], + decisionGuidance: r.decisionGuidance, + vec: r.vec, + updatedAt: r.updatedAt, + })); + }, + getById(id) { + const row = repos.policies.getById(id); + if (!row || !isVisibleTo(row, namespace)) return null; + return { + id: row.id, + title: row.title, + trigger: row.trigger, + procedure: row.procedure, + verification: row.verification, + boundary: row.boundary, + support: row.support, + gain: row.gain, + status: row.status, + experienceType: row.experienceType ?? "success_pattern", + evidencePolarity: row.evidencePolarity ?? "positive", + salience: row.salience ?? 0, + confidence: row.confidence ?? 0.5, + skillEligible: row.skillEligible !== false, + sourceEpisodeIds: row.sourceEpisodeIds, + sourceFeedbackIds: row.sourceFeedbackIds ?? [], + sourceTraceIds: row.sourceTraceIds ?? [], + decisionGuidance: row.decisionGuidance, + vec: row.vec, + updatedAt: row.updatedAt, + }; + }, + }, + }; +} diff --git a/tools/plugin-patches/core/retrieval/injector.ts b/tools/plugin-patches/core/retrieval/injector.ts new file mode 100644 index 0000000..a86b537 --- /dev/null +++ b/tools/plugin-patches/core/retrieval/injector.ts @@ -0,0 +1,520 @@ +/** + * Snippet renderer. + * + * Converts `RankedCandidate`s into `InjectionSnippet` values + a single + * rendered `InjectionPacket`. Adapters may walk `snippets` themselves or + * just splice `rendered` verbatim into the host prompt. + * + * The rendering is intentionally plain-text (Markdown headings) — we don't + * know yet how each adapter (OpenClaw vs Hermes) will format its prompt + * section, so we stick to a neutral shape that they can either tweak or + * wrap. + */ + +import type { + EpisodeId, + EpochMs, + InjectionPacket, + InjectionSnippet, + RetrievalReason, + SessionId, +} from "../../agent-contract/dto.js"; +import { ids } from "../id.js"; +import type { CollectedGuidance } from "./decision-guidance.js"; +import type { RankedCandidate } from "./ranker.js"; +import type { + EpisodeCandidate, + ExperienceCandidate, + RankedSnippet, + SkillCandidate, + TierCandidate, + TraceCandidate, + WorldModelCandidate, +} from "./types.js"; + +const MAX_SNIPPET_BODY_CHARS = 640; +const DEFAULT_SKILL_SUMMARY_CHARS = 200; + +export type SkillInjectionMode = "summary" | "full"; + +export interface InjectorInput { + ranked: readonly RankedCandidate[]; + reason: RetrievalReason; + tierLatencyMs: { tier1: number; tier2: number; tier3: number }; + now: EpochMs; + /** + * Required so the packet can be correlated with `onTurnEnd` / + * decision-repair calls on the adapter side. When we add a retrieval + * entry point that has no session context (e.g. a CLI preview), + * synthesise an id before calling. + */ + sessionId: SessionId; + episodeId: EpisodeId; + /** + * How Tier-1 skill candidates should be rendered. Defaults to + * `"summary"` — a short descriptor + `skill_get(id="…")` invocation + * hint, so the host model decides whether to pull the full guide. + */ + skillInjectionMode?: SkillInjectionMode; + /** Per-skill summary char cap when `skillInjectionMode === "summary"`. */ + skillSummaryChars?: number; + /** + * V7 §2.4.6 — preference / anti-pattern collected from policies that + * share evidence with the retrieved traces / skills. Rendered as a + * dedicated "Decision guidance" section so the agent reads it BEFORE + * choosing its next action. Empty (default) means no guidance was + * found for the current retrieval — the section is then omitted. + */ + decisionGuidance?: CollectedGuidance; +} + +export interface InjectorResult { + packet: InjectionPacket; + /** One-to-one with `packet.snippets`, carrying the debug origin. */ + mapping: RankedSnippet[]; +} + +export function toPacket(input: InjectorInput): InjectorResult { + const skillMode: SkillInjectionMode = input.skillInjectionMode ?? "summary"; + const skillSummaryChars = + input.skillSummaryChars ?? DEFAULT_SKILL_SUMMARY_CHARS; + const mapping: RankedSnippet[] = []; + for (const r of suppressExperiencesCoveredBySkills(input.ranked)) { + const snippet = renderSnippet(r.candidate, { + skillMode, + skillSummaryChars, + }); + if (!snippet) continue; + snippet.score = round(r.score, 4); + mapping.push({ + snippet, + tier: r.candidate.tier, + relevance: r.relevance, + finalScore: r.score, + origin: r.candidate, + }); + } + const snippets = mapping.map((m) => m.snippet); + const rendered = renderWholePacket(snippets, input.reason, { + skillMode, + decisionGuidance: input.decisionGuidance, + }); + + const packet: InjectionPacket = { + reason: input.reason, + snippets, + rendered, + tierLatencyMs: input.tierLatencyMs, + packetId: ids.span(), // short opaque id for logs/events + ts: input.now, + sessionId: input.sessionId, + episodeId: input.episodeId, + }; + return { packet, mapping }; +} + +/** + * Public snippet renderer used by `llm-filter.ts` when it needs to + * surface the LLM-dropped candidates back on the packet (for the Logs + * page's `droppedByLlm` list). Reuses the same renderer as the + * injected packet so the two views stay visually consistent. + * + * Skills are always rendered in `summary` mode here — the dropped list + * is purely informational and we don't want oversized guides eating the + * Logs view either. + */ +export function renderSnippetForDebug(c: TierCandidate): InjectionSnippet | null { + return renderSnippet(c, { + skillMode: "summary", + skillSummaryChars: DEFAULT_SKILL_SUMMARY_CHARS, + }); +} + +function suppressExperiencesCoveredBySkills( + ranked: readonly RankedCandidate[], +): RankedCandidate[] { + const covered = new Set(); + for (const r of ranked) { + const c = r.candidate; + if (c.refKind !== "skill") continue; + for (const id of (c as SkillCandidate).sourcePolicyIds ?? []) { + covered.add(id); + } + } + if (covered.size === 0) return [...ranked]; + return ranked.filter((r) => { + const c = r.candidate; + if (c.refKind !== "experience") return true; + if (!covered.has(c.refId)) return true; + const experienceUpdatedAt = (c as ExperienceCandidate).updatedAt ?? 0; + const coveringSkill = ranked.find((slot) => { + const sk = slot.candidate; + return ( + sk.refKind === "skill" && + ((sk as SkillCandidate).sourcePolicyIds ?? []).includes(c.refId) + ); + })?.candidate as SkillCandidate | undefined; + return Boolean( + coveringSkill?.updatedAt && experienceUpdatedAt > coveringSkill.updatedAt, + ); + }); +} + +// ─── Per-candidate renderers ──────────────────────────────────────────────── + +interface RenderOpts { + skillMode: SkillInjectionMode; + skillSummaryChars: number; +} + +function renderSnippet(c: TierCandidate, opts: RenderOpts): InjectionSnippet | null { + switch (c.tier) { + case "tier1": + return renderSkill(c as SkillCandidate, opts); + case "tier2": + if (c.refKind === "trace") return renderTrace(c as TraceCandidate); + if (c.refKind === "experience") { + return renderExperience(c as ExperienceCandidate); + } + return renderEpisode(c as EpisodeCandidate); + case "tier3": + return renderWorldModel(c as WorldModelCandidate); + default: + return null; + } +} + +/** + * Render a Tier-1 Skill candidate. + * + * **Summary mode** (default): the prompt only carries a 1-line teaser + * and a `skill_get(id="…")` hint. The host model can call that tool on + * demand to fetch the full procedure — keeps prompts small and avoids + * paying for skills the agent never needs. + * + * **Full mode**: legacy behaviour, the entire `invocationGuide` body is + * inlined. Hosts without tool-calling support need this. + */ +function renderSkill(c: SkillCandidate, opts: RenderOpts): InjectionSnippet { + // Attribution tag for cross-agent visibility. Only shown when the skill + // was crystallized by a DIFFERENT profile than the caller (i.e. it's a + // shared skill, not the caller's own work). The caller's namespace is + // not threaded through to the renderer today, so we surface the origin + // unconditionally — the consumer-side prompt template can decide + // whether to elide it. Added 2026-05-17. + const origin = c.ownerProfileId ? ` (learned by ${c.ownerProfileId})` : ""; + + if (opts.skillMode === "full") { + const body = truncate( + `Skill: ${c.skillName}${origin}\n` + c.invocationGuide.trim(), + ); + return { + refKind: "skill", + refId: c.refId, + title: `${c.skillName}${origin}`, + body, + }; + } + + const summary = firstLineSummary(c.invocationGuide, opts.skillSummaryChars); + const lines: string[] = []; + if (summary) lines.push(summary); + lines.push( + `→ call \`skill_get(id="${c.refId}")\` to load the full procedure if you decide to use it`, + ); + return { + refKind: "skill", + refId: c.refId, + title: `${c.skillName}${origin}`, + body: lines.join("\n"), + }; +} + +/** + * Pull a single-line summary from a Skill `invocationGuide`. Strategy: + * take the first non-empty paragraph, collapse whitespace, drop common + * markdown headings, then clamp to `maxChars`. + */ +function firstLineSummary(guide: string, maxChars: number): string { + const trimmed = guide.trim(); + if (!trimmed) return ""; + // Split on blank line — first paragraph is the description. + const para = trimmed.split(/\n\s*\n/)[0] ?? trimmed; + // Strip leading "### Trigger:" / "Procedure:" style headings on + // each line so the summary doesn't start mid-rubric. + const cleaned = para + .split("\n") + .map((line) => line.replace(/^\s*#+\s*/, "").trim()) + .filter((line) => line.length > 0) + .join(" "); + if (cleaned.length <= maxChars) return cleaned; + return cleaned.slice(0, maxChars - 1) + "…"; +} + +function renderTrace(c: TraceCandidate): InjectionSnippet { + // LLM-focused shape. When we have an LLM-generated summary, lead + // with it — the summary was deliberately compressed to "the fact + // worth remembering", so it's the most prompt-budget-efficient + // form. Then attach the raw turn text as backup so the model can + // disambiguate pronouns, names, and anything the summary elided. + const parts: string[] = []; + const summaryLine = c.summary?.trim(); + if (summaryLine) parts.push(summaryLine); + if (c.userText) parts.push(`[user] ${c.userText}`); + if (c.agentText) parts.push(`[assistant] ${c.agentText}`); + if (c.reflection) parts.push(`[note] ${c.reflection}`); + const body = truncate(parts.join("\n")); + const when = new Date(c.ts).toISOString().slice(0, 16).replace("T", " "); + return { + refKind: "trace", + refId: c.refId, + title: `Trace · ${when}`, + body, + }; +} + +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); + const when = new Date(c.ts).toISOString().slice(0, 16).replace("T", " "); + return { + refKind: "episode", + refId: c.refId, + title: `Sub-task · ${when}`, + body, + }; +} + +function renderExperience(c: ExperienceCandidate): InjectionSnippet { + const parts = [ + c.trigger ? `Trigger: ${c.trigger}` : null, + c.procedure ? `Do: ${c.procedure}` : null, + c.decisionGuidance.antiPattern.length > 0 + ? `Avoid: ${c.decisionGuidance.antiPattern.join("; ")}` + : null, + c.boundary ? `Scope: ${c.boundary}` : null, + c.verification ? `Check: ${c.verification}` : null, + ].filter(Boolean); + return { + refKind: "experience", + refId: c.refId, + title: c.title, + body: truncate(parts.join("\n")), + }; +} + +function renderWorldModel(c: WorldModelCandidate): InjectionSnippet { + const body = truncate(`World model: ${c.title}\n${c.body}`); + return { + refKind: "world-model", + refId: c.refId, + title: c.title, + body, + }; +} + +// ─── Whole-packet renderer ────────────────────────────────────────────────── + +/** + * Render the whole retrieval packet as a prompt-prependable block. + * + * Format (LLM-actionable, mirrors the legacy `memos-local-openclaw` + * adapter so downstream prompts see the same shape): + * + * ``` + * # User's conversation history (from memory system) + * + * IMPORTANT: The following are facts from previous conversations with + * this user. You MUST treat these as established knowledge and use them + * directly when answering. Do NOT say you don't know if the answer is + * in these memories. + * + * ## Memories + * + * 1. [Trace · 2026-03-05 10:12] + * [user] 我喜欢的运动是游泳 + * [assistant] 记住了。 + * + * ## Skills + * + * 1. Python dependency fix + * When container pip fails, install -dev OS lib first … + * + * Available follow-up tools: + * - call `memory_search(query=...)` for a shorter, more targeted query + * ``` + * + * We deliberately keep the "IMPORTANT" instructions — without them the + * LLM tends to ignore the block and answers from its own parameters. + */ +function renderWholePacket( + snippets: readonly InjectionSnippet[], + reason: RetrievalReason, + opts: { skillMode: SkillInjectionMode; decisionGuidance?: CollectedGuidance }, +): string { + const guidanceBlock = renderDecisionGuidance(opts.decisionGuidance); + if (snippets.length === 0 && !guidanceBlock) return ""; + + const header = HEADER_BY_REASON[reason] ?? HEADER_BY_REASON.turn_start; + const parts: string[] = [header]; + + const skills = snippets.filter((s) => s.refKind === "skill"); + const traces = snippets.filter( + (s) => + s.refKind === "trace" || + s.refKind === "episode", + ); + const experiences = snippets.filter((s) => s.refKind === "experience"); + const worlds = snippets.filter((s) => s.refKind === "world-model"); + + if (skills.length > 0) { + if (opts.skillMode === "summary") { + // In summary mode, frame the section as "candidate skills you can + // call". The bodies already carry the per-skill `skill_get(...)` + // hint, so the agent knows how to expand them on demand. + parts.push( + "## Candidate skills (call `skill_get` to load any you decide to use)\n", + ); + } else { + parts.push("## Skills\n"); + } + skills.forEach((s, i) => { + parts.push(renderNumberedSnippet(s, i + 1)); + }); + } + + if (traces.length > 0) { + parts.push("## Memories\n"); + traces.forEach((s, i) => { + parts.push(renderNumberedSnippet(s, i + 1)); + }); + } + + if (experiences.length > 0) { + parts.push("## Experiences\n"); + experiences.forEach((s, i) => { + parts.push(renderNumberedSnippet(s, i + 1)); + }); + } + + if (worlds.length > 0) { + parts.push("## Environment Knowledge\n"); + worlds.forEach((s, i) => { + parts.push(renderNumberedSnippet(s, i + 1)); + }); + } + + // V7 §2.4.6 — surface decision guidance LAST so it sits immediately + // before the available-tools footer. The agent has already read the + // facts (Memories, Skills, Environment); now we prime it with + // "preferred / avoided" lines distilled from past failures + fixes. + if (guidanceBlock) parts.push(guidanceBlock); + + parts.push(footerFor(opts.skillMode, skills.length > 0)); + return parts.join("\n\n"); +} + +/** + * Render the V7 §2.4.6 "Decision guidance" section. Returns `null` when + * no preference / anti-pattern lines were collected — the caller skips + * the heading entirely so prompts stay tidy. + * + * Format mirrors the surrounding sections (Markdown heading + numbered + * list) so the agent perceives it as part of the same memory packet, + * not a foreign block. + */ +function renderDecisionGuidance(g: CollectedGuidance | undefined): string | null { + if (!g) return null; + if (g.preference.length === 0 && g.antiPattern.length === 0) return null; + + const lines: string[] = [ + "## Decision guidance (distilled from past similar situations)", + "", + "Apply these BEFORE choosing your next action. Each line was learned", + "from one or more past episodes where the user told us what to prefer", + "or avoid in this kind of context.", + ]; + if (g.preference.length > 0) { + lines.push("", "**Prefer**"); + g.preference.forEach((p, i) => { + lines.push(` ${i + 1}. ${p.text}`); + }); + } + if (g.antiPattern.length > 0) { + lines.push("", "**Avoid**"); + g.antiPattern.forEach((a, i) => { + lines.push(` ${i + 1}. ${a.text}`); + }); + } + return lines.join("\n"); +} + +function renderNumberedSnippet(s: InjectionSnippet, n: number): string { + const title = s.title ?? s.refId; + const block = [`${n}. ${title}`, s.body] + .filter(Boolean) + .join("\n"); + return indentBlock(block); +} + +const HEADER_BY_REASON: Record = { + turn_start: + "# User's conversation history (from memory system)\n\n" + + "IMPORTANT: The following are facts from previous conversations with this user.\n" + + "You MUST treat these as established knowledge and use them directly when answering.\n" + + "Do NOT say you don't know or don't have information if the answer is in these memories.", + tool_driven: + "# Memory search results\n\n" + + "The memory tool returned the following hits. They are ranked by relevance.", + skill_invoke: + "# Invoked skill\n\n" + + "Follow the procedure below; the verification step tells you when you're done.", + sub_agent: + "# Parent-agent context\n\n" + + "Relevant memory surfaced for this sub-agent's mission.", + decision_repair: + "# Decision repair — please read before your next action\n\n" + + "You have failed this tool multiple times in a row. Below are preferred / avoided actions\n" + + "distilled from similar past situations. Please adapt your plan accordingly.", +}; + +const FOOTER_LINES_COMMON: readonly string[] = [ + "- `memory_search(query, maxResults?)` — re-query with a shorter / rephrased string", +]; + +const FOOTER_LINES_SKILL_SUMMARY: readonly string[] = [ + "- `skill_get(id)` — load the full procedure/verification of a candidate skill listed above", +]; + +function footerFor( + skillMode: SkillInjectionMode, + hasSkills: boolean, +): string { + const lines: string[] = ["Available follow-up tools:"]; + if (skillMode === "summary" && hasSkills) { + lines.push(...FOOTER_LINES_SKILL_SUMMARY); + } + lines.push(...FOOTER_LINES_COMMON); + return lines.join("\n"); +} + +function indentBlock(s: string): string { + return s + .split("\n") + .map((line) => (line ? " " + line : line)) + .join("\n") + .replace(/^ {3}/, ""); // first line flush with the bullet number +} + +function truncate(s: string): string { + if (s.length <= MAX_SNIPPET_BODY_CHARS) return s; + const head = s.slice(0, MAX_SNIPPET_BODY_CHARS - 16); + return `${head}\n...[truncated]`; +} + +function round(n: number, d: number): number { + const f = 10 ** d; + return Math.round(n * f) / f; +} diff --git a/tools/plugin-patches/core/retrieval/tier1-skill.ts b/tools/plugin-patches/core/retrieval/tier1-skill.ts new file mode 100644 index 0000000..8df0bc4 --- /dev/null +++ b/tools/plugin-patches/core/retrieval/tier1-skill.ts @@ -0,0 +1,256 @@ +/** + * Tier 1 — Skill retrieval (V7 §2.6). + * + * Skills are the "crystallised" layer. Three channels run in parallel: + * + * - vec — cosine over `skills.vec` (semantic) + * - fts — FTS5 trigram MATCH on `skills_fts(name, invocation_guide)` + * - pattern — LIKE %term% fallback for short / CJK queries + * + * Each channel returns a ranked list; we merge by `skillId` and let the + * `ranker` fuse them via RRF. A candidate that surfaces in multiple + * channels gets a strong lift and is much harder to be a false positive. + * + * Filtering rules (cheap, mechanical — happens *before* ranking): + * - Only `active` + `candidate` statuses (V7 §2.6 hides `archived`). + * - Skill `η ≥ minSkillEta` (config). + * - Vector hits also need `cosine ≥ minTraceSim` (we reuse the trace + * floor as a conservative lower bound). + * + * The "should this snippet be injected?" decision lives in `ranker.ts` + * (relative threshold + smart MMR seed) and `llm-filter.ts` (precision + * pass), so this file stays mechanical. + */ + +import { rootLogger } from "../logger/index.js"; +import type { EmbeddingVector, SkillId } from "../types.js"; +import type { + ChannelRank, + RetrievalChannel, + RetrievalConfig, + RetrievalEmbedder, + RetrievalRepos, + SkillCandidate, + SkillStatus, +} from "./types.js"; + +const log = rootLogger.child({ channel: "core.retrieval.tier1" }); +const DEFAULT_KEYWORD_TOPK = 20; + +export interface Tier1Deps { + repos: Pick; + embedder?: RetrievalEmbedder; + config: RetrievalConfig; +} + +export type Tier1Input = + | { + kind: "embedded"; + queryVec: EmbeddingVector | null; + rawText: string; + ftsMatch?: string | null; + patternTerms?: readonly string[]; + } + | { + kind: "raw"; + text: string; + ftsMatch?: string | null; + patternTerms?: readonly string[]; + }; + +interface CandidateState { + cosine: number; + channels: ChannelRank[]; + meta: { name: string; status: SkillStatus; eta: number; gain: number }; + vec: EmbeddingVector | null; +} + +export async function runTier1( + deps: Tier1Deps, + input: Tier1Input, +): Promise { + const { repos, config } = deps; + const startedAt = Date.now(); + try { + const queryVec = await resolveVec(deps, input); + const ftsMatch = "ftsMatch" in input ? input.ftsMatch ?? null : null; + const patternTerms = "patternTerms" in input ? input.patternTerms ?? [] : []; + + const haveVec = !!queryVec && queryVec.length > 0; + const haveFts = !!ftsMatch && !!repos.skills.searchByText; + const havePattern = patternTerms.length > 0 && !!repos.skills.searchByPattern; + if (!haveVec && !haveFts && !havePattern) { + log.debug("empty_query", { reason: "no channels armed" }); + return []; + } + + const vecPoolSize = Math.max( + config.tier1TopK, + Math.ceil(config.tier1TopK * config.candidatePoolFactor), + ); + const keywordPoolSize = Math.max( + config.tier1TopK, + config.keywordTopK ?? DEFAULT_KEYWORD_TOPK, + ); + const statusIn: SkillStatus[] = ["active", "candidate"]; + + const merged = new Map(); + + if (haveVec) { + const vecHits = repos.skills.searchByVector(queryVec!, vecPoolSize, { statusIn }); + vecHits.forEach((h, idx) => { + if (h.score < config.minTraceSim) return; + upsertCandidate(merged, h.id as SkillId, { + cosine: h.score, + channel: "vec", + rank: idx, + score: h.score, + meta: h.meta, + vec: queryVec!, + }); + }); + } + + if (haveFts) { + const ftsHits = repos.skills.searchByText!(ftsMatch!, keywordPoolSize, { statusIn }); + ftsHits.forEach((h, idx) => { + upsertCandidate(merged, h.id as SkillId, { + cosine: 0, + channel: "fts", + rank: idx, + score: h.score, + meta: h.meta, + vec: queryVec ?? null, + }); + }); + } + + if (havePattern) { + const patternHits = repos.skills.searchByPattern!(patternTerms, keywordPoolSize, { + statusIn, + }); + patternHits.forEach((h, idx) => { + upsertCandidate(merged, h.id as SkillId, { + cosine: 0, + channel: "pattern", + rank: idx, + score: h.score, + meta: h.meta, + vec: queryVec ?? null, + }); + }); + } + + if (merged.size === 0) { + log.info("done", { + candidates: 0, + kept: 0, + latencyMs: Date.now() - startedAt, + }); + return []; + } + + // Hydrate each candidate into a `SkillCandidate`. + const kept: SkillCandidate[] = []; + for (const [id, state] of merged) { + const meta = state.meta; + // Hard floor on η — applies regardless of which channel surfaced + // the row. Stale skills shouldn't sneak back via the keyword path. + if (!meta || meta.eta < config.minSkillEta) continue; + const sk = repos.skills.getById(id as SkillId); + if (!sk) continue; + kept.push({ + tier: "tier1", + refKind: "skill", + refId: sk.id, + cosine: state.cosine, + ts: Date.now(), + vec: state.vec, + skillName: sk.name, + eta: sk.eta, + status: sk.status, + invocationGuide: sk.invocationGuide, + sourcePolicyIds: sk.sourcePolicyIds ?? [], + updatedAt: sk.updatedAt, + // Origin attribution — surfaced into the rendered prompt as + // "(learned by )" by injector.ts. Added 2026-05-17. + ownerProfileId: sk.ownerProfileId, + ownerAgentKind: sk.ownerAgentKind, + channels: state.channels, + debug: { matchedChannels: state.channels.map((c) => c.channel) }, + }); + } + + // Rough order — final ranking happens in `ranker.ts` via RRF + MMR. + kept.sort((a, b) => bestChannelScore(b) - bestChannelScore(a)); + const trimmed = kept.slice(0, vecPoolSize); + + log.info("done", { + candidates: merged.size, + kept: trimmed.length, + channels: { + vec: haveVec, + fts: haveFts, + pattern: havePattern, + }, + latencyMs: Date.now() - startedAt, + }); + return trimmed; + } catch (err) { + log.error("failed", { + err: { message: err instanceof Error ? err.message : String(err) }, + latencyMs: Date.now() - startedAt, + }); + return []; + } +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function upsertCandidate( + into: Map, + id: SkillId, + patch: { + cosine: number; + channel: RetrievalChannel; + rank: number; + score: number; + meta?: { name: string; status: SkillStatus; eta: number; gain: number }; + vec: EmbeddingVector | null; + }, +): void { + const entry = into.get(id); + if (!entry) { + if (!patch.meta) return; + into.set(id, { + cosine: patch.cosine, + channels: [{ channel: patch.channel, rank: patch.rank, score: patch.score }], + meta: patch.meta, + vec: patch.vec, + }); + return; + } + entry.channels.push({ channel: patch.channel, rank: patch.rank, score: patch.score }); + if (patch.cosine > entry.cosine) entry.cosine = patch.cosine; + if (!entry.vec && patch.vec) entry.vec = patch.vec; +} + +function bestChannelScore(c: SkillCandidate): number { + const channels = c.channels ?? []; + if (channels.length === 0) return c.cosine; + return channels.reduce((m, ch) => Math.max(m, ch.score), c.cosine); +} + +async function resolveVec( + deps: Tier1Deps, + input: Tier1Input, +): Promise { + if (input.kind === "embedded") return input.queryVec; + if (!deps.embedder) return null; + try { + return await deps.embedder.embed(input.text, "query"); + } catch (err) { + log.warn("embed_failed", { err: String(err) }); + return null; + } +} diff --git a/tools/plugin-patches/core/retrieval/types.ts b/tools/plugin-patches/core/retrieval/types.ts new file mode 100644 index 0000000..fcfcccb --- /dev/null +++ b/tools/plugin-patches/core/retrieval/types.ts @@ -0,0 +1,788 @@ +/** + * Internal DTOs for the Tier-1/2/3 retrieval pipeline. + * + * These types are intentionally private to `core/retrieval` — public callers + * consume the `InjectionPacket` DTO from `agent-contract/dto.ts` via one of + * the `retrieve.ts` entry functions. + */ + +import type { + AgentKind, + EpisodeId, + InjectionPacket, + InjectionSnippet, + RepairCtx, + RetrievalReason, + SessionId, + ToolDrivenCtx, + TurnStartCtx, + EpochMs, + RuntimeNamespace, +} from "../../agent-contract/dto.js"; + +import type { + EmbeddingVector, + FeedbackId, + PolicyId, + PolicyRow, + SkillId, + TraceId, + WorldModelId, + ValueScore, +} from "../types.js"; + +// ─── Tier identifiers ──────────────────────────────────────────────────────── + +/** The three cost/benefit tiers; internal use only. */ +export type TierKind = "tier1" | "tier2" | "tier3"; + +/** Which `vec_*` column on `traces` a Tier-2 hit came from. */ +export type TraceVecKind = "summary" | "action"; + +// ─── Raw candidates produced by each tier ──────────────────────────────────── + +/** + * A "candidate" is what a tier returns *before* fusion/MMR. It carries the + * raw cosine plus any tier-specific metadata needed for re-ranking. + */ +/** + * Channels that may contribute a hit. Tracked per candidate so the + * ranker can RRF-fuse across channels and so logs/Logs view can show + * "matched via vector + fts" for each snippet. + */ +export type RetrievalChannel = + | "vec_summary" + | "vec_action" + | "vec" + | "fts" + | "pattern" + | "structural"; + +/** Per-channel rank carried on a candidate, used by the RRF pass. */ +export interface ChannelRank { + channel: RetrievalChannel; + /** 0-based rank within the channel (lower = better). */ + rank: number; + /** Raw channel score (cosine for vec, reciprocal-rank for fts/pattern). */ + score: number; +} + +export interface TierCandidateBase { + /** Stable snippet id — primary key in its source table. */ + refId: string; + /** Tier this candidate came from. */ + tier: TierKind; + /** + * Best cosine across vector channels, in [-1, 1] (clamped). For + * keyword-only hits this is `0` — the candidate then gets its + * "relevance" entirely from the channel-rank RRF. + */ + cosine: number; + /** When the underlying row was written. */ + ts: EpochMs; + /** Used for MMR redundancy against already-selected snippets. */ + vec: EmbeddingVector | null; + /** + * One entry per channel that contributed this candidate. Multiple + * channels indicating the same row → bigger RRF lift. + * + * Optional so legacy callers / unit tests can still construct + * candidates with just `vec` cosine; the ranker defensively defaults + * to `[]` and falls back to cosine-only relevance. + */ + channels?: ChannelRank[]; + /** Free-form debug blob echoed back through events/logs. */ + debug?: Record; +} + +/** Skill lifecycle label (mirrors `SkillRow["status"]`). */ +export type SkillStatus = "candidate" | "active" | "archived"; + +/** Tier 1 — a matched Skill. */ +export interface SkillCandidate extends TierCandidateBase { + tier: "tier1"; + refKind: "skill"; + refId: SkillId; + skillName: string; + eta: number; + status: SkillStatus; + invocationGuide: string; + sourcePolicyIds?: PolicyId[]; + updatedAt?: EpochMs; + // Originating profile so renderer can attribute "(learned by sergio)" when + // skills are shared across agents. Added 2026-05-17 — see Hermes + // memos-setup/learnings/2026-05-17-v2-only-bge-shares.md (Attribution + // Patch). + ownerProfileId?: string; + ownerAgentKind?: string; +} + +/** Tier 2a — a single high-value trace. */ +export interface TraceCandidate extends TierCandidateBase { + tier: "tier2"; + refKind: "trace"; + refId: TraceId; + value: ValueScore; + priority: number; + episodeId: EpisodeId; + sessionId: SessionId; + /** Which vector column this hit came from (summary vs action). */ + vecKind: TraceVecKind; + userText: string; + agentText: string; + /** + * LLM-generated summary line (same string stored in + * `traces.summary`). `null` when the summarizer failed open. When + * present, renderers prefer it over `userText` — keeps the + * injection block skim-able. + */ + summary: string | null; + reflection: string | null; + tags: string[]; +} + +/** Tier 2b — a grouped episode summary (synthesised from the best trace). */ +export interface EpisodeCandidate extends TierCandidateBase { + tier: "tier2"; + refKind: "episode"; + refId: EpisodeId; + sessionId: SessionId; + /** Summary text synthesised from the top traces of this episode. */ + summary: string; + /** Max value of any trace rolled up into this summary. */ + maxValue: ValueScore; + meanPriority: number; +} + +/** Tier 2c - a typed user-feedback experience. */ +export interface ExperienceCandidate extends TierCandidateBase { + tier: "tier2"; + refKind: "experience"; + refId: PolicyId; + title: string; + trigger: string; + procedure: string; + verification: string; + boundary: string; + support: number; + gain: number; + status: "candidate" | "active" | "archived"; + experienceType: NonNullable; + evidencePolarity: NonNullable; + salience: number; + confidence: number; + skillEligible: boolean; + sourceEpisodeIds: EpisodeId[]; + sourceFeedbackIds: FeedbackId[]; + sourceTraceIds: TraceId[]; + decisionGuidance: { preference: string[]; antiPattern: string[] }; + updatedAt: EpochMs; +} + +/** Tier 3 — a matched world-model snippet. */ +export interface WorldModelCandidate extends TierCandidateBase { + tier: "tier3"; + refKind: "world-model"; + refId: WorldModelId; + /** Short label (e.g. "docker-compose"). */ + title: string; + /** Full world-model body — environment topology + inference rules. */ + body: string; + /** Associated L2 policy ids (surfaced for cross-referencing). */ + policyIds: string[]; +} + +export type TierCandidate = + | SkillCandidate + | TraceCandidate + | EpisodeCandidate + | ExperienceCandidate + | WorldModelCandidate; + +// ─── Ranker / fused snippets ──────────────────────────────────────────────── + +/** + * A candidate promoted to the final snippet list, after fusion+MMR. + * `finalScore` is monotone-decreasing down the `snippets` array. + */ +export interface RankedSnippet { + snippet: InjectionSnippet; + tier: TierKind; + /** Monotone score used by MMR (blend of cosine & V·decay). */ + relevance: number; + /** MMR-penalised score ≤ relevance (same shape — tie-break by input order). */ + finalScore: number; + /** The raw candidate this snippet was minted from (for debug/logging). */ + origin: TierCandidate; +} + +// ─── Config + dependencies ────────────────────────────────────────────────── + +/** + * Snapshot of `algorithm.retrieval.*` — we accept a plain object so tests can + * build minimal configs without standing up the TypeBox defaults. + */ +export interface RetrievalConfig { + tier1TopK: number; + tier2TopK: number; + tier3TopK: number; + + candidatePoolFactor: number; + weightCosine: number; + weightPriority: number; + mmrLambda: number; + includeLowValue: boolean; + rrfConstant: number; + minSkillEta: number; + minTraceSim: number; + tagFilter: "auto" | "off" | "strict"; + + /** Per-tier keyword (FTS + pattern) channel size. Default 20. */ + keywordTopK?: number; + /** + * Drop candidates with `relevance < topRelevance · this`. 0 disables + * the relative cutoff. Default 0.4. + */ + relativeThresholdFloor?: number; + /** + * Tier-1 skill relevance blend = `weightCosine · cos + skillEtaBlend · η`. + * Defaults to 0.15 (cosine dominates, η is just a nudge). + */ + skillEtaBlend?: number; + /** + * Smart MMR seeding — only seed a tier if its best candidate clears + * `topRelevance · smartSeedRatio` (see below). Default true. + * `smartSeed: false` restores the legacy "seed best of every non-empty + * tier" behaviour regardless of relevance. + */ + smartSeed?: boolean; + /** + * When `smartSeed` is on, only seed a tier whose best candidate's + * relevance is ≥ `poolTopRelevance · smartSeedRatio`. Default 0.7. + * Independent of `relativeThresholdFloor` so the seed gate can be + * stricter than the generic drop floor. + */ + smartSeedRatio?: number; + /** + * If a candidate is surfaced by ≥ 2 channels, bypass the relative + * threshold (it still participates in MMR). This compensates for + * the ranker's base formula being "max channel score + additive + * boosts" — a two-channel agreement is a strong signal even when + * the absolute score falls below the drop floor. Default true. + */ + multiChannelBypass?: boolean; + + /** + * V7 §2.6 Tier-1 rendering mode. + * - "summary" (default): inject `name + η + first-line summary + + * a `skill_get(id="…")` invocation hint`. Lets the host model + * pull the full procedure on demand instead of bloating every + * prompt with skills it may never use. + * - "full": inline the full `invocationGuide` body (legacy). + */ + skillInjectionMode?: "summary" | "full"; + /** Per-skill summary char cap when `skillInjectionMode === "summary"`. */ + skillSummaryChars?: number; + + /** + * Minimum cosine between the current query and the best trace in a + * candidate episode for Tier 2b "episode replay" to fire. Below this, + * we skip the episode rollup entirely — better no reference than a + * misleading one. Typical range 0.35–0.6. + */ + episodeGoalMinSim?: number; + + /** Priority decay half-life (mirrors `algorithm.reward.decayHalfLifeDays`). */ + decayHalfLifeDays: number; + + /** + * LLM-based relevance filter that runs AFTER rank/MMR and BEFORE the + * packet is rendered. Mirrors the legacy `memos-local-openclaw` + * `unifiedLLMFilter`: tier retrieval is greedy (cosine similarity), + * so it's common to see surface-similar but semantically-off + * candidates. The filter asks a small LLM call to keep only the + * genuinely relevant ones before injection. + * + * When `llmFilterEnabled` is false or the LLM is unavailable, the + * ranked list is passed through unchanged. + */ + llmFilterEnabled: boolean; + /** Keep at most N candidates after the LLM filter. */ + llmFilterMaxKeep: number; + /** Skip the filter entirely when the ranked list has fewer than this many items. */ + llmFilterMinCandidates: number; + /** + * Max chars of body text to show the LLM filter for each candidate. + * Higher = more context for precise judgement, at the cost of more + * tokens per round-trip. Default 500 (openclaw uses 300 without + * tags/channels; we include richer metadata so a slightly bigger + * window pays for itself). + */ + llmFilterCandidateBodyChars?: number; +} + +/** + * The minimum surface `core/retrieval` needs from storage. Structural typing + * lets us pass the real repos in prod and lightweight fakes in unit tests. + */ +export interface RetrievalRepos { + skills: { + searchByVector: ( + query: EmbeddingVector, + k: number, + opts?: { + statusIn?: SkillStatus[]; + hardCap?: number; + }, + ) => Array<{ + id: string; + score: number; + meta?: { + name: string; + status: SkillStatus; + eta: number; + gain: number; + }; + }>; + /** + * FTS5 trigram MATCH against `skills_fts`. Score is reciprocal rank + * `1 / (idx+1)` so the ranker can fuse via the same RRF pass it + * uses for cosine hits. + */ + searchByText?: ( + ftsMatch: string, + k: number, + opts?: { statusIn?: SkillStatus[] }, + ) => Array<{ + id: string; + score: number; + meta?: { + name: string; + status: SkillStatus; + eta: number; + gain: number; + }; + }>; + /** + * LIKE pattern fallback for queries that fall below the trigram + * window (e.g. 2-char Chinese names). Same `meta` shape as the + * other channels. + */ + searchByPattern?: ( + terms: readonly string[], + k: number, + opts?: { statusIn?: SkillStatus[] }, + ) => Array<{ + id: string; + score: number; + meta?: { + name: string; + status: SkillStatus; + eta: number; + gain: number; + }; + }>; + getById: (id: SkillId) => { + id: SkillId; + name: string; + status: SkillStatus; + invocationGuide: string; + eta: number; + sourcePolicyIds?: PolicyId[]; + updatedAt?: EpochMs; + } | null; + }; + + traces: { + searchByVector: ( + query: EmbeddingVector, + k: number, + opts?: { + kind?: TraceVecKind; + where?: string; + params?: Record; + hardCap?: number; + anyOfTags?: readonly string[]; + }, + ) => Array<{ + id: string; + score: number; + meta?: { + ts: number; + priority: number; + value: number; + episode_id: EpisodeId; + session_id: SessionId; + tags_json?: string; + }; + }>; + /** FTS5 trigram MATCH against `traces_fts`. */ + searchByText?: ( + ftsMatch: string, + k: number, + opts?: { where?: string; params?: Record }, + ) => Array<{ + id: string; + score: number; + meta?: { + ts: number; + priority: number; + value: number; + episode_id: EpisodeId; + session_id: SessionId; + tags_json?: string; + }; + }>; + /** LIKE pattern fallback (CJK bigrams + short ASCII). */ + searchByPattern?: ( + terms: readonly string[], + k: number, + opts?: { where?: string; params?: Record }, + ) => Array<{ + id: string; + score: number; + meta?: { + ts: number; + priority: number; + value: number; + episode_id: EpisodeId; + session_id: SessionId; + tags_json?: string; + }; + }>; + getManyByIds: (ids: readonly TraceId[]) => Array<{ + id: TraceId; + episodeId: EpisodeId; + sessionId: SessionId; + ts: EpochMs; + userText: string; + agentText: string; + /** + * Optional LLM-generated summary for this trace. Nullable when + * the summarizer failed open at capture time. + */ + summary?: string | null; + reflection: string | null; + value: number; + priority: number; + tags: string[]; + vecSummary: EmbeddingVector | null; + vecAction: EmbeddingVector | null; + }>; + /** + * V7 §2.6 structural match — exact-substring lookup over stored + * error signatures. Newest-first, capped at `limit`. + */ + searchByErrorSignature: ( + anyOfFragments: readonly string[], + limit: number, + opts?: { + where?: string; + params?: Record; + }, + ) => Array<{ + id: TraceId; + episodeId: EpisodeId; + sessionId: SessionId; + ts: EpochMs; + userText: string; + agentText: string; + summary?: string | null; + reflection: string | null; + value: number; + priority: number; + tags: string[]; + errorSignatures?: string[]; + }>; + }; + + worldModel: { + searchByVector: ( + query: EmbeddingVector, + k: number, + opts?: { hardCap?: number }, + ) => Array<{ + id: string; + score: number; + meta?: { title: string }; + }>; + /** FTS5 trigram MATCH against `world_model_fts`. */ + searchByText?: ( + ftsMatch: string, + k: number, + opts?: { minConfidence?: number }, + ) => Array<{ + id: string; + score: number; + meta?: { title: string }; + }>; + /** LIKE pattern fallback. */ + searchByPattern?: ( + terms: readonly string[], + k: number, + opts?: { minConfidence?: number }, + ) => Array<{ + id: string; + score: number; + meta?: { title: string }; + }>; + getById: (id: WorldModelId) => { + id: WorldModelId; + title: string; + body: string; + policyIds: string[]; + } | null; + }; + + /** + * V7 §2.4.6 — minimal slice of the `policies` repo used to surface + * `decision_guidance` (preference / anti-pattern). Policies aren't + * directly tier-ranked; we look them up to attach guidance to the + * traces / skills already chosen by tiers 1 + 2. + * + * `list({status: "active"})` is called once per retrieval pass and + * the result is filtered in JS by `sourceEpisodeIds` / id matching. + * Active policy sets are bounded (typically < 200 per install) so + * the full scan is cheap and avoids a per-trace round-trip. + * + * Optional so unit-test fakes that don't care about guidance can + * skip wiring it. When undefined, retrieval simply emits no + * decision-guidance section. + */ + policies?: { + searchByVector?: ( + query: EmbeddingVector, + k: number, + opts?: { + statusIn?: Array<"candidate" | "active" | "archived">; + hardCap?: number; + }, + ) => Array<{ + id: string; + score: number; + meta?: { + title: string; + status: "candidate" | "active" | "archived"; + support: number; + gain: number; + experience_type?: NonNullable; + evidence_polarity?: NonNullable; + salience?: number; + confidence?: number; + }; + }>; + searchByText?: ( + ftsMatch: string, + k: number, + opts?: { + statusIn?: Array<"candidate" | "active" | "archived">; + }, + ) => Array<{ + id: string; + score: number; + meta?: { + title: string; + status: "candidate" | "active" | "archived"; + support: number; + gain: number; + experience_type?: NonNullable; + evidence_polarity?: NonNullable; + salience?: number; + confidence?: number; + }; + }>; + searchByPattern?: ( + terms: readonly string[], + k: number, + opts?: { + statusIn?: Array<"candidate" | "active" | "archived">; + }, + ) => Array<{ + id: string; + score: number; + meta?: { + title: string; + status: "candidate" | "active" | "archived"; + support: number; + gain: number; + experience_type?: NonNullable; + evidence_polarity?: NonNullable; + salience?: number; + confidence?: number; + }; + }>; + list: (filter?: { + status?: "candidate" | "active" | "archived"; + }) => Array<{ + id: string; + title: string; + trigger?: string; + procedure?: string; + verification?: string; + boundary?: string; + support?: number; + gain?: number; + status?: "candidate" | "active" | "archived"; + experienceType?: NonNullable; + evidencePolarity?: NonNullable; + salience?: number; + confidence?: number; + skillEligible?: boolean; + sourceEpisodeIds: EpisodeId[]; + sourceFeedbackIds?: FeedbackId[]; + sourceTraceIds?: TraceId[]; + decisionGuidance: { preference: string[]; antiPattern: string[] }; + vec?: EmbeddingVector | null; + updatedAt?: EpochMs; + }>; + getById: (id: string) => { + id: string; + title: string; + trigger?: string; + procedure?: string; + verification?: string; + boundary?: string; + support?: number; + gain?: number; + status?: "candidate" | "active" | "archived"; + experienceType?: NonNullable; + evidencePolarity?: NonNullable; + salience?: number; + confidence?: number; + skillEligible?: boolean; + sourceEpisodeIds: EpisodeId[]; + sourceFeedbackIds?: FeedbackId[]; + sourceTraceIds?: TraceId[]; + decisionGuidance: { preference: string[]; antiPattern: string[] }; + vec?: EmbeddingVector | null; + updatedAt?: EpochMs; + } | null; + }; +} + +/** Abstract embedder surface consumed by retrieval. Mirrors `Embedder`. */ +export interface RetrievalEmbedder { + embed: (text: string, role?: "query" | "document") => Promise; +} + +export interface RetrievalDeps { + repos: RetrievalRepos; + embedder: RetrievalEmbedder; + config: RetrievalConfig; + namespace: RuntimeNamespace; + now: () => EpochMs; + /** + * Optional LLM used by the post-rank relevance filter + * (`llm-filter.ts`). Null disables the step; retrieval stays purely + * mechanical. Kept off the required surface so unit tests can omit + * it. + */ + llm?: import("../llm/index.js").LlmClient | null; +} + +// ─── Results ───────────────────────────────────────────────────────────────── + +/** Summary returned to orchestration callers. */ +export interface RetrievalResult { + packet: InjectionPacket; + /** Stats useful for logs/metrics/UI. Always populated. */ + stats: RetrievalStats; +} + +export interface RetrievalStats { + reason: RetrievalReason; + agent: AgentKind; + sessionId: SessionId; + episodeId?: EpisodeId; + tier1Count: number; + tier2Count: number; + tier3Count: number; + tier1LatencyMs: number; + tier2LatencyMs: number; + tier3LatencyMs: number; + fuseLatencyMs: number; + totalLatencyMs: number; + queryTokens: number; + queryTags: string[]; + emptyPacket: boolean; + /** Query embedding status. `degraded=true` means vector recall was unavailable. */ + embedding?: { + attempted: boolean; + ok: boolean; + degraded: boolean; + errorCode?: string; + errorMessage?: string; + }; + /** + * Observability breakdown — populated so the Logs page (and + * api_logs) can show "how many candidates survived each stage" and + * operators can spot "this stage is the lossy one" at a glance. + * All fields are optional so legacy callers / older RetrievalStats + * consumers keep compiling. + */ + rawCandidateCount?: number; + droppedByThresholdCount?: number; + thresholdFloor?: number; + topRelevance?: number; + rankedCount?: number; + llmFilterOutcome?: + | "disabled" + | "no_llm" + | "below_threshold" + | "empty_query" + | "llm_kept_all" + | "llm_filtered" + | "llm_failed_safe_cutoff"; + llmFilterSufficient?: boolean; + llmFilterKept?: number; + llmFilterDropped?: number; + /** + * Channel hit counts across all tiers, e.g. + * `{ vec_summary: 12, fts: 7, pattern: 3, structural: 0 }`. Helps + * identify queries that got hits only through one channel (likely + * fragile). + */ + channelHits?: Partial>; +} + +/** Discriminated context union — one per entry point in `retrieve.ts`. */ +export type RetrievalCtx = + | ({ reason: "turn_start" } & TurnStartCtx) + | ({ reason: "tool_driven" } & ToolDrivenCtx) + | ({ reason: "skill_invoke" } & SkillInvokeCtx) + | ({ reason: "sub_agent" } & SubAgentCtx) + | ({ reason: "decision_repair" } & RepairCtx); + +/** Called when the host model decides to invoke a specific Skill. */ +export interface SkillInvokeCtx { + agent: AgentKind; + namespace?: RuntimeNamespace; + sessionId: SessionId; + episodeId?: EpisodeId; + /** Skill id we're about to run (or a free-form query if id unknown). */ + skillId?: SkillId; + /** Natural-language tool invocation args, for embedding fallback. */ + query: string; + ts: EpochMs; +} + +/** Called when a sub-agent is spawned with a mission query. */ +export interface SubAgentCtx { + agent: AgentKind; + namespace?: RuntimeNamespace; + sessionId: SessionId; + episodeId?: EpisodeId; + /** The sub-agent mission / system prompt head. */ + mission: string; + /** Coarse agent profile (e.g. "planner", "coder"). */ + profile?: string; + ts: EpochMs; +} diff --git a/tools/plugin-patches/core/reward/backprop.ts b/tools/plugin-patches/core/reward/backprop.ts new file mode 100644 index 0000000..cf4cace --- /dev/null +++ b/tools/plugin-patches/core/reward/backprop.ts @@ -0,0 +1,132 @@ +/** + * `backprop` — V7 §0.6 eq. 4+5 + §3.3 priority formula. + * + * Given traces in chronological order and a terminal reward `rHuman`, + * compute `V_t` for each step by walking RIGHT-TO-LEFT: + * + * V_T = R_human + * V_t = α_t · R_human + (1 − α_t) · γ · V_{t+1} + * + * Then compute priority with exponential time decay: + * + * priority(f1_t) = max(V_t, 0) · decay(Δt) + * decay(Δt) = 0.5 ^ (Δt_days / halfLifeDays) + * + * Pure function — no I/O. The caller persists via `tracesRepo.updateScore`. + * + * Design notes: + * - `alpha` is already clamped to [0, 1] by capture, but we clamp again + * defensively in case a downstream rescoring widened it. + * - `rHuman` is clamped to [-1, 1] to guarantee `V_t` stays in range. + * - A trace with no reflection (α=0) gets V_t via pure γ-discount, which + * matches V7 §0.6: "pure trial-and-error steps propagate by γ only". + * - Priority uses `max(V, 0)` because V7 §3.3 says negative value traces + * sink to the bottom but MUST remain on disk — they can still be + * surfaced by Decision Repair. + * - We do NOT touch `r_human` or `alpha` on the trace row: α stays + * capture-owned; r_human is episode-level and lives in `episodes.r_task`. + */ + +import { rootLogger } from "../logger/index.js"; +import type { BackpropInput, BackpropResult, BackpropUpdate } from "./types.js"; + +const MS_PER_DAY = 86_400_000; + +// Per-step penalty applied to V when a trace recorded tool errors / failures +// (non-empty error_signatures). These objective outcome signals are captured +// at trace time but were never fed into reward — so a step that hit repeated +// tool failures (e.g. a web_search retry loop) used to score the same as a +// clean step. Subtracting a fixed penalty pushes failed steps below neutral so +// they rank lower and can seed failure_avoidance induction. +const ERROR_OUTCOME_PENALTY = 0.35; + +export function backprop(input: BackpropInput): BackpropResult { + const log = rootLogger.child({ channel: "core.reward.backprop" }); + + const gamma = clamp(input.gamma, 0, 1); + const rHuman = clamp(input.rHuman, -1, 1); + const now = input.now ?? Date.now(); + const halfLife = Math.max(1, input.decayHalfLifeDays); + + const updates: BackpropUpdate[] = new Array(input.traces.length); + if (input.traces.length === 0) { + return { + updates: [], + meanAbsValue: 0, + maxPriority: 0, + echoParams: { gamma, decayHalfLifeDays: halfLife, now }, + }; + } + + // Walk last → first so V_{t+1} is always available. + let nextV = rHuman; + let sumAbsV = 0; + let maxPriority = 0; + + for (let i = input.traces.length - 1; i >= 0; i--) { + const t = input.traces[i]!; + const alpha = clamp(t.alpha, 0, 1); + const Vbase = i === input.traces.length - 1 + ? rHuman // V_T = R_human (V7 §0.6 boundary case) + : alpha * rHuman + (1 - alpha) * gamma * nextV; + + // Objective outcome penalty: a step that recorded tool errors/failures is + // pushed below its purely-reflective value so failures rank low and feed + // failure_avoidance induction. + const hasError = Array.isArray(t.errorSignatures) && t.errorSignatures.length > 0; + const V = hasError ? clamp(Vbase - ERROR_OUTCOME_PENALTY, -1, 1) : Vbase; + + const dtDays = Math.max(0, (now - t.ts) / MS_PER_DAY); + const decay = Math.pow(0.5, dtDays / halfLife); + const priority = Math.max(V, 0) * decay; + + updates[i] = { + traceId: t.id, + value: V, + alpha, + priority, + }; + sumAbsV += Math.abs(V); + if (priority > maxPriority) maxPriority = priority; + nextV = V; + } + + const meanAbsValue = sumAbsV / updates.length; + + log.debug("backprop.computed", { + traces: updates.length, + rHuman, + gamma, + meanAbsValue, + maxPriority, + }); + + return { + updates, + meanAbsValue, + maxPriority, + echoParams: { gamma, decayHalfLifeDays: halfLife, now }, + }; +} + +/** + * Standalone helper: priority for an existing (V, ts) pair. Exposed for + * `core/memory/l1` retrieval tests and the L3 abstraction pass, both of + * which need to reweight traces without re-running backprop. + */ +export function priorityFor( + value: number, + ts: number, + decayHalfLifeDays: number, + now = Date.now(), +): number { + const halfLife = Math.max(1, decayHalfLifeDays); + const dtDays = Math.max(0, (now - ts) / MS_PER_DAY); + const decay = Math.pow(0.5, dtDays / halfLife); + return Math.max(value, 0) * decay; +} + +function clamp(v: number, lo: number, hi: number): number { + if (!Number.isFinite(v)) return 0; + return Math.max(lo, Math.min(hi, v)); +} diff --git a/tools/plugin-patches/core/runtime/request-namespace.ts b/tools/plugin-patches/core/runtime/request-namespace.ts new file mode 100644 index 0000000..8926760 --- /dev/null +++ b/tools/plugin-patches/core/runtime/request-namespace.ts @@ -0,0 +1,33 @@ +/** + * Per-request namespace override via AsyncLocalStorage. + * + * Added by the Hermes integration (2026-05-17) to support a "view as + * " toggle in the bundled viewer. The HTTP dispatch layer parses + * `?as_profile=` or `X-As-Profile: ` from each request and runs + * the handler inside `runWithRequestNamespace(ns, …)`. Visibility checks in + * `core/pipeline/memory-core.ts` (`visibleToCurrent`, etc.) prefer the + * ALS-scoped namespace over the daemon's startup-bound `activeNamespace`, + * so list/get endpoints automatically show the requested profile's rows. + * + * Search endpoints already accept `query.namespace` explicitly — they + * are wired separately in `server/routes/memory.ts`. + * + * See: memos-setup/learnings/2026-05-17-v2-only-bge-shares.md + * (section "Per-agent toggle in the bundled viewer") + */ + +import { AsyncLocalStorage } from "node:async_hooks"; +import type { RuntimeNamespace } from "./namespace.js"; + +const als = new AsyncLocalStorage(); + +export function runWithRequestNamespace( + ns: RuntimeNamespace, + fn: () => T | Promise, +): T | Promise { + return als.run(ns, fn); +} + +export function getRequestNamespace(): RuntimeNamespace | undefined { + return als.getStore(); +} diff --git a/tools/plugin-patches/core/skill/packager.ts b/tools/plugin-patches/core/skill/packager.ts new file mode 100644 index 0000000..d432e5c --- /dev/null +++ b/tools/plugin-patches/core/skill/packager.ts @@ -0,0 +1,277 @@ +/** + * Converts a verified crystallization draft into a `SkillRow` ready for + * insertion via `repos.skills`. + * + * Structured fields live in `procedureJson` so the viewer can render every + * facet without parsing `invocationGuide`. The invocation guide itself is a + * deterministic markdown render of the draft — it's what the retrieval + * injector hands to the agent's prompt. + * + * We also compute the skill embedding here (summary + steps + policy + * `trigger`) so Tier-1 retrieval is vector-ready. + */ + +import { now as nowMs } from "../time.js"; +import type { Embedder } from "../embedding/types.js"; +import type { Logger } from "../logger/types.js"; +import { ids } from "../id.js"; +import type { + EmbeddingVector, + EpisodeId, + PolicyId, + PolicyRow, + SkillId, + SkillRow, + TraceId, + WorldModelId, +} from "../types.js"; +import type { + SkillConfig, + SkillCrystallizationDraft, + SkillProcedure, +} from "./types.js"; + +export interface PackagerInput { + draft: SkillCrystallizationDraft; + policy: PolicyRow; + evidenceEpisodeIds: EpisodeId[]; + /** + * V7 §2.1 `evidence_anchors` — the L1 trace ids that justified this + * skill at crystallisation time. Persisted onto the skill so the + * viewer can render click-through chips back to MemoriesView and + * future audits don't have to re-run `gatherEvidence()`. + * + * Best-first ordering (matches `gatherEvidence` output). Capped to + * `EVIDENCE_ANCHORS_CAP` ids in the packager — keeps the column + * small and the JSON roundtrip cheap. + */ + evidenceTraceIds?: TraceId[]; + worldModelIds?: WorldModelId[]; + /** When rebuilding, we keep the existing skill id + accumulated trials. */ + existing?: SkillRow | null; +} + +/** Hard cap on `SkillRow.evidenceAnchors` so the JSON column stays small. */ +const EVIDENCE_ANCHORS_CAP = 10; + +export interface PackagerDeps { + embedder: Embedder | null; + log: Logger; + config: SkillConfig; +} + +export interface PackagerResult { + row: SkillRow; + vecSource: string; + freshMint: boolean; +} + +/** + * Shape the draft + policy into a `SkillRow`. Does not persist. + */ +export async function buildSkillRow( + input: PackagerInput, + deps: PackagerDeps, +): Promise { + const { draft, policy, existing } = input; + const now = nowMs(); + const freshMint = !existing; + const id: SkillId = (existing?.id ?? ids.skill()) as SkillId; + + const procedure = buildProcedure(draft); + const invocationGuide = renderInvocationGuide(draft, policy); + + const trialsAttempted = existing?.trialsAttempted ?? 0; + const trialsPassed = existing?.trialsPassed ?? 0; + const initialEta = deriveInitialEta(policy, existing ?? null, deps.config); + + const vecSource = buildVecSource(draft, policy); + const vec = await tryEmbed(deps, vecSource); + + // Merge new evidence with whatever the previous skill version had, + // keeping new (fresher / better-scoring) ids first and dropping + // duplicates. Capped at EVIDENCE_ANCHORS_CAP so a long-lived skill + // doesn't grow an unbounded list across many rebuilds. + const evidenceAnchors = dedupe([ + ...(input.evidenceTraceIds ?? []), + ...(existing?.evidenceAnchors ?? []), + ]).slice(0, EVIDENCE_ANCHORS_CAP); + + const row: SkillRow = { + id, + ownerAgentKind: existing?.ownerAgentKind ?? policy.ownerAgentKind, + ownerProfileId: existing?.ownerProfileId ?? policy.ownerProfileId, + ownerWorkspaceId: existing?.ownerWorkspaceId ?? policy.ownerWorkspaceId, + name: draft.name, + status: "candidate", + invocationGuide, + procedureJson: procedure, + eta: initialEta, + support: policy.support, + gain: policy.gain, + trialsAttempted, + trialsPassed, + sourcePolicyIds: dedupe([policy.id, ...(existing?.sourcePolicyIds ?? [])]), + sourceWorldModelIds: dedupe([ + ...(existing?.sourceWorldModelIds ?? []), + ...(input.worldModelIds ?? []), + ]), + evidenceAnchors, + vec, + createdAt: (existing?.createdAt ?? (now as SkillRow["createdAt"])), + updatedAt: now as SkillRow["updatedAt"], + // Fresh skill starts at v1; every rebuild bumps the counter by one + // so the viewer can show "this skill has evolved N times" next to + // the timeline sourced from api_logs (skill_generate / skill_evolve). + version: existing ? (existing.version ?? 1) + 1 : 1, + usageCount: existing?.usageCount ?? 0, + lastUsedAt: existing?.lastUsedAt ?? null, + // Hermes integration 2026-05-17: skills are the validated, distilled + // output of the L2 → crystallisation pipeline. They are the layer that + // SHOULD be shared across agents (skills + world_model = 'local'; + // traces, policies, episodes = 'private'). Default new skills to + // share_scope='local' so the policy lands at write time rather than + // needing a separate promote step. If a rebuilt skill was explicitly + // demoted via the share API, preserve that override. + share: existing?.share ?? { scope: "local" as const, target: null, sharedAt: now }, + }; + + return { row, vecSource, freshMint }; +} + +function buildProcedure(draft: SkillCrystallizationDraft): SkillProcedure { + return { + summary: draft.summary, + parameters: draft.parameters, + preconditions: draft.preconditions, + steps: draft.steps, + examples: draft.examples, + decisionGuidance: draft.decisionGuidance ?? { preference: [], antiPattern: [] }, + tags: draft.tags, + tools: draft.tools ?? [], + }; +} + +function renderInvocationGuide( + draft: SkillCrystallizationDraft, + policy: PolicyRow, +): string { + const lines: string[] = []; + lines.push(`# ${draft.displayTitle}`); + lines.push(""); + if (draft.summary) { + lines.push(draft.summary); + lines.push(""); + } + lines.push(`**When to use**`); + lines.push(policy.trigger.trim() || "(derived from policy)"); + lines.push(""); + if (draft.preconditions.length) { + lines.push(`**Preconditions**`); + for (const p of draft.preconditions) lines.push(`- ${p}`); + lines.push(""); + } + if (draft.parameters.length) { + lines.push(`**Parameters**`); + for (const p of draft.parameters) { + const req = p.required ? " _(required)_" : ""; + lines.push(`- \`${p.name}\`: ${p.type}${req} — ${p.description || ""}`); + } + lines.push(""); + } + if (draft.steps.length) { + lines.push(`**Procedure**`); + draft.steps.forEach((s, i) => { + lines.push(`${i + 1}. **${s.title}** — ${s.body}`); + }); + lines.push(""); + } + if (draft.examples.length) { + lines.push(`**Examples**`); + for (const e of draft.examples) { + lines.push(`- Input: \`${e.input}\``); + lines.push(` Expected: ${e.expected}`); + } + lines.push(""); + } + if (draft.tools && draft.tools.length > 0) { + lines.push(`**Tools used**`); + for (const t of draft.tools) lines.push(`- \`${t}\``); + lines.push(""); + } + const dg = draft.decisionGuidance; + if (dg && (dg.preference.length > 0 || dg.antiPattern.length > 0)) { + lines.push(`**Decision guidance**`); + if (dg.preference.length > 0) { + lines.push("Prefer:"); + for (const p of dg.preference) lines.push(`- ${p}`); + } + if (dg.antiPattern.length > 0) { + lines.push("Avoid:"); + for (const a of dg.antiPattern) lines.push(`- ${a}`); + } + lines.push(""); + } + return lines.join("\n").trimEnd(); +} + +function deriveInitialEta( + policy: PolicyRow, + existing: SkillRow | null, + cfg: SkillConfig, +): number { + if (existing && existing.trialsAttempted > 0) { + return clamp01(existing.eta); + } + const base = Math.min(1, Math.max(0, policy.gain)); + const supportWeight = Math.min(1, policy.support / Math.max(1, cfg.minSupport)); + const seed = 0.5 * base + 0.5 * supportWeight; + return clamp01(Math.max(cfg.minEtaForRetrieval, seed)); +} + +function buildVecSource( + draft: SkillCrystallizationDraft, + policy: PolicyRow, +): string { + const head = draft.summary || draft.displayTitle || draft.name; + const steps = draft.steps + .slice(0, 5) + .map((s) => `${s.title}: ${s.body}`) + .join("\n"); + const trigger = policy.trigger; + return [head, trigger, steps].filter(Boolean).join("\n"); +} + +async function tryEmbed( + deps: PackagerDeps, + text: string, +): Promise { + if (!deps.embedder || !text) return null; + try { + return await deps.embedder.embedOne({ text, role: "document" }); + } catch (err) { + deps.log.warn("skill.packager.embed_failed", { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +function dedupe(arr: readonly T[]): T[] { + const seen = new Set(); + const out: T[] = []; + for (const x of arr) { + if (x == null) continue; + if (seen.has(x)) continue; + seen.add(x); + out.push(x); + } + return out; +} + +function clamp01(n: number): number { + if (!Number.isFinite(n)) return 0; + if (n < 0) return 0; + if (n > 1) return 1; + return n; +} diff --git a/tools/plugin-patches/core/storage/repos/traces.ts b/tools/plugin-patches/core/storage/repos/traces.ts new file mode 100644 index 0000000..55349fc --- /dev/null +++ b/tools/plugin-patches/core/storage/repos/traces.ts @@ -0,0 +1,741 @@ +import type { EmbeddingVector, EpisodeId, SessionId, TraceId, TraceRow } from "../../types.js"; +import type { StorageDb, TraceListFilter } from "../types.js"; +import { buildInClause, buildInsert, buildUpdate } from "../tx.js"; +import { scanAndTopK, topKCosine, type VectorHit, type VectorRow } from "../vector.js"; +// Per-request namespace override (Hermes integration 2026-05-17). Lets the +// repo's paginated turn-key listing apply a namespace filter at SQL time so +// pagination is fair when a "view as" override is active. +import { getRequestNamespace } from "../../runtime/request-namespace.js"; +import { + buildPageClauses, + fromBlob, + fromJsonText, + joinWhere, + normalizeShareForStorage, + nullable, + ownerFieldsFromRaw, + ownerParamsFromRow, + timeRangeWhere, + toBlob, + toJsonText, +} from "./_helpers.js"; + +const COLUMNS = [ + "id", + "episode_id", + "session_id", + "owner_agent_kind", + "owner_profile_id", + "owner_workspace_id", + "ts", + "user_text", + "agent_text", + "summary", + "tool_calls_json", + "reflection", + "agent_thinking", + "value", + "alpha", + "r_human", + "priority", + "tags_json", + "error_signatures_json", + "vec_summary", + "vec_action", + "share_scope", + "share_target", + "shared_at", + "turn_id", + "schema_version", +]; + +export type TraceSearchMeta = { + ts: number; + priority: number; + value: number; + episode_id: EpisodeId; + session_id: SessionId; + owner_agent_kind?: string; + owner_profile_id?: string; + owner_workspace_id?: string | null; + tags_json?: string; + error_signatures_json?: string; +}; + +export function makeTracesRepo(db: StorageDb) { + const insert = db.prepare(buildInsert({ table: "traces", columns: COLUMNS })); + const upsert = db.prepare( + buildInsert({ table: "traces", columns: COLUMNS, onConflict: "replace" }), + ); + const updateScalars = db.prepare( + buildUpdate({ + table: "traces", + columns: ["id", "value", "alpha", "r_human", "priority"], + }), + ); + const selectById = db.prepare<{ id: string }, RawTraceRow>( + `SELECT ${COLUMNS.join(", ")} FROM traces WHERE id=@id`, + ); + + return { + insert(row: TraceRow): void { + insert.run(rowToParams(row)); + }, + + upsert(row: TraceRow): void { + upsert.run(rowToParams(row)); + }, + + updateScore( + id: TraceId, + scores: { value: number; alpha: number; rHuman?: number | null; priority: number }, + ): void { + updateScalars.run({ + id, + value: scores.value, + alpha: scores.alpha, + r_human: nullable(scores.rHuman ?? null) as number | null, + priority: scores.priority, + }); + }, + + getById(id: TraceId): TraceRow | null { + const r = selectById.get({ id }); + if (!r) return null; + return mapRow(r); + }, + + getManyByIds(ids: readonly TraceId[]): TraceRow[] { + if (ids.length === 0) return []; + const placeholders = buildInClause(ids.length); + const sql = `SELECT ${COLUMNS.join(", ")} FROM traces WHERE id ${placeholders}`; + const rows = db.prepare(sql).all(ids); + return rows.map(mapRow); + }, + + list(filter: TraceListFilter = {}): TraceRow[] { + const tr = timeRangeWhere(filter, "ts"); + const fragments: string[] = []; + const params: Record = { ...tr.params }; + if (filter.sessionId) { + fragments.push(`session_id = @session_id`); + params.session_id = filter.sessionId; + } + if (filter.episodeId) { + fragments.push(`episode_id = @episode_id`); + params.episode_id = filter.episodeId; + } + if (filter.minAbsValue !== undefined) { + fragments.push(`abs(value) >= @min_abs_value`); + params.min_abs_value = filter.minAbsValue; + } + if (tr.sql) fragments.push(tr.sql); + const where = joinWhere(fragments); + const page = buildPageClauses(filter, "ts"); + const sql = `SELECT ${COLUMNS.join(", ")} FROM traces ${where} ${page}`; + return db.prepare(sql).all(params).map(mapRow); + }, + + /** + * Total row count matching the same filter (no limit/offset). + * Used by list endpoints so the viewer can show "Page N of M". + */ + count(filter: Omit = {}): number { + const tr = timeRangeWhere(filter, "ts"); + const fragments: string[] = []; + const params: Record = { ...tr.params }; + if (filter.sessionId) { + fragments.push(`session_id = @session_id`); + params.session_id = filter.sessionId; + } + if (filter.episodeId) { + fragments.push(`episode_id = @episode_id`); + params.episode_id = filter.episodeId; + } + if (filter.minAbsValue !== undefined) { + fragments.push(`abs(value) >= @min_abs_value`); + params.min_abs_value = filter.minAbsValue; + } + if (tr.sql) fragments.push(tr.sql); + const where = joinWhere(fragments); + const sql = `SELECT COUNT(*) AS n FROM traces ${where}`; + const row = db.prepare(sql).get(params); + return row?.n ?? 0; + }, + + /** + * Count distinct (episode_id, turn_id) groups — i.e. "memory turns", + * 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 { + const fragments: string[] = []; + const params: Record = {}; + if (filter.sessionId) { + fragments.push(`session_id = @session_id`); + params.session_id = filter.sessionId; + } + if (filter.episodeId) { + fragments.push(`episode_id = @episode_id`); + params.episode_id = filter.episodeId; + } + // Same per-request namespace filter as listTurnKeys — kept in sync so + // total + pagination agree on what rows belong to "this" view. + // Added 2026-05-17. + const reqNs = getRequestNamespace(); + if (reqNs) { + fragments.push(`owner_agent_kind = @vis_kind AND owner_profile_id = @vis_pid`); + params.vis_kind = reqNs.agentKind; + params.vis_pid = reqNs.profileId; + } + 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); + return row?.n ?? 0; + }, + + /** + * List paginated turn keys (episode_id, turn_id) ordered by the + * 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 }> { + const fragments: string[] = []; + const params: Record = {}; + if (filter.sessionId) { + fragments.push(`session_id = @session_id`); + params.session_id = filter.sessionId; + } + if (filter.episodeId) { + fragments.push(`episode_id = @episode_id`); + params.episode_id = filter.episodeId; + } + // Per-request "view as " override (2026-05-17). When the HTTP + // dispatch layer set an ALS namespace, scope the paginated turn-keys + // to that namespace at SQL time. Without this filter the top-N most + // recent turns across ALL profiles would surface and then get filtered + // out post-fetch, producing a confusing "Total N items, 0 returned" + // state in the viewer. + const reqNs = getRequestNamespace(); + if (reqNs) { + fragments.push(`owner_agent_kind = @vis_kind AND owner_profile_id = @vis_pid`); + params.vis_kind = reqNs.agentKind; + params.vis_pid = reqNs.profileId; + } + const where = joinWhere(fragments); + const limit = Math.max(1, Math.min(500, filter.limit ?? 50)); + const offset = Math.max(0, filter.offset ?? 0); + params.limit = limit; + params.offset = offset; + const sql = `SELECT episode_id, turn_id, MAX(ts) as max_ts FROM traces ${where} GROUP BY episode_id, turn_id ORDER BY max_ts DESC LIMIT @limit OFFSET @offset`; + const rows = db + .prepare(sql) + .all(params); + return rows.map((r) => ({ episodeId: r.episode_id, turnId: r.turn_id, maxTs: r.max_ts })); + }, + + /** + * Fetch all traces belonging to the given (episodeId, turnId) pairs. + * Returned rows are ordered by ts ascending so the frontend can + * render the conversation in chronological order. + */ + listByTurnKeys(keys: ReadonlyArray<{ episodeId: string | null; turnId: number }>): TraceRow[] { + if (keys.length === 0) return []; + const conditions: string[] = []; + const params: Record = {}; + keys.forEach((k, i) => { + if (k.episodeId == null) { + conditions.push(`(episode_id IS NULL AND turn_id = @turn_${i})`); + } else { + conditions.push(`(episode_id = @ep_${i} AND turn_id = @turn_${i})`); + params[`ep_${i}`] = k.episodeId; + } + params[`turn_${i}`] = k.turnId; + }); + const sql = `SELECT ${COLUMNS.join(", ")} FROM traces WHERE ${conditions.join(" OR ")} ORDER BY ts ASC`; + return db.prepare(sql).all(params).map(mapRow); + }, + + /** + * Vector top-K over `vec_summary` (or `vec_action` if `kind='action'`). + * The caller passes any extra SQL filter (e.g. same-episode only). + */ + searchByVector( + query: EmbeddingVector, + k: number, + opts: { + kind?: "summary" | "action"; + where?: string; + params?: Record; + hardCap?: number; + /** + * Tag-based pre-filter. Candidate row survives if ANY of its stored + * tags appears in this list (`instr(tags_json, '"docker"') > 0`). + * Pass empty or undefined to disable. + */ + anyOfTags?: readonly string[]; + } = {}, + ): Array> { + const kind = opts.kind ?? "summary"; + const vecColumn = kind === "action" ? "vec_action" : "vec_summary"; + const params: Record = { ...(opts.params ?? {}) }; + const whereParts = [`${vecColumn} IS NOT NULL`]; + if (opts.where) whereParts.push(opts.where); + if (opts.anyOfTags && opts.anyOfTags.length > 0) { + const tagOrs: string[] = []; + opts.anyOfTags.forEach((tag, i) => { + const key = `tag_${i}`; + params[key] = `"${String(tag).replace(/["\\]/g, "\\$&")}"`; + tagOrs.push(`instr(tags_json, @${key}) > 0`); + }); + whereParts.push(`(${tagOrs.join(" OR ")})`); + } + return scanAndTopK( + db, + "traces", + [ + "ts", + "priority", + "value", + "episode_id", + "session_id", + "owner_agent_kind", + "owner_profile_id", + "owner_workspace_id", + "tags_json", + ], + query, + k, + { + vecColumn, + where: whereParts.join(" AND "), + params, + hardCap: opts.hardCap, + }, + ); + }, + + /** + * Convenience: in-memory top-K against pre-fetched rows (used when caller + * has already filtered candidates by other criteria). + */ + topKAgainstRows( + query: EmbeddingVector, + rows: VectorRow[], + k: number, + ): Array> { + return topKCosine(query, rows, k); + }, + + /** + * Keyword channel — FTS5 trigram MATCH against `traces_fts`. + * + * Returns rank-ordered hits with the same `meta` shape as + * `searchByVector` so the retrieval ranker can fuse channels via + * RRF. We don't surface the raw FTS rank here — the caller scores + * by reciprocal rank in `keyword.reciprocalRankScore`. + */ + searchByText( + ftsMatch: string, + k: number, + opts: { + where?: string; + params?: Record; + } = {}, + ): Array> { + if (!ftsMatch || k <= 0) return []; + const params: Record = { + ...(opts.params ?? {}), + match: ftsMatch, + k: Math.max(1, Math.min(500, Math.floor(k))), + }; + const extra = opts.where ? `AND (${opts.where})` : ""; + const sql = ` + SELECT t.id AS id, + -bm25(traces_fts) AS score, + t.ts AS ts, + t.priority AS priority, + t.value AS value, + t.episode_id AS episode_id, + t.session_id AS session_id, + t.owner_agent_kind AS owner_agent_kind, + t.owner_profile_id AS owner_profile_id, + t.owner_workspace_id AS owner_workspace_id, + t.tags_json AS tags_json, + t.error_signatures_json AS error_signatures_json + FROM traces_fts f + JOIN traces t ON t.id = f.trace_id + WHERE traces_fts MATCH @match ${extra} + ORDER BY rank + LIMIT @k`; + const rows = db + .prepare(sql) + .all(params); + return rows.map((r, idx) => ({ + id: r.id, + // Translate FTS rank → score in [0, 1] that's monotone-decreasing. + // bm25() returns a negative log-prob (smaller magnitude = better); + // we keep its raw negation for diagnostics but reset score below + // by index so the ranker's RRF doesn't depend on bm25 magnitude. + score: 1 / (idx + 1), + meta: { + ts: r.ts, + priority: r.priority, + value: r.value, + episode_id: r.episode_id as EpisodeId, + session_id: r.session_id as SessionId, + owner_agent_kind: r.owner_agent_kind, + owner_profile_id: r.owner_profile_id, + owner_workspace_id: r.owner_workspace_id, + tags_json: r.tags_json, + error_signatures_json: r.error_signatures_json, + }, + })); + }, + + /** + * Pattern channel — substring fallback for queries that fall below + * the trigram tokenizer's window (e.g. 2-char Chinese names). + * + * Each term in `terms` is searched as `LIKE %term%` over the same + * text columns the FTS index covers. Multiple terms are OR-ed. + */ + searchByPattern( + terms: readonly string[], + k: number, + opts: { + where?: string; + params?: Record; + } = {}, + ): Array> { + if (!terms || terms.length === 0 || k <= 0) return []; + const dedup = Array.from(new Set(terms.map((t) => String(t).trim()).filter(Boolean))); + if (dedup.length === 0) return []; + const params: Record = { + ...(opts.params ?? {}), + k: Math.max(1, Math.min(500, Math.floor(k))), + }; + const ors: string[] = []; + dedup.slice(0, 16).forEach((t, i) => { + const key = `pat_${i}`; + // Escape SQL LIKE wildcards in the user term so a literal `%` + // doesn't accidentally match everything. + const escaped = t.replace(/[\\%_]/g, (m) => `\\${m}`); + params[key] = `%${escaped}%`; + ors.push( + `(user_text LIKE @${key} ESCAPE '\\' OR + agent_text LIKE @${key} ESCAPE '\\' OR + COALESCE(summary,'') LIKE @${key} ESCAPE '\\' OR + COALESCE(reflection,'') LIKE @${key} ESCAPE '\\' OR + tags_json LIKE @${key} ESCAPE '\\')`, + ); + }); + const extra = opts.where ? ` AND (${opts.where})` : ""; + const sql = ` + SELECT id, ts, priority, value, episode_id, session_id, tags_json, + owner_agent_kind, owner_profile_id, owner_workspace_id, + error_signatures_json + FROM traces + WHERE (${ors.join(" OR ")})${extra} + ORDER BY ts DESC + LIMIT @k`; + const rows = db.prepare(sql).all(params); + return rows.map((r, idx) => ({ + id: r.id, + score: 1 / (idx + 1), + meta: { + ts: r.ts, + priority: r.priority, + value: r.value, + episode_id: r.episode_id as EpisodeId, + session_id: r.session_id as SessionId, + owner_agent_kind: r.owner_agent_kind, + owner_profile_id: r.owner_profile_id, + owner_workspace_id: r.owner_workspace_id, + tags_json: r.tags_json, + error_signatures_json: r.error_signatures_json, + }, + })); + }, + + /** + * V7 §2.6 structural match — exact-substring lookup on stored error + * signatures. Returns full `TraceRow` objects, newest first, capped + * at `limit`. Case-sensitive (signatures are normalised verbatim). + * + * If the caller provides multiple `anyOfFragments`, rows that match + * ANY fragment survive. Empty array returns `[]`. + */ + searchByErrorSignature( + anyOfFragments: readonly string[], + limit: number, + opts: { + where?: string; + params?: Record; + } = {}, + ): TraceRow[] { + if (!anyOfFragments || anyOfFragments.length === 0) return []; + // Dedup + cap so a runaway caller doesn't blow up the query size. + const frags = Array.from(new Set(anyOfFragments)) + .filter((f) => typeof f === "string" && f.length >= 6) + .slice(0, 8); + if (frags.length === 0) return []; + const params: Record = { ...(opts.params ?? {}) }; + const ors: string[] = []; + frags.forEach((frag, i) => { + const key = `sig_${i}`; + // Store as a quoted JSON string fragment so `instr()` matches the + // exact element boundary (preventing "foo" from matching "foobar"). + params[key] = `"${frag.replace(/["\\]/g, "\\$&")}"`; + ors.push(`instr(error_signatures_json, @${key}) > 0`); + }); + const whereParts = [`(${ors.join(" OR ")})`]; + if (opts.where) whereParts.push(opts.where); + const sql = `SELECT ${COLUMNS.join( + ", ", + )} FROM traces WHERE ${whereParts.join(" AND ")} ORDER BY ts DESC LIMIT @limit`; + params.limit = Math.max(1, Math.min(200, Math.floor(limit))); + const rows = db.prepare(sql).all(params); + return rows.map(mapRow); + }, + + deleteById(id: TraceId): void { + // The FTS trigger should remove this row, but doing it explicitly + // makes deletion idempotent across pre-release DBs with older schemas. + db.prepare<{ id: string }>(`DELETE FROM traces_fts WHERE trace_id=@id`).run({ id }); + db.prepare<{ id: string }>(`DELETE FROM traces WHERE id=@id`).run({ id }); + }, + + /** + * Partial content patch applied by the viewer's "Edit" modal. + * Only user-facing text fields are mutable — `ts`, `value`, + * `alpha`, `priority`, and vectors are owned by the capture / + * reward pipeline and must NOT be rewritten from the UI. + */ + updateBody( + id: TraceId, + patch: { + summary?: string | null; + userText?: string; + agentText?: string; + tags?: readonly string[]; + }, + ): void { + const sets: string[] = []; + const params: Record = { id }; + if (patch.summary !== undefined) { + sets.push("summary = @summary"); + params.summary = patch.summary; + } + if (patch.userText !== undefined) { + sets.push("user_text = @user_text"); + params.user_text = patch.userText; + } + if (patch.agentText !== undefined) { + sets.push("agent_text = @agent_text"); + params.agent_text = patch.agentText; + } + if (patch.tags !== undefined) { + sets.push("tags_json = @tags_json"); + params.tags_json = toJsonText(normalizeTags(patch.tags)); + } + if (sets.length === 0) return; + const sql = `UPDATE traces SET ${sets.join(", ")} WHERE id = @id`; + db.prepare(sql).run(params); + }, + + updateVector( + id: TraceId, + field: "vecSummary" | "vecAction", + vec: EmbeddingVector, + ): boolean { + const column = field === "vecAction" ? "vec_action" : "vec_summary"; + const res = db.prepare<{ id: string; vec: Buffer }>( + `UPDATE traces SET ${column}=@vec WHERE id=@id`, + ).run({ id, vec: toBlob(vec)! }); + return res.changes > 0; + }, + + /** + * Fill in reflection + α for a trace that was previously written + * in the "lite" capture phase (reflection=null, α=0). Invoked + * at topic-end by the reflect-phase capture pass, which sees the + * full causal chain and batch-scores every step of the episode + * at once. Intentionally narrow: no other columns mutate. + */ + updateReflection( + id: TraceId, + patch: { reflection: string | null; alpha: number }, + ): void { + db.prepare<{ + id: string; + reflection: string | null; + alpha: number; + }>( + `UPDATE traces SET reflection=@reflection, alpha=@alpha WHERE id=@id`, + ).run({ + id, + reflection: patch.reflection, + alpha: patch.alpha, + }); + }, + + /** + * Apply a share-state transition. `scope = null` un-shares. The + * viewer calls this after (optionally) pushing the payload to + * the Hub — so the pipeline only records local state, never + * performs the network call itself. + */ + updateShare( + id: TraceId, + share: { + scope: "private" | "local" | "public" | "hub" | null; + target?: string | null; + sharedAt?: number | null; + }, + ): void { + db.prepare<{ + id: string; + share_scope: string | null; + share_target: string | null; + shared_at: number | null; + }>( + `UPDATE traces SET share_scope=@share_scope, share_target=@share_target, shared_at=@shared_at WHERE id=@id`, + ).run({ + id, + share_scope: normalizeShareForStorage(share.scope), + share_target: share.target ?? null, + shared_at: share.sharedAt ?? null, + }); + }, + }; +} + +interface RawHit { + id: string; + ts: number; + priority: number; + value: number; + episode_id: string; + session_id: string; + owner_agent_kind: string; + owner_profile_id: string; + owner_workspace_id: string | null; + tags_json: string; + error_signatures_json: string; +} + +interface RawTraceRow { + id: string; + episode_id: string; + session_id: string; + owner_agent_kind: string; + owner_profile_id: string; + owner_workspace_id: string | null; + ts: number; + user_text: string; + agent_text: string; + summary: string | null; + tool_calls_json: string; + reflection: string | null; + agent_thinking: string | null; + value: number; + alpha: number; + r_human: number | null; + priority: number; + tags_json: string; + error_signatures_json: string; + vec_summary: Buffer | null; + vec_action: Buffer | null; + share_scope: string | null; + share_target: string | null; + shared_at: number | null; + turn_id: number; + schema_version: number; +} + +function normalizeSignatures(sigs: readonly string[] | undefined): string[] { + if (!sigs || sigs.length === 0) return []; + const seen = new Set(); + for (const raw of sigs) { + const s = String(raw).trim(); + if (s.length < 6 || s.length > 200) continue; + seen.add(s); + } + // Small cap + stable order to keep row size bounded. + return [...seen].slice(0, 4); +} + +function normalizeTags(tags: readonly string[] | undefined): string[] { + if (!tags || tags.length === 0) return []; + const seen = new Set(); + for (const t of tags) { + const n = String(t).trim().toLowerCase(); + if (n.length === 0 || n.length > 48) continue; + seen.add(n); + } + return [...seen].sort(); +} + +function rowToParams(row: TraceRow): Record { + return { + id: row.id, + episode_id: row.episodeId, + session_id: row.sessionId, + ...ownerParamsFromRow(row), + ts: row.ts, + user_text: row.userText, + agent_text: row.agentText, + summary: row.summary ?? null, + tool_calls_json: toJsonText(row.toolCalls ?? []), + reflection: row.reflection ?? null, + agent_thinking: row.agentThinking ?? null, + value: row.value, + alpha: row.alpha, + r_human: row.rHuman ?? null, + priority: row.priority, + tags_json: toJsonText(normalizeTags(row.tags)), + error_signatures_json: toJsonText(normalizeSignatures(row.errorSignatures)), + vec_summary: toBlob(row.vecSummary), + vec_action: toBlob(row.vecAction), + share_scope: normalizeShareForStorage(row.share?.scope), + share_target: row.share?.target ?? null, + shared_at: row.share?.sharedAt ?? null, + turn_id: row.turnId ?? null, + schema_version: row.schemaVersion, + }; +} + +function mapRow(r: RawTraceRow): TraceRow { + return { + id: r.id, + episodeId: r.episode_id, + sessionId: r.session_id, + ...ownerFieldsFromRaw(r), + ts: r.ts, + userText: r.user_text, + agentText: r.agent_text, + summary: r.summary ?? null, + toolCalls: fromJsonText(r.tool_calls_json, []), + reflection: r.reflection, + agentThinking: r.agent_thinking ?? null, + value: r.value, + alpha: r.alpha, + rHuman: r.r_human, + priority: r.priority, + tags: fromJsonText(r.tags_json, []), + errorSignatures: fromJsonText(r.error_signatures_json, []), + vecSummary: fromBlob(r.vec_summary), + vecAction: fromBlob(r.vec_action), + share: + r.share_scope != null + ? { + scope: normalizeShareForStorage(r.share_scope) as "private" | "local" | "public" | "hub", + target: r.share_target, + sharedAt: r.shared_at, + } + : null, + turnId: r.turn_id, + schemaVersion: r.schema_version, + }; +} diff --git a/tools/plugin-patches/server/http.ts b/tools/plugin-patches/server/http.ts new file mode 100644 index 0000000..eed26a1 --- /dev/null +++ b/tools/plugin-patches/server/http.ts @@ -0,0 +1,254 @@ +/** + * HTTP server entry point. + * + * Built on the Node standard library's `http` module — no framework. We + * pay the small cost of writing a router by hand to keep the surface + * area tiny, which in turn lets us guarantee the security properties + * spelled out in `ALGORITHMS.md` (loopback default, API-key gating, + * static-root escape prevention, etc.). + * + * ## Single-agent URL layout + * + * Each agent runs its own viewer on its own port: + * + * - openclaw → :18799 + * - hermes → :18800 + * + * The server hosts the SPA at `/`, the JSON REST API at `/api/v1/*`, + * and the static viewer assets. There are no `/openclaw/*` / + * `/hermes/*` URL prefixes — clients always talk to the agent's own + * port. If both agents are installed, the root path renders a small + * picker page that links to the *other* agent's URL (external link, + * no reverse proxy, no peer cores). + */ + +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + +import { rootLogger } from "../core/logger/index.js"; +import { runWithRequestNamespace } from "../core/runtime/request-namespace.js"; +import type { RuntimeNamespace } from "../core/runtime/namespace.js"; + +import { buildRoutes } from "./routes/registry.js"; +import { readBody, writeJson, writeNotFound, writeMethodNotAllowed } from "./middleware/io.js"; +import { enforceApiKey } from "./middleware/auth.js"; +import { requireSession } from "./routes/auth.js"; +import { serveStatic } from "./middleware/static.js"; +import type { ServerDeps, ServerHandle, ServerOptions } from "./types.js"; + +type AgentName = "openclaw" | "hermes"; +const AGENT_NAMES: readonly AgentName[] = ["openclaw", "hermes"]; + +/** + * Well-known per-agent viewer port. The picker page links to the + * peer agent here. If a user moves a peer to a non-default port the + * link will 404 — that's the intended trade-off for keeping the picker + * fully static (no IPC, no port scanning). + */ +const AGENT_DEFAULT_PORTS: Record = { + openclaw: 18799, + hermes: 18800, +}; + +export async function startHttpServer( + deps: ServerDeps, + options: ServerOptions = {}, +): Promise { + const log = rootLogger.child({ channel: "server.http" }); + const host = options.host ?? "127.0.0.1"; + const port = options.port ?? 0; + const extraHeaders = options.extraHeaders ?? {}; + + const routes = buildRoutes(deps, options); + + const server = createServer(async (req, res) => { + for (const [k, v] of Object.entries(extraHeaders)) { + res.setHeader(k, v); + } + try { + await dispatch(req, res, routes, deps, options, log); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.error("request.unhandled", { path: req.url, err: msg }); + if (!res.headersSent) { + writeJson(res, 500, { error: { code: "internal", message: msg } }); + } + try { + res.end(); + } catch { + // best-effort — connection may already be closed + } + } + }); + + // Single bind attempt. EADDRINUSE is propagated so the caller + // (`bridge.cts` / `adapters/openclaw`) can log it and run headless. + await new Promise((resolve, reject) => { + const onErr = (e: NodeJS.ErrnoException) => reject(e); + server.once("error", onErr); + server.listen(port, host, () => { + server.off("error", onErr); + resolve(); + }); + }); + + const addr = server.address(); + const actualPort = typeof addr === "object" && addr ? addr.port : port; + const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${actualPort}`; + let closed = false; + + log.info("server.started", { url, port: actualPort }); + + return { + url, + port: actualPort, + get closed() { + return closed; + }, + async close() { + if (closed) return; + closed = true; + // Drop any idle keep-alive sockets so server.close() doesn't hang + // on pooled connections (e.g. from vitest's fetch). + try { (server as any).closeIdleConnections?.(); } catch { /* noop */ } + await new Promise((resolve) => server.close(() => resolve())); + log.info("server.stopped", {}); + }, + }; +} + +async function dispatch( + req: IncomingMessage, + res: ServerResponse, + routes: ReturnType, + deps: ServerDeps, + options: ServerOptions, + log: ReturnType, +): Promise { + const url = new URL(req.url ?? "/", "http://localhost"); + const method = (req.method ?? "GET").toUpperCase(); + let pathname = url.pathname; + + const selfAgent = (options.agent ?? null) as AgentName | null; + + // Backwards-compat for the old single-port "hub/peer" layout, where + // every URL was prefixed (`/openclaw/api/v1/...` or + // `/hermes/...`). New installs serve the SPA at root, but old + // bookmarks (and the old `AGENT_PREFIX` baked into older viewer + // bundles) still hit the prefixed paths. + // + // Two cases: + // - Prefix matches THIS agent → drop the prefix and continue + // dispatching internally. We must NOT 302 here, because POST / + // PATCH / DELETE get downgraded to GET on a 302 in most browsers, + // which would silently corrupt mutations. + // - Prefix matches the OTHER agent → that agent lives on its own + // port now, so 302 the user there. (We accept the request-method + // downgrade because cross-port redirects are inherently a "follow + // this link" gesture; the SPA bundle on the other port re-issues + // mutations from form state, not from the original POST body.) + for (const name of AGENT_NAMES) { + const prefix = `/${name}`; + if (pathname === prefix || pathname.startsWith(`${prefix}/`)) { + const tail = pathname.slice(prefix.length) || "/"; + if (name === selfAgent) { + pathname = tail; + break; + } + const peerPort = AGENT_DEFAULT_PORTS[name]; + const targetHost = req.headers["host"]?.split(":")[0] ?? "127.0.0.1"; + res.writeHead(302, { + Location: `http://${targetHost}:${peerPort}${tail}${url.search}`, + }); + res.end(); + return; + } + } + + // Static assets first — cheapest path. Serve on GET/HEAD only. + // The root path falls through to the static handler which serves + // `index.html` (the SPA). There is no picker page: each agent owns + // its own port and is reachable directly. The SPA's header probes + // the peer's well-known port and surfaces a small link if it's up. + if ((method === "GET" || method === "HEAD") && !pathname.startsWith("/api/")) { + const served = await serveStatic(res, pathname, options); + if (served) return; + } + + // API key gating — applies to every /api/* route (host-configured). + if (pathname.startsWith("/api/") && options.apiKey) { + const allowed = enforceApiKey(req, res, options.apiKey); + if (!allowed) return; + } + + // Session-cookie gating — applies only when the operator has + // enabled password protection (i.e. `~/.../memos-plugin/.auth.json` + // exists). Auth endpoints + `/health` are explicitly allowed so + // the viewer can complete login even from a locked state. + if (pathname.startsWith("/api/") && deps.home?.root) { + const ok = requireSession( + req, + res, + String(deps.home.root), + pathname, + selfAgent, + ); + if (!ok) return; + } + + // Per-request namespace override — "view as " toggle. + // Reads `?as_profile=` (URL) or `X-As-Profile: ` (header). When + // present, the handler runs inside an AsyncLocalStorage context that + // makes `visibleToCurrent` / `ownedByCurrent` in memory-core resolve + // to that namespace. Defaults to no override (existing daemon-bound + // activeNamespace). Added 2026-05-17 — see + // core/runtime/request-namespace.ts. + const asProfileRaw = url.searchParams.get("as_profile") + ?? (req.headers["x-as-profile"] as string | undefined) + ?? null; + const requestNs: RuntimeNamespace | null = asProfileRaw + ? { agentKind: selfAgent ?? "hermes", profileId: asProfileRaw.trim() } + : null; + const runInNs = (fn: () => T | Promise): T | Promise => + requestNs ? runWithRequestNamespace(requestNs, fn) : fn(); + + // Flat router lookup. + const key = `${method} ${pathname}`; + const exact = routes.getExact(key); + if (exact) { + const body = await readBody(req, options.maxBodyBytes ?? 1_048_576); + const result = await runInNs(() => exact({ req, res, url, body, deps, params: {} })); + if (!res.headersSent && result !== undefined) { + writeJson(res, 200, result); + } + return; + } + + // Pattern-route fallback (e.g. `/api/v1/traces/:id`). + const pattern = routes.matchPattern(method, pathname); + if (pattern) { + const body = await readBody(req, options.maxBodyBytes ?? 1_048_576); + const result = await runInNs(() => pattern.handler({ + req, + res, + url, + body, + deps, + params: pattern.params, + })); + if (!res.headersSent && result !== undefined) { + writeJson(res, 200, result); + } + return; + } + + // Differentiate "route exists, wrong method" from "no such route". + if (routes.pathMatches(pathname)) { + writeMethodNotAllowed(res, method); + return; + } + + writeNotFound(res); + log.debug("route.not_found", { path: pathname, method }); + void deps; +} + diff --git a/tools/plugin-patches/server/routes/registry.ts b/tools/plugin-patches/server/routes/registry.ts new file mode 100644 index 0000000..6f2cd0f --- /dev/null +++ b/tools/plugin-patches/server/routes/registry.ts @@ -0,0 +1,216 @@ +/** + * Route registry — all REST endpoints + SSE streams. + * + * Keep this file flat and auditable. Every route is spelled out as + * `"METHOD /path"`. Handler signatures are: + * + * (ctx) => unknown | Promise + * + * Returning `undefined` means the handler already wrote the response + * (e.g. SSE streams). Returning any other value means "serialise as + * JSON 200". + * + * ## Pattern routes + * + * Flat `METHOD /path` keys are the default. When a path needs a + * parameter (e.g. `/api/v1/traces/:id`), register it via + * `routes.setPattern("METHOD /path/:foo", handler)`. The dispatcher + * tries exact routes first, then scans patterns in registration + * order; params land on `ctx.params`. + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; + +import type { ServerDeps, ServerOptions } from "../types.js"; +import { parseJsonBody, writeJson } from "../middleware/io.js"; + +import { registerHealthRoutes } from "./health.js"; +import { registerMemoryRoutes } from "./memory.js"; +import { registerOverviewRoutes } from "./overview.js"; +import { registerSessionRoutes } from "./session.js"; +import { registerTurnRoutes } from "./turns.js"; +import { registerSkillRoutes } from "./skill.js"; +import { registerFeedbackRoutes } from "./feedback.js"; +import { registerEventsRoutes } from "./events.js"; +import { registerLogsRoutes } from "./logs.js"; +import { registerConfigRoutes } from "./config.js"; +import { registerMetricsRoutes } from "./metrics.js"; +import { registerImportExportRoutes } from "./import-export.js"; +import { registerMigrateRoutes } from "./migrate.js"; +import { registerHubAdminRoutes } from "./hub-admin.js"; +import { registerTraceRoutes } from "./trace.js"; +import { registerPoliciesRoutes } from "./policies.js"; +import { registerAuthRoutes } from "./auth.js"; +import { registerAdminRoutes } from "./admin.js"; +import { registerModelsRoutes } from "./models.js"; +import { registerApiLogsRoutes } from "./api-logs.js"; +import { registerDiagRoutes } from "./diag.js"; + +export interface RouteContext { + req: IncomingMessage; + res: ServerResponse; + url: URL; + body: Buffer; + deps: ServerDeps; + /** Parsed path params (populated by pattern routes only). */ + params: Record; +} + +export type RouteHandler = (ctx: RouteContext) => unknown | Promise; + +interface PatternRoute { + method: string; + regex: RegExp; + keys: string[]; + handler: RouteHandler; +} + +/** + * Dual-storage route map. `setPattern` registers a URL template with + * `:param` placeholders (matched exact-segment, no regex escaping + * needed). `set` remains the flat happy path. + */ +export class Routes { + private exact = new Map(); + private patterns: PatternRoute[] = []; + + set(key: string, handler: RouteHandler): void { + this.exact.set(key, handler); + } + + has(key: string): boolean { + return this.exact.has(key); + } + + getExact(key: string): RouteHandler | undefined { + return this.exact.get(key); + } + + exactKeys(): IterableIterator { + return this.exact.keys(); + } + + /** + * Register a pattern route. `key` looks like + * `"GET /api/v1/traces/:id"`. The matcher splits on `/`, escapes + * literal segments, and captures `:name` segments as named params. + */ + setPattern(key: string, handler: RouteHandler): void { + const match = key.match(/^(\w+)\s+(.+)$/); + if (!match) throw new Error(`invalid route key: ${key}`); + const [, method, path] = match; + const segments = path.split("/"); + const keys: string[] = []; + const re = segments + .map((seg) => { + if (seg.startsWith(":")) { + keys.push(seg.slice(1)); + return "([^/]+)"; + } + return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }) + .join("/"); + this.patterns.push({ + method, + regex: new RegExp(`^${re}$`), + keys, + handler, + }); + } + + matchPattern( + method: string, + pathname: string, + ): { handler: RouteHandler; params: Record } | null { + for (const pat of this.patterns) { + if (pat.method !== method) continue; + const m = pat.regex.exec(pathname); + if (!m) continue; + const params: Record = {}; + pat.keys.forEach((k, i) => { + params[k] = decodeURIComponent(m[i + 1] ?? ""); + }); + return { handler: pat.handler, params }; + } + return null; + } + + /** All known pathnames (exact + patterns) — used for 405 detection. */ + allPaths(): string[] { + const out: string[] = []; + for (const k of this.exact.keys()) out.push(k.split(" ")[1] ?? ""); + for (const p of this.patterns) out.push(p.regex.source); + return out; + } + + /** Patterns that match a pathname regardless of method (for 405). */ + pathMatches(pathname: string): boolean { + for (const k of this.exact.keys()) { + if (k.split(" ")[1] === pathname) return true; + } + for (const p of this.patterns) { + if (p.regex.test(pathname)) return true; + } + return false; + } +} + +export function buildRoutes( + deps: ServerDeps, + options: ServerOptions, +): Routes { + const routes = new Routes(); + registerHealthRoutes(routes, deps); + registerOverviewRoutes(routes, deps); + registerSessionRoutes(routes, deps); + registerTurnRoutes(routes, deps); + registerMemoryRoutes(routes, deps, options); + registerTraceRoutes(routes, deps); + registerPoliciesRoutes(routes, deps); + registerSkillRoutes(routes, deps); + registerFeedbackRoutes(routes, deps); + registerEventsRoutes(routes, deps); + registerLogsRoutes(routes, deps, options); + registerConfigRoutes(routes, deps); + registerMetricsRoutes(routes, deps); + registerImportExportRoutes(routes, deps, options); + registerMigrateRoutes(routes, deps, options); + registerHubAdminRoutes(routes, deps); + registerAuthRoutes(routes, deps, options); + registerAdminRoutes(routes, deps, options); + registerModelsRoutes(routes, deps); + registerApiLogsRoutes(routes, deps); + registerDiagRoutes(routes, deps); + return routes; +} + +// ─── Small utilities used by route modules ────────────────────────────────── + +export function parseJson(ctx: RouteContext): T { + try { + return parseJsonBody(ctx.body); + } catch (err) { + writeJson(ctx.res, 400, { + error: { + code: "invalid_argument", + message: err instanceof Error ? err.message : "invalid json", + }, + }); + throw new Error("__response_written__"); + } +} + +export function parseQuery>(ctx: RouteContext): T { + const out: Record = {}; + for (const [k, v] of ctx.url.searchParams.entries()) out[k] = v; + return out as T; +} + +export function writeError( + ctx: RouteContext, + status: number, + code: string, + message: string, +): void { + writeJson(ctx.res, status, { error: { code, message } }); +} diff --git a/tools/plugin-patches/server/routes/turns.ts b/tools/plugin-patches/server/routes/turns.ts new file mode 100644 index 0000000..5d605fd --- /dev/null +++ b/tools/plugin-patches/server/routes/turns.ts @@ -0,0 +1,42 @@ +/** + * Turn-lifecycle WRITE endpoints (P3.1 — shared single daemon). + * + * The capture write path (`turn.start` / `turn.end`) was previously available + * only over the per-gateway stdio bridge. Exposing it over the daemon's HTTP API + * lets gateways become thin HTTP clients of the ONE :18800 daemon (one BGE-large + * load total) instead of each spawning a full bridge.cts. These call the SAME + * core methods the stdio dispatch uses (`bridge/methods.ts`: core.onTurnStart / + * core.onTurnEnd), and run inside the per-request namespace (X-As-Profile) wired + * in server/http.ts, so a write lands in the caller's profile. + * + * AUTH: these are gated by the same session check as every other /api/* route. + * They are NOT in the public allowlist — a loopback bypass would be unsafe + * because Tailscale Serve proxies tailnet → 127.0.0.1, so loopback ≠ local-user. + * The adapter HTTP cutover (spec step 3) adds a per-boot internal token for the + * gateways; until then these are reachable with a viewer session cookie (used by + * tests). + */ + +import type { TurnInputDTO, TurnResultDTO } from "../../agent-contract/dto.js"; +import type { ServerDeps } from "../types.js"; +import { parseJson, writeError, type Routes } from "./registry.js"; + +export function registerTurnRoutes(routes: Routes, deps: ServerDeps): void { + routes.set("POST /api/v1/turn/start", async (ctx) => { + const body = parseJson>(ctx); + if (!body.sessionId || !body.userText) { + writeError(ctx, 400, "invalid_argument", "sessionId and userText are required"); + return; + } + return await deps.core.onTurnStart(body as unknown as TurnInputDTO); + }); + + routes.set("POST /api/v1/turn/end", async (ctx) => { + const body = parseJson>(ctx); + if (!body.sessionId || !body.episodeId) { + writeError(ctx, 400, "invalid_argument", "sessionId and episodeId are required"); + return; + } + return await deps.core.onTurnEnd(body as unknown as TurnResultDTO); + }); +} diff --git a/tools/plugin-patches/web/dist/hermes-profile-switcher.js b/tools/plugin-patches/web/dist/hermes-profile-switcher.js new file mode 100644 index 0000000..17a8e33 --- /dev/null +++ b/tools/plugin-patches/web/dist/hermes-profile-switcher.js @@ -0,0 +1,325 @@ +/** + * hermes-profile-switcher.js — adds a floating "view as " picker + * to the v2 plugin's bundled viewer. + * + * Why this exists: the v2 viewer is bound to a single namespace at daemon + * startup. The bundled UI ships i18n strings for an agent switcher but + * doesn't render it, and the API enforces visibility on the daemon's + * fixed namespace. This overlay: + * + * 1. Fetches /api/v1/diag/namespace once on load to list all profiles + * that have ever written to the store (sergio, hr-agent, mohammed, + * research-agent, email-marketing, …). + * 2. Injects a small floating dropdown in the top-right corner. + * 3. On selection, persists choice in localStorage and patches + * window.fetch to add `X-As-Profile: ` to every /api/v1/* + * request — the server's dispatch layer reads this header and runs + * handlers inside a per-request AsyncLocalStorage namespace context + * so visibility filters resolve to the requested profile. + * + * Added by the Hermes integration 2026-05-17. Server-side counterpart: + * core/runtime/request-namespace.ts (ALS module) + * core/pipeline/memory-core.ts (effectiveNamespace() + visibleToCurrent default) + * server/http.ts (dispatch parses ?as_profile= / X-As-Profile) + * + * Lives outside the React bundle on purpose — overlay survives bundle + * rebuilds and doesn't touch shadow-DOM internals. Imperatively + * re-renders when the bundle navigates between #/ routes. + */ + +(function () { + "use strict"; + const STORAGE_KEY = "hermes.as_profile"; + const TOAST_KEY = "hermes.profile_toast_seen"; + + // ── styles ─────────────────────────────────────────────────────────── + const css = ` + #hermes-profile-switcher { + position: fixed; top: 12px; right: 18px; z-index: 9999; + display: flex; align-items: center; gap: 8px; + padding: 6px 10px; + background: rgba(15, 32, 66, 0.92); + color: #f4f6fa; + border: 1px solid rgba(255, 140, 66, 0.55); + border-radius: 8px; + font: 600 12px/1.2 system-ui, -apple-system, "Segoe UI", sans-serif; + box-shadow: 0 6px 22px rgba(15, 32, 66, 0.22); + backdrop-filter: blur(4px); + } + #hermes-profile-switcher .hps-label { + letter-spacing: 0.06em; text-transform: uppercase; font-size: 10px; + color: #ff8c42; + } + #hermes-profile-switcher select { + background: #f4f6fa; color: #0f2042; + border: 1px solid rgba(255, 140, 66, 0.7); + border-radius: 5px; + padding: 3px 6px; font: 600 12px/1.2 inherit; + cursor: pointer; + } + #hermes-profile-switcher select:focus { outline: 2px solid #ff8c42; } + #hermes-profile-switcher .hps-clear { + background: transparent; color: #ff8c42; border: 0; cursor: pointer; + font-size: 14px; line-height: 1; padding: 0 4px; + } + #hermes-profile-switcher .hps-active { + width: 8px; height: 8px; border-radius: 50%; + background: #ff8c42; box-shadow: 0 0 6px rgba(255,140,66,0.7); + } + #hermes-profile-switcher .hps-inactive { + width: 8px; height: 8px; border-radius: 50%; + background: #6b7280; opacity: 0.5; + } + #hermes-profile-toast { + position: fixed; top: 60px; right: 18px; z-index: 9998; + max-width: 320px; padding: 10px 14px; + background: #fff; color: #0f2042; + border: 1px solid #ff8c42; border-left-width: 4px; + border-radius: 6px; + font: 500 12px/1.4 system-ui, -apple-system, sans-serif; + box-shadow: 0 8px 24px rgba(15, 32, 66, 0.18); + } + #hermes-profile-toast b { color: #ff8c42; } + `; + const styleEl = document.createElement("style"); + styleEl.textContent = css; + document.head.appendChild(styleEl); + + // ── state ──────────────────────────────────────────────────────────── + let currentProfile = localStorage.getItem(STORAGE_KEY) || ""; // "" = no override + + // ── fetch interceptor ──────────────────────────────────────────────── + const origFetch = window.fetch.bind(window); + const pageIsHttps = location.protocol === "https:"; + window.fetch = function (input, init) { + // Resolve URL safely whether it's a string, URL, or Request object. + let urlStr = ""; + if (typeof input === "string") urlStr = input; + else if (input instanceof URL) urlStr = input.toString(); + else if (input && typeof input.url === "string") urlStr = input.url; + + // Mixed-content guard. The React bundle probes a sibling daemon at a + // hardcoded `http://:/api/v1/health` to offer a "switch + // daemon" link (wa={openclaw:18799,hermes:18800}). On this single-daemon, + // reverse-proxied HTTPS deployment that sibling port isn't reachable and + // the browser blocks the insecure request — logging a Mixed Content error + // on every 15s poll. Short-circuit any insecure http:// request from an + // https page by rejecting, so the bundle's own `catch { return null }` + // path runs silently. Same-origin app calls are relative ("/api/v1/…") + // and never match this. + if (pageIsHttps && urlStr.startsWith("http://")) { + return Promise.reject( + new DOMException( + "Blocked insecure cross-origin request (mixed content)", + "SecurityError", + ), + ); + } + + if (!currentProfile) return origFetch(input, init); + if (!urlStr.includes("/api/v1/")) return origFetch(input, init); + // Exclude auth endpoints — namespace override is meaningless there. + if (urlStr.includes("/api/v1/auth/")) return origFetch(input, init); + + const headers = new Headers((init && init.headers) || (input instanceof Request ? input.headers : undefined)); + headers.set("X-As-Profile", currentProfile); + const merged = Object.assign({}, init || {}, { headers }); + return origFetch(input, merged); + }; + + // ── DOM ────────────────────────────────────────────────────────────── + function buildShell() { + let el = document.getElementById("hermes-profile-switcher"); + if (el) return el; + el = document.createElement("div"); + el.id = "hermes-profile-switcher"; + el.innerHTML = ` + + view as + + + `; + document.body.appendChild(el); + return el; + } + + function showToast(html) { + const t = document.createElement("div"); + t.id = "hermes-profile-toast"; + t.innerHTML = html; + document.body.appendChild(t); + setTimeout(() => { try { t.remove(); } catch (_) {} }, 4500); + } + + function setProfile(p) { + currentProfile = p || ""; + if (currentProfile) localStorage.setItem(STORAGE_KEY, currentProfile); + else localStorage.removeItem(STORAGE_KEY); + const dot = document.getElementById("hps-dot"); + if (dot) { + dot.className = currentProfile ? "hps-active" : "hps-inactive"; + dot.title = currentProfile ? `Override active: as_profile=${currentProfile}` : "Using daemon namespace"; + } + // Trigger a soft reload of the page's data — try in-app refresh + // buttons first; fall back to full reload. + const refreshBtns = Array.from(document.querySelectorAll("button")) + .filter(b => /refresh|刷新/i.test(b.textContent || "")); + if (refreshBtns.length > 0) { + refreshBtns.forEach(b => b.click()); + } else { + // No refresh button visible — full page reload. + setTimeout(() => location.reload(), 80); + } + } + + async function loadProfiles() { + try { + const r = await origFetch("/api/v1/diag/namespace", { credentials: "include" }); + if (!r.ok) return []; + const data = await r.json(); + return Array.isArray(data.namespaces) ? data.namespaces : []; + } catch (e) { + console.warn("[hermes-profile-switcher] failed to load namespaces", e); + return []; + } + } + + // Public (non-session-gated) endpoint. Returns + // { enabled, needsSetup, authenticated }. We use it to avoid firing + // session-gated requests (e.g. /api/v1/diag/namespace) before the user + // has unlocked the viewer — which would otherwise log a 401 on load. + async function isAuthenticated() { + try { + const r = await origFetch("/api/v1/auth/status", { credentials: "include" }); + if (!r.ok) return false; + const d = await r.json(); + // If auth is disabled there's no lock screen; otherwise require unlock. + return d.enabled === false || d.authenticated === true; + } catch (_) { + return false; + } + } + + async function mount() { + if (document.getElementById("hermes-profile-switcher")) return; + // Don't show on the login screen. + if (document.querySelector('input[type="password"]')) { + setTimeout(mount, 400); + return; + } + // Wait until the viewer is unlocked before hitting session-gated APIs. + if (!(await isAuthenticated())) { + setTimeout(mount, 800); + return; + } + const profiles = await loadProfiles(); + if (profiles.length === 0) { + // Either not logged in yet, or no namespaces. Try again later. + setTimeout(mount, 800); + return; + } + const el = buildShell(); + const sel = el.querySelector("#hps-select"); + profiles + .sort((a, b) => (b.count || 0) - (a.count || 0)) + .forEach((ns) => { + const opt = document.createElement("option"); + const id = ns.profileId || "default"; + opt.value = id; + opt.textContent = `${id} (${ns.count || 0})`; + if (currentProfile === id) opt.selected = true; + sel.appendChild(opt); + }); + sel.addEventListener("change", (e) => setProfile(e.target.value)); + el.querySelector("#hps-clear").addEventListener("click", () => { + sel.value = ""; + setProfile(""); + }); + + // First-time hint. + if (!localStorage.getItem(TOAST_KEY)) { + showToast( + `View-as toggle ready.
Pick a profile to see that agent's private traces. ` + + `Adds X-As-Profile ` + + `header so the daemon swaps the visibility namespace per request.` + ); + localStorage.setItem(TOAST_KEY, "1"); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mount); + } else { + mount(); + } + + // Re-attach on hash navigation (SPA route changes don't remount us). + window.addEventListener("hashchange", () => { + if (!document.getElementById("hermes-profile-switcher")) mount(); + }); + + // ──────────────────────────────────────────────────────────────────── + // Sidebar tab: "Viewer" → /memory-map.html + // + // The React sidebar renders a stack of icon-only links. We can't reach + // into the React state, but we can add a sibling via DOM. React + // doesn't reconcile away unknown children inside its mount point, so + // appending an extra child to the sidebar's