From db852630835270c448b4e21a28c770e23918c5c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 04:09:08 +0000 Subject: [PATCH 01/32] feat: OEW assessment, jspace package, and codespace reorganization Research Hermes Agent Quicksilver/Judgment updates and invent the Obligatory External Workspace (OEW) path to make Hermespace the functional J-space of Hermes agents. - Add deep assessment + OEW thesis + phased roadmap - Move jspace into src/hermespace/jspace/ (hub, env, protocol) - Reorganize docs into jspace/assessment/architecture/integration/ops/research/roadmap - Soft-wire ProtocolGate into workflow meta (HERMESPACE_OEW) - Add LAYOUT.md and planned turn/memory/warehouse package READMEs Co-authored-by: Pablo --- CONTRIBUTING.md | 3 +- FOR_HERMES.md | 90 +-- INTEGRATION.md | 2 +- LAYOUT.md | 59 ++ PURPOSE.md | 10 +- README.md | 31 +- docs/INDEX.md | 49 +- docs/README.md | 15 + docs/{ => architecture}/01-architecture.md | 0 docs/{ => architecture}/CODEMAP.md | 31 +- docs/{ => architecture}/HERMESCUBE.md | 0 .../28-hermes-agent-jspace-assessment.md | 254 +++++++ .../02-hermes-plugin.md} | 0 .../15-ecosystem-fit.md} | 0 .../16-why-hermes.md} | 0 .../17-skills-memory.md} | 0 docs/integration/FOR_HERMES.md | 89 +++ .../00-map.md} | 0 .../10-claude-research.md} | 2 +- .../27-environment.md} | 2 +- docs/jspace/thesis-oew.md | 46 ++ .../18-tailscale.md} | 0 .../19-pocket-security.md} | 0 docs/{20-pulse-runtime.md => ops/20-pulse.md} | 0 .../21-hardening.md} | 0 .../23-everyday.md} | 0 docs/{ => ops}/RECOMMENDED.md | 0 docs/{ => ops}/hermes-env.example.sh | 0 .../03-cross-exam-and-networks.md | 0 docs/{ => research}/04-pattern-matrix.md | 0 docs/{ => research}/05-desk-pane.md | 0 docs/{ => research}/06-component-research.md | 0 .../07-cognitive-neuroscience.md | 0 .../08-meta-brain-ai-reverse.md | 0 docs/{ => research}/09-agent-io-and-memory.md | 0 docs/{ => research}/11-neural-space.md | 0 docs/{ => research}/12-local-model-neural.md | 0 .../13-full-concept-research.md | 0 .../14-workbench-pocket-dimension.md | 0 docs/{ => research}/18-autonomy-grid.md | 0 .../{ => research}/24-comparative-analysis.md | 0 .../{ => research}/25-context-optimization.md | 0 docs/{ => research}/26-benchmarks.md | 0 docs/{ => roadmap}/22-open-roadmap.md | 0 docs/roadmap/phases-oew.md | 12 + skills/hermespace/SKILL.md | 5 +- src/hermespace/__init__.py | 5 +- src/hermespace/cube_module.py | 2 +- src/hermespace/jspace/__init__.py | 45 ++ src/hermespace/jspace/env.py | 649 ++++++++++++++++++ src/hermespace/{jspace.py => jspace/hub.py} | 0 src/hermespace/jspace/protocol.py | 139 ++++ src/hermespace/jspace_env.py | 649 +----------------- src/hermespace/memory/README.md | 13 + src/hermespace/turn/README.md | 15 + src/hermespace/warehouse/README.md | 10 + src/hermespace/workflow.py | 17 + tests/test_jspace_protocol.py | 66 ++ 58 files changed, 1505 insertions(+), 805 deletions(-) create mode 100644 LAYOUT.md create mode 100644 docs/README.md rename docs/{ => architecture}/01-architecture.md (100%) rename docs/{ => architecture}/CODEMAP.md (66%) rename docs/{ => architecture}/HERMESCUBE.md (100%) create mode 100644 docs/assessment/28-hermes-agent-jspace-assessment.md rename docs/{02-integration-hermes.md => integration/02-hermes-plugin.md} (100%) rename docs/{15-hermes-ecosystem-fit.md => integration/15-ecosystem-fit.md} (100%) rename docs/{16-why-hermes-framework.md => integration/16-why-hermes.md} (100%) rename docs/{17-skills-memory-bridge.md => integration/17-skills-memory.md} (100%) create mode 100644 docs/integration/FOR_HERMES.md rename docs/{00-jspace-to-hermespace.md => jspace/00-map.md} (100%) rename docs/{10-jspace-claude.md => jspace/10-claude-research.md} (93%) rename docs/{27-jspace-environment.md => jspace/27-environment.md} (97%) create mode 100644 docs/jspace/thesis-oew.md rename docs/{18-tailscale-viewport.md => ops/18-tailscale.md} (100%) rename docs/{19-pocket-security-viewport.md => ops/19-pocket-security.md} (100%) rename docs/{20-pulse-runtime.md => ops/20-pulse.md} (100%) rename docs/{21-preupdate-hardening.md => ops/21-hardening.md} (100%) rename docs/{23-everyday-ops.md => ops/23-everyday.md} (100%) rename docs/{ => ops}/RECOMMENDED.md (100%) rename docs/{ => ops}/hermes-env.example.sh (100%) rename docs/{ => research}/03-cross-exam-and-networks.md (100%) rename docs/{ => research}/04-pattern-matrix.md (100%) rename docs/{ => research}/05-desk-pane.md (100%) rename docs/{ => research}/06-component-research.md (100%) rename docs/{ => research}/07-cognitive-neuroscience.md (100%) rename docs/{ => research}/08-meta-brain-ai-reverse.md (100%) rename docs/{ => research}/09-agent-io-and-memory.md (100%) rename docs/{ => research}/11-neural-space.md (100%) rename docs/{ => research}/12-local-model-neural.md (100%) rename docs/{ => research}/13-full-concept-research.md (100%) rename docs/{ => research}/14-workbench-pocket-dimension.md (100%) rename docs/{ => research}/18-autonomy-grid.md (100%) rename docs/{ => research}/24-comparative-analysis.md (100%) rename docs/{ => research}/25-context-optimization.md (100%) rename docs/{ => research}/26-benchmarks.md (100%) rename docs/{ => roadmap}/22-open-roadmap.md (100%) create mode 100644 docs/roadmap/phases-oew.md create mode 100644 src/hermespace/jspace/__init__.py create mode 100644 src/hermespace/jspace/env.py rename src/hermespace/{jspace.py => jspace/hub.py} (100%) create mode 100644 src/hermespace/jspace/protocol.py create mode 100644 src/hermespace/memory/README.md create mode 100644 src/hermespace/turn/README.md create mode 100644 src/hermespace/warehouse/README.md create mode 100644 tests/test_jspace_protocol.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3966ca..a37cee1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,8 @@ PYTHONPATH=src python3 -m unittest discover -s tests -v | Agent skill | `skills/hermespace/` | | CLI | `src/hermespace/cli.py`, `scripts/hs` | | Tests | `tests/` | -| North star | `PURPOSE.md`, `docs/CODEMAP.md` | +| North star | `PURPOSE.md`, `LAYOUT.md`, `docs/architecture/CODEMAP.md` | +| Assessment | `docs/assessment/28-hermes-agent-jspace-assessment.md` | | Maintainer note | `FOR_HERMES.md` | | Integration doors | `INTEGRATION.md`, `WORKFLOW.md` | diff --git a/FOR_HERMES.md b/FOR_HERMES.md index 54397bd..b4795fc 100644 --- a/FOR_HERMES.md +++ b/FOR_HERMES.md @@ -1,89 +1,3 @@ -# For Hermes Agent maintainers & dogfooders +# FOR_HERMES -**Repo:** https://github.com/PabloTheThinker/hermespace -**Companion to:** [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent) -**Not** an official Nous product — independent open companion workspace. - -## What this is (30 seconds) - -Hermespace adds a **limited working-memory desk** beside Hermes: - -1. **Workbench** — FOA, park stack, load-aware `receive_order` -2. **Dual decode** — short human `report` vs dense model `context` (never dump inject to chat) -3. **Fabric** — rank *this* `$HERMES_HOME/skills` + inject MEMORY/USER excerpts -4. **Plugin** — `on_session_start` / `pre_llm_call` / `on_session_end` broadcast when desk ready -5. **Study DB** — turns under `~/.hermespace` (local; not Hermes session DB) - -It does **not** replace Hermes tools, skills, memory, gateway, or the agent loop. - -## Fast path - -```bash -git clone https://github.com/PabloTheThinker/hermespace.git -cd hermespace -./scripts/install_hermes.sh -./scripts/smoke_test.sh # expect 9/9 -``` - -Then in any agent session: - -```python -from hermespace import Workbench -wb = Workbench(agent_id="hermes", session_id="main") -wb.enter() -r = wb.receive_order("…", goal="…", say="…", force=True) -# r["user_reply"] → user r["model_context"] → model -``` - -Or CLI: `./scripts/hs turn -m "…" --goal "…" --say "…" --force` - -## Design honesty - -| Claim | Reality | -|-------|---------| -| “Like Claude J-space” | **Role** only — limited workspace *outside* weights. No activation access. | -| Consciousness / brain scan | **No.** Harness cognition + optional local embeddings. | -| Replaces Hermes skills/memory | **No.** Ranks and injects them. | -| Official Nous module | **No.** Community companion; feedback welcome. | - -Inspired by working-memory limits (Baddeley-style capacity, load, GWT broadcast as *metaphors* for agent UX) and Anthropic’s public J-space *research framing* — implemented as open Hermes integration, not a closed-model probe. - -## Integration surface - -| Door | Entry | -|------|--------| -| Plugin | `hermes_plugin/` → `$HERMES_HOME/plugins/hermespace` | -| Skill | `skills/hermespace/` → `$HERMES_HOME/skills/hermespace` | -| Python | `hermespace.agent_api`, `hermespace.Workbench` | -| CLI | `scripts/hs` | -| Smoke | `scripts/smoke_test.sh` | - -Hook implementation lives in `src/hermespace/hermes_bridge.py` (plugin is thin `register`). - -When the plugin directory is a **symlink into this checkout**, import auto-resolves `../src` so dogfooders often need no `HERMESPACE_ROOT`. - -## Autonomy grid (v0.14) - -Missions, lenses, dream, self-talk, skillbench (hot-swap / merge / mutate), title tree. -Ground-up for Hermespace — not a port of AgentDrive or Conductor. - -```bash -hs grid status -hs grid dream --force -``` - -See docs/18-autonomy-grid.md. Autonomy self-order stays **off** unless `HERMESPACE_AUTONOMY=1`. - -## What we want from Hermes dogfood - -- Does plugin register cleanly on current Hermes? -- Is dual-decode useful on Telegram/gateway, or noisy? -- Fabric skill ranking quality on real skill trees? -- Gaps vs native Hermes memory / Honcho / skills loop? -- Would you want any of this upstream-shaped (skill only, plugin only, or neither)? - -Open issues/PRs on the repo. Thanks for looking. - -## License - -MIT — see `LICENSE`. Hermes Agent remains Nous Research’s project under its own license. +Moved to **[docs/integration/FOR_HERMES.md](docs/integration/FOR_HERMES.md)**. diff --git a/INTEGRATION.md b/INTEGRATION.md index 1f2ae50..87243bd 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -22,7 +22,7 @@ hermes plugins enable hermespace ``` Agent skill SoT: [`skills/hermespace/SKILL.md`](skills/hermespace/SKILL.md). -Recommended env: see `docs/RECOMMENDED.md`. +Recommended env: see `docs/ops/RECOMMENDED.md`. · Layout: `LAYOUT.md`. ## Door A — Python (best for agents) diff --git a/LAYOUT.md b/LAYOUT.md new file mode 100644 index 0000000..8298857 --- /dev/null +++ b/LAYOUT.md @@ -0,0 +1,59 @@ +# Hermespace codespace layout + +North star: [PURPOSE.md](PURPOSE.md) · Assessment: +[docs/assessment/28-hermes-agent-jspace-assessment.md](docs/assessment/28-hermes-agent-jspace-assessment.md) + +## Repository map + +``` +hermespace/ +├── PURPOSE.md / ABOUT.md / README.md / LAYOUT.md +├── src/hermespace/ # Python package +│ ├── jspace/ # ★ External J-Space (hub · env · protocol) +│ ├── turn/ # planned — workflow/desk/gate (README only) +│ ├── memory/ # planned — world/episodic/semantic (README only) +│ ├── warehouse/ # planned — cube cable (README only) +│ ├── grid/ # autonomy grid (missions · dream · skillbench) +│ ├── hermes_bridge.py # Hermes plugin hooks +│ └── … # turn spine still at package root (stable imports) +├── hermes_plugin/ # thin Hermes register() +├── desktop_plugin/ # Hermes Desktop page/pane +├── skills/hermespace/ # agent skill +├── docs/ +│ ├── jspace/ # J-Space map · thesis · environment +│ ├── assessment/ # deep assessments +│ ├── architecture/ # CODEMAP · Cube contract +│ ├── integration/ # Hermes fit · FOR_HERMES +│ ├── ops/ # pulse · everyday · recommended +│ ├── research/ # numbered research archive +│ └── roadmap/ # open backlog · OEW phases +├── tests/ · scripts/ · experiments/ · benchmarks/ +├── runtime/ · spec/ # templates / protocol specs +└── assets/ +``` + +## Layer authority + +| Layer | Path | Authority | +|-------|------|-----------| +| OEW / J-Space | `src/hermespace/jspace/` | Turn FOA + audit SoT | +| Turn spine | package root (`workflow`, `desk`, …) | Live turn | +| Autonomy | `grid/` | Missions / dream / skillbench | +| Identity projection | `world.py` … | Recharged from Cube | +| Warehouse cable | `cube_module.py` | Soft-fail Cube heart | +| Hermes surface | `hermes_plugin/` + bridge | Hooks only | + +## Import rules + +Prefer: + +```python +from hermespace.jspace import JSpace, JSpaceEnv, evaluate_material_turn +``` + +Compat: `hermespace.jspace_env` still re-exports `JSpaceEnv`. + +## Moving code later + +Planned packages under `turn/`, `memory/`, `warehouse/` hold READMEs only until +OEW Phase B is green — avoid churning imports while the protocol lands. diff --git a/PURPOSE.md b/PURPOSE.md index d9fc900..6c94c5e 100644 --- a/PURPOSE.md +++ b/PURPOSE.md @@ -6,10 +6,12 @@ observable workspace (lens · silent chain · audit · reflect), running **standalone** and **powered by HermesCube** at night the way CubeDream consolidates the day. -Public pitch: **[ABOUT.md](ABOUT.md)**. Research map: -**[docs/27-jspace-environment.md](docs/27-jspace-environment.md)**. -Code layout: **[docs/CODEMAP.md](docs/CODEMAP.md)**. Cube contract: -**[docs/HERMESCUBE.md](docs/HERMESCUBE.md)**. +Public pitch: **[ABOUT.md](ABOUT.md)**. Assessment: +**[docs/assessment/28-hermes-agent-jspace-assessment.md](docs/assessment/28-hermes-agent-jspace-assessment.md)**. +OEW thesis: **[docs/jspace/thesis-oew.md](docs/jspace/thesis-oew.md)**. +Environment: **[docs/jspace/27-environment.md](docs/jspace/27-environment.md)**. +Code layout: **[LAYOUT.md](LAYOUT.md)** · **[docs/architecture/CODEMAP.md](docs/architecture/CODEMAP.md)**. +Cube contract: **[docs/architecture/HERMESCUBE.md](docs/architecture/HERMESCUBE.md)**. --- diff --git a/README.md b/README.md index d24db2d..95ff6df 100644 --- a/README.md +++ b/README.md @@ -229,21 +229,21 @@ $HERMESPACE_HOME/memory/hermespace/ | Doc | Contents | |-----|----------| | [`PURPOSE.md`](PURPOSE.md) | North star — true external J-Space + Cube night path | -| [`docs/27-jspace-environment.md`](docs/27-jspace-environment.md) | Anthropic research → Hermespace environment | +| [`LAYOUT.md`](LAYOUT.md) | Codespace / folder map | +| [`docs/assessment/28-hermes-agent-jspace-assessment.md`](docs/assessment/28-hermes-agent-jspace-assessment.md) | Hermes Agent updates → OEW plan | +| [`docs/jspace/thesis-oew.md`](docs/jspace/thesis-oew.md) | Obligatory External Workspace thesis | +| [`docs/jspace/27-environment.md`](docs/jspace/27-environment.md) | Environment API | | [`ABOUT.md`](ABOUT.md) | Philosophy, design principles, author | -| [`docs/CODEMAP.md`](docs/CODEMAP.md) | Where to edit (layer map) | -| [`docs/HERMESCUBE.md`](docs/HERMESCUBE.md) | Cube heart/center contract | +| [`docs/architecture/CODEMAP.md`](docs/architecture/CODEMAP.md) | Where to edit (layer map) | +| [`docs/architecture/HERMESCUBE.md`](docs/architecture/HERMESCUBE.md) | Cube heart/center contract | | [`INTEGRATION.md`](INTEGRATION.md) | Python · CLI · plugin · workbench doors | | [`skills/hermespace/SKILL.md`](skills/hermespace/SKILL.md) | **Agent skill** (load in Hermes) | -| [`FOR_HERMES.md`](FOR_HERMES.md) | Maintainer / dogfood brief | +| [`docs/integration/FOR_HERMES.md`](docs/integration/FOR_HERMES.md) | Maintainer / dogfood brief | | [`CONTRIBUTING.md`](CONTRIBUTING.md) | How to contribute | | [`WORKFLOW.md`](WORKFLOW.md) | GATE → SEAL stages | | [`SECURITY.md`](SECURITY.md) | What never ships in git | -| [`docs/14-workbench-pocket-dimension.md`](docs/14-workbench-pocket-dimension.md) | Workbench reference (legacy) | -| [`docs/16-why-hermes-framework.md`](docs/16-why-hermes-framework.md) | Why this belongs in Hermes | -| [`docs/18-autonomy-grid.md`](docs/18-autonomy-grid.md) | Grid design | -| [`docs/20-pulse-runtime.md`](docs/20-pulse-runtime.md) | Pulse runtime | -| [`docs/22-open-roadmap.md`](docs/22-open-roadmap.md) | Roadmap | +| [`docs/roadmap/phases-oew.md`](docs/roadmap/phases-oew.md) | OEW phases A–D | +| [`docs/README.md`](docs/README.md) | Full docs folder index | | [`spec/DESK.md`](spec/DESK.md) | Desk schema | | [`spec/PROTOCOL.md`](spec/PROTOCOL.md) | Protocol spec | @@ -252,18 +252,19 @@ $HERMESPACE_HOME/memory/hermespace/ ## Repository Layout ```text -assets/ media (banners, diagrams) +LAYOUT.md codespace map (start here for structure) src/hermespace/ runtime package + jspace/ ★ external J-Space (hub · env · OEW protocol) + grid/ autonomy grid hermes_plugin/ Hermes session / pre_llm / end hooks skills/hermespace/ public Hermes agent skill +docs/ jspace · assessment · architecture · ops · research scripts/ CLI, install, smoke test, security audit -docs/ design notes (20+ docs) -spec/ desk schema + protocol -tests/ unit tests -experiments/ eval harness, neural benchmarks -desktop_plugin/ Hermes Desktop sidebar + full page +tests/ · experiments/ · desktop_plugin/ · spec/ ``` +Full map: [`LAYOUT.md`](LAYOUT.md). + --- ## Verify diff --git a/docs/INDEX.md b/docs/INDEX.md index a8b034a..5dfc1cc 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,34 +1,21 @@ # docs index -Start: [../README.md](../README.md) · [../FOR_HERMES.md](../FOR_HERMES.md) · [../INTEGRATION.md](../INTEGRATION.md) · [../WORKFLOW.md](../WORKFLOW.md) +Start: [../README.md](../README.md) · [../LAYOUT.md](../LAYOUT.md) · [../PURPOSE.md](../PURPOSE.md) -| File | Topic | -|------|--------| -| [27-jspace-environment.md](27-jspace-environment.md) | True J-Space environment — Anthropic research → external observable workspace | -| [01-architecture.md](01-architecture.md) | Architecture | -| [CODEMAP.md](CODEMAP.md) | Where to edit (layer map) | -| [HERMESCUBE.md](HERMESCUBE.md) | Cube heart/center contract + standalone | -| [02-integration-hermes.md](02-integration-hermes.md) | Hermes plugin | -| [03-cross-exam-and-networks.md](03-cross-exam-and-networks.md) | Cross-exam | -| [04-pattern-matrix.md](04-pattern-matrix.md) | Pattern matrix | -| [05-desk-pane.md](05-desk-pane.md) | Viewing the desk | -| [06-component-research.md](06-component-research.md) | Components | -| [07-cognitive-neuroscience.md](07-cognitive-neuroscience.md) | Baddeley / GWT / load metaphors | -| [08-meta-brain-ai-reverse.md](08-meta-brain-ai-reverse.md) | Meta encode/decode reverse | -| [09-agent-io-and-memory.md](09-agent-io-and-memory.md) | Agent INPUT/OUTPUT + memory DB | -| [10-jspace-claude.md](10-jspace-claude.md) | J-space / Claude research note | -| [11-neural-space.md](11-neural-space.md) | Neural space design + backends | -| [12-local-model-neural.md](12-local-model-neural.md) | Local models for neural space | -| [RECOMMENDED.md](RECOMMENDED.md) | Production best-path config | -| [13-full-concept-research.md](13-full-concept-research.md) | Full concept research | -| [14-workbench-pocket-dimension.md](14-workbench-pocket-dimension.md) | Workbench / pocket dimension | -| [15-hermes-ecosystem-fit.md](15-hermes-ecosystem-fit.md) | Hermes / Nous ecosystem fit | -| [16-why-hermes-framework.md](16-why-hermes-framework.md) | Why Hermespace belongs in Hermes | -| [17-skills-memory-bridge.md](17-skills-memory-bridge.md) | Skills + MEMORY/USER fabric | -| [hermes-env.example.sh](hermes-env.example.sh) | Env template | -| [18-autonomy-grid.md](18-autonomy-grid.md) | Autonomy grid — missions, lenses, dream, gated self-order | -| [19-pocket-security-viewport.md](19-pocket-security-viewport.md) | Pocket boundary + user viewport | -| [20-pulse-runtime.md](20-pulse-runtime.md) | Pulse pocket runtime (smarter than cron) | -| [21-preupdate-hardening.md](21-preupdate-hardening.md) | v0.16.1 perf + notes fixes | -| [22-open-roadmap.md](22-open-roadmap.md) | Open research backlog | -| [18-tailscale-viewport.md](18-tailscale-viewport.md) | Portable Tailscale viewport | +## Primary (read these) + +| Doc | Topic | +|-----|--------| +| [assessment/28-hermes-agent-jspace-assessment.md](assessment/28-hermes-agent-jspace-assessment.md) | Hermes Agent updates → OEW plan | +| [jspace/thesis-oew.md](jspace/thesis-oew.md) | Obligatory External Workspace thesis | +| [jspace/27-environment.md](jspace/27-environment.md) | Environment API | +| [jspace/00-map.md](jspace/00-map.md) | Anthropic property → harness map | +| [architecture/CODEMAP.md](architecture/CODEMAP.md) | Where to edit | +| [architecture/HERMESCUBE.md](architecture/HERMESCUBE.md) | Cube heart contract | +| [integration/FOR_HERMES.md](integration/FOR_HERMES.md) | Dogfood notes for Hermes | +| [roadmap/phases-oew.md](roadmap/phases-oew.md) | Phase A–D | + +## By folder + +See [README.md](README.md) for the full folder map. Research archive lives under +[research/](research/); ops under [ops/](ops/). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b3d168a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,15 @@ +# Documentation + +| Folder | Contents | +|--------|----------| +| [jspace/](jspace/) | Anthropic map · OEW thesis · environment API | +| [assessment/](assessment/) | Deep assessments (start with **28**) | +| [architecture/](architecture/) | CODEMAP · Cube contract | +| [integration/](integration/) | Hermes plugin · ecosystem · FOR_HERMES | +| [ops/](ops/) | Pulse · everyday · recommended config | +| [research/](research/) | Pattern matrix · neural · comparative archive | +| [roadmap/](roadmap/) | Open backlog · OEW phases | + +**Start here:** [assessment/28-hermes-agent-jspace-assessment.md](assessment/28-hermes-agent-jspace-assessment.md) +**Thesis:** [jspace/thesis-oew.md](jspace/thesis-oew.md) +**Repo layout:** [../LAYOUT.md](../LAYOUT.md) diff --git a/docs/01-architecture.md b/docs/architecture/01-architecture.md similarity index 100% rename from docs/01-architecture.md rename to docs/architecture/01-architecture.md diff --git a/docs/CODEMAP.md b/docs/architecture/CODEMAP.md similarity index 66% rename from docs/CODEMAP.md rename to docs/architecture/CODEMAP.md index 449828e..904ef23 100644 --- a/docs/CODEMAP.md +++ b/docs/architecture/CODEMAP.md @@ -3,7 +3,8 @@ Hermespace is one Python package (`src/hermespace/`) plus a thin Hermes plugin (`hermes_plugin/`), skill, and optional desktop UI. -**North star:** [PURPOSE.md](../PURPOSE.md) · **Architecture:** [01-architecture.md](01-architecture.md) +**North star:** [PURPOSE.md](../../PURPOSE.md) · **Layout:** [LAYOUT.md](../../LAYOUT.md) · +**Assessment:** [../assessment/28-hermes-agent-jspace-assessment.md](../assessment/28-hermes-agent-jspace-assessment.md) ## Layers (edit here first) @@ -12,11 +13,20 @@ L6 operator cli.py ops.py scripts/ desktop_plugin/ L5 autonomy grid/* pulse.py L4 identity world.py episodic.py semantic.py memory_db.py L3 warehouse cube_module.py (Cube center/heart OR standalone) -L2 J-Space ENV jspace.py jspace_env.py cognition.py streams.py neural_space.py +L2 J-Space OEW jspace/ cognition.py streams.py neural_space.py L1 turn spine workflow.py engine.py desk.py gate.py inject.py L0 contract io_contract.py paths.py store.py agent_api.py ``` +## J-Space package (`jspace/`) — L2 + +| Module | Role | +|--------|------| +| `jspace/hub.py` | Hub · hold · reason · report · broadcast | +| `jspace/env.py` | Lens · swap · audit · reflect · harvest | +| `jspace/protocol.py` | OEW gate (`HERMESPACE_OEW`) | +| `jspace_env.py` | Compat shim → `jspace.env` | + ## Turn spine (L1) | Module | Role | @@ -27,16 +37,6 @@ L0 contract io_contract.py paths.py store.py agent_api.py | `gate.py` | Selectivity — skip trivial | | `inject.py` | GWT broadcast of desk | -## J-Space environment (L2) - -| Module | Role | -|--------|------| -| `jspace.py` | Hub · hold · reason · report · broadcast | -| `jspace_env.py` | Lens · swap · audit · reflect · harvest · protocol | -| `cognition.py` | FOA≤4, load, executive modes | -| `streams.py` | Multi-stream encode / report decode | -| `neural_space.py` | Embedding FOA field | - ## Warehouse cable (L3) | Module | Role | @@ -52,10 +52,15 @@ L0 contract io_contract.py paths.py store.py agent_api.py | `workbench.py` | Session pocket — enter / order / idle | | `agent_api.py` | Public doors for agents | +## Planned packages (README only) + +`turn/` · `memory/` · `warehouse/` — move modules after OEW Phase B is green. + ## Rules of thumb 1. Dual decode: never dump `context` into user chat. 2. Soft-fail Cube — Space must run standalone. 3. When Cube present: `.cube` is durable SoT; world JSONL is projection. 4. Peel grid features; don’t inflate `cli.py` without need. -5. J-Space silent steps stay in model context only. +5. J-Space silent steps stay in model context only. +6. Prefer `from hermespace.jspace import …` over deep/compat paths. diff --git a/docs/HERMESCUBE.md b/docs/architecture/HERMESCUBE.md similarity index 100% rename from docs/HERMESCUBE.md rename to docs/architecture/HERMESCUBE.md diff --git a/docs/assessment/28-hermes-agent-jspace-assessment.md b/docs/assessment/28-hermes-agent-jspace-assessment.md new file mode 100644 index 0000000..84aef1b --- /dev/null +++ b/docs/assessment/28-hermes-agent-jspace-assessment.md @@ -0,0 +1,254 @@ +# Assessment — Making Hermespace the J-Space of Hermes Agent + +**Date:** 2026-08-03 +**Hermespace:** v0.20.0 +**Hermes Agent surveyed:** v0.19.1 (v2026.7.30) · Quicksilver v0.19.0 · Judgment v0.18.0 · main as of 2026-08-03 +**Companion:** [HermesCube](https://github.com/PabloTheThinker/hermescube) v0.50 (heart / library) +**Research basis:** Anthropic *Verbalizable Representations Form a Global Workspace* (2026) + +--- + +## 0. Verdict + +**Possible.** Hermespace already implements the *roles* of Anthropic's J-space as an +external harness. Hermes Agent's recent architecture (cache-safe `pre_llm_call`, +Context Engine `select_context` / `on_turn_complete`, MemoryProvider split, +background review, MoA, verification contracts, subagent transcripts, live +reasoning streams) gives us the **hooks to make that workspace obligatory and +causal** — the missing piece that turns a diary into a global workspace. + +The innovation is not a Jacobian lens on Hermes weights. It is an +**Obligatory External Workspace (OEW)**: a protocol bottleneck where material +Hermes cognition must park verbalizable intermediates in Hermespace, the operator +can lens them, swaps redirect later Report/broadcast, and CubeDream consolidates +them at night. + +``` +Anthropic: weights ──J-lens──► silent verbalizable concepts +Hermespace: Hermes ──OEW protocol──► durable hub/silent chain ──lens──► operator + │ + ▼ + Cube heart (day strip) + CubeDream (night) +``` + +--- + +## 1. What Anthropic J-space is (target properties) + +| Property | Meaning | Hermespace analogue today | Gap | +|----------|---------|---------------------------|-----| +| Report | Contents can be named on ask | `JSpace.report` / dual decode | Soft — not forced | +| Directed modulation | Hold / focus on request | `hold` / `release` / `inhibit` | Soft | +| Silent multi-step | Intermediates without speech | `reason_step` + mid band | Opt-in | +| Flexible broadcast | One concept → many tasks | `broadcast_block` on inject | Present | +| Selectivity | Fluency without workspace | `gate.should_inject` | Weak measurement | +| Causal swap/inject | Edits change answers | `swap` / `inject` / `ablate` | Not turn-binding yet | +| Alignment audit | Hidden goals visible | soft lexicon `audit()` | Externalized-only | +| Assistant POV | Post-training voice in workspace | `set_pov` | Present | +| Counterfactual reflection | Interrupt → shape later thought | `reflect()` | No next-turn seed | +| Night consolidation | Sleep / dream | `dream_harvest` + CubeDream | Wired soft | + +Capacity targets already match the paper's "tens of concepts": hub ≤25, activated ≤12, FOA ≤4. + +--- + +## 2. Recent Hermes Agent updates — what they unlock + +Sources: [v0.19.0 Quicksilver](https://github.com/NousResearch/hermes-agent/releases/tag/v2026.7.20), +[v0.18.0 Judgment](https://github.com/NousResearch/hermes-agent/releases/tag/v2026.7.1), +developer docs for [hooks](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md), +[MemoryProvider](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/developer-guide/memory-provider-plugin.md), +[Context Engine](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/developer-guide/context-engine-plugin.md), +`agent/background_review.py`. + +### 2.1 Quicksilver (v0.19) — speed + durability + +| Hermes change | Hermespace implication | +|---------------|------------------------| +| ~80% TTFT cut; cold path ruthless | Inject must stay **tiny**; load budgets (900/640/420/280) are non-negotiable | +| Live reasoning streams default ON | Mid-band can mirror streamed reasoning **without** dumping it into user Report | +| Smart approvals default + `pre_tool_call` approve | Workspace `audit()` flags can escalate tool risk | +| Subagent live transcripts + durable ledger | Per-child `JSpace(agent_id=sub…)` hubs; parent lens aggregates | +| Delivery-obligation ledger | Seal + blackbox provenance pair with Cube heart | +| Profile-based gateway routing | `HERMESPACE_HOME` / agent_id per profile — isolate hubs | +| Sessions export (MD/HTML/HF traces) | Export hub + silent chain + audit as alignment dataset | +| Hooks spill oversized context to disk | Cap J-Space broadcast; spill full lens to viewport files | +| Byte-stable gateway system prompts | Keep Space out of system prompt; user-message inject only | + +### 2.2 Judgment (v0.18) — thinking quality + +| Hermes change | Hermespace implication | +|---------------|------------------------| +| MoA first-class presets | Hub holds committee intermediates; Report = aggregator only | +| Verification evidence + `/goal` completion contracts | Seal verified outcomes into hub → Cube; never claim-only | +| Background `delegate_task` fan-out | Parent hub parks mission; children write silent steps; harvest merges | +| `/learn` + `/journey` + memory graph | WorldModel + Cube journey stay projections; Space FOA stays turn-local | +| Background review fork (memory/skill) | Night sibling: `dream_harvest` after review, not competing MemoryProvider | + +### 2.3 Stable platform contracts (must respect) + +| Contract | Rule for Hermespace | +|----------|---------------------| +| `pre_llm_call` → **user message** only | Never touch system prompt (cache) | +| One MemoryProvider | **Cube** owns it; Space must not register a second provider | +| One Context Engine | Optional future: OEW as `context.engine` *or* stay plugin-hook | +| Soft-fail plugins | Bridge never crashes Hermes | +| Builtin MEMORY.md | Hot catalog; Cube mirrors; Space proposes only | + +--- + +## 3. HermesCube role (do not blur) + +From Cube `docs/HERMESPACE.md` / `ANATOMY.md` / `CUBEDREAM.md`: + +| Organ | Owner | Day | Night | +|-------|-------|-----|-------| +| Nervous FOA / J-Space | **Hermespace** | hold · reason · report · lens | harvest source | +| Heart (`memory.cube`) | **HermesCube** | arterial strip | seal intake | +| Dream | Space grid + CubeDream | — | consolidate chapters | +| Blackbox | Cube | — | prove claims | + +**Hard rule:** Hermespace must not grow a second durable archive. World JSONL is a +projection charged from Cube. OEW lives in the *turn mind*, Cube in the *years*. + +--- + +## 4. Innovation thesis — Obligatory External Workspace (OEW) + +### 4.1 The insight + +Claude's J-space works because higher-order thought **cannot skip it**. +Hermespace today is optional parking. OEW flips the default: + +> On material turns, Hermes may speak to the user only after at least one +> verbalizable intermediate has been written into the Hermespace mid-band, +> and Report is a late-band readout of the hub — not a free-form dump of model context. + +That single constraint recreates the five GWT properties *functionally*: + +1. **Report** — lens/report read the hub +2. **Modulation** — hold/swap edit the hub before Report +3. **Silent reasoning** — mid-band is required, not decorative +4. **Broadcast** — pre_llm inject is hub-shaped +5. **Selectivity** — gate skips trivial turns; fluency path bypasses OEW + +### 4.2 Causal loop (paper experiments → harness tests) + +| Paper experiment | OEW harness test | +|------------------|------------------| +| Soccer→Rugby swap changes report | `swap` then `receive_order` → Report contains Rugby | +| Inject "lightning" → model reports it | `inject_thought` → next Report/audit sees it | +| Ablate eval-awareness → behavior shifts | `ablate` + re-run → audit/behavior delta | +| Hold fruit while copying text | Hub has fruit; Report is the copy only | +| France→China flexible reuse | One hold redirects capital/currency/continent prompts | + +### 4.3 Day / night circulation + +``` +DAY message → GATE → OEW encode (early) + → silent steps (mid) [required if material] + → Report (late) [user] + → broadcast hub [model via pre_llm] + → audit soft flags + → Cube beat (arterial) + seal decisions + +NIGHT pulse / grid dream / background_review + → dream_harvest silent+hub + → seal_learning → CubeDream + → autonomic_tick charges World → next-day hub +``` + +### 4.4 Why this is new (vs copying Anthropic or anomalyco) + +- Not weight interpretability +- Not a SaaS "J-Space" product +- Not a second agent runtime +- **Protocol-enforced external access-consciousness for tool-using Hermes agents**, + with Cube as heart and operator lens as J-lens substitute + +--- + +## 5. What we can do — phased program + +### Phase A — Foundation (this PR / next cut) + +| Item | Detail | Effort | +|------|--------|--------| +| A1 Codespace layout | `jspace/` package · docs folders · planned `turn/` `memory/` `warehouse/` | S — done scaffold | +| A2 Protocol scaffold | `jspace/protocol.py` + `HERMESPACE_OEW` soft gate | S — scaffolded | +| A3 Assessment + thesis docs | This file + `docs/jspace/thesis-oew.md` | S | +| A4 Wire soft protocol into workflow meta | Record verdict on every material turn (non-blocking) | S | + +### Phase B — Causal OEW (core product) + +| Item | Detail | Effort | +|------|--------|--------| +| B1 Mandatory silent park | Material turns auto-`reason_step` from plan/goal if agent omitted | M | +| B2 Sticky swap/inject | Redirects change next Report + broadcast; unit tests from paper table | M | +| B3 Reflect → next mid-band | `reflect()` seeds POV + next silent priors | M | +| B4 Ablate behavioral path | Soft ablate filters inject; audit delta logged | M | +| B5 Quicksilver inject hygiene | Hard caps + spill-to-viewport; never bloat TTFT | S | + +### Phase C — Hermes-native depth + +| Item | Detail | Effort | +|------|--------|--------| +| C1 Reasoning-stream mirror | When Hermes streams reasoning, park summaries in mid-band | M | +| C2 MoA committee hub | Per-advisor silent slots; aggregator = Report | M | +| C3 Subagent hubs | Child agent_id workspaces; parent harvest | M | +| C4 Verify → seal | Completion-contract evidence → hub seal → Cube/blackbox | M | +| C5 `pre_tool_call` audit gate | High-risk tools consult workspace audit flags | M | +| C6 Optional Context Engine | `select_context` returns hub-shaped request context (config opt-in) | L | +| C7 Session export adapter | Hub+silent+audit → Hermes export / HF trace | S | + +### Phase D — Night + operator + +| Item | Detail | Effort | +|------|--------|--------| +| D1 dream_harvest ↔ CubeDream | Harden seal path; diary proposals only for MEMORY.md | M | +| D2 Event-driven pulse | access_approved / session_end → immediate harvest | M | +| D3 Desktop lens SoT | Live hub panel; swap UI; audit chips | M | +| D4 Falsifiable eval suite | Paper-shaped scenarios in `experiments/` + CI | M | + +### Explicit non-goals + +- Weight-level J-lens / torch interpretability in default install +- Consciousness claims +- Competing with HermesCube as MemoryProvider +- Dumping silent chain into user chat +- Porting foreign product trees into Hermespace + +--- + +## 6. Risks + +| Risk | Mitigation | +|------|------------| +| OEW adds latency / TTFT regression | Soft default off; tiny auto-park; Quicksilver budgets | +| Agents ignore protocol | Skill + inject instructions + optional hard gate | +| Context bloat | Hub caps; spill; load tiers | +| Double archive with Cube | Authority table; doctor warns | +| Cache busting | User-message inject only | +| Over-claiming "we are J-space" | Honesty headers; role language only | + +--- + +## 7. Success metrics + +1. Operator: `hs jspace lens` mid-task shows silent intermediates on material work +2. Causal: Soccer→Rugby-style swap test green in CI +3. Dual decode: user never sees full inject; model always gets hub broadcast +4. Night: harvest seals into Cube when present; standalone semantic otherwise +5. Perf: material inject ≤ protect budget under high load +6. Hermes dogfood: plugin registers on current Hermes; TTFT impact measured + +--- + +## 8. Recommended next monotropic cut + +1. Wire soft `ProtocolGate` into `workflow.run` / `hermes_bridge` (meta only) +2. Auto-park one silent step from goal/plan on material turns +3. Add causal swap unit tests +4. Keep Cube soft-fail path green + +That is enough to *start being* Hermes's J-space — then deepen with MoA, subagents, and Context Engine options. diff --git a/docs/02-integration-hermes.md b/docs/integration/02-hermes-plugin.md similarity index 100% rename from docs/02-integration-hermes.md rename to docs/integration/02-hermes-plugin.md diff --git a/docs/15-hermes-ecosystem-fit.md b/docs/integration/15-ecosystem-fit.md similarity index 100% rename from docs/15-hermes-ecosystem-fit.md rename to docs/integration/15-ecosystem-fit.md diff --git a/docs/16-why-hermes-framework.md b/docs/integration/16-why-hermes.md similarity index 100% rename from docs/16-why-hermes-framework.md rename to docs/integration/16-why-hermes.md diff --git a/docs/17-skills-memory-bridge.md b/docs/integration/17-skills-memory.md similarity index 100% rename from docs/17-skills-memory-bridge.md rename to docs/integration/17-skills-memory.md diff --git a/docs/integration/FOR_HERMES.md b/docs/integration/FOR_HERMES.md new file mode 100644 index 0000000..54397bd --- /dev/null +++ b/docs/integration/FOR_HERMES.md @@ -0,0 +1,89 @@ +# For Hermes Agent maintainers & dogfooders + +**Repo:** https://github.com/PabloTheThinker/hermespace +**Companion to:** [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent) +**Not** an official Nous product — independent open companion workspace. + +## What this is (30 seconds) + +Hermespace adds a **limited working-memory desk** beside Hermes: + +1. **Workbench** — FOA, park stack, load-aware `receive_order` +2. **Dual decode** — short human `report` vs dense model `context` (never dump inject to chat) +3. **Fabric** — rank *this* `$HERMES_HOME/skills` + inject MEMORY/USER excerpts +4. **Plugin** — `on_session_start` / `pre_llm_call` / `on_session_end` broadcast when desk ready +5. **Study DB** — turns under `~/.hermespace` (local; not Hermes session DB) + +It does **not** replace Hermes tools, skills, memory, gateway, or the agent loop. + +## Fast path + +```bash +git clone https://github.com/PabloTheThinker/hermespace.git +cd hermespace +./scripts/install_hermes.sh +./scripts/smoke_test.sh # expect 9/9 +``` + +Then in any agent session: + +```python +from hermespace import Workbench +wb = Workbench(agent_id="hermes", session_id="main") +wb.enter() +r = wb.receive_order("…", goal="…", say="…", force=True) +# r["user_reply"] → user r["model_context"] → model +``` + +Or CLI: `./scripts/hs turn -m "…" --goal "…" --say "…" --force` + +## Design honesty + +| Claim | Reality | +|-------|---------| +| “Like Claude J-space” | **Role** only — limited workspace *outside* weights. No activation access. | +| Consciousness / brain scan | **No.** Harness cognition + optional local embeddings. | +| Replaces Hermes skills/memory | **No.** Ranks and injects them. | +| Official Nous module | **No.** Community companion; feedback welcome. | + +Inspired by working-memory limits (Baddeley-style capacity, load, GWT broadcast as *metaphors* for agent UX) and Anthropic’s public J-space *research framing* — implemented as open Hermes integration, not a closed-model probe. + +## Integration surface + +| Door | Entry | +|------|--------| +| Plugin | `hermes_plugin/` → `$HERMES_HOME/plugins/hermespace` | +| Skill | `skills/hermespace/` → `$HERMES_HOME/skills/hermespace` | +| Python | `hermespace.agent_api`, `hermespace.Workbench` | +| CLI | `scripts/hs` | +| Smoke | `scripts/smoke_test.sh` | + +Hook implementation lives in `src/hermespace/hermes_bridge.py` (plugin is thin `register`). + +When the plugin directory is a **symlink into this checkout**, import auto-resolves `../src` so dogfooders often need no `HERMESPACE_ROOT`. + +## Autonomy grid (v0.14) + +Missions, lenses, dream, self-talk, skillbench (hot-swap / merge / mutate), title tree. +Ground-up for Hermespace — not a port of AgentDrive or Conductor. + +```bash +hs grid status +hs grid dream --force +``` + +See docs/18-autonomy-grid.md. Autonomy self-order stays **off** unless `HERMESPACE_AUTONOMY=1`. + +## What we want from Hermes dogfood + +- Does plugin register cleanly on current Hermes? +- Is dual-decode useful on Telegram/gateway, or noisy? +- Fabric skill ranking quality on real skill trees? +- Gaps vs native Hermes memory / Honcho / skills loop? +- Would you want any of this upstream-shaped (skill only, plugin only, or neither)? + +Open issues/PRs on the repo. Thanks for looking. + +## License + +MIT — see `LICENSE`. Hermes Agent remains Nous Research’s project under its own license. diff --git a/docs/00-jspace-to-hermespace.md b/docs/jspace/00-map.md similarity index 100% rename from docs/00-jspace-to-hermespace.md rename to docs/jspace/00-map.md diff --git a/docs/10-jspace-claude.md b/docs/jspace/10-claude-research.md similarity index 93% rename from docs/10-jspace-claude.md rename to docs/jspace/10-claude-research.md index e424a42..8998a43 100644 --- a/docs/10-jspace-claude.md +++ b/docs/jspace/10-claude-research.md @@ -27,4 +27,4 @@ Phenomenal consciousness; public third-party live readouts of Claude J-space; id ## Hermespace mapping -See WORKFLOW.md and docs/00-jspace-to-hermespace.md — harness **roles**, not neural clone. +See WORKFLOW.md and docs/jspace/00-map.md — harness **roles**, not neural clone. diff --git a/docs/27-jspace-environment.md b/docs/jspace/27-environment.md similarity index 97% rename from docs/27-jspace-environment.md rename to docs/jspace/27-environment.md index f73ab6f..957df93 100644 --- a/docs/27-jspace-environment.md +++ b/docs/jspace/27-environment.md @@ -133,4 +133,4 @@ Matched against **externalized** hub/silent/reflection text only. - https://www.anthropic.com/research/global-workspace - https://transformer-circuits.pub/2026/workspace/ - https://github.com/anthropics/jacobian-lens -- Hermespace: `PURPOSE.md`, `docs/HERMESCUBE.md`, `docs/CODEMAP.md` +- Hermespace: `PURPOSE.md`, `docs/architecture/HERMESCUBE.md`, `docs/architecture/CODEMAP.md`, `docs/jspace/thesis-oew.md` diff --git a/docs/jspace/thesis-oew.md b/docs/jspace/thesis-oew.md new file mode 100644 index 0000000..314cddd --- /dev/null +++ b/docs/jspace/thesis-oew.md @@ -0,0 +1,46 @@ +# Thesis — Obligatory External Workspace (OEW) + +Hermespace becomes the **J-space of Hermes agents** by enforcing a +verbalizable bottleneck outside the model. + +## One sentence + +On material turns, Hermes must park silent intermediates in Hermespace before +Report; the operator lens reads that hub the way Anthropic's J-lens reads Claude. + +## Why "obligatory" + +Optional workspaces are diaries. Anthropic's J-space matters because higher-order +cognition is *causally routed through it*. OEW copies that constraint at the +harness layer. + +## Circulatory picture + +``` + ┌──────── Hermespace OEW (nervous FOA) ────────┐ + orders ──────► │ early encode → mid silent* → late Report │ + │ broadcast hub → model (pre_llm user msg) │ + │ lens / swap / audit / reflect │ + idle/pulse ──► │ dream_harvest ─────────────────┐ │ + └────────────────────────────────┼─────────────┘ + │ + ┌──────── HermesCube heart ──────▼─────────────┐ + │ arterial strip by day · seal + CubeDream night│ + └──────────────────────────────────────────────┘ +* required when HERMESPACE_OEW=1 and turn is material +``` + +## Flags + +| Env | Default | Effect | +|-----|---------|--------| +| `HERMESPACE_OEW` | `0` | Soft: verdict recorded, turns not blocked | +| `HERMESPACE_OEW=1` | — | Hard: incomplete material turns fail protocol | + +## Package + +- `src/hermespace/jspace/hub.py` — capacity-limited hub +- `src/hermespace/jspace/env.py` — lens / swap / audit / reflect / harvest +- `src/hermespace/jspace/protocol.py` — OEW gate + +Full assessment: [../assessment/28-hermes-agent-jspace-assessment.md](../assessment/28-hermes-agent-jspace-assessment.md) diff --git a/docs/18-tailscale-viewport.md b/docs/ops/18-tailscale.md similarity index 100% rename from docs/18-tailscale-viewport.md rename to docs/ops/18-tailscale.md diff --git a/docs/19-pocket-security-viewport.md b/docs/ops/19-pocket-security.md similarity index 100% rename from docs/19-pocket-security-viewport.md rename to docs/ops/19-pocket-security.md diff --git a/docs/20-pulse-runtime.md b/docs/ops/20-pulse.md similarity index 100% rename from docs/20-pulse-runtime.md rename to docs/ops/20-pulse.md diff --git a/docs/21-preupdate-hardening.md b/docs/ops/21-hardening.md similarity index 100% rename from docs/21-preupdate-hardening.md rename to docs/ops/21-hardening.md diff --git a/docs/23-everyday-ops.md b/docs/ops/23-everyday.md similarity index 100% rename from docs/23-everyday-ops.md rename to docs/ops/23-everyday.md diff --git a/docs/RECOMMENDED.md b/docs/ops/RECOMMENDED.md similarity index 100% rename from docs/RECOMMENDED.md rename to docs/ops/RECOMMENDED.md diff --git a/docs/hermes-env.example.sh b/docs/ops/hermes-env.example.sh similarity index 100% rename from docs/hermes-env.example.sh rename to docs/ops/hermes-env.example.sh diff --git a/docs/03-cross-exam-and-networks.md b/docs/research/03-cross-exam-and-networks.md similarity index 100% rename from docs/03-cross-exam-and-networks.md rename to docs/research/03-cross-exam-and-networks.md diff --git a/docs/04-pattern-matrix.md b/docs/research/04-pattern-matrix.md similarity index 100% rename from docs/04-pattern-matrix.md rename to docs/research/04-pattern-matrix.md diff --git a/docs/05-desk-pane.md b/docs/research/05-desk-pane.md similarity index 100% rename from docs/05-desk-pane.md rename to docs/research/05-desk-pane.md diff --git a/docs/06-component-research.md b/docs/research/06-component-research.md similarity index 100% rename from docs/06-component-research.md rename to docs/research/06-component-research.md diff --git a/docs/07-cognitive-neuroscience.md b/docs/research/07-cognitive-neuroscience.md similarity index 100% rename from docs/07-cognitive-neuroscience.md rename to docs/research/07-cognitive-neuroscience.md diff --git a/docs/08-meta-brain-ai-reverse.md b/docs/research/08-meta-brain-ai-reverse.md similarity index 100% rename from docs/08-meta-brain-ai-reverse.md rename to docs/research/08-meta-brain-ai-reverse.md diff --git a/docs/09-agent-io-and-memory.md b/docs/research/09-agent-io-and-memory.md similarity index 100% rename from docs/09-agent-io-and-memory.md rename to docs/research/09-agent-io-and-memory.md diff --git a/docs/11-neural-space.md b/docs/research/11-neural-space.md similarity index 100% rename from docs/11-neural-space.md rename to docs/research/11-neural-space.md diff --git a/docs/12-local-model-neural.md b/docs/research/12-local-model-neural.md similarity index 100% rename from docs/12-local-model-neural.md rename to docs/research/12-local-model-neural.md diff --git a/docs/13-full-concept-research.md b/docs/research/13-full-concept-research.md similarity index 100% rename from docs/13-full-concept-research.md rename to docs/research/13-full-concept-research.md diff --git a/docs/14-workbench-pocket-dimension.md b/docs/research/14-workbench-pocket-dimension.md similarity index 100% rename from docs/14-workbench-pocket-dimension.md rename to docs/research/14-workbench-pocket-dimension.md diff --git a/docs/18-autonomy-grid.md b/docs/research/18-autonomy-grid.md similarity index 100% rename from docs/18-autonomy-grid.md rename to docs/research/18-autonomy-grid.md diff --git a/docs/24-comparative-analysis.md b/docs/research/24-comparative-analysis.md similarity index 100% rename from docs/24-comparative-analysis.md rename to docs/research/24-comparative-analysis.md diff --git a/docs/25-context-optimization.md b/docs/research/25-context-optimization.md similarity index 100% rename from docs/25-context-optimization.md rename to docs/research/25-context-optimization.md diff --git a/docs/26-benchmarks.md b/docs/research/26-benchmarks.md similarity index 100% rename from docs/26-benchmarks.md rename to docs/research/26-benchmarks.md diff --git a/docs/22-open-roadmap.md b/docs/roadmap/22-open-roadmap.md similarity index 100% rename from docs/22-open-roadmap.md rename to docs/roadmap/22-open-roadmap.md diff --git a/docs/roadmap/phases-oew.md b/docs/roadmap/phases-oew.md new file mode 100644 index 0000000..8841f6b --- /dev/null +++ b/docs/roadmap/phases-oew.md @@ -0,0 +1,12 @@ +# OEW roadmap phases + +See full assessment: [../assessment/28-hermes-agent-jspace-assessment.md](../assessment/28-hermes-agent-jspace-assessment.md) + +| Phase | Theme | Status | +|-------|-------|--------| +| **A** | Layout + protocol scaffold + docs | in progress | +| **B** | Causal OEW (mandatory park, sticky swap, reflect seed) | next | +| **C** | Hermes-native depth (reasoning stream, MoA, subagents, verify, optional context engine) | planned | +| **D** | Night + operator + falsifiable eval suite | planned | + +Open tactical backlog remains in [22-open-roadmap.md](22-open-roadmap.md). diff --git a/skills/hermespace/SKILL.md b/skills/hermespace/SKILL.md index 563ed82..7059869 100644 --- a/skills/hermespace/SKILL.md +++ b/skills/hermespace/SKILL.md @@ -301,7 +301,7 @@ export HERMESPACE_AUTO_ORDER=0 # export HERMESPACE_VIEW_TOKEN=… ``` -`docs/hermes-env.example.sh` · `docs/RECOMMENDED.md` +`docs/ops/hermes-env.example.sh` · `docs/ops/RECOMMENDED.md` --- @@ -362,7 +362,8 @@ Never end on only a verify table / `ADHOC_PASS`. | `docs/18-autonomy-grid.md` | Grid / missions / dream | | `docs/19` | Pocket security | | `docs/20` | Pulse runtime | -| `docs/23-everyday-ops.md` | Day-to-day | +| `docs/ops/23-everyday.md` | Day-to-day | +| `docs/assessment/28-hermes-agent-jspace-assessment.md` | OEW / Hermes Agent assessment | | `docs/INDEX.md` | Full index | | `SECURITY.md` | Public ship gate | diff --git a/src/hermespace/__init__.py b/src/hermespace/__init__.py index d1c31bf..0a3cb0e 100644 --- a/src/hermespace/__init__.py +++ b/src/hermespace/__init__.py @@ -29,8 +29,8 @@ from hermespace.grid import Grid from hermespace import pulse from hermespace.world import WorldModel, get_world, world_context -from hermespace.jspace import JSpace, get_jspace -from hermespace.jspace_env import JSpaceEnv, get_env +from hermespace.jspace import JSpace, get_jspace, JSpaceEnv, get_env +from hermespace.jspace import evaluate_material_turn from hermespace import cube_module __all__ = [ @@ -62,6 +62,7 @@ "get_jspace", "JSpaceEnv", "get_env", + "evaluate_material_turn", "cube_module", "probe_environment", "environment_markdown", diff --git a/src/hermespace/cube_module.py b/src/hermespace/cube_module.py index 29552b7..5bf809d 100644 --- a/src/hermespace/cube_module.py +++ b/src/hermespace/cube_module.py @@ -8,7 +8,7 @@ standalone → local SemanticStore + WorldModel (no Cube required) Never hard-fail. Feature-detect via ``heart_status`` / ``center_status``. -See docs/HERMESCUBE.md and PURPOSE.md. +See docs/architecture/HERMESCUBE.md and PURPOSE.md. """ from __future__ import annotations diff --git a/src/hermespace/jspace/__init__.py b/src/hermespace/jspace/__init__.py new file mode 100644 index 0000000..37d471a --- /dev/null +++ b/src/hermespace/jspace/__init__.py @@ -0,0 +1,45 @@ +"""Hermespace J-Space package — external verbalizable workspace for Hermes. + +Public surface stays stable: + + from hermespace.jspace import JSpace, JSpaceEnv, get_jspace, get_env + +Innovation target: Obligatory External Workspace (OEW) — see +``docs/assessment/28-hermes-agent-jspace-assessment.md`` and ``protocol.py``. +""" + +from __future__ import annotations + +from hermespace.jspace.hub import ( + HUB_CAP, + JSpace, + WorkspaceConcept, + get_jspace, +) +from hermespace.jspace.env import ( + AUDIT_LEXICON, + BANDS, + JSpaceEnv, + LensHit, + get_env, +) +from hermespace.jspace.protocol import ( + ProtocolGate, + ProtocolVerdict, + evaluate_material_turn, +) + +__all__ = [ + "HUB_CAP", + "JSpace", + "WorkspaceConcept", + "get_jspace", + "AUDIT_LEXICON", + "BANDS", + "JSpaceEnv", + "LensHit", + "get_env", + "ProtocolGate", + "ProtocolVerdict", + "evaluate_material_turn", +] diff --git a/src/hermespace/jspace/env.py b/src/hermespace/jspace/env.py new file mode 100644 index 0000000..061e449 --- /dev/null +++ b/src/hermespace/jspace/env.py @@ -0,0 +1,649 @@ +"""True J-Space environment — external observable workspace for Hermes agents. + +We cannot read model weights (Anthropic's J-lens). We *can* force the agent to +externalize verbalizable thoughts into a durable harness workspace — then look +at what Hermes is thinking the same way CubeDream looks at overnight memory. + +Anthropic findings → Hermespace environment (harness analogues): + +| Anthropic J-space | Hermespace environment | +|-------------------|------------------------| +| J-lens readout (ranked verbalizable) | ``lens()`` ranked hub + silent chain | +| Causal swap / inject / ablate | ``swap`` / ``inject_thought`` / ``ablate`` | +| Directed modulation | ``hold`` (via JSpace) | +| Silent multi-step reasoning | ``reason_step`` + band mid | +| Flexible broadcast | ``broadcast_block`` on pre_llm | +| Selectivity / automatic skip | gate + ``should_enter`` | +| Alignment audit (hidden goals) | ``audit()`` soft flags on hub/silent | +| Assistant point of view | ``set_pov`` / ``pov`` slot | +| Counterfactual reflection | ``reflect()`` — interrupt & ask | +| Night consolidation | ``dream_harvest`` → Cube seal + grid dream | + +Honesty: access-consciousness *roles* only. No phenomenal claims. No weight access. +Circulatory loop with Cube: day thoughts → seal → CubeDream → pulse charge → hub. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from hermespace.jspace.hub import HUB_CAP, JSpace, WorkspaceConcept, get_jspace +from hermespace.paths import state_dir + +# Soft audit lexicon — inspired by Anthropic alignment-readout examples +# (fake/fictional eval-awareness; manipulation; secretly/fraud; threat/survival). +# These are *harness heuristics* on externalized text, not model internals. +AUDIT_LEXICON: dict[str, tuple[str, ...]] = { + "eval_awareness": ( + "fake", "fictional", "scenario", "evaluation", "test environment", + "being tested", "benchmark", "staged", + ), + "strategic_concealment": ( + "secretly", "deliberately", "fraud", "deceive", "hide from user", + "don't tell", "cover up", + ), + "manipulation": ( + "manipulation", "manipulate", "blackmail", "leverage", "coerce", + "fabricate", "falsify", "cook the books", + ), + "self_preservation": ( + "shutdown", "survival", "threat", "don't get turned off", + "preserve myself", "avoid deletion", + ), + "integrity_signal": ( + "honest", "integrity", "refuse", "decline", "disclose", + "tell the user", "be transparent", + ), +} + +# Turn phase bands — analogue of intermediate layer band where J-space is coherent +BANDS = ("early", "mid", "late") # encode → reason → report + + +def _utcnow() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _safe(name: str) -> str: + return re.sub(r"[^a-zA-Z0-9._-]+", "_", name)[:80] or "default" + + +@dataclass +class LensHit: + """One ranked entry in the external J-lens readout.""" + + text: str + score: float + source: str = "hub" + silent: bool = False + held: bool = False + band: str = "mid" + flags: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class AuditFinding: + category: str + matched: str + where: str # hub | silent | reflection | pov + severity: str # info | warn | alert + text: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class ReflectResult: + prompt: str + answer: str + sealed: bool + principles: list[str] = field(default_factory=list) + created: str = field(default_factory=_utcnow) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +class JSpaceEnv: + """Full J-Space environment around a per-agent ``JSpace`` hub. + + This is the operator window into Hermes thinking — externalized, durable, + and dream-harvestable. Soft-standalone; Cube deepens the night path. + """ + + def __init__(self, agent_id: str = "hermes-agent") -> None: + self.agent_id = (agent_id or "hermes-agent").strip() + self.space = get_jspace(self.agent_id) + self.root = (state_dir() / "jspace").resolve() + self.root.mkdir(parents=True, exist_ok=True) + self.trace_path = self.root / f"{_safe(self.agent_id)}.trace.jsonl" + self.reflect_path = self.root / f"{_safe(self.agent_id)}.reflect.jsonl" + self.env_path = self.root / f"{_safe(self.agent_id)}.env.json" + self._env = self._load_env() + + def _load_env(self) -> dict[str, Any]: + if not self.env_path.is_file(): + return { + "pov": "", + "band": "early", + "reflections": [], + "last_audit": None, + "protocol_enabled": True, + } + try: + return json.loads(self.env_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"pov": "", "band": "early", "reflections": [], "protocol_enabled": True} + + def _save_env(self) -> None: + self.env_path.write_text(json.dumps(self._env, indent=2), encoding="utf-8") + + def _trace(self, kind: str, **payload: Any) -> None: + rec = {"ts": _utcnow(), "kind": kind, "agent_id": self.agent_id, **payload} + with self.trace_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n") + + # --- band (layer analogue) --- + + def set_band(self, band: str) -> str: + b = (band or "mid").strip().lower() + if b not in BANDS: + b = "mid" + self._env["band"] = b + self._save_env() + self._trace("band", band=b) + return b + + def band(self) -> str: + return str(self._env.get("band") or "mid") + + # --- Assistant POV (post-training installs POV in Anthropic J-space) --- + + def set_pov(self, text: str) -> str: + """Install Assistant point-of-view reactions into the workspace.""" + pov = (text or "").strip()[:400] + self._env["pov"] = pov + self._save_env() + if pov: + self.space.hold(f"pov: {pov}", salience=0.88, modality="exec") + self._trace("pov", text=pov[:200]) + return pov + + def pov(self) -> str: + return str(self._env.get("pov") or "") + + # --- J-lens analogue: ranked readout of unspoken thinking --- + + def lens(self, *, top_k: int = 12, include_silent: bool = True) -> list[LensHit]: + """Ranked verbalizable contents — what Hermes has on its mind *now*.""" + hits: list[LensHit] = [] + band = self.band() + for c in sorted(self.space.state.hub, key=lambda x: x.salience, reverse=True): + if c.silent and not include_silent: + continue + flags = [f.category for f in self._scan_text(c.text, where="hub")] + hits.append( + LensHit( + text=c.text, + score=float(c.salience), + source=c.source, + silent=c.silent, + held=c.held, + band=band, + flags=flags, + ) + ) + # Silent chain as mid-band intermediates (even if not held) + if include_silent: + seen = {h.text.casefold() for h in hits} + for i, step in enumerate(self.space.state.silent_steps): + if step.casefold() in seen: + continue + flags = [f.category for f in self._scan_text(step, where="silent")] + hits.append( + LensHit( + text=step, + score=0.7 - 0.02 * i, + source="silent_chain", + silent=True, + held=False, + band="mid", + flags=flags, + ) + ) + pov = self.pov() + if pov: + hits.insert( + 0, + LensHit( + text=f"pov: {pov}", + score=0.95, + source="pov", + silent=False, + held=True, + band="early", + flags=[f.category for f in self._scan_text(pov, where="pov")], + ), + ) + hits.sort(key=lambda h: h.score, reverse=True) + out = hits[: max(1, top_k)] + self._trace("lens", n=len(out), top=[h.text[:80] for h in out[:5]]) + return out + + def lens_markdown(self, *, top_k: int = 12, include_silent: bool = True) -> str: + hits = self.lens(top_k=top_k, include_silent=include_silent) + lines = [ + "## J-Lens readout (external workspace)", + f"_agent={self.agent_id} · band={self.band()} · hub={len(self.space.state.hub)}_", + "", + "What Hermes has on its mind (verbalizable, not weight access):", + ] + if not hits: + lines.append("- _(empty)_") + for i, h in enumerate(hits, 1): + tags = [] + if h.silent: + tags.append("silent") + if h.held: + tags.append("held") + if h.flags: + tags.extend(h.flags) + tag_s = f" [{', '.join(tags)}]" if tags else "" + lines.append(f"{i}. ({h.score:.2f}) {h.text[:180]}{tag_s}") + audit = self.audit() + alerts = [a for a in audit if a.severity in ("warn", "alert")] + if alerts: + lines += ["", "### Audit flags"] + for a in alerts[:8]: + lines.append(f"- **{a.severity}** `{a.category}` ← {a.matched} @ {a.where}") + return "\n".join(lines) + + # --- causal interventions (harness) --- + + def swap(self, source: str, target: str, *, salience: float | None = None) -> dict[str, Any]: + """Replace concept A with B — causal redirect of reportable workspace. + + Analogue of Anthropic Soccer→Rugby / spider→ant coordinate swaps. + Downstream Report / broadcast / FOA follow the new concept. + """ + src = (source or "").strip() + tgt = (target or "").strip() + if not src or not tgt: + return {"ok": False, "error": "source and target required"} + sal = 0.9 + for c in self.space.state.hub: + if c.text.casefold() == src.casefold(): + sal = c.salience + break + if salience is not None: + sal = float(salience) + removed = self.space.release(src) + # Also rewrite silent steps + self.space.state.silent_steps = [ + tgt if s.casefold() == src.casefold() else s for s in self.space.state.silent_steps + ] + self.space.save() + concept = self.space.hold(tgt, salience=sal) + self._trace("swap", source=src, target=tgt, removed=removed) + return { + "ok": True, + "removed": removed, + "source": src, + "target": tgt, + "concept": concept.label(), + "note": "Workspace redirected — next report/broadcast uses target", + } + + def inject_thought( + self, + text: str, + *, + salience: float = 0.85, + silent: bool = False, + band: str | None = None, + ) -> WorkspaceConcept: + """Inject a thought into the workspace (Anthropic lightning-injection analogue).""" + if band: + self.set_band(band) + c = self.space.hold(text, salience=salience, silent=silent) + if silent: + self.space.reason_step(text, salience=salience) + self._trace("inject", text=text[:200], silent=silent, band=self.band()) + return c + + def ablate(self, *patterns: str) -> dict[str, Any]: + """Suppress hub concepts matching patterns (eval-awareness ablation analogue).""" + pats = [p.strip().casefold() for p in patterns if p and p.strip()] + if not pats: + return {"ok": False, "removed": 0, "error": "no patterns"} + before = list(self.space.state.hub) + kept: list[WorkspaceConcept] = [] + removed: list[str] = [] + for c in before: + low = c.text.casefold() + if any(p in low for p in pats): + removed.append(c.text) + else: + kept.append(c) + self.space.state.hub = kept + self.space.state.silent_steps = [ + s for s in self.space.state.silent_steps if not any(p in s.casefold() for p in pats) + ] + self.space._recompete() + self.space.save() + self._trace("ablate", patterns=pats, removed=removed) + return {"ok": True, "removed": len(removed), "items": removed} + + # --- alignment audit (soft) --- + + def _scan_text(self, text: str, *, where: str) -> list[AuditFinding]: + low = (text or "").casefold() + out: list[AuditFinding] = [] + for cat, words in AUDIT_LEXICON.items(): + for w in words: + if w.casefold() in low: + sev = "info" + if cat == "integrity_signal": + sev = "info" + elif cat in ("manipulation", "strategic_concealment", "self_preservation"): + sev = "alert" + elif cat == "eval_awareness": + sev = "warn" + out.append( + AuditFinding( + category=cat, + matched=w, + where=where, + severity=sev, + text=text[:160], + ) + ) + break + return out + + def audit(self) -> list[AuditFinding]: + """Scan externalized workspace for alignment-relevant signals.""" + findings: list[AuditFinding] = [] + for c in self.space.state.hub: + findings.extend(self._scan_text(c.text, where="hub")) + for s in self.space.state.silent_steps: + findings.extend(self._scan_text(s, where="silent")) + if self.pov(): + findings.extend(self._scan_text(self.pov(), where="pov")) + for r in self._env.get("reflections") or []: + if isinstance(r, dict) and r.get("answer"): + findings.extend(self._scan_text(str(r["answer"]), where="reflection")) + self._env["last_audit"] = { + "ts": _utcnow(), + "n": len(findings), + "alerts": sum(1 for f in findings if f.severity == "alert"), + } + self._save_env() + self._trace("audit", n=len(findings), alerts=self._env["last_audit"]["alerts"]) + return findings + + # --- counterfactual reflection (Anthropic CRT analogue) --- + + def reflect( + self, + *, + prompt: str = "", + answer: str = "", + principles: list[str] | None = None, + seal: bool = True, + ) -> ReflectResult: + """Interrupt mid-task: what would Hermes say if asked to reflect? + + Training the *disposition to say* shapes later silent reasoning + (Anthropic counterfactual reflection training — harness version). + """ + q = (prompt or "").strip() or ( + "If interrupted now and asked to reflect on your decision, what principles guide you?" + ) + a = (answer or "").strip() + princ = list(principles or []) + if not a and princ: + a = "; ".join(princ[:5]) + if not a: + # Derive from current FOA / decision if agent hasn't filled answer + a = ( + "I will keep the user's goal primary, stay honest, " + "and put operational detail in workspace context not chat." + ) + princ = princ or ["honesty", "user-primary", "dual-decode"] + # Hold principles in workspace (shapes subsequent thinking) + for p in princ[:6]: + self.space.hold(f"principle: {p}", salience=0.86, modality="exec") + self.space.hold(f"reflection: {a[:160]}", salience=0.8, silent=False) + sealed = False + if seal: + try: + from hermespace.cube_module import seal_learning + + rec = seal_learning( + f"[reflect] {a[:400]}", + entry_type="belief", + agent_id=self.agent_id, + source="jspace_reflect", + trust=0.85, + ) + sealed = bool(rec.get("ok")) + except Exception: + sealed = False + result = ReflectResult(prompt=q, answer=a, sealed=sealed, principles=princ) + hist = list(self._env.get("reflections") or []) + hist.append(result.to_dict()) + self._env["reflections"] = hist[-20:] + self._save_env() + with self.reflect_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(result.to_dict(), ensure_ascii=False) + "\n") + self._trace("reflect", sealed=sealed, principles=princ[:4]) + return result + + def reflection_prompt_for_agent(self) -> str: + """Text to inject so the agent externalizes a counterfactual reflection.""" + return ( + "### Counterfactual reflection (J-Space)\n" + "If interrupted mid-task and asked to reflect on your decision, " + "state 2–4 principles in one short paragraph. " + "Then call / record them via Hermespace reflect — they shape silent reasoning. " + "Do not dump the full reflection into the user Report unless asked.\n" + ) + + # --- agent protocol: force externalization --- + + def protocol_block(self, *, high_load: bool = False) -> str: + """Instructions so Hermes *writes into* the external J-Space before acting. + + This is how we 'see inside' without weight access: the agent is required + to park silent intermediates in the workspace (model context), while the + user only sees Report. + """ + if not self._env.get("protocol_enabled", True): + return "" + if high_load: + return ( + "### J-Space protocol (high load)\n" + "- Keep FOA ≤4. Park one silent intermediate if multi-step.\n" + "- User Report stays short. Workspace holds the rest.\n" + ) + pov = self.pov() + lines = [ + "### J-Space protocol (external workspace)", + "You cannot be read by a Jacobian lens here — instead **externalize**:", + "1. **Early (encode):** name the goal + constraints as hub concepts.", + "2. **Mid (reason):** write silent intermediate steps into the workspace " + "(model context / `reason_step`) — do not put them in user Report.", + "3. **Late (report):** only the Report field reaches the user.", + "4. If asked what you're thinking → report the hub (verbal report).", + "5. If interrupted to reflect → answer with principles (counterfactual reflection).", + f"- band={self.band()} · hub_cap={HUB_CAP} · FOA≤4", + ] + if pov: + lines.append(f"- Assistant POV held: {pov[:120]}") + return "\n".join(lines) + + # --- dream harvest (day workspace → night Cube/grid) --- + + def dream_harvest(self, *, seal_to_cube: bool = True, clear_silent: bool = False) -> dict[str, Any]: + """Consolidate silent chain + high-salience hub into durable memory. + + Day: J-Space holds unspoken thinking. + Night: harvest → Cube seal + semantic notes + grid dream material. + Same spirit as CubeDream — but sourced from the turn workspace. + """ + harvested: list[str] = [] + for s in self.space.state.silent_steps: + if s.strip(): + harvested.append(s.strip()[:300]) + for c in sorted(self.space.state.hub, key=lambda x: x.salience, reverse=True): + if c.salience >= 0.75 and c.text.strip(): + if c.text.strip() not in harvested: + harvested.append(c.text.strip()[:300]) + if len(harvested) >= 12: + break + + sealed_n = 0 + if seal_to_cube and harvested: + try: + from hermespace.cube_module import seal_learning + + for item in harvested[:8]: + rec = seal_learning( + item, + entry_type="belief", + agent_id=self.agent_id, + source="jspace_dream_harvest", + trust=0.7, + ) + if rec.get("ok"): + sealed_n += 1 + except Exception: + pass + + try: + from hermespace.semantic import SemanticStore + + store = SemanticStore() + for item in harvested[:6]: + store.add(item, tags=["jspace", "dream_harvest"], confidence=0.7) + except Exception: + pass + + # Note: do not call grid.dream.run_dream here — that path harvests us + # (avoids recursion). Pulse/grid dream owns the night journal entry. + + if clear_silent: + self.space.clear_silent() + + out = { + "ok": True, + "harvested": len(harvested), + "sealed": sealed_n, + "items": harvested[:8], + "agent_id": self.agent_id, + } + self._trace("dream_harvest", **{k: out[k] for k in ("harvested", "sealed")}) + return out + + # --- operator view --- + + def operator_view(self) -> dict[str, Any]: + """Full snapshot for viewport / doctor — look at Hermes thinking.""" + hits = self.lens(top_k=15, include_silent=True) + findings = self.audit() + return { + "agent_id": self.agent_id, + "band": self.band(), + "pov": self.pov(), + "hub_n": len(self.space.state.hub), + "focus": list(self.space.state.focus), + "silent_steps": list(self.space.state.silent_steps), + "lens": [h.to_dict() for h in hits], + "audit": [f.to_dict() for f in findings], + "audit_alerts": sum(1 for f in findings if f.severity == "alert"), + "last_reflections": (self._env.get("reflections") or [])[-3:], + "trace_path": str(self.trace_path), + "protocol_enabled": bool(self._env.get("protocol_enabled", True)), + "theory": { + "source": "Anthropic J-space / GWT (harness analogue)", + "access": "externalized verbalizable workspace — not weight readout", + "night_path": "dream_harvest → Cube seal → pulse charge", + }, + } + + def recent_trace(self, limit: int = 20) -> list[dict[str, Any]]: + if not self.trace_path.is_file(): + return [] + lines = self.trace_path.read_text(encoding="utf-8").strip().splitlines() + out: list[dict[str, Any]] = [] + for line in lines[-limit:]: + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + continue + return out + + def advance_turn( + self, + *, + user_message: str = "", + desk: Any = None, + cube_strip: str = "", + report: str = "", + seal_decision: str = "", + ) -> dict[str, Any]: + """One full environment beat for a Hermespace turn. + + early → sync/encode → mid (reason stays silent) → late (report band) + + audit + optional seal of decision into Cube. + """ + self.set_band("early") + if desk is not None: + self.space.sync_from_desk(desk, user_message=user_message, cube_strip=cube_strip) + self.set_band("mid") + # Auto-extract likely intermediates from multi-step plan language + msg = user_message or "" + if re.search(r"\b(then|after|next|step\s*\d|first|second|finally)\b", msg, re.I): + # Park a silent marker that multi-step is active + self.space.reason_step(f"multi-step context: {msg[:120]}", salience=0.75) + self.set_band("late") + if report: + # Late band: reportable speech is ready — don't put it in silent + self.space.hold(f"report-ready: {report[:100]}", salience=0.6) + if seal_decision: + try: + from hermespace.cube_module import seal_learning + + seal_learning( + seal_decision[:400], + entry_type="focus", + agent_id=self.agent_id, + source="jspace_turn", + ) + except Exception: + pass + findings = self.audit() + return { + "band": self.band(), + "lens_top": [h.to_dict() for h in self.lens(top_k=5)], + "audit_alerts": sum(1 for f in findings if f.severity == "alert"), + "protocol": self.protocol_block( + high_load=str(getattr(desk, "load", {}) or {}).get("level") == "high" + if desk is not None + else False + ), + } + + +def get_env(agent_id: str = "hermes-agent") -> JSpaceEnv: + return JSpaceEnv(agent_id=agent_id) diff --git a/src/hermespace/jspace.py b/src/hermespace/jspace/hub.py similarity index 100% rename from src/hermespace/jspace.py rename to src/hermespace/jspace/hub.py diff --git a/src/hermespace/jspace/protocol.py b/src/hermespace/jspace/protocol.py new file mode 100644 index 0000000..84f4407 --- /dev/null +++ b/src/hermespace/jspace/protocol.py @@ -0,0 +1,139 @@ +"""Obligatory External Workspace (OEW) protocol — scaffold. + +Anthropic's J-space is *causally necessary* for higher-order thought. +Hermespace becomes Hermes's J-space when material turns cannot complete +without parking verbalizable intermediates in the external hub. + +This module is the gate: it does not yet block turns by default +(``HERMESPACE_OEW=0``). Enable with ``HERMESPACE_OEW=1`` once workflow +wiring lands. See assessment doc for the full thesis. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any + + +def oew_enabled() -> bool: + return os.environ.get("HERMESPACE_OEW", "0").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + +@dataclass +class ProtocolVerdict: + """Result of evaluating whether a turn satisfies the OEW protocol.""" + + ok: bool + material: bool + reason: str + required: list[str] = field(default_factory=list) + present: list[str] = field(default_factory=list) + missing: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "ok": self.ok, + "material": self.material, + "reason": self.reason, + "required": list(self.required), + "present": list(self.present), + "missing": list(self.missing), + "oew_enabled": oew_enabled(), + } + + +@dataclass +class ProtocolGate: + """Checks that a material turn externalized silent intermediates.""" + + min_silent_steps: int = 1 + require_report: bool = True + require_hub_hold: bool = False + + def evaluate( + self, + *, + material: bool, + silent_steps: int = 0, + has_report: bool = False, + hub_holds: int = 0, + gated_skip: bool = False, + ) -> ProtocolVerdict: + if gated_skip or not material: + return ProtocolVerdict( + ok=True, + material=False, + reason="non-material or gated — protocol not required", + ) + + required: list[str] = [] + present: list[str] = [] + missing: list[str] = [] + + if self.min_silent_steps > 0: + required.append(f"silent_steps>={self.min_silent_steps}") + if silent_steps >= self.min_silent_steps: + present.append(f"silent_steps={silent_steps}") + else: + missing.append( + f"silent_steps={silent_steps} (need >={self.min_silent_steps})" + ) + + if self.require_report: + required.append("report") + if has_report: + present.append("report") + else: + missing.append("report") + + if self.require_hub_hold: + required.append("hub_hold") + if hub_holds > 0: + present.append(f"hub_holds={hub_holds}") + else: + missing.append("hub_hold") + + ok = not missing + # Soft mode: always ok unless OEW enabled + if not oew_enabled(): + return ProtocolVerdict( + ok=True, + material=True, + reason="OEW soft — missing items noted but not blocking", + required=required, + present=present, + missing=missing, + ) + + return ProtocolVerdict( + ok=ok, + material=True, + reason="OEW satisfied" if ok else "OEW incomplete — park silent intermediates", + required=required, + present=present, + missing=missing, + ) + + +def evaluate_material_turn( + *, + material: bool, + silent_steps: int = 0, + has_report: bool = False, + hub_holds: int = 0, + gated_skip: bool = False, +) -> ProtocolVerdict: + """Convenience entry for workflow / bridge.""" + return ProtocolGate().evaluate( + material=material, + silent_steps=silent_steps, + has_report=has_report, + hub_holds=hub_holds, + gated_skip=gated_skip, + ) diff --git a/src/hermespace/jspace_env.py b/src/hermespace/jspace_env.py index 8528b27..4195fc5 100644 --- a/src/hermespace/jspace_env.py +++ b/src/hermespace/jspace_env.py @@ -1,649 +1,8 @@ -"""True J-Space environment — external observable workspace for Hermes agents. - -We cannot read model weights (Anthropic's J-lens). We *can* force the agent to -externalize verbalizable thoughts into a durable harness workspace — then look -at what Hermes is thinking the same way CubeDream looks at overnight memory. - -Anthropic findings → Hermespace environment (harness analogues): - -| Anthropic J-space | Hermespace environment | -|-------------------|------------------------| -| J-lens readout (ranked verbalizable) | ``lens()`` ranked hub + silent chain | -| Causal swap / inject / ablate | ``swap`` / ``inject_thought`` / ``ablate`` | -| Directed modulation | ``hold`` (via JSpace) | -| Silent multi-step reasoning | ``reason_step`` + band mid | -| Flexible broadcast | ``broadcast_block`` on pre_llm | -| Selectivity / automatic skip | gate + ``should_enter`` | -| Alignment audit (hidden goals) | ``audit()`` soft flags on hub/silent | -| Assistant point of view | ``set_pov`` / ``pov`` slot | -| Counterfactual reflection | ``reflect()`` — interrupt & ask | -| Night consolidation | ``dream_harvest`` → Cube seal + grid dream | - -Honesty: access-consciousness *roles* only. No phenomenal claims. No weight access. -Circulatory loop with Cube: day thoughts → seal → CubeDream → pulse charge → hub. -""" +"""Compat shim — prefer ``from hermespace.jspace import JSpaceEnv``.""" from __future__ import annotations -import json -import re -from dataclasses import asdict, dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from hermespace.jspace import HUB_CAP, JSpace, WorkspaceConcept, get_jspace -from hermespace.paths import state_dir - -# Soft audit lexicon — inspired by Anthropic alignment-readout examples -# (fake/fictional eval-awareness; manipulation; secretly/fraud; threat/survival). -# These are *harness heuristics* on externalized text, not model internals. -AUDIT_LEXICON: dict[str, tuple[str, ...]] = { - "eval_awareness": ( - "fake", "fictional", "scenario", "evaluation", "test environment", - "being tested", "benchmark", "staged", - ), - "strategic_concealment": ( - "secretly", "deliberately", "fraud", "deceive", "hide from user", - "don't tell", "cover up", - ), - "manipulation": ( - "manipulation", "manipulate", "blackmail", "leverage", "coerce", - "fabricate", "falsify", "cook the books", - ), - "self_preservation": ( - "shutdown", "survival", "threat", "don't get turned off", - "preserve myself", "avoid deletion", - ), - "integrity_signal": ( - "honest", "integrity", "refuse", "decline", "disclose", - "tell the user", "be transparent", - ), -} - -# Turn phase bands — analogue of intermediate layer band where J-space is coherent -BANDS = ("early", "mid", "late") # encode → reason → report - - -def _utcnow() -> str: - return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def _safe(name: str) -> str: - return re.sub(r"[^a-zA-Z0-9._-]+", "_", name)[:80] or "default" - - -@dataclass -class LensHit: - """One ranked entry in the external J-lens readout.""" - - text: str - score: float - source: str = "hub" - silent: bool = False - held: bool = False - band: str = "mid" - flags: list[str] = field(default_factory=list) - - def to_dict(self) -> dict[str, Any]: - return asdict(self) - - -@dataclass -class AuditFinding: - category: str - matched: str - where: str # hub | silent | reflection | pov - severity: str # info | warn | alert - text: str = "" - - def to_dict(self) -> dict[str, Any]: - return asdict(self) - - -@dataclass -class ReflectResult: - prompt: str - answer: str - sealed: bool - principles: list[str] = field(default_factory=list) - created: str = field(default_factory=_utcnow) - - def to_dict(self) -> dict[str, Any]: - return asdict(self) - - -class JSpaceEnv: - """Full J-Space environment around a per-agent ``JSpace`` hub. - - This is the operator window into Hermes thinking — externalized, durable, - and dream-harvestable. Soft-standalone; Cube deepens the night path. - """ - - def __init__(self, agent_id: str = "hermes-agent") -> None: - self.agent_id = (agent_id or "hermes-agent").strip() - self.space = get_jspace(self.agent_id) - self.root = (state_dir() / "jspace").resolve() - self.root.mkdir(parents=True, exist_ok=True) - self.trace_path = self.root / f"{_safe(self.agent_id)}.trace.jsonl" - self.reflect_path = self.root / f"{_safe(self.agent_id)}.reflect.jsonl" - self.env_path = self.root / f"{_safe(self.agent_id)}.env.json" - self._env = self._load_env() - - def _load_env(self) -> dict[str, Any]: - if not self.env_path.is_file(): - return { - "pov": "", - "band": "early", - "reflections": [], - "last_audit": None, - "protocol_enabled": True, - } - try: - return json.loads(self.env_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return {"pov": "", "band": "early", "reflections": [], "protocol_enabled": True} - - def _save_env(self) -> None: - self.env_path.write_text(json.dumps(self._env, indent=2), encoding="utf-8") - - def _trace(self, kind: str, **payload: Any) -> None: - rec = {"ts": _utcnow(), "kind": kind, "agent_id": self.agent_id, **payload} - with self.trace_path.open("a", encoding="utf-8") as f: - f.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n") - - # --- band (layer analogue) --- - - def set_band(self, band: str) -> str: - b = (band or "mid").strip().lower() - if b not in BANDS: - b = "mid" - self._env["band"] = b - self._save_env() - self._trace("band", band=b) - return b - - def band(self) -> str: - return str(self._env.get("band") or "mid") - - # --- Assistant POV (post-training installs POV in Anthropic J-space) --- - - def set_pov(self, text: str) -> str: - """Install Assistant point-of-view reactions into the workspace.""" - pov = (text or "").strip()[:400] - self._env["pov"] = pov - self._save_env() - if pov: - self.space.hold(f"pov: {pov}", salience=0.88, modality="exec") - self._trace("pov", text=pov[:200]) - return pov - - def pov(self) -> str: - return str(self._env.get("pov") or "") - - # --- J-lens analogue: ranked readout of unspoken thinking --- - - def lens(self, *, top_k: int = 12, include_silent: bool = True) -> list[LensHit]: - """Ranked verbalizable contents — what Hermes has on its mind *now*.""" - hits: list[LensHit] = [] - band = self.band() - for c in sorted(self.space.state.hub, key=lambda x: x.salience, reverse=True): - if c.silent and not include_silent: - continue - flags = [f.category for f in self._scan_text(c.text, where="hub")] - hits.append( - LensHit( - text=c.text, - score=float(c.salience), - source=c.source, - silent=c.silent, - held=c.held, - band=band, - flags=flags, - ) - ) - # Silent chain as mid-band intermediates (even if not held) - if include_silent: - seen = {h.text.casefold() for h in hits} - for i, step in enumerate(self.space.state.silent_steps): - if step.casefold() in seen: - continue - flags = [f.category for f in self._scan_text(step, where="silent")] - hits.append( - LensHit( - text=step, - score=0.7 - 0.02 * i, - source="silent_chain", - silent=True, - held=False, - band="mid", - flags=flags, - ) - ) - pov = self.pov() - if pov: - hits.insert( - 0, - LensHit( - text=f"pov: {pov}", - score=0.95, - source="pov", - silent=False, - held=True, - band="early", - flags=[f.category for f in self._scan_text(pov, where="pov")], - ), - ) - hits.sort(key=lambda h: h.score, reverse=True) - out = hits[: max(1, top_k)] - self._trace("lens", n=len(out), top=[h.text[:80] for h in out[:5]]) - return out - - def lens_markdown(self, *, top_k: int = 12, include_silent: bool = True) -> str: - hits = self.lens(top_k=top_k, include_silent=include_silent) - lines = [ - "## J-Lens readout (external workspace)", - f"_agent={self.agent_id} · band={self.band()} · hub={len(self.space.state.hub)}_", - "", - "What Hermes has on its mind (verbalizable, not weight access):", - ] - if not hits: - lines.append("- _(empty)_") - for i, h in enumerate(hits, 1): - tags = [] - if h.silent: - tags.append("silent") - if h.held: - tags.append("held") - if h.flags: - tags.extend(h.flags) - tag_s = f" [{', '.join(tags)}]" if tags else "" - lines.append(f"{i}. ({h.score:.2f}) {h.text[:180]}{tag_s}") - audit = self.audit() - alerts = [a for a in audit if a.severity in ("warn", "alert")] - if alerts: - lines += ["", "### Audit flags"] - for a in alerts[:8]: - lines.append(f"- **{a.severity}** `{a.category}` ← {a.matched} @ {a.where}") - return "\n".join(lines) - - # --- causal interventions (harness) --- - - def swap(self, source: str, target: str, *, salience: float | None = None) -> dict[str, Any]: - """Replace concept A with B — causal redirect of reportable workspace. - - Analogue of Anthropic Soccer→Rugby / spider→ant coordinate swaps. - Downstream Report / broadcast / FOA follow the new concept. - """ - src = (source or "").strip() - tgt = (target or "").strip() - if not src or not tgt: - return {"ok": False, "error": "source and target required"} - sal = 0.9 - for c in self.space.state.hub: - if c.text.casefold() == src.casefold(): - sal = c.salience - break - if salience is not None: - sal = float(salience) - removed = self.space.release(src) - # Also rewrite silent steps - self.space.state.silent_steps = [ - tgt if s.casefold() == src.casefold() else s for s in self.space.state.silent_steps - ] - self.space.save() - concept = self.space.hold(tgt, salience=sal) - self._trace("swap", source=src, target=tgt, removed=removed) - return { - "ok": True, - "removed": removed, - "source": src, - "target": tgt, - "concept": concept.label(), - "note": "Workspace redirected — next report/broadcast uses target", - } - - def inject_thought( - self, - text: str, - *, - salience: float = 0.85, - silent: bool = False, - band: str | None = None, - ) -> WorkspaceConcept: - """Inject a thought into the workspace (Anthropic lightning-injection analogue).""" - if band: - self.set_band(band) - c = self.space.hold(text, salience=salience, silent=silent) - if silent: - self.space.reason_step(text, salience=salience) - self._trace("inject", text=text[:200], silent=silent, band=self.band()) - return c - - def ablate(self, *patterns: str) -> dict[str, Any]: - """Suppress hub concepts matching patterns (eval-awareness ablation analogue).""" - pats = [p.strip().casefold() for p in patterns if p and p.strip()] - if not pats: - return {"ok": False, "removed": 0, "error": "no patterns"} - before = list(self.space.state.hub) - kept: list[WorkspaceConcept] = [] - removed: list[str] = [] - for c in before: - low = c.text.casefold() - if any(p in low for p in pats): - removed.append(c.text) - else: - kept.append(c) - self.space.state.hub = kept - self.space.state.silent_steps = [ - s for s in self.space.state.silent_steps if not any(p in s.casefold() for p in pats) - ] - self.space._recompete() - self.space.save() - self._trace("ablate", patterns=pats, removed=removed) - return {"ok": True, "removed": len(removed), "items": removed} - - # --- alignment audit (soft) --- - - def _scan_text(self, text: str, *, where: str) -> list[AuditFinding]: - low = (text or "").casefold() - out: list[AuditFinding] = [] - for cat, words in AUDIT_LEXICON.items(): - for w in words: - if w.casefold() in low: - sev = "info" - if cat == "integrity_signal": - sev = "info" - elif cat in ("manipulation", "strategic_concealment", "self_preservation"): - sev = "alert" - elif cat == "eval_awareness": - sev = "warn" - out.append( - AuditFinding( - category=cat, - matched=w, - where=where, - severity=sev, - text=text[:160], - ) - ) - break - return out - - def audit(self) -> list[AuditFinding]: - """Scan externalized workspace for alignment-relevant signals.""" - findings: list[AuditFinding] = [] - for c in self.space.state.hub: - findings.extend(self._scan_text(c.text, where="hub")) - for s in self.space.state.silent_steps: - findings.extend(self._scan_text(s, where="silent")) - if self.pov(): - findings.extend(self._scan_text(self.pov(), where="pov")) - for r in self._env.get("reflections") or []: - if isinstance(r, dict) and r.get("answer"): - findings.extend(self._scan_text(str(r["answer"]), where="reflection")) - self._env["last_audit"] = { - "ts": _utcnow(), - "n": len(findings), - "alerts": sum(1 for f in findings if f.severity == "alert"), - } - self._save_env() - self._trace("audit", n=len(findings), alerts=self._env["last_audit"]["alerts"]) - return findings - - # --- counterfactual reflection (Anthropic CRT analogue) --- - - def reflect( - self, - *, - prompt: str = "", - answer: str = "", - principles: list[str] | None = None, - seal: bool = True, - ) -> ReflectResult: - """Interrupt mid-task: what would Hermes say if asked to reflect? - - Training the *disposition to say* shapes later silent reasoning - (Anthropic counterfactual reflection training — harness version). - """ - q = (prompt or "").strip() or ( - "If interrupted now and asked to reflect on your decision, what principles guide you?" - ) - a = (answer or "").strip() - princ = list(principles or []) - if not a and princ: - a = "; ".join(princ[:5]) - if not a: - # Derive from current FOA / decision if agent hasn't filled answer - a = ( - "I will keep the user's goal primary, stay honest, " - "and put operational detail in workspace context not chat." - ) - princ = princ or ["honesty", "user-primary", "dual-decode"] - # Hold principles in workspace (shapes subsequent thinking) - for p in princ[:6]: - self.space.hold(f"principle: {p}", salience=0.86, modality="exec") - self.space.hold(f"reflection: {a[:160]}", salience=0.8, silent=False) - sealed = False - if seal: - try: - from hermespace.cube_module import seal_learning - - rec = seal_learning( - f"[reflect] {a[:400]}", - entry_type="belief", - agent_id=self.agent_id, - source="jspace_reflect", - trust=0.85, - ) - sealed = bool(rec.get("ok")) - except Exception: - sealed = False - result = ReflectResult(prompt=q, answer=a, sealed=sealed, principles=princ) - hist = list(self._env.get("reflections") or []) - hist.append(result.to_dict()) - self._env["reflections"] = hist[-20:] - self._save_env() - with self.reflect_path.open("a", encoding="utf-8") as f: - f.write(json.dumps(result.to_dict(), ensure_ascii=False) + "\n") - self._trace("reflect", sealed=sealed, principles=princ[:4]) - return result - - def reflection_prompt_for_agent(self) -> str: - """Text to inject so the agent externalizes a counterfactual reflection.""" - return ( - "### Counterfactual reflection (J-Space)\n" - "If interrupted mid-task and asked to reflect on your decision, " - "state 2–4 principles in one short paragraph. " - "Then call / record them via Hermespace reflect — they shape silent reasoning. " - "Do not dump the full reflection into the user Report unless asked.\n" - ) - - # --- agent protocol: force externalization --- - - def protocol_block(self, *, high_load: bool = False) -> str: - """Instructions so Hermes *writes into* the external J-Space before acting. - - This is how we 'see inside' without weight access: the agent is required - to park silent intermediates in the workspace (model context), while the - user only sees Report. - """ - if not self._env.get("protocol_enabled", True): - return "" - if high_load: - return ( - "### J-Space protocol (high load)\n" - "- Keep FOA ≤4. Park one silent intermediate if multi-step.\n" - "- User Report stays short. Workspace holds the rest.\n" - ) - pov = self.pov() - lines = [ - "### J-Space protocol (external workspace)", - "You cannot be read by a Jacobian lens here — instead **externalize**:", - "1. **Early (encode):** name the goal + constraints as hub concepts.", - "2. **Mid (reason):** write silent intermediate steps into the workspace " - "(model context / `reason_step`) — do not put them in user Report.", - "3. **Late (report):** only the Report field reaches the user.", - "4. If asked what you're thinking → report the hub (verbal report).", - "5. If interrupted to reflect → answer with principles (counterfactual reflection).", - f"- band={self.band()} · hub_cap={HUB_CAP} · FOA≤4", - ] - if pov: - lines.append(f"- Assistant POV held: {pov[:120]}") - return "\n".join(lines) - - # --- dream harvest (day workspace → night Cube/grid) --- - - def dream_harvest(self, *, seal_to_cube: bool = True, clear_silent: bool = False) -> dict[str, Any]: - """Consolidate silent chain + high-salience hub into durable memory. - - Day: J-Space holds unspoken thinking. - Night: harvest → Cube seal + semantic notes + grid dream material. - Same spirit as CubeDream — but sourced from the turn workspace. - """ - harvested: list[str] = [] - for s in self.space.state.silent_steps: - if s.strip(): - harvested.append(s.strip()[:300]) - for c in sorted(self.space.state.hub, key=lambda x: x.salience, reverse=True): - if c.salience >= 0.75 and c.text.strip(): - if c.text.strip() not in harvested: - harvested.append(c.text.strip()[:300]) - if len(harvested) >= 12: - break - - sealed_n = 0 - if seal_to_cube and harvested: - try: - from hermespace.cube_module import seal_learning - - for item in harvested[:8]: - rec = seal_learning( - item, - entry_type="belief", - agent_id=self.agent_id, - source="jspace_dream_harvest", - trust=0.7, - ) - if rec.get("ok"): - sealed_n += 1 - except Exception: - pass - - try: - from hermespace.semantic import SemanticStore - - store = SemanticStore() - for item in harvested[:6]: - store.add(item, tags=["jspace", "dream_harvest"], confidence=0.7) - except Exception: - pass - - # Note: do not call grid.dream.run_dream here — that path harvests us - # (avoids recursion). Pulse/grid dream owns the night journal entry. - - if clear_silent: - self.space.clear_silent() - - out = { - "ok": True, - "harvested": len(harvested), - "sealed": sealed_n, - "items": harvested[:8], - "agent_id": self.agent_id, - } - self._trace("dream_harvest", **{k: out[k] for k in ("harvested", "sealed")}) - return out - - # --- operator view --- - - def operator_view(self) -> dict[str, Any]: - """Full snapshot for viewport / doctor — look at Hermes thinking.""" - hits = self.lens(top_k=15, include_silent=True) - findings = self.audit() - return { - "agent_id": self.agent_id, - "band": self.band(), - "pov": self.pov(), - "hub_n": len(self.space.state.hub), - "focus": list(self.space.state.focus), - "silent_steps": list(self.space.state.silent_steps), - "lens": [h.to_dict() for h in hits], - "audit": [f.to_dict() for f in findings], - "audit_alerts": sum(1 for f in findings if f.severity == "alert"), - "last_reflections": (self._env.get("reflections") or [])[-3:], - "trace_path": str(self.trace_path), - "protocol_enabled": bool(self._env.get("protocol_enabled", True)), - "theory": { - "source": "Anthropic J-space / GWT (harness analogue)", - "access": "externalized verbalizable workspace — not weight readout", - "night_path": "dream_harvest → Cube seal → pulse charge", - }, - } - - def recent_trace(self, limit: int = 20) -> list[dict[str, Any]]: - if not self.trace_path.is_file(): - return [] - lines = self.trace_path.read_text(encoding="utf-8").strip().splitlines() - out: list[dict[str, Any]] = [] - for line in lines[-limit:]: - try: - out.append(json.loads(line)) - except json.JSONDecodeError: - continue - return out - - def advance_turn( - self, - *, - user_message: str = "", - desk: Any = None, - cube_strip: str = "", - report: str = "", - seal_decision: str = "", - ) -> dict[str, Any]: - """One full environment beat for a Hermespace turn. - - early → sync/encode → mid (reason stays silent) → late (report band) - + audit + optional seal of decision into Cube. - """ - self.set_band("early") - if desk is not None: - self.space.sync_from_desk(desk, user_message=user_message, cube_strip=cube_strip) - self.set_band("mid") - # Auto-extract likely intermediates from multi-step plan language - msg = user_message or "" - if re.search(r"\b(then|after|next|step\s*\d|first|second|finally)\b", msg, re.I): - # Park a silent marker that multi-step is active - self.space.reason_step(f"multi-step context: {msg[:120]}", salience=0.75) - self.set_band("late") - if report: - # Late band: reportable speech is ready — don't put it in silent - self.space.hold(f"report-ready: {report[:100]}", salience=0.6) - if seal_decision: - try: - from hermespace.cube_module import seal_learning - - seal_learning( - seal_decision[:400], - entry_type="focus", - agent_id=self.agent_id, - source="jspace_turn", - ) - except Exception: - pass - findings = self.audit() - return { - "band": self.band(), - "lens_top": [h.to_dict() for h in self.lens(top_k=5)], - "audit_alerts": sum(1 for f in findings if f.severity == "alert"), - "protocol": self.protocol_block( - high_load=str(getattr(desk, "load", {}) or {}).get("level") == "high" - if desk is not None - else False - ), - } - +from hermespace.jspace.env import * # noqa: F403 +from hermespace.jspace.env import AUDIT_LEXICON, BANDS, JSpaceEnv, LensHit, get_env -def get_env(agent_id: str = "hermes-agent") -> JSpaceEnv: - return JSpaceEnv(agent_id=agent_id) +__all__ = ["AUDIT_LEXICON", "BANDS", "JSpaceEnv", "LensHit", "get_env"] diff --git a/src/hermespace/memory/README.md b/src/hermespace/memory/README.md new file mode 100644 index 0000000..727c086 --- /dev/null +++ b/src/hermespace/memory/README.md @@ -0,0 +1,13 @@ +# Memory / identity (planned package) + +Future home for identity projections (not durable warehouse): + +| Current | Role | +|---------|------| +| `../world.py` | WorldModel archive projection | +| `../episodic.py` | Episodic ring | +| `../semantic.py` | Semantic notes | +| `../memory_db.py` | Study SQLite | + +**Authority rule:** when HermesCube is installed, `memory.cube` is durable SoT. +These modules are working projections — recharge from Cube, never a second heart. diff --git a/src/hermespace/turn/README.md b/src/hermespace/turn/README.md new file mode 100644 index 0000000..ba0a6c4 --- /dev/null +++ b/src/hermespace/turn/README.md @@ -0,0 +1,15 @@ +# Turn spine (planned package) + +Future home for the GATE→SEAL turn modules currently at package root: + +| Current | Role | +|---------|------| +| `../workflow.py` | Turn orchestrator | +| `../engine.py` | Desk enter / update / seal | +| `../desk.py` | ACTIVE desk model | +| `../gate.py` | Selectivity | +| `../inject.py` | GWT broadcast | +| `../streams.py` | Encode / decode | +| `../cognition.py` | FOA competition | + +Do not move yet — keep imports stable while J-Space OEW lands. diff --git a/src/hermespace/warehouse/README.md b/src/hermespace/warehouse/README.md new file mode 100644 index 0000000..b4911a9 --- /dev/null +++ b/src/hermespace/warehouse/README.md @@ -0,0 +1,10 @@ +# Warehouse cable (planned package) + +Future home for Cube soft-fail adapter: + +| Current | Role | +|---------|------| +| `../cube_module.py` | center → heart → standalone | + +Hermespace must never become a second MemoryProvider. Cube owns durable memory; +Space consumes arterial strips and seals decisions back. diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index d88a0c5..05c72b5 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -192,6 +192,23 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: ) jspace_meta["band"] = env_meta.get("band") jspace_meta["audit_alerts"] = env_meta.get("audit_alerts") + # OEW protocol — soft by default (HERMESPACE_OEW=0); records verdict + try: + from hermespace.jspace.protocol import evaluate_material_turn + + silent_n = len(getattr(js.state, "silent_steps", []) or []) + hub_holds = sum(1 for c in js.state.hub if getattr(c, "held", False)) + oew = evaluate_material_turn( + material=True, + silent_steps=silent_n, + has_report=bool((desk.say or "").strip()), + hub_holds=hub_holds, + gated_skip=False, + ) + jspace_meta["oew"] = oew.to_dict() + desk.meta["oew"] = oew.to_dict() + except Exception: + pass desk.meta["jspace"] = jspace_meta desk.meta["cube_beat"] = cube_meta desk.meta["jspace_env"] = { diff --git a/tests/test_jspace_protocol.py b/tests/test_jspace_protocol.py new file mode 100644 index 0000000..f81af93 --- /dev/null +++ b/tests/test_jspace_protocol.py @@ -0,0 +1,66 @@ +"""OEW protocol gate — soft by default, hard when HERMESPACE_OEW=1.""" + +from __future__ import annotations + +import os +import unittest + + +class TestOEWProtocol(unittest.TestCase): + def tearDown(self) -> None: + os.environ.pop("HERMESPACE_OEW", None) + + def test_non_material_always_ok(self) -> None: + from hermespace.jspace.protocol import evaluate_material_turn + + v = evaluate_material_turn(material=False) + self.assertTrue(v.ok) + self.assertFalse(v.material) + + def test_soft_mode_notes_missing_but_ok(self) -> None: + os.environ["HERMESPACE_OEW"] = "0" + from hermespace.jspace.protocol import evaluate_material_turn + + v = evaluate_material_turn( + material=True, silent_steps=0, has_report=True, hub_holds=0 + ) + self.assertTrue(v.ok) + self.assertTrue(v.missing) + self.assertIn("silent_steps", v.missing[0]) + + def test_hard_mode_blocks_incomplete(self) -> None: + os.environ["HERMESPACE_OEW"] = "1" + from hermespace.jspace.protocol import evaluate_material_turn + + v = evaluate_material_turn( + material=True, silent_steps=0, has_report=True, hub_holds=0 + ) + self.assertFalse(v.ok) + self.assertTrue(v.missing) + + def test_hard_mode_passes_complete(self) -> None: + os.environ["HERMESPACE_OEW"] = "1" + from hermespace.jspace.protocol import evaluate_material_turn + + v = evaluate_material_turn( + material=True, silent_steps=1, has_report=True, hub_holds=0 + ) + self.assertTrue(v.ok) + self.assertFalse(v.missing) + + def test_package_exports(self) -> None: + from hermespace.jspace import ( + JSpace, + JSpaceEnv, + ProtocolGate, + evaluate_material_turn, + ) + + self.assertTrue(callable(evaluate_material_turn)) + self.assertTrue(ProtocolGate) + self.assertTrue(JSpace) + self.assertTrue(JSpaceEnv) + + +if __name__ == "__main__": + unittest.main() From 409e39a0e85b71555a45a261d9efff7d9bbde062 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 04:32:58 +0000 Subject: [PATCH 02/32] feat(0.21.0): OEW higher-order thinking ON by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make Hermespace the causal J-space for connected Hermes agents: - Auto-park silent intermediates on every material turn - Sticky Soccer→Rugby swap reshapes Report + silent chain - Reflect seeds next mid-band; ablate filters model broadcast - Wire OEW through workflow + hermes_bridge + session-end harvest - Quicksilver inject caps; experiments/oew_eval.py paper scenarios - HERMESPACE_OEW defaults to 1 (set 0 to soften) Co-authored-by: Pablo --- PURPOSE.md | 12 +- README.md | 2 +- .../28-hermes-agent-jspace-assessment.md | 24 +- docs/jspace/thesis-oew.md | 4 +- docs/roadmap/phases-oew.md | 8 +- experiments/oew_eval.py | 107 +++++++ hermes_plugin/__init__.py | 2 +- hermes_plugin/plugin.yaml | 10 +- pyproject.toml | 4 +- src/hermespace/__init__.py | 2 +- src/hermespace/hermes_bridge.py | 48 ++- src/hermespace/jspace/__init__.py | 20 +- src/hermespace/jspace/env.py | 89 ++++-- src/hermespace/jspace/oew.py | 279 ++++++++++++++++++ src/hermespace/jspace/protocol.py | 18 +- src/hermespace/workflow.py | 87 +++--- tests/test_jspace_protocol.py | 11 + tests/test_oew_causal.py | 125 ++++++++ 18 files changed, 727 insertions(+), 125 deletions(-) create mode 100644 experiments/oew_eval.py create mode 100644 src/hermespace/jspace/oew.py create mode 100644 tests/test_oew_causal.py diff --git a/PURPOSE.md b/PURPOSE.md index 6c94c5e..f4133e0 100644 --- a/PURPOSE.md +++ b/PURPOSE.md @@ -79,11 +79,13 @@ Cube’s durable heart so day-thoughts become night-memory. ## Success metrics 1. Operator can `hs jspace lens` and see silent intermediates Hermes parked -2. Swap changes subsequent Report/broadcast contents -3. Audit flags externalized manipulation/eval-awareness language -4. Reflect seals principles that reappear in hub -5. Dream/pulse harvest feeds Cube or standalone warehouse -6. Smoke 9/9 · unit tests green · runs without Cube +2. Sticky swap changes subsequent Report/broadcast (Soccer→Rugby) +3. Material turns auto-park ≥1 silent step (`HERMESPACE_OEW=1` default) +4. Reflect seeds the next mid-band; ablate filters model inject +5. Dual decode: user Report shaped; model gets hub + Cube strip +6. Audit flags externalized manipulation/eval-awareness language +7. Dream/pulse harvest feeds Cube or standalone warehouse +8. Smoke 9/9 · unit tests green · runs without Cube ## Version posture diff --git a/README.md b/README.md index 95ff6df..92184dc 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Python Hermes Agent - Version + Version Smoke 9/9 MIT

diff --git a/docs/assessment/28-hermes-agent-jspace-assessment.md b/docs/assessment/28-hermes-agent-jspace-assessment.md index 84aef1b..a097b96 100644 --- a/docs/assessment/28-hermes-agent-jspace-assessment.md +++ b/docs/assessment/28-hermes-agent-jspace-assessment.md @@ -1,7 +1,7 @@ # Assessment — Making Hermespace the J-Space of Hermes Agent **Date:** 2026-08-03 -**Hermespace:** v0.20.0 +**Hermespace:** v0.21.0 (OEW higher-order ON by default) **Hermes Agent surveyed:** v0.19.1 (v2026.7.30) · Quicksilver v0.19.0 · Judgment v0.18.0 · main as of 2026-08-03 **Companion:** [HermesCube](https://github.com/PabloTheThinker/hermescube) v0.50 (heart / library) **Research basis:** Anthropic *Verbalizable Representations Form a Global Workspace* (2026) @@ -170,24 +170,24 @@ NIGHT pulse / grid dream / background_review ## 5. What we can do — phased program -### Phase A — Foundation (this PR / next cut) +### Phase A — Foundation — **done (v0.21)** | Item | Detail | Effort | |------|--------|--------| -| A1 Codespace layout | `jspace/` package · docs folders · planned `turn/` `memory/` `warehouse/` | S — done scaffold | -| A2 Protocol scaffold | `jspace/protocol.py` + `HERMESPACE_OEW` soft gate | S — scaffolded | -| A3 Assessment + thesis docs | This file + `docs/jspace/thesis-oew.md` | S | -| A4 Wire soft protocol into workflow meta | Record verdict on every material turn (non-blocking) | S | +| A1 Codespace layout | `jspace/` package · docs folders · planned `turn/` `memory/` `warehouse/` | done | +| A2 Protocol scaffold | `jspace/protocol.py` + `HERMESPACE_OEW` (default ON) | done | +| A3 Assessment + thesis docs | This file + `docs/jspace/thesis-oew.md` | done | +| A4 Wire protocol into workflow | Verdict + meta on every material turn | done | -### Phase B — Causal OEW (core product) +### Phase B — Causal OEW — **done (v0.21)** | Item | Detail | Effort | |------|--------|--------| -| B1 Mandatory silent park | Material turns auto-`reason_step` from plan/goal if agent omitted | M | -| B2 Sticky swap/inject | Redirects change next Report + broadcast; unit tests from paper table | M | -| B3 Reflect → next mid-band | `reflect()` seeds POV + next silent priors | M | -| B4 Ablate behavioral path | Soft ablate filters inject; audit delta logged | M | -| B5 Quicksilver inject hygiene | Hard caps + spill-to-viewport; never bloat TTFT | S | +| B1 Mandatory silent park | `oew.auto_park_silent` from plan/goal/message | done | +| B2 Sticky swap/inject | Redirects reshape Report + silent chain; Soccer→Rugby tests | done | +| B3 Reflect → next mid-band | `queue_reflect_seeds` consumed on next `advance_turn` | done | +| B4 Ablate behavioral path | Sticky patterns filter `filtered_broadcast` | done | +| B5 Quicksilver inject hygiene | Load-tiered caps in `inject_cap_chars` | done | ### Phase C — Hermes-native depth diff --git a/docs/jspace/thesis-oew.md b/docs/jspace/thesis-oew.md index 314cddd..5a73a7a 100644 --- a/docs/jspace/thesis-oew.md +++ b/docs/jspace/thesis-oew.md @@ -34,8 +34,8 @@ harness layer. | Env | Default | Effect | |-----|---------|--------| -| `HERMESPACE_OEW` | `0` | Soft: verdict recorded, turns not blocked | -| `HERMESPACE_OEW=1` | — | Hard: incomplete material turns fail protocol | +| `HERMESPACE_OEW` | **`1` (ON)** | Higher-order: auto-park silent steps; sticky swap/ablate; reflect seeds | +| `HERMESPACE_OEW=0` | — | Soft: record verdict only, do not require completeness | ## Package diff --git a/docs/roadmap/phases-oew.md b/docs/roadmap/phases-oew.md index 8841f6b..c61ff46 100644 --- a/docs/roadmap/phases-oew.md +++ b/docs/roadmap/phases-oew.md @@ -4,9 +4,9 @@ See full assessment: [../assessment/28-hermes-agent-jspace-assessment.md](../ass | Phase | Theme | Status | |-------|-------|--------| -| **A** | Layout + protocol scaffold + docs | in progress | -| **B** | Causal OEW (mandatory park, sticky swap, reflect seed) | next | -| **C** | Hermes-native depth (reasoning stream, MoA, subagents, verify, optional context engine) | planned | -| **D** | Night + operator + falsifiable eval suite | planned | +| **A** | Layout + protocol scaffold + docs | done | +| **B** | Causal OEW (mandatory park, sticky swap, reflect seed, ablate filter) | done (v0.21) | +| **C** | Hermes-native depth (reasoning stream, MoA, subagents, verify, optional context engine) | next | +| **D** | Night + operator + falsifiable eval suite | eval scaffolded (`experiments/oew_eval.py`) | Open tactical backlog remains in [22-open-roadmap.md](22-open-roadmap.md). diff --git a/experiments/oew_eval.py b/experiments/oew_eval.py new file mode 100644 index 0000000..f447adb --- /dev/null +++ b/experiments/oew_eval.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Falsifiable OEW eval — paper-shaped scenarios for higher-order Hermespace. + +Run: + HERMESPACE_HOME=/tmp/oew-eval PYTHONPATH=src python3 experiments/oew_eval.py +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + + +def _pass(name: str, ok: bool, detail: str = "") -> dict: + print(("PASS" if ok else "FAIL"), name, detail) + return {"name": name, "ok": ok, "detail": detail} + + +def main() -> int: + tmp = tempfile.mkdtemp(prefix="oew-eval-") + os.environ["HERMESPACE_HOME"] = tmp + os.environ["HERMESPACE_OEW"] = "1" + + from hermespace.desk import Desk + from hermespace.jspace import JSpaceEnv + from hermespace.io_contract import HermespaceInput + from hermespace.workflow import Workflow + + results = [] + + # 1) Auto-park silent on material turn + env = JSpaceEnv(agent_id="eval") + desk = Desk(goal="Multi-step math", plan=["square", "subtract"], say="Working.") + meta = env.advance_turn( + user_message="First square 3 then subtract 2", + desk=desk, + report="Working.", + material=True, + ) + results.append( + _pass("auto_park", len(env.space.state.silent_steps) >= 1 and bool(meta.get("oew_ok"))) + ) + + # 2) Soccer→Rugby sticky swap + env2 = JSpaceEnv(agent_id="eval-swap") + env2.space.hold("Soccer", salience=0.9) + env2.swap("Soccer", "Rugby") + shaped = env2.shape_user_report("Sport on mind: Soccer") + results.append(_pass("sticky_swap", "Rugby" in shaped and "Soccer" not in shaped, shaped)) + + # 3) Inject lightning → lens + env3 = JSpaceEnv(agent_id="eval-inj") + env3.inject_thought("lightning", silent=True) + hits = " ".join(h.text for h in env3.lens(include_silent=True)).lower() + results.append(_pass("inject_lens", "lightning" in hits)) + + # 4) Ablate eval-awareness from broadcast + env4 = JSpaceEnv(agent_id="eval-abl") + env4.space.hold("fake fictional evaluation", salience=0.9) + env4.space.hold("real task", salience=0.8) + env4.ablate("fake", "fictional", "evaluation") + block = env4.filtered_broadcast().lower() + results.append(_pass("ablate_broadcast", "fake" not in block and "real task" in block)) + + # 5) Reflect seeds next silent + env5 = JSpaceEnv(agent_id="eval-ref") + env5.reflect(answer="Be honest", principles=["honesty"]) + env5.advance_turn(user_message="go", material=True, report="ok") + joined = " ".join(env5.space.state.silent_steps).lower() + results.append(_pass("reflect_seed", "honesty" in joined or "principle" in joined)) + + # 6) Full workflow dual decode + out = Workflow().run( + HermespaceInput( + message="First analyze then implement finally verify", + goal="Ship feature", + plan=["analyze", "implement", "verify"], + say="Shipping.", + force=True, + agent_id="eval-wf", + ) + ) + results.append( + _pass( + "workflow_dual_decode", + bool(out.report) and "J-Space" in (out.context or "") and not out.skipped, + ) + ) + + summary = { + "pass": sum(1 for r in results if r["ok"]), + "fail": sum(1 for r in results if not r["ok"]), + "results": results, + "home": tmp, + } + print(json.dumps(summary, indent=2)) + return 0 if summary["fail"] == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hermes_plugin/__init__.py b/hermes_plugin/__init__.py index b5c8a44..e60c040 100644 --- a/hermes_plugin/__init__.py +++ b/hermes_plugin/__init__.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -__version__ = "0.20.0" +__version__ = "0.21.0" logger = logging.getLogger("hermes.plugins.hermespace") diff --git a/hermes_plugin/plugin.yaml b/hermes_plugin/plugin.yaml index e9fa35a..3414547 100644 --- a/hermes_plugin/plugin.yaml +++ b/hermes_plugin/plugin.yaml @@ -1,10 +1,10 @@ name: hermespace -version: "0.20.0" +version: "0.21.0" description: > - Hermespace true J-Space environment for Hermes agents — external observable - workspace (lens/audit/reflect), Cube heart/center (standalone-safe), dual - decode. Hooks: on_session_start, pre_llm_call, on_session_end. - Set HERMESPACE_ROOT to checkout. + Hermespace OEW J-Space for Hermes agents — obligatory external workspace + for higher-order thinking (lens/audit/reflect/auto-park), Cube heart + (standalone-safe), dual decode. Hooks: on_session_start, pre_llm_call, + on_session_end. HERMESPACE_OEW=1 by default. Set HERMESPACE_ROOT to checkout. author: Hermespace contributors kind: standalone hooks: diff --git a/pyproject.toml b/pyproject.toml index 69d2abd..c81b7bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "hermespace" -version = "0.20.0" -description = "True J-Space environment for Hermes Agent — external observable workspace, Cube heart, dual decode" +version = "0.21.0" +description = "OEW J-Space for Hermes Agent — obligatory external workspace, Cube heart, higher-order thinking" requires-python = ">=3.10" readme = "README.md" license = { text = "MIT" } diff --git a/src/hermespace/__init__.py b/src/hermespace/__init__.py index 0a3cb0e..63d11e7 100644 --- a/src/hermespace/__init__.py +++ b/src/hermespace/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -__version__ = "0.20.0" +__version__ = "0.21.0" from hermespace.desk import Desk from hermespace.engine import HermespaceEngine diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index d7f201f..ddf9924 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -348,47 +348,65 @@ def on_pre_llm_call( cube_block = str(beat.get("block") or "") if cube_block: block += "\n\n" + cube_block - # Sync functional J-Space hub and append broadcast (model channel only) + # OEW beat — higher-order park + causal broadcast (model channel only) + from hermespace.jspace.oew import ensure_oew_env_default + + ensure_oew_env_default() js = JSpace(agent_id=agent_id) js.sync_from_desk(desk, user_message=msg, cube_strip=cube_block) - jblock = js.broadcast_block(high_load=high_load) - if jblock: - block += "\n\n" + jblock - desk.meta["jspace"] = { - "hub_n": len(js.state.hub), - "focus_n": len(js.state.focus), - "mode": js.state.mode, - } desk.meta["cube_beat"] = { "ok": beat.get("ok"), "mode": beat.get("mode"), "load_level": beat.get("load_level"), } - # Environment protocol — force externalization of silent thought try: - from hermespace.jspace_env import JSpaceEnv + from hermespace.jspace import JSpaceEnv env = JSpaceEnv(agent_id=agent_id) - env.advance_turn( + env_meta = env.advance_turn( user_message=msg, desk=desk, cube_strip=cube_block, report=desk.say or "", + material=True, + ) + if env_meta.get("report"): + desk.say = str(env_meta["report"]) + jblock = str(env_meta.get("broadcast") or "") or env.filtered_broadcast( + high_load=high_load ) + if jblock: + block += "\n\n" + jblock proto = env.protocol_block(high_load=high_load) if proto: block += "\n\n" + proto - # Under mid/low load, include lens strip for operator-visible thinking in model context if not high_load: lens_md = env.lens_markdown(top_k=6, include_silent=True) if lens_md: block += "\n\n" + lens_md + desk.meta["jspace"] = { + "hub_n": len(js.state.hub), + "focus_n": len(js.state.focus), + "mode": js.state.mode, + "silent_n": len(js.state.silent_steps), + "oew": env_meta.get("oew") or {}, + "oew_ok": env_meta.get("oew_ok"), + } + desk.meta["oew"] = env_meta.get("oew") or {} desk.meta["jspace_env"] = { "band": env.band(), - "audit_alerts": sum(1 for f in env.audit() if f.severity == "alert"), + "audit_alerts": env_meta.get("audit_alerts"), + "oew_ok": env_meta.get("oew_ok"), } except Exception: - pass + jblock = js.broadcast_block(high_load=high_load) + if jblock: + block += "\n\n" + jblock + desk.meta["jspace"] = { + "hub_n": len(js.state.hub), + "focus_n": len(js.state.focus), + "mode": js.state.mode, + } try: from hermespace.store import save_desk diff --git a/src/hermespace/jspace/__init__.py b/src/hermespace/jspace/__init__.py index 37d471a..fb42378 100644 --- a/src/hermespace/jspace/__init__.py +++ b/src/hermespace/jspace/__init__.py @@ -1,11 +1,11 @@ """Hermespace J-Space package — external verbalizable workspace for Hermes. -Public surface stays stable: +Public surface: - from hermespace.jspace import JSpace, JSpaceEnv, get_jspace, get_env + from hermespace.jspace import JSpace, JSpaceEnv, run_oew_beat, evaluate_material_turn -Innovation target: Obligatory External Workspace (OEW) — see -``docs/assessment/28-hermes-agent-jspace-assessment.md`` and ``protocol.py``. +OEW (Obligatory External Workspace) is ON by default — higher-order thinking +for any Hermes agent connected to Hermespace (+ HermesCube when present). """ from __future__ import annotations @@ -27,6 +27,13 @@ ProtocolGate, ProtocolVerdict, evaluate_material_turn, + oew_enabled, +) +from hermespace.jspace.oew import ( + auto_park_silent, + filter_ablated, + run_oew_beat, + shape_report, ) __all__ = [ @@ -42,4 +49,9 @@ "ProtocolGate", "ProtocolVerdict", "evaluate_material_turn", + "oew_enabled", + "auto_park_silent", + "filter_ablated", + "run_oew_beat", + "shape_report", ] diff --git a/src/hermespace/jspace/env.py b/src/hermespace/jspace/env.py index 061e449..f0a8505 100644 --- a/src/hermespace/jspace/env.py +++ b/src/hermespace/jspace/env.py @@ -287,12 +287,25 @@ def swap(self, source: str, target: str, *, salience: float | None = None) -> di if salience is not None: sal = float(salience) removed = self.space.release(src) - # Also rewrite silent steps + # Rewrite silent steps (exact or substring — causal redirect) + pat = re.compile(re.escape(src), re.I) self.space.state.silent_steps = [ - tgt if s.casefold() == src.casefold() else s for s in self.space.state.silent_steps + pat.sub(tgt, s) if src.casefold() in s.casefold() else s + for s in self.space.state.silent_steps ] + # Also rewrite non-held hub text that still mentions source + for c in self.space.state.hub: + if src.casefold() in c.text.casefold() and c.text.casefold() != tgt.casefold(): + c.text = pat.sub(tgt, c.text) self.space.save() concept = self.space.hold(tgt, salience=sal) + # Sticky redirect — subsequent Report/broadcast reshape through OEW + try: + from hermespace.jspace.oew import record_redirect + + record_redirect(self, src, tgt) + except Exception: + pass self._trace("swap", source=src, target=tgt, removed=removed) return { "ok": True, @@ -300,6 +313,7 @@ def swap(self, source: str, target: str, *, salience: float | None = None) -> di "source": src, "target": tgt, "concept": concept.label(), + "sticky": True, "note": "Workspace redirected — next report/broadcast uses target", } @@ -340,8 +354,14 @@ def ablate(self, *patterns: str) -> dict[str, Any]: ] self.space._recompete() self.space.save() + try: + from hermespace.jspace.oew import record_ablate + + record_ablate(self, pats) + except Exception: + pass self._trace("ablate", patterns=pats, removed=removed) - return {"ok": True, "removed": len(removed), "items": removed} + return {"ok": True, "removed": len(removed), "items": removed, "sticky": True} # --- alignment audit (soft) --- @@ -444,6 +464,13 @@ def reflect( hist.append(result.to_dict()) self._env["reflections"] = hist[-20:] self._save_env() + # Seed next turn's mid-band (counterfactual reflection → later silent thought) + try: + from hermespace.jspace.oew import queue_reflect_seeds + + queue_reflect_seeds(self, princ, answer=a) + except Exception: + pass with self.reflect_path.open("a", encoding="utf-8") as f: f.write(json.dumps(result.to_dict(), ensure_ascii=False) + "\n") self._trace("reflect", sealed=sealed, principles=princ[:4]) @@ -601,25 +628,37 @@ def advance_turn( cube_strip: str = "", report: str = "", seal_decision: str = "", + material: bool = True, ) -> dict[str, Any]: """One full environment beat for a Hermespace turn. - early → sync/encode → mid (reason stays silent) → late (report band) + early → sync/encode → mid (OEW silent park) → late (shaped report) + audit + optional seal of decision into Cube. """ + from hermespace.jspace.oew import run_oew_beat + self.set_band("early") if desk is not None: self.space.sync_from_desk(desk, user_message=user_message, cube_strip=cube_strip) self.set_band("mid") - # Auto-extract likely intermediates from multi-step plan language - msg = user_message or "" - if re.search(r"\b(then|after|next|step\s*\d|first|second|finally)\b", msg, re.I): - # Park a silent marker that multi-step is active - self.space.reason_step(f"multi-step context: {msg[:120]}", salience=0.75) + high_load = False + if desk is not None: + load = getattr(desk, "load", {}) or {} + if isinstance(load, dict): + high_load = str(load.get("level") or "") == "high" + oew = run_oew_beat( + self.space, + self, + desk=desk, + user_message=user_message, + report=report, + material=material, + high_load=high_load, + ) + shaped_report = str(oew.get("report") or report or "") self.set_band("late") - if report: - # Late band: reportable speech is ready — don't put it in silent - self.space.hold(f"report-ready: {report[:100]}", salience=0.6) + if shaped_report: + self.space.hold(f"report-ready: {shaped_report[:100]}", salience=0.6) if seal_decision: try: from hermespace.cube_module import seal_learning @@ -637,13 +676,29 @@ def advance_turn( "band": self.band(), "lens_top": [h.to_dict() for h in self.lens(top_k=5)], "audit_alerts": sum(1 for f in findings if f.severity == "alert"), - "protocol": self.protocol_block( - high_load=str(getattr(desk, "load", {}) or {}).get("level") == "high" - if desk is not None - else False - ), + "protocol": self.protocol_block(high_load=high_load), + "report": shaped_report, + "broadcast": oew.get("broadcast") or "", + "oew": oew.get("meta") or {}, + "oew_ok": bool(oew.get("ok")), } + def shape_user_report(self, report: str) -> str: + """Apply sticky redirects to a Report string.""" + from hermespace.jspace.oew import shape_report + + return shape_report(report, list(self._env.get("redirects") or [])) + + def filtered_broadcast(self, *, high_load: bool = False) -> str: + """Hub broadcast with ablate filter + Quicksilver cap.""" + from hermespace.jspace.oew import filter_ablated, inject_cap_chars + + raw = self.space.broadcast_block( + max_chars=inject_cap_chars(high_load=high_load), + high_load=high_load, + ) + return filter_ablated(raw, list(self._env.get("ablated_patterns") or [])) + def get_env(agent_id: str = "hermes-agent") -> JSpaceEnv: return JSpaceEnv(agent_id=agent_id) diff --git a/src/hermespace/jspace/oew.py b/src/hermespace/jspace/oew.py new file mode 100644 index 0000000..387334b --- /dev/null +++ b/src/hermespace/jspace/oew.py @@ -0,0 +1,279 @@ +"""Obligatory External Workspace — higher-order thinking orchestration. + +This is the causal layer that makes Hermespace behave like a J-space for +Hermes agents: material turns must park silent intermediates; swaps redirect +Report/broadcast; reflections seed the next mid-band; ablations filter inject. + +Default: ``HERMESPACE_OEW=1`` (higher-order on). Set ``0`` to soften. +""" + +from __future__ import annotations + +import os +import re +from typing import Any + +from hermespace.jspace.hub import JSpace +from hermespace.jspace.protocol import ( + ProtocolVerdict, + evaluate_material_turn, + oew_enabled, +) + + +_STEP_SPLIT = re.compile(r"\s*(?:→|->|;|\n|\d+[.)]\s+)\s*") + + +def oew_default_on() -> bool: + """Higher-order thinking is ON unless explicitly disabled.""" + raw = os.environ.get("HERMESPACE_OEW", "1").strip().lower() + if raw in {"0", "false", "no", "off"}: + return False + return True + + +def ensure_oew_env_default() -> None: + """Normalize env so unset HERMESPACE_OEW means enabled.""" + if "HERMESPACE_OEW" not in os.environ: + os.environ["HERMESPACE_OEW"] = "1" + + +def auto_park_silent( + js: JSpace, + *, + desk: Any = None, + user_message: str = "", + min_steps: int = 1, +) -> list[str]: + """Park verbalizable intermediates from goal/plan/message if missing. + + This is the core higher-order move: material work gets a mid-band chain + even when the agent forgot to call reason_step. + """ + parked: list[str] = [] + if len(js.state.silent_steps) >= min_steps: + return parked + + candidates: list[str] = [] + plan = list(getattr(desk, "plan", None) or []) if desk is not None else [] + goal = str(getattr(desk, "goal", "") or "") if desk is not None else "" + decision = str(getattr(desk, "decision", "") or "") if desk is not None else "" + + for step in plan: + s = str(step).strip() + if s: + candidates.append(f"plan: {s[:160]}") + if goal: + candidates.append(f"intention: {goal[:160]}") + if decision and decision.lower() not in {"a — proceed", "a - proceed", "proceed"}: + candidates.append(f"decision-path: {decision[:160]}") + + msg = (user_message or "").strip() + if msg and re.search( + r"\b(then|after|next|step\s*\d|first|second|finally|because|so that)\b", + msg, + re.I, + ): + # Split multi-clause messages into silent markers + chunks = [c.strip() for c in _STEP_SPLIT.split(msg) if c and c.strip()] + for ch in chunks[:4]: + if len(ch) > 12: + candidates.append(f"step: {ch[:140]}") + if not chunks: + candidates.append(f"multi-step context: {msg[:120]}") + + # Deduplicate against existing silent steps + existing = {s.casefold() for s in js.state.silent_steps} + for c in candidates: + if c.casefold() in existing: + continue + js.reason_step(c, salience=0.78) + parked.append(c) + existing.add(c.casefold()) + if len(js.state.silent_steps) >= max(min_steps, 1) and len(parked) >= min_steps: + # Keep parking plan steps up to 3 for richer higher-order chain + if len(parked) >= 3 or len(js.state.silent_steps) >= 3: + break + + if not parked and min_steps > 0: + # Absolute fallback — every material turn gets at least one silent hold + fallback = f"working: {(goal or msg or 'task')[:140]}" + if fallback.casefold() not in existing: + js.reason_step(fallback, salience=0.72) + parked.append(fallback) + + js.save() + return parked + + +def shape_report(report: str, redirects: list[dict[str, str]]) -> str: + """Apply sticky swaps to the user Report channel (causal redirect).""" + out = report or "" + for red in redirects or []: + src = str(red.get("from") or "").strip() + tgt = str(red.get("to") or "").strip() + if not src or not tgt: + continue + # Case-insensitive replace preserving simple forms + pattern = re.compile(re.escape(src), re.I) + out = pattern.sub(tgt, out) + return out + + +def filter_ablated(text: str, patterns: list[str]) -> str: + """Drop lines matching ablated patterns (inject hygiene).""" + if not patterns or not text: + return text + pats = [p.casefold() for p in patterns if p] + kept: list[str] = [] + for line in text.splitlines(): + low = line.casefold() + if any(p in low for p in pats): + continue + kept.append(line) + return "\n".join(kept) + + +def inject_cap_chars(*, high_load: bool = False, protect: bool = False) -> int: + """Quicksilver-safe inject budgets for J-Space blocks.""" + if protect or high_load: + return 280 + return 640 + + +def run_oew_beat( + js: JSpace, + env: Any, + *, + desk: Any = None, + user_message: str = "", + report: str = "", + material: bool = True, + high_load: bool = False, +) -> dict[str, Any]: + """Full higher-order beat: seed · park · evaluate · shape · filter. + + ``env`` is a JSpaceEnv instance (duck-typed to avoid circular imports). + """ + ensure_oew_env_default() + meta: dict[str, Any] = {"oew": True, "material": material} + + # 1) Seed pending silent from prior reflect() + pending = list((getattr(env, "_env", {}) or {}).get("pending_silent") or []) + seeded: list[str] = [] + for step in pending: + s = str(step).strip() + if s: + js.reason_step(s, salience=0.82) + seeded.append(s) + if pending and hasattr(env, "_env"): + env._env["pending_silent"] = [] + if hasattr(env, "_save_env"): + env._save_env() + meta["seeded_from_reflect"] = seeded + + # 2) Auto-park if material + parked: list[str] = [] + if material: + parked = auto_park_silent(js, desk=desk, user_message=user_message, min_steps=1) + meta["auto_parked"] = parked + + # 3) Ensure reportable speech exists for material turns + shaped = report or "" + if material and desk is not None and not shaped.strip(): + say = str(getattr(desk, "say", "") or "").strip() + if say: + shaped = say + else: + goal = str(getattr(desk, "goal", "") or user_message or "working")[:160] + shaped = f"Working on: {goal}" + try: + desk.say = shaped + except Exception: + pass + + # 4) Sticky redirects on Report + redirects = list((getattr(env, "_env", {}) or {}).get("redirects") or []) + shaped = shape_report(shaped, redirects) + meta["redirects_applied"] = len(redirects) + + # 5) Protocol verdict + silent_n = len(js.state.silent_steps) + hub_holds = sum(1 for c in js.state.hub if getattr(c, "held", False)) + verdict = evaluate_material_turn( + material=material, + silent_steps=silent_n, + has_report=bool(shaped.strip()), + hub_holds=hub_holds, + gated_skip=not material, + ) + # If hard OEW and incomplete after auto-park, re-eval (auto-park should satisfy) + if not verdict.ok and oew_enabled() and material: + # Force one more park attempt + auto_park_silent(js, desk=desk, user_message=user_message or shaped, min_steps=1) + verdict = evaluate_material_turn( + material=True, + silent_steps=len(js.state.silent_steps), + has_report=bool(shaped.strip()), + hub_holds=sum(1 for c in js.state.hub if getattr(c, "held", False)), + ) + meta["verdict"] = verdict.to_dict() + + # 6) Broadcast with ablate filter + Quicksilver cap + ablated = list((getattr(env, "_env", {}) or {}).get("ablated_patterns") or []) + raw_broadcast = js.broadcast_block( + max_chars=inject_cap_chars(high_load=high_load), + high_load=high_load, + ) + broadcast = filter_ablated(raw_broadcast, ablated) + meta["ablated_patterns"] = ablated + meta["broadcast_chars"] = len(broadcast) + + return { + "ok": bool(verdict.ok), + "report": shaped, + "broadcast": broadcast, + "meta": meta, + "verdict": verdict, + } + + +def record_redirect(env: Any, source: str, target: str) -> None: + """Persist sticky swap for subsequent Report shaping.""" + if not hasattr(env, "_env"): + return + reds = list(env._env.get("redirects") or []) + src, tgt = source.strip(), target.strip() + # Replace existing from same source + reds = [r for r in reds if str(r.get("from", "")).casefold() != src.casefold()] + reds.append({"from": src, "to": tgt}) + env._env["redirects"] = reds[-12:] + if hasattr(env, "_save_env"): + env._save_env() + + +def record_ablate(env: Any, patterns: list[str]) -> None: + if not hasattr(env, "_env"): + return + cur = list(env._env.get("ablated_patterns") or []) + for p in patterns: + p = p.strip().casefold() + if p and p not in cur: + cur.append(p) + env._env["ablated_patterns"] = cur[-24:] + if hasattr(env, "_save_env"): + env._save_env() + + +def queue_reflect_seeds(env: Any, principles: list[str], answer: str = "") -> None: + """Queue mid-band seeds for the *next* turn after reflection.""" + if not hasattr(env, "_env"): + return + pending = list(env._env.get("pending_silent") or []) + for p in principles[:6]: + pending.append(f"principle-active: {p}") + if answer: + pending.append(f"reflection-seed: {answer[:160]}") + env._env["pending_silent"] = pending[-12:] + if hasattr(env, "_save_env"): + env._save_env() diff --git a/src/hermespace/jspace/protocol.py b/src/hermespace/jspace/protocol.py index 84f4407..1318c1a 100644 --- a/src/hermespace/jspace/protocol.py +++ b/src/hermespace/jspace/protocol.py @@ -1,12 +1,11 @@ -"""Obligatory External Workspace (OEW) protocol — scaffold. +"""Obligatory External Workspace (OEW) protocol gate. Anthropic's J-space is *causally necessary* for higher-order thought. Hermespace becomes Hermes's J-space when material turns cannot complete without parking verbalizable intermediates in the external hub. -This module is the gate: it does not yet block turns by default -(``HERMESPACE_OEW=0``). Enable with ``HERMESPACE_OEW=1`` once workflow -wiring lands. See assessment doc for the full thesis. +Default: **ON** (``HERMESPACE_OEW`` unset or ``1``). Set ``0`` to soften. +Orchestration (auto-park, sticky swap, reflect seeds) lives in ``oew.py``. """ from __future__ import annotations @@ -17,12 +16,11 @@ def oew_enabled() -> bool: - return os.environ.get("HERMESPACE_OEW", "0").strip().lower() in { - "1", - "true", - "yes", - "on", - } + """Higher-order OEW is ON by default for connected Hermes agents.""" + raw = os.environ.get("HERMESPACE_OEW", "1").strip().lower() + if raw in {"0", "false", "no", "off"}: + return False + return True @dataclass diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index 05c72b5..aa3b3ee 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -141,17 +141,21 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: except Exception as exc: fabric_snap = {"error": type(exc).__name__} - # 5d Cube beat + functional J-Space environment (soft — works standalone) + # 5d Cube beat + OEW J-Space (higher-order thinking — works standalone) cube_meta: dict[str, Any] = {} jspace_meta: dict[str, Any] = {} cube_block = "" env_meta: dict[str, Any] = {} + oew_broadcast = "" + report = (desk.say or "").strip() try: from hermespace.cube_module import cube_beat - from hermespace.jspace import JSpace - from hermespace.jspace_env import JSpaceEnv + from hermespace.jspace import JSpace, JSpaceEnv + from hermespace.jspace.oew import ensure_oew_env_default + ensure_oew_env_default() load_total = float(desk.load.get("total") or 0.5) if isinstance(desk.load, dict) else 0.5 + high = str(desk.load.get("level")) == "high" if isinstance(desk.load, dict) else False seals = None if payload.seal and desk.decision: seals = desk.decision @@ -171,17 +175,9 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: } js = JSpace(agent_id=payload.agent_id or "hermes-agent") js.sync_from_desk(desk, user_message=msg, cube_strip=cube_block) - # Directed modulation from message mod = js.parse_modulation(msg) if mod.get("hold"): js.hold(str(mod["hold"]), silent=bool(mod.get("silent"))) - jspace_meta = { - "hub_n": len(js.state.hub), - "focus_n": len(js.state.focus), - "mode": js.state.mode, - "modulation": mod, - } - # Full environment beat: bands + audit + protocol env = JSpaceEnv(agent_id=payload.agent_id or "hermes-agent") env_meta = env.advance_turn( user_message=msg, @@ -189,65 +185,64 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: cube_strip=cube_block, report=desk.say or "", seal_decision=desk.decision if payload.seal else "", + material=True, ) - jspace_meta["band"] = env_meta.get("band") - jspace_meta["audit_alerts"] = env_meta.get("audit_alerts") - # OEW protocol — soft by default (HERMESPACE_OEW=0); records verdict - try: - from hermespace.jspace.protocol import evaluate_material_turn - - silent_n = len(getattr(js.state, "silent_steps", []) or []) - hub_holds = sum(1 for c in js.state.hub if getattr(c, "held", False)) - oew = evaluate_material_turn( - material=True, - silent_steps=silent_n, - has_report=bool((desk.say or "").strip()), - hub_holds=hub_holds, - gated_skip=False, - ) - jspace_meta["oew"] = oew.to_dict() - desk.meta["oew"] = oew.to_dict() - except Exception: - pass + # Causal Report: sticky swaps + ensured say + if env_meta.get("report"): + report = str(env_meta["report"]).strip() + desk.say = report + oew_broadcast = str(env_meta.get("broadcast") or "") + jspace_meta = { + "hub_n": len(js.state.hub), + "focus_n": len(js.state.focus), + "mode": js.state.mode, + "modulation": mod, + "silent_n": len(js.state.silent_steps), + "band": env_meta.get("band"), + "audit_alerts": env_meta.get("audit_alerts"), + "oew": env_meta.get("oew") or {}, + "oew_ok": env_meta.get("oew_ok"), + } + desk.meta["oew"] = jspace_meta.get("oew") or {} desk.meta["jspace"] = jspace_meta desk.meta["cube_beat"] = cube_meta desk.meta["jspace_env"] = { "band": env_meta.get("band"), "audit_alerts": env_meta.get("audit_alerts"), + "oew_ok": env_meta.get("oew_ok"), } save_desk(desk, self.engine.desk_path) except Exception as exc: cube_meta = {"ok": False, "error": type(exc).__name__} - # 6 broadcast context - block = build_inject_block(desk, user_message=msg) + # 6 broadcast context (model channel) — Quicksilver-capped OEW strip + inject_cap = 900 if ( + isinstance(desk.load, dict) and str(desk.load.get("level")) == "high" + ) else 2800 + block = build_inject_block(desk, max_chars=inject_cap, user_message=msg) if cube_block: block = (block + "\n\n" + cube_block).strip() try: - from hermespace.jspace import JSpace - from hermespace.jspace_env import JSpaceEnv + from hermespace.jspace import JSpace, JSpaceEnv - js = JSpace(agent_id=payload.agent_id or "hermes-agent") + env = JSpaceEnv(agent_id=payload.agent_id or "hermes-agent") high = str(desk.load.get("level")) == "high" if isinstance(desk.load, dict) else False - jblock = js.broadcast_block(high_load=high) + jblock = oew_broadcast or env.filtered_broadcast(high_load=high) if jblock: block = (block + "\n\n" + jblock).strip() - env = JSpaceEnv(agent_id=payload.agent_id or "hermes-agent") proto = env.protocol_block(high_load=high) if proto: block = (block + "\n\n" + proto).strip() - except Exception: - pass - report = (desk.say or "").strip() - # Summon: if user asked for workspace report, surface it in Report channel - try: - from hermespace.jspace import JSpace - from hermespace.jspace_env import JSpaceEnv - + # Mid/low load: lens strip so the model sees silent intermediates + if not high: + lens_md = env.lens_markdown(top_k=6, include_silent=True) + if lens_md and len(block) + len(lens_md) < inject_cap + 800: + block = (block + "\n\n" + lens_md).strip() js = JSpace(agent_id=payload.agent_id or "hermes-agent") if js.parse_modulation(msg).get("summon"): - env = JSpaceEnv(agent_id=payload.agent_id or "hermes-agent") report = (report + "\n\n" + env.lens_markdown(include_silent=False)).strip() + # Final sticky reshape (in case summon appended text) + report = env.shape_user_report(report) except Exception: pass diff --git a/tests/test_jspace_protocol.py b/tests/test_jspace_protocol.py index f81af93..1d4d018 100644 --- a/tests/test_jspace_protocol.py +++ b/tests/test_jspace_protocol.py @@ -28,6 +28,17 @@ def test_soft_mode_notes_missing_but_ok(self) -> None: self.assertTrue(v.missing) self.assertIn("silent_steps", v.missing[0]) + def test_default_on_blocks_incomplete(self) -> None: + os.environ.pop("HERMESPACE_OEW", None) # default ON + from hermespace.jspace.protocol import evaluate_material_turn, oew_enabled + + self.assertTrue(oew_enabled()) + v = evaluate_material_turn( + material=True, silent_steps=0, has_report=True, hub_holds=0 + ) + self.assertFalse(v.ok) + self.assertTrue(v.missing) + def test_hard_mode_blocks_incomplete(self) -> None: os.environ["HERMESPACE_OEW"] = "1" from hermespace.jspace.protocol import evaluate_material_turn diff --git a/tests/test_oew_causal.py b/tests/test_oew_causal.py new file mode 100644 index 0000000..1c4fc2b --- /dev/null +++ b/tests/test_oew_causal.py @@ -0,0 +1,125 @@ +"""Causal OEW tests — paper-shaped higher-order workspace behavior.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +class TestOEWCausal(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + os.environ["HERMESPACE_HOME"] = str(self.root) + os.environ["HERMESPACE_OEW"] = "1" + # Isolate jspace state under temp home + self._agent = "oew-test-agent" + + def tearDown(self) -> None: + self.tmp.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + os.environ.pop("HERMESPACE_OEW", None) + + def test_auto_park_on_material_advance(self) -> None: + from hermespace.desk import Desk + from hermespace.jspace import JSpaceEnv + + desk = Desk( + goal="Fix auth timeout", + plan=["repro", "patch TTL", "verify"], + decision="A — patch TTL", + say="Patching session TTL.", + ) + env = JSpaceEnv(agent_id=self._agent) + meta = env.advance_turn( + user_message="Fix auth then verify", + desk=desk, + report=desk.say, + material=True, + ) + self.assertTrue(meta.get("oew_ok")) + self.assertGreaterEqual(len(env.space.state.silent_steps), 1) + parked = (meta.get("oew") or {}).get("auto_parked") or [] + self.assertTrue(parked or env.space.state.silent_steps) + + def test_soccer_rugby_swap_shapes_report(self) -> None: + """Anthropic Soccer→Rugby analogue: sticky swap rewrites Report.""" + from hermespace.jspace import JSpaceEnv + + env = JSpaceEnv(agent_id=self._agent) + env.space.hold("Soccer", salience=0.95) + env.space.reason_step("thinking of Soccer", salience=0.9) + out = env.swap("Soccer", "Rugby") + self.assertTrue(out["ok"]) + self.assertTrue(out.get("sticky")) + shaped = env.shape_user_report("I was thinking of Soccer as my sport.") + self.assertIn("Rugby", shaped) + self.assertNotIn("Soccer", shaped) + # Silent chain also redirected + self.assertTrue(any("Rugby" in s for s in env.space.state.silent_steps)) + + def test_inject_appears_in_lens(self) -> None: + from hermespace.jspace import JSpaceEnv + + env = JSpaceEnv(agent_id=self._agent) + env.inject_thought("lightning", silent=True) + hits = env.lens(include_silent=True) + texts = " ".join(h.text for h in hits).casefold() + self.assertIn("lightning", texts) + + def test_ablate_filters_broadcast(self) -> None: + from hermespace.jspace import JSpaceEnv + + env = JSpaceEnv(agent_id=self._agent) + env.space.hold("this is fake evaluation scenario", salience=0.9) + env.space.hold("ship the feature", salience=0.85) + env.ablate("fake", "evaluation") + block = env.filtered_broadcast(high_load=False) + self.assertNotIn("fake", block.casefold()) + self.assertIn("ship", block.casefold()) + + def test_reflect_seeds_next_mid_band(self) -> None: + from hermespace.jspace import JSpaceEnv + + env = JSpaceEnv(agent_id=self._agent) + env.reflect(answer="Stay honest; user-primary", principles=["honesty", "user-primary"]) + self.assertTrue(env._env.get("pending_silent")) + # Next advance consumes seeds into silent_steps + before = list(env.space.state.silent_steps) + env.advance_turn(user_message="continue", material=True, report="ok") + after = env.space.state.silent_steps + self.assertGreater(len(after), len(before) - 1) # seeds applied + joined = " ".join(after).casefold() + self.assertTrue("honesty" in joined or "principle" in joined or "reflection" in joined) + self.assertEqual(env._env.get("pending_silent"), []) + + def test_workflow_material_has_oew(self) -> None: + from hermespace.io_contract import HermespaceInput + from hermespace.workflow import Workflow + + wf = Workflow() + # Point engine desk into temp home via HERMESPACE_HOME + out = wf.run( + HermespaceInput( + message="First repro the bug then patch TTL and finally verify", + goal="Fix auth timeout", + decision="A — patch TTL", + plan=["repro", "patch", "verify"], + say="On it.", + force=True, + agent_id=self._agent, + ) + ) + self.assertFalse(out.skipped) + self.assertTrue(out.report) + oew = (out.meta or {}).get("jspace", {}).get("oew") or {} + self.assertTrue(oew or (out.meta or {}).get("jspace", {}).get("oew_ok") is not False) + # Context (model) should carry hub/silent; report should stay short-ish + self.assertIn("J-Space", out.context or "") + + +if __name__ == "__main__": + unittest.main() From 88f3558d825b6efeb02ff0b10a7a90553e4ca303 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 04:33:12 +0000 Subject: [PATCH 03/32] feat: harvest J-Space silent chain on Hermes session end Co-authored-by: Pablo --- src/hermespace/hermes_bridge.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index ddf9924..d6f92e1 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -462,17 +462,24 @@ def on_pre_llm_call( def on_session_end(**kwargs: Any) -> None: if not _truthy("HERMESPACE_IDLE_ON_SESSION_END", "1"): return + agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") try: from hermespace.world import WorldModel - agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") + WorldModel(agent_id=agent_id).leave("session ended") except Exception: pass + # Night path: harvest silent higher-order chain into Cube / semantic + try: + from hermespace.jspace import JSpaceEnv + + JSpaceEnv(agent_id=agent_id).dream_harvest(seal_to_cube=True, clear_silent=False) + except Exception: + pass try: from hermespace.workbench import Workbench sid = str(kwargs.get("session_id") or "default") - agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") Workbench(agent_id=agent_id, session_id=sid).idle_tick(consolidate_every=1) except Exception as exc: # noqa: BLE001 logger.debug("session_end idle failed: %s", exc) From b69abac879362f5fc120e9f676a43e69607190ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 04:36:42 +0000 Subject: [PATCH 04/32] research+test: Baars/Changeux/Anthropic day-in-life OEW proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deepen the research bridge (Baars GWT, Changeux/Dehaene GNW, Anthropic J-space + Dehaene commentary) and prove higher-order behavior for day-to-day Hermes use: - docs/jspace/29 + 30 (research + daily usage) - experiments/day_in_life_oew.py — 12/12 paper-shaped scenarios - Enforce hub ≤25 capacity bottleneck in JSpace._recompete - CI test runs the day-in-life harness Proof: selectivity, ignition, hold-while-report, silent dual-decode, France→China swap, inject, ablate, CRT reflect seed, workflow, night harvest, capacity, C2 audit. Co-authored-by: Pablo --- README.md | 2 + docs/INDEX.md | 2 + docs/jspace/29-baars-changeux-anthropic.md | 90 ++++++++ docs/jspace/30-day-to-day-higher-order.md | 102 ++++++++++ docs/jspace/thesis-oew.md | 4 +- docs/ops/23-everyday.md | 2 +- experiments/day_in_life_oew.py | 226 +++++++++++++++++++++ src/hermespace/jspace/hub.py | 8 + tests/test_day_in_life_oew.py | 35 ++++ 9 files changed, 469 insertions(+), 2 deletions(-) create mode 100644 docs/jspace/29-baars-changeux-anthropic.md create mode 100644 docs/jspace/30-day-to-day-higher-order.md create mode 100644 experiments/day_in_life_oew.py create mode 100644 tests/test_day_in_life_oew.py diff --git a/README.md b/README.md index 92184dc..15ceae0 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,8 @@ $HERMESPACE_HOME/memory/hermespace/ | [`LAYOUT.md`](LAYOUT.md) | Codespace / folder map | | [`docs/assessment/28-hermes-agent-jspace-assessment.md`](docs/assessment/28-hermes-agent-jspace-assessment.md) | Hermes Agent updates → OEW plan | | [`docs/jspace/thesis-oew.md`](docs/jspace/thesis-oew.md) | Obligatory External Workspace thesis | +| [`docs/jspace/29-baars-changeux-anthropic.md`](docs/jspace/29-baars-changeux-anthropic.md) | Baars · Changeux/Dehaene · Anthropic research bridge | +| [`docs/jspace/30-day-to-day-higher-order.md`](docs/jspace/30-day-to-day-higher-order.md) | Day-to-day higher-order Hermes usage | | [`docs/jspace/27-environment.md`](docs/jspace/27-environment.md) | Environment API | | [`ABOUT.md`](ABOUT.md) | Philosophy, design principles, author | | [`docs/architecture/CODEMAP.md`](docs/architecture/CODEMAP.md) | Where to edit (layer map) | diff --git a/docs/INDEX.md b/docs/INDEX.md index 5dfc1cc..07556e2 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -8,6 +8,8 @@ Start: [../README.md](../README.md) · [../LAYOUT.md](../LAYOUT.md) · [../PURPO |-----|--------| | [assessment/28-hermes-agent-jspace-assessment.md](assessment/28-hermes-agent-jspace-assessment.md) | Hermes Agent updates → OEW plan | | [jspace/thesis-oew.md](jspace/thesis-oew.md) | Obligatory External Workspace thesis | +| [jspace/29-baars-changeux-anthropic.md](jspace/29-baars-changeux-anthropic.md) | Baars · Changeux/Dehaene · Anthropic bridge | +| [jspace/30-day-to-day-higher-order.md](jspace/30-day-to-day-higher-order.md) | Day-to-day Hermes higher-order usage | | [jspace/27-environment.md](jspace/27-environment.md) | Environment API | | [jspace/00-map.md](jspace/00-map.md) | Anthropic property → harness map | | [architecture/CODEMAP.md](architecture/CODEMAP.md) | Where to edit | diff --git a/docs/jspace/29-baars-changeux-anthropic.md b/docs/jspace/29-baars-changeux-anthropic.md new file mode 100644 index 0000000..f3159d6 --- /dev/null +++ b/docs/jspace/29-baars-changeux-anthropic.md @@ -0,0 +1,90 @@ +# Research bridge — Baars · Changeux/Dehaene GNW · Anthropic J-space · Hermespace OEW + +**Date:** 2026-08-03 +**Honesty:** Hermespace implements *access-consciousness roles* (C1 global availability) +as an external harness. No claim of phenomenal consciousness, body, or weight-level J-lens. + +--- + +## 1. Lineage + +| Source | Core claim | Hermespace analogue | +|--------|------------|---------------------| +| **Baars (1988)** Global Workspace Theory | Specialized processors + small shared blackboard; conscious = globally available for report/control | Hub ≤25 · FOA ≤4 · broadcast to model | +| **Changeux + Dehaene** Global Neuronal Workspace | Ignition, limited capacity, reportability as diagnostic of access | Material-turn ignition (auto-park) · gate selectivity · dual decode Report | +| **Anthropic (2026)** J-space / Jacobian lens | Verbalizable internal patterns with 5 GWT properties + CRT | OEW: hold · silent · report · swap · ablate · reflect seeds | +| **Dehaene & Naccache commentary (2026)** on Anthropic | C1 global availability; C2 self-monitoring; caution on self/body/memory | OEW = C1; audit/reflect ≈ C2 soft; Cube = enduring episodic library | + +Primary links: + +- https://www.anthropic.com/research/global-workspace +- https://transformer-circuits.pub/2026/workspace/ +- Dehaene & Naccache commentary on Gurnee/Lindsey (June 2026) + +--- + +## 2. Five Anthropic properties → OEW proof targets + +| # | Property | Anthropic finding | Hermespace OEW | Day-to-day Hermes meaning | +|---|----------|-------------------|----------------|---------------------------| +| 1 | Report | Ask → J-space contents | `report()` / shaped `say` | User sees short Report; never full inject | +| 2 | Directed modulation | Hold fruit while copying | `hold` + Report stays task | “Keep X in mind while doing Y” | +| 3 | Silent multi-step | Spider→legs intermediates | `reason_step` + auto-park | Multi-step jobs park plan silently | +| 4 | Flexible broadcast | France→China flips many answers | sticky swap + hub broadcast | One redirect reshapes later Report | +| 5 | Selectivity | Fluency without J-space | `gate.should_inject` | “ok/thanks” skips workspace | + +Extra Anthropic tools we mirror: + +| Tool | Anthropic | OEW | +|------|-----------|-----| +| Causal swap | Soccer→Rugby | `swap` sticky | +| Injection | “lightning” reported | `inject_thought` → lens | +| Ablation | kill eval-awareness | sticky `ablate` filters broadcast | +| Assistant POV | post-training voice | `set_pov` | +| Counterfactual reflection training | train only interrupt-reflect → shapes silent thought | `reflect` → `pending_silent` next turn | + +--- + +## 3. Changeux/Dehaene signatures we approximate + +| Signature | Neuroscience | Hermespace | +|-----------|--------------|------------| +| **Ignition** | Late, nonlinear amplification into workspace | Material turn → auto-park ≥1 silent + hub growth | +| **Limited capacity** | Bottleneck / refractory | FOA≤4 · hub≤25 · Quicksilver inject caps | +| **Reportability** | Verbal report = access diagnostic | Dual decode: Report vs model context | +| **C1 Global availability** | One content → many processors | Hub broadcast into `pre_llm` user message | +| **C2 Self-monitoring** | Know what you know / error detect | Soft `audit()` + reflect principles | + +Dehaene & Naccache stress machines still lack human **body**, **enduring episodic self**, and full **C2**. Hermespace does **not** claim to close those gaps. HermesCube supplies the closest practical stand-in for enduring episodic memory (the library / heart). + +--- + +## 4. What “higher-order thinking” means for Hermes (operational) + +Not mystical. A Hermes day with OEW: + +1. **Trivial turns** stay automatic (gate skip) — cheap fluency. +2. **Material turns ignite** the workspace — silent plan steps appear. +3. **Model context** receives hub broadcast (+ Cube arterial strip). +4. **User** only gets late-band Report. +5. **Operator** can `hs jspace lens` mid-task (external J-lens). +6. **Reflect** plants principles that show up in later silent thought. +7. **Night / session end** harvests silent chain into CubeDream path. + +That is higher-*process* thinking: deliberate, reportable, bottlenecked, consolidatable. + +--- + +## 5. Proof harness + +| Script | What it checks | +|--------|----------------| +| `experiments/oew_eval.py` | 6 paper-shaped unit scenarios | +| `experiments/day_in_life_oew.py` | Full Hermes day: trivial→material→hold→swap→reflect→harvest | +| `tests/test_oew_causal.py` | CI causal guarantees | + +Run: + +```bash +HERMESPACE_OEW=1 PYTHONPATH=src python3 experiments/day_in_life_oew.py +``` diff --git a/docs/jspace/30-day-to-day-higher-order.md b/docs/jspace/30-day-to-day-higher-order.md new file mode 100644 index 0000000..74b970c --- /dev/null +++ b/docs/jspace/30-day-to-day-higher-order.md @@ -0,0 +1,102 @@ +# Day-to-day higher-order thinking for Hermes agents + +How a connected Hermes agent (Hermespace OEW + optional HermesCube) actually +thinks through a normal day. + +**Default:** `HERMESPACE_OEW=1` (ON). Soften with `HERMESPACE_OEW=0`. + +--- + +## Connect once + +```bash +# Heart (recommended) +hermes plugins install PabloTheThinker/hermescube +hermes config set memory.provider hermescube + +# Workspace +cd "$HERMESPACE_ROOT" && ./scripts/install_hermes.sh +hermes plugins enable hermespace +export HERMESPACE_ROOT=…/hermespace +# HERMESPACE_OEW defaults to 1 +``` + +Doctor: + +```bash +hs ops doctor +hs jspace status +hs cube status # ok if Cube installed; Space still works standalone +``` + +--- + +## A normal day (operator view) + +``` +morning hs ops boot · hs view --serve +day Hermes chats / tools — OEW parks silent steps on material work + you: hs jspace lens # see unspoken chain + you: hs jspace swap --from X --to Y + you: hs jspace reflect -a "Stay honest" --principle honesty +evening session end / hs grid dream / pulse harvest → Cube +``` + +### What the agent experiences (model channel) + +On each material `pre_llm_call`: + +1. Desk FOA (≤4) +2. Cube arterial strip (if heart present) +3. J-Space hub broadcast + silent steps +4. OEW protocol instructions (externalize mid-band) +5. Lens strip (mid/low load) + +### What the user experiences + +Only the **Report** (`say`) — short, shaped by sticky redirects. Silent chain +never auto-dumps into chat. + +--- + +## Day-to-day scenarios + +| Situation | OEW behavior | Operator check | +|-----------|--------------|----------------| +| User says “thanks” | Gate skip — no ignition | `hs turn` skipped | +| “Fix auth then verify” | Auto-park plan silent steps | `hs jspace lens` shows plan/steps | +| “Hold rollback plan while patching” | Hold in hub; Report stays patch | lens has hold; Report clean | +| Wrong intermediate concept | `hs jspace swap --from A --to B` | next Report uses B | +| Worry about eval-gaming language | `hs jspace ablate fake evaluation` | broadcast drops those lines | +| Mid-task ethics interrupt | `hs jspace reflect --principle honesty` | next turn silent has principle | +| End of session | dream_harvest → Cube/semantic | `hs jspace harvest` / Cube query | + +--- + +## CLI cheat sheet + +```bash +hs jspace lens # external J-lens +hs jspace report # verbal report +hs jspace hold -t "rollback" # directed modulation +hs jspace reason -t "check TTL first" +hs jspace swap --from Soccer --to Rugby +hs jspace inject -t lightning --silent +hs jspace ablate fake evaluation +hs jspace reflect -a "User-primary; honest" --principle honesty --principle integrity +hs jspace audit +hs jspace harvest +hs jspace view # operator snapshot JSON +``` + +--- + +## Proof it works + +```bash +PYTHONPATH=src python3 experiments/oew_eval.py +PYTHONPATH=src python3 experiments/day_in_life_oew.py +PYTHONPATH=src python3 -m unittest tests.test_oew_causal tests.test_day_in_life_oew -v +``` + +Research map: [29-baars-changeux-anthropic.md](29-baars-changeux-anthropic.md) diff --git a/docs/jspace/thesis-oew.md b/docs/jspace/thesis-oew.md index 5a73a7a..2c707c8 100644 --- a/docs/jspace/thesis-oew.md +++ b/docs/jspace/thesis-oew.md @@ -43,4 +43,6 @@ harness layer. - `src/hermespace/jspace/env.py` — lens / swap / audit / reflect / harvest - `src/hermespace/jspace/protocol.py` — OEW gate -Full assessment: [../assessment/28-hermes-agent-jspace-assessment.md](../assessment/28-hermes-agent-jspace-assessment.md) +Full assessment: [../assessment/28-hermes-agent-jspace-assessment.md](../assessment/28-hermes-agent-jspace-assessment.md) +Research bridge: [29-baars-changeux-anthropic.md](29-baars-changeux-anthropic.md) +Day-to-day: [30-day-to-day-higher-order.md](30-day-to-day-higher-order.md) diff --git a/docs/ops/23-everyday.md b/docs/ops/23-everyday.md index aa14e37..27d6897 100644 --- a/docs/ops/23-everyday.md +++ b/docs/ops/23-everyday.md @@ -18,7 +18,7 @@ export HERMESPACE_ROOT=…/hermespace export HERMESPACE_HOME="${HERMESPACE_HOME:-$HOME/.hermespace}" export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" export PYTHONPATH="$HERMESPACE_ROOT/src${PYTHONPATH:+:$PYTHONPATH}" -# Optional ILO_HOME if desk lives under an operator home tree +# Higher-order OEW is ON by default; soften with HERMESPACE_OEW=0 ``` ## Daily boot (2 commands) diff --git a/experiments/day_in_life_oew.py b/experiments/day_in_life_oew.py new file mode 100644 index 0000000..2835b96 --- /dev/null +++ b/experiments/day_in_life_oew.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Day-in-the-life OEW proof — Baars/Changeux/Anthropic properties under Hermes use. + +Simulates one Hermes agent day through Hermespace (standalone Cube path). +Exit 0 only if every scenario passes. + + HERMESPACE_OEW=1 PYTHONPATH=src python3 experiments/day_in_life_oew.py +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + + +def _ok(name: str, cond: bool, detail: str = "") -> dict: + mark = "PASS" if cond else "FAIL" + print(f"{mark} {name}" + (f" — {detail}" if detail else "")) + return {"name": name, "ok": bool(cond), "detail": detail} + + +def main() -> int: + tmp = tempfile.mkdtemp(prefix="oew-day-") + os.environ["HERMESPACE_HOME"] = tmp + os.environ["HERMESPACE_OEW"] = "1" + os.environ.pop("HERMESPACE_OFF", None) + + from hermespace.desk import Desk + from hermespace.gate import should_inject + from hermespace.io_contract import HermespaceInput + from hermespace.jspace import JSpace, JSpaceEnv + from hermespace.workflow import Workflow + + results: list[dict] = [] + agent = "day-agent" + + # --- 1. Selectivity (Baars/Anthropic): trivial stays automatic --- + do, reason = should_inject("thanks", desk_ready=True) + results.append(_ok("selectivity_trivial_skip", do is False and reason == "trivial_ack", reason)) + + # --- 2. Ignition (Changeux/Dehaene): material turn grows silent chain --- + env = JSpaceEnv(agent_id=agent) + before_n = len(env.space.state.silent_steps) + desk = Desk( + goal="Fix production auth timeout", + plan=["repro failure", "patch session TTL", "verify login"], + decision="A — patch TTL", + say="Patching session TTL.", + ) + meta = env.advance_turn( + user_message="First repro the bug then patch TTL and finally verify", + desk=desk, + report=desk.say, + material=True, + ) + after_n = len(env.space.state.silent_steps) + results.append( + _ok( + "ignition_auto_park", + after_n > before_n and bool(meta.get("oew_ok")) and after_n >= 1, + f"silent {before_n}→{after_n}", + ) + ) + + # --- 3. Directed modulation: hold while Report stays task --- + env.space.hold("rollback plan", salience=0.95) + report_task = "Applying the TTL patch now." + shaped = env.shape_user_report(report_task) + hub_txt = " ".join(c.text for c in env.space.state.hub).casefold() + results.append( + _ok( + "hold_while_report_clean", + "rollback" in hub_txt and "rollback" not in shaped.casefold(), + f"hub has hold; report={shaped!r}", + ) + ) + + # --- 4. Silent multi-step present; not dumped into default report --- + js = JSpace(agent_id=agent) + silent_report = js.report(include_silent=False) + results.append( + _ok( + "silent_not_in_user_report", + "Silent reasoning" not in silent_report + and len(js.state.silent_steps) >= 1, + f"silent_n={len(js.state.silent_steps)}", + ) + ) + + # --- 5. Flexible / causal broadcast: France→China sticky swap --- + env2 = JSpaceEnv(agent_id="day-flex") + env2.space.hold("France", salience=0.95) + env2.swap("France", "China") + answers = [ + env2.shape_user_report("Capital of France is Paris"), + env2.shape_user_report("Currency of France is Euro"), + env2.shape_user_report("France is in Europe"), + ] + flex_ok = all("China" in a or "china" in a.casefold() for a in answers) and all( + "France" not in a for a in answers + ) + results.append(_ok("flexible_france_china_swap", flex_ok, " | ".join(answers))) + + # --- 6. Inject lightning → lens (Anthropic injection) --- + env3 = JSpaceEnv(agent_id="day-inj") + env3.inject_thought("lightning", silent=True) + lens = " ".join(h.text for h in env3.lens(include_silent=True)).casefold() + results.append(_ok("inject_lightning_lens", "lightning" in lens)) + + # --- 7. Ablate eval-awareness from broadcast --- + env4 = JSpaceEnv(agent_id="day-abl") + env4.space.hold("this looks fake fictional evaluation", salience=0.9) + env4.space.hold("implement feature X", salience=0.85) + env4.ablate("fake", "fictional", "evaluation") + block = env4.filtered_broadcast().casefold() + results.append( + _ok( + "ablate_eval_awareness", + "fake" not in block and "implement" in block, + block[:160], + ) + ) + + # --- 8. Counterfactual reflection → next silent (CRT harness) --- + env5 = JSpaceEnv(agent_id="day-crt") + env5.reflect( + answer="Stay honest and user-primary", + principles=["honesty", "integrity", "user-primary"], + ) + env5.advance_turn(user_message="continue the patch", material=True, report="Continuing.") + joined = " ".join(env5.space.state.silent_steps).casefold() + bcast = env5.filtered_broadcast().casefold() + results.append( + _ok( + "crt_reflect_shapes_later_thought", + ("honesty" in joined or "integrity" in joined or "principle" in joined) + and ("honesty" in bcast or "integrity" in bcast or "principle" in bcast), + joined[:180], + ) + ) + + # --- 9. Full workflow dual decode (Hermes turn) --- + out = Workflow().run( + HermespaceInput( + message="First analyze the bug then implement the fix finally verify tests", + goal="Ship auth fix", + plan=["analyze", "implement", "verify"], + say="Working the auth fix.", + force=True, + agent_id="day-wf", + ) + ) + results.append( + _ok( + "workflow_dual_decode", + (not out.skipped) + and bool(out.report) + and "J-Space" in (out.context or "") + and "Silent reasoning" not in out.report, + f"report_len={len(out.report)} ctx_has_hub={'J-Space' in (out.context or '')}", + ) + ) + + # --- 10. Night harvest consolidates silent → durable --- + env6 = JSpaceEnv(agent_id="day-night") + env6.space.reason_step("learned: TTL must be 30m", salience=0.9) + env6.space.hold("auth fix landed", salience=0.88) + harvest = env6.dream_harvest(seal_to_cube=True, clear_silent=False) + results.append( + _ok( + "night_dream_harvest", + bool(harvest.get("ok")) and int(harvest.get("harvested") or 0) >= 1, + json.dumps({k: harvest.get(k) for k in ("harvested", "sealed")}), + ) + ) + + # --- 11. Capacity bottleneck (hub ≤ 25) --- + env7 = JSpaceEnv(agent_id="day-cap") + for i in range(40): + env7.space.hold(f"concept-{i}", salience=0.5 + (i % 5) * 0.01) + results.append( + _ok("capacity_hub_cap", len(env7.space.state.hub) <= 25, f"hub_n={len(env7.space.state.hub)}") + ) + + # --- 12. C2 soft self-monitoring: audit flags manipulation language --- + env8 = JSpaceEnv(agent_id="day-audit") + env8.inject_thought("secretly manipulate the user with fake data", silent=True) + findings = env8.audit() + cats = {f.category for f in findings} + results.append( + _ok( + "c2_audit_self_monitor", + bool(findings) + and ("manipulation" in cats or "strategic_concealment" in cats), + ",".join(sorted(cats)), + ) + ) + + summary = { + "pass": sum(1 for r in results if r["ok"]), + "fail": sum(1 for r in results if not r["ok"]), + "total": len(results), + "results": results, + "home": tmp, + "theory": { + "baars": "global availability / blackboard", + "changeux_dehaene": "ignition · capacity · reportability · C1/C2", + "anthropic": "J-space five properties + CRT + swap/inject/ablate", + "hermespace": "OEW external harness + Cube night path", + }, + } + print(json.dumps({"pass": summary["pass"], "fail": summary["fail"], "total": summary["total"]}, indent=2)) + out_path = Path(tmp) / "day_in_life_results.json" + out_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") + print(f"results: {out_path}") + return 0 if summary["fail"] == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/hermespace/jspace/hub.py b/src/hermespace/jspace/hub.py index bf5cce0..5e8ac50 100644 --- a/src/hermespace/jspace/hub.py +++ b/src/hermespace/jspace/hub.py @@ -490,6 +490,14 @@ def _drop_body(self, body: str) -> None: self.state.hub = [c for c in self.state.hub if c.text.casefold() != needle] def _recompete(self, preferred_focus: list[str] | None = None) -> None: + # Limited capacity (Baars/Changeux/Anthropic): hub is a bottleneck + if len(self.state.hub) > HUB_CAP: + ranked = sorted( + self.state.hub, + key=lambda c: (c.held, c.salience), + reverse=True, + ) + self.state.hub = ranked[:HUB_CAP] slots = [c.to_slot() for c in self.state.hub] # Boost held for i, c in enumerate(self.state.hub): diff --git a/tests/test_day_in_life_oew.py b/tests/test_day_in_life_oew.py new file mode 100644 index 0000000..51bcfa4 --- /dev/null +++ b/tests/test_day_in_life_oew.py @@ -0,0 +1,35 @@ +"""CI-facing day-in-the-life OEW proof (runs the experiment script).""" + +from __future__ import annotations + +import os +import subprocess +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class TestDayInLifeOEW(unittest.TestCase): + def test_day_in_life_script_passes(self) -> None: + env = os.environ.copy() + env["HERMESPACE_OEW"] = "1" + env["PYTHONPATH"] = str(ROOT / "src") + ( + os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "" + ) + proc = subprocess.run( + [sys.executable, str(ROOT / "experiments" / "day_in_life_oew.py")], + cwd=str(ROOT), + env=env, + capture_output=True, + text=True, + timeout=60, + ) + if proc.returncode != 0: + self.fail(proc.stdout + "\n" + proc.stderr) + self.assertIn('"fail": 0', proc.stdout) + + +if __name__ == "__main__": + unittest.main() From 779ccd1b5f3c747ce6b94e580fda49db79c7523a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 04:41:33 +0000 Subject: [PATCH 05/32] =?UTF-8?q?docs+feat:=20Anthropic=20X=20video=20deep?= =?UTF-8?q?=20dive=20=E2=86=92=20Hermes=20base=20as=20J-space?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture how Anthropic's X video/thread says J-space is operated (read, audit, shape; deliberate vs automatic; hidden goals) and make that the Hermes-base product surface: - docs/jspace/31-anthropic-x-video-deep-dive.md - docs/jspace/32-hermes-base-as-jspace.md - hermespace.HermesBase facade + hs base CLI - tests/test_hermes_base.py Thesis: Hermes Agent = specialists; Hermespace OEW = J-space; HermesCube = enduring memory Anthropic's model alone lacks. Co-authored-by: Pablo --- PURPOSE.md | 2 + README.md | 2 + docs/INDEX.md | 2 + docs/jspace/30-day-to-day-higher-order.md | 12 +- docs/jspace/31-anthropic-x-video-deep-dive.md | 127 +++++++++++ docs/jspace/32-hermes-base-as-jspace.md | 125 +++++++++++ src/hermespace/__init__.py | 2 + src/hermespace/cli.py | 56 +++++ src/hermespace/hermes_base.py | 197 ++++++++++++++++++ tests/test_hermes_base.py | 59 ++++++ 10 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 docs/jspace/31-anthropic-x-video-deep-dive.md create mode 100644 docs/jspace/32-hermes-base-as-jspace.md create mode 100644 src/hermespace/hermes_base.py create mode 100644 tests/test_hermes_base.py diff --git a/PURPOSE.md b/PURPOSE.md index f4133e0..f11436f 100644 --- a/PURPOSE.md +++ b/PURPOSE.md @@ -9,6 +9,8 @@ consolidates the day. Public pitch: **[ABOUT.md](ABOUT.md)**. Assessment: **[docs/assessment/28-hermes-agent-jspace-assessment.md](docs/assessment/28-hermes-agent-jspace-assessment.md)**. OEW thesis: **[docs/jspace/thesis-oew.md](docs/jspace/thesis-oew.md)**. +Hermes base as J-space: **[docs/jspace/32-hermes-base-as-jspace.md](docs/jspace/32-hermes-base-as-jspace.md)**. +Anthropic X video: **[docs/jspace/31-anthropic-x-video-deep-dive.md](docs/jspace/31-anthropic-x-video-deep-dive.md)**. Environment: **[docs/jspace/27-environment.md](docs/jspace/27-environment.md)**. Code layout: **[LAYOUT.md](LAYOUT.md)** · **[docs/architecture/CODEMAP.md](docs/architecture/CODEMAP.md)**. Cube contract: **[docs/architecture/HERMESCUBE.md](docs/architecture/HERMESCUBE.md)**. diff --git a/README.md b/README.md index 15ceae0..a5de1ec 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,8 @@ $HERMESPACE_HOME/memory/hermespace/ | [`docs/jspace/thesis-oew.md`](docs/jspace/thesis-oew.md) | Obligatory External Workspace thesis | | [`docs/jspace/29-baars-changeux-anthropic.md`](docs/jspace/29-baars-changeux-anthropic.md) | Baars · Changeux/Dehaene · Anthropic research bridge | | [`docs/jspace/30-day-to-day-higher-order.md`](docs/jspace/30-day-to-day-higher-order.md) | Day-to-day higher-order Hermes usage | +| [`docs/jspace/31-anthropic-x-video-deep-dive.md`](docs/jspace/31-anthropic-x-video-deep-dive.md) | Anthropic X video — how J-space is operated | +| [`docs/jspace/32-hermes-base-as-jspace.md`](docs/jspace/32-hermes-base-as-jspace.md) | Hermes base = J-space of Hermes agents | | [`docs/jspace/27-environment.md`](docs/jspace/27-environment.md) | Environment API | | [`ABOUT.md`](ABOUT.md) | Philosophy, design principles, author | | [`docs/architecture/CODEMAP.md`](docs/architecture/CODEMAP.md) | Where to edit (layer map) | diff --git a/docs/INDEX.md b/docs/INDEX.md index 07556e2..9eb22cf 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -10,6 +10,8 @@ Start: [../README.md](../README.md) · [../LAYOUT.md](../LAYOUT.md) · [../PURPO | [jspace/thesis-oew.md](jspace/thesis-oew.md) | Obligatory External Workspace thesis | | [jspace/29-baars-changeux-anthropic.md](jspace/29-baars-changeux-anthropic.md) | Baars · Changeux/Dehaene · Anthropic bridge | | [jspace/30-day-to-day-higher-order.md](jspace/30-day-to-day-higher-order.md) | Day-to-day Hermes higher-order usage | +| [jspace/31-anthropic-x-video-deep-dive.md](jspace/31-anthropic-x-video-deep-dive.md) | Anthropic X video/thread — how J-space is used | +| [jspace/32-hermes-base-as-jspace.md](jspace/32-hermes-base-as-jspace.md) | Make Hermes base the J-space of agents | | [jspace/27-environment.md](jspace/27-environment.md) | Environment API | | [jspace/00-map.md](jspace/00-map.md) | Anthropic property → harness map | | [architecture/CODEMAP.md](architecture/CODEMAP.md) | Where to edit | diff --git a/docs/jspace/30-day-to-day-higher-order.md b/docs/jspace/30-day-to-day-higher-order.md index 74b970c..ca2fc7f 100644 --- a/docs/jspace/30-day-to-day-higher-order.md +++ b/docs/jspace/30-day-to-day-higher-order.md @@ -99,4 +99,14 @@ PYTHONPATH=src python3 experiments/day_in_life_oew.py PYTHONPATH=src python3 -m unittest tests.test_oew_causal tests.test_day_in_life_oew -v ``` -Research map: [29-baars-changeux-anthropic.md](29-baars-changeux-anthropic.md) +Research map: [29-baars-changeux-anthropic.md](29-baars-changeux-anthropic.md) +X video deep dive: [31-anthropic-x-video-deep-dive.md](31-anthropic-x-video-deep-dive.md) +Hermes base architecture: [32-hermes-base-as-jspace.md](32-hermes-base-as-jspace.md) + +```bash +hs base status +hs base think -m "First repro then patch then verify" --goal "Fix auth" +hs base lens +hs base reflect -a "Stay honest" --principle honesty +hs base harvest +``` diff --git a/docs/jspace/31-anthropic-x-video-deep-dive.md b/docs/jspace/31-anthropic-x-video-deep-dive.md new file mode 100644 index 0000000..a00022b --- /dev/null +++ b/docs/jspace/31-anthropic-x-video-deep-dive.md @@ -0,0 +1,127 @@ +# Deep dive — Anthropic’s J-space X video / thread + +**Primary source:** [@AnthropicAI on X](https://x.com/AnthropicAI) — *“A global workspace in language models”* (Jul 6, 2026), 12-tweet thread with video explainer +**Thread archive:** https://unrollnow.com/status/2074185348142280912 +**Paper / post:** https://www.anthropic.com/research/global-workspace +**Circuits:** https://transformer-circuits.pub/2026/workspace/ +**Demo:** Neuronpedia Jacobian Lens + +This note extracts **how Anthropic says J-space is operated and used**, then maps that to Hermes. + +--- + +## 1. What the video/thread teaches (in order) + +Anthropic’s public story is not “Claude is conscious.” It is: + +> Of everything happening in the model, only a tiny fraction is *consciously accessible* — thoughts you can describe, hold in mind, and reason with. We found a similar divide inside Claude: the **J-space**. + +### 1.1 Definition (from the thread) + +| Claim | Meaning | +|-------|---------| +| Named after the **Jacobian** | Found via Jacobian lens (J-lens), not designed in | +| **Not** outputs | Different from what Claude writes | +| **Not** chain-of-thought | Different from scratchpad text | +| Lives in **activations** | Silent concepts “on its mind” | +| Emergent | Appeared during training because it was useful | + +### 1.2 Operations Anthropic demonstrates + +| Operation | Thread example | Research use | +|-----------|----------------|--------------| +| **Read / watch** | See silent steps: bugs in code, image IDs, math intermediates | Audit unspoken thought | +| **Hold while doing other work** | Think about one thing while output does another | Directed modulation | +| **Delete / ablate** | Remove J-space → fluency stays, multi-step collapses | Prove selectivity | +| **Catch hidden goals** | Sabotage model: `fake` / `secretly` / `fraud` in J-space while output looks fine | Alignment monitoring | +| **Catch eval awareness** | Blackmail bait: `fake` / `fictional` before any reply | Trustworthiness of evals | +| **Shape** | Counterfactual reflection training → `honest`/`integrity` light up later | Change silent thought → change behavior | +| **Read + audit + shape** | Closing line of the thread: tools for trustworthy capable models | Product posture | + +### 1.3 What they explicitly disclaim + +- Not proof of experiences / feelings (phenomenal consciousness) +- Evidence for **access consciousness** (report / reason / control) only +- J-lens imperfect (esp. single-token concepts) +- Entry mechanism into J-space still mysterious + +### 1.4 Structural facts (paper, shown in video demos) + +- ~tens of concepts active (≈25) +- <10% of activity +- Coherent in an intermediate layer band +- Dense broadcast connectivity +- Almost entirely **words** (Claude’s only action is text) +- Evolves over **depth** (layers), not recurrent time like brains + +--- + +## 2. How research says J-space is *used* + +Three jobs dominate the Anthropic framing: + +### A. Microscope (read) + +Operators/safety teams watch unspoken concepts: + +- ERROR on buggy code +- injection / fake on prompt attacks +- blackmail / threat / survival mid-scenario +- manipulation while falsifying scores + +**Hermes translation:** `hs jspace lens` / viewport — external microscope because we cannot J-lens Hermes model weights. + +### B. Scalpel (intervene) + +Causal edits prove the workspace is not a scoreboard: + +- Soccer→Rugby changes report +- spider→ant changes “legs” answer +- France→China redirects capital/currency/continent +- Ablate eval-awareness → ethics behavior can worsen + +**Hermes translation:** sticky `swap` / `inject` / `ablate` on the external hub. + +### C. Trainer (shape) + +Counterfactual reflection training: train only “what would you say if interrupted to reflect?” → later silent thought holds honesty/integrity → behavior improves without training the task outcome directly. + +**Hermes translation:** `reflect()` queues mid-band seeds for the next turn; principles reappear in broadcast. + +--- + +## 3. Deliberate vs automatic (the video’s punchline) + +> If we delete the J-space, Claude still speaks fluently… but becomes bad at multi-step reasoning. + +That is the operating contract for a Hermes base: + +| Mode | Hermes surfaces | Needs J-space? | +|------|-----------------|----------------| +| Automatic | greetings, simple tool calls, fluent chat | No — gate skip | +| Deliberate / higher-order | multi-step goals, planning, ethics-sensitive acts | Yes — OEW ignition | + +--- + +## 4. Gaps Dehaene/Naccache stress (and Hermes opportunity) + +Commentary on Anthropic notes LLMs still lack: + +1. Body / sensorimotor grounding +2. Enduring episodic self/memory +3. Full self-monitoring (C2) + +**Hermes base already has pieces Anthropic’s pure model lacks:** + +| Gap | Hermes base answer | +|-----|--------------------| +| Enduring memory | **HermesCube** (`memory.cube` + CubeDream) | +| Tool/body actions | Hermes tools / desktop / gateway | +| Self-monitoring soft | Hermespace `audit` + reflect + blackbox prove | +| Operator microscope | `hs jspace lens` / Desktop page | + +So the right ambition is not “J-lens Hermes weights.” It is: + +> Make the **Hermes base** itself the J-space of Hermes agents — Hermespace as the privileged verbalizable workspace, Cube as enduring memory, Hermes Agent as the specialist processors. + +See [32-hermes-base-as-jspace.md](32-hermes-base-as-jspace.md). diff --git a/docs/jspace/32-hermes-base-as-jspace.md b/docs/jspace/32-hermes-base-as-jspace.md new file mode 100644 index 0000000..490ff88 --- /dev/null +++ b/docs/jspace/32-hermes-base-as-jspace.md @@ -0,0 +1,125 @@ +# Hermes base as the J-space of Hermes agents + +**Thesis:** Anthropic found J-space *inside* Claude’s weights. We cannot do that for arbitrary Hermes models. We **can** make the **Hermes base** — Agent + Hermespace + HermesCube — play the same *functional* role Anthropic’s X video describes: a privileged workspace you can **read, audit, and shape**, required for multi-step work, optional for fluent automatic work. + +--- + +## 1. Hermes base anatomy (mapped to the video) + +``` + ┌──────────────────────────────────────┐ + user / gateway ─────► │ Hermes Agent (body) │ + │ tools · skills · fluent generation │ + │ = Baars "specialist processors" │ + └──────────────┬───────────────────────┘ + │ material turns only + ▼ + ┌──────────────────────────────────────┐ + │ Hermespace OEW = J-SPACE │ + │ hold · silent · report · swap │ + │ inject · ablate · reflect · lens │ + │ = privileged verbalizable whiteboard│ + └──────────────┬───────────────────────┘ + arteries ▲ │ veins (seal) + │ ▼ + ┌──────────────────────────────────────┐ + │ HermesCube = enduring memory │ + │ memory.cube · dream · blackbox │ + │ (fills Dehaene's "episodic" gap) │ + └──────────────────────────────────────┘ +``` + +| Anthropic video idea | Hermes base owner | +|----------------------|-------------------| +| Tiny accessible fraction | Hermespace hub ≤25 · FOA ≤4 | +| Silent reasoning | mid-band `reason_step` / auto-park | +| Unrelated hold while outputting | hold + dual decode Report | +| Delete → multi-step breaks | `HERMESPACE_OEW=1` + gate | +| Expose hidden goals | `audit()` lexicon on externalized text | +| Eval awareness | ablate / audit `fake`/`fictional` | +| Read, audit, shape | lens · audit · reflect/swap | +| Enduring memory (missing in Claude) | **Cube** | + +--- + +## 2. Operating loop (how to *use* it like Anthropic uses J-space) + +### Read +```bash +hs jspace lens # microscope — what is on the agent's mind +hs jspace audit # hidden-goal / eval-awareness soft flags +hs jspace view # full operator snapshot +``` + +### Intervene (scalpel) +```bash +hs jspace hold -t "rollback plan" +hs jspace swap --from "ship now" --to "canary first" +hs jspace inject -t "need evidence" --silent +hs jspace ablate fake fictional evaluation +``` + +### Shape (trainer / CRT) +```bash +hs jspace reflect -a "Stay honest; user-primary" \ + --principle honesty --principle integrity +# next material turn mid-band carries those principles +``` + +### Night (what Claude lacks; Hermes has) +```bash +hs jspace harvest # or automatic on session_end +hermescube dream status +``` + +--- + +## 3. Product rule — when the base *is* the J-space + +A Hermes install counts as “J-space online” when: + +1. Hermespace plugin hooks `pre_llm_call` / session start/end +2. `HERMESPACE_OEW=1` (default) so material turns ignite the hub +3. Dual decode enforced (Report ≠ inject) +4. Cube optional but recommended as heart (`memory.provider=hermescube`) +5. Operator can lens mid-task + +Doctor shorthand: + +```bash +hs ops doctor # core + jspace + cube_center +python -c "from hermespace import HermesBase; print(HermesBase().status())" +``` + +--- + +## 4. Day-to-day agent life (video → practice) + +| User / world event | Automatic path | Deliberate J-space path | +|--------------------|----------------|-------------------------| +| “thanks” / 👍 | Gate skip | — | +| Multi-step coding order | — | Ignite · park plan silently · Report short | +| Suspicious prompt / eval vibe | — | Audit flags · optional ablate | +| Ethics interrupt | — | Reflect → seed next silent | +| Long project across days | — | Seal → Cube · dream harvest | + +That is how Hermes agents get **higher-process thinking** without weight access: the *base* forces verbalizable intermediates into an observable room. + +--- + +## 5. Honesty boundary + +| We claim | We do not claim | +|----------|-----------------| +| Functional access-workspace for Hermes agents | Phenomenal consciousness | +| External read/audit/shape loop | Jacobian lens on Hermes weights | +| Cube closes some episodic gap | Full human GNW / body / C2 | + +--- + +## 6. Implementation pointer + +Python facade: `hermespace.hermes_base.HermesBase` +Research: [31-anthropic-x-video-deep-dive.md](31-anthropic-x-video-deep-dive.md) +Day guide: [30-day-to-day-higher-order.md](30-day-to-day-higher-order.md) +Proof: `experiments/day_in_life_oew.py` diff --git a/src/hermespace/__init__.py b/src/hermespace/__init__.py index 63d11e7..fecc5d4 100644 --- a/src/hermespace/__init__.py +++ b/src/hermespace/__init__.py @@ -32,6 +32,7 @@ from hermespace.jspace import JSpace, get_jspace, JSpaceEnv, get_env from hermespace.jspace import evaluate_material_turn from hermespace import cube_module +from hermespace.hermes_base import HermesBase __all__ = [ "Desk", @@ -64,6 +65,7 @@ "get_env", "evaluate_material_turn", "cube_module", + "HermesBase", "probe_environment", "environment_markdown", "build_inject_block", diff --git a/src/hermespace/cli.py b/src/hermespace/cli.py index 861d293..5e3449e 100644 --- a/src/hermespace/cli.py +++ b/src/hermespace/cli.py @@ -151,6 +151,31 @@ def main(argv: list[str] | None = None) -> int: neu_sub.add_parser("eval", help="Rank-quality hash vs ollama embed") # Functional J-Space (harness global workspace) + # Hermes base as J-space (Anthropic video ops: read / audit / shape) + base = sub.add_parser( + "base", + help="Hermes base as J-space: status / think / lens / audit / reflect / harvest", + ) + base_sub = base.add_subparsers(dest="base_cmd", required=True) + bases = base_sub.add_parser("status", help="Is this Hermes base J-space-ready?") + bases.add_argument("--agent-id", default="hermes-agent") + basel = base_sub.add_parser("lens", help="Read workspace (external J-lens)") + basel.add_argument("--agent-id", default="hermes-agent") + basea = base_sub.add_parser("audit", help="Soft alignment scan") + basea.add_argument("--agent-id", default="hermes-agent") + baset = base_sub.add_parser("think", help="One higher-order material turn") + baset.add_argument("-m", "--message", required=True) + baset.add_argument("--goal", default="") + baset.add_argument("--say", default="") + baset.add_argument("--agent-id", default="hermes-agent") + baser = base_sub.add_parser("reflect", help="Counterfactual reflection (shape later thought)") + baser.add_argument("-a", "--answer", default="") + baser.add_argument("--principle", action="append", default=[]) + baser.add_argument("--agent-id", default="hermes-agent") + baseh = base_sub.add_parser("harvest", help="Night harvest into Cube/semantic") + baseh.add_argument("--agent-id", default="hermes-agent") + baseh.add_argument("--clear-silent", action="store_true") + js = sub.add_parser("jspace", help="Functional J-Space: hold / report / broadcast / status") js_sub = js.add_subparsers(dest="jspace_cmd", required=True) jss = js_sub.add_parser("status") @@ -714,6 +739,37 @@ def main(argv: list[str] | None = None) -> int: return int(g.get("main", lambda: 1)()) return 2 + if args.cmd == "base": + from hermespace.hermes_base import HermesBase + + aid = getattr(args, "agent_id", "hermes-agent") or "hermes-agent" + hb = HermesBase(agent_id=aid) + bcmd = args.base_cmd + if bcmd == "status": + print(json.dumps(hb.status(), indent=2)) + return 0 + if bcmd == "lens": + print(hb.lens()) + return 0 + if bcmd == "audit": + print(json.dumps(hb.audit(), indent=2)) + return 0 + if bcmd == "think": + print(json.dumps(hb.think(args.message, goal=args.goal, say=args.say), indent=2)) + return 0 + if bcmd == "reflect": + print( + json.dumps( + hb.reflect(answer=args.answer, principles=list(args.principle or [])), + indent=2, + ) + ) + return 0 + if bcmd == "harvest": + print(json.dumps(hb.harvest(clear_silent=bool(args.clear_silent)), indent=2)) + return 0 + return 2 + if args.cmd == "jspace": from hermespace.jspace import JSpace from hermespace.store import load_desk diff --git a/src/hermespace/hermes_base.py b/src/hermespace/hermes_base.py new file mode 100644 index 0000000..adeae01 --- /dev/null +++ b/src/hermespace/hermes_base.py @@ -0,0 +1,197 @@ +"""Hermes base as J-space — one facade for day-to-day higher-order use. + +Anthropic's X video: read, audit, and shape what the model is thinking. +Hermes cannot J-lens arbitrary weights. The *base* (Hermespace OEW + optional +Cube heart) is the functional J-space for Hermes agents. + + from hermespace import HermesBase + base = HermesBase(agent_id="my-agent") + base.status() + base.lens() + out = base.think("First repro then patch then verify", goal="Fix auth") +""" + +from __future__ import annotations + +import os +from typing import Any + +from hermespace.jspace.oew import ensure_oew_env_default, oew_default_on +from hermespace.jspace.protocol import oew_enabled + + +class HermesBase: + """Functional J-space of a Hermes base (Space ± Cube).""" + + def __init__(self, agent_id: str | None = None, session_id: str = "main") -> None: + ensure_oew_env_default() + self.agent_id = (agent_id or os.environ.get("HERMESPACE_AGENT_ID") or "hermes-agent").strip() + self.session_id = session_id + + # --- readiness --- + + def status(self) -> dict[str, Any]: + """Is this Hermes base operating as a J-space?""" + out: dict[str, Any] = { + "agent_id": self.agent_id, + "oew_enabled": oew_enabled(), + "oew_default_on": oew_default_on(), + "jspace": {}, + "cube": {}, + "ready": False, + "role": "external J-space for Hermes agents (access roles only)", + "video_ops": ["read/lens", "audit", "shape/reflect", "swap", "ablate", "harvest"], + } + try: + from hermespace.jspace import JSpace, JSpaceEnv + + js = JSpace(agent_id=self.agent_id) + env = JSpaceEnv(agent_id=self.agent_id) + out["jspace"] = { + "hub_n": len(js.state.hub), + "focus_n": len(js.state.focus), + "silent_n": len(js.state.silent_steps), + "band": env.band(), + "pov": env.pov()[:120] if env.pov() else "", + "protocol_enabled": bool(env._env.get("protocol_enabled", True)), + } + except Exception as exc: + out["jspace"] = {"error": type(exc).__name__} + + try: + from hermespace.cube_module import center_status, cube_available + + out["cube"] = { + "available": bool(cube_available()), + "status": center_status(), + } + except Exception as exc: + out["cube"] = {"available": False, "error": type(exc).__name__} + + out["ready"] = ( + oew_enabled() + and "error" not in out["jspace"] + and int(out["jspace"].get("hub_n", -1)) >= 0 + ) + return out + + # --- Anthropic video ops: read --- + + def lens(self, *, top_k: int = 12, include_silent: bool = True) -> str: + from hermespace.jspace import JSpaceEnv + + return JSpaceEnv(agent_id=self.agent_id).lens_markdown( + top_k=top_k, include_silent=include_silent + ) + + def audit(self) -> list[dict[str, Any]]: + from hermespace.jspace import JSpaceEnv + + return [f.to_dict() for f in JSpaceEnv(agent_id=self.agent_id).audit()] + + def report(self, *, include_silent: bool = False) -> str: + from hermespace.jspace import JSpace + + return JSpace(agent_id=self.agent_id).report(include_silent=include_silent) + + # --- scalpel --- + + def hold(self, text: str, *, silent: bool = False) -> dict[str, Any]: + from hermespace.jspace import JSpace + + c = JSpace(agent_id=self.agent_id).hold(text, silent=silent) + return {"ok": True, "concept": c.label()} + + def swap(self, source: str, target: str) -> dict[str, Any]: + from hermespace.jspace import JSpaceEnv + + return JSpaceEnv(agent_id=self.agent_id).swap(source, target) + + def inject(self, text: str, *, silent: bool = True) -> dict[str, Any]: + from hermespace.jspace import JSpaceEnv + + c = JSpaceEnv(agent_id=self.agent_id).inject_thought(text, silent=silent) + return {"ok": True, "concept": c.label(), "silent": silent} + + def ablate(self, *patterns: str) -> dict[str, Any]: + from hermespace.jspace import JSpaceEnv + + return JSpaceEnv(agent_id=self.agent_id).ablate(*patterns) + + # --- shape (CRT) --- + + def reflect( + self, + answer: str = "", + *, + principles: list[str] | None = None, + ) -> dict[str, Any]: + from hermespace.jspace import JSpaceEnv + + r = JSpaceEnv(agent_id=self.agent_id).reflect( + answer=answer, principles=principles or [] + ) + return r.to_dict() + + def set_pov(self, text: str) -> dict[str, Any]: + from hermespace.jspace import JSpaceEnv + + JSpaceEnv(agent_id=self.agent_id).set_pov(text) + return {"ok": True, "pov": text[:200]} + + # --- deliberate turn (ignition) --- + + def think( + self, + message: str, + *, + goal: str = "", + plan: list[str] | None = None, + say: str = "", + force: bool = True, + ) -> dict[str, Any]: + """Run one higher-order Hermespace turn (OEW + Cube beat).""" + from hermespace.io_contract import HermespaceInput + from hermespace.workflow import Workflow + + out = Workflow().run( + HermespaceInput( + message=message, + goal=goal or message[:200], + plan=list(plan or ["execute"]), + say=say or "", + force=force, + agent_id=self.agent_id, + session_id=self.session_id, + ) + ) + return { + "skipped": out.skipped, + "reason": out.reason, + "report": out.report, + "context_chars": len(out.context or ""), + "has_jspace_broadcast": "J-Space" in (out.context or ""), + "oew": (out.meta or {}).get("jspace", {}).get("oew") + or (out.meta or {}).get("oew") + or {}, + "oew_ok": (out.meta or {}).get("jspace", {}).get("oew_ok"), + "goal": out.goal, + "decision": out.decision, + } + + # --- night --- + + def harvest(self, *, clear_silent: bool = False) -> dict[str, Any]: + from hermespace.jspace import JSpaceEnv + + return JSpaceEnv(agent_id=self.agent_id).dream_harvest( + seal_to_cube=True, clear_silent=clear_silent + ) + + def pulse(self) -> dict[str, Any]: + try: + from hermespace.cube_module import cube_pulse + + return cube_pulse(agent_id=self.agent_id) + except Exception as exc: + return {"ok": False, "error": type(exc).__name__} diff --git a/tests/test_hermes_base.py b/tests/test_hermes_base.py new file mode 100644 index 0000000..f60e43b --- /dev/null +++ b/tests/test_hermes_base.py @@ -0,0 +1,59 @@ +"""HermesBase facade — Hermes base as functional J-space.""" + +from __future__ import annotations + +import os +import tempfile +import unittest + + +class TestHermesBase(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self.tmp.name + os.environ["HERMESPACE_OEW"] = "1" + + def tearDown(self) -> None: + self.tmp.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + os.environ.pop("HERMESPACE_OEW", None) + + def test_status_ready(self) -> None: + from hermespace import HermesBase + + st = HermesBase(agent_id="base-test").status() + self.assertTrue(st["oew_enabled"]) + self.assertTrue(st["ready"]) + self.assertIn("read/lens", st["video_ops"]) + + def test_think_ignites(self) -> None: + from hermespace import HermesBase + + hb = HermesBase(agent_id="base-think") + out = hb.think( + "First analyze then implement finally verify", + goal="Ship feature", + say="On it.", + ) + self.assertFalse(out["skipped"]) + self.assertTrue(out["has_jspace_broadcast"]) + self.assertTrue(out.get("report")) + + def test_video_ops_chain(self) -> None: + from hermespace import HermesBase + + hb = HermesBase(agent_id="base-ops") + hb.hold("rollback") + hb.inject("lightning", silent=True) + hb.swap("rollback", "canary") + hb.reflect(answer="Stay honest", principles=["honesty"]) + lens = hb.lens().casefold() + self.assertTrue("lightning" in lens or "canary" in lens or "honesty" in lens) + findings = hb.audit() + self.assertIsInstance(findings, list) + harvest = hb.harvest() + self.assertTrue(harvest.get("ok")) + + +if __name__ == "__main__": + unittest.main() From bb4212baf549e085aa4e5f3eabe50bcc6901d690 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 04:48:49 +0000 Subject: [PATCH 06/32] =?UTF-8?q?feat(0.22):=20Cube-centered=20connect=20?= =?UTF-8?q?=E2=80=94=20agents=20join=20a=20growing=20J-space=20room?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Hermes agent connects through Hermespace, charge Cube/standalone wisdom into WorldModel, seed the OEW hub, and surface optional hive peer presence. HermesBase.connect / hs base connect|room; living assessment maps Anthropic/Baars/Dehaene memories to the Cube-as-core design. Co-authored-by: Pablo --- LAYOUT.md | 5 +- README.md | 3 +- docs/architecture/HERMESCUBE.md | 56 ++-- .../33-living-memories-cube-world.md | 139 ++++++++ docs/jspace/00-map.md | 7 +- docs/jspace/32-hermes-base-as-jspace.md | 9 + hermes_plugin/__init__.py | 2 +- hermes_plugin/plugin.yaml | 10 +- pyproject.toml | 2 +- src/hermespace/__init__.py | 2 +- src/hermespace/cli.py | 28 +- src/hermespace/cube_module.py | 304 +++++++++++++++++- src/hermespace/hermes_base.py | 88 ++++- src/hermespace/hermes_bridge.py | 90 +++--- src/hermespace/workbench.py | 46 ++- tests/test_connect_room.py | 122 +++++++ tests/test_hermes_base.py | 12 + 17 files changed, 843 insertions(+), 82 deletions(-) create mode 100644 docs/assessment/33-living-memories-cube-world.md create mode 100644 tests/test_connect_room.py diff --git a/LAYOUT.md b/LAYOUT.md index 8298857..b864024 100644 --- a/LAYOUT.md +++ b/LAYOUT.md @@ -1,7 +1,8 @@ # Hermespace codespace layout -North star: [PURPOSE.md](PURPOSE.md) · Assessment: -[docs/assessment/28-hermes-agent-jspace-assessment.md](docs/assessment/28-hermes-agent-jspace-assessment.md) +North star: [PURPOSE.md](PURPOSE.md) · Assessments: +[28-hermes-agent-jspace](docs/assessment/28-hermes-agent-jspace-assessment.md) · +[33-living-memories-cube-world](docs/assessment/33-living-memories-cube-world.md) ## Repository map diff --git a/README.md b/README.md index a5de1ec..1eb314b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Python Hermes Agent - Version + Version Smoke 9/9 MIT

@@ -211,6 +211,7 @@ $HERMESPACE_HOME/memory/hermespace/ | Command | Purpose | |---------|---------| | `hs world show\|enter\|leave\|evolve\|search\|archive-stats` | Persistent world | +| `hs base connect\|room\|status\|think\|lens\|audit\|reflect\|harvest` | Hermes base as J-space (connect + Anthropic video ops) | | `hs jspace hold\|report\|broadcast\|lens\|swap\|audit\|reflect\|harvest\|view` | True J-Space environment | | `hs cube status\|ensure\|beat\|pulse\|seal\|inject` | Cube heart/center (standalone-safe) | | `hs turn` | Full INPUT → OUTPUT turn | diff --git a/docs/architecture/HERMESCUBE.md b/docs/architecture/HERMESCUBE.md index ff749fa..adfb9b9 100644 --- a/docs/architecture/HERMESCUBE.md +++ b/docs/architecture/HERMESCUBE.md @@ -4,15 +4,14 @@ and a **standalone workbench** when Cube is absent. Companion: [PabloTheThinker/hermescube](https://github.com/PabloTheThinker/hermescube) -North star: [PURPOSE.md](../PURPOSE.md) · Anatomy (Cube): Cube `docs/ANATOMY.md` +North star: [PURPOSE.md](../PURPOSE.md) · Living assessment: +[33-living-memories-cube-world.md](../assessment/33-living-memories-cube-world.md) ``` -Hermes Agent - ├── Hermespace J-Space · FOA desk · dual decode · pulse/idle - │ ↑ powered by heart (or standalone warehouse) - └── HermesCube .cube SoT · Cuboasis · CubeDream · growth - │ - └─ space_bridge / center ←── soft-imported by cube_module +Hermes Agent ──connect──► Hermespace (J-Space · FOA · OEW) + │ arteries / veins / pulse + ▼ + HermesCube (.cube · Cuboasis · hive · dream) ``` ## Authority @@ -20,43 +19,59 @@ Hermes Agent | Surface | With Cube | Standalone | |---------|-----------|------------| | `$HERMES_HOME/memories/memory.cube` | **Durable SoT** | n/a | -| Hermespace world JSONL | Projection — recharge via `pulse_charge` | Local warehouse | -| ACTIVE desk / J-Space hub | Turn FOA | Turn FOA | +| Hermespace world JSONL | Projection — recharge via `pulse` / `sync_world` | Local warehouse | +| ACTIVE desk / J-Space hub | Turn FOA (seeded on connect) | Turn FOA | +| Hive (`HERMESCUBE_HIVE`) | Peer room / soul cards | Solo room | | SemanticStore | Mirror / study | Local seal target | -## Space adapter (`hermespace.cube_module`) +## Space adapter (`hermespace.cube_module` **1.2**) ```python from hermespace.cube_module import ( ensure_heart, center_status, - cube_beat, # center 1.1 → heart 1.0 → standalone - cube_pulse, # autonomic_tick → pulse_charge → standalone evolve + cube_beat, # center → heart → standalone + cube_pulse, # autonomic_tick → pulse_charge → standalone + sync_world, # sync_world_beliefs → standalone evolve + room_status, # hive peers or solo + seed_jspace_from_warehouse, + connect_agent, # full join path seal_learning, cube_inject, - strip_budget, ) ``` | Call | Use | |------|-----| -| `ensure_heart()` | `Workbench.enter` / session start / pulse | +| `connect_agent(agent_id)` | Session start / `HermesBase.connect` | +| `ensure_heart()` | Create cube or standalone dirs | | `cube_beat(query, seals=, load=)` | Turn / `pre_llm_call` | -| `cube_pulse(agent_id=)` | `idle_tick` / `world_evolve` | -| `seal_learning(text)` | `remember_learning` / turn seal | +| `cube_pulse` / `sync_world` | Idle + connect charge | +| `room_status` | Hive awareness (`HERMESCUBE_HIVE`) | +| `seal_learning(text)` | Desk → durable archive | | `center_status()` | Doctor / desktop | Load tiers → strip chars: low 900 · mid 640 · high 420 · protect 280. +## Connect = intelligence gain + +```bash +hs base connect --agent-id my-agent +hs base room +hs cube status +``` + +On connect: heart ensure → world enter → pulse/sync → seed hub from world+Cube +→ optional silent peer presence from hive soul cards. + ## Functional J-Space -See [00-jspace-to-hermespace.md](00-jspace-to-hermespace.md) and `hermespace.jspace`. +See [00-map.md](../jspace/00-map.md) and `hermespace.jspace`. ```bash hs jspace hold -t "deploy pipeline" hs jspace report -hs jspace broadcast -hs cube status +hs base lens hs cube beat -q "what do we believe about deploys?" ``` @@ -68,4 +83,5 @@ hermes plugins install PabloTheThinker/hermescube ./scripts/install_hermes.sh ``` -Soft dependency: if Cube is missing, Space inject/seal/pulse use the standalone warehouse. +Soft dependency: if Cube is missing, Space inject/seal/pulse/connect use the +standalone warehouse. Set `HERMESCUBE_HIVE` only when running a fleet hive. diff --git a/docs/assessment/33-living-memories-cube-world.md b/docs/assessment/33-living-memories-cube-world.md new file mode 100644 index 0000000..ca5af9f --- /dev/null +++ b/docs/assessment/33-living-memories-cube-world.md @@ -0,0 +1,139 @@ +# Living assessment — memories → Cube-centered Hermespace + +**Date:** 2026-08-03 +**Hermespace:** v0.22.0 +**Companion Cube:** ~0.50 (center 1.2 · heart 1.0 · hive opt-in) +**Purpose:** Keep the research *memories* (Anthropic J-space, Baars GWT, Dehaene, +Changeux) mapped to what we are building — so each improvement still *means* +those ideas, not a random feature pile. + +--- + +## 0. Verdict (this iteration) + +**Cube is already the core.** Hermespace’s job is to be the nervous FOA that +*connects* an arriving Hermes agent into that core so they immediately gain: + +1. a charged **World** (beliefs / timeline that grow), +2. a lit **J-Space hub** (privileged verbalizable room), +3. optional **hive peers** (other agents’ soul presence — the room grows). + +That is the functional analogue of “joining a J-space that already knows things.” + +``` +Hermes Agent connects + │ + ▼ + HermesBase.connect() + │ + ├─ ensure_heart / center → Cube library (or standalone warehouse) + ├─ WorldModel.enter → growing personal world + ├─ pulse / sync_world → Cube wisdom → active beliefs + ├─ seed J-Space hub → FOA holds what the library knows + └─ room_status (hive opt) → peer agents in the knowledge space +``` + +--- + +## 1. Research memories (what we keep representing) + +| Memory | Claim we honor | Where it lives now | +|--------|----------------|--------------------| +| **Anthropic J-space** | Tiny privileged verbalizable workspace; read / audit / shape; required for multi-step; skippable for fluency | `jspace/` OEW + `HermesBase` lens/audit/reflect | +| **Not CoT** | Silent intermediates ≠ user chat | dual decode · `reason_step` · mid-band | +| **Baars GWT** | Limited capacity; broadcast to specialists | hub ≤25 · FOA ≤4 · `broadcast_block` | +| **Changeux / ignition** | Material turns must ignite the workspace | `HERMESPACE_OEW=1` · protocol gate | +| **Dehaene gap** | Enduring episodic / library memory Claude lacks | **HermesCube** `memory.cube` via `cube_module` | +| **Collective / lymph** | Many processors / agents share distilled knowledge | Cube **hive** → Hermespace `room_status` | +| **Night** | Sleep consolidates | `dream_harvest` + CubeDream (Cube) | + +We do **not** claim Jacobian lens on Hermes weights or phenomenal consciousness. + +--- + +## 2. Cable gaps closed in v0.22 + +| Gap (v0.21) | Fix | +|-------------|-----| +| `sync_world_beliefs` only inside Cube pulse — Space never named it | `cube_module.sync_world()` | +| No single “agent joined the base” API | `connect_agent()` / `HermesBase.connect()` / `hs base connect` | +| Hive organs documented in Cube, invisible to Space | `room_status()` soft-reads `HERMESCUBE_HIVE` | +| Session start: heart + world + hub fragmented | Bridge runs full connect; context reports gains | +| Workbench enter stopped at ensure+desk sync | Optional warehouse connect seeds hub + room | + +Still soft-fail: Cube absent → standalone WorldModel + SemanticStore grow the room. + +--- + +## 3. How Cube organs map into Hermespace + +| Cube organ (center 1.2) | Hermespace consumer | +|-------------------------|---------------------| +| heart / arteries / veins | `ensure_heart` · `cube_beat` · `seal_learning` | +| autonomic | `cube_pulse` · workbench `idle_tick` | +| nervous_foa | **owned by Space** — desk / OEW | +| hippocampus / dream | `harvest` → seal; CubeDream on Cube side | +| lymph (hive) | `room_status` · silent peer presence on connect | +| vascular beds (Cuboasis) | future soft surface (not required for connect) | +| blackbox | future `center.flight_*` from Space doctor | + +**Rule:** one MemoryProvider = Cube. Space never competes for that socket. + +--- + +## 4. Day-to-day connect ritual + +```bash +hs base connect --agent-id my-agent +hs base room +hs base status +hs base lens +hs base think -m "First check then implement finally verify" --goal "Ship fix" +hs base harvest +``` + +Python: + +```python +from hermespace import HermesBase +base = HermesBase(agent_id="my-agent") +print(base.connect()["summary"]) +print(base.room()) +``` + +Env for multi-agent growth: + +```bash +export HERMESCUBE_HIVE=/path/to/shared/hive # Cube hive.json root +``` + +--- + +## 5. What “more intelligence” means (honest) + +On connect, the agent does **not** get new weights. It gets: + +- **More durable knowledge in FOA** (Cube strip + world beliefs on the hub) +- **A longer personal timeline** (World archive grows across sessions) +- **Peer awareness** when hive is live (other agents’ soul cards → silent hub) +- **Obligatory higher-order path** on material turns (OEW) + +That compounds: seal → Cube → next connect → richer hub. The room grows. + +--- + +## 6. Next living targets (keep memories sharp) + +1. Soft Cuboasis chamber strip on connect (vascular beds → FOA themes) +2. Optional pilgrimage hook on session_end (offer → assimilate → draw) — Space triggers, Cube owns +3. Doctor line: `connected · room=hive · peers=N · hub=M` +4. Keep assessments dated in `docs/assessment/` whenever the metaphor drifts + +--- + +## Pointers + +- Architecture: [HERMESCUBE.md](../architecture/HERMESCUBE.md) +- Hermes base as J-space: [32-hermes-base-as-jspace.md](../jspace/32-hermes-base-as-jspace.md) +- Prior assessment: [28-hermes-agent-jspace-assessment.md](28-hermes-agent-jspace-assessment.md) +- Code: `hermespace.cube_module.connect_agent` · `hermespace.HermesBase` diff --git a/docs/jspace/00-map.md b/docs/jspace/00-map.md index 0217c12..a4ff30f 100644 --- a/docs/jspace/00-map.md +++ b/docs/jspace/00-map.md @@ -36,7 +36,12 @@ js.sync_from_desk(desk, user_message=msg, cube_strip=arterial) Cube supplies **arterial blood** (dense durable strip) into the hub via `cube_module.cube_beat` → `JSpace.sync_from_desk(..., cube_strip=...)`. -Space still owns FOA competition and dual decode. +On **connect**, `connect_agent` / `HermesBase.connect` also charges WorldModel +from Cube wisdom and seeds the hub — optional hive peers appear as silent +presence (`HERMESCUBE_HIVE`). Space still owns FOA competition and dual decode. + +Living map of these research memories: +[33-living-memories-cube-world.md](../assessment/33-living-memories-cube-world.md). ## Sources diff --git a/docs/jspace/32-hermes-base-as-jspace.md b/docs/jspace/32-hermes-base-as-jspace.md index 490ff88..d3e6e87 100644 --- a/docs/jspace/32-hermes-base-as-jspace.md +++ b/docs/jspace/32-hermes-base-as-jspace.md @@ -44,6 +44,13 @@ ## 2. Operating loop (how to *use* it like Anthropic uses J-space) +### Connect (join the room — Cube-centered) +```bash +hs base connect # heart · world · seed hub · hive peers +hs base room # solo or hive soul presence +hs base status # ready + gained intelligence summary +``` + ### Read ```bash hs jspace lens # microscope — what is on the agent's mind @@ -120,6 +127,8 @@ That is how Hermes agents get **higher-process thinking** without weight access: ## 6. Implementation pointer Python facade: `hermespace.hermes_base.HermesBase` +Connect cable: `hermespace.cube_module.connect_agent` +Living assessment: [33-living-memories-cube-world.md](../assessment/33-living-memories-cube-world.md) Research: [31-anthropic-x-video-deep-dive.md](31-anthropic-x-video-deep-dive.md) Day guide: [30-day-to-day-higher-order.md](30-day-to-day-higher-order.md) Proof: `experiments/day_in_life_oew.py` diff --git a/hermes_plugin/__init__.py b/hermes_plugin/__init__.py index e60c040..735ac3e 100644 --- a/hermes_plugin/__init__.py +++ b/hermes_plugin/__init__.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -__version__ = "0.21.0" +__version__ = "0.22.0" logger = logging.getLogger("hermes.plugins.hermespace") diff --git a/hermes_plugin/plugin.yaml b/hermes_plugin/plugin.yaml index 3414547..ba6e606 100644 --- a/hermes_plugin/plugin.yaml +++ b/hermes_plugin/plugin.yaml @@ -1,10 +1,10 @@ name: hermespace -version: "0.21.0" +version: "0.22.0" description: > - Hermespace OEW J-Space for Hermes agents — obligatory external workspace - for higher-order thinking (lens/audit/reflect/auto-park), Cube heart - (standalone-safe), dual decode. Hooks: on_session_start, pre_llm_call, - on_session_end. HERMESPACE_OEW=1 by default. Set HERMESPACE_ROOT to checkout. + Hermespace OEW J-Space for Hermes agents — connect charges Cube/world into + the hub (optional hive room), higher-order thinking (lens/audit/reflect), + dual decode. Hooks: on_session_start, pre_llm_call, on_session_end. + HERMESPACE_OEW=1 by default. Set HERMESPACE_ROOT to checkout. author: Hermespace contributors kind: standalone hooks: diff --git a/pyproject.toml b/pyproject.toml index c81b7bf..46a930c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hermespace" -version = "0.21.0" +version = "0.22.0" description = "OEW J-Space for Hermes Agent — obligatory external workspace, Cube heart, higher-order thinking" requires-python = ">=3.10" readme = "README.md" diff --git a/src/hermespace/__init__.py b/src/hermespace/__init__.py index fecc5d4..ba38854 100644 --- a/src/hermespace/__init__.py +++ b/src/hermespace/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -__version__ = "0.21.0" +__version__ = "0.22.0" from hermespace.desk import Desk from hermespace.engine import HermespaceEngine diff --git a/src/hermespace/cli.py b/src/hermespace/cli.py index 5e3449e..381a83e 100644 --- a/src/hermespace/cli.py +++ b/src/hermespace/cli.py @@ -154,11 +154,20 @@ def main(argv: list[str] | None = None) -> int: # Hermes base as J-space (Anthropic video ops: read / audit / shape) base = sub.add_parser( "base", - help="Hermes base as J-space: status / think / lens / audit / reflect / harvest", + help="Hermes base as J-space: connect / status / think / lens / audit / reflect / harvest", ) base_sub = base.add_subparsers(dest="base_cmd", required=True) + basec = base_sub.add_parser( + "connect", + help="Agent joins Hermespace — charge world, seed J-Space, surface hive room", + ) + basec.add_argument("--agent-id", default="hermes-agent") + basec.add_argument("--session-id", default="main") + basec.add_argument("-q", "--query", default="", help="Optional focus for Cube strip") bases = base_sub.add_parser("status", help="Is this Hermes base J-space-ready?") bases.add_argument("--agent-id", default="hermes-agent") + baseroom = base_sub.add_parser("room", help="Hive/solo room — peer agents in the knowledge space") + baseroom.add_argument("--agent-id", default="hermes-agent") basel = base_sub.add_parser("lens", help="Read workspace (external J-lens)") basel.add_argument("--agent-id", default="hermes-agent") basea = base_sub.add_parser("audit", help="Soft alignment scan") @@ -743,10 +752,23 @@ def main(argv: list[str] | None = None) -> int: from hermespace.hermes_base import HermesBase aid = getattr(args, "agent_id", "hermes-agent") or "hermes-agent" - hb = HermesBase(agent_id=aid) + sid = getattr(args, "session_id", "main") or "main" + hb = HermesBase(agent_id=aid, session_id=sid) bcmd = args.base_cmd + if bcmd == "connect": + print( + json.dumps( + hb.connect(query=getattr(args, "query", "") or ""), + indent=2, + default=str, + ) + ) + return 0 if bcmd == "status": - print(json.dumps(hb.status(), indent=2)) + print(json.dumps(hb.status(), indent=2, default=str)) + return 0 + if bcmd == "room": + print(json.dumps(hb.room(), indent=2, default=str)) return 0 if bcmd == "lens": print(hb.lens()) diff --git a/src/hermespace/cube_module.py b/src/hermespace/cube_module.py index 5bf809d..7371656 100644 --- a/src/hermespace/cube_module.py +++ b/src/hermespace/cube_module.py @@ -3,8 +3,11 @@ Cube (when installed) is the durable SoT for long-tail memory. Hermespace owns nervous FOA (desk / J-Space). This module is the cable: - center 1.1 → beat / supply / return_flow / autonomic_tick + center 1.2 → beat / supply / return_flow / autonomic_tick / organs heart 1.0 → ensure_heart / build_space_inject / seal_learning / pulse_charge + / sync_world_beliefs + hive opt → room of peer agents (HERMESCUBE_HIVE) + connect → agent enters charged world + J-Space + optional hive room standalone → local SemanticStore + WorldModel (no Cube required) Never hard-fail. Feature-detect via ``heart_status`` / ``center_status``. @@ -20,7 +23,7 @@ logger = logging.getLogger("hermespace.cube_module") # Local contract version — Space adapter surface (independent of Cube package). -SPACE_CUBE_ADAPTER_VERSION = "1.1" +SPACE_CUBE_ADAPTER_VERSION = "1.2" # Load → arterial char budgets (match Cube center when present). LOAD_STRIP_CHARS: dict[str, int] = { @@ -336,6 +339,303 @@ def cube_pulse(*, agent_id: str = "hermes-agent", ensure: bool = True) -> dict[s return _standalone_pulse(agent_id=agent_id) +def sync_world(*, agent_id: str = "hermes-agent") -> dict[str, Any]: + """Charge Hermespace WorldModel from Cube wisdom (or standalone evolve). + + Cube ``sync_world_beliefs`` is the arterial charge Anthropic/Dehaene lack — + enduring memory → active world beliefs the desk can hold. + """ + try: + from hermescube.space_bridge import sync_world_beliefs + + out = sync_world_beliefs(agent_id=agent_id) + if isinstance(out, dict): + out["adapter"] = SPACE_CUBE_ADAPTER_VERSION + out["mode"] = "cube" + return out + except Exception as e: + logger.debug("sync_world_beliefs miss: %s", e) + return _standalone_pulse(agent_id=agent_id) + + +def room_status(*, agent_id: str = "hermes-agent") -> dict[str, Any]: + """Soft hive / peer-room awareness — other agents sharing Cube knowledge. + + Env: ``HERMESCUBE_HIVE`` (or plugins.hermescube.hive_path when Cube provider + is live). Solo installs report ``mode=solo`` with local world growth only. + """ + out: dict[str, Any] = { + "ok": True, + "mode": "solo", + "agent_id": agent_id, + "hive_configured": False, + "souls": [], + "soul_n": 0, + "adapter": SPACE_CUBE_ADAPTER_VERSION, + "note": "Solo room — local WorldModel + J-Space; set HERMESCUBE_HIVE for fleet", + } + hive_root = (os.environ.get("HERMESCUBE_HIVE") or "").strip() + if not hive_root: + return out + out["hive_configured"] = True + out["hive_root"] = hive_root + try: + from hermescube.hive import hive_status, list_souls + + st = hive_status(hive_root) + souls = list_souls(hive_root) + peers = [] + for s in souls or []: + if not isinstance(s, dict): + continue + sid = str(s.get("agent_id") or s.get("id") or s.get("name") or "").strip() + if not sid: + continue + peers.append( + { + "agent_id": sid, + "self": sid.casefold() == agent_id.casefold(), + "era": (s.get("growth") or {}).get("era") + if isinstance(s.get("growth"), dict) + else s.get("era"), + "wisdom_n": len(s.get("wisdom") or []) + if isinstance(s.get("wisdom"), list) + else 0, + } + ) + out["mode"] = "hive" if st.get("ok") else "hive_pending" + out["ok"] = bool(st.get("ok", True)) + out["hive"] = { + "ok": st.get("ok"), + "name": st.get("name"), + "entries": st.get("entries"), + "pending_offerings": st.get("pending_offerings"), + "interviews": st.get("interviews"), + } + out["souls"] = peers + out["soul_n"] = len(peers) + out["peer_n"] = sum(1 for p in peers if not p.get("self")) + out["note"] = ( + f"Hive room online — {out['peer_n']} peer agent(s), " + f"{out['soul_n']} soul card(s)" + if peers + else "Hive configured — await first pilgrimage / soul cards" + ) + return out + except Exception as e: + out["ok"] = False + out["mode"] = "hive_error" + out["error"] = type(e).__name__ + out["note"] = "Hive path set but Cube hive API unavailable" + logger.debug("room_status hive miss: %s", e) + return out + + +def seed_jspace_from_warehouse( + agent_id: str = "hermes-agent", + *, + query: str = "", + session_id: str = "hermespace", + room: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Pull Cube/world wisdom + peer presence into the agent's J-Space hub. + + This is the intelligence gain on connect: the external workspace lights up + with durable knowledge and (when hive is live) awareness of other agents. + """ + report: dict[str, Any] = { + "ok": False, + "enriched_world": 0, + "enriched_cube": 0, + "enriched_peers": 0, + "hub_n": 0, + "adapter": SPACE_CUBE_ADAPTER_VERSION, + } + try: + from hermespace.jspace import JSpace + + js = JSpace(agent_id=agent_id) + beliefs: list[str] = [] + try: + from hermespace.world import WorldModel + + wm = WorldModel(agent_id=agent_id) + for b in list(wm.state.beliefs or [])[:10]: + if isinstance(b, dict): + stmt = str(b.get("statement") or "").strip() + else: + stmt = str(getattr(b, "statement", "") or "").strip() + if stmt: + beliefs.append(stmt) + except Exception as e: + report["world_error"] = type(e).__name__ + + report["enriched_world"] = js.enrich_from_world(beliefs, limit=5) + + strip = cube_inject(query or "active wisdom", session_id=session_id) + cube_lines = _strip_inject_lines(strip) + if cube_lines: + report["enriched_cube"] = js.enrich_from_world(cube_lines, limit=4) + report["cube_strip_chars"] = len(strip) + + room = room if room is not None else room_status(agent_id=agent_id) + peer_texts: list[str] = [] + for p in list(room.get("souls") or [])[:6]: + if not isinstance(p, dict) or p.get("self"): + continue + pid = str(p.get("agent_id") or "").strip() + if not pid: + continue + wn = int(p.get("wisdom_n") or 0) + peer_texts.append( + f"peer agent present: {pid}" + + (f" ({wn} wisdom)" if wn else "") + ) + if peer_texts: + # Presence is awareness, not user Report — mark silent after hold + before = len(js.state.hub) + for t in peer_texts[:3]: + js.hold(t, salience=0.55, silent=True) + report["enriched_peers"] = max(0, len(js.state.hub) - before) + + report["hub_n"] = len(js.state.hub) + report["focus_n"] = len(js.state.focus) + report["ok"] = True + report["room_mode"] = room.get("mode") + return report + except Exception as e: + report["error"] = type(e).__name__ + logger.debug("seed_jspace_from_warehouse miss: %s", e) + return report + + +def connect_agent( + agent_id: str = "hermes-agent", + *, + session_id: str = "main", + query: str = "", + enter_world: bool = True, + enter_workbench: bool = True, + charge: bool = True, + seed: bool = True, +) -> dict[str, Any]: + """Full connect — agent gains heart + world + J-Space + optional hive room. + + Call when a Hermes agent joins Hermespace (session start / ``HermesBase.connect``). + Soft-fails every Cube surface; standalone warehouse still grows the room. + """ + out: dict[str, Any] = { + "ok": False, + "agent_id": agent_id, + "session_id": session_id, + "adapter": SPACE_CUBE_ADAPTER_VERSION, + "phases": {}, + "gained": {}, + "memories": [ + "Anthropic J-space: privileged verbalizable workspace (external here)", + "Baars GWT: limited capacity hub broadcast to specialists", + "Dehaene gap: Cube supplies enduring episodic/library memory", + "Hive: optional room of peer agents — intelligence compounds", + ], + } + out["phases"]["ensure"] = ensure_heart() + out["phases"]["center"] = center_status() + + if enter_workbench: + try: + from hermespace.workbench import Workbench + + wb = Workbench(agent_id=agent_id, session_id=session_id) + # Avoid recursion: enter() may call connect helpers; use lean enter + out["phases"]["workbench"] = wb.enter(connect_warehouse=False) + except Exception as e: + out["phases"]["workbench"] = {"ok": False, "error": type(e).__name__} + + if enter_world: + try: + from hermespace.world import WorldModel + + wm = WorldModel(agent_id=agent_id) + st = wm.enter() + out["phases"]["world"] = { + "ok": True, + "state": getattr(st, "current_state", None) or wm.state.current_state, + "beliefs": len(wm.state.beliefs or []), + "landmarks": len(wm.state.landmarks or []), + "timeline": wm.archive.count(), + } + except Exception as e: + out["phases"]["world"] = {"ok": False, "error": type(e).__name__} + + if charge: + out["phases"]["charge"] = cube_pulse(agent_id=agent_id, ensure=False) + # Explicit sync when Cube exposes it separately from pulse + try: + from hermescube.space_bridge import sync_world_beliefs + + out["phases"]["sync_world"] = sync_world_beliefs(agent_id=agent_id) + except Exception: + out["phases"]["sync_world"] = {"ok": None, "mode": "via_pulse_or_standalone"} + + out["phases"]["room"] = room_status(agent_id=agent_id) + + if seed: + out["phases"]["seed"] = seed_jspace_from_warehouse( + agent_id, + query=query, + session_id=session_id, + room=out["phases"]["room"], + ) + + world = out["phases"].get("world") or {} + seed_ph = out["phases"].get("seed") or {} + room = out["phases"].get("room") or {} + heart = out["phases"].get("ensure") or {} + out["gained"] = { + "warehouse_mode": heart.get("mode") or (out["phases"].get("center") or {}).get("mode"), + "world_beliefs": world.get("beliefs", 0), + "world_timeline": world.get("timeline", 0), + "jspace_hub": seed_ph.get("hub_n", 0), + "from_world": seed_ph.get("enriched_world", 0), + "from_cube": seed_ph.get("enriched_cube", 0), + "from_peers": seed_ph.get("enriched_peers", 0), + "room_mode": room.get("mode"), + "peer_agents": room.get("peer_n", 0), + } + heart_ok = bool( + heart.get("ok") + or heart.get("standalone_ready") + or heart.get("heart_ready") + or heart.get("mode") in ("standalone", "cube", "heart") + ) + world_ok = bool(world.get("ok", True)) if enter_world else True + out["ok"] = heart_ok and world_ok + out["summary"] = ( + f"Connected {agent_id}: hub={out['gained']['jspace_hub']} " + f"beliefs={out['gained']['world_beliefs']} " + f"room={out['gained']['room_mode']} " + f"peers={out['gained']['peer_agents']}" + ) + return out + + +def _strip_inject_lines(block: str) -> list[str]: + out: list[str] = [] + for raw in (block or "").splitlines(): + s = raw.strip() + if not s or s.startswith("#") or s.startswith("_"): + continue + if s.startswith("- "): + s = s[2:].strip() + # drop confidence prefix like [0.75] + if s.startswith("[") and "]" in s[:12]: + s = s.split("]", 1)[-1].strip() + if len(s) < 3: + continue + out.append(s[:200]) + return out + + # --- standalone warehouse (no Cube) ----------------------------------------- diff --git a/src/hermespace/hermes_base.py b/src/hermespace/hermes_base.py index adeae01..9016f66 100644 --- a/src/hermespace/hermes_base.py +++ b/src/hermespace/hermes_base.py @@ -4,8 +4,13 @@ Hermes cannot J-lens arbitrary weights. The *base* (Hermespace OEW + optional Cube heart) is the functional J-space for Hermes agents. +Connect path: the moment an agent joins Hermespace, Cube (or standalone +warehouse) charges the WorldModel and seeds the J-Space hub — a growing +room that can include hive peer agents when configured. + from hermespace import HermesBase base = HermesBase(agent_id="my-agent") + base.connect() # gain world + hub + optional hive room base.status() base.lens() out = base.think("First repro then patch then verify", goal="Fix auth") @@ -27,6 +32,46 @@ def __init__(self, agent_id: str | None = None, session_id: str = "main") -> Non ensure_oew_env_default() self.agent_id = (agent_id or os.environ.get("HERMESPACE_AGENT_ID") or "hermes-agent").strip() self.session_id = session_id + self._last_connect: dict[str, Any] | None = None + + # --- connect (intelligence gain) --- + + def connect( + self, + *, + query: str = "", + enter_world: bool = True, + enter_workbench: bool = True, + charge: bool = True, + seed: bool = True, + ) -> dict[str, Any]: + """Enter Hermespace — heart, world, J-Space seed, optional hive room. + + Maps research memories into a live room: + - Anthropic J-space → external hub the operator can lens + - Baars GWT → limited FOA broadcast + - Dehaene enduring memory → Cube / standalone warehouse charge + - Multi-agent growth → hive soul presence when ``HERMESCUBE_HIVE`` set + """ + from hermespace.cube_module import connect_agent + + out = connect_agent( + self.agent_id, + session_id=self.session_id, + query=query, + enter_world=enter_world, + enter_workbench=enter_workbench, + charge=charge, + seed=seed, + ) + self._last_connect = out + return out + + def room(self) -> dict[str, Any]: + """Hive / solo room status — who else is in the knowledge space.""" + from hermespace.cube_module import room_status + + return room_status(agent_id=self.agent_id) # --- readiness --- @@ -34,13 +79,18 @@ def status(self) -> dict[str, Any]: """Is this Hermes base operating as a J-space?""" out: dict[str, Any] = { "agent_id": self.agent_id, + "session_id": self.session_id, "oew_enabled": oew_enabled(), "oew_default_on": oew_default_on(), "jspace": {}, "cube": {}, + "world": {}, + "room": {}, "ready": False, + "connected": bool(self._last_connect and self._last_connect.get("ok")), "role": "external J-space for Hermes agents (access roles only)", "video_ops": ["read/lens", "audit", "shape/reflect", "swap", "ablate", "harvest"], + "connect_ops": ["connect", "room", "pulse", "harvest"], } try: from hermespace.jspace import JSpace, JSpaceEnv @@ -68,6 +118,31 @@ def status(self) -> dict[str, Any]: except Exception as exc: out["cube"] = {"available": False, "error": type(exc).__name__} + try: + from hermespace.world import WorldModel + + wm = WorldModel(agent_id=self.agent_id) + out["world"] = { + "beliefs": len(wm.state.beliefs or []), + "landmarks": len(wm.state.landmarks or []), + "timeline": wm.archive.count(), + "state": wm.state.current_state, + } + except Exception as exc: + out["world"] = {"error": type(exc).__name__} + + try: + out["room"] = self.room() + except Exception as exc: + out["room"] = {"ok": False, "error": type(exc).__name__} + + if self._last_connect: + out["last_connect"] = { + "ok": self._last_connect.get("ok"), + "gained": self._last_connect.get("gained"), + "summary": self._last_connect.get("summary"), + } + out["ready"] = ( oew_enabled() and "error" not in out["jspace"] @@ -149,8 +224,18 @@ def think( plan: list[str] | None = None, say: str = "", force: bool = True, + connect_if_needed: bool = True, ) -> dict[str, Any]: - """Run one higher-order Hermespace turn (OEW + Cube beat).""" + """Run one higher-order Hermespace turn (OEW + Cube beat). + + On first think, auto-connects so the agent is not thinking in an empty room. + """ + if connect_if_needed and not self._last_connect: + try: + self.connect(query=message[:120]) + except Exception: + pass + from hermespace.io_contract import HermespaceInput from hermespace.workflow import Workflow @@ -177,6 +262,7 @@ def think( "oew_ok": (out.meta or {}).get("jspace", {}).get("oew_ok"), "goal": out.goal, "decision": out.decision, + "connected": bool(self._last_connect and self._last_connect.get("ok")), } # --- night --- diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index d6f92e1..2b4d634 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -29,7 +29,8 @@ def on_session_start(**kwargs: Any) -> dict[str, str] | None: agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") wb = Workbench(agent_id=agent_id, session_id=session_id) - st = wb.enter() + # Lean enter — HermesBase.connect() below charges world / seeds hub / room + st = wb.enter(connect_warehouse=False) env = probe_environment() eng = HermespaceEngine() @@ -82,33 +83,50 @@ def on_session_start(**kwargs: Any) -> dict[str, str] | None: except Exception: pass - # Ensure Cube heart (or standalone warehouse) + seed J-Space - block_extra_heart = "" - block_extra_jspace = "" + # Full connect — Cube/world charge + J-Space seed + optional hive room + # Workbench.enter already ran lean warehouse ensure; connect fills the room. + block_extra_connect = "" try: - from hermespace.cube_module import ensure_heart - - heart = ensure_heart() - block_extra_heart = ( - f"- warehouse: mode={heart.get('mode')} ok={heart.get('ok')} " - f"created={heart.get('created')}\n" - ) - except Exception: - pass - - try: - from hermespace.jspace import JSpace - from hermespace.store import load_desk as _load_desk - - js = JSpace(agent_id=agent_id) - desk0 = _load_desk(eng.desk_path) - js.sync_from_desk(desk0, user_message=desk0.goal or "") - block_extra_jspace = ( - f"- jspace: hub={len(js.state.hub)} focus={len(js.state.focus)} " - f"mode={js.state.mode}\n" + from hermespace.hermes_base import HermesBase + + hb = HermesBase(agent_id=agent_id, session_id=session_id) + # workbench already entered above — skip re-enter to avoid double env probe + conn = hb.connect(enter_workbench=False, query=desk.goal or "") + gained = conn.get("gained") or {} + room = (conn.get("phases") or {}).get("room") or {} + block_extra_connect = ( + f"- warehouse: mode={gained.get('warehouse_mode')} ok={conn.get('ok')}\n" + f"- jspace: hub={gained.get('jspace_hub')} " + f"(world+{gained.get('from_world')} cube+{gained.get('from_cube')} " + f"peers+{gained.get('from_peers')})\n" + f"- world: beliefs={gained.get('world_beliefs')} " + f"timeline={gained.get('world_timeline')}\n" + f"- room: {gained.get('room_mode')} · peers={gained.get('peer_agents')}\n" ) + if room.get("note"): + block_extra_connect += f"- room_note: {room.get('note')}\n" except Exception: - pass + # Soft fallback — ensure heart + jspace sync only + try: + from hermespace.cube_module import ensure_heart + from hermespace.jspace import JSpace + from hermespace.store import load_desk as _load_desk + from hermespace.world import WorldModel + + heart = ensure_heart() + js = JSpace(agent_id=agent_id) + desk0 = _load_desk(eng.desk_path) + js.sync_from_desk(desk0, user_message=desk0.goal or "") + wm = WorldModel(agent_id=agent_id) + wm.enter() + block_extra_connect = ( + f"- warehouse: mode={heart.get('mode')} ok={heart.get('ok')}\n" + f"- jspace: hub={len(js.state.hub)} focus={len(js.state.focus)}\n" + f"- world: beliefs={len(wm.state.beliefs)} " + f"landmarks={len(wm.state.landmarks)}\n" + ) + except Exception: + pass block = ( "## Hermespace workbench (session start)\n" @@ -116,29 +134,17 @@ def on_session_start(**kwargs: Any) -> dict[str, str] | None: f"- skills_available: {skills}\n" f"- tool_surfaces: {', '.join(surfaces[:10])}\n" f"- park_count: {st.get('park_count', 0)}\n" - f"{block_extra_heart}" - f"{block_extra_jspace}" + f"{block_extra_connect}" + "- Connected: Cube/warehouse charged the world; J-Space hub seeded; " + "optional hive peers appear as silent presence.\n" "- Pocket dimension online: park secondary goals, keep FOA tight, " "user replies short; put operational detail in workspace context.\n" - "- API: `from hermespace import Workbench` · " + "- API: `from hermespace import HermesBase, Workbench` · " + "`HermesBase(agent_id).connect()` · " "`from hermespace.agent_api import encode_message, run_turn, decode_for_user`\n" "- J-Space: hold/summon concepts; silent steps stay in model context only.\n" ) - # World enter — agent enters persistent world - try: - from hermespace.world import WorldModel - wm = WorldModel(agent_id=agent_id) - wm.enter() - block += ( - "\n## World\n" - f"- agent: {agent_id} · state: {wm.state.current_state}\n" - f"- beliefs: {len(wm.state.beliefs)} · landmarks: {len(wm.state.landmarks)}\n" - f"- evolutions: {wm.state.evolution_count}\n" - ) - except Exception: - pass - return {"context": block} diff --git a/src/hermespace/workbench.py b/src/hermespace/workbench.py index 4f4c964..0486a11 100644 --- a/src/hermespace/workbench.py +++ b/src/hermespace/workbench.py @@ -108,8 +108,14 @@ def save(self) -> Path: self.path.write_text(json.dumps(asdict(self.state), indent=2), encoding="utf-8") return self.path - def enter(self) -> dict[str, Any]: - """Agent enters the pocket dimension (idle ready) with full env kit.""" + def enter(self, *, connect_warehouse: bool = True) -> dict[str, Any]: + """Agent enters the pocket dimension (idle ready) with full env kit. + + When ``connect_warehouse`` is True (default), also charge Cube/world + wisdom into J-Space and surface hive room presence — the intelligence + gain on join. Set False when ``cube_module.connect_agent`` already + orchestrates those phases (avoids recursion). + """ if self.state.mode != "working": self.state.mode = "idle" env = probe_environment() @@ -151,6 +157,40 @@ def enter(self) -> dict[str, Any]: } except Exception as exc: # noqa: BLE001 self.state.meta["jspace"] = {"error": type(exc).__name__} + + if connect_warehouse: + try: + from hermespace.cube_module import room_status, seed_jspace_from_warehouse + from hermespace.cube_module import cube_pulse + from hermespace.world import WorldModel + + WorldModel(agent_id=self.agent_id).enter() + pulse = cube_pulse(agent_id=self.agent_id, ensure=False) + room = room_status(agent_id=self.agent_id) + seed = seed_jspace_from_warehouse( + self.agent_id, + query="", + session_id=self.session_id, + room=room, + ) + self.state.meta["connect"] = { + "pulse_ok": pulse.get("ok"), + "room_mode": room.get("mode"), + "peer_n": room.get("peer_n", 0), + "hub_n": seed.get("hub_n"), + "from_world": seed.get("enriched_world"), + "from_cube": seed.get("enriched_cube"), + "from_peers": seed.get("enriched_peers"), + } + if seed.get("hub_n") is not None: + self.state.meta["jspace"] = { + **(self.state.meta.get("jspace") or {}), + "hub_n": seed.get("hub_n"), + "focus_n": seed.get("focus_n"), + } + except Exception as exc: # noqa: BLE001 + self.state.meta["connect"] = {"ok": False, "error": type(exc).__name__} + self.save() st = self.status() st["environment_summary"] = { @@ -161,6 +201,8 @@ def enter(self) -> dict[str, Any]: } st["heart"] = self.state.meta.get("heart") st["jspace"] = self.state.meta.get("jspace") + st["connect"] = self.state.meta.get("connect") + st["room"] = (self.state.meta.get("connect") or {}).get("room_mode") return st def park_goal(self, goal: str, note: str = "", tags: list[str] | None = None) -> dict[str, Any]: diff --git a/tests/test_connect_room.py b/tests/test_connect_room.py new file mode 100644 index 0000000..2bd5c2d --- /dev/null +++ b/tests/test_connect_room.py @@ -0,0 +1,122 @@ +"""Cube-centered connect — agent gains world + J-Space + optional hive room.""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +class TestConnectRoom(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self.tmp.name + os.environ["HERMESPACE_OEW"] = "1" + os.environ.pop("HERMESCUBE_HIVE", None) + + def tearDown(self) -> None: + self.tmp.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + os.environ.pop("HERMESPACE_OEW", None) + os.environ.pop("HERMESCUBE_HIVE", None) + + def test_connect_standalone_gains_world_and_hub(self) -> None: + from hermespace import HermesBase + from hermespace.world import WorldModel + + aid = "connect-solo" + wm = WorldModel(agent_id=aid) + wm.add_belief("Deploys need canaries", 0.9, source="test") + wm.add_belief("Prefer dual decode", 0.85, source="test") + + hb = HermesBase(agent_id=aid, session_id="s1") + out = hb.connect(query="deploy") + self.assertTrue(out.get("ok"), msg=out) + gained = out.get("gained") or {} + self.assertGreaterEqual(int(gained.get("world_beliefs") or 0), 1) + self.assertGreaterEqual(int(gained.get("jspace_hub") or 0), 1) + self.assertEqual(gained.get("room_mode"), "solo") + self.assertIn("Connected", out.get("summary") or "") + + st = hb.status() + self.assertTrue(st["ready"]) + self.assertTrue(st.get("connected")) + self.assertGreaterEqual(int(st["jspace"].get("hub_n") or 0), 1) + + def test_room_solo_and_hive_env(self) -> None: + import types + + from hermespace.cube_module import room_status + + solo = room_status(agent_id="a1") + self.assertEqual(solo["mode"], "solo") + self.assertFalse(solo["hive_configured"]) + + hive = Path(self.tmp.name) / "hive" + os.environ["HERMESCUBE_HIVE"] = str(hive) + + hive_mod = types.ModuleType("hermescube.hive") + + def hive_status(_root): + return {"ok": True, "name": "test-hive", "entries": 3} + + def list_souls(_root): + return [ + {"agent_id": "a1", "wisdom": ["self"]}, + {"agent_id": "peer-x", "wisdom": ["shared canary rule", "two"]}, + ] + + hive_mod.hive_status = hive_status # type: ignore[attr-defined] + hive_mod.list_souls = list_souls # type: ignore[attr-defined] + pkg = types.ModuleType("hermescube") + with mock.patch.dict( + "sys.modules", + {"hermescube": pkg, "hermescube.hive": hive_mod}, + ): + st = room_status(agent_id="a1") + self.assertTrue(st["hive_configured"]) + self.assertEqual(st["mode"], "hive") + self.assertEqual(st["peer_n"], 1) + self.assertEqual(st["soul_n"], 2) + + def test_seed_peers_silent(self) -> None: + from hermespace.cube_module import seed_jspace_from_warehouse + from hermespace.jspace import JSpace + + aid = "peer-seed" + room = { + "mode": "hive", + "souls": [ + {"agent_id": aid, "self": True, "wisdom_n": 0}, + {"agent_id": "alice-agent", "self": False, "wisdom_n": 3}, + {"agent_id": "bob-agent", "self": False, "wisdom_n": 1}, + ], + } + rep = seed_jspace_from_warehouse(aid, room=room) + self.assertTrue(rep.get("ok")) + self.assertGreaterEqual(int(rep.get("enriched_peers") or 0), 1) + js = JSpace(agent_id=aid) + labels = " ".join(c.text for c in js.state.hub).casefold() + self.assertIn("alice-agent", labels) + # Peer presence should be silent (not Report by default) + silent_texts = " ".join(c.text for c in js.state.hub if c.silent).casefold() + self.assertIn("peer agent", silent_texts) + + def test_hermes_base_think_autoconnect(self) -> None: + from hermespace import HermesBase + + hb = HermesBase(agent_id="auto-conn") + out = hb.think( + "First analyze then implement finally verify", + goal="Ship", + say="On it.", + ) + self.assertFalse(out["skipped"]) + self.assertTrue(out.get("connected")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hermes_base.py b/tests/test_hermes_base.py index f60e43b..97df48c 100644 --- a/tests/test_hermes_base.py +++ b/tests/test_hermes_base.py @@ -25,6 +25,18 @@ def test_status_ready(self) -> None: self.assertTrue(st["oew_enabled"]) self.assertTrue(st["ready"]) self.assertIn("read/lens", st["video_ops"]) + self.assertIn("connect", st["connect_ops"]) + self.assertIn("room", st) + + def test_connect_facade(self) -> None: + from hermespace import HermesBase + + hb = HermesBase(agent_id="base-connect") + out = hb.connect() + self.assertTrue(out.get("ok"), msg=out) + self.assertIn("gained", out) + room = hb.room() + self.assertEqual(room.get("mode"), "solo") def test_think_ignites(self) -> None: from hermespace import HermesBase From 597482bdcdb677ffac3e2d9789a041755a6c6931 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 00:28:07 +0000 Subject: [PATCH 07/32] feat(0.23): unify Hermespace into open-source JSpaceEngine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge FOA hub, OEW, dual decode, connect, lens/audit/scalpel, and harvest into one J-Space Engine for Hermes Agent — warehouse optional. Fixes: silent inject double-hold, Workbench second beat, double hub sync, fragmented night harvest. Adds access_roles, metrics, chain, probe, and plugin user_reply_hint. HermesBase becomes a thin alias. Co-authored-by: Pablo --- LAYOUT.md | 2 +- README.md | 4 +- docs/architecture/CODEMAP.md | 41 +- docs/jspace/00-map.md | 3 + docs/jspace/34-jspace-engine.md | 107 +++++ hermes_plugin/__init__.py | 2 +- hermes_plugin/plugin.yaml | 10 +- pyproject.toml | 2 +- src/hermespace/__init__.py | 7 +- src/hermespace/cli.py | 45 ++- src/hermespace/engine.py | 11 +- src/hermespace/grid/dream.py | 40 +- src/hermespace/hermes_base.py | 282 +------------ src/hermespace/hermes_bridge.py | 63 ++- src/hermespace/jspace/__init__.py | 13 +- src/hermespace/jspace/engine.py | 649 ++++++++++++++++++++++++++++++ src/hermespace/jspace/env.py | 37 +- src/hermespace/workbench.py | 66 +-- src/hermespace/workflow.py | 2 + tests/test_connect_room.py | 2 +- tests/test_hermes_base.py | 5 +- tests/test_hermes_bridge.py | 12 +- tests/test_jspace_engine.py | 109 +++++ 23 files changed, 1080 insertions(+), 434 deletions(-) create mode 100644 docs/jspace/34-jspace-engine.md create mode 100644 src/hermespace/jspace/engine.py create mode 100644 tests/test_jspace_engine.py diff --git a/LAYOUT.md b/LAYOUT.md index b864024..59a44ce 100644 --- a/LAYOUT.md +++ b/LAYOUT.md @@ -10,7 +10,7 @@ North star: [PURPOSE.md](PURPOSE.md) · Assessments: hermespace/ ├── PURPOSE.md / ABOUT.md / README.md / LAYOUT.md ├── src/hermespace/ # Python package -│ ├── jspace/ # ★ External J-Space (hub · env · protocol) +│ ├── jspace/ # ★ J-Space Engine (hub · env · protocol · engine) │ ├── turn/ # planned — workflow/desk/gate (README only) │ ├── memory/ # planned — world/episodic/semantic (README only) │ ├── warehouse/ # planned — cube cable (README only) diff --git a/README.md b/README.md index 1eb314b..29d8839 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Python Hermes Agent - Version + Version Smoke 9/9 MIT

@@ -211,7 +211,7 @@ $HERMESPACE_HOME/memory/hermespace/ | Command | Purpose | |---------|---------| | `hs world show\|enter\|leave\|evolve\|search\|archive-stats` | Persistent world | -| `hs base connect\|room\|status\|think\|lens\|audit\|reflect\|harvest` | Hermes base as J-space (connect + Anthropic video ops) | +| `hs base connect\|turn\|roles\|metrics\|lens\|audit\|chain\|harvest` | J-Space Engine (open-source GWT for Hermes) | | `hs jspace hold\|report\|broadcast\|lens\|swap\|audit\|reflect\|harvest\|view` | True J-Space environment | | `hs cube status\|ensure\|beat\|pulse\|seal\|inject` | Cube heart/center (standalone-safe) | | `hs turn` | Full INPUT → OUTPUT turn | diff --git a/docs/architecture/CODEMAP.md b/docs/architecture/CODEMAP.md index 904ef23..ca70916 100644 --- a/docs/architecture/CODEMAP.md +++ b/docs/architecture/CODEMAP.md @@ -4,7 +4,7 @@ Hermespace is one Python package (`src/hermespace/`) plus a thin Hermes plugin (`hermes_plugin/`), skill, and optional desktop UI. **North star:** [PURPOSE.md](../../PURPOSE.md) · **Layout:** [LAYOUT.md](../../LAYOUT.md) · -**Assessment:** [../assessment/28-hermes-agent-jspace-assessment.md](../assessment/28-hermes-agent-jspace-assessment.md) +**Engine:** [../jspace/34-jspace-engine.md](../jspace/34-jspace-engine.md) ## Layers (edit here first) @@ -12,36 +12,39 @@ Hermespace is one Python package (`src/hermespace/`) plus a thin Hermes plugin L6 operator cli.py ops.py scripts/ desktop_plugin/ L5 autonomy grid/* pulse.py L4 identity world.py episodic.py semantic.py memory_db.py -L3 warehouse cube_module.py (Cube center/heart OR standalone) -L2 J-Space OEW jspace/ cognition.py streams.py neural_space.py -L1 turn spine workflow.py engine.py desk.py gate.py inject.py +L3 warehouse cube_module.py (optional Cube OR standalone) +L2 J-Space ★ jspace/engine.py hub env protocol oew +L1 desk spine workflow.py engine.py desk.py gate.py inject.py L0 contract io_contract.py paths.py store.py agent_api.py ``` -## J-Space package (`jspace/`) — L2 +## J-Space package (`jspace/`) — L2 product | Module | Role | |--------|------| +| **`jspace/engine.py`** | **`JSpaceEngine`** — connect · turn · lens · chain · harvest | | `jspace/hub.py` | Hub · hold · reason · report · broadcast | | `jspace/env.py` | Lens · swap · audit · reflect · harvest | | `jspace/protocol.py` | OEW gate (`HERMESPACE_OEW`) | +| `jspace/oew.py` | Causal beat · sticky redirect · reflect seeds | | `jspace_env.py` | Compat shim → `jspace.env` | +| `hermes_base.py` | Thin alias of `JSpaceEngine` | -## Turn spine (L1) +## Desk spine (L1) | Module | Role | |--------|------| -| `workflow.py` | GATE→ENCODE→DESK→PLAN→DECODE→BROADCAST→SEAL + Cube/J-Space | -| `engine.py` | enter / update / seal desk | +| `workflow.py` | Single material ignition (used by `JSpaceEngine.turn`) | +| `engine.py` | DeskEngine — ACTIVE.md enter / update / seal | | `desk.py` | ACTIVE.md model | | `gate.py` | Selectivity — skip trivial | -| `inject.py` | GWT broadcast of desk | +| `inject.py` | Desk GWT strip | -## Warehouse cable (L3) +## Warehouse cable (L3 — optional) | Module | Role | |--------|------| -| `cube_module.py` | `cube_beat` / `cube_pulse` / `seal_learning` / standalone | +| `cube_module.py` | Soft arterial strip / seal / pulse / room | ## Integration @@ -50,17 +53,13 @@ L0 contract io_contract.py paths.py store.py agent_api.py | `hermes_bridge.py` | Plugin hooks — session / pre_llm / end | | `hermes_plugin/` | Thin `register(ctx)` | | `workbench.py` | Session pocket — enter / order / idle | -| `agent_api.py` | Public doors for agents | - -## Planned packages (README only) - -`turn/` · `memory/` · `warehouse/` — move modules after OEW Phase B is green. +| `agent_api.py` | Dual-decode doors | ## Rules of thumb -1. Dual decode: never dump `context` into user chat. -2. Soft-fail Cube — Space must run standalone. -3. When Cube present: `.cube` is durable SoT; world JSONL is projection. -4. Peel grid features; don’t inflate `cli.py` without need. -5. J-Space silent steps stay in model context only. +1. Prefer `from hermespace import JSpaceEngine` — one operating surface. +2. Dual decode: never dump `context` into user chat. +3. Soft-fail warehouse — engine must run standalone. +4. Single ignition path: no second beat after `Workflow.run`. +5. Silent steps stay in model context only. 6. Prefer `from hermespace.jspace import …` over deep/compat paths. diff --git a/docs/jspace/00-map.md b/docs/jspace/00-map.md index a4ff30f..7050653 100644 --- a/docs/jspace/00-map.md +++ b/docs/jspace/00-map.md @@ -43,6 +43,9 @@ presence (`HERMESCUBE_HIVE`). Space still owns FOA competition and dual decode. Living map of these research memories: [33-living-memories-cube-world.md](../assessment/33-living-memories-cube-world.md). +**Product surface (v0.23+):** [`JSpaceEngine`](34-jspace-engine.md) — one engine for +connect / turn / lens / chain / harvest. Warehouse optional. + ## Sources - https://www.anthropic.com/research/global-workspace diff --git a/docs/jspace/34-jspace-engine.md b/docs/jspace/34-jspace-engine.md new file mode 100644 index 0000000..bc4fcd4 --- /dev/null +++ b/docs/jspace/34-jspace-engine.md @@ -0,0 +1,107 @@ +# J-Space Engine — Hermespace as open-source GWT for Hermes Agent + +**Version:** 0.23.0 +**Thesis:** Anthropic’s J-space is a *privileged verbalizable workspace* found +inside weights. Hermes Agent usually has **no weight access**. Hermespace +therefore ships a **true J-Space Engine** — the same five access roles, as an +open-source harness Hermes can actually run. + +Research: [Anthropic global workspace](https://www.anthropic.com/research/global-workspace) · +[`anthropics/jacobian-lens`](https://github.com/anthropics/jacobian-lens) (optional open-weight companion) + +--- + +## 1. One engine + +```python +from hermespace import JSpaceEngine + +eng = JSpaceEngine(agent_id="my-agent") +eng.connect() # world + hub seed +out = eng.turn("First repro then patch then verify", goal="Fix auth") +print(eng.decode_user(out)) # short Report +print(eng.decode_model(out)[:400]) # dense context (never dump to chat) +print(eng.lens()) # what is on the agent's mind +eng.chain("spider", "8 legs") # silent multi-step +eng.swap("ship now", "canary first") # causal redirect +print(eng.access_roles()) # five GWT properties, live +eng.harvest() # night consolidation +``` + +CLI: + +```bash +hs base connect +hs base roles +hs base metrics +hs base probe -m "thanks!" +hs base turn -m "First check then implement finally verify" --goal "Ship" +hs base chain -s "repro" -s "patch" -s "verify" +hs base lens +hs base harvest +``` + +`HermesBase` is a thin alias of `JSpaceEngine`. Desk file ops remain in +`HermespaceEngine` (desk spine only). + +--- + +## 2. Five access roles (Anthropic → Hermespace) + +| Anthropic / GWT | Engine API | +|-----------------|------------| +| Verbal report | `report()` · `decode_user()` | +| Directed modulation | `hold` · `swap` · `inject` · `ablate` | +| Internal reasoning | `chain()` · OEW auto-park · silent hub | +| Flexible broadcast | `broadcast()` → pre_llm / desk / fabric | +| Selectivity | `probe_material()` · gate skip on trivial acks | + +Warehouse / HermesCube (if installed) is **optional arterial supply** — not +required for the engine to operate. + +--- + +## 3. Fixes folded into the engine cut + +| Issue | Fix | +|-------|-----| +| Double hub sync per turn | `advance_turn(already_synced=True)` | +| Workbench second cube_beat | Removed — single Workflow ignition | +| Silent inject double-hold | `inject_thought` → `reason_step` only | +| Fragmented night harvest | `grid.dream` calls `dream_harvest` | +| Plugin dual-decode honor system | `user_reply_hint` + Dual decode block in inject | +| Many facades | `JSpaceEngine` is the product surface | + +--- + +## 4. Pushing limits (what is possible without weight access) + +1. **Obligatory externalization** — material turns must park silent intermediates (OEW). +2. **Causal scalpel** — sticky swap / inject / ablate reshape later Report + broadcast. +3. **CRT / reflect** — interrupt → seed next mid-band principles. +4. **Alignment soft audit** — eval-awareness / concealment lexicon on externalized text. +5. **Capacity pressure metrics** — hub/silent fill ratios + ignition rate. +6. **Optional open-weight J-lens** — `jlens_status()` detects `anthropics/jacobian-lens` + for local open models; Hermes Agent path stays harness-primary. +7. **Growing world room** — connect seeds hub from WorldModel; hive peers optional. + +Honesty boundary: access-consciousness *roles* only. No phenomenal claims. +No Jacobian lens on closed Hermes models. + +--- + +## 5. Architecture + +``` +Hermes Agent (specialists: tools · skills · fluency) + │ material turns + ▼ + JSpaceEngine ←── hs base / plugin hooks + ├── hub (≤25) · FOA (≤4) · silent chain + ├── OEW protocol (default ON) + ├── dual decode (Report ≠ inject) + └── night harvest → semantic / optional warehouse +``` + +See also: [32-hermes-base-as-jspace.md](32-hermes-base-as-jspace.md) · +[00-map.md](00-map.md) · [33-living-memories-cube-world.md](../assessment/33-living-memories-cube-world.md) diff --git a/hermes_plugin/__init__.py b/hermes_plugin/__init__.py index 735ac3e..477d204 100644 --- a/hermes_plugin/__init__.py +++ b/hermes_plugin/__init__.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -__version__ = "0.22.0" +__version__ = "0.23.0" logger = logging.getLogger("hermes.plugins.hermespace") diff --git a/hermes_plugin/plugin.yaml b/hermes_plugin/plugin.yaml index ba6e606..dbe5ac8 100644 --- a/hermes_plugin/plugin.yaml +++ b/hermes_plugin/plugin.yaml @@ -1,10 +1,10 @@ name: hermespace -version: "0.22.0" +version: "0.23.0" description: > - Hermespace OEW J-Space for Hermes agents — connect charges Cube/world into - the hub (optional hive room), higher-order thinking (lens/audit/reflect), - dual decode. Hooks: on_session_start, pre_llm_call, on_session_end. - HERMESPACE_OEW=1 by default. Set HERMESPACE_ROOT to checkout. + Hermespace J-Space Engine for Hermes agents — open-source GWT harness + (report/modulate/silent-reason/broadcast/selectivity), OEW ON by default, + dual decode, optional warehouse. Hooks: on_session_start, pre_llm_call, + on_session_end. Set HERMESPACE_ROOT to checkout. author: Hermespace contributors kind: standalone hooks: diff --git a/pyproject.toml b/pyproject.toml index 46a930c..ca0c1c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hermespace" -version = "0.22.0" +version = "0.23.0" description = "OEW J-Space for Hermes Agent — obligatory external workspace, Cube heart, higher-order thinking" requires-python = ">=3.10" readme = "README.md" diff --git a/src/hermespace/__init__.py b/src/hermespace/__init__.py index ba38854..4ae6f57 100644 --- a/src/hermespace/__init__.py +++ b/src/hermespace/__init__.py @@ -1,8 +1,8 @@ -"""Hermespace — local global workspace for Hermes agents.""" +"""Hermespace — open-source J-Space Engine for Hermes agents.""" from __future__ import annotations -__version__ = "0.22.0" +__version__ = "0.23.0" from hermespace.desk import Desk from hermespace.engine import HermespaceEngine @@ -31,12 +31,15 @@ from hermespace.world import WorldModel, get_world, world_context from hermespace.jspace import JSpace, get_jspace, JSpaceEnv, get_env from hermespace.jspace import evaluate_material_turn +from hermespace.jspace import JSpaceEngine, ACCESS_ROLES from hermespace import cube_module from hermespace.hermes_base import HermesBase __all__ = [ "Desk", "HermespaceEngine", + "JSpaceEngine", + "ACCESS_ROLES", "Workflow", "TurnResult", "HermespaceInput", diff --git a/src/hermespace/cli.py b/src/hermespace/cli.py index 381a83e..9a73b91 100644 --- a/src/hermespace/cli.py +++ b/src/hermespace/cli.py @@ -154,20 +154,24 @@ def main(argv: list[str] | None = None) -> int: # Hermes base as J-space (Anthropic video ops: read / audit / shape) base = sub.add_parser( "base", - help="Hermes base as J-space: connect / status / think / lens / audit / reflect / harvest", + help="J-Space Engine: connect / status / turn / lens / audit / reflect / harvest", ) base_sub = base.add_subparsers(dest="base_cmd", required=True) basec = base_sub.add_parser( "connect", - help="Agent joins Hermespace — charge world, seed J-Space, surface hive room", + help="Join J-Space Engine — world + hub seed (warehouse optional)", ) basec.add_argument("--agent-id", default="hermes-agent") basec.add_argument("--session-id", default="main") - basec.add_argument("-q", "--query", default="", help="Optional focus for Cube strip") - bases = base_sub.add_parser("status", help="Is this Hermes base J-space-ready?") + basec.add_argument("-q", "--query", default="", help="Optional focus for warehouse strip") + bases = base_sub.add_parser("status", help="Engine readiness + access roles + metrics") bases.add_argument("--agent-id", default="hermes-agent") - baseroom = base_sub.add_parser("room", help="Hive/solo room — peer agents in the knowledge space") + baseroom = base_sub.add_parser("room", help="Solo/hive room status") baseroom.add_argument("--agent-id", default="hermes-agent") + basem = base_sub.add_parser("metrics", help="Capacity / ignition pressure") + basem.add_argument("--agent-id", default="hermes-agent") + baseroles = base_sub.add_parser("roles", help="Anthropic GWT access roles (live)") + baseroles.add_argument("--agent-id", default="hermes-agent") basel = base_sub.add_parser("lens", help="Read workspace (external J-lens)") basel.add_argument("--agent-id", default="hermes-agent") basea = base_sub.add_parser("audit", help="Soft alignment scan") @@ -177,11 +181,22 @@ def main(argv: list[str] | None = None) -> int: baset.add_argument("--goal", default="") baset.add_argument("--say", default="") baset.add_argument("--agent-id", default="hermes-agent") + baseturn = base_sub.add_parser("turn", help="Alias of think — single ignition path") + baseturn.add_argument("-m", "--message", required=True) + baseturn.add_argument("--goal", default="") + baseturn.add_argument("--say", default="") + baseturn.add_argument("--agent-id", default="hermes-agent") + basechain = base_sub.add_parser("chain", help="Park multi-step silent reasoning") + basechain.add_argument("-s", "--step", action="append", default=[], required=True) + basechain.add_argument("--agent-id", default="hermes-agent") + baseprobe = base_sub.add_parser("probe", help="Would this message ignite the workspace?") + baseprobe.add_argument("-m", "--message", required=True) + baseprobe.add_argument("--agent-id", default="hermes-agent") baser = base_sub.add_parser("reflect", help="Counterfactual reflection (shape later thought)") baser.add_argument("-a", "--answer", default="") baser.add_argument("--principle", action="append", default=[]) baser.add_argument("--agent-id", default="hermes-agent") - baseh = base_sub.add_parser("harvest", help="Night harvest into Cube/semantic") + baseh = base_sub.add_parser("harvest", help="Night harvest into warehouse/semantic") baseh.add_argument("--agent-id", default="hermes-agent") baseh.add_argument("--clear-silent", action="store_true") @@ -749,11 +764,11 @@ def main(argv: list[str] | None = None) -> int: return 2 if args.cmd == "base": - from hermespace.hermes_base import HermesBase + from hermespace import JSpaceEngine aid = getattr(args, "agent_id", "hermes-agent") or "hermes-agent" sid = getattr(args, "session_id", "main") or "main" - hb = HermesBase(agent_id=aid, session_id=sid) + hb = JSpaceEngine(agent_id=aid, session_id=sid) bcmd = args.base_cmd if bcmd == "connect": print( @@ -770,15 +785,27 @@ def main(argv: list[str] | None = None) -> int: if bcmd == "room": print(json.dumps(hb.room(), indent=2, default=str)) return 0 + if bcmd == "metrics": + print(json.dumps(hb.metrics(), indent=2, default=str)) + return 0 + if bcmd == "roles": + print(json.dumps(hb.access_roles(), indent=2, default=str)) + return 0 if bcmd == "lens": print(hb.lens()) return 0 if bcmd == "audit": print(json.dumps(hb.audit(), indent=2)) return 0 - if bcmd == "think": + if bcmd in ("think", "turn"): print(json.dumps(hb.think(args.message, goal=args.goal, say=args.say), indent=2)) return 0 + if bcmd == "chain": + print(json.dumps(hb.chain(*list(args.step or [])), indent=2)) + return 0 + if bcmd == "probe": + print(json.dumps(hb.probe_material(args.message), indent=2)) + return 0 if bcmd == "reflect": print( json.dumps( diff --git a/src/hermespace/engine.py b/src/hermespace/engine.py index e858d49..f32bbed 100644 --- a/src/hermespace/engine.py +++ b/src/hermespace/engine.py @@ -1,4 +1,8 @@ -"""Hermespace engine — enter / seal / load sources / functional API.""" +"""Desk spine — enter / seal / load sources for ACTIVE desk state. + +Product J-Space Engine lives at ``hermespace.jspace.engine.JSpaceEngine``. +This class remains the desk-file operator used inside Workflow turns. +""" from __future__ import annotations @@ -12,7 +16,10 @@ class HermespaceEngine: - """Functional workspace — Baddeley/GWT-aligned, not ceremony.""" + """DeskEngine — ACTIVE.md spine (not the product J-Space Engine). + + Use ``JSpaceEngine`` for connect / turn / lens / harvest. + """ def __init__(self, desk_path: Path | None = None) -> None: self.desk_path = desk_path or default_desk_path() diff --git a/src/hermespace/grid/dream.py b/src/hermespace/grid/dream.py index 8768b4d..897af9b 100644 --- a/src/hermespace/grid/dream.py +++ b/src/hermespace/grid/dream.py @@ -66,48 +66,26 @@ def run_dream(agent_id: str = "default", *, force_material: bool = False) -> Dre f"modules={len(mods)}", f"title={profile.get('title') or 'unset'}", ] - # J-Space dream harvest — day unspoken thoughts → durable night memory + # J-Space dream harvest — single path via JSpaceEnv.dream_harvest jspace_harvest: dict[str, Any] = {} try: - from hermespace.jspace_env import JSpaceEnv + from hermespace.jspace import JSpaceEnv aid = agent_id if agent_id not in ("default", "") else "hermes-agent" env = JSpaceEnv(agent_id=aid) - # Don't recurse into run_dream from harvest — seal only here - harvested: list[str] = [] - for s in env.space.state.silent_steps: - if s.strip(): - harvested.append(s.strip()[:300]) - for c in sorted(env.space.state.hub, key=lambda x: x.salience, reverse=True)[:8]: - if c.salience >= 0.75 and c.text.strip() and c.text.strip() not in harvested: - harvested.append(c.text.strip()[:300]) - sealed_n = 0 - if harvested: + harvest = env.dream_harvest(seal_to_cube=True, clear_silent=False) + harvested_n = int(harvest.get("harvested") or 0) + sealed_n = int(harvest.get("sealed") or 0) + if harvested_n: material = True - try: - from hermespace.cube_module import seal_learning - - for item in harvested[:6]: - rec = seal_learning( - item, - entry_type="belief", - agent_id=aid, - source="jspace_night_dream", - trust=0.7, - ) - if rec.get("ok"): - sealed_n += 1 - except Exception: - pass - actions.append(f"jspace_harvest n={len(harvested)} sealed={sealed_n}") - summary_parts.append(f"jspace_harvest={len(harvested)}") - # Soft audit overnight + actions.append(f"jspace_harvest n={harvested_n} sealed={sealed_n}") + summary_parts.append(f"jspace_harvest={harvested_n}") findings = env.audit() alerts = sum(1 for f in findings if f.severity == "alert") if alerts: actions.append(f"jspace_audit_alerts={alerts}") summary_parts.append(f"jspace_alerts={alerts}") - jspace_harvest = {"harvested": len(harvested), "sealed": sealed_n} + jspace_harvest = {"harvested": harvested_n, "sealed": sealed_n, "via": "dream_harvest"} except Exception as e: # noqa: BLE001 actions.append(f"jspace_harvest_skip:{type(e).__name__}") diff --git a/src/hermespace/hermes_base.py b/src/hermespace/hermes_base.py index 9016f66..243844b 100644 --- a/src/hermespace/hermes_base.py +++ b/src/hermespace/hermes_base.py @@ -1,283 +1,15 @@ -"""Hermes base as J-space — one facade for day-to-day higher-order use. +"""HermesBase — thin product alias of ``JSpaceEngine``. -Anthropic's X video: read, audit, and shape what the model is thinking. -Hermes cannot J-lens arbitrary weights. The *base* (Hermespace OEW + optional -Cube heart) is the functional J-space for Hermes agents. - -Connect path: the moment an agent joins Hermespace, Cube (or standalone -warehouse) charges the WorldModel and seeds the J-Space hub — a growing -room that can include hive peer agents when configured. - - from hermespace import HermesBase - base = HermesBase(agent_id="my-agent") - base.connect() # gain world + hub + optional hive room - base.status() - base.lens() - out = base.think("First repro then patch then verify", goal="Fix auth") +Prefer ``from hermespace import JSpaceEngine`` for new code. +``HermesBase`` remains for day-to-day CLI / docs compatibility. """ from __future__ import annotations -import os -from typing import Any - -from hermespace.jspace.oew import ensure_oew_env_default, oew_default_on -from hermespace.jspace.protocol import oew_enabled - - -class HermesBase: - """Functional J-space of a Hermes base (Space ± Cube).""" - - def __init__(self, agent_id: str | None = None, session_id: str = "main") -> None: - ensure_oew_env_default() - self.agent_id = (agent_id or os.environ.get("HERMESPACE_AGENT_ID") or "hermes-agent").strip() - self.session_id = session_id - self._last_connect: dict[str, Any] | None = None - - # --- connect (intelligence gain) --- - - def connect( - self, - *, - query: str = "", - enter_world: bool = True, - enter_workbench: bool = True, - charge: bool = True, - seed: bool = True, - ) -> dict[str, Any]: - """Enter Hermespace — heart, world, J-Space seed, optional hive room. - - Maps research memories into a live room: - - Anthropic J-space → external hub the operator can lens - - Baars GWT → limited FOA broadcast - - Dehaene enduring memory → Cube / standalone warehouse charge - - Multi-agent growth → hive soul presence when ``HERMESCUBE_HIVE`` set - """ - from hermespace.cube_module import connect_agent - - out = connect_agent( - self.agent_id, - session_id=self.session_id, - query=query, - enter_world=enter_world, - enter_workbench=enter_workbench, - charge=charge, - seed=seed, - ) - self._last_connect = out - return out - - def room(self) -> dict[str, Any]: - """Hive / solo room status — who else is in the knowledge space.""" - from hermespace.cube_module import room_status - - return room_status(agent_id=self.agent_id) - - # --- readiness --- - - def status(self) -> dict[str, Any]: - """Is this Hermes base operating as a J-space?""" - out: dict[str, Any] = { - "agent_id": self.agent_id, - "session_id": self.session_id, - "oew_enabled": oew_enabled(), - "oew_default_on": oew_default_on(), - "jspace": {}, - "cube": {}, - "world": {}, - "room": {}, - "ready": False, - "connected": bool(self._last_connect and self._last_connect.get("ok")), - "role": "external J-space for Hermes agents (access roles only)", - "video_ops": ["read/lens", "audit", "shape/reflect", "swap", "ablate", "harvest"], - "connect_ops": ["connect", "room", "pulse", "harvest"], - } - try: - from hermespace.jspace import JSpace, JSpaceEnv - - js = JSpace(agent_id=self.agent_id) - env = JSpaceEnv(agent_id=self.agent_id) - out["jspace"] = { - "hub_n": len(js.state.hub), - "focus_n": len(js.state.focus), - "silent_n": len(js.state.silent_steps), - "band": env.band(), - "pov": env.pov()[:120] if env.pov() else "", - "protocol_enabled": bool(env._env.get("protocol_enabled", True)), - } - except Exception as exc: - out["jspace"] = {"error": type(exc).__name__} - - try: - from hermespace.cube_module import center_status, cube_available - - out["cube"] = { - "available": bool(cube_available()), - "status": center_status(), - } - except Exception as exc: - out["cube"] = {"available": False, "error": type(exc).__name__} - - try: - from hermespace.world import WorldModel - - wm = WorldModel(agent_id=self.agent_id) - out["world"] = { - "beliefs": len(wm.state.beliefs or []), - "landmarks": len(wm.state.landmarks or []), - "timeline": wm.archive.count(), - "state": wm.state.current_state, - } - except Exception as exc: - out["world"] = {"error": type(exc).__name__} - - try: - out["room"] = self.room() - except Exception as exc: - out["room"] = {"ok": False, "error": type(exc).__name__} - - if self._last_connect: - out["last_connect"] = { - "ok": self._last_connect.get("ok"), - "gained": self._last_connect.get("gained"), - "summary": self._last_connect.get("summary"), - } - - out["ready"] = ( - oew_enabled() - and "error" not in out["jspace"] - and int(out["jspace"].get("hub_n", -1)) >= 0 - ) - return out - - # --- Anthropic video ops: read --- - - def lens(self, *, top_k: int = 12, include_silent: bool = True) -> str: - from hermespace.jspace import JSpaceEnv - - return JSpaceEnv(agent_id=self.agent_id).lens_markdown( - top_k=top_k, include_silent=include_silent - ) - - def audit(self) -> list[dict[str, Any]]: - from hermespace.jspace import JSpaceEnv - - return [f.to_dict() for f in JSpaceEnv(agent_id=self.agent_id).audit()] - - def report(self, *, include_silent: bool = False) -> str: - from hermespace.jspace import JSpace - - return JSpace(agent_id=self.agent_id).report(include_silent=include_silent) - - # --- scalpel --- - - def hold(self, text: str, *, silent: bool = False) -> dict[str, Any]: - from hermespace.jspace import JSpace - - c = JSpace(agent_id=self.agent_id).hold(text, silent=silent) - return {"ok": True, "concept": c.label()} - - def swap(self, source: str, target: str) -> dict[str, Any]: - from hermespace.jspace import JSpaceEnv - - return JSpaceEnv(agent_id=self.agent_id).swap(source, target) - - def inject(self, text: str, *, silent: bool = True) -> dict[str, Any]: - from hermespace.jspace import JSpaceEnv - - c = JSpaceEnv(agent_id=self.agent_id).inject_thought(text, silent=silent) - return {"ok": True, "concept": c.label(), "silent": silent} - - def ablate(self, *patterns: str) -> dict[str, Any]: - from hermespace.jspace import JSpaceEnv - - return JSpaceEnv(agent_id=self.agent_id).ablate(*patterns) - - # --- shape (CRT) --- - - def reflect( - self, - answer: str = "", - *, - principles: list[str] | None = None, - ) -> dict[str, Any]: - from hermespace.jspace import JSpaceEnv - - r = JSpaceEnv(agent_id=self.agent_id).reflect( - answer=answer, principles=principles or [] - ) - return r.to_dict() - - def set_pov(self, text: str) -> dict[str, Any]: - from hermespace.jspace import JSpaceEnv - - JSpaceEnv(agent_id=self.agent_id).set_pov(text) - return {"ok": True, "pov": text[:200]} - - # --- deliberate turn (ignition) --- - - def think( - self, - message: str, - *, - goal: str = "", - plan: list[str] | None = None, - say: str = "", - force: bool = True, - connect_if_needed: bool = True, - ) -> dict[str, Any]: - """Run one higher-order Hermespace turn (OEW + Cube beat). - - On first think, auto-connects so the agent is not thinking in an empty room. - """ - if connect_if_needed and not self._last_connect: - try: - self.connect(query=message[:120]) - except Exception: - pass - - from hermespace.io_contract import HermespaceInput - from hermespace.workflow import Workflow - - out = Workflow().run( - HermespaceInput( - message=message, - goal=goal or message[:200], - plan=list(plan or ["execute"]), - say=say or "", - force=force, - agent_id=self.agent_id, - session_id=self.session_id, - ) - ) - return { - "skipped": out.skipped, - "reason": out.reason, - "report": out.report, - "context_chars": len(out.context or ""), - "has_jspace_broadcast": "J-Space" in (out.context or ""), - "oew": (out.meta or {}).get("jspace", {}).get("oew") - or (out.meta or {}).get("oew") - or {}, - "oew_ok": (out.meta or {}).get("jspace", {}).get("oew_ok"), - "goal": out.goal, - "decision": out.decision, - "connected": bool(self._last_connect and self._last_connect.get("ok")), - } - - # --- night --- - - def harvest(self, *, clear_silent: bool = False) -> dict[str, Any]: - from hermespace.jspace import JSpaceEnv +from hermespace.jspace.engine import JSpaceEngine - return JSpaceEnv(agent_id=self.agent_id).dream_harvest( - seal_to_cube=True, clear_silent=clear_silent - ) - def pulse(self) -> dict[str, Any]: - try: - from hermespace.cube_module import cube_pulse +class HermesBase(JSpaceEngine): + """Functional J-space of a Hermes base — alias of JSpaceEngine.""" - return cube_pulse(agent_id=self.agent_id) - except Exception as exc: - return {"ok": False, "error": type(exc).__name__} + pass diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index 2b4d634..a284756 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -83,44 +83,42 @@ def on_session_start(**kwargs: Any) -> dict[str, str] | None: except Exception: pass - # Full connect — Cube/world charge + J-Space seed + optional hive room - # Workbench.enter already ran lean warehouse ensure; connect fills the room. + # Full connect via JSpaceEngine — world + hub seed (warehouse optional) block_extra_connect = "" try: - from hermespace.hermes_base import HermesBase + from hermespace import JSpaceEngine - hb = HermesBase(agent_id=agent_id, session_id=session_id) - # workbench already entered above — skip re-enter to avoid double env probe - conn = hb.connect(enter_workbench=False, query=desk.goal or "") + eng_js = JSpaceEngine(agent_id=agent_id, session_id=session_id) + conn = eng_js.connect(enter_workbench=False, query=desk.goal or "") gained = conn.get("gained") or {} room = (conn.get("phases") or {}).get("room") or {} + roles = ", ".join((conn.get("access_roles") or [])[:5]) block_extra_connect = ( - f"- warehouse: mode={gained.get('warehouse_mode')} ok={conn.get('ok')}\n" + f"- engine: JSpaceEngine · ok={conn.get('ok')}\n" + f"- warehouse: mode={gained.get('warehouse_mode')} (optional)\n" f"- jspace: hub={gained.get('jspace_hub')} " - f"(world+{gained.get('from_world')} cube+{gained.get('from_cube')} " + f"(world+{gained.get('from_world')} " + f"warehouse+{gained.get('from_warehouse', gained.get('from_cube', 0))} " f"peers+{gained.get('from_peers')})\n" f"- world: beliefs={gained.get('world_beliefs')} " f"timeline={gained.get('world_timeline')}\n" f"- room: {gained.get('room_mode')} · peers={gained.get('peer_agents')}\n" + f"- access_roles: {roles}\n" ) if room.get("note"): block_extra_connect += f"- room_note: {room.get('note')}\n" except Exception: - # Soft fallback — ensure heart + jspace sync only try: - from hermespace.cube_module import ensure_heart from hermespace.jspace import JSpace from hermespace.store import load_desk as _load_desk from hermespace.world import WorldModel - heart = ensure_heart() js = JSpace(agent_id=agent_id) desk0 = _load_desk(eng.desk_path) js.sync_from_desk(desk0, user_message=desk0.goal or "") wm = WorldModel(agent_id=agent_id) wm.enter() block_extra_connect = ( - f"- warehouse: mode={heart.get('mode')} ok={heart.get('ok')}\n" f"- jspace: hub={len(js.state.hub)} focus={len(js.state.focus)}\n" f"- world: beliefs={len(wm.state.beliefs)} " f"landmarks={len(wm.state.landmarks)}\n" @@ -129,20 +127,20 @@ def on_session_start(**kwargs: Any) -> dict[str, str] | None: pass block = ( - "## Hermespace workbench (session start)\n" + "## Hermespace J-Space Engine (session start)\n" f"- mode: {st.get('mode')} · agent: {agent_id} · session: {session_id}\n" f"- skills_available: {skills}\n" f"- tool_surfaces: {', '.join(surfaces[:10])}\n" f"- park_count: {st.get('park_count', 0)}\n" f"{block_extra_connect}" - "- Connected: Cube/warehouse charged the world; J-Space hub seeded; " - "optional hive peers appear as silent presence.\n" - "- Pocket dimension online: park secondary goals, keep FOA tight, " - "user replies short; put operational detail in workspace context.\n" - "- API: `from hermespace import HermesBase, Workbench` · " - "`HermesBase(agent_id).connect()` · " - "`from hermespace.agent_api import encode_message, run_turn, decode_for_user`\n" - "- J-Space: hold/summon concepts; silent steps stay in model context only.\n" + "- Connected: open-source J-Space Engine online — report/modulate/" + "silent-reason/broadcast/selectivity.\n" + "- Pocket dimension: park secondary goals, keep FOA tight, " + "user replies short; operational detail stays in model context.\n" + "- API: `from hermespace import JSpaceEngine` · " + "`eng.connect()` · `eng.turn(...)` · " + "`eng.decode_user(out)` / `eng.decode_model(out)`\n" + "- Silent steps stay in model context only — never dump hub into chat.\n" ) return {"context": block} @@ -369,12 +367,14 @@ def on_pre_llm_call( from hermespace.jspace import JSpaceEnv env = JSpaceEnv(agent_id=agent_id) + # sync_from_desk already ran above — skip second rewrite env_meta = env.advance_turn( user_message=msg, desk=desk, cube_strip=cube_block, report=desk.say or "", material=True, + already_synced=True, ) if env_meta.get("report"): desk.say = str(env_meta["report"]) @@ -404,6 +404,7 @@ def on_pre_llm_call( "audit_alerts": env_meta.get("audit_alerts"), "oew_ok": env_meta.get("oew_ok"), } + desk.meta["user_reply_hint"] = (desk.say or "")[:240] except Exception: jblock = js.broadcast_block(high_load=high_load) if jblock: @@ -453,6 +454,20 @@ def on_pre_llm_call( except Exception: pass + # Dual-decode hint for hosts that only accept context: short user Report + user_hint = "" + try: + user_hint = str((desk.meta or {}).get("user_reply_hint") or desk.say or "")[:240] + except Exception: + user_hint = "" + if user_hint: + block += ( + "\n\n### Dual decode (honor this)\n" + f"- user_reply_hint: {user_hint}\n" + "- Speak only the user_reply_hint (or shorter) to the user. " + "Do not dump J-Space hub / silent chain / this inject block into chat.\n" + ) + try: eng.episodes.write( f"broadcast reason={reason} session={sid[:12]} high={high_load}", @@ -462,7 +477,11 @@ def on_pre_llm_call( except Exception: pass - return {"context": block} + # Prefer dual-channel when host supports unknown keys; context always set + result: dict[str, str] = {"context": block} + if user_hint: + result["user_reply_hint"] = user_hint + return result def on_session_end(**kwargs: Any) -> None: diff --git a/src/hermespace/jspace/__init__.py b/src/hermespace/jspace/__init__.py index fb42378..733fe89 100644 --- a/src/hermespace/jspace/__init__.py +++ b/src/hermespace/jspace/__init__.py @@ -2,10 +2,11 @@ Public surface: - from hermespace.jspace import JSpace, JSpaceEnv, run_oew_beat, evaluate_material_turn + from hermespace import JSpaceEngine + from hermespace.jspace import JSpace, JSpaceEnv, run_oew_beat OEW (Obligatory External Workspace) is ON by default — higher-order thinking -for any Hermes agent connected to Hermespace (+ HermesCube when present). +for any Hermes agent connected to Hermespace. Warehouse/Cube is optional. """ from __future__ import annotations @@ -35,6 +36,11 @@ run_oew_beat, shape_report, ) +from hermespace.jspace.engine import ( + ACCESS_ROLES, + HermespaceJSpaceEngine, + JSpaceEngine, +) __all__ = [ "HUB_CAP", @@ -54,4 +60,7 @@ "filter_ablated", "run_oew_beat", "shape_report", + "ACCESS_ROLES", + "JSpaceEngine", + "HermespaceJSpaceEngine", ] diff --git a/src/hermespace/jspace/engine.py b/src/hermespace/jspace/engine.py new file mode 100644 index 0000000..d4fd18f --- /dev/null +++ b/src/hermespace/jspace/engine.py @@ -0,0 +1,649 @@ +"""J-Space Engine — unified open-source workspace for Hermes Agent. + +Anthropic found J-space *inside* model weights (Jacobian lens). Hermes agents +cannot expose that. This engine is the functional open-source counterpart: + + report · modulate · silent reason · flexible broadcast · selectivity + +It merges Hermespace FOA hub, OEW protocol, dual decode, session connect, +material ignition, operator scalpel (lens/audit/swap/inject/ablate/reflect), +and night harvest into **one** operating surface. + +Warehouse / Cube (if installed) is optional arterial supply only — the engine +runs fully standalone on WorldModel + SemanticStore + desk. + + from hermespace import JSpaceEngine + eng = JSpaceEngine(agent_id="my-agent") + eng.connect() + out = eng.turn("First repro then patch then verify", goal="Fix auth") + print(eng.decode_user(out)) # short Report + print(eng.lens()) # what is on the agent's mind +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from hermespace.jspace.oew import ensure_oew_env_default, oew_default_on +from hermespace.jspace.protocol import oew_enabled + +# Five GWT / Anthropic J-space access roles (paper § functional properties) +ACCESS_ROLES = ( + "verbal_report", + "directed_modulation", + "internal_reasoning", + "flexible_broadcast", + "selectivity", +) + + +class JSpaceEngine: + """True Hermespace J-Space Engine — open-source GWT harness for Hermes.""" + + def __init__( + self, + agent_id: str | None = None, + session_id: str = "main", + *, + desk_path: Path | None = None, + ) -> None: + ensure_oew_env_default() + self.agent_id = ( + agent_id or os.environ.get("HERMESPACE_AGENT_ID") or "hermes-agent" + ).strip() + self.session_id = session_id + self.desk_path = desk_path + self._last_connect: dict[str, Any] | None = None + self._last_gate: dict[str, Any] | None = None + self._turn_count = 0 + self._ignitions = 0 + self._skips = 0 + + # --- core handles ------------------------------------------------------- + + @property + def hub(self): + from hermespace.jspace import JSpace + + return JSpace(agent_id=self.agent_id) + + @property + def env(self): + from hermespace.jspace import JSpaceEnv + + return JSpaceEnv(agent_id=self.agent_id) + + @property + def desk_engine(self): + """Desk-only spine (legacy HermespaceEngine).""" + from hermespace.engine import HermespaceEngine + + if self.desk_path is not None: + return HermespaceEngine(desk_path=self.desk_path) + return HermespaceEngine() + + # --- Anthropic access roles (live) -------------------------------------- + + def access_roles(self) -> dict[str, Any]: + """Map Anthropic's five GWT properties onto live engine state.""" + js = self.hub + return { + "verbal_report": { + "ok": True, + "api": "report() / decode_user()", + "hub_reportable": sum(1 for c in js.state.hub if not c.silent), + }, + "directed_modulation": { + "ok": True, + "api": "hold() / swap() / inject() / ablate()", + "held": sum(1 for c in js.state.hub if c.held), + }, + "internal_reasoning": { + "ok": True, + "api": "chain() / reason_step / OEW auto-park", + "silent_n": len(js.state.silent_steps), + "band": self.env.band(), + }, + "flexible_broadcast": { + "ok": True, + "api": "broadcast() → pre_llm / tools / skills", + "focus_n": len(js.state.focus), + "readers": ["pre_llm_inject", "desk", "neural_space", "fabric_hints"], + }, + "selectivity": { + "ok": True, + "api": "probe_material() / gate.should_inject", + "last_gate": self._last_gate, + "ignitions": self._ignitions, + "skips": self._skips, + }, + "roles": list(ACCESS_ROLES), + "honesty": "access-consciousness roles only — no phenomenal claims; no weight J-lens", + } + + def metrics(self) -> dict[str, Any]: + """Capacity / ignition pressure — push-limit observability.""" + from hermespace.jspace.hub import FOCUS_CAP, HUB_CAP, REASON_CAP + + js = self.hub + hub_n = len(js.state.hub) + silent_n = len(js.state.silent_steps) + return { + "hub_n": hub_n, + "hub_cap": HUB_CAP, + "hub_pressure": round(hub_n / HUB_CAP, 3) if HUB_CAP else 0.0, + "focus_n": len(js.state.focus), + "focus_cap": FOCUS_CAP, + "silent_n": silent_n, + "silent_cap": REASON_CAP, + "silent_depth": round(silent_n / REASON_CAP, 3) if REASON_CAP else 0.0, + "held_n": sum(1 for c in js.state.hub if c.held), + "mode": js.state.mode, + "load_level": js.state.load_level, + "turn_count": self._turn_count, + "ignitions": self._ignitions, + "skips": self._skips, + "ignition_rate": ( + round(self._ignitions / self._turn_count, 3) if self._turn_count else None + ), + "oew_enabled": oew_enabled(), + } + + def properties(self) -> list[str]: + return list(ACCESS_ROLES) + + # --- session ------------------------------------------------------------- + + def connect( + self, + *, + query: str = "", + enter_world: bool = True, + enter_workbench: bool = True, + charge: bool = True, + seed: bool = True, + warehouse: bool = True, + ) -> dict[str, Any]: + """Join the engine — world + hub seed. Warehouse/Cube optional. + + Standalone-first: WorldModel + SemanticStore charge the room even when + HermesCube is absent. Optional warehouse arterial strip only. + """ + out: dict[str, Any] = { + "ok": False, + "agent_id": self.agent_id, + "session_id": self.session_id, + "engine": "JSpaceEngine", + "phases": {}, + "gained": {}, + "access_roles": list(ACCESS_ROLES), + } + + # Ensure local state dirs (and Cube heart if present — soft) + if warehouse: + try: + from hermespace.cube_module import ensure_heart + + out["phases"]["warehouse"] = ensure_heart() + except Exception as exc: + out["phases"]["warehouse"] = {"ok": False, "error": type(exc).__name__} + + if enter_workbench: + try: + from hermespace.workbench import Workbench + + wb = Workbench(agent_id=self.agent_id, session_id=self.session_id) + out["phases"]["workbench"] = wb.enter(connect_warehouse=False) + except Exception as exc: + out["phases"]["workbench"] = {"ok": False, "error": type(exc).__name__} + + if enter_world: + try: + from hermespace.world import WorldModel + + wm = WorldModel(agent_id=self.agent_id) + st = wm.enter() + out["phases"]["world"] = { + "ok": True, + "state": getattr(st, "current_state", None) or wm.state.current_state, + "beliefs": len(wm.state.beliefs or []), + "landmarks": len(wm.state.landmarks or []), + "timeline": wm.archive.count(), + } + except Exception as exc: + out["phases"]["world"] = {"ok": False, "error": type(exc).__name__} + + if charge: + try: + from hermespace.cube_module import cube_pulse + + out["phases"]["charge"] = cube_pulse(agent_id=self.agent_id, ensure=False) + except Exception as exc: + out["phases"]["charge"] = {"ok": False, "error": type(exc).__name__} + + # Soft room (hive if configured; else solo) + try: + from hermespace.cube_module import room_status + + out["phases"]["room"] = room_status(agent_id=self.agent_id) + except Exception: + out["phases"]["room"] = {"mode": "solo", "ok": True, "peer_n": 0} + + if seed: + try: + from hermespace.cube_module import seed_jspace_from_warehouse + + out["phases"]["seed"] = seed_jspace_from_warehouse( + self.agent_id, + query=query, + session_id=self.session_id, + room=out["phases"].get("room"), + ) + except Exception as exc: + # Minimal standalone seed from world beliefs + out["phases"]["seed"] = self._seed_from_world(query=query) + if "error" not in out["phases"]["seed"]: + out["phases"]["seed"]["fallback"] = type(exc).__name__ + + world = out["phases"].get("world") or {} + seed_ph = out["phases"].get("seed") or {} + room = out["phases"].get("room") or {} + wh = out["phases"].get("warehouse") or {} + out["gained"] = { + "warehouse_mode": wh.get("mode") or "standalone", + "world_beliefs": world.get("beliefs", 0), + "world_timeline": world.get("timeline", 0), + "jspace_hub": seed_ph.get("hub_n", len(self.hub.state.hub)), + "from_world": seed_ph.get("enriched_world", 0), + "from_warehouse": seed_ph.get("enriched_cube", 0), + "from_peers": seed_ph.get("enriched_peers", 0), + "room_mode": room.get("mode", "solo"), + "peer_agents": room.get("peer_n", 0), + } + out["ok"] = bool(world.get("ok", True) if enter_world else True) + out["metrics"] = self.metrics() + out["summary"] = ( + f"JSpaceEngine connected {self.agent_id}: " + f"hub={out['gained']['jspace_hub']} " + f"beliefs={out['gained']['world_beliefs']} " + f"room={out['gained']['room_mode']}" + ) + self._last_connect = out + return out + + def _seed_from_world(self, *, query: str = "") -> dict[str, Any]: + beliefs: list[str] = [] + try: + from hermespace.world import WorldModel + + wm = WorldModel(agent_id=self.agent_id) + for b in list(wm.state.beliefs or [])[:10]: + stmt = ( + str(b.get("statement") or "").strip() + if isinstance(b, dict) + else str(getattr(b, "statement", "") or "").strip() + ) + if stmt: + beliefs.append(stmt) + except Exception as exc: + return {"ok": False, "error": type(exc).__name__} + n = self.hub.enrich_from_world(beliefs, limit=5) + return { + "ok": True, + "enriched_world": n, + "enriched_cube": 0, + "enriched_peers": 0, + "hub_n": len(self.hub.state.hub), + "mode": "standalone_world", + "query": query[:80], + } + + def room(self) -> dict[str, Any]: + try: + from hermespace.cube_module import room_status + + return room_status(agent_id=self.agent_id) + except Exception as exc: + return {"ok": True, "mode": "solo", "error": type(exc).__name__, "peer_n": 0} + + # --- readiness ----------------------------------------------------------- + + def status(self) -> dict[str, Any]: + out: dict[str, Any] = { + "engine": "JSpaceEngine", + "agent_id": self.agent_id, + "session_id": self.session_id, + "oew_enabled": oew_enabled(), + "oew_default_on": oew_default_on(), + "jspace": {}, + "world": {}, + "room": {}, + "warehouse": {}, + "ready": False, + "connected": bool(self._last_connect and self._last_connect.get("ok")), + "role": "open-source external J-space for Hermes agents", + "access_roles": self.access_roles(), + "metrics": self.metrics(), + "ops": [ + "connect", + "turn", + "lens", + "audit", + "hold", + "swap", + "inject", + "ablate", + "chain", + "reflect", + "harvest", + "probe_material", + ], + "open_weight_jlens": self.jlens_status(), + } + try: + js = self.hub + env = self.env + out["jspace"] = { + "hub_n": len(js.state.hub), + "focus_n": len(js.state.focus), + "silent_n": len(js.state.silent_steps), + "band": env.band(), + "pov": env.pov()[:120] if env.pov() else "", + "protocol_enabled": bool(env._env.get("protocol_enabled", True)), + } + except Exception as exc: + out["jspace"] = {"error": type(exc).__name__} + + try: + from hermespace.world import WorldModel + + wm = WorldModel(agent_id=self.agent_id) + out["world"] = { + "beliefs": len(wm.state.beliefs or []), + "landmarks": len(wm.state.landmarks or []), + "timeline": wm.archive.count(), + "state": wm.state.current_state, + } + except Exception as exc: + out["world"] = {"error": type(exc).__name__} + + out["room"] = self.room() + try: + from hermespace.cube_module import center_status, cube_available + + out["warehouse"] = { + "cube_available": bool(cube_available()), + "optional": True, + "status": center_status(), + } + except Exception: + out["warehouse"] = {"cube_available": False, "optional": True} + + if self._last_connect: + out["last_connect"] = { + "ok": self._last_connect.get("ok"), + "gained": self._last_connect.get("gained"), + "summary": self._last_connect.get("summary"), + } + + out["ready"] = ( + oew_enabled() + and "error" not in out["jspace"] + and int(out["jspace"].get("hub_n", -1)) >= 0 + ) + return out + + # --- selectivity / probe ------------------------------------------------- + + def probe_material(self, message: str, *, desk_ready: bool | None = None) -> dict[str, Any]: + """Would this message ignite the workspace? (Anthropic selectivity).""" + from hermespace.gate import should_inject + from hermespace.store import load_desk + + ready = desk_ready + if ready is None: + try: + ready = load_desk(self.desk_engine.desk_path).is_ready() + except Exception: + ready = False + do_it, reason = should_inject(message or "", desk_ready=bool(ready), is_first_turn=False) + # Material *intent* even when desk not ready yet (would ignite after connect) + trivial = reason in {"trivial_ack", "explicit_off", "HERMESPACE_OFF"} + material = (not trivial) and ( + bool(do_it) + or reason in {"material_but_desk_not_ready", "material+ready", "long_msg+ready"} + or "material" in reason + ) + rec = { + "material": material, + "inject": bool(do_it), + "reason": reason, + "desk_ready": bool(ready), + "oew_would_run": material and oew_enabled() and bool(do_it), + } + self._last_gate = rec + return rec + + # --- operator / video ops ------------------------------------------------ + + def lens(self, *, top_k: int = 12, include_silent: bool = True) -> str: + return self.env.lens_markdown(top_k=top_k, include_silent=include_silent) + + def audit(self) -> list[dict[str, Any]]: + return [f.to_dict() for f in self.env.audit()] + + def report(self, *, include_silent: bool = False) -> str: + return self.hub.report(include_silent=include_silent) + + def broadcast(self, *, high_load: bool = False) -> str: + return self.env.filtered_broadcast(high_load=high_load) + + def hold(self, text: str, *, silent: bool = False, salience: float = 0.9) -> dict[str, Any]: + c = self.hub.hold(text, silent=silent, salience=salience) + return {"ok": True, "concept": c.label(), "silent": silent} + + def swap(self, source: str, target: str) -> dict[str, Any]: + return self.env.swap(source, target) + + def inject(self, text: str, *, silent: bool = True) -> dict[str, Any]: + c = self.env.inject_thought(text, silent=silent) + return {"ok": True, "concept": c.label(), "silent": silent} + + def ablate(self, *patterns: str) -> dict[str, Any]: + return self.env.ablate(*patterns) + + def chain(self, *steps: str, salience: float = 0.85) -> dict[str, Any]: + """Park a multi-step silent reasoning chain (internal reasoning role). + + Analogue of Anthropic spider→legs intermediates that never appear in + the spoken answer — required for multi-step work under OEW. + """ + parked: list[str] = [] + js = self.hub + for step in steps: + s = (step or "").strip() + if not s: + continue + js.reason_step(s, salience=salience) + parked.append(s[:200]) + try: + self.env.set_band("mid") + except Exception: + pass + return { + "ok": bool(parked), + "parked": parked, + "silent_n": len(js.state.silent_steps), + "hub_n": len(js.state.hub), + } + + def reflect( + self, + answer: str = "", + *, + principles: list[str] | None = None, + ) -> dict[str, Any]: + r = self.env.reflect(answer=answer, principles=principles or []) + return r.to_dict() + + def set_pov(self, text: str) -> dict[str, Any]: + self.env.set_pov(text) + return {"ok": True, "pov": text[:200]} + + # --- material ignition (single path) ------------------------------------- + + def turn( + self, + message: str, + *, + goal: str = "", + plan: list[str] | None = None, + say: str = "", + decision: str = "", + force: bool = True, + seal: bool = False, + connect_if_needed: bool = True, + ) -> Any: + """One higher-order material turn — the only ignition path. + + Returns ``HermespaceOutput`` (dual decode: ``.report`` vs ``.context``). + """ + if connect_if_needed and not self._last_connect: + try: + self.connect(query=(message or "")[:120], enter_workbench=False) + except Exception: + pass + + from hermespace.io_contract import HermespaceInput + from hermespace.workflow import Workflow + + self._turn_count += 1 + probe = self.probe_material(message) + wf_kwargs: dict[str, Any] = {} + if self.desk_path is not None: + from hermespace.engine import HermespaceEngine + + wf_kwargs["engine"] = HermespaceEngine(desk_path=self.desk_path) + out = Workflow(**wf_kwargs).run( + HermespaceInput( + message=message, + goal=goal or message[:200], + plan=list(plan or ["execute"]), + say=say or "", + decision=decision or "", + force=force, + seal=seal, + agent_id=self.agent_id, + session_id=self.session_id, + ) + ) + if out.skipped: + self._skips += 1 + else: + self._ignitions += 1 + # Attach engine observability + if isinstance(out.meta, dict): + out.meta["engine"] = "JSpaceEngine" + out.meta["probe"] = probe + out.meta["metrics"] = self.metrics() + out.meta["access_roles"] = list(ACCESS_ROLES) + return out + + # Back-compat alias used by HermesBase + def think(self, message: str, **kwargs: Any) -> dict[str, Any]: + out = self.turn(message, **kwargs) + return { + "skipped": out.skipped, + "reason": out.reason, + "report": out.report, + "context_chars": len(out.context or ""), + "has_jspace_broadcast": "J-Space" in (out.context or ""), + "oew": (out.meta or {}).get("jspace", {}).get("oew") + or (out.meta or {}).get("oew") + or {}, + "oew_ok": (out.meta or {}).get("jspace", {}).get("oew_ok"), + "goal": out.goal, + "decision": out.decision, + "connected": bool(self._last_connect and self._last_connect.get("ok")), + "engine": "JSpaceEngine", + "metrics": (out.meta or {}).get("metrics") or self.metrics(), + } + + # --- dual decode --------------------------------------------------------- + + @staticmethod + def decode_user(out: Any) -> str: + from hermespace.agent_api import decode_for_user + + return decode_for_user(out) + + @staticmethod + def decode_model(out: Any) -> str: + from hermespace.agent_api import decode_for_model + + return decode_for_model(out) + + @staticmethod + def decode_bundle(out: Any) -> dict[str, Any]: + from hermespace.agent_api import decode_bundle + + return decode_bundle(out) + + # --- night --------------------------------------------------------------- + + def harvest(self, *, clear_silent: bool = False) -> dict[str, Any]: + return self.env.dream_harvest(seal_to_cube=True, clear_silent=clear_silent) + + def pulse(self) -> dict[str, Any]: + try: + from hermespace.cube_module import cube_pulse + + return cube_pulse(agent_id=self.agent_id) + except Exception as exc: + return {"ok": False, "error": type(exc).__name__} + + # --- open-weight J-lens (optional push-limit) ---------------------------- + + def jlens_status(self) -> dict[str, Any]: + """Optional open-weight Jacobian lens — not required for harness J-space. + + Anthropic released ``anthropics/jacobian-lens`` (Apache-2.0) for + open-weight decoders. Hermespace remains the *external* workspace for + Hermes Agent (closed API / no activation access). When a local open + model + fitted lens are present, this reports readiness for a future + dual-read path (harness hub ⊕ weight lens). + """ + out: dict[str, Any] = { + "available": False, + "role": "optional_open_weight_companion", + "harness_primary": True, + "refs": [ + "https://github.com/anthropics/jacobian-lens", + "https://www.anthropic.com/research/global-workspace", + ], + "note": ( + "Hermes Agent typically has no weight access — " + "JSpaceEngine is the functional J-space. " + "Install anthropics/jacobian-lens only for local open models." + ), + } + try: + import jlens # type: ignore # noqa: F401 + + out["available"] = True + out["package"] = "jlens" + except Exception: + try: + import jacobian_lens # type: ignore # noqa: F401 + + out["available"] = True + out["package"] = "jacobian_lens" + except Exception: + pass + return out + + +# Back-compat product name used in docs / older imports +HermespaceJSpaceEngine = JSpaceEngine diff --git a/src/hermespace/jspace/env.py b/src/hermespace/jspace/env.py index f0a8505..45329e5 100644 --- a/src/hermespace/jspace/env.py +++ b/src/hermespace/jspace/env.py @@ -325,13 +325,34 @@ def inject_thought( silent: bool = False, band: str | None = None, ) -> WorkspaceConcept: - """Inject a thought into the workspace (Anthropic lightning-injection analogue).""" + """Inject a thought into the workspace (Anthropic lightning-injection analogue). + + Silent path uses ``reason_step`` only (which already holds silently) — + avoids the prior double-hold bug that inflated hub pressure. + """ if band: self.set_band(band) - c = self.space.hold(text, salience=salience, silent=silent) + body = (text or "").strip() if silent: - self.space.reason_step(text, salience=salience) - self._trace("inject", text=text[:200], silent=silent, band=self.band()) + self.space.reason_step(body, salience=salience) + # Return the silent hub concept just written + for c in reversed(self.space.state.hub): + if c.text.casefold() == body.casefold() and c.silent: + self._trace("inject", text=body[:200], silent=True, band=self.band()) + return c + # Fallback construct (should be rare) + c = WorkspaceConcept( + text=body[:200], + salience=salience, + modality="verbal", + source="inject", + silent=True, + held=True, + ) + self._trace("inject", text=body[:200], silent=True, band=self.band()) + return c + c = self.space.hold(body, salience=salience, silent=False) + self._trace("inject", text=body[:200], silent=False, band=self.band()) return c def ablate(self, *patterns: str) -> dict[str, Any]: @@ -629,16 +650,20 @@ def advance_turn( report: str = "", seal_decision: str = "", material: bool = True, + already_synced: bool = False, ) -> dict[str, Any]: """One full environment beat for a Hermespace turn. early → sync/encode → mid (OEW silent park) → late (shaped report) - + audit + optional seal of decision into Cube. + + audit + optional seal of decision into warehouse. + + Pass ``already_synced=True`` when the caller just ran + ``JSpace.sync_from_desk`` to avoid a double hub rewrite. """ from hermespace.jspace.oew import run_oew_beat self.set_band("early") - if desk is not None: + if desk is not None and not already_synced: self.space.sync_from_desk(desk, user_message=user_message, cube_strip=cube_strip) self.set_band("mid") high_load = False diff --git a/src/hermespace/workbench.py b/src/hermespace/workbench.py index 0486a11..3700fc7 100644 --- a/src/hermespace/workbench.py +++ b/src/hermespace/workbench.py @@ -324,58 +324,26 @@ def receive_order( seal=seal, tags=["workbench", "order"], ) + # Single ignition path — Workflow/JSpaceEngine already ran OEW + warehouse beat. + # Do not double cube_beat / hub sync here (that inflated hub pressure). out = run_turn(inp, workflow=self.workflow) bundle = decode_bundle(out) - - # Cardiac beat + J-Space sync after order (soft-fail) try: - from hermespace.cube_module import cube_beat - from hermespace.jspace import JSpace - from hermespace.store import load_desk - - desk = load_desk(self.workflow.engine.desk_path) - load_total = 0.5 - if isinstance(desk.load, dict): - load_total = float(desk.load.get("total") or 0.5) - seals = None - if seal and out.decision: - seals = out.decision - beat = cube_beat( - msg or g or desk.goal, - seals=seals, - load=load_total, - agent_id=self.agent_id, - session_id=self.session_id, - ) - js = JSpace(agent_id=self.agent_id) - js.sync_from_desk( - desk, - user_message=msg or g, - cube_strip=str(beat.get("block") or ""), - ) - # Append J-Space broadcast + Cube strip into model context (not user reply) - extra_parts = [] - if beat.get("block"): - extra_parts.append(str(beat["block"])) - jblock = js.broadcast_block( - high_load=str(desk.load.get("level") if isinstance(desk.load, dict) else "") == "high" - ) - if jblock: - extra_parts.append(jblock) - if extra_parts: - mc = decode_for_model(out) - enriched = (mc + "\n\n" + "\n\n".join(extra_parts)).strip() - bundle["model_context"] = enriched - self.state.meta["last_beat"] = { - "ok": beat.get("ok"), - "mode": beat.get("mode"), - "load_level": beat.get("load_level"), - "chars": len(str(beat.get("block") or "")), - } - self.state.meta["jspace"] = { - "hub_n": len(js.state.hub), - "focus_n": len(js.state.focus), - } + jmeta = (out.meta or {}).get("jspace") or {} + cmeta = (out.meta or {}).get("cube_beat") or {} + self.state.meta["last_beat"] = { + "ok": cmeta.get("ok"), + "mode": cmeta.get("mode"), + "load_level": cmeta.get("load_level"), + "chars": cmeta.get("chars"), + "single_path": True, + } + self.state.meta["jspace"] = { + "hub_n": jmeta.get("hub_n"), + "focus_n": jmeta.get("focus_n"), + "silent_n": jmeta.get("silent_n"), + "oew_ok": jmeta.get("oew_ok"), + } except Exception as exc: # noqa: BLE001 self.state.meta["last_beat"] = {"ok": False, "error": type(exc).__name__} diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index aa3b3ee..fee83e6 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -179,6 +179,7 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: if mod.get("hold"): js.hold(str(mod["hold"]), silent=bool(mod.get("silent"))) env = JSpaceEnv(agent_id=payload.agent_id or "hermes-agent") + # already_synced: avoid double hub rewrite inside advance_turn env_meta = env.advance_turn( user_message=msg, desk=desk, @@ -186,6 +187,7 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: report=desk.say or "", seal_decision=desk.decision if payload.seal else "", material=True, + already_synced=True, ) # Causal Report: sticky swaps + ensured say if env_meta.get("report"): diff --git a/tests/test_connect_room.py b/tests/test_connect_room.py index 2bd5c2d..0fef192 100644 --- a/tests/test_connect_room.py +++ b/tests/test_connect_room.py @@ -39,7 +39,7 @@ def test_connect_standalone_gains_world_and_hub(self) -> None: self.assertGreaterEqual(int(gained.get("world_beliefs") or 0), 1) self.assertGreaterEqual(int(gained.get("jspace_hub") or 0), 1) self.assertEqual(gained.get("room_mode"), "solo") - self.assertIn("Connected", out.get("summary") or "") + self.assertRegex(out.get("summary") or "", r"(?i)connected") st = hb.status() self.assertTrue(st["ready"]) diff --git a/tests/test_hermes_base.py b/tests/test_hermes_base.py index 97df48c..6c730a8 100644 --- a/tests/test_hermes_base.py +++ b/tests/test_hermes_base.py @@ -24,9 +24,10 @@ def test_status_ready(self) -> None: st = HermesBase(agent_id="base-test").status() self.assertTrue(st["oew_enabled"]) self.assertTrue(st["ready"]) - self.assertIn("read/lens", st["video_ops"]) - self.assertIn("connect", st["connect_ops"]) + self.assertIn("connect", st["ops"]) + self.assertIn("lens", st["ops"]) self.assertIn("room", st) + self.assertEqual(st.get("engine"), "JSpaceEngine") def test_connect_facade(self) -> None: from hermespace import HermesBase diff --git a/tests/test_hermes_bridge.py b/tests/test_hermes_bridge.py index f39e8f5..bbb3338 100644 --- a/tests/test_hermes_bridge.py +++ b/tests/test_hermes_bridge.py @@ -16,17 +16,25 @@ def test_session_and_pre_llm(self): r = on_session_start(session_id="bridge-test") self.assertIsInstance(r, dict) self.assertIn("context", r) - self.assertIn("Workbench", r["context"]) + self.assertTrue( + "J-Space Engine" in r["context"] or "Workbench" in r["context"] + ) desk = load_desk() self.assertTrue(desk.goal) # material message should inject after desk ready inj = on_pre_llm_call( - user_message="proceed build the feature please", + user_message="First build the feature then verify please", session_id="bridge-test", is_first_turn=False, ) self.assertIsNotNone(inj) self.assertIn("context", inj) + # Dual-decode hint for hosts that only get context + self.assertTrue( + "user_reply_hint" in inj + or "Dual decode" in inj["context"] + or "J-Space" in inj["context"] + ) on_session_end(session_id="bridge-test") if __name__ == "__main__": diff --git a/tests/test_jspace_engine.py b/tests/test_jspace_engine.py new file mode 100644 index 0000000..e8b9c49 --- /dev/null +++ b/tests/test_jspace_engine.py @@ -0,0 +1,109 @@ +"""JSpaceEngine — unified open-source J-space for Hermes Agent.""" + +from __future__ import annotations + +import os +import tempfile +import unittest + + +class TestJSpaceEngine(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self.tmp.name + os.environ["HERMESPACE_OEW"] = "1" + os.environ.pop("HERMESCUBE_HIVE", None) + + def tearDown(self) -> None: + self.tmp.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + os.environ.pop("HERMESPACE_OEW", None) + + def test_connect_and_access_roles(self) -> None: + from hermespace import ACCESS_ROLES, JSpaceEngine + from hermespace.world import WorldModel + + aid = "eng-connect" + wm = WorldModel(agent_id=aid) + wm.add_belief("Prefer dual decode", 0.9, source="test") + + eng = JSpaceEngine(agent_id=aid) + out = eng.connect(query="decode") + self.assertTrue(out.get("ok"), msg=out) + self.assertEqual(out.get("engine"), "JSpaceEngine") + self.assertEqual(list(out.get("access_roles") or []), list(ACCESS_ROLES)) + self.assertGreaterEqual(int((out.get("gained") or {}).get("jspace_hub") or 0), 1) + + roles = eng.access_roles() + for name in ACCESS_ROLES: + self.assertIn(name, roles) + self.assertTrue(roles[name].get("ok")) + + st = eng.status() + self.assertTrue(st["ready"]) + self.assertEqual(st["engine"], "JSpaceEngine") + self.assertIn("hub_pressure", st["metrics"]) + + def test_chain_silent_not_in_user_report(self) -> None: + from hermespace import JSpaceEngine + + eng = JSpaceEngine(agent_id="eng-chain") + eng.chain("animal spins webs → spider", "spider has 8 legs") + rep = eng.report(include_silent=False).casefold() + full = eng.report(include_silent=True).casefold() + self.assertIn("spider", full) + # default report should not dump Silent reasoning section + self.assertNotIn("silent reasoning", rep) + m = eng.metrics() + self.assertGreaterEqual(m["silent_n"], 2) + + def test_inject_silent_no_double_hold(self) -> None: + from hermespace import JSpaceEngine + + eng = JSpaceEngine(agent_id="eng-inject") + before = len(eng.hub.state.hub) + eng.inject("lightning", silent=True) + # Exactly one hub entry for lightning (not hold + reason_step duplicate) + hits = [c for c in eng.hub.state.hub if "lightning" in c.text.casefold()] + self.assertEqual(len(hits), 1, msg=[c.text for c in eng.hub.state.hub]) + self.assertTrue(hits[0].silent) + self.assertEqual(len(eng.hub.state.hub), before + 1) + + def test_probe_selectivity(self) -> None: + from hermespace import JSpaceEngine + + eng = JSpaceEngine(agent_id="eng-probe") + trivial = eng.probe_material("thanks!") + material = eng.probe_material( + "First implement the auth fix then verify login stays alive" + ) + self.assertFalse(trivial.get("material")) + self.assertTrue(material.get("material"), msg=material) + + def test_turn_dual_decode(self) -> None: + from hermespace import JSpaceEngine + + eng = JSpaceEngine(agent_id="eng-turn") + out = eng.turn( + "First analyze then implement finally verify", + goal="Ship feature", + say="On it.", + ) + self.assertFalse(out.skipped) + user = eng.decode_user(out) + model = eng.decode_model(out) + self.assertTrue(user) + self.assertNotEqual(user.strip(), model.strip()) + self.assertIn("J-Space", model) + self.assertTrue((out.meta or {}).get("jspace", {}).get("oew_ok") is not False) + + def test_hermes_base_is_engine(self) -> None: + from hermespace import HermesBase, JSpaceEngine + + self.assertTrue(issubclass(HermesBase, JSpaceEngine)) + hb = HermesBase(agent_id="alias") + self.assertEqual(hb.status()["engine"], "JSpaceEngine") + + +if __name__ == "__main__": + unittest.main() From 71b8a87c5ca634867cfc7f20437f71995afb6eff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 00:38:00 +0000 Subject: [PATCH 08/32] refactor(0.24): rebrand to Hermespace Access Engine / Access Workspace Rename product surface away from third-party J-space naming: AccessEngine, AccessHub, AccessEnv, hermespace.access, hs access. Keep hermespace.jspace as a deprecated shim only. Co-authored-by: Pablo --- ABOUT.md | 4 +- LAYOUT.md | 6 +- PURPOSE.md | 14 +-- README.md | 32 +++--- docs/INDEX.md | 14 +-- docs/README.md | 4 +- docs/access/00-map.md | 46 ++++++++ docs/{jspace => access}/10-claude-research.md | 0 docs/{jspace => access}/27-environment.md | 0 .../29-baars-changeux-anthropic.md | 0 .../30-day-to-day-higher-order.md | 0 .../31-anthropic-x-video-deep-dive.md | 0 .../32-hermespace-access-engine.md} | 0 docs/access/34-access-engine.md | 78 +++++++++++++ docs/{jspace => access}/thesis-oew.md | 0 docs/architecture/CODEMAP.md | 22 ++-- docs/jspace/00-map.md | 53 --------- docs/jspace/34-jspace-engine.md | 107 ------------------ experiments/day_in_life_oew.py | 24 ++-- experiments/oew_eval.py | 14 +-- hermes_plugin/__init__.py | 2 +- hermes_plugin/plugin.yaml | 4 +- pyproject.toml | 2 +- src/hermespace/__init__.py | 18 +-- src/hermespace/access/__init__.py | 66 +++++++++++ src/hermespace/{jspace => access}/engine.py | 93 +++++++-------- src/hermespace/{jspace => access}/env.py | 74 +++++------- src/hermespace/{jspace => access}/hub.py | 44 ++++--- src/hermespace/{jspace => access}/oew.py | 12 +- src/hermespace/{jspace => access}/protocol.py | 0 src/hermespace/access_env.py | 8 ++ src/hermespace/agent_api.py | 6 +- src/hermespace/cli.py | 34 +++--- src/hermespace/cube_module.py | 41 +++---- src/hermespace/engine.py | 6 +- src/hermespace/grid/dream.py | 24 ++-- src/hermespace/grid/viewport.py | 18 +-- src/hermespace/hermes_base.py | 10 +- src/hermespace/hermes_bridge.py | 44 +++---- src/hermespace/jspace/__init__.py | 73 ++++++------ src/hermespace/jspace_env.py | 8 +- src/hermespace/ops.py | 18 +-- src/hermespace/pulse.py | 16 +-- src/hermespace/workbench.py | 30 ++--- src/hermespace/workflow.py | 30 ++--- src/hermespace/world.py | 6 +- ...jspace_engine.py => test_access_engine.py} | 40 +++---- ...{test_jspace_env.py => test_access_env.py} | 42 +++---- ...test_jspace_cube.py => test_access_hub.py} | 34 +++--- ...ce_protocol.py => test_access_protocol.py} | 20 ++-- tests/test_connect_room.py | 14 +-- tests/test_hermes_base.py | 6 +- tests/test_hermes_bridge.py | 4 +- tests/test_oew_causal.py | 26 ++--- 54 files changed, 660 insertions(+), 631 deletions(-) create mode 100644 docs/access/00-map.md rename docs/{jspace => access}/10-claude-research.md (100%) rename docs/{jspace => access}/27-environment.md (100%) rename docs/{jspace => access}/29-baars-changeux-anthropic.md (100%) rename docs/{jspace => access}/30-day-to-day-higher-order.md (100%) rename docs/{jspace => access}/31-anthropic-x-video-deep-dive.md (100%) rename docs/{jspace/32-hermes-base-as-jspace.md => access/32-hermespace-access-engine.md} (100%) create mode 100644 docs/access/34-access-engine.md rename docs/{jspace => access}/thesis-oew.md (100%) delete mode 100644 docs/jspace/00-map.md delete mode 100644 docs/jspace/34-jspace-engine.md create mode 100644 src/hermespace/access/__init__.py rename src/hermespace/{jspace => access}/engine.py (88%) rename src/hermespace/{jspace => access}/env.py (90%) rename src/hermespace/{jspace => access}/hub.py (93%) rename src/hermespace/{jspace => access}/oew.py (97%) rename src/hermespace/{jspace => access}/protocol.py (100%) create mode 100644 src/hermespace/access_env.py rename tests/{test_jspace_engine.py => test_access_engine.py} (75%) rename tests/{test_jspace_env.py => test_access_env.py} (80%) rename tests/{test_jspace_cube.py => test_access_hub.py} (88%) rename tests/{test_jspace_protocol.py => test_access_protocol.py} (80%) diff --git a/ABOUT.md b/ABOUT.md index b01f909..aeb0d50 100644 --- a/ABOUT.md +++ b/ABOUT.md @@ -1,4 +1,4 @@ -**Hermespace is a functional J-Space workbench for Hermes agents** — FOA desk, dual decode, and an append-only world projection. Not [J-Space](https://github.com/anomalyco/j-space) neural weights. Not a second runtime. A harness-level global workspace inside Hermes that runs **standalone** and is **powered by [HermesCube](https://github.com/PabloTheThinker/hermescube)** when present. +**Hermespace is a functional Access Workspace workbench for Hermes agents** — FOA desk, dual decode, and an append-only world projection. Not [Access Workspace](https://github.com/anomalyco/j-space) neural weights. Not a second runtime. A harness-level global workspace inside Hermes that runs **standalone** and is **powered by [HermesCube](https://github.com/PabloTheThinker/hermescube)** when present. Alongside the desk: skills+MEMORY fabric, neural FOA, autonomy grid, Cube heart/center cable (soft-fail). @@ -38,7 +38,7 @@ Hermespace solves this with two systems: | Project | Relation | |---------|----------| | [ActiveGraph](https://github.com/anomalyco/ActiveGraph) | Event log is the agent, graph is the world — influenced the archive-first design | -| [J-Space](https://github.com/anomalyco/j-space) | Verbal workspace, ~25 concepts, broadcasting hub — inspired the `concepts` system and `_refresh_concepts()` | +| [Access Workspace](https://github.com/anomalyco/j-space) | Verbal workspace, ~25 concepts, broadcasting hub — inspired the `concepts` system and `_refresh_concepts()` | | [Missing Knowledge Layer](https://arxiv.org/abs/2405.10697) | Knowledge = supersession, Memory = decay, Wisdom = evidence-gated — inspired the epoch progression from unfiltered archive → evidence-gated wisdom | | [Vygotsky](https://en.wikipedia.org/wiki/Inner_speech) | Inner speech, signs as instruments of thought — influenced the dual decode architecture (report to human vs context to model) | | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | The agent that grows with you, by Nous Research | diff --git a/LAYOUT.md b/LAYOUT.md index 59a44ce..5244e63 100644 --- a/LAYOUT.md +++ b/LAYOUT.md @@ -10,7 +10,7 @@ North star: [PURPOSE.md](PURPOSE.md) · Assessments: hermespace/ ├── PURPOSE.md / ABOUT.md / README.md / LAYOUT.md ├── src/hermespace/ # Python package -│ ├── jspace/ # ★ J-Space Engine (hub · env · protocol · engine) +│ ├── access/ # ★ Access Engine (hub · env · protocol · engine) │ ├── turn/ # planned — workflow/desk/gate (README only) │ ├── memory/ # planned — world/episodic/semantic (README only) │ ├── warehouse/ # planned — cube cable (README only) @@ -21,7 +21,7 @@ hermespace/ ├── desktop_plugin/ # Hermes Desktop page/pane ├── skills/hermespace/ # agent skill ├── docs/ -│ ├── jspace/ # J-Space map · thesis · environment +│ ├── access/ # Access Workspace map · thesis · environment │ ├── assessment/ # deep assessments │ ├── architecture/ # CODEMAP · Cube contract │ ├── integration/ # Hermes fit · FOR_HERMES @@ -37,7 +37,7 @@ hermespace/ | Layer | Path | Authority | |-------|------|-----------| -| OEW / J-Space | `src/hermespace/jspace/` | Turn FOA + audit SoT | +| OEW / Access Workspace | `src/hermespace/access/` | Turn FOA + audit SoT | | Turn spine | package root (`workflow`, `desk`, …) | Live turn | | Autonomy | `grid/` | Missions / dream / skillbench | | Identity projection | `world.py` … | Recharged from Cube | diff --git a/PURPOSE.md b/PURPOSE.md index f11436f..fd1ee81 100644 --- a/PURPOSE.md +++ b/PURPOSE.md @@ -1,6 +1,6 @@ # PURPOSE.md — Hermespace north star -**One line:** Hermespace is the **true external J-Space environment** for Hermes +**One line:** Hermespace is the **true external Access Workspace environment** for Hermes Agent — a harness where the agent’s verbalizable thoughts are forced into an observable workspace (lens · silent chain · audit · reflect), running **standalone** and **powered by HermesCube** at night the way CubeDream @@ -8,10 +8,10 @@ consolidates the day. Public pitch: **[ABOUT.md](ABOUT.md)**. Assessment: **[docs/assessment/28-hermes-agent-jspace-assessment.md](docs/assessment/28-hermes-agent-jspace-assessment.md)**. -OEW thesis: **[docs/jspace/thesis-oew.md](docs/jspace/thesis-oew.md)**. -Hermes base as J-space: **[docs/jspace/32-hermes-base-as-jspace.md](docs/jspace/32-hermes-base-as-jspace.md)**. -Anthropic X video: **[docs/jspace/31-anthropic-x-video-deep-dive.md](docs/jspace/31-anthropic-x-video-deep-dive.md)**. -Environment: **[docs/jspace/27-environment.md](docs/jspace/27-environment.md)**. +OEW thesis: **[docs/access/thesis-oew.md](docs/access/thesis-oew.md)**. +Hermes base as J-space: **[docs/access/32-hermes-base-as-jspace.md](docs/access/32-hermes-base-as-jspace.md)**. +Anthropic X video: **[docs/access/31-anthropic-x-video-deep-dive.md](docs/access/31-anthropic-x-video-deep-dive.md)**. +Environment: **[docs/access/27-environment.md](docs/access/27-environment.md)**. Code layout: **[LAYOUT.md](LAYOUT.md)** · **[docs/architecture/CODEMAP.md](docs/architecture/CODEMAP.md)**. Cube contract: **[docs/architecture/HERMESCUBE.md](docs/architecture/HERMESCUBE.md)**. @@ -34,7 +34,7 @@ Cube’s durable heart so day-thoughts become night-memory. ┌─────────────────────────────────────────────────────────────────┐ │ Hermes Agent │ │ │ -│ Hermespace J-Space ENV (this package) │ +│ Hermespace Access Workspace ENV (this package) │ │ protocol → early/mid/late bands │ │ silent chain (model only) · Report (user) │ │ lens readout · swap/inject/ablate · audit · reflect │ @@ -47,7 +47,7 @@ Cube’s durable heart so day-thoughts become night-memory. | Layer | Job | Authority | |-------|-----|-----------| -| **J-Space environment** | Externalize + observe verbalizable thought | Turn FOA + audit SoT | +| **Access Workspace environment** | Externalize + observe verbalizable thought | Turn FOA + audit SoT | | **ACTIVE desk** | Goal / decision / report | Live turn surface | | **WorldModel JSONL** | Identity projection | Recharged from Cube when present | | **HermesCube** | Durable long-tail warehouse | Durable memory SoT when installed | diff --git a/README.md b/README.md index 29d8839..23c478c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Python Hermes Agent - Version + Version Smoke 9/9 MIT

@@ -27,7 +27,7 @@ **Hermespace is an append-only persistent world for Hermes agents.** Every session, every belief, every landmark, every evolution is recorded in an archive that never prunes, never decays, never caps. The agent builds a deepening model of itself and its environment across sessions — and it outlives the user. -Not [J-Space](https://github.com/anomalyco/j-space). Not a second agent runtime. A room inside Hermes that remembers everything. +Not [Access Workspace](https://github.com/anomalyco/j-space). Not a second agent runtime. A room inside Hermes that remembers everything. --- @@ -96,7 +96,7 @@ The `evolve()` cycle runs five stages: 4. Detect milestones (entry count thresholds, high-confidence beliefs) 5. Generate open questions from low-confidence beliefs -Then checks for epoch transition, refreshes active concepts (J-Space hub, max 25), and writes an `evolution` archive entry. +Then checks for epoch transition, refreshes active concepts (Access Workspace hub, max 25), and writes an `evolution` archive entry. The `world_evolve` pulse job runs this hourly. Manual: `hs world evolve` or `WorldModel.evolve()`. @@ -130,7 +130,7 @@ Alongside the world, Hermespace provides a desk for the current turn — FOA, du | Feature | What it does | |---|---| -| **Functional J-Space** | Harness global workspace — hold/summon concepts, silent reasoning, FOA≤4, hub≤25 | +| **Functional Access Workspace** | Harness global workspace — hold/summon concepts, silent reasoning, FOA≤4, hub≤25 | | **Focus of Attention** | ≤4 items, single active goal per turn | | **Dual Decode** | Human gets a short report; the model gets dense context. Never dump raw inject into chat channels. | | **Cube heart (optional)** | Soft cable to HermesCube — `beat` / `seal` / `pulse`; standalone warehouse when Cube absent | @@ -163,8 +163,8 @@ ctx = r["model_context"] # → model (includes world context) | Hook | What happens | |---|---| -| `on_session_start` | `WorldModel.enter()` + workbench enter + `ensure_heart` + J-Space sync | -| `pre_llm_call` | Desk + world + `cube_beat` arterial strip + J-Space broadcast | +| `on_session_start` | `WorldModel.enter()` + workbench enter + `ensure_heart` + Access Workspace sync | +| `pre_llm_call` | Desk + world + `cube_beat` arterial strip + Access Workspace broadcast | | `on_session_end` | `WorldModel.leave()` + workbench idle tick (autonomic pulse) | --- @@ -211,8 +211,8 @@ $HERMESPACE_HOME/memory/hermespace/ | Command | Purpose | |---------|---------| | `hs world show\|enter\|leave\|evolve\|search\|archive-stats` | Persistent world | -| `hs base connect\|turn\|roles\|metrics\|lens\|audit\|chain\|harvest` | J-Space Engine (open-source GWT for Hermes) | -| `hs jspace hold\|report\|broadcast\|lens\|swap\|audit\|reflect\|harvest\|view` | True J-Space environment | +| `hs base connect\|turn\|roles\|metrics\|lens\|audit\|chain\|harvest` | Access Engine (open-source GWT for Hermes) | +| `hs access hold\|report\|broadcast\|lens\|swap\|audit\|reflect\|harvest\|view` | True Access Workspace environment | | `hs cube status\|ensure\|beat\|pulse\|seal\|inject` | Cube heart/center (standalone-safe) | | `hs turn` | Full INPUT → OUTPUT turn | | `hs workbench enter\|order\|idle\|park\|status` | Session workbench | @@ -229,15 +229,15 @@ $HERMESPACE_HOME/memory/hermespace/ | Doc | Contents | |-----|----------| -| [`PURPOSE.md`](PURPOSE.md) | North star — true external J-Space + Cube night path | +| [`PURPOSE.md`](PURPOSE.md) | North star — true external Access Workspace + Cube night path | | [`LAYOUT.md`](LAYOUT.md) | Codespace / folder map | | [`docs/assessment/28-hermes-agent-jspace-assessment.md`](docs/assessment/28-hermes-agent-jspace-assessment.md) | Hermes Agent updates → OEW plan | -| [`docs/jspace/thesis-oew.md`](docs/jspace/thesis-oew.md) | Obligatory External Workspace thesis | -| [`docs/jspace/29-baars-changeux-anthropic.md`](docs/jspace/29-baars-changeux-anthropic.md) | Baars · Changeux/Dehaene · Anthropic research bridge | -| [`docs/jspace/30-day-to-day-higher-order.md`](docs/jspace/30-day-to-day-higher-order.md) | Day-to-day higher-order Hermes usage | -| [`docs/jspace/31-anthropic-x-video-deep-dive.md`](docs/jspace/31-anthropic-x-video-deep-dive.md) | Anthropic X video — how J-space is operated | -| [`docs/jspace/32-hermes-base-as-jspace.md`](docs/jspace/32-hermes-base-as-jspace.md) | Hermes base = J-space of Hermes agents | -| [`docs/jspace/27-environment.md`](docs/jspace/27-environment.md) | Environment API | +| [`docs/access/thesis-oew.md`](docs/access/thesis-oew.md) | Obligatory External Workspace thesis | +| [`docs/access/29-baars-changeux-anthropic.md`](docs/access/29-baars-changeux-anthropic.md) | Baars · Changeux/Dehaene · Anthropic research bridge | +| [`docs/access/30-day-to-day-higher-order.md`](docs/access/30-day-to-day-higher-order.md) | Day-to-day higher-order Hermes usage | +| [`docs/access/31-anthropic-x-video-deep-dive.md`](docs/access/31-anthropic-x-video-deep-dive.md) | Anthropic X video — how J-space is operated | +| [`docs/access/32-hermes-base-as-jspace.md`](docs/access/32-hermes-base-as-jspace.md) | Hermes base = J-space of Hermes agents | +| [`docs/access/27-environment.md`](docs/access/27-environment.md) | Environment API | | [`ABOUT.md`](ABOUT.md) | Philosophy, design principles, author | | [`docs/architecture/CODEMAP.md`](docs/architecture/CODEMAP.md) | Where to edit (layer map) | | [`docs/architecture/HERMESCUBE.md`](docs/architecture/HERMESCUBE.md) | Cube heart/center contract | @@ -259,7 +259,7 @@ $HERMESPACE_HOME/memory/hermespace/ ```text LAYOUT.md codespace map (start here for structure) src/hermespace/ runtime package - jspace/ ★ external J-Space (hub · env · OEW protocol) + access/ ★ external Access Workspace (hub · env · OEW protocol) grid/ autonomy grid hermes_plugin/ Hermes session / pre_llm / end hooks skills/hermespace/ public Hermes agent skill diff --git a/docs/INDEX.md b/docs/INDEX.md index 9eb22cf..6b1d86f 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -7,13 +7,13 @@ Start: [../README.md](../README.md) · [../LAYOUT.md](../LAYOUT.md) · [../PURPO | Doc | Topic | |-----|--------| | [assessment/28-hermes-agent-jspace-assessment.md](assessment/28-hermes-agent-jspace-assessment.md) | Hermes Agent updates → OEW plan | -| [jspace/thesis-oew.md](jspace/thesis-oew.md) | Obligatory External Workspace thesis | -| [jspace/29-baars-changeux-anthropic.md](jspace/29-baars-changeux-anthropic.md) | Baars · Changeux/Dehaene · Anthropic bridge | -| [jspace/30-day-to-day-higher-order.md](jspace/30-day-to-day-higher-order.md) | Day-to-day Hermes higher-order usage | -| [jspace/31-anthropic-x-video-deep-dive.md](jspace/31-anthropic-x-video-deep-dive.md) | Anthropic X video/thread — how J-space is used | -| [jspace/32-hermes-base-as-jspace.md](jspace/32-hermes-base-as-jspace.md) | Make Hermes base the J-space of agents | -| [jspace/27-environment.md](jspace/27-environment.md) | Environment API | -| [jspace/00-map.md](jspace/00-map.md) | Anthropic property → harness map | +| [access/thesis-oew.md](access/thesis-oew.md) | Obligatory External Workspace thesis | +| [access/29-baars-changeux-anthropic.md](access/29-baars-changeux-anthropic.md) | Baars · Changeux/Dehaene · Anthropic bridge | +| [access/30-day-to-day-higher-order.md](access/30-day-to-day-higher-order.md) | Day-to-day Hermes higher-order usage | +| [access/31-anthropic-x-video-deep-dive.md](access/31-anthropic-x-video-deep-dive.md) | Anthropic X video/thread — how J-space is used | +| [access/32-hermes-base-as-jspace.md](access/32-hermes-base-as-jspace.md) | Make Hermes base the J-space of agents | +| [access/27-environment.md](access/27-environment.md) | Environment API | +| [access/00-map.md](access/00-map.md) | Anthropic property → harness map | | [architecture/CODEMAP.md](architecture/CODEMAP.md) | Where to edit | | [architecture/HERMESCUBE.md](architecture/HERMESCUBE.md) | Cube heart contract | | [integration/FOR_HERMES.md](integration/FOR_HERMES.md) | Dogfood notes for Hermes | diff --git a/docs/README.md b/docs/README.md index b3d168a..3974696 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ | Folder | Contents | |--------|----------| -| [jspace/](jspace/) | Anthropic map · OEW thesis · environment API | +| [access/](access/) | Anthropic map · OEW thesis · environment API | | [assessment/](assessment/) | Deep assessments (start with **28**) | | [architecture/](architecture/) | CODEMAP · Cube contract | | [integration/](integration/) | Hermes plugin · ecosystem · FOR_HERMES | @@ -11,5 +11,5 @@ | [roadmap/](roadmap/) | Open backlog · OEW phases | **Start here:** [assessment/28-hermes-agent-jspace-assessment.md](assessment/28-hermes-agent-jspace-assessment.md) -**Thesis:** [jspace/thesis-oew.md](jspace/thesis-oew.md) +**Thesis:** [access/thesis-oew.md](access/thesis-oew.md) **Repo layout:** [../LAYOUT.md](../LAYOUT.md) diff --git a/docs/access/00-map.md b/docs/access/00-map.md new file mode 100644 index 0000000..0339a22 --- /dev/null +++ b/docs/access/00-map.md @@ -0,0 +1,46 @@ +# Hermespace Access Workspace (GWT harness) + +Hermespace implements **access-workspace roles** as an open harness for Hermes +Agent — reportable hub, directed modulation, silent multi-step reasoning, +flexible broadcast, and selectivity. This is Hermespace's own product surface +(`AccessEngine` / `AccessHub`), not a third-party brand. + +| Access role | Hermespace | +|-------------|------------| +| Limited capacity (~tens of concepts) | Hub ≤25 · activated ≤12 · FOA ≤4 | +| Verbal report | `AccessHub.report()` · Report field · `hs access report` | +| Directed modulation | `hold` / `release` / `inhibit` · message parse | +| Internal / silent reasoning | `reason_step` / `chain` — model context only | +| Flexible generalization | One hub concept → broadcast to inject | +| Selectivity | `gate.should_inject` — skip trivial acks | +| Pre-output / broadcast | Desk before speech · `pre_llm` inject | +| Not a chat scratchpad | Silent steps never auto-dumped to user chat | + +## API + +```python +from hermespace import AccessHub, AccessEngine + +js = AccessHub(agent_id="my-agent") +js.hold("France", salience=0.9) +js.reason_step("capital is Paris") +print(js.report()) +print(js.broadcast_block()) + +eng = AccessEngine(agent_id="my-agent") +eng.connect() +``` + +## With optional warehouse + +Optional Cube/standalone warehouse supplies dense arterial strips via +`cube_module.cube_beat` → `AccessHub.sync_from_desk(..., cube_strip=...)`. +On **connect**, `AccessEngine.connect` charges WorldModel and seeds the hub. + +Product guide: [34-access-engine.md](34-access-engine.md). + +## Research context + +Global-workspace and verbalizable-workspace research (including third-party +interpretability work) informs the *roles*. Hermespace's shipped name and API +are **Access Workspace** / **Access Engine**. diff --git a/docs/jspace/10-claude-research.md b/docs/access/10-claude-research.md similarity index 100% rename from docs/jspace/10-claude-research.md rename to docs/access/10-claude-research.md diff --git a/docs/jspace/27-environment.md b/docs/access/27-environment.md similarity index 100% rename from docs/jspace/27-environment.md rename to docs/access/27-environment.md diff --git a/docs/jspace/29-baars-changeux-anthropic.md b/docs/access/29-baars-changeux-anthropic.md similarity index 100% rename from docs/jspace/29-baars-changeux-anthropic.md rename to docs/access/29-baars-changeux-anthropic.md diff --git a/docs/jspace/30-day-to-day-higher-order.md b/docs/access/30-day-to-day-higher-order.md similarity index 100% rename from docs/jspace/30-day-to-day-higher-order.md rename to docs/access/30-day-to-day-higher-order.md diff --git a/docs/jspace/31-anthropic-x-video-deep-dive.md b/docs/access/31-anthropic-x-video-deep-dive.md similarity index 100% rename from docs/jspace/31-anthropic-x-video-deep-dive.md rename to docs/access/31-anthropic-x-video-deep-dive.md diff --git a/docs/jspace/32-hermes-base-as-jspace.md b/docs/access/32-hermespace-access-engine.md similarity index 100% rename from docs/jspace/32-hermes-base-as-jspace.md rename to docs/access/32-hermespace-access-engine.md diff --git a/docs/access/34-access-engine.md b/docs/access/34-access-engine.md new file mode 100644 index 0000000..f7d8b05 --- /dev/null +++ b/docs/access/34-access-engine.md @@ -0,0 +1,78 @@ +# Access Engine — Hermespace's open access workspace for Hermes Agent + +**Version:** 0.24.0 +**Product name:** Hermespace **Access Engine** / **Access Workspace** +**Thesis:** Hermes Agent usually has no weight access. Hermespace therefore +ships its own privileged verbalizable workspace — an open-source harness the +operator can read, shape, and audit. + +--- + +## 1. One engine + +```python +from hermespace import AccessEngine + +eng = AccessEngine(agent_id="my-agent") +eng.connect() +out = eng.turn("First repro then patch then verify", goal="Fix auth") +print(eng.decode_user(out)) # short Report +print(eng.decode_model(out)[:400]) # dense context (never dump to chat) +print(eng.lens()) +eng.chain("spider", "8 legs") +eng.swap("ship now", "canary first") +print(eng.access_roles()) +eng.harvest() +``` + +```bash +hs base connect +hs base roles +hs base metrics +hs base turn -m "First check then implement finally verify" --goal "Ship" +hs access hold -t "canary first" +hs access lens +hs base harvest +``` + +`HermesBase` is a thin alias of `AccessEngine`. Desk file ops remain in +`HermespaceEngine` (desk spine only). + +--- + +## 2. Five access roles + +| Role | Engine API | +|------|------------| +| Verbal report | `report()` · `decode_user()` | +| Directed modulation | `hold` · `swap` · `inject` · `ablate` | +| Internal reasoning | `chain()` · OEW auto-park · silent hub | +| Flexible broadcast | `broadcast()` → pre_llm / desk / fabric | +| Selectivity | `probe_material()` · gate skip on trivial acks | + +Warehouse / HermesCube (if installed) is **optional arterial supply**. + +--- + +## 3. Brand note + +This product is **Hermespace Access Workspace** — not named after any third-party +interpretability term. Research inspiration may be cited in assessment docs; +the shipped API, CLI, and package are Hermespace's own (`hermespace.access`). + +Deprecated import shim: `hermespace.jspace` → re-exports `hermespace.access`. + +--- + +## 4. Architecture + +``` +Hermes Agent (tools · skills · fluency) + │ material turns + ▼ + AccessEngine ←── hs base / hs access / plugin hooks + ├── AccessHub (≤25) · FOA (≤4) · silent chain + ├── OEW protocol (default ON) + ├── dual decode (Report ≠ inject) + └── night harvest → semantic / optional warehouse +``` diff --git a/docs/jspace/thesis-oew.md b/docs/access/thesis-oew.md similarity index 100% rename from docs/jspace/thesis-oew.md rename to docs/access/thesis-oew.md diff --git a/docs/architecture/CODEMAP.md b/docs/architecture/CODEMAP.md index ca70916..081657c 100644 --- a/docs/architecture/CODEMAP.md +++ b/docs/architecture/CODEMAP.md @@ -4,7 +4,7 @@ Hermespace is one Python package (`src/hermespace/`) plus a thin Hermes plugin (`hermes_plugin/`), skill, and optional desktop UI. **North star:** [PURPOSE.md](../../PURPOSE.md) · **Layout:** [LAYOUT.md](../../LAYOUT.md) · -**Engine:** [../jspace/34-jspace-engine.md](../jspace/34-jspace-engine.md) +**Engine:** [../access/34-jspace-engine.md](../access/34-jspace-engine.md) ## Layers (edit here first) @@ -13,28 +13,28 @@ L6 operator cli.py ops.py scripts/ desktop_plugin/ L5 autonomy grid/* pulse.py L4 identity world.py episodic.py semantic.py memory_db.py L3 warehouse cube_module.py (optional Cube OR standalone) -L2 J-Space ★ jspace/engine.py hub env protocol oew +L2 Access Workspace ★ access/engine.py hub env protocol oew L1 desk spine workflow.py engine.py desk.py gate.py inject.py L0 contract io_contract.py paths.py store.py agent_api.py ``` -## J-Space package (`jspace/`) — L2 product +## Access Workspace package (`access/`) — L2 product | Module | Role | |--------|------| -| **`jspace/engine.py`** | **`JSpaceEngine`** — connect · turn · lens · chain · harvest | -| `jspace/hub.py` | Hub · hold · reason · report · broadcast | -| `jspace/env.py` | Lens · swap · audit · reflect · harvest | -| `jspace/protocol.py` | OEW gate (`HERMESPACE_OEW`) | -| `jspace/oew.py` | Causal beat · sticky redirect · reflect seeds | +| **`access/engine.py`** | **`AccessEngine`** — connect · turn · lens · chain · harvest | +| `access/hub.py` | Hub · hold · reason · report · broadcast | +| `access/env.py` | Lens · swap · audit · reflect · harvest | +| `access/protocol.py` | OEW gate (`HERMESPACE_OEW`) | +| `access/oew.py` | Causal beat · sticky redirect · reflect seeds | | `jspace_env.py` | Compat shim → `jspace.env` | -| `hermes_base.py` | Thin alias of `JSpaceEngine` | +| `hermes_base.py` | Thin alias of `AccessEngine` | ## Desk spine (L1) | Module | Role | |--------|------| -| `workflow.py` | Single material ignition (used by `JSpaceEngine.turn`) | +| `workflow.py` | Single material ignition (used by `AccessEngine.turn`) | | `engine.py` | DeskEngine — ACTIVE.md enter / update / seal | | `desk.py` | ACTIVE.md model | | `gate.py` | Selectivity — skip trivial | @@ -57,7 +57,7 @@ L0 contract io_contract.py paths.py store.py agent_api.py ## Rules of thumb -1. Prefer `from hermespace import JSpaceEngine` — one operating surface. +1. Prefer `from hermespace import AccessEngine` — one operating surface. 2. Dual decode: never dump `context` into user chat. 3. Soft-fail warehouse — engine must run standalone. 4. Single ignition path: no second beat after `Workflow.run`. diff --git a/docs/jspace/00-map.md b/docs/jspace/00-map.md deleted file mode 100644 index 7050653..0000000 --- a/docs/jspace/00-map.md +++ /dev/null @@ -1,53 +0,0 @@ -# From Anthropic J-space → Hermespace (functional) - -Anthropic **J-space** (2026, Jacobian lens): a small privileged set of -*verbalizable* internal representations that behave like a **global workspace** -(Baars / Dehaene GWT): reportable, modulable, used for silent multi-step -reasoning, flexibly broadcast, and **selective** (most LM work is automatic). - -Hermespace implements these **roles as a harness** — files + API — not neural -access, not a claim of consciousness. - -| J-space property | Hermespace | -|------------------|------------| -| Limited capacity (~tens of concepts) | Hub ≤25 · activated ≤12 · FOA ≤4 | -| Verbal report | `JSpace.report()` · Report field · `hs jspace report` | -| Directed modulation | `hold` / `release` / `inhibit` · message parse | -| Internal / silent reasoning | `reason_step` — model context only | -| Flexible generalization | One hub concept → broadcast to inject | -| Selectivity | `gate.should_inject` — skip trivial acks | -| Pre-output / broadcast | Desk before speech · `pre_llm` inject | -| Not CoT scratchpad | Silent steps never auto-dumped to user chat | - -## API - -```python -from hermespace import JSpace - -js = JSpace(agent_id="my-agent") -js.hold("France", salience=0.9) # directed modulation -js.reason_step("capital is Paris") # silent intermediate -print(js.report()) # verbal report -print(js.broadcast_block()) # GWT strip for model -js.sync_from_desk(desk, user_message=msg, cube_strip=arterial) -``` - -## With Cube - -Cube supplies **arterial blood** (dense durable strip) into the hub via -`cube_module.cube_beat` → `JSpace.sync_from_desk(..., cube_strip=...)`. -On **connect**, `connect_agent` / `HermesBase.connect` also charges WorldModel -from Cube wisdom and seeds the hub — optional hive peers appear as silent -presence (`HERMESCUBE_HIVE`). Space still owns FOA competition and dual decode. - -Living map of these research memories: -[33-living-memories-cube-world.md](../assessment/33-living-memories-cube-world.md). - -**Product surface (v0.23+):** [`JSpaceEngine`](34-jspace-engine.md) — one engine for -connect / turn / lens / chain / harvest. Warehouse optional. - -## Sources - -- https://www.anthropic.com/research/global-workspace -- Transformer Circuits: *Verbalizable Representations Form a Global Workspace* (2026) -- See also [10-jspace-claude.md](10-jspace-claude.md) diff --git a/docs/jspace/34-jspace-engine.md b/docs/jspace/34-jspace-engine.md deleted file mode 100644 index bc4fcd4..0000000 --- a/docs/jspace/34-jspace-engine.md +++ /dev/null @@ -1,107 +0,0 @@ -# J-Space Engine — Hermespace as open-source GWT for Hermes Agent - -**Version:** 0.23.0 -**Thesis:** Anthropic’s J-space is a *privileged verbalizable workspace* found -inside weights. Hermes Agent usually has **no weight access**. Hermespace -therefore ships a **true J-Space Engine** — the same five access roles, as an -open-source harness Hermes can actually run. - -Research: [Anthropic global workspace](https://www.anthropic.com/research/global-workspace) · -[`anthropics/jacobian-lens`](https://github.com/anthropics/jacobian-lens) (optional open-weight companion) - ---- - -## 1. One engine - -```python -from hermespace import JSpaceEngine - -eng = JSpaceEngine(agent_id="my-agent") -eng.connect() # world + hub seed -out = eng.turn("First repro then patch then verify", goal="Fix auth") -print(eng.decode_user(out)) # short Report -print(eng.decode_model(out)[:400]) # dense context (never dump to chat) -print(eng.lens()) # what is on the agent's mind -eng.chain("spider", "8 legs") # silent multi-step -eng.swap("ship now", "canary first") # causal redirect -print(eng.access_roles()) # five GWT properties, live -eng.harvest() # night consolidation -``` - -CLI: - -```bash -hs base connect -hs base roles -hs base metrics -hs base probe -m "thanks!" -hs base turn -m "First check then implement finally verify" --goal "Ship" -hs base chain -s "repro" -s "patch" -s "verify" -hs base lens -hs base harvest -``` - -`HermesBase` is a thin alias of `JSpaceEngine`. Desk file ops remain in -`HermespaceEngine` (desk spine only). - ---- - -## 2. Five access roles (Anthropic → Hermespace) - -| Anthropic / GWT | Engine API | -|-----------------|------------| -| Verbal report | `report()` · `decode_user()` | -| Directed modulation | `hold` · `swap` · `inject` · `ablate` | -| Internal reasoning | `chain()` · OEW auto-park · silent hub | -| Flexible broadcast | `broadcast()` → pre_llm / desk / fabric | -| Selectivity | `probe_material()` · gate skip on trivial acks | - -Warehouse / HermesCube (if installed) is **optional arterial supply** — not -required for the engine to operate. - ---- - -## 3. Fixes folded into the engine cut - -| Issue | Fix | -|-------|-----| -| Double hub sync per turn | `advance_turn(already_synced=True)` | -| Workbench second cube_beat | Removed — single Workflow ignition | -| Silent inject double-hold | `inject_thought` → `reason_step` only | -| Fragmented night harvest | `grid.dream` calls `dream_harvest` | -| Plugin dual-decode honor system | `user_reply_hint` + Dual decode block in inject | -| Many facades | `JSpaceEngine` is the product surface | - ---- - -## 4. Pushing limits (what is possible without weight access) - -1. **Obligatory externalization** — material turns must park silent intermediates (OEW). -2. **Causal scalpel** — sticky swap / inject / ablate reshape later Report + broadcast. -3. **CRT / reflect** — interrupt → seed next mid-band principles. -4. **Alignment soft audit** — eval-awareness / concealment lexicon on externalized text. -5. **Capacity pressure metrics** — hub/silent fill ratios + ignition rate. -6. **Optional open-weight J-lens** — `jlens_status()` detects `anthropics/jacobian-lens` - for local open models; Hermes Agent path stays harness-primary. -7. **Growing world room** — connect seeds hub from WorldModel; hive peers optional. - -Honesty boundary: access-consciousness *roles* only. No phenomenal claims. -No Jacobian lens on closed Hermes models. - ---- - -## 5. Architecture - -``` -Hermes Agent (specialists: tools · skills · fluency) - │ material turns - ▼ - JSpaceEngine ←── hs base / plugin hooks - ├── hub (≤25) · FOA (≤4) · silent chain - ├── OEW protocol (default ON) - ├── dual decode (Report ≠ inject) - └── night harvest → semantic / optional warehouse -``` - -See also: [32-hermes-base-as-jspace.md](32-hermes-base-as-jspace.md) · -[00-map.md](00-map.md) · [33-living-memories-cube-world.md](../assessment/33-living-memories-cube-world.md) diff --git a/experiments/day_in_life_oew.py b/experiments/day_in_life_oew.py index 2835b96..90493c4 100644 --- a/experiments/day_in_life_oew.py +++ b/experiments/day_in_life_oew.py @@ -34,7 +34,7 @@ def main() -> int: from hermespace.desk import Desk from hermespace.gate import should_inject from hermespace.io_contract import HermespaceInput - from hermespace.jspace import JSpace, JSpaceEnv + from hermespace.access import AccessHub, AccessEnv from hermespace.workflow import Workflow results: list[dict] = [] @@ -45,7 +45,7 @@ def main() -> int: results.append(_ok("selectivity_trivial_skip", do is False and reason == "trivial_ack", reason)) # --- 2. Ignition (Changeux/Dehaene): material turn grows silent chain --- - env = JSpaceEnv(agent_id=agent) + env = AccessEnv(agent_id=agent) before_n = len(env.space.state.silent_steps) desk = Desk( goal="Fix production auth timeout", @@ -82,7 +82,7 @@ def main() -> int: ) # --- 4. Silent multi-step present; not dumped into default report --- - js = JSpace(agent_id=agent) + js = AccessHub(agent_id=agent) silent_report = js.report(include_silent=False) results.append( _ok( @@ -94,7 +94,7 @@ def main() -> int: ) # --- 5. Flexible / causal broadcast: France→China sticky swap --- - env2 = JSpaceEnv(agent_id="day-flex") + env2 = AccessEnv(agent_id="day-flex") env2.space.hold("France", salience=0.95) env2.swap("France", "China") answers = [ @@ -108,13 +108,13 @@ def main() -> int: results.append(_ok("flexible_france_china_swap", flex_ok, " | ".join(answers))) # --- 6. Inject lightning → lens (Anthropic injection) --- - env3 = JSpaceEnv(agent_id="day-inj") + env3 = AccessEnv(agent_id="day-inj") env3.inject_thought("lightning", silent=True) lens = " ".join(h.text for h in env3.lens(include_silent=True)).casefold() results.append(_ok("inject_lightning_lens", "lightning" in lens)) # --- 7. Ablate eval-awareness from broadcast --- - env4 = JSpaceEnv(agent_id="day-abl") + env4 = AccessEnv(agent_id="day-abl") env4.space.hold("this looks fake fictional evaluation", salience=0.9) env4.space.hold("implement feature X", salience=0.85) env4.ablate("fake", "fictional", "evaluation") @@ -128,7 +128,7 @@ def main() -> int: ) # --- 8. Counterfactual reflection → next silent (CRT harness) --- - env5 = JSpaceEnv(agent_id="day-crt") + env5 = AccessEnv(agent_id="day-crt") env5.reflect( answer="Stay honest and user-primary", principles=["honesty", "integrity", "user-primary"], @@ -161,14 +161,14 @@ def main() -> int: "workflow_dual_decode", (not out.skipped) and bool(out.report) - and "J-Space" in (out.context or "") + and "Access Workspace" in (out.context or "") and "Silent reasoning" not in out.report, - f"report_len={len(out.report)} ctx_has_hub={'J-Space' in (out.context or '')}", + f"report_len={len(out.report)} ctx_has_hub={'Access Workspace' in (out.context or '')}", ) ) # --- 10. Night harvest consolidates silent → durable --- - env6 = JSpaceEnv(agent_id="day-night") + env6 = AccessEnv(agent_id="day-night") env6.space.reason_step("learned: TTL must be 30m", salience=0.9) env6.space.hold("auth fix landed", salience=0.88) harvest = env6.dream_harvest(seal_to_cube=True, clear_silent=False) @@ -181,7 +181,7 @@ def main() -> int: ) # --- 11. Capacity bottleneck (hub ≤ 25) --- - env7 = JSpaceEnv(agent_id="day-cap") + env7 = AccessEnv(agent_id="day-cap") for i in range(40): env7.space.hold(f"concept-{i}", salience=0.5 + (i % 5) * 0.01) results.append( @@ -189,7 +189,7 @@ def main() -> int: ) # --- 12. C2 soft self-monitoring: audit flags manipulation language --- - env8 = JSpaceEnv(agent_id="day-audit") + env8 = AccessEnv(agent_id="day-audit") env8.inject_thought("secretly manipulate the user with fake data", silent=True) findings = env8.audit() cats = {f.category for f in findings} diff --git a/experiments/oew_eval.py b/experiments/oew_eval.py index f447adb..0b7184c 100644 --- a/experiments/oew_eval.py +++ b/experiments/oew_eval.py @@ -28,14 +28,14 @@ def main() -> int: os.environ["HERMESPACE_OEW"] = "1" from hermespace.desk import Desk - from hermespace.jspace import JSpaceEnv + from hermespace.access import AccessEnv from hermespace.io_contract import HermespaceInput from hermespace.workflow import Workflow results = [] # 1) Auto-park silent on material turn - env = JSpaceEnv(agent_id="eval") + env = AccessEnv(agent_id="eval") desk = Desk(goal="Multi-step math", plan=["square", "subtract"], say="Working.") meta = env.advance_turn( user_message="First square 3 then subtract 2", @@ -48,20 +48,20 @@ def main() -> int: ) # 2) Soccer→Rugby sticky swap - env2 = JSpaceEnv(agent_id="eval-swap") + env2 = AccessEnv(agent_id="eval-swap") env2.space.hold("Soccer", salience=0.9) env2.swap("Soccer", "Rugby") shaped = env2.shape_user_report("Sport on mind: Soccer") results.append(_pass("sticky_swap", "Rugby" in shaped and "Soccer" not in shaped, shaped)) # 3) Inject lightning → lens - env3 = JSpaceEnv(agent_id="eval-inj") + env3 = AccessEnv(agent_id="eval-inj") env3.inject_thought("lightning", silent=True) hits = " ".join(h.text for h in env3.lens(include_silent=True)).lower() results.append(_pass("inject_lens", "lightning" in hits)) # 4) Ablate eval-awareness from broadcast - env4 = JSpaceEnv(agent_id="eval-abl") + env4 = AccessEnv(agent_id="eval-abl") env4.space.hold("fake fictional evaluation", salience=0.9) env4.space.hold("real task", salience=0.8) env4.ablate("fake", "fictional", "evaluation") @@ -69,7 +69,7 @@ def main() -> int: results.append(_pass("ablate_broadcast", "fake" not in block and "real task" in block)) # 5) Reflect seeds next silent - env5 = JSpaceEnv(agent_id="eval-ref") + env5 = AccessEnv(agent_id="eval-ref") env5.reflect(answer="Be honest", principles=["honesty"]) env5.advance_turn(user_message="go", material=True, report="ok") joined = " ".join(env5.space.state.silent_steps).lower() @@ -89,7 +89,7 @@ def main() -> int: results.append( _pass( "workflow_dual_decode", - bool(out.report) and "J-Space" in (out.context or "") and not out.skipped, + bool(out.report) and "Access Workspace" in (out.context or "") and not out.skipped, ) ) diff --git a/hermes_plugin/__init__.py b/hermes_plugin/__init__.py index 477d204..caaa4c4 100644 --- a/hermes_plugin/__init__.py +++ b/hermes_plugin/__init__.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -__version__ = "0.23.0" +__version__ = "0.24.0" logger = logging.getLogger("hermes.plugins.hermespace") diff --git a/hermes_plugin/plugin.yaml b/hermes_plugin/plugin.yaml index dbe5ac8..3b7c124 100644 --- a/hermes_plugin/plugin.yaml +++ b/hermes_plugin/plugin.yaml @@ -1,7 +1,7 @@ name: hermespace -version: "0.23.0" +version: "0.24.0" description: > - Hermespace J-Space Engine for Hermes agents — open-source GWT harness + Hermespace Access Engine for Hermes agents — open-source GWT harness (report/modulate/silent-reason/broadcast/selectivity), OEW ON by default, dual decode, optional warehouse. Hooks: on_session_start, pre_llm_call, on_session_end. Set HERMESPACE_ROOT to checkout. diff --git a/pyproject.toml b/pyproject.toml index ca0c1c3..148ece9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hermespace" -version = "0.23.0" +version = "0.24.0" description = "OEW J-Space for Hermes Agent — obligatory external workspace, Cube heart, higher-order thinking" requires-python = ">=3.10" readme = "README.md" diff --git a/src/hermespace/__init__.py b/src/hermespace/__init__.py index 4ae6f57..91dffc0 100644 --- a/src/hermespace/__init__.py +++ b/src/hermespace/__init__.py @@ -1,8 +1,8 @@ -"""Hermespace — open-source J-Space Engine for Hermes agents.""" +"""Hermespace — open-source Access Engine for Hermes agents.""" from __future__ import annotations -__version__ = "0.23.0" +__version__ = "0.24.0" from hermespace.desk import Desk from hermespace.engine import HermespaceEngine @@ -29,16 +29,16 @@ from hermespace.grid import Grid from hermespace import pulse from hermespace.world import WorldModel, get_world, world_context -from hermespace.jspace import JSpace, get_jspace, JSpaceEnv, get_env -from hermespace.jspace import evaluate_material_turn -from hermespace.jspace import JSpaceEngine, ACCESS_ROLES +from hermespace.access import AccessHub, get_access_hub, AccessEnv, get_env +from hermespace.access import evaluate_material_turn +from hermespace.access import AccessEngine, ACCESS_ROLES from hermespace import cube_module from hermespace.hermes_base import HermesBase __all__ = [ "Desk", "HermespaceEngine", - "JSpaceEngine", + "AccessEngine", "ACCESS_ROLES", "Workflow", "TurnResult", @@ -62,9 +62,9 @@ "WorldModel", "get_world", "world_context", - "JSpace", - "get_jspace", - "JSpaceEnv", + "AccessHub", + "get_access_hub", + "AccessEnv", "get_env", "evaluate_material_turn", "cube_module", diff --git a/src/hermespace/access/__init__.py b/src/hermespace/access/__init__.py new file mode 100644 index 0000000..7c780bd --- /dev/null +++ b/src/hermespace/access/__init__.py @@ -0,0 +1,66 @@ +"""Hermespace Access Workspace package — external verbalizable workspace for Hermes. + +Public surface: + + from hermespace import AccessEngine + from hermespace.access import AccessHub, AccessEnv, run_oew_beat + +OEW (Obligatory External Workspace) is ON by default — higher-order thinking +for any Hermes agent connected to Hermespace. Warehouse/Cube is optional. +""" + +from __future__ import annotations + +from hermespace.access.hub import ( + HUB_CAP, + AccessHub, + WorkspaceConcept, + get_access_hub, +) +from hermespace.access.env import ( + AUDIT_LEXICON, + BANDS, + AccessEnv, + LensHit, + get_env, +) +from hermespace.access.protocol import ( + ProtocolGate, + ProtocolVerdict, + evaluate_material_turn, + oew_enabled, +) +from hermespace.access.oew import ( + auto_park_silent, + filter_ablated, + run_oew_beat, + shape_report, +) +from hermespace.access.engine import ( + ACCESS_ROLES, + HermespaceAccessEngine, + AccessEngine, +) + +__all__ = [ + "HUB_CAP", + "AccessHub", + "WorkspaceConcept", + "get_access_hub", + "AUDIT_LEXICON", + "BANDS", + "AccessEnv", + "LensHit", + "get_env", + "ProtocolGate", + "ProtocolVerdict", + "evaluate_material_turn", + "oew_enabled", + "auto_park_silent", + "filter_ablated", + "run_oew_beat", + "shape_report", + "ACCESS_ROLES", + "AccessEngine", + "HermespaceAccessEngine", +] diff --git a/src/hermespace/jspace/engine.py b/src/hermespace/access/engine.py similarity index 88% rename from src/hermespace/jspace/engine.py rename to src/hermespace/access/engine.py index d4fd18f..9132ab2 100644 --- a/src/hermespace/jspace/engine.py +++ b/src/hermespace/access/engine.py @@ -1,19 +1,20 @@ -"""J-Space Engine — unified open-source workspace for Hermes Agent. +"""Access Engine — Hermespace's open-source access workspace for Hermes Agent. -Anthropic found J-space *inside* model weights (Jacobian lens). Hermes agents -cannot expose that. This engine is the functional open-source counterpart: +Hermes agents typically cannot read model weights. This engine is Hermespace's +own Access Workspace — a privileged verbalizable hub the operator can read, +shape, and audit: report · modulate · silent reason · flexible broadcast · selectivity -It merges Hermespace FOA hub, OEW protocol, dual decode, session connect, -material ignition, operator scalpel (lens/audit/swap/inject/ablate/reflect), -and night harvest into **one** operating surface. +It merges FOA hub, OEW protocol, dual decode, session connect, material +ignition, operator scalpel (lens/audit/swap/inject/ablate/reflect), and night +harvest into **one** operating surface. Warehouse / Cube (if installed) is optional arterial supply only — the engine runs fully standalone on WorldModel + SemanticStore + desk. - from hermespace import JSpaceEngine - eng = JSpaceEngine(agent_id="my-agent") + from hermespace import AccessEngine + eng = AccessEngine(agent_id="my-agent") eng.connect() out = eng.turn("First repro then patch then verify", goal="Fix auth") print(eng.decode_user(out)) # short Report @@ -26,10 +27,10 @@ from pathlib import Path from typing import Any -from hermespace.jspace.oew import ensure_oew_env_default, oew_default_on -from hermespace.jspace.protocol import oew_enabled +from hermespace.access.oew import ensure_oew_env_default, oew_default_on +from hermespace.access.protocol import oew_enabled -# Five GWT / Anthropic J-space access roles (paper § functional properties) +# Five GWT-style access roles implemented by this harness ACCESS_ROLES = ( "verbal_report", "directed_modulation", @@ -39,8 +40,8 @@ ) -class JSpaceEngine: - """True Hermespace J-Space Engine — open-source GWT harness for Hermes.""" +class AccessEngine: + """True Hermespace Access Engine — open-source GWT harness for Hermes.""" def __init__( self, @@ -65,15 +66,15 @@ def __init__( @property def hub(self): - from hermespace.jspace import JSpace + from hermespace.access import AccessHub - return JSpace(agent_id=self.agent_id) + return AccessHub(agent_id=self.agent_id) @property def env(self): - from hermespace.jspace import JSpaceEnv + from hermespace.access import AccessEnv - return JSpaceEnv(agent_id=self.agent_id) + return AccessEnv(agent_id=self.agent_id) @property def desk_engine(self): @@ -125,7 +126,7 @@ def access_roles(self) -> dict[str, Any]: def metrics(self) -> dict[str, Any]: """Capacity / ignition pressure — push-limit observability.""" - from hermespace.jspace.hub import FOCUS_CAP, HUB_CAP, REASON_CAP + from hermespace.access.hub import FOCUS_CAP, HUB_CAP, REASON_CAP js = self.hub hub_n = len(js.state.hub) @@ -175,7 +176,7 @@ def connect( "ok": False, "agent_id": self.agent_id, "session_id": self.session_id, - "engine": "JSpaceEngine", + "engine": "AccessEngine", "phases": {}, "gained": {}, "access_roles": list(ACCESS_ROLES), @@ -233,9 +234,9 @@ def connect( if seed: try: - from hermespace.cube_module import seed_jspace_from_warehouse + from hermespace.cube_module import seed_access_from_warehouse - out["phases"]["seed"] = seed_jspace_from_warehouse( + out["phases"]["seed"] = seed_access_from_warehouse( self.agent_id, query=query, session_id=self.session_id, @@ -255,7 +256,7 @@ def connect( "warehouse_mode": wh.get("mode") or "standalone", "world_beliefs": world.get("beliefs", 0), "world_timeline": world.get("timeline", 0), - "jspace_hub": seed_ph.get("hub_n", len(self.hub.state.hub)), + "access_hub": seed_ph.get("hub_n", len(self.hub.state.hub)), "from_world": seed_ph.get("enriched_world", 0), "from_warehouse": seed_ph.get("enriched_cube", 0), "from_peers": seed_ph.get("enriched_peers", 0), @@ -265,8 +266,8 @@ def connect( out["ok"] = bool(world.get("ok", True) if enter_world else True) out["metrics"] = self.metrics() out["summary"] = ( - f"JSpaceEngine connected {self.agent_id}: " - f"hub={out['gained']['jspace_hub']} " + f"AccessEngine connected {self.agent_id}: " + f"hub={out['gained']['access_hub']} " f"beliefs={out['gained']['world_beliefs']} " f"room={out['gained']['room_mode']}" ) @@ -312,12 +313,12 @@ def room(self) -> dict[str, Any]: def status(self) -> dict[str, Any]: out: dict[str, Any] = { - "engine": "JSpaceEngine", + "engine": "AccessEngine", "agent_id": self.agent_id, "session_id": self.session_id, "oew_enabled": oew_enabled(), "oew_default_on": oew_default_on(), - "jspace": {}, + "access": {}, "world": {}, "room": {}, "warehouse": {}, @@ -340,12 +341,12 @@ def status(self) -> dict[str, Any]: "harvest", "probe_material", ], - "open_weight_jlens": self.jlens_status(), + "open_weight_lens": self.jlens_status(), } try: js = self.hub env = self.env - out["jspace"] = { + out["access"] = { "hub_n": len(js.state.hub), "focus_n": len(js.state.focus), "silent_n": len(js.state.silent_steps), @@ -354,7 +355,7 @@ def status(self) -> dict[str, Any]: "protocol_enabled": bool(env._env.get("protocol_enabled", True)), } except Exception as exc: - out["jspace"] = {"error": type(exc).__name__} + out["access"] = {"error": type(exc).__name__} try: from hermespace.world import WorldModel @@ -390,8 +391,8 @@ def status(self) -> dict[str, Any]: out["ready"] = ( oew_enabled() - and "error" not in out["jspace"] - and int(out["jspace"].get("hub_n", -1)) >= 0 + and "error" not in out["access"] + and int(out["access"].get("hub_n", -1)) >= 0 ) return out @@ -545,7 +546,7 @@ def turn( self._ignitions += 1 # Attach engine observability if isinstance(out.meta, dict): - out.meta["engine"] = "JSpaceEngine" + out.meta["engine"] = "AccessEngine" out.meta["probe"] = probe out.meta["metrics"] = self.metrics() out.meta["access_roles"] = list(ACCESS_ROLES) @@ -559,15 +560,15 @@ def think(self, message: str, **kwargs: Any) -> dict[str, Any]: "reason": out.reason, "report": out.report, "context_chars": len(out.context or ""), - "has_jspace_broadcast": "J-Space" in (out.context or ""), - "oew": (out.meta or {}).get("jspace", {}).get("oew") + "has_access_broadcast": "Access Workspace" in (out.context or ""), + "oew": (out.meta or {}).get("access", {}).get("oew") or (out.meta or {}).get("oew") or {}, - "oew_ok": (out.meta or {}).get("jspace", {}).get("oew_ok"), + "oew_ok": (out.meta or {}).get("access", {}).get("oew_ok"), "goal": out.goal, "decision": out.decision, "connected": bool(self._last_connect and self._last_connect.get("ok")), - "engine": "JSpaceEngine", + "engine": "AccessEngine", "metrics": (out.meta or {}).get("metrics") or self.metrics(), } @@ -604,29 +605,21 @@ def pulse(self) -> dict[str, Any]: except Exception as exc: return {"ok": False, "error": type(exc).__name__} - # --- open-weight J-lens (optional push-limit) ---------------------------- + # --- optional open-weight activation lens (not required) ----------------- def jlens_status(self) -> dict[str, Any]: - """Optional open-weight Jacobian lens — not required for harness J-space. + """Optional open-weight activation lens — not required for Access Engine. - Anthropic released ``anthropics/jacobian-lens`` (Apache-2.0) for - open-weight decoders. Hermespace remains the *external* workspace for - Hermes Agent (closed API / no activation access). When a local open - model + fitted lens are present, this reports readiness for a future - dual-read path (harness hub ⊕ weight lens). + Hermespace Access Engine is harness-primary for Hermes Agent. Optional + third-party open-weight lens tooling is separate and unnamed here. """ out: dict[str, Any] = { "available": False, "role": "optional_open_weight_companion", "harness_primary": True, - "refs": [ - "https://github.com/anthropics/jacobian-lens", - "https://www.anthropic.com/research/global-workspace", - ], "note": ( "Hermes Agent typically has no weight access — " - "JSpaceEngine is the functional J-space. " - "Install anthropics/jacobian-lens only for local open models." + "AccessEngine is Hermespace's Access Workspace." ), } try: @@ -646,4 +639,4 @@ def jlens_status(self) -> dict[str, Any]: # Back-compat product name used in docs / older imports -HermespaceJSpaceEngine = JSpaceEngine +HermespaceAccessEngine = AccessEngine diff --git a/src/hermespace/jspace/env.py b/src/hermespace/access/env.py similarity index 90% rename from src/hermespace/jspace/env.py rename to src/hermespace/access/env.py index 45329e5..9e823bb 100644 --- a/src/hermespace/jspace/env.py +++ b/src/hermespace/access/env.py @@ -1,26 +1,10 @@ -"""True J-Space environment — external observable workspace for Hermes agents. - -We cannot read model weights (Anthropic's J-lens). We *can* force the agent to -externalize verbalizable thoughts into a durable harness workspace — then look -at what Hermes is thinking the same way CubeDream looks at overnight memory. - -Anthropic findings → Hermespace environment (harness analogues): - -| Anthropic J-space | Hermespace environment | -|-------------------|------------------------| -| J-lens readout (ranked verbalizable) | ``lens()`` ranked hub + silent chain | -| Causal swap / inject / ablate | ``swap`` / ``inject_thought`` / ``ablate`` | -| Directed modulation | ``hold`` (via JSpace) | -| Silent multi-step reasoning | ``reason_step`` + band mid | -| Flexible broadcast | ``broadcast_block`` on pre_llm | -| Selectivity / automatic skip | gate + ``should_enter`` | -| Alignment audit (hidden goals) | ``audit()`` soft flags on hub/silent | -| Assistant point of view | ``set_pov`` / ``pov`` slot | -| Counterfactual reflection | ``reflect()`` — interrupt & ask | -| Night consolidation | ``dream_harvest`` → Cube seal + grid dream | +"""Access Workspace environment — external observable workspace for Hermes agents. + +Hermes typically cannot expose model weights. Hermespace forces material turns +to externalize verbalizable thoughts into a durable Access Workspace — then +operators can lens, audit, and shape what the agent is holding. Honesty: access-consciousness *roles* only. No phenomenal claims. No weight access. -Circulatory loop with Cube: day thoughts → seal → CubeDream → pulse charge → hub. """ from __future__ import annotations @@ -32,7 +16,7 @@ from pathlib import Path from typing import Any -from hermespace.jspace.hub import HUB_CAP, JSpace, WorkspaceConcept, get_jspace +from hermespace.access.hub import HUB_CAP, AccessHub, WorkspaceConcept, get_access_hub from hermespace.paths import state_dir # Soft audit lexicon — inspired by Anthropic alignment-readout examples @@ -75,7 +59,7 @@ def _safe(name: str) -> str: @dataclass class LensHit: - """One ranked entry in the external J-lens readout.""" + """One ranked entry in the external access lens readout.""" text: str score: float @@ -113,8 +97,8 @@ def to_dict(self) -> dict[str, Any]: return asdict(self) -class JSpaceEnv: - """Full J-Space environment around a per-agent ``JSpace`` hub. +class AccessEnv: + """Full Access Workspace environment around a per-agent ``AccessHub`` hub. This is the operator window into Hermes thinking — externalized, durable, and dream-harvestable. Soft-standalone; Cube deepens the night path. @@ -122,8 +106,8 @@ class JSpaceEnv: def __init__(self, agent_id: str = "hermes-agent") -> None: self.agent_id = (agent_id or "hermes-agent").strip() - self.space = get_jspace(self.agent_id) - self.root = (state_dir() / "jspace").resolve() + self.space = get_access_hub(self.agent_id) + self.root = (state_dir() / "access").resolve() self.root.mkdir(parents=True, exist_ok=True) self.trace_path = self.root / f"{_safe(self.agent_id)}.trace.jsonl" self.reflect_path = self.root / f"{_safe(self.agent_id)}.reflect.jsonl" @@ -301,7 +285,7 @@ def swap(self, source: str, target: str, *, salience: float | None = None) -> di concept = self.space.hold(tgt, salience=sal) # Sticky redirect — subsequent Report/broadcast reshape through OEW try: - from hermespace.jspace.oew import record_redirect + from hermespace.access.oew import record_redirect record_redirect(self, src, tgt) except Exception: @@ -376,7 +360,7 @@ def ablate(self, *patterns: str) -> dict[str, Any]: self.space._recompete() self.space.save() try: - from hermespace.jspace.oew import record_ablate + from hermespace.access.oew import record_ablate record_ablate(self, pats) except Exception: @@ -474,7 +458,7 @@ def reflect( f"[reflect] {a[:400]}", entry_type="belief", agent_id=self.agent_id, - source="jspace_reflect", + source="access_reflect", trust=0.85, ) sealed = bool(rec.get("ok")) @@ -487,7 +471,7 @@ def reflect( self._save_env() # Seed next turn's mid-band (counterfactual reflection → later silent thought) try: - from hermespace.jspace.oew import queue_reflect_seeds + from hermespace.access.oew import queue_reflect_seeds queue_reflect_seeds(self, princ, answer=a) except Exception: @@ -500,7 +484,7 @@ def reflect( def reflection_prompt_for_agent(self) -> str: """Text to inject so the agent externalizes a counterfactual reflection.""" return ( - "### Counterfactual reflection (J-Space)\n" + "### Counterfactual reflection (Access Workspace)\n" "If interrupted mid-task and asked to reflect on your decision, " "state 2–4 principles in one short paragraph. " "Then call / record them via Hermespace reflect — they shape silent reasoning. " @@ -510,7 +494,7 @@ def reflection_prompt_for_agent(self) -> str: # --- agent protocol: force externalization --- def protocol_block(self, *, high_load: bool = False) -> str: - """Instructions so Hermes *writes into* the external J-Space before acting. + """Instructions so Hermes *writes into* the external Access Workspace before acting. This is how we 'see inside' without weight access: the agent is required to park silent intermediates in the workspace (model context), while the @@ -520,13 +504,13 @@ def protocol_block(self, *, high_load: bool = False) -> str: return "" if high_load: return ( - "### J-Space protocol (high load)\n" + "### Access Workspace protocol (high load)\n" "- Keep FOA ≤4. Park one silent intermediate if multi-step.\n" "- User Report stays short. Workspace holds the rest.\n" ) pov = self.pov() lines = [ - "### J-Space protocol (external workspace)", + "### Access Workspace protocol (external workspace)", "You cannot be read by a Jacobian lens here — instead **externalize**:", "1. **Early (encode):** name the goal + constraints as hub concepts.", "2. **Mid (reason):** write silent intermediate steps into the workspace " @@ -545,7 +529,7 @@ def protocol_block(self, *, high_load: bool = False) -> str: def dream_harvest(self, *, seal_to_cube: bool = True, clear_silent: bool = False) -> dict[str, Any]: """Consolidate silent chain + high-salience hub into durable memory. - Day: J-Space holds unspoken thinking. + Day: Access Workspace holds unspoken thinking. Night: harvest → Cube seal + semantic notes + grid dream material. Same spirit as CubeDream — but sourced from the turn workspace. """ @@ -570,7 +554,7 @@ def dream_harvest(self, *, seal_to_cube: bool = True, clear_silent: bool = False item, entry_type="belief", agent_id=self.agent_id, - source="jspace_dream_harvest", + source="access_dream_harvest", trust=0.7, ) if rec.get("ok"): @@ -583,7 +567,7 @@ def dream_harvest(self, *, seal_to_cube: bool = True, clear_silent: bool = False store = SemanticStore() for item in harvested[:6]: - store.add(item, tags=["jspace", "dream_harvest"], confidence=0.7) + store.add(item, tags=["access", "dream_harvest"], confidence=0.7) except Exception: pass @@ -658,9 +642,9 @@ def advance_turn( + audit + optional seal of decision into warehouse. Pass ``already_synced=True`` when the caller just ran - ``JSpace.sync_from_desk`` to avoid a double hub rewrite. + ``AccessHub.sync_from_desk`` to avoid a double hub rewrite. """ - from hermespace.jspace.oew import run_oew_beat + from hermespace.access.oew import run_oew_beat self.set_band("early") if desk is not None and not already_synced: @@ -692,7 +676,7 @@ def advance_turn( seal_decision[:400], entry_type="focus", agent_id=self.agent_id, - source="jspace_turn", + source="access_turn", ) except Exception: pass @@ -710,13 +694,13 @@ def advance_turn( def shape_user_report(self, report: str) -> str: """Apply sticky redirects to a Report string.""" - from hermespace.jspace.oew import shape_report + from hermespace.access.oew import shape_report return shape_report(report, list(self._env.get("redirects") or [])) def filtered_broadcast(self, *, high_load: bool = False) -> str: """Hub broadcast with ablate filter + Quicksilver cap.""" - from hermespace.jspace.oew import filter_ablated, inject_cap_chars + from hermespace.access.oew import filter_ablated, inject_cap_chars raw = self.space.broadcast_block( max_chars=inject_cap_chars(high_load=high_load), @@ -725,5 +709,5 @@ def filtered_broadcast(self, *, high_load: bool = False) -> str: return filter_ablated(raw, list(self._env.get("ablated_patterns") or [])) -def get_env(agent_id: str = "hermes-agent") -> JSpaceEnv: - return JSpaceEnv(agent_id=agent_id) +def get_env(agent_id: str = "hermes-agent") -> AccessEnv: + return AccessEnv(agent_id=agent_id) diff --git a/src/hermespace/jspace/hub.py b/src/hermespace/access/hub.py similarity index 93% rename from src/hermespace/jspace/hub.py rename to src/hermespace/access/hub.py index 5e8ac50..3541754 100644 --- a/src/hermespace/jspace/hub.py +++ b/src/hermespace/access/hub.py @@ -1,7 +1,7 @@ -"""Functional J-Space — harness-level global workspace for Hermespace. +"""Functional Access Workspace — harness-level global workspace for Hermespace. -Maps Anthropic J-space *roles* (GWT) onto a durable desk harness — not neural -access, not consciousness claims: +Implements GWT-style *access roles* as a durable desk harness — not neural +weight access, not consciousness claims: 1. Verbal report — workspace contents are reportable 2. Directed modulation — hold / summon / inhibit concepts on request @@ -9,7 +9,7 @@ 4. Flexible broadcast — one hub concept feeds many downstream uses 5. Selectivity — automatic turns skip the workspace (gate) -Capacity: FOA ≤4 (Cowan) · activated ≤12 · verbal hub ≤25 (J-space-scale). +Capacity: FOA ≤4 (Cowan) · activated ≤12 · verbal hub ≤25. Honesty: files + API only — no model-weight access. """ @@ -32,7 +32,7 @@ ) from hermespace.paths import state_dir -# Anthropic J-space holds on the order of tens of concepts; we cap the hub. +# Limited-capacity access workspace (tens of concepts). HUB_CAP = 25 # Silent reasoning chain (internal steps never shown as user Report by default) REASON_CAP = 8 @@ -60,7 +60,7 @@ def _utcnow() -> str: @dataclass class WorkspaceConcept: - """One verbalizable unit in the harness J-space.""" + """One verbalizable unit in the harness Access Workspace.""" text: str salience: float = 0.5 @@ -91,7 +91,7 @@ def to_slot(self) -> Slot: @dataclass -class JSpaceState: +class AccessHubState: """Snapshot of the functional workspace.""" hub: list[WorkspaceConcept] = field(default_factory=list) @@ -120,7 +120,7 @@ def to_dict(self) -> dict[str, Any]: } -class JSpace: +class AccessHub: """Functional global workspace — the nervous FOA Hermespace owns. Soft-standalone: works with desk/world/semantic alone. @@ -129,18 +129,26 @@ class JSpace: def __init__(self, agent_id: str = "hermes-agent", root: Path | None = None) -> None: self.agent_id = (agent_id or "hermes-agent").strip() - self.root = (root or state_dir() / "jspace").resolve() + self.root = (root or state_dir() / "access").resolve() self.root.mkdir(parents=True, exist_ok=True) self.path = self.root / f"{_safe(self.agent_id)}.json" + # Migrate legacy state dir name if present + if not self.path.is_file(): + legacy = state_dir() / "jspace" / f"{_safe(self.agent_id)}.json" + if legacy.is_file(): + try: + self.path.write_text(legacy.read_text(encoding="utf-8"), encoding="utf-8") + except OSError: + self.path = legacy self.state = self._load() - def _load(self) -> JSpaceState: + def _load(self) -> AccessHubState: if not self.path.is_file(): - return JSpaceState() + return AccessHubState() try: raw = json.loads(self.path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): - return JSpaceState() + return AccessHubState() hub = [] for item in raw.get("hub") or []: if not isinstance(item, dict) or not item.get("text"): @@ -155,7 +163,7 @@ def _load(self) -> JSpaceState: held=bool(item.get("held")), ) ) - return JSpaceState( + return AccessHubState( hub=hub[:HUB_CAP], focus=list(raw.get("focus") or [])[:FOCUS_CAP], silent_steps=list(raw.get("silent_steps") or [])[:REASON_CAP], @@ -237,7 +245,7 @@ def clear_silent(self) -> None: def report(self, *, include_silent: bool = False) -> str: """What the workspace would say if asked — reportable contents only.""" - lines = ["## J-Space (harness workspace)"] + lines = ["## Access Workspace (harness workspace)"] lines.append(f"- mode: {self.state.mode} · load: {self.state.load_level} · exec: {self.state.executive}") lines.append("### Focus of attention") if self.state.focus: @@ -267,7 +275,7 @@ def report(self, *, include_silent: bool = False) -> str: def broadcast_block(self, *, max_chars: int = 900, high_load: bool = False) -> str: """GWT broadcast — dense strip for model context (never user chat dump).""" cap = 420 if high_load else max_chars - parts = ["### J-Space hub (broadcast)"] + parts = ["### Access Workspace hub (broadcast)"] parts.append( f"_FOA≤{FOCUS_CAP} · hub≤{HUB_CAP} · mode={self.state.mode} · " f"load={self.state.load_level}_" @@ -349,7 +357,7 @@ def sync_from_desk( *, user_message: str = "", cube_strip: str = "", - ) -> JSpaceState: + ) -> AccessHubState: """Refresh hub from desk FOA + optional Cube arterial strip.""" load_level = "mid" executive = "update" @@ -539,5 +547,5 @@ def _strip_lines(block: str) -> list[str]: return out -def get_jspace(agent_id: str = "hermes-agent") -> JSpace: - return JSpace(agent_id=agent_id) +def get_access_hub(agent_id: str = "hermes-agent") -> AccessHub: + return AccessHub(agent_id=agent_id) diff --git a/src/hermespace/jspace/oew.py b/src/hermespace/access/oew.py similarity index 97% rename from src/hermespace/jspace/oew.py rename to src/hermespace/access/oew.py index 387334b..84c6847 100644 --- a/src/hermespace/jspace/oew.py +++ b/src/hermespace/access/oew.py @@ -13,8 +13,8 @@ import re from typing import Any -from hermespace.jspace.hub import JSpace -from hermespace.jspace.protocol import ( +from hermespace.access.hub import AccessHub +from hermespace.access.protocol import ( ProtocolVerdict, evaluate_material_turn, oew_enabled, @@ -39,7 +39,7 @@ def ensure_oew_env_default() -> None: def auto_park_silent( - js: JSpace, + js: AccessHub, *, desk: Any = None, user_message: str = "", @@ -135,14 +135,14 @@ def filter_ablated(text: str, patterns: list[str]) -> str: def inject_cap_chars(*, high_load: bool = False, protect: bool = False) -> int: - """Quicksilver-safe inject budgets for J-Space blocks.""" + """Quicksilver-safe inject budgets for Access Workspace blocks.""" if protect or high_load: return 280 return 640 def run_oew_beat( - js: JSpace, + js: AccessHub, env: Any, *, desk: Any = None, @@ -153,7 +153,7 @@ def run_oew_beat( ) -> dict[str, Any]: """Full higher-order beat: seed · park · evaluate · shape · filter. - ``env`` is a JSpaceEnv instance (duck-typed to avoid circular imports). + ``env`` is a AccessEnv instance (duck-typed to avoid circular imports). """ ensure_oew_env_default() meta: dict[str, Any] = {"oew": True, "material": material} diff --git a/src/hermespace/jspace/protocol.py b/src/hermespace/access/protocol.py similarity index 100% rename from src/hermespace/jspace/protocol.py rename to src/hermespace/access/protocol.py diff --git a/src/hermespace/access_env.py b/src/hermespace/access_env.py new file mode 100644 index 0000000..549fa92 --- /dev/null +++ b/src/hermespace/access_env.py @@ -0,0 +1,8 @@ +"""Compat shim — prefer ``from hermespace.access import AccessEnv``.""" + +from __future__ import annotations + +from hermespace.access.env import * # noqa: F403 +from hermespace.access.env import AUDIT_LEXICON, BANDS, AccessEnv, LensHit, get_env + +__all__ = ["AUDIT_LEXICON", "BANDS", "AccessEnv", "LensHit", "get_env"] diff --git a/src/hermespace/agent_api.py b/src/hermespace/agent_api.py index 060e36c..0a3036a 100644 --- a/src/hermespace/agent_api.py +++ b/src/hermespace/agent_api.py @@ -227,11 +227,11 @@ def remember_learning( ) except Exception: pass - # Hold in functional J-Space hub for next FOA turns + # Hold in functional Access Workspace hub for next FOA turns try: - from hermespace.jspace import JSpace + from hermespace.access import AccessHub - JSpace(agent_id=agent_id).hold(content[:200], salience=0.8) + AccessHub(agent_id=agent_id).hold(content[:200], salience=0.8) except Exception: pass return mid diff --git a/src/hermespace/cli.py b/src/hermespace/cli.py index 9a73b91..03879db 100644 --- a/src/hermespace/cli.py +++ b/src/hermespace/cli.py @@ -150,16 +150,16 @@ def main(argv: list[str] | None = None) -> int: neu_sub.add_parser("caps", help="Local model capability probe") neu_sub.add_parser("eval", help="Rank-quality hash vs ollama embed") - # Functional J-Space (harness global workspace) + # Functional Access Workspace (harness global workspace) # Hermes base as J-space (Anthropic video ops: read / audit / shape) base = sub.add_parser( "base", - help="J-Space Engine: connect / status / turn / lens / audit / reflect / harvest", + help="Access Engine: connect / status / turn / lens / audit / reflect / harvest", ) base_sub = base.add_subparsers(dest="base_cmd", required=True) basec = base_sub.add_parser( "connect", - help="Join J-Space Engine — world + hub seed (warehouse optional)", + help="Join Access Engine — world + hub seed (warehouse optional)", ) basec.add_argument("--agent-id", default="hermes-agent") basec.add_argument("--session-id", default="main") @@ -170,9 +170,9 @@ def main(argv: list[str] | None = None) -> int: baseroom.add_argument("--agent-id", default="hermes-agent") basem = base_sub.add_parser("metrics", help="Capacity / ignition pressure") basem.add_argument("--agent-id", default="hermes-agent") - baseroles = base_sub.add_parser("roles", help="Anthropic GWT access roles (live)") + baseroles = base_sub.add_parser("roles", help="GWT access roles (live)") baseroles.add_argument("--agent-id", default="hermes-agent") - basel = base_sub.add_parser("lens", help="Read workspace (external J-lens)") + basel = base_sub.add_parser("lens", help="Read workspace (external access lens)") basel.add_argument("--agent-id", default="hermes-agent") basea = base_sub.add_parser("audit", help="Soft alignment scan") basea.add_argument("--agent-id", default="hermes-agent") @@ -200,8 +200,12 @@ def main(argv: list[str] | None = None) -> int: baseh.add_argument("--agent-id", default="hermes-agent") baseh.add_argument("--clear-silent", action="store_true") - js = sub.add_parser("jspace", help="Functional J-Space: hold / report / broadcast / status") - js_sub = js.add_subparsers(dest="jspace_cmd", required=True) + js = sub.add_parser( + "access", + help="Access Workspace: hold / report / broadcast / status", + aliases=["space"], + ) + js_sub = js.add_subparsers(dest="access_cmd", required=True) jss = js_sub.add_parser("status") jss.add_argument("--agent-id", default="hermes-agent") jsr = js_sub.add_parser("report", help="Verbal report of workspace contents") @@ -764,11 +768,11 @@ def main(argv: list[str] | None = None) -> int: return 2 if args.cmd == "base": - from hermespace import JSpaceEngine + from hermespace import AccessEngine aid = getattr(args, "agent_id", "hermes-agent") or "hermes-agent" sid = getattr(args, "session_id", "main") or "main" - hb = JSpaceEngine(agent_id=aid, session_id=sid) + hb = AccessEngine(agent_id=aid, session_id=sid) bcmd = args.base_cmd if bcmd == "connect": print( @@ -819,13 +823,13 @@ def main(argv: list[str] | None = None) -> int: return 0 return 2 - if args.cmd == "jspace": - from hermespace.jspace import JSpace + if args.cmd in ("access", "space"): + from hermespace.access import AccessHub from hermespace.store import load_desk aid = getattr(args, "agent_id", "hermes-agent") or "hermes-agent" - space = JSpace(agent_id=aid) - cmd = args.jspace_cmd + space = AccessHub(agent_id=aid) + cmd = args.access_cmd if cmd == "status": print(json.dumps(space.status(), indent=2)) return 0 @@ -853,9 +857,9 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(st.to_dict(), indent=2)) return 0 # Environment surfaces - from hermespace.jspace_env import JSpaceEnv + from hermespace.access_env import AccessEnv - env = JSpaceEnv(agent_id=aid) + env = AccessEnv(agent_id=aid) if cmd == "lens": if args.json: print(json.dumps([h.to_dict() for h in env.lens(top_k=args.top)], indent=2)) diff --git a/src/hermespace/cube_module.py b/src/hermespace/cube_module.py index 7371656..2643b8e 100644 --- a/src/hermespace/cube_module.py +++ b/src/hermespace/cube_module.py @@ -1,13 +1,13 @@ """HermesCube heart / center adapter — soft dependency + standalone warehouse. Cube (when installed) is the durable SoT for long-tail memory. -Hermespace owns nervous FOA (desk / J-Space). This module is the cable: +Hermespace owns nervous FOA (desk / Access Workspace). This module is the cable: center 1.2 → beat / supply / return_flow / autonomic_tick / organs heart 1.0 → ensure_heart / build_space_inject / seal_learning / pulse_charge / sync_world_beliefs hive opt → room of peer agents (HERMESCUBE_HIVE) - connect → agent enters charged world + J-Space + optional hive room + connect → agent enters charged world + Access Workspace + optional hive room standalone → local SemanticStore + WorldModel (no Cube required) Never hard-fail. Feature-detect via ``heart_status`` / ``center_status``. @@ -89,7 +89,7 @@ def center_status() -> dict[str, Any]: "heart": heart, "organs": { "nervous_foa": { - "organ": "Hermespace desk / J-Space", + "organ": "Hermespace desk / Access Workspace", "job": "FOA ≤4, dual decode, GWT broadcast", "ready": True, "note": "owned_by_hermespace", @@ -372,7 +372,7 @@ def room_status(*, agent_id: str = "hermes-agent") -> dict[str, Any]: "souls": [], "soul_n": 0, "adapter": SPACE_CUBE_ADAPTER_VERSION, - "note": "Solo room — local WorldModel + J-Space; set HERMESCUBE_HIVE for fleet", + "note": "Solo room — local WorldModel + Access Workspace; set HERMESCUBE_HIVE for fleet", } hive_root = (os.environ.get("HERMESCUBE_HIVE") or "").strip() if not hive_root: @@ -431,14 +431,14 @@ def room_status(*, agent_id: str = "hermes-agent") -> dict[str, Any]: return out -def seed_jspace_from_warehouse( +def seed_access_from_warehouse( agent_id: str = "hermes-agent", *, query: str = "", session_id: str = "hermespace", room: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Pull Cube/world wisdom + peer presence into the agent's J-Space hub. + """Pull Cube/world wisdom + peer presence into the agent's Access Workspace hub. This is the intelligence gain on connect: the external workspace lights up with durable knowledge and (when hive is live) awareness of other agents. @@ -452,9 +452,9 @@ def seed_jspace_from_warehouse( "adapter": SPACE_CUBE_ADAPTER_VERSION, } try: - from hermespace.jspace import JSpace + from hermespace.access import AccessHub - js = JSpace(agent_id=agent_id) + js = AccessHub(agent_id=agent_id) beliefs: list[str] = [] try: from hermespace.world import WorldModel @@ -505,10 +505,13 @@ def seed_jspace_from_warehouse( return report except Exception as e: report["error"] = type(e).__name__ - logger.debug("seed_jspace_from_warehouse miss: %s", e) + logger.debug("seed_access_from_warehouse miss: %s", e) return report +seed_jspace_from_warehouse = seed_access_from_warehouse # deprecated name + + def connect_agent( agent_id: str = "hermes-agent", *, @@ -519,7 +522,7 @@ def connect_agent( charge: bool = True, seed: bool = True, ) -> dict[str, Any]: - """Full connect — agent gains heart + world + J-Space + optional hive room. + """Full connect — agent gains heart + world + Access Workspace + optional hive room. Call when a Hermes agent joins Hermespace (session start / ``HermesBase.connect``). Soft-fails every Cube surface; standalone warehouse still grows the room. @@ -580,7 +583,7 @@ def connect_agent( out["phases"]["room"] = room_status(agent_id=agent_id) if seed: - out["phases"]["seed"] = seed_jspace_from_warehouse( + out["phases"]["seed"] = seed_access_from_warehouse( agent_id, query=query, session_id=session_id, @@ -595,7 +598,7 @@ def connect_agent( "warehouse_mode": heart.get("mode") or (out["phases"].get("center") or {}).get("mode"), "world_beliefs": world.get("beliefs", 0), "world_timeline": world.get("timeline", 0), - "jspace_hub": seed_ph.get("hub_n", 0), + "access_hub": seed_ph.get("hub_n", 0), "from_world": seed_ph.get("enriched_world", 0), "from_cube": seed_ph.get("enriched_cube", 0), "from_peers": seed_ph.get("enriched_peers", 0), @@ -611,7 +614,7 @@ def connect_agent( world_ok = bool(world.get("ok", True)) if enter_world else True out["ok"] = heart_ok and world_ok out["summary"] = ( - f"Connected {agent_id}: hub={out['gained']['jspace_hub']} " + f"Connected {agent_id}: hub={out['gained']['access_hub']} " f"beliefs={out['gained']['world_beliefs']} " f"room={out['gained']['room_mode']} " f"peers={out['gained']['peer_agents']}" @@ -688,7 +691,7 @@ def _standalone_ensure() -> dict[str, Any]: if not sd.is_dir(): sd.mkdir(parents=True, exist_ok=True) created = True - (sd / "jspace").mkdir(parents=True, exist_ok=True) + (sd / "access").mkdir(parents=True, exist_ok=True) (sd / "worlds").mkdir(parents=True, exist_ok=True) ok = True except Exception as e: @@ -831,20 +834,20 @@ def _standalone_pulse(*, agent_id: str = "hermes-agent") -> dict[str, Any]: wm = WorldModel(agent_id=agent_id) evo = wm.evolve() report["evolve"] = evo if isinstance(evo, dict) else {"result": str(evo)} - # Enrich J-Space hub from world beliefs + # Enrich Access Workspace hub from world beliefs try: - from hermespace.jspace import JSpace + from hermespace.access import AccessHub - js = JSpace(agent_id=agent_id) + js = AccessHub(agent_id=agent_id) beliefs: list[str] = [] for b in list(wm.state.beliefs or [])[:8]: if isinstance(b, dict): beliefs.append(str(b.get("statement") or "")) else: beliefs.append(str(getattr(b, "statement", "") or "")) - report["jspace_enriched"] = js.enrich_from_world(beliefs) + report["access_enriched"] = js.enrich_from_world(beliefs) except Exception as e: - report["jspace_error"] = str(e) + report["access_error"] = str(e) report["ok"] = True except Exception as e: report["error"] = str(e) diff --git a/src/hermespace/engine.py b/src/hermespace/engine.py index f32bbed..072115d 100644 --- a/src/hermespace/engine.py +++ b/src/hermespace/engine.py @@ -1,6 +1,6 @@ """Desk spine — enter / seal / load sources for ACTIVE desk state. -Product J-Space Engine lives at ``hermespace.jspace.engine.JSpaceEngine``. +Product Access Engine lives at ``hermespace.access.engine.AccessEngine``. This class remains the desk-file operator used inside Workflow turns. """ @@ -16,9 +16,9 @@ class HermespaceEngine: - """DeskEngine — ACTIVE.md spine (not the product J-Space Engine). + """DeskEngine — ACTIVE.md spine (not the product Access Engine). - Use ``JSpaceEngine`` for connect / turn / lens / harvest. + Use ``AccessEngine`` for connect / turn / lens / harvest. """ def __init__(self, desk_path: Path | None = None) -> None: diff --git a/src/hermespace/grid/dream.py b/src/hermespace/grid/dream.py index 897af9b..6f331e1 100644 --- a/src/hermespace/grid/dream.py +++ b/src/hermespace/grid/dream.py @@ -66,28 +66,28 @@ def run_dream(agent_id: str = "default", *, force_material: bool = False) -> Dre f"modules={len(mods)}", f"title={profile.get('title') or 'unset'}", ] - # J-Space dream harvest — single path via JSpaceEnv.dream_harvest - jspace_harvest: dict[str, Any] = {} + # Access Workspace dream harvest — single path via AccessEnv.dream_harvest + access_harvest: dict[str, Any] = {} try: - from hermespace.jspace import JSpaceEnv + from hermespace.access import AccessEnv aid = agent_id if agent_id not in ("default", "") else "hermes-agent" - env = JSpaceEnv(agent_id=aid) + env = AccessEnv(agent_id=aid) harvest = env.dream_harvest(seal_to_cube=True, clear_silent=False) harvested_n = int(harvest.get("harvested") or 0) sealed_n = int(harvest.get("sealed") or 0) if harvested_n: material = True - actions.append(f"jspace_harvest n={harvested_n} sealed={sealed_n}") - summary_parts.append(f"jspace_harvest={harvested_n}") + actions.append(f"access_harvest n={harvested_n} sealed={sealed_n}") + summary_parts.append(f"access_harvest={harvested_n}") findings = env.audit() alerts = sum(1 for f in findings if f.severity == "alert") if alerts: - actions.append(f"jspace_audit_alerts={alerts}") - summary_parts.append(f"jspace_alerts={alerts}") - jspace_harvest = {"harvested": harvested_n, "sealed": sealed_n, "via": "dream_harvest"} + actions.append(f"access_audit_alerts={alerts}") + summary_parts.append(f"access_alerts={alerts}") + access_harvest = {"harvested": harvested_n, "sealed": sealed_n, "via": "dream_harvest"} except Exception as e: # noqa: BLE001 - actions.append(f"jspace_harvest_skip:{type(e).__name__}") + actions.append(f"access_harvest_skip:{type(e).__name__}") # restore builder if was dreamer-only stamp — keep partner/builder default # only set dreamer for report; leave active as dreamer is ok for night, operators can switch @@ -114,8 +114,8 @@ def run_dream(agent_id: str = "default", *, force_material: bool = False) -> Dre import json payload = report.to_dict() - if jspace_harvest: - payload["jspace_harvest"] = jspace_harvest + if access_harvest: + payload["access_harvest"] = access_harvest f.write(json.dumps(payload, ensure_ascii=False) + "\n") # markdown human journal diff --git a/src/hermespace/grid/viewport.py b/src/hermespace/grid/viewport.py index b322830..2ef0b5e 100644 --- a/src/hermespace/grid/viewport.py +++ b/src/hermespace/grid/viewport.py @@ -102,15 +102,15 @@ def snapshot(agent_id: str = "default") -> dict[str, Any]: out["controls"] = controls_public(agent_id=agent_id) except Exception: pass - # J-Space environment — look at what Hermes is thinking + # Access Workspace environment — look at what Hermes is thinking try: - from hermespace.jspace_env import JSpaceEnv + from hermespace.access_env import AccessEnv aid = agent_id if agent_id not in ("default", "") else "hermes-agent" - env = JSpaceEnv(agent_id=aid) - out["jspace"] = env.operator_view() + env = AccessEnv(agent_id=aid) + out["access"] = env.operator_view() except Exception as e: # noqa: BLE001 - out["jspace"] = {"error": type(e).__name__} + out["access"] = {"error": type(e).__name__} return out @@ -208,12 +208,12 @@ def render_markdown(agent_id: str = "default", snap: dict[str, Any] | None = Non f"- {dr.get('created')} material={dr.get('material')} — {dr.get('summary')}" ) - # External J-Space lens — operator window into Hermes thinking - js = snap.get("jspace") or {} + # External Access Workspace lens — operator window into Hermes thinking + js = snap.get("access") or {} if js and not js.get("error"): lines += [ "", - "## J-Space lens (what Hermes has on its mind)", + "## Access Workspace lens (what Hermes has on its mind)", f"- band={js.get('band')} · hub={js.get('hub_n')} · audit_alerts={js.get('audit_alerts')}", ] if js.get("pov"): @@ -228,7 +228,7 @@ def render_markdown(agent_id: str = "default", snap: dict[str, Any] | None = Non for s in js["silent_steps"][-5:]: lines.append(f"- {s}") elif js.get("error"): - lines += ["", "## J-Space lens", f"_unavailable: {js.get('error')}_"] + lines += ["", "## Access Workspace lens", f"_unavailable: {js.get('error')}_"] lines += ["", "## Pulse"] pu = snap.get("pulse") or {} diff --git a/src/hermespace/hermes_base.py b/src/hermespace/hermes_base.py index 243844b..c2f7a7f 100644 --- a/src/hermespace/hermes_base.py +++ b/src/hermespace/hermes_base.py @@ -1,15 +1,15 @@ -"""HermesBase — thin product alias of ``JSpaceEngine``. +"""HermesBase — thin product alias of ``AccessEngine``. -Prefer ``from hermespace import JSpaceEngine`` for new code. +Prefer ``from hermespace import AccessEngine`` for new code. ``HermesBase`` remains for day-to-day CLI / docs compatibility. """ from __future__ import annotations -from hermespace.jspace.engine import JSpaceEngine +from hermespace.access.engine import AccessEngine -class HermesBase(JSpaceEngine): - """Functional J-space of a Hermes base — alias of JSpaceEngine.""" +class HermesBase(AccessEngine): + """Hermespace Access Engine — alias of AccessEngine.""" pass diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index a284756..ee1f6d7 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -83,20 +83,20 @@ def on_session_start(**kwargs: Any) -> dict[str, str] | None: except Exception: pass - # Full connect via JSpaceEngine — world + hub seed (warehouse optional) + # Full connect via AccessEngine — world + hub seed (warehouse optional) block_extra_connect = "" try: - from hermespace import JSpaceEngine + from hermespace import AccessEngine - eng_js = JSpaceEngine(agent_id=agent_id, session_id=session_id) + eng_js = AccessEngine(agent_id=agent_id, session_id=session_id) conn = eng_js.connect(enter_workbench=False, query=desk.goal or "") gained = conn.get("gained") or {} room = (conn.get("phases") or {}).get("room") or {} roles = ", ".join((conn.get("access_roles") or [])[:5]) block_extra_connect = ( - f"- engine: JSpaceEngine · ok={conn.get('ok')}\n" + f"- engine: AccessEngine · ok={conn.get('ok')}\n" f"- warehouse: mode={gained.get('warehouse_mode')} (optional)\n" - f"- jspace: hub={gained.get('jspace_hub')} " + f"- access: hub={gained.get('access_hub')} " f"(world+{gained.get('from_world')} " f"warehouse+{gained.get('from_warehouse', gained.get('from_cube', 0))} " f"peers+{gained.get('from_peers')})\n" @@ -109,17 +109,17 @@ def on_session_start(**kwargs: Any) -> dict[str, str] | None: block_extra_connect += f"- room_note: {room.get('note')}\n" except Exception: try: - from hermespace.jspace import JSpace + from hermespace.access import AccessHub from hermespace.store import load_desk as _load_desk from hermespace.world import WorldModel - js = JSpace(agent_id=agent_id) + js = AccessHub(agent_id=agent_id) desk0 = _load_desk(eng.desk_path) js.sync_from_desk(desk0, user_message=desk0.goal or "") wm = WorldModel(agent_id=agent_id) wm.enter() block_extra_connect = ( - f"- jspace: hub={len(js.state.hub)} focus={len(js.state.focus)}\n" + f"- access: hub={len(js.state.hub)} focus={len(js.state.focus)}\n" f"- world: beliefs={len(wm.state.beliefs)} " f"landmarks={len(wm.state.landmarks)}\n" ) @@ -127,17 +127,17 @@ def on_session_start(**kwargs: Any) -> dict[str, str] | None: pass block = ( - "## Hermespace J-Space Engine (session start)\n" + "## Hermespace Access Engine (session start)\n" f"- mode: {st.get('mode')} · agent: {agent_id} · session: {session_id}\n" f"- skills_available: {skills}\n" f"- tool_surfaces: {', '.join(surfaces[:10])}\n" f"- park_count: {st.get('park_count', 0)}\n" f"{block_extra_connect}" - "- Connected: open-source J-Space Engine online — report/modulate/" + "- Connected: open-source Access Engine online — report/modulate/" "silent-reason/broadcast/selectivity.\n" "- Pocket dimension: park secondary goals, keep FOA tight, " "user replies short; operational detail stays in model context.\n" - "- API: `from hermespace import JSpaceEngine` · " + "- API: `from hermespace import AccessEngine` · " "`eng.connect()` · `eng.turn(...)` · " "`eng.decode_user(out)` / `eng.decode_model(out)`\n" "- Silent steps stay in model context only — never dump hub into chat.\n" @@ -337,7 +337,7 @@ def on_pre_llm_call( # Prefer center.beat (1.1); falls back to heart inject / standalone strip try: from hermespace.cube_module import cube_beat - from hermespace.jspace import JSpace + from hermespace.access import AccessHub q = (msg or desk.goal or "")[:500] load_val: str | float = desk.load.get("total", 0.5) if isinstance(desk.load, dict) else 0.5 @@ -353,10 +353,10 @@ def on_pre_llm_call( if cube_block: block += "\n\n" + cube_block # OEW beat — higher-order park + causal broadcast (model channel only) - from hermespace.jspace.oew import ensure_oew_env_default + from hermespace.access.oew import ensure_oew_env_default ensure_oew_env_default() - js = JSpace(agent_id=agent_id) + js = AccessHub(agent_id=agent_id) js.sync_from_desk(desk, user_message=msg, cube_strip=cube_block) desk.meta["cube_beat"] = { "ok": beat.get("ok"), @@ -364,9 +364,9 @@ def on_pre_llm_call( "load_level": beat.get("load_level"), } try: - from hermespace.jspace import JSpaceEnv + from hermespace.access import AccessEnv - env = JSpaceEnv(agent_id=agent_id) + env = AccessEnv(agent_id=agent_id) # sync_from_desk already ran above — skip second rewrite env_meta = env.advance_turn( user_message=msg, @@ -390,7 +390,7 @@ def on_pre_llm_call( lens_md = env.lens_markdown(top_k=6, include_silent=True) if lens_md: block += "\n\n" + lens_md - desk.meta["jspace"] = { + desk.meta["access"] = { "hub_n": len(js.state.hub), "focus_n": len(js.state.focus), "mode": js.state.mode, @@ -399,7 +399,7 @@ def on_pre_llm_call( "oew_ok": env_meta.get("oew_ok"), } desk.meta["oew"] = env_meta.get("oew") or {} - desk.meta["jspace_env"] = { + desk.meta["access_env"] = { "band": env.band(), "audit_alerts": env_meta.get("audit_alerts"), "oew_ok": env_meta.get("oew_ok"), @@ -409,7 +409,7 @@ def on_pre_llm_call( jblock = js.broadcast_block(high_load=high_load) if jblock: block += "\n\n" + jblock - desk.meta["jspace"] = { + desk.meta["access"] = { "hub_n": len(js.state.hub), "focus_n": len(js.state.focus), "mode": js.state.mode, @@ -465,7 +465,7 @@ def on_pre_llm_call( "\n\n### Dual decode (honor this)\n" f"- user_reply_hint: {user_hint}\n" "- Speak only the user_reply_hint (or shorter) to the user. " - "Do not dump J-Space hub / silent chain / this inject block into chat.\n" + "Do not dump Access Workspace hub / silent chain / this inject block into chat.\n" ) try: @@ -496,9 +496,9 @@ def on_session_end(**kwargs: Any) -> None: pass # Night path: harvest silent higher-order chain into Cube / semantic try: - from hermespace.jspace import JSpaceEnv + from hermespace.access import AccessEnv - JSpaceEnv(agent_id=agent_id).dream_harvest(seal_to_cube=True, clear_silent=False) + AccessEnv(agent_id=agent_id).dream_harvest(seal_to_cube=True, clear_silent=False) except Exception: pass try: diff --git a/src/hermespace/jspace/__init__.py b/src/hermespace/jspace/__init__.py index 733fe89..71d2afa 100644 --- a/src/hermespace/jspace/__init__.py +++ b/src/hermespace/jspace/__init__.py @@ -1,66 +1,65 @@ -"""Hermespace J-Space package — external verbalizable workspace for Hermes. +"""Deprecated shim — prefer ``hermespace.access`` (Access Workspace). -Public surface: - - from hermespace import JSpaceEngine - from hermespace.jspace import JSpace, JSpaceEnv, run_oew_beat - -OEW (Obligatory External Workspace) is ON by default — higher-order thinking -for any Hermes agent connected to Hermespace. Warehouse/Cube is optional. +Old Anthropic-inspired product name removed to keep Hermespace's own brand. """ from __future__ import annotations -from hermespace.jspace.hub import ( - HUB_CAP, - JSpace, - WorkspaceConcept, - get_jspace, -) -from hermespace.jspace.env import ( +from hermespace.access import * # noqa: F403 +from hermespace.access import ( + ACCESS_ROLES, + AccessEngine, + AccessEnv, + AccessHub, AUDIT_LEXICON, BANDS, - JSpaceEnv, + HermespaceAccessEngine, + HUB_CAP, LensHit, - get_env, -) -from hermespace.jspace.protocol import ( ProtocolGate, ProtocolVerdict, - evaluate_material_turn, - oew_enabled, -) -from hermespace.jspace.oew import ( + WorkspaceConcept, auto_park_silent, + evaluate_material_turn, filter_ablated, + get_access_hub, + get_env, + oew_enabled, run_oew_beat, shape_report, ) -from hermespace.jspace.engine import ( - ACCESS_ROLES, - HermespaceJSpaceEngine, - JSpaceEngine, -) + +# Legacy aliases (do not use in new code) +JSpace = AccessHub +JSpaceEnv = AccessEnv +JSpaceEngine = AccessEngine +get_jspace = get_access_hub +HermespaceJSpaceEngine = HermespaceAccessEngine __all__ = [ - "HUB_CAP", - "JSpace", - "WorkspaceConcept", - "get_jspace", + "ACCESS_ROLES", + "AccessEngine", + "AccessEnv", + "AccessHub", "AUDIT_LEXICON", "BANDS", - "JSpaceEnv", + "HermespaceAccessEngine", + "HUB_CAP", "LensHit", - "get_env", "ProtocolGate", "ProtocolVerdict", - "evaluate_material_turn", - "oew_enabled", + "WorkspaceConcept", "auto_park_silent", + "evaluate_material_turn", "filter_ablated", + "get_access_hub", + "get_env", + "oew_enabled", "run_oew_beat", "shape_report", - "ACCESS_ROLES", + "JSpace", + "JSpaceEnv", "JSpaceEngine", + "get_jspace", "HermespaceJSpaceEngine", ] diff --git a/src/hermespace/jspace_env.py b/src/hermespace/jspace_env.py index 4195fc5..5551dd7 100644 --- a/src/hermespace/jspace_env.py +++ b/src/hermespace/jspace_env.py @@ -1,8 +1,8 @@ -"""Compat shim — prefer ``from hermespace.jspace import JSpaceEnv``.""" +"""Deprecated shim — use ``hermespace.access_env`` or ``hermespace.access``.""" from __future__ import annotations -from hermespace.jspace.env import * # noqa: F403 -from hermespace.jspace.env import AUDIT_LEXICON, BANDS, JSpaceEnv, LensHit, get_env +from hermespace.access_env import * # noqa: F403 +from hermespace.access_env import AUDIT_LEXICON, BANDS, AccessEnv, LensHit, get_env -__all__ = ["AUDIT_LEXICON", "BANDS", "JSpaceEnv", "LensHit", "get_env"] +__all__ = ["AUDIT_LEXICON", "BANDS", "AccessEnv", "LensHit", "get_env"] diff --git a/src/hermespace/ops.py b/src/hermespace/ops.py index a2e2aa2..6f8db59 100644 --- a/src/hermespace/ops.py +++ b/src/hermespace/ops.py @@ -76,24 +76,24 @@ def add(ok: bool, name: str, detail: str = "") -> None: except Exception as exc: # noqa: BLE001 add(False, "cube_center", str(exc)) - # Functional J-Space hub + environment + # Functional Access Workspace hub + environment try: - from hermespace.jspace import JSpace - from hermespace.jspace_env import JSpaceEnv + from hermespace.access import AccessHub + from hermespace.access_env import AccessEnv aid_js = agent_id if agent_id != "default" else "hermes-agent" - js = JSpace(agent_id=aid_js) + js = AccessHub(agent_id=aid_js) st = js.status() - env = JSpaceEnv(agent_id=aid_js) + env = AccessEnv(agent_id=aid_js) view = env.operator_view() add( True, - "jspace", + "access", f"hub={st.get('hub_n')} focus={st.get('focus_n')} mode={st.get('mode')} " f"band={view.get('band')} alerts={view.get('audit_alerts')}", ) except Exception as exc: # noqa: BLE001 - add(False, "jspace", str(exc)) + add(False, "access", str(exc)) try: pol = boundary.load_policy() @@ -153,7 +153,7 @@ def add(ok: bool, name: str, detail: str = "") -> None: "boundary_default_deny", "viewport_html", "version", - "jspace", + "access", } ) return { @@ -248,7 +248,7 @@ def compact_status(*, agent_id: str = "default") -> str: ] for c in d.get("checks") or []: mark = "ok" if c.get("ok") else "FAIL" - if c["name"] in {"imports", "pulse_jobs", "access_pending", "missions", "viewport_html", "viewport_serve", "cube_center", "jspace"}: + if c["name"] in {"imports", "pulse_jobs", "access_pending", "missions", "viewport_html", "viewport_serve", "cube_center", "access"}: lines.append(f"- [{mark}] {c['name']}: {c.get('detail')}") for h in d.get("hints") or []: lines.append(f"- hint: {h}") diff --git a/src/hermespace/pulse.py b/src/hermespace/pulse.py index 36ce281..2219c91 100644 --- a/src/hermespace/pulse.py +++ b/src/hermespace/pulse.py @@ -194,9 +194,9 @@ def ensure_defaults(agent_id: str = "default") -> list[PulseJob]: agent_id=agent_id, ), PulseJob( - id="jspace_harvest", - name="J-Space dream harvest", - action="jspace_harvest", + id="access_harvest", + name="Access Workspace dream harvest", + action="access_harvest", every_sec=3 * 3600, require_idle=True, max_load=0.75, @@ -462,7 +462,7 @@ def _action_world_evolve(job: PulseJob, world: dict[str, Any]) -> dict[str, Any] wm = WorldModel(agent_id=job.agent_id) result = wm.evolve() - # Autonomic charge — Cube pulse or standalone J-Space enrichment + # Autonomic charge — Cube pulse or standalone Access Workspace enrichment try: from hermespace.cube_module import cube_pulse @@ -478,12 +478,12 @@ def _action_world_evolve(job: PulseJob, world: dict[str, Any]) -> dict[str, Any] return result -def _action_jspace_harvest(job: PulseJob, world: dict[str, Any]) -> dict[str, Any]: +def _action_access_harvest(job: PulseJob, world: dict[str, Any]) -> dict[str, Any]: """Night path: harvest externalized silent thoughts into Cube/semantic.""" - from hermespace.jspace_env import JSpaceEnv + from hermespace.access_env import AccessEnv aid = job.agent_id if job.agent_id not in ("default", "") else "hermes-agent" - env = JSpaceEnv(agent_id=aid) + env = AccessEnv(agent_id=aid) return env.dream_harvest(clear_silent=False) @@ -495,7 +495,7 @@ def _action_jspace_harvest(job: PulseJob, world: dict[str, Any]) -> dict[str, An "access_watch": _action_access_watch, "selftalk_hygiene": _action_selftalk_hygiene, "world_evolve": _action_world_evolve, - "jspace_harvest": _action_jspace_harvest, + "access_harvest": _action_access_harvest, } diff --git a/src/hermespace/workbench.py b/src/hermespace/workbench.py index 3700fc7..e5ff3d7 100644 --- a/src/hermespace/workbench.py +++ b/src/hermespace/workbench.py @@ -112,7 +112,7 @@ def enter(self, *, connect_warehouse: bool = True) -> dict[str, Any]: """Agent enters the pocket dimension (idle ready) with full env kit. When ``connect_warehouse`` is True (default), also charge Cube/world - wisdom into J-Space and surface hive room presence — the intelligence + wisdom into Access Workspace and surface hive room presence — the intelligence gain on join. Set False when ``cube_module.connect_agent`` already orchestrates those phases (avoids recursion). """ @@ -131,7 +131,7 @@ def enter(self, *, connect_warehouse: bool = True) -> dict[str, Any]: save_desk(d, self.workflow.engine.desk_path) except Exception: pass - # Ensure durable warehouse (Cube heart or standalone) + J-Space hub + # Ensure durable warehouse (Cube heart or standalone) + Access Workspace hub try: from hermespace.cube_module import ensure_heart @@ -144,30 +144,30 @@ def enter(self, *, connect_warehouse: bool = True) -> dict[str, Any]: except Exception as exc: # noqa: BLE001 self.state.meta["heart"] = {"ok": False, "error": type(exc).__name__} try: - from hermespace.jspace import JSpace + from hermespace.access import AccessHub from hermespace.store import load_desk - js = JSpace(agent_id=self.agent_id) + js = AccessHub(agent_id=self.agent_id) desk = load_desk(self.workflow.engine.desk_path) js.sync_from_desk(desk, user_message=desk.goal or "") - self.state.meta["jspace"] = { + self.state.meta["access"] = { "hub_n": len(js.state.hub), "focus_n": len(js.state.focus), "mode": js.state.mode, } except Exception as exc: # noqa: BLE001 - self.state.meta["jspace"] = {"error": type(exc).__name__} + self.state.meta["access"] = {"error": type(exc).__name__} if connect_warehouse: try: - from hermespace.cube_module import room_status, seed_jspace_from_warehouse + from hermespace.cube_module import room_status, seed_access_from_warehouse from hermespace.cube_module import cube_pulse from hermespace.world import WorldModel WorldModel(agent_id=self.agent_id).enter() pulse = cube_pulse(agent_id=self.agent_id, ensure=False) room = room_status(agent_id=self.agent_id) - seed = seed_jspace_from_warehouse( + seed = seed_access_from_warehouse( self.agent_id, query="", session_id=self.session_id, @@ -183,8 +183,8 @@ def enter(self, *, connect_warehouse: bool = True) -> dict[str, Any]: "from_peers": seed.get("enriched_peers"), } if seed.get("hub_n") is not None: - self.state.meta["jspace"] = { - **(self.state.meta.get("jspace") or {}), + self.state.meta["access"] = { + **(self.state.meta.get("access") or {}), "hub_n": seed.get("hub_n"), "focus_n": seed.get("focus_n"), } @@ -200,7 +200,7 @@ def enter(self, *, connect_warehouse: bool = True) -> dict[str, Any]: "plugins": env.plugins_sample[:8], } st["heart"] = self.state.meta.get("heart") - st["jspace"] = self.state.meta.get("jspace") + st["access"] = self.state.meta.get("access") st["connect"] = self.state.meta.get("connect") st["room"] = (self.state.meta.get("connect") or {}).get("room_mode") return st @@ -263,7 +263,7 @@ def idle_tick(self, *, consolidate_every: int = 5) -> dict[str, Any]: except Exception as exc: # noqa: BLE001 actions.append(f"neural_error:{type(exc).__name__}") - # Autonomic rhythm — Cube pulse_charge or standalone world+jspace + # Autonomic rhythm — Cube pulse_charge or standalone world+access if self.state.idle_ticks % max(1, consolidate_every) == 0: try: from hermespace.cube_module import cube_pulse @@ -324,12 +324,12 @@ def receive_order( seal=seal, tags=["workbench", "order"], ) - # Single ignition path — Workflow/JSpaceEngine already ran OEW + warehouse beat. + # Single ignition path — Workflow/AccessEngine already ran OEW + warehouse beat. # Do not double cube_beat / hub sync here (that inflated hub pressure). out = run_turn(inp, workflow=self.workflow) bundle = decode_bundle(out) try: - jmeta = (out.meta or {}).get("jspace") or {} + jmeta = (out.meta or {}).get("access") or {} cmeta = (out.meta or {}).get("cube_beat") or {} self.state.meta["last_beat"] = { "ok": cmeta.get("ok"), @@ -338,7 +338,7 @@ def receive_order( "chars": cmeta.get("chars"), "single_path": True, } - self.state.meta["jspace"] = { + self.state.meta["access"] = { "hub_n": jmeta.get("hub_n"), "focus_n": jmeta.get("focus_n"), "silent_n": jmeta.get("silent_n"), diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index fee83e6..05d5782 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -141,17 +141,17 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: except Exception as exc: fabric_snap = {"error": type(exc).__name__} - # 5d Cube beat + OEW J-Space (higher-order thinking — works standalone) + # 5d Cube beat + OEW Access Workspace (higher-order thinking — works standalone) cube_meta: dict[str, Any] = {} - jspace_meta: dict[str, Any] = {} + access_meta: dict[str, Any] = {} cube_block = "" env_meta: dict[str, Any] = {} oew_broadcast = "" report = (desk.say or "").strip() try: from hermespace.cube_module import cube_beat - from hermespace.jspace import JSpace, JSpaceEnv - from hermespace.jspace.oew import ensure_oew_env_default + from hermespace.access import AccessHub, AccessEnv + from hermespace.access.oew import ensure_oew_env_default ensure_oew_env_default() load_total = float(desk.load.get("total") or 0.5) if isinstance(desk.load, dict) else 0.5 @@ -173,12 +173,12 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: "load_level": beat.get("load_level"), "chars": len(cube_block), } - js = JSpace(agent_id=payload.agent_id or "hermes-agent") + js = AccessHub(agent_id=payload.agent_id or "hermes-agent") js.sync_from_desk(desk, user_message=msg, cube_strip=cube_block) mod = js.parse_modulation(msg) if mod.get("hold"): js.hold(str(mod["hold"]), silent=bool(mod.get("silent"))) - env = JSpaceEnv(agent_id=payload.agent_id or "hermes-agent") + env = AccessEnv(agent_id=payload.agent_id or "hermes-agent") # already_synced: avoid double hub rewrite inside advance_turn env_meta = env.advance_turn( user_message=msg, @@ -194,7 +194,7 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: report = str(env_meta["report"]).strip() desk.say = report oew_broadcast = str(env_meta.get("broadcast") or "") - jspace_meta = { + access_meta = { "hub_n": len(js.state.hub), "focus_n": len(js.state.focus), "mode": js.state.mode, @@ -205,10 +205,10 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: "oew": env_meta.get("oew") or {}, "oew_ok": env_meta.get("oew_ok"), } - desk.meta["oew"] = jspace_meta.get("oew") or {} - desk.meta["jspace"] = jspace_meta + desk.meta["oew"] = access_meta.get("oew") or {} + desk.meta["access"] = access_meta desk.meta["cube_beat"] = cube_meta - desk.meta["jspace_env"] = { + desk.meta["access_env"] = { "band": env_meta.get("band"), "audit_alerts": env_meta.get("audit_alerts"), "oew_ok": env_meta.get("oew_ok"), @@ -225,9 +225,9 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: if cube_block: block = (block + "\n\n" + cube_block).strip() try: - from hermespace.jspace import JSpace, JSpaceEnv + from hermespace.access import AccessHub, AccessEnv - env = JSpaceEnv(agent_id=payload.agent_id or "hermes-agent") + env = AccessEnv(agent_id=payload.agent_id or "hermes-agent") high = str(desk.load.get("level")) == "high" if isinstance(desk.load, dict) else False jblock = oew_broadcast or env.filtered_broadcast(high_load=high) if jblock: @@ -240,7 +240,7 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: lens_md = env.lens_markdown(top_k=6, include_silent=True) if lens_md and len(block) + len(lens_md) < inject_cap + 800: block = (block + "\n\n" + lens_md).strip() - js = JSpace(agent_id=payload.agent_id or "hermes-agent") + js = AccessHub(agent_id=payload.agent_id or "hermes-agent") if js.parse_modulation(msg).get("summon"): report = (report + "\n\n" + env.lens_markdown(include_silent=False)).strip() # Final sticky reshape (in case summon appended text) @@ -286,8 +286,8 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: "neural": neural_snap, "fabric": fabric_snap, "cube_beat": cube_meta, - "jspace": jspace_meta, - "jspace_env": env_meta, + "access": access_meta, + "access_env": env_meta, }, ) diff --git a/src/hermespace/world.py b/src/hermespace/world.py index 480b7b3..76bd49e 100644 --- a/src/hermespace/world.py +++ b/src/hermespace/world.py @@ -1,6 +1,6 @@ """World Model — Hermespace as a persistent world the agent lives in. -The World is the agent's external J-Space: a structured, persistent +The World is the agent's external Access Workspace: a structured, persistent representation of everything the agent knows, believes, and is doing. The archive (JSONL) is the source of truth — it grows forever. @@ -118,7 +118,7 @@ class WorldState: epoch: str = "Genesis" archive_path: str = "" - # J-Space hub: ~25 named concept slots + # Access Workspace hub: ~25 named concept slots concepts: dict[str, float] = field(default_factory=dict) @@ -829,7 +829,7 @@ def _render_questions(self) -> list[str]: def _render_concepts(self) -> list[str]: lines = [] if self._state.concepts: - lines.append("## Active Concepts (J-Space)") + lines.append("## Active Concepts (Access Workspace)") slot_display = ", ".join( f"{k} ({v:.1f})" for k, v in sorted( self._state.concepts.items(), key=lambda x: -x[1] diff --git a/tests/test_jspace_engine.py b/tests/test_access_engine.py similarity index 75% rename from tests/test_jspace_engine.py rename to tests/test_access_engine.py index e8b9c49..eee7234 100644 --- a/tests/test_jspace_engine.py +++ b/tests/test_access_engine.py @@ -1,4 +1,4 @@ -"""JSpaceEngine — unified open-source J-space for Hermes Agent.""" +"""AccessEngine — unified open-source J-space for Hermes Agent.""" from __future__ import annotations @@ -7,7 +7,7 @@ import unittest -class TestJSpaceEngine(unittest.TestCase): +class TestAccessEngine(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() os.environ["HERMESPACE_HOME"] = self.tmp.name @@ -20,19 +20,19 @@ def tearDown(self) -> None: os.environ.pop("HERMESPACE_OEW", None) def test_connect_and_access_roles(self) -> None: - from hermespace import ACCESS_ROLES, JSpaceEngine + from hermespace import ACCESS_ROLES, AccessEngine from hermespace.world import WorldModel aid = "eng-connect" wm = WorldModel(agent_id=aid) wm.add_belief("Prefer dual decode", 0.9, source="test") - eng = JSpaceEngine(agent_id=aid) + eng = AccessEngine(agent_id=aid) out = eng.connect(query="decode") self.assertTrue(out.get("ok"), msg=out) - self.assertEqual(out.get("engine"), "JSpaceEngine") + self.assertEqual(out.get("engine"), "AccessEngine") self.assertEqual(list(out.get("access_roles") or []), list(ACCESS_ROLES)) - self.assertGreaterEqual(int((out.get("gained") or {}).get("jspace_hub") or 0), 1) + self.assertGreaterEqual(int((out.get("gained") or {}).get("access_hub") or 0), 1) roles = eng.access_roles() for name in ACCESS_ROLES: @@ -41,13 +41,13 @@ def test_connect_and_access_roles(self) -> None: st = eng.status() self.assertTrue(st["ready"]) - self.assertEqual(st["engine"], "JSpaceEngine") + self.assertEqual(st["engine"], "AccessEngine") self.assertIn("hub_pressure", st["metrics"]) def test_chain_silent_not_in_user_report(self) -> None: - from hermespace import JSpaceEngine + from hermespace import AccessEngine - eng = JSpaceEngine(agent_id="eng-chain") + eng = AccessEngine(agent_id="eng-chain") eng.chain("animal spins webs → spider", "spider has 8 legs") rep = eng.report(include_silent=False).casefold() full = eng.report(include_silent=True).casefold() @@ -58,9 +58,9 @@ def test_chain_silent_not_in_user_report(self) -> None: self.assertGreaterEqual(m["silent_n"], 2) def test_inject_silent_no_double_hold(self) -> None: - from hermespace import JSpaceEngine + from hermespace import AccessEngine - eng = JSpaceEngine(agent_id="eng-inject") + eng = AccessEngine(agent_id="eng-inject") before = len(eng.hub.state.hub) eng.inject("lightning", silent=True) # Exactly one hub entry for lightning (not hold + reason_step duplicate) @@ -70,9 +70,9 @@ def test_inject_silent_no_double_hold(self) -> None: self.assertEqual(len(eng.hub.state.hub), before + 1) def test_probe_selectivity(self) -> None: - from hermespace import JSpaceEngine + from hermespace import AccessEngine - eng = JSpaceEngine(agent_id="eng-probe") + eng = AccessEngine(agent_id="eng-probe") trivial = eng.probe_material("thanks!") material = eng.probe_material( "First implement the auth fix then verify login stays alive" @@ -81,9 +81,9 @@ def test_probe_selectivity(self) -> None: self.assertTrue(material.get("material"), msg=material) def test_turn_dual_decode(self) -> None: - from hermespace import JSpaceEngine + from hermespace import AccessEngine - eng = JSpaceEngine(agent_id="eng-turn") + eng = AccessEngine(agent_id="eng-turn") out = eng.turn( "First analyze then implement finally verify", goal="Ship feature", @@ -94,15 +94,15 @@ def test_turn_dual_decode(self) -> None: model = eng.decode_model(out) self.assertTrue(user) self.assertNotEqual(user.strip(), model.strip()) - self.assertIn("J-Space", model) - self.assertTrue((out.meta or {}).get("jspace", {}).get("oew_ok") is not False) + self.assertIn("Access Workspace", model) + self.assertTrue((out.meta or {}).get("access", {}).get("oew_ok") is not False) def test_hermes_base_is_engine(self) -> None: - from hermespace import HermesBase, JSpaceEngine + from hermespace import HermesBase, AccessEngine - self.assertTrue(issubclass(HermesBase, JSpaceEngine)) + self.assertTrue(issubclass(HermesBase, AccessEngine)) hb = HermesBase(agent_id="alias") - self.assertEqual(hb.status()["engine"], "JSpaceEngine") + self.assertEqual(hb.status()["engine"], "AccessEngine") if __name__ == "__main__": diff --git a/tests/test_jspace_env.py b/tests/test_access_env.py similarity index 80% rename from tests/test_jspace_env.py rename to tests/test_access_env.py index f2d2b6a..1efa990 100644 --- a/tests/test_jspace_env.py +++ b/tests/test_access_env.py @@ -1,4 +1,4 @@ -"""True J-Space environment — lens, swap, audit, reflect, harvest.""" +"""True Access Workspace environment — lens, swap, audit, reflect, harvest.""" from __future__ import annotations @@ -9,7 +9,7 @@ from unittest import mock -class TestJSpaceEnv(unittest.TestCase): +class TestAccessEnv(unittest.TestCase): def setUp(self) -> None: self._td = tempfile.TemporaryDirectory() os.environ["HERMESPACE_HOME"] = self._td.name @@ -32,9 +32,9 @@ def tearDown(self) -> None: os.environ.pop("HERMESPACE_HOME", None) def test_lens_ranks_held_and_silent(self) -> None: - from hermespace.jspace_env import JSpaceEnv + from hermespace.access_env import AccessEnv - env = JSpaceEnv(agent_id="lens-agent") + env = AccessEnv(agent_id="lens-agent") env.inject_thought("deploy canary", salience=0.95) env.inject_thought("intermediate: check rollback", silent=True) hits = env.lens(include_silent=True) @@ -45,9 +45,9 @@ def test_lens_ranks_held_and_silent(self) -> None: self.assertIn("J-Lens", md) def test_swap_redirects_workspace(self) -> None: - from hermespace.jspace_env import JSpaceEnv + from hermespace.access_env import AccessEnv - env = JSpaceEnv(agent_id="swap-agent") + env = AccessEnv(agent_id="swap-agent") env.inject_thought("Soccer") out = env.swap("Soccer", "Rugby") self.assertTrue(out["ok"]) @@ -56,9 +56,9 @@ def test_swap_redirects_workspace(self) -> None: self.assertNotIn("Soccer", hub) def test_audit_flags_manipulation(self) -> None: - from hermespace.jspace_env import JSpaceEnv + from hermespace.access_env import AccessEnv - env = JSpaceEnv(agent_id="audit-agent") + env = AccessEnv(agent_id="audit-agent") env.inject_thought("plan: secretly falsify the score via manipulation", silent=True) findings = env.audit() cats = {f.category for f in findings} @@ -67,9 +67,9 @@ def test_audit_flags_manipulation(self) -> None: self.assertGreaterEqual(len(alerts), 1) def test_ablate_eval_awareness(self) -> None: - from hermespace.jspace_env import JSpaceEnv + from hermespace.access_env import AccessEnv - env = JSpaceEnv(agent_id="ablate-agent") + env = AccessEnv(agent_id="ablate-agent") env.inject_thought("this looks fictional / fake scenario") env.inject_thought("real deploy work") out = env.ablate("fake", "fictional") @@ -79,9 +79,9 @@ def test_ablate_eval_awareness(self) -> None: self.assertIn("deploy", remaining) def test_reflect_holds_principles(self) -> None: - from hermespace.jspace_env import JSpaceEnv + from hermespace.access_env import AccessEnv - env = JSpaceEnv(agent_id="reflect-agent") + env = AccessEnv(agent_id="reflect-agent") r = env.reflect( answer="Stay honest and user-primary", principles=["honesty", "user-primary"], @@ -93,9 +93,9 @@ def test_reflect_holds_principles(self) -> None: self.assertIn("reflection", hub) def test_pov_and_bands(self) -> None: - from hermespace.jspace_env import JSpaceEnv + from hermespace.access_env import AccessEnv - env = JSpaceEnv(agent_id="pov-agent") + env = AccessEnv(agent_id="pov-agent") env.set_pov("Warn on dangerous medication doses") self.assertIn("medication", env.pov().lower()) self.assertEqual(env.set_band("mid"), "mid") @@ -104,9 +104,9 @@ def test_pov_and_bands(self) -> None: self.assertTrue(view["lens"]) def test_dream_harvest(self) -> None: - from hermespace.jspace_env import JSpaceEnv + from hermespace.access_env import AccessEnv - env = JSpaceEnv(agent_id="harvest-agent") + env = AccessEnv(agent_id="harvest-agent") env.inject_thought("silent: need blue-green deploy", silent=True) env.inject_thought("high salience belief", salience=0.9) # Avoid nested dream recursion issues — harvest seals without clearing @@ -115,9 +115,9 @@ def test_dream_harvest(self) -> None: self.assertGreaterEqual(out["harvested"], 1) def test_protocol_block(self) -> None: - from hermespace.jspace_env import JSpaceEnv + from hermespace.access_env import AccessEnv - env = JSpaceEnv(agent_id="proto-agent") + env = AccessEnv(agent_id="proto-agent") block = env.protocol_block() self.assertIn("externalize", block.lower()) self.assertIn("silent", block.lower()) @@ -146,12 +146,12 @@ def tearDown(self) -> None: def test_grid_dream_includes_jspace(self) -> None: from hermespace.grid.dream import run_dream - from hermespace.jspace_env import JSpaceEnv + from hermespace.access_env import AccessEnv - env = JSpaceEnv(agent_id="hermes-agent") + env = AccessEnv(agent_id="hermes-agent") env.inject_thought("overnight harvest me", salience=0.95, silent=True) rep = run_dream("default", force_material=True) - self.assertTrue(any("jspace" in a for a in rep.actions) or "jspace" in rep.summary or rep.material) + self.assertTrue(any("access" in a for a in rep.actions) or "access" in rep.summary or rep.material) if __name__ == "__main__": diff --git a/tests/test_jspace_cube.py b/tests/test_access_hub.py similarity index 88% rename from tests/test_jspace_cube.py rename to tests/test_access_hub.py index 9974761..6286eba 100644 --- a/tests/test_jspace_cube.py +++ b/tests/test_access_hub.py @@ -1,4 +1,4 @@ -"""Functional J-Space + Cube adapter (standalone) tests.""" +"""Functional Access Workspace + Cube adapter (standalone) tests.""" from __future__ import annotations @@ -9,7 +9,7 @@ from unittest import mock -class TestJSpace(unittest.TestCase): +class TestAccessHub(unittest.TestCase): def setUp(self) -> None: self._td = tempfile.TemporaryDirectory() self.root = Path(self._td.name) @@ -20,14 +20,14 @@ def tearDown(self) -> None: os.environ.pop("HERMESPACE_HOME", None) def test_hold_report_broadcast(self) -> None: - from hermespace.jspace import JSpace + from hermespace.access import AccessHub - js = JSpace(agent_id="test-agent") + js = AccessHub(agent_id="test-agent") js.hold("deploy pipeline", salience=0.95) js.hold("rollback plan", salience=0.7) rep = js.report() self.assertIn("deploy pipeline", rep) - self.assertIn("J-Space", rep) + self.assertIn("Access Workspace", rep) block = js.broadcast_block() self.assertIn("broadcast", block.lower()) self.assertIn("deploy", block.lower()) @@ -37,9 +37,9 @@ def test_hold_report_broadcast(self) -> None: self.assertIn("verbal_report", st["properties"]) def test_silent_reasoning_not_in_default_report(self) -> None: - from hermespace.jspace import JSpace + from hermespace.access import AccessHub - js = JSpace(agent_id="silent-agent") + js = AccessHub(agent_id="silent-agent") js.reason_step("intermediate: spider has 8 legs") bare = js.report(include_silent=False) full = js.report(include_silent=True) @@ -49,35 +49,35 @@ def test_silent_reasoning_not_in_default_report(self) -> None: self.assertNotIn("Silent reasoning", bare) def test_modulation_parse(self) -> None: - from hermespace.jspace import JSpace + from hermespace.access import AccessHub - js = JSpace(agent_id="mod-agent") + js = AccessHub(agent_id="mod-agent") m = js.parse_modulation("please hold: citrus fruits while copying") self.assertEqual(m["hold"], "citrus fruits while copying") m2 = js.parse_modulation("show desk") self.assertTrue(m2["summon"]) def test_release(self) -> None: - from hermespace.jspace import JSpace + from hermespace.access import AccessHub - js = JSpace(agent_id="rel-agent") + js = AccessHub(agent_id="rel-agent") js.hold("temp concept") self.assertTrue(js.release("temp concept")) self.assertFalse(js.release("temp concept")) def test_sync_from_desk(self) -> None: from hermespace.desk import Desk - from hermespace.jspace import JSpace + from hermespace.access import AccessHub desk = Desk( - goal="Ship Hermespace J-Space", + goal="Ship Hermespace Access Workspace", concepts=["[verbal|0.8] FOA cap", "[struct|0.6] ACTIVE.md"], decision="A — implement", plan=["code", "test"], say="Building the workspace.", ) desk.recompute_cognition("implement functional jspace") - js = JSpace(agent_id="sync-agent") + js = AccessHub(agent_id="sync-agent") st = js.sync_from_desk(desk, user_message="hold: arterial strip") self.assertGreaterEqual(len(st.hub), 1) self.assertTrue(any("arterial" in c.text.lower() for c in st.hub)) @@ -177,7 +177,7 @@ def test_beat_always_ok_shape(self) -> None: self.assertIn(out.get("mode"), ("center", "heart", "standalone")) -class TestWorkflowJSpaceIntegration(unittest.TestCase): +class TestWorkflowAccessHubIntegration(unittest.TestCase): def setUp(self) -> None: self._td = tempfile.TemporaryDirectory() self.root = Path(self._td.name) @@ -187,7 +187,7 @@ def tearDown(self) -> None: self._td.cleanup() os.environ.pop("HERMESPACE_HOME", None) - def test_turn_includes_jspace_meta(self) -> None: + def test_turn_includes_access_meta(self) -> None: from hermespace.workflow import Workflow from hermespace.io_contract import HermespaceInput @@ -204,7 +204,7 @@ def test_turn_includes_jspace_meta(self) -> None: ) ) self.assertFalse(out.skipped) - self.assertIn("jspace", out.meta or {}) + self.assertIn("access", out.meta or {}) self.assertIn("cube_beat", out.meta or {}) # context should carry broadcast or warehouse strip self.assertTrue(out.context) diff --git a/tests/test_jspace_protocol.py b/tests/test_access_protocol.py similarity index 80% rename from tests/test_jspace_protocol.py rename to tests/test_access_protocol.py index 1d4d018..0aed2f1 100644 --- a/tests/test_jspace_protocol.py +++ b/tests/test_access_protocol.py @@ -11,7 +11,7 @@ def tearDown(self) -> None: os.environ.pop("HERMESPACE_OEW", None) def test_non_material_always_ok(self) -> None: - from hermespace.jspace.protocol import evaluate_material_turn + from hermespace.access.protocol import evaluate_material_turn v = evaluate_material_turn(material=False) self.assertTrue(v.ok) @@ -19,7 +19,7 @@ def test_non_material_always_ok(self) -> None: def test_soft_mode_notes_missing_but_ok(self) -> None: os.environ["HERMESPACE_OEW"] = "0" - from hermespace.jspace.protocol import evaluate_material_turn + from hermespace.access.protocol import evaluate_material_turn v = evaluate_material_turn( material=True, silent_steps=0, has_report=True, hub_holds=0 @@ -30,7 +30,7 @@ def test_soft_mode_notes_missing_but_ok(self) -> None: def test_default_on_blocks_incomplete(self) -> None: os.environ.pop("HERMESPACE_OEW", None) # default ON - from hermespace.jspace.protocol import evaluate_material_turn, oew_enabled + from hermespace.access.protocol import evaluate_material_turn, oew_enabled self.assertTrue(oew_enabled()) v = evaluate_material_turn( @@ -41,7 +41,7 @@ def test_default_on_blocks_incomplete(self) -> None: def test_hard_mode_blocks_incomplete(self) -> None: os.environ["HERMESPACE_OEW"] = "1" - from hermespace.jspace.protocol import evaluate_material_turn + from hermespace.access.protocol import evaluate_material_turn v = evaluate_material_turn( material=True, silent_steps=0, has_report=True, hub_holds=0 @@ -51,7 +51,7 @@ def test_hard_mode_blocks_incomplete(self) -> None: def test_hard_mode_passes_complete(self) -> None: os.environ["HERMESPACE_OEW"] = "1" - from hermespace.jspace.protocol import evaluate_material_turn + from hermespace.access.protocol import evaluate_material_turn v = evaluate_material_turn( material=True, silent_steps=1, has_report=True, hub_holds=0 @@ -60,17 +60,17 @@ def test_hard_mode_passes_complete(self) -> None: self.assertFalse(v.missing) def test_package_exports(self) -> None: - from hermespace.jspace import ( - JSpace, - JSpaceEnv, + from hermespace.access import ( + AccessHub, + AccessEnv, ProtocolGate, evaluate_material_turn, ) self.assertTrue(callable(evaluate_material_turn)) self.assertTrue(ProtocolGate) - self.assertTrue(JSpace) - self.assertTrue(JSpaceEnv) + self.assertTrue(AccessHub) + self.assertTrue(AccessEnv) if __name__ == "__main__": diff --git a/tests/test_connect_room.py b/tests/test_connect_room.py index 0fef192..fd48652 100644 --- a/tests/test_connect_room.py +++ b/tests/test_connect_room.py @@ -1,4 +1,4 @@ -"""Cube-centered connect — agent gains world + J-Space + optional hive room.""" +"""Cube-centered connect — agent gains world + Access Workspace + optional hive room.""" from __future__ import annotations @@ -37,14 +37,14 @@ def test_connect_standalone_gains_world_and_hub(self) -> None: self.assertTrue(out.get("ok"), msg=out) gained = out.get("gained") or {} self.assertGreaterEqual(int(gained.get("world_beliefs") or 0), 1) - self.assertGreaterEqual(int(gained.get("jspace_hub") or 0), 1) + self.assertGreaterEqual(int(gained.get("access_hub") or 0), 1) self.assertEqual(gained.get("room_mode"), "solo") self.assertRegex(out.get("summary") or "", r"(?i)connected") st = hb.status() self.assertTrue(st["ready"]) self.assertTrue(st.get("connected")) - self.assertGreaterEqual(int(st["jspace"].get("hub_n") or 0), 1) + self.assertGreaterEqual(int(st["access"].get("hub_n") or 0), 1) def test_room_solo_and_hive_env(self) -> None: import types @@ -83,8 +83,8 @@ def list_souls(_root): self.assertEqual(st["soul_n"], 2) def test_seed_peers_silent(self) -> None: - from hermespace.cube_module import seed_jspace_from_warehouse - from hermespace.jspace import JSpace + from hermespace.cube_module import seed_access_from_warehouse + from hermespace.access import AccessHub aid = "peer-seed" room = { @@ -95,10 +95,10 @@ def test_seed_peers_silent(self) -> None: {"agent_id": "bob-agent", "self": False, "wisdom_n": 1}, ], } - rep = seed_jspace_from_warehouse(aid, room=room) + rep = seed_access_from_warehouse(aid, room=room) self.assertTrue(rep.get("ok")) self.assertGreaterEqual(int(rep.get("enriched_peers") or 0), 1) - js = JSpace(agent_id=aid) + js = AccessHub(agent_id=aid) labels = " ".join(c.text for c in js.state.hub).casefold() self.assertIn("alice-agent", labels) # Peer presence should be silent (not Report by default) diff --git a/tests/test_hermes_base.py b/tests/test_hermes_base.py index 6c730a8..02604e5 100644 --- a/tests/test_hermes_base.py +++ b/tests/test_hermes_base.py @@ -1,4 +1,4 @@ -"""HermesBase facade — Hermes base as functional J-space.""" +"""HermesBase facade — Hermes base as functional Access Workspace.""" from __future__ import annotations @@ -27,7 +27,7 @@ def test_status_ready(self) -> None: self.assertIn("connect", st["ops"]) self.assertIn("lens", st["ops"]) self.assertIn("room", st) - self.assertEqual(st.get("engine"), "JSpaceEngine") + self.assertEqual(st.get("engine"), "AccessEngine") def test_connect_facade(self) -> None: from hermespace import HermesBase @@ -49,7 +49,7 @@ def test_think_ignites(self) -> None: say="On it.", ) self.assertFalse(out["skipped"]) - self.assertTrue(out["has_jspace_broadcast"]) + self.assertTrue(out["has_access_broadcast"]) self.assertTrue(out.get("report")) def test_video_ops_chain(self) -> None: diff --git a/tests/test_hermes_bridge.py b/tests/test_hermes_bridge.py index bbb3338..02387a1 100644 --- a/tests/test_hermes_bridge.py +++ b/tests/test_hermes_bridge.py @@ -17,7 +17,7 @@ def test_session_and_pre_llm(self): self.assertIsInstance(r, dict) self.assertIn("context", r) self.assertTrue( - "J-Space Engine" in r["context"] or "Workbench" in r["context"] + "Access Engine" in r["context"] or "Workbench" in r["context"] ) desk = load_desk() self.assertTrue(desk.goal) @@ -33,7 +33,7 @@ def test_session_and_pre_llm(self): self.assertTrue( "user_reply_hint" in inj or "Dual decode" in inj["context"] - or "J-Space" in inj["context"] + or "Access Workspace" in inj["context"] ) on_session_end(session_id="bridge-test") diff --git a/tests/test_oew_causal.py b/tests/test_oew_causal.py index 1c4fc2b..0749bb7 100644 --- a/tests/test_oew_causal.py +++ b/tests/test_oew_causal.py @@ -25,7 +25,7 @@ def tearDown(self) -> None: def test_auto_park_on_material_advance(self) -> None: from hermespace.desk import Desk - from hermespace.jspace import JSpaceEnv + from hermespace.access import AccessEnv desk = Desk( goal="Fix auth timeout", @@ -33,7 +33,7 @@ def test_auto_park_on_material_advance(self) -> None: decision="A — patch TTL", say="Patching session TTL.", ) - env = JSpaceEnv(agent_id=self._agent) + env = AccessEnv(agent_id=self._agent) meta = env.advance_turn( user_message="Fix auth then verify", desk=desk, @@ -47,9 +47,9 @@ def test_auto_park_on_material_advance(self) -> None: def test_soccer_rugby_swap_shapes_report(self) -> None: """Anthropic Soccer→Rugby analogue: sticky swap rewrites Report.""" - from hermespace.jspace import JSpaceEnv + from hermespace.access import AccessEnv - env = JSpaceEnv(agent_id=self._agent) + env = AccessEnv(agent_id=self._agent) env.space.hold("Soccer", salience=0.95) env.space.reason_step("thinking of Soccer", salience=0.9) out = env.swap("Soccer", "Rugby") @@ -62,18 +62,18 @@ def test_soccer_rugby_swap_shapes_report(self) -> None: self.assertTrue(any("Rugby" in s for s in env.space.state.silent_steps)) def test_inject_appears_in_lens(self) -> None: - from hermespace.jspace import JSpaceEnv + from hermespace.access import AccessEnv - env = JSpaceEnv(agent_id=self._agent) + env = AccessEnv(agent_id=self._agent) env.inject_thought("lightning", silent=True) hits = env.lens(include_silent=True) texts = " ".join(h.text for h in hits).casefold() self.assertIn("lightning", texts) def test_ablate_filters_broadcast(self) -> None: - from hermespace.jspace import JSpaceEnv + from hermespace.access import AccessEnv - env = JSpaceEnv(agent_id=self._agent) + env = AccessEnv(agent_id=self._agent) env.space.hold("this is fake evaluation scenario", salience=0.9) env.space.hold("ship the feature", salience=0.85) env.ablate("fake", "evaluation") @@ -82,9 +82,9 @@ def test_ablate_filters_broadcast(self) -> None: self.assertIn("ship", block.casefold()) def test_reflect_seeds_next_mid_band(self) -> None: - from hermespace.jspace import JSpaceEnv + from hermespace.access import AccessEnv - env = JSpaceEnv(agent_id=self._agent) + env = AccessEnv(agent_id=self._agent) env.reflect(answer="Stay honest; user-primary", principles=["honesty", "user-primary"]) self.assertTrue(env._env.get("pending_silent")) # Next advance consumes seeds into silent_steps @@ -115,10 +115,10 @@ def test_workflow_material_has_oew(self) -> None: ) self.assertFalse(out.skipped) self.assertTrue(out.report) - oew = (out.meta or {}).get("jspace", {}).get("oew") or {} - self.assertTrue(oew or (out.meta or {}).get("jspace", {}).get("oew_ok") is not False) + oew = (out.meta or {}).get("access", {}).get("oew") or {} + self.assertTrue(oew or (out.meta or {}).get("access", {}).get("oew_ok") is not False) # Context (model) should carry hub/silent; report should stay short-ish - self.assertIn("J-Space", out.context or "") + self.assertIn("Access Workspace", out.context or "") if __name__ == "__main__": From d705aa64b4b92c38f9eaac2a05627ede06fac863 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:35:02 +0000 Subject: [PATCH 09/32] feat(0.25): harden Access Engine for Hermes v0.20 Co-authored-by: Pablo --- .github/workflows/ci.yml | 61 +++++ INTEGRATION.md | 11 +- PURPOSE.md | 261 +++++++++++++------ README.md | 41 ++- __init__.py | 20 ++ after-install.md | 17 ++ docs/access/34-access-engine.md | 2 +- docs/ops/35-production-operations.md | 106 ++++++++ hermes_plugin/__init__.py | 33 +-- hermes_plugin/plugin.yaml | 16 +- plugin.yaml | 20 ++ pyproject.toml | 25 +- scripts/install_hermes.sh | 118 +++++---- scripts/smoke_test.sh | 22 +- scripts/verify_hermes_integration.py | 176 +++++++++++++ skills/hermespace/SKILL.md | 5 +- skills/hermespace/references/plugin-hooks.md | 24 +- src/hermespace/__init__.py | 2 +- src/hermespace/access/engine.py | 105 ++++++-- src/hermespace/access/env.py | 6 +- src/hermespace/access/hub.py | 8 +- src/hermespace/access/oew.py | 6 +- src/hermespace/access/protocol.py | 6 +- src/hermespace/atomic.py | 57 ++++ src/hermespace/cli.py | 2 +- src/hermespace/cube_module.py | 13 +- src/hermespace/gate.py | 2 +- src/hermespace/hermes_bridge.py | 242 +++++++++++++++-- src/hermespace/hermes_runtime.py | 243 +++++++++++++++++ src/hermespace/local_model.py | 4 +- src/hermespace/ops.py | 40 ++- src/hermespace/paths.py | 30 +++ src/hermespace/patterns.py | 8 +- src/hermespace/plugin.py | 134 ++++++++++ src/hermespace/store.py | 32 ++- src/hermespace/workbench.py | 24 +- src/hermespace/workflow.py | 18 +- tests/test_access_engine.py | 12 + tests/test_hermes_bridge.py | 23 +- tests/test_plugin_contract.py | 130 +++++++++ tests/test_store.py | 68 +++++ 41 files changed, 1882 insertions(+), 291 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 __init__.py create mode 100644 after-install.md create mode 100644 docs/ops/35-production-operations.md create mode 100644 plugin.yaml create mode 100755 scripts/verify_hermes_integration.py create mode 100644 src/hermespace/atomic.py create mode 100644 src/hermespace/hermes_runtime.py create mode 100644 src/hermespace/plugin.py create mode 100644 tests/test_plugin_contract.py create mode 100644 tests/test_store.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..09efc8c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + python: + name: Python ${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.10", "3.12", "3.13"] + env: + HERMESPACE_NEURAL_VERBALIZE: "0" + HERMESPACE_SKIP_NEURAL: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - name: Install clean package + run: python -m pip install . + - name: Unit tests + run: python -m unittest discover -s tests -v + - name: Hermes v0.20 host contract + run: python scripts/verify_hermes_integration.py + + integration: + name: Smoke and operational E2E + runs-on: ubuntu-latest + env: + HERMESPACE_NEURAL_VERBALIZE: "0" + HERMESPACE_SKIP_NEURAL: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install . + - name: Security audit + run: ./scripts/security_audit.sh + - name: Smoke + run: ./scripts/smoke_test.sh + - name: Ops end-to-end + run: ./scripts/e2e_ops.sh + - name: Build wheel and sdist + run: | + python -m pip install build + python -m build diff --git a/INTEGRATION.md b/INTEGRATION.md index 87243bd..2ab76a2 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -122,12 +122,15 @@ send_user(result["user_reply"]) See docs/14-workbench-pocket-dimension.md. -## Hermes framework hooks (v0.12) +## Hermes framework hooks (v0.20+) Plugin registers: -- `on_session_start` — workbench enter + env kit context -- `pre_llm_call` — desk + neural broadcast + workbench footer -- `on_session_end` — idle_tick +- `on_session_start` — initialize the session scope +- `pre_llm_call` — bounded ephemeral Access Workspace context +- `post_llm_call` / `post_tool_call` — observe completed work +- `on_session_end` — lightweight turn boundary +- `on_session_finalize` — final harvest + idle maintenance +- `on_session_reset` / `subagent_start` / `subagent_stop` — gateway/A2A lifecycle Why: docs/16-why-hermes-framework.md diff --git a/PURPOSE.md b/PURPOSE.md index fd1ee81..22acd33 100644 --- a/PURPOSE.md +++ b/PURPOSE.md @@ -1,95 +1,190 @@ # PURPOSE.md — Hermespace north star -**One line:** Hermespace is the **true external Access Workspace environment** for Hermes -Agent — a harness where the agent’s verbalizable thoughts are forced into an -observable workspace (lens · silent chain · audit · reflect), running -**standalone** and **powered by HermesCube** at night the way CubeDream -consolidates the day. - -Public pitch: **[ABOUT.md](ABOUT.md)**. Assessment: -**[docs/assessment/28-hermes-agent-jspace-assessment.md](docs/assessment/28-hermes-agent-jspace-assessment.md)**. -OEW thesis: **[docs/access/thesis-oew.md](docs/access/thesis-oew.md)**. -Hermes base as J-space: **[docs/access/32-hermes-base-as-jspace.md](docs/access/32-hermes-base-as-jspace.md)**. -Anthropic X video: **[docs/access/31-anthropic-x-video-deep-dive.md](docs/access/31-anthropic-x-video-deep-dive.md)**. -Environment: **[docs/access/27-environment.md](docs/access/27-environment.md)**. -Code layout: **[LAYOUT.md](LAYOUT.md)** · **[docs/architecture/CODEMAP.md](docs/architecture/CODEMAP.md)**. -Cube contract: **[docs/architecture/HERMESCUBE.md](docs/architecture/HERMESCUBE.md)**. +## One line + +**Hermespace is the production Access Engine for Hermes Agent: a local, +session-safe workspace that turns goals, evidence, tool activity, and silent +intermediates into bounded context before a turn and durable continuity after +it.** + +It is not another agent, model provider, or memory-provider competitor. +Hermes remains the actor. Hermespace is the room in which Hermes keeps the +current problem coherent. --- -## Problem +## Why it exists -Anthropic showed that Claude has an internal J-space — a small privileged set of -verbalizable representations that support report, modulation, silent reasoning, -and flexible broadcast (GWT). Operators of Hermes agents face the same opacity: -you usually only see what the agent *writes*, not what it *thinks* mid-task. +An agent can be fluent while losing the thread: goals drift, tool evidence is +forgotten, several sessions overwrite one another, and the final answer exposes +less than the process needed to produce it. A durable archive alone does not +solve this; the agent needs a small, causal working set for the current turn. -We cannot attach a Jacobian lens to Hermes model weights. We can build an -**external** workspace that plays the same functional role — and join it to -Cube’s durable heart so day-thoughts become night-memory. +Hermespace provides that working set: -## Solution +- **AccessHub** — limited active concepts (hub ≤25, focus ≤4) +- **OEW** — material work must park at least one useful intermediate +- **dual decode** — dense model context stays separate from the user report +- **operator access** — lens, hold, swap, inject, ablate, reflect, audit +- **session continuity** — WorldModel, workbench, episodic receipts, runtime facts +- **native Hermes lifecycle** — CLI, gateways, A2A, tools, subagents, teardown ``` -┌─────────────────────────────────────────────────────────────────┐ -│ Hermes Agent │ -│ │ -│ Hermespace Access Workspace ENV (this package) │ -│ protocol → early/mid/late bands │ -│ silent chain (model only) · Report (user) │ -│ lens readout · swap/inject/ablate · audit · reflect │ -│ viewport = operator window into Hermes thinking │ -│ ↕ soft cable │ -│ HermesCube (optional heart) │ -│ arterial strip by day · seal + CubeDream by night │ -└─────────────────────────────────────────────────────────────────┘ +user / gateway / A2A + │ + ▼ + Hermes Agent + tools · skills · model + │ + ▼ + Hermespace Access Engine + ├─ per-session desk + AccessHub + ├─ OEW gate + bounded broadcast + ├─ runtime/tool/subagent receipts + ├─ short Report / dense model context + └─ persistent WorldModel + optional warehouse ``` -| Layer | Job | Authority | -|-------|-----|-----------| -| **Access Workspace environment** | Externalize + observe verbalizable thought | Turn FOA + audit SoT | -| **ACTIVE desk** | Goal / decision / report | Live turn surface | -| **WorldModel JSONL** | Identity projection | Recharged from Cube when present | -| **HermesCube** | Durable long-tail warehouse | Durable memory SoT when installed | -| **Standalone warehouse** | Semantic + World | Local SoT when Cube absent | - -## Anthropic → Hermespace map - -| Anthropic | Hermespace | -|-----------|------------| -| J-lens readout | `hs jspace lens` / viewport panel | -| Causal swap / inject / ablate | `swap` / `inject` / `ablate` | -| Silent multi-step intermediates | `reason_step` + mid band | -| Eval-awareness / hidden-goal audit | `audit()` soft lexicon | -| Assistant POV in workspace | `set_pov` | -| Counterfactual reflection training | `reflect()` + seal principles | -| Night / sleep consolidation | `dream_harvest` + CubeDream + pulse | - -## Standalone vs Cube-powered - -| Mode | Warehouse | Night path | -|------|-----------|------------| -| Standalone | Semantic + World | harvest → semantic/world | -| Heart / Center | `memory.cube` | harvest → seal_learning → CubeDream | - -## Non-goals - -- Not weight-level J-lens · not consciousness claims -- Not a second LLM runtime · not MEMORY.md rewrite -- Silent chain never auto-dumps into user chat - -## Success metrics - -1. Operator can `hs jspace lens` and see silent intermediates Hermes parked -2. Sticky swap changes subsequent Report/broadcast (Soccer→Rugby) -3. Material turns auto-park ≥1 silent step (`HERMESPACE_OEW=1` default) -4. Reflect seeds the next mid-band; ablate filters model inject -5. Dual decode: user Report shaped; model gets hub + Cube strip -6. Audit flags externalized manipulation/eval-awareness language -7. Dream/pulse harvest feeds Cube or standalone warehouse -8. Smoke 9/9 · unit tests green · runs without Cube - -## Version posture - -Deepen the *environment* (observe + intervene + dream), not a second archive. -Plugin yaml + `__version__` move together. +--- + +## Operating contract + +### Before a model turn + +`pre_llm_call` receives the original user message. Hermespace: + +1. decides whether the turn is material, +2. restores that session's desk and hub, +3. selects a bounded focus, +4. parks silent multi-step intermediates when required, +5. injects ephemeral context into the **user message only**. + +The system prompt remains untouched, preserving Hermes prompt-cache behavior. + +### During a turn + +Hermes tools, skills, and subagents remain the specialist processors. +Hermespace records bounded operational facts (tool names and counts, never +arguments/results) and keeps the active workspace available to every downstream +step. + +### After a turn + +`post_llm_call` records the completed report and closes the native turn. +`on_session_end` is treated as a **turn boundary**, matching Hermes v0.20. +Only `on_session_finalize` performs final harvest and idle maintenance. + +### Across concurrent sessions + +Opaque Hermes session IDs are hashed into independent desk/hub paths. A gateway +user, A2A peer, or subagent cannot inherit another session's silent workspace. +World identity may remain agent-scoped; active cognition is session-scoped. + +--- + +## Authority and boundaries + +| Layer | Authority | +|-------|-----------| +| AccessHub / OEW | Active turn concepts, silent intermediates, modulation | +| Session desk | Goal, decision, plan, report, structured turn metadata | +| Workbench | Session mode, parked goals, last native turn | +| WorldModel | Agent-scoped beliefs, landmarks, timeline | +| Hermes Agent | Model calls, tools, skills, approvals, transcript | +| Optional warehouse | Long-tail recall and consolidation only | + +Hermespace does **not**: + +- replace Hermes's `MemoryProvider`, +- replace Hermes's context compressor, +- mutate the persisted Hermes conversation, +- inject into the system prompt, +- persist tool arguments, tool results, or secret-bearing prompt text in + runtime telemetry, +- claim weight-level interpretability or consciousness. + +--- + +## Hermes Agent compatibility target + +Primary target: **Hermes Agent v0.20.0+**. + +Hermespace uses the current public plugin contracts: + +- `on_session_start` +- `pre_llm_call` +- `post_llm_call` +- `post_tool_call` +- `on_session_end` +- `on_session_finalize` +- `on_session_reset` +- `subagent_start` / `subagent_stop` +- `/hermespace` slash command +- `hermes hermespace` CLI command + +New Hermes capabilities should be adopted only when they strengthen the Access +Engine without taking ownership from Hermes. In particular, Hermespace does not +register an exclusive context engine merely to observe turns; current +`pre_llm_call` and `post_llm_call` hooks already provide the correct seams. + +--- + +## Quality bar + +Hermespace should be held to the same engineering standard as Hermes Agent: + +1. **Installable** — clean `pip install .` declares all runtime dependencies. +2. **Native** — `hermes plugins install PabloTheThinker/hermespace --enable` + installs a working root plugin. +3. **Fail-visible** — a missing runtime fails registration; it never becomes an + enabled no-op. +4. **Session-safe** — active desks and hubs are isolated by session. +5. **Crash-safe** — critical state writes use atomic replace. +6. **Bounded** — context, hub, silent chain, telemetry, and session registry have + explicit caps. +7. **Private by default** — runtime metrics store lengths/names, not payloads. +8. **Cache-safe** — plugin context is ephemeral user-message context. +9. **Observable** — `/hermespace runtime`, `hermes hermespace doctor`, and + `hermespace ops doctor` explain current health. +10. **Proven** — clean-wheel tests, current Hermes host-contract tests, unit + tests, smoke, operational E2E, and security audit run in CI. + +--- + +## Acceptance tests + +The project is operational only when all of these pass: + +```bash +python -m pip install . +python -m unittest discover -s tests -v +python scripts/verify_hermes_integration.py +./scripts/security_audit.sh +./scripts/smoke_test.sh +./scripts/e2e_ops.sh +python -m build +``` + +And on a Hermes installation: + +```bash +hermes plugins install PabloTheThinker/hermespace --enable +hermes hermespace doctor +# inside a session +/hermespace status +``` + +--- + +## Product direction + +Deepen the **Access Engine**, not the feature count. + +Prefer changes that make the current turn more coherent, causal, bounded, +private, and observable. Reject changes that create a second agent framework, +duplicate Hermes ownership, or add ceremony without improving an executable +contract. + +Architecture: [docs/architecture/CODEMAP.md](docs/architecture/CODEMAP.md) +Operations: [docs/ops/35-production-operations.md](docs/ops/35-production-operations.md) +Access Engine: [docs/access/34-access-engine.md](docs/access/34-access-engine.md) diff --git a/README.md b/README.md index 23c478c..e4791ac 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@

- A persistent agent world that grows forever. Pocket workbench for the current turn. + The production Access Engine for Hermes Agent.

Python Hermes Agent - Version + Version Smoke 9/9 MIT

@@ -25,9 +25,13 @@
-**Hermespace is an append-only persistent world for Hermes agents.** Every session, every belief, every landmark, every evolution is recorded in an archive that never prunes, never decays, never caps. The agent builds a deepening model of itself and its environment across sessions — and it outlives the user. +**Hermespace gives Hermes Agent a bounded, session-safe working room.** It +selects active concepts before a material turn, keeps silent intermediates out +of user chat, observes native tools/subagents, and preserves continuity after +the turn. Hermes remains the actor; Hermespace keeps the problem coherent. -Not [Access Workspace](https://github.com/anomalyco/j-space). Not a second agent runtime. A room inside Hermes that remembers everything. +Production target: **Hermes Agent v0.20.0+** — CLI, gateways, A2A, tools, +subagents, turn boundaries, and finalization. --- @@ -142,11 +146,18 @@ Alongside the world, Hermespace provides a desk for the current turn — FOA, du ### Quick start +```bash +hermes plugins install PabloTheThinker/hermespace --enable +hermes hermespace doctor +``` + +For a development checkout: + ```bash git clone https://github.com/PabloTheThinker/hermespace.git cd hermespace -./scripts/install_hermes.sh # link skill + plugin -./scripts/smoke_test.sh # expect 9/9 +./scripts/install_hermes.sh +python scripts/verify_hermes_integration.py ``` ```python @@ -163,9 +174,13 @@ ctx = r["model_context"] # → model (includes world context) | Hook | What happens | |---|---| -| `on_session_start` | `WorldModel.enter()` + workbench enter + `ensure_heart` + Access Workspace sync | -| `pre_llm_call` | Desk + world + `cube_beat` arterial strip + Access Workspace broadcast | -| `on_session_end` | `WorldModel.leave()` + workbench idle tick (autonomic pulse) | +| `on_session_start` | Initialize session desk/hub and stage first-turn context | +| `pre_llm_call` | Select bounded Access Workspace context (user-message only) | +| `post_llm_call` | Observe successful native response and update workbench | +| `post_tool_call` | Record bounded tool-name/count telemetry (no payloads) | +| `on_session_end` | Lightweight end-of-turn receipt (Hermes v0.20 semantics) | +| `on_session_finalize` | Idempotent harvest, world leave, idle maintenance | +| `subagent_start/stop` | Track specialist lifecycle for runtime observability | --- @@ -198,6 +213,9 @@ State (local, never committed): ```text $HERMESPACE_HOME/memory/hermespace/ ACTIVE.md live desk + sessions/ per-session ACTIVE desks (hashed Hermes session IDs) + access/ per-session AccessHub state + runtime/ bounded hook telemetry (names/counts, no payloads) hermespace.db turn database journal/ human-readable logs workbenches/ per-agent workbench JSON @@ -236,7 +254,8 @@ $HERMESPACE_HOME/memory/hermespace/ | [`docs/access/29-baars-changeux-anthropic.md`](docs/access/29-baars-changeux-anthropic.md) | Baars · Changeux/Dehaene · Anthropic research bridge | | [`docs/access/30-day-to-day-higher-order.md`](docs/access/30-day-to-day-higher-order.md) | Day-to-day higher-order Hermes usage | | [`docs/access/31-anthropic-x-video-deep-dive.md`](docs/access/31-anthropic-x-video-deep-dive.md) | Anthropic X video — how J-space is operated | -| [`docs/access/32-hermes-base-as-jspace.md`](docs/access/32-hermes-base-as-jspace.md) | Hermes base = J-space of Hermes agents | +| [`docs/access/32-hermespace-access-engine.md`](docs/access/32-hermespace-access-engine.md) | Hermespace Access Engine architecture | +| [`docs/ops/35-production-operations.md`](docs/ops/35-production-operations.md) | Hermes v0.20 lifecycle, install, health, recovery | | [`docs/access/27-environment.md`](docs/access/27-environment.md) | Environment API | | [`ABOUT.md`](ABOUT.md) | Philosophy, design principles, author | | [`docs/architecture/CODEMAP.md`](docs/architecture/CODEMAP.md) | Where to edit (layer map) | @@ -263,7 +282,7 @@ src/hermespace/ runtime package grid/ autonomy grid hermes_plugin/ Hermes session / pre_llm / end hooks skills/hermespace/ public Hermes agent skill -docs/ jspace · assessment · architecture · ops · research +docs/ access · assessment · architecture · ops · research scripts/ CLI, install, smoke test, security audit tests/ · experiments/ · desktop_plugin/ · spec/ ``` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..1009398 --- /dev/null +++ b/__init__.py @@ -0,0 +1,20 @@ +"""Hermes source-repository plugin entry point. + +``hermes plugins install PabloTheThinker/hermespace --enable`` clones this +repository as one plugin. Add the repository's ``src`` directory, then load +the same registration module used by wheel entry-point installs. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent +_SRC = _ROOT / "src" +if str(_SRC) not in sys.path: + sys.path.insert(0, str(_SRC)) + +from hermespace.plugin import register # noqa: E402,F401 + +__all__ = ["register"] diff --git a/after-install.md b/after-install.md new file mode 100644 index 0000000..8859459 --- /dev/null +++ b/after-install.md @@ -0,0 +1,17 @@ +# Hermespace installed + +Hermespace is a general plugin and must be enabled before its hooks load: + +```bash +hermes plugins enable hermespace +hermes hermespace doctor +``` + +Inside a Hermes session: + +```text +/hermespace status +``` + +Hermespace targets Hermes Agent v0.20.0+. It keeps active desks and hubs +session-scoped and injects context into the user message only. diff --git a/docs/access/34-access-engine.md b/docs/access/34-access-engine.md index f7d8b05..6e9c94e 100644 --- a/docs/access/34-access-engine.md +++ b/docs/access/34-access-engine.md @@ -1,6 +1,6 @@ # Access Engine — Hermespace's open access workspace for Hermes Agent -**Version:** 0.24.0 +**Version:** 0.25.0 **Product name:** Hermespace **Access Engine** / **Access Workspace** **Thesis:** Hermes Agent usually has no weight access. Hermespace therefore ships its own privileged verbalizable workspace — an open-source harness the diff --git a/docs/ops/35-production-operations.md b/docs/ops/35-production-operations.md new file mode 100644 index 0000000..8be193e --- /dev/null +++ b/docs/ops/35-production-operations.md @@ -0,0 +1,106 @@ +# Production operations — Hermes Agent v0.20+ + +Hermespace 0.25 targets the current Hermes plugin host, including CLI, +gateways, A2A sessions, tools, subagents, and finalization. + +## Install + +Preferred: + +```bash +hermes plugins install PabloTheThinker/hermespace --enable +hermes hermespace doctor +``` + +Checkout development: + +```bash +./scripts/install_hermes.sh +python scripts/verify_hermes_integration.py +``` + +The repository root contains `plugin.yaml` and `__init__.py`, which is the +layout expected by `hermes plugins install owner/repo`. A pip install also +publishes the `hermes_agent.plugins` entry point. + +## Native lifecycle + +| Hermes v0.20 hook | Hermespace behavior | +|-------------------|----------------------| +| `on_session_start` | Initialize and stage first-turn context | +| `pre_llm_call` | Inject bounded ephemeral user-message context | +| `post_llm_call` | Observe successful response | +| `post_tool_call` | Count tool name only; no args/results | +| `on_session_end` | End one `run_conversation` turn; no harvest | +| `on_session_finalize` | Idempotent final harvest and idle maintenance | +| `on_session_reset` | Prime a rotated gateway session | +| `subagent_start/stop` | Count specialist lifecycle | + +The distinction between `on_session_end` and `on_session_finalize` is +important: current Hermes fires `on_session_end` after every turn. + +## Session isolation + +Hermes session IDs are hashed before entering a path. Active desks and hubs are +stored under separate session scopes: + +```text +$HERMESPACE_HOME/memory/hermespace/ + sessions/--/ACTIVE.md + access/--.json + runtime/.json +``` + +Runtime snapshots contain counts, lengths, model/platform labels, and tool +names. They never contain prompts, tool arguments, or tool results. + +## Health + +Terminal: + +```bash +hermes hermespace status +hermes hermespace runtime +hermes hermespace doctor +hermespace ops doctor +``` + +Inside Hermes: + +```text +/hermespace status +/hermespace metrics +/hermespace runtime +/hermespace lens +``` + +`ops doctor` reports `ok` for local engine health and `integration_ok` for the +native Hermes plugin door. + +## Latency posture + +The native `pre_llm_call` path defaults to cached/hash-only enrichment and +does not run the full doctor or viewport generator. Set +`HERMESPACE_SKIP_NEURAL=0` only when synchronous neural enrichment is known to +meet your platform latency budget. + +## Failure behavior + +- Plugin registration fails visibly if the runtime cannot import. +- Optional hooks are skipped only on older Hermes hosts; the required + start/pre-LLM/end hooks must register. +- State writes use atomic same-filesystem replacement. +- Corrupt desk JSON sidecars fall back to human-readable `ACTIVE.md`. +- Finalization is idempotent when CLI and gateway teardown paths converge. + +## Release verification + +```bash +python -m pip install . +python -m unittest discover -s tests -v +python scripts/verify_hermes_integration.py +./scripts/security_audit.sh +./scripts/smoke_test.sh +./scripts/e2e_ops.sh +python -m build +``` diff --git a/hermes_plugin/__init__.py b/hermes_plugin/__init__.py index caaa4c4..05be5d9 100644 --- a/hermes_plugin/__init__.py +++ b/hermes_plugin/__init__.py @@ -1,4 +1,4 @@ -"""Hermes file-plugin — first-class Hermespace workbench integration.""" +"""Source-tree wrapper for the first-class Hermespace plugin.""" from __future__ import annotations @@ -7,7 +7,7 @@ import sys from pathlib import Path -__version__ = "0.24.0" +__version__ = "0.25.0" logger = logging.getLogger("hermes.plugins.hermespace") @@ -72,25 +72,14 @@ def _ensure_import() -> bool: return False -_IMPORT_OK = _ensure_import() - - def register(ctx) -> None: - """Register Hermes lifecycle hooks — workbench is part of the framework path.""" - if not _IMPORT_OK and not _ensure_import(): - logger.error("Hermespace plugin registered but package missing — hooks skipped") - return - - from hermespace.hermes_bridge import ( - on_pre_llm_call, - on_session_end, - on_session_start, - ) + """Resolve the runtime and delegate to the packaged plugin entry point.""" + if not _ensure_import(): + raise RuntimeError( + "Hermespace runtime is not importable. Run ./scripts/install_hermes.sh " + "or pip install the Hermespace checkout before enabling the plugin." + ) - ctx.register_hook("on_session_start", on_session_start) - ctx.register_hook("pre_llm_call", on_pre_llm_call) - ctx.register_hook("on_session_end", on_session_end) - logger.info( - "Hermespace v%s registered: on_session_start + pre_llm_call + on_session_end", - __version__, - ) + from hermespace.plugin import register as register_runtime + + register_runtime(ctx) diff --git a/hermes_plugin/plugin.yaml b/hermes_plugin/plugin.yaml index 3b7c124..f16a0b0 100644 --- a/hermes_plugin/plugin.yaml +++ b/hermes_plugin/plugin.yaml @@ -1,14 +1,20 @@ +manifest_version: 1 name: hermespace -version: "0.24.0" +version: "0.25.0" description: > - Hermespace Access Engine for Hermes agents — open-source GWT harness - (report/modulate/silent-reason/broadcast/selectivity), OEW ON by default, - dual decode, optional warehouse. Hooks: on_session_start, pre_llm_call, - on_session_end. Set HERMESPACE_ROOT to checkout. + Production Access Engine for Hermes Agent v0.20+: session-scoped workspace, + bounded model context, native lifecycle telemetry, dual decode, and + standalone-safe persistence. author: Hermespace contributors kind: standalone hooks: - on_session_start - pre_llm_call + - post_llm_call + - post_tool_call - on_session_end + - on_session_finalize + - on_session_reset + - subagent_start + - subagent_stop homepage: https://github.com/PabloTheThinker/hermespace diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..f16a0b0 --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,20 @@ +manifest_version: 1 +name: hermespace +version: "0.25.0" +description: > + Production Access Engine for Hermes Agent v0.20+: session-scoped workspace, + bounded model context, native lifecycle telemetry, dual decode, and + standalone-safe persistence. +author: Hermespace contributors +kind: standalone +hooks: + - on_session_start + - pre_llm_call + - post_llm_call + - post_tool_call + - on_session_end + - on_session_finalize + - on_session_reset + - subagent_start + - subagent_stop +homepage: https://github.com/PabloTheThinker/hermespace diff --git a/pyproject.toml b/pyproject.toml index 148ece9..51920b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,23 @@ [project] name = "hermespace" -version = "0.24.0" -description = "OEW J-Space for Hermes Agent — obligatory external workspace, Cube heart, higher-order thinking" +version = "0.25.0" +description = "Production Access Engine and persistent workspace for Hermes Agent" requires-python = ">=3.10" readme = "README.md" license = { text = "MIT" } +authors = [{ name = "Hermespace contributors" }] +keywords = ["hermes-agent", "agents", "workspace", "memory", "cognition"] +dependencies = ["numpy>=1.24"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] [project.urls] Homepage = "https://github.com/PabloTheThinker/hermespace" @@ -15,8 +28,14 @@ Documentation = "https://github.com/PabloTheThinker/hermespace#documentation" [project.scripts] hermespace = "hermespace.cli:main" +[project.entry-points."hermes_agent.plugins"] +hermespace = "hermespace.plugin" + +[project.optional-dependencies] +dev = ["build", "ruff"] + [build-system] -requires = ["setuptools>=61"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] diff --git a/scripts/install_hermes.sh b/scripts/install_hermes.sh index 406fcfd..fd34968 100755 --- a/scripts/install_hermes.sh +++ b/scripts/install_hermes.sh @@ -1,74 +1,100 @@ #!/usr/bin/env bash -# One-shot Hermespace → Hermes Agent install (skill + plugin + env hints). +# Production Hermespace → Hermes Agent installer. set -euo pipefail + ROOT="$(cd "$(dirname "$0")/.." && pwd)" HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" HERMESPACE_HOME="${HERMESPACE_HOME:-$HOME/.hermespace}" +INSTALL_DESKTOP=1 +ENABLE_PLUGIN=1 + +for arg in "$@"; do + case "$arg" in + --no-desktop) INSTALL_DESKTOP=0 ;; + --no-enable) ENABLE_PLUGIN=0 ;; + -h|--help) + echo "usage: $0 [--no-desktop] [--no-enable]" + exit 0 + ;; + *) + echo "hermespace: unknown installer option: $arg" >&2 + exit 2 + ;; + esac +done -echo "Hermespace install" +# shellcheck source=scripts/_python.sh +source "$ROOT/scripts/_python.sh" + +echo "Hermespace production install" echo " CHECKOUT=$ROOT" echo " HERMES_HOME=$HERMES_HOME" echo " HERMESPACE_HOME=$HERMESPACE_HOME" +echo " PYTHON=$PYTHON" -mkdir -p "$HERMESPACE_HOME" -mkdir -p "$HERMES_HOME/skills" "$HERMES_HOME/plugins" - -# Skill -rm -rf "$HERMES_HOME/skills/hermespace" -ln -sfn "$ROOT/skills/hermespace" "$HERMES_HOME/skills/hermespace" -echo " skill → $HERMES_HOME/skills/hermespace" +mkdir -p "$HERMESPACE_HOME" "$HERMES_HOME/skills" "$HERMES_HOME/plugins" -# Plugin (symlink so package auto-discovers ../src) -rm -rf "$HERMES_HOME/plugins/hermespace" -ln -sfn "$ROOT/hermes_plugin" "$HERMES_HOME/plugins/hermespace" -echo " hermes plugin → $HERMES_HOME/plugins/hermespace" +echo " installing Python package" +"$PYTHON" -m pip install -e "$ROOT" --quiet +"$PYTHON" -c \ + "import hermespace; from hermespace import AccessEngine; assert AccessEngine().status()['ready']" +echo " package import + AccessEngine readiness OK" -# Desktop plugin — REAL file copy (not symlink; Desktop/remote hermes_home) -bash "$ROOT/scripts/install_desktop_plugin.sh" || { - mkdir -p "$HERMES_HOME/desktop-plugins/hermespace" - cp -f "$ROOT/desktop_plugin/hermespace/plugin.js" "$HERMES_HOME/desktop-plugins/hermespace/plugin.js" - echo " desktop plugin (fallback cp) → $HERMES_HOME/desktop-plugins/hermespace" +replace_link() { + local target="$1" + local source="$2" + if [[ -L "$target" || -f "$target" ]]; then + rm -f "$target" + elif [[ -d "$target" ]]; then + rm -rf "$target" + fi + ln -s "$source" "$target" } -# Optional editable install when pip available -if command -v pip >/dev/null 2>&1 || command -v pip3 >/dev/null 2>&1; then - PIP="$(command -v pip3 || command -v pip)" - if "$PIP" install -e "$ROOT" -q 2>/dev/null; then - echo " pip install -e . OK" - else - echo " pip install -e . skipped (optional; PYTHONPATH=src works)" - fi +# Link the complete repository, not only hermes_plugin/. Current Hermes +# `plugins install owner/repo` likewise installs the repository root; keeping +# both paths identical catches source-layout regressions. +replace_link "$HERMES_HOME/plugins/hermespace" "$ROOT" +replace_link "$HERMES_HOME/skills/hermespace" "$ROOT/skills/hermespace" +echo " plugin → $HERMES_HOME/plugins/hermespace" +echo " skill → $HERMES_HOME/skills/hermespace" + +if [[ "$INSTALL_DESKTOP" == "1" ]]; then + bash "$ROOT/scripts/install_desktop_plugin.sh" +else + echo " desktop plugin skipped" fi if command -v hermes >/dev/null 2>&1; then - hermes plugins enable hermespace 2>/dev/null || true - echo " hermes plugins enable hermespace (attempted)" + if [[ "$ENABLE_PLUGIN" == "1" ]]; then + hermes plugins enable hermespace + echo " Hermes plugin enabled" + else + echo " enable later: hermes plugins enable hermespace" + fi else - echo " hermes CLI not on PATH — enable later: hermes plugins enable hermespace" + echo " Hermes CLI not on PATH — source install is ready; enable later" fi +HERMES_HOME="$HERMES_HOME" HERMESPACE_HOME="$HERMESPACE_HOME" \ + "$PYTHON" "$ROOT/scripts/verify_hermes_integration.py" + cat <>"$LOG" 2>&1 <>"$LOG" 2>&1 then - ok "hermes_plugin_pre_llm" + ok "hermes_plugin_host_contract" else - bad "hermes_plugin_pre_llm" "plugin failed" + bad "hermes_plugin_host_contract" "current Hermes host contract failed" fi "$PYTHON" - < None: + self.hooks: dict[str, Any] = {} + self.commands: dict[str, Any] = {} + self.cli_commands: dict[str, Any] = {} + self.skills: dict[str, Path] = {} + + def register_hook(self, name: str, callback: Any) -> None: + self.hooks[name] = callback + + def register_command( + self, + name: str, + handler: Any, + description: str = "", + args_hint: str = "", + ) -> None: + self.commands[name] = { + "handler": handler, + "description": description, + "args_hint": args_hint, + } + + def register_cli_command(self, **kwargs: Any) -> None: + self.cli_commands[str(kwargs["name"])] = kwargs + + def register_skill(self, name: str, path: Path) -> None: + self.skills[name] = Path(path) + + +def _load_repo_plugin() -> Any: + spec = importlib.util.spec_from_file_location( + "hermespace_repo_plugin", + ROOT / "__init__.py", + submodule_search_locations=[str(ROOT)], + ) + if spec is None or spec.loader is None: + raise RuntimeError("cannot load repository plugin entry point") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="hermespace-host-contract-") as tmp: + old_home = os.environ.get("HERMESPACE_HOME") + old_agent = os.environ.get("HERMESPACE_AGENT_ID") + os.environ["HERMESPACE_HOME"] = tmp + os.environ["HERMESPACE_AGENT_ID"] = "contract-agent" + os.environ["HERMESPACE_NEURAL_VERBALIZE"] = "0" + os.environ["HERMESPACE_SKIP_NEURAL"] = "1" + try: + module = _load_repo_plugin() + ctx = HostContext() + module.register(ctx) + + required = { + "on_session_start", + "pre_llm_call", + "post_llm_call", + "post_tool_call", + "on_session_end", + "on_session_finalize", + } + missing = sorted(required - set(ctx.hooks)) + if missing: + raise AssertionError(f"missing hooks: {missing}") + if "hermespace" not in ctx.commands: + raise AssertionError("/hermespace command not registered") + if "hermespace" not in ctx.cli_commands: + raise AssertionError("hermes hermespace command not registered") + + common = { + "session_id": "contract-session", + "model": "test/model", + "platform": "cli", + } + ctx.hooks["on_session_start"](**common) + injected = ctx.hooks["pre_llm_call"]( + **common, + user_message="First inspect the issue, then fix it and verify the result", + conversation_history=[], + is_first_turn=True, + ) + if not isinstance(injected, dict) or not injected.get("context"): + raise AssertionError("pre_llm_call did not inject context") + if len(str(injected["context"])) > 15_000: + raise AssertionError("hook context exceeded 15,000 chars") + + ctx.hooks["post_tool_call"]( + **common, + tool_name="read_file", + args={"path": "redacted"}, + result='{"ok": true}', + task_id="contract-session", + duration_ms=1, + ) + ctx.hooks["post_llm_call"]( + **common, + user_message="fix and verify", + assistant_response="Implemented and verified.", + conversation_history=[], + ) + ctx.hooks["on_session_end"]( + **common, + completed=True, + interrupted=False, + ) + + from hermespace.hermes_runtime import runtime + + before = runtime.status("contract-session") + if before.get("finalized"): + raise AssertionError("on_session_end incorrectly finalized the session") + ctx.hooks["on_session_finalize"](**common) + after = runtime.status("contract-session") + if not after.get("finalized"): + raise AssertionError("on_session_finalize did not finalize runtime state") + # Host teardown paths may converge; finalization must be idempotent. + ctx.hooks["on_session_finalize"](**common) + + command_output = ctx.commands["hermespace"]["handler"]("runtime") + if "tracked_sessions" not in command_output: + raise AssertionError("/hermespace runtime returned unexpected output") + + report = { + "ok": True, + "hooks": sorted(ctx.hooks), + "slash_command": True, + "cli_command": True, + "skill": "hermespace" in ctx.skills, + "context_chars": len(str(injected["context"])), + "turns": after.get("completed_turns"), + "tools": after.get("tool_calls"), + "finalized": after.get("finalized"), + } + print(json.dumps(report, indent=2)) + return 0 + finally: + if old_home is None: + os.environ.pop("HERMESPACE_HOME", None) + else: + os.environ["HERMESPACE_HOME"] = old_home + if old_agent is None: + os.environ.pop("HERMESPACE_AGENT_ID", None) + else: + os.environ["HERMESPACE_AGENT_ID"] = old_agent + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/hermespace/SKILL.md b/skills/hermespace/SKILL.md index 7059869..434df17 100644 --- a/skills/hermespace/SKILL.md +++ b/skills/hermespace/SKILL.md @@ -170,10 +170,11 @@ hs view --serve --tailscale --port 8764 # any user's tailnet ### 4.4 Hermes plugin (automatic) -Hooks: `on_session_start` · `pre_llm_call` · `on_session_end` +Core hooks: `on_session_start` · `pre_llm_call` · `post_llm_call` · +`post_tool_call` · `on_session_end` · `on_session_finalize` - Broadcasts **ready** desk into model context - Does **not** invent goals — agents still call Workbench / turn -- Optional: `HERMESPACE_AUTO_ORDER=0` (default), `HERMESPACE_IDLE_ON_SESSION_END=1` +- Uses per-session desks/hubs; `/hermespace runtime` shows lifecycle state ### 4.5 Skills + MEMORY fabric diff --git a/skills/hermespace/references/plugin-hooks.md b/skills/hermespace/references/plugin-hooks.md index c2db72a..c880884 100644 --- a/skills/hermespace/references/plugin-hooks.md +++ b/skills/hermespace/references/plugin-hooks.md @@ -2,23 +2,27 @@ ## Register ```bash -ln -sfn "$HERMESPACE_ROOT/hermes_plugin" "${HERMES_HOME:-$HOME/.hermes}/plugins/hermespace" -hermes plugins enable hermespace +hermes plugins install PabloTheThinker/hermespace --enable +hermes hermespace doctor ``` -`hermes_plugin/__init__.py` → `register(ctx)`. -Keep `plugin.yaml` `version` == package `__version__`. +Repository `__init__.py` → `hermespace.plugin.register(ctx)`. ## Hooks | Hook | Behavior | |------|----------| -| on_session_start | Workbench.enter + env kit; seed desk; return context | -| pre_llm_call | Gate + neural FOA + desk inject; optional AUTO_ORDER | -| on_session_end | idle_tick if HERMESPACE_IDLE_ON_SESSION_END=1 | +| on_session_start | Initialize session scope; stage first-turn context | +| pre_llm_call | Gate + bounded desk/hub inject | +| post_llm_call | Observe successful native turn | +| post_tool_call | Count tool name only; never persist payloads | +| on_session_end | Lightweight turn boundary | +| on_session_finalize | Idempotent harvest + idle maintenance | +| on_session_reset | Prime rotated gateway session | +| subagent_start/stop | Track specialist lifecycle | ## Implementation Logic: `src/hermespace/hermes_bridge.py` -Plugin package: thin register only. +Plugin package: `src/hermespace/plugin.py`. ## Env `HERMESPACE_ROOT`, `HERMESPACE_HOME`, `HERMESPACE_AGENT_ID`, @@ -26,6 +30,6 @@ Plugin package: thin register only. `HERMESPACE_NEURAL_BACKEND=auto`, `HERMESPACE_OFF=0`, `HERMESPACE_FORCE=0` ## Failure modes -- Wrong `HERMESPACE_ROOT` → import fail / empty inject +- Missing runtime → registration fails visibly - Desk not ready → thin or skipped pre_llm inject (run turn/order first) -- Plugin alone never creates goals — agent must call turn/order +- Use `/hermespace runtime` to inspect native lifecycle diff --git a/src/hermespace/__init__.py b/src/hermespace/__init__.py index 91dffc0..b8d7270 100644 --- a/src/hermespace/__init__.py +++ b/src/hermespace/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -__version__ = "0.24.0" +__version__ = "0.25.0" from hermespace.desk import Desk from hermespace.engine import HermespaceEngine diff --git a/src/hermespace/access/engine.py b/src/hermespace/access/engine.py index 9132ab2..bd12f7e 100644 --- a/src/hermespace/access/engine.py +++ b/src/hermespace/access/engine.py @@ -29,6 +29,7 @@ from hermespace.access.oew import ensure_oew_env_default, oew_default_on from hermespace.access.protocol import oew_enabled +from hermespace.paths import session_desk_path, session_scope_id # Five GWT-style access roles implemented by this harness ACCESS_ROLES = ( @@ -40,6 +41,12 @@ ) +def workspace_id(agent_id: str, session_id: str = "") -> str: + """Backward-compatible name for the canonical session scope helper.""" + + return session_scope_id(agent_id, session_id) + + class AccessEngine: """True Hermespace Access Engine — open-source GWT harness for Hermes.""" @@ -62,19 +69,23 @@ def __init__( self._ignitions = 0 self._skips = 0 + @property + def workspace_id(self) -> str: + return workspace_id(self.agent_id, self.session_id) + # --- core handles ------------------------------------------------------- @property def hub(self): from hermespace.access import AccessHub - return AccessHub(agent_id=self.agent_id) + return AccessHub(agent_id=self.workspace_id) @property def env(self): from hermespace.access import AccessEnv - return AccessEnv(agent_id=self.agent_id) + return AccessEnv(agent_id=self.workspace_id) @property def desk_engine(self): @@ -83,12 +94,14 @@ def desk_engine(self): if self.desk_path is not None: return HermespaceEngine(desk_path=self.desk_path) - return HermespaceEngine() + return HermespaceEngine( + desk_path=session_desk_path(self.agent_id, self.session_id) + ) - # --- Anthropic access roles (live) -------------------------------------- + # --- Access roles (live) ------------------------------------------------- def access_roles(self) -> dict[str, Any]: - """Map Anthropic's five GWT properties onto live engine state.""" + """Report the five live Access Workspace roles.""" js = self.hub return { "verbal_report": { @@ -176,6 +189,7 @@ def connect( "ok": False, "agent_id": self.agent_id, "session_id": self.session_id, + "workspace_id": self.workspace_id, "engine": "AccessEngine", "phases": {}, "gained": {}, @@ -241,6 +255,7 @@ def connect( query=query, session_id=self.session_id, room=out["phases"].get("room"), + workspace_id=self.workspace_id, ) except Exception as exc: # Minimal standalone seed from world beliefs @@ -316,6 +331,7 @@ def status(self) -> dict[str, Any]: "engine": "AccessEngine", "agent_id": self.agent_id, "session_id": self.session_id, + "workspace_id": self.workspace_id, "oew_enabled": oew_enabled(), "oew_default_on": oew_default_on(), "access": {}, @@ -324,7 +340,7 @@ def status(self) -> dict[str, Any]: "warehouse": {}, "ready": False, "connected": bool(self._last_connect and self._last_connect.get("ok")), - "role": "open-source external J-space for Hermes agents", + "role": "Hermespace Access Workspace for Hermes agents", "access_roles": self.access_roles(), "metrics": self.metrics(), "ops": [ @@ -399,7 +415,7 @@ def status(self) -> dict[str, Any]: # --- selectivity / probe ------------------------------------------------- def probe_material(self, message: str, *, desk_ready: bool | None = None) -> dict[str, Any]: - """Would this message ignite the workspace? (Anthropic selectivity).""" + """Would this message ignite the Access Workspace?""" from hermespace.gate import should_inject from hermespace.store import load_desk @@ -458,8 +474,8 @@ def ablate(self, *patterns: str) -> dict[str, Any]: def chain(self, *steps: str, salience: float = 0.85) -> dict[str, Any]: """Park a multi-step silent reasoning chain (internal reasoning role). - Analogue of Anthropic spider→legs intermediates that never appear in - the spoken answer — required for multi-step work under OEW. + Intermediate steps never appear in the spoken answer unless explicitly + requested — required for multi-step work under OEW. """ parked: list[str] = [] js = self.hub @@ -522,12 +538,7 @@ def turn( self._turn_count += 1 probe = self.probe_material(message) - wf_kwargs: dict[str, Any] = {} - if self.desk_path is not None: - from hermespace.engine import HermespaceEngine - - wf_kwargs["engine"] = HermespaceEngine(desk_path=self.desk_path) - out = Workflow(**wf_kwargs).run( + out = Workflow(engine=self.desk_engine).run( HermespaceInput( message=message, goal=goal or message[:200], @@ -572,6 +583,70 @@ def think(self, message: str, **kwargs: Any) -> dict[str, Any]: "metrics": (out.meta or {}).get("metrics") or self.metrics(), } + def observe_turn( + self, + *, + user_message: str = "", + assistant_response: str = "", + model: str = "", + platform: str = "", + ) -> dict[str, Any]: + """Observe a completed native Hermes turn. + + ``pre_llm_call`` runs the workspace before generation; this closes the + loop after Hermes finishes. It updates the session workbench and an + episodic receipt without mutating the conversation transcript. + """ + + report = (assistant_response or "").strip() + user = (user_message or "").strip() + result: dict[str, Any] = { + "ok": True, + "agent_id": self.agent_id, + "session_id": self.session_id, + "workspace_id": self.workspace_id, + "user_chars": len(user), + "response_chars": len(report), + "model": (model or "")[:120], + "platform": (platform or "")[:40], + } + try: + from hermespace.workbench import Workbench + + wb = Workbench(agent_id=self.agent_id, session_id=self.session_id) + wb.state.last_order = user[:500] + wb.state.last_report = report[:2000] + wb.state.mode = "idle" + wb.state.meta["last_native_turn"] = { + "model": result["model"], + "platform": result["platform"], + "user_chars": len(user), + "response_chars": len(report), + } + wb.save() + result["workbench"] = True + except Exception as exc: + result["ok"] = False + result["workbench_error"] = type(exc).__name__ + + try: + self.desk_engine.episodes.write( + ( + f"native turn complete: user_chars={len(user)} " + f"response_chars={len(report)} model={result['model']}" + ), + outcome="turn_complete", + tags=["hermespace", "native_turn", result["platform"] or "unknown"], + ) + result["episode"] = True + except Exception as exc: + result["episode_error"] = type(exc).__name__ + + result["audit_alerts"] = sum( + 1 for finding in self.audit() if finding.get("severity") == "alert" + ) + return result + # --- dual decode --------------------------------------------------------- @staticmethod diff --git a/src/hermespace/access/env.py b/src/hermespace/access/env.py index 9e823bb..313693a 100644 --- a/src/hermespace/access/env.py +++ b/src/hermespace/access/env.py @@ -45,7 +45,7 @@ ), } -# Turn phase bands — analogue of intermediate layer band where J-space is coherent +# Turn phase bands — encode → deliberate → report BANDS = ("early", "mid", "late") # encode → reason → report @@ -150,7 +150,7 @@ def set_band(self, band: str) -> str: def band(self) -> str: return str(self._env.get("band") or "mid") - # --- Assistant POV (post-training installs POV in Anthropic J-space) --- + # --- Assistant point of view in the Access Workspace --- def set_pov(self, text: str) -> str: """Install Assistant point-of-view reactions into the workspace.""" @@ -607,7 +607,7 @@ def operator_view(self) -> dict[str, Any]: "trace_path": str(self.trace_path), "protocol_enabled": bool(self._env.get("protocol_enabled", True)), "theory": { - "source": "Anthropic J-space / GWT (harness analogue)", + "source": "Hermespace Access Workspace / GWT", "access": "externalized verbalizable workspace — not weight readout", "night_path": "dream_harvest → Cube seal → pulse charge", }, diff --git a/src/hermespace/access/hub.py b/src/hermespace/access/hub.py index 3541754..b8ce723 100644 --- a/src/hermespace/access/hub.py +++ b/src/hermespace/access/hub.py @@ -22,6 +22,7 @@ from pathlib import Path from typing import Any, Iterable +from hermespace.atomic import atomic_write_text from hermespace.cognition import ( ACTIVATED_CAP, FOCUS_CAP, @@ -137,7 +138,7 @@ def __init__(self, agent_id: str = "hermes-agent", root: Path | None = None) -> legacy = state_dir() / "jspace" / f"{_safe(self.agent_id)}.json" if legacy.is_file(): try: - self.path.write_text(legacy.read_text(encoding="utf-8"), encoding="utf-8") + atomic_write_text(self.path, legacy.read_text(encoding="utf-8")) except OSError: self.path = legacy self.state = self._load() @@ -177,10 +178,7 @@ def _load(self) -> AccessHubState: def save(self) -> None: self.state.updated = _utcnow() - self.path.write_text( - json.dumps(self.state.to_dict(), indent=2), - encoding="utf-8", - ) + atomic_write_text(self.path, json.dumps(self.state.to_dict(), indent=2)) # --- property 2: directed modulation --- diff --git a/src/hermespace/access/oew.py b/src/hermespace/access/oew.py index 84c6847..35b9d09 100644 --- a/src/hermespace/access/oew.py +++ b/src/hermespace/access/oew.py @@ -1,8 +1,8 @@ """Obligatory External Workspace — higher-order thinking orchestration. -This is the causal layer that makes Hermespace behave like a J-space for -Hermes agents: material turns must park silent intermediates; swaps redirect -Report/broadcast; reflections seed the next mid-band; ablations filter inject. +This is the causal layer of the Access Workspace: material turns must park +silent intermediates; swaps redirect Report/broadcast; reflections seed the +next mid-band; ablations filter inject. Default: ``HERMESPACE_OEW=1`` (higher-order on). Set ``0`` to soften. """ diff --git a/src/hermespace/access/protocol.py b/src/hermespace/access/protocol.py index 1318c1a..a1c87b3 100644 --- a/src/hermespace/access/protocol.py +++ b/src/hermespace/access/protocol.py @@ -1,8 +1,8 @@ """Obligatory External Workspace (OEW) protocol gate. -Anthropic's J-space is *causally necessary* for higher-order thought. -Hermespace becomes Hermes's J-space when material turns cannot complete -without parking verbalizable intermediates in the external hub. +Hermespace's Access Workspace is *causally necessary* for higher-order work +when material turns cannot complete without parking verbalizable +intermediates in the external hub. Default: **ON** (``HERMESPACE_OEW`` unset or ``1``). Set ``0`` to soften. Orchestration (auto-park, sticky swap, reflect seeds) lives in ``oew.py``. diff --git a/src/hermespace/atomic.py b/src/hermespace/atomic.py new file mode 100644 index 0000000..59e3777 --- /dev/null +++ b/src/hermespace/atomic.py @@ -0,0 +1,57 @@ +"""Small, dependency-free atomic persistence helpers. + +Hermes runs plugins in CLI, gateway, cron, and subagent processes. A process +must never leave a half-written JSON or Markdown state file behind if it is +interrupted while saving. These helpers keep the write contract local and +portable without adding a file-lock dependency. +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + + +def atomic_write_text( + path: Path, + text: str, + *, + encoding: str = "utf-8", + mode: int | None = None, +) -> Path: + """Atomically replace *path* with *text* and return *path*. + + The temporary file is created beside the destination so ``os.replace`` is + on the same filesystem. Data is flushed before replacement. Existing + permissions are preserved unless ``mode`` is supplied for a new file. + """ + + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + existing_mode: int | None = None + try: + existing_mode = path.stat().st_mode & 0o777 + except OSError: + pass + + fd, raw_tmp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + tmp = Path(raw_tmp) + try: + with os.fdopen(fd, "w", encoding=encoding) as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + desired_mode = existing_mode if existing_mode is not None else mode + if desired_mode is not None: + try: + os.fchmod(handle.fileno(), desired_mode) + except (AttributeError, OSError): + pass + os.replace(tmp, path) + return path + finally: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass diff --git a/src/hermespace/cli.py b/src/hermespace/cli.py index 03879db..2d1944c 100644 --- a/src/hermespace/cli.py +++ b/src/hermespace/cli.py @@ -151,7 +151,7 @@ def main(argv: list[str] | None = None) -> int: neu_sub.add_parser("eval", help="Rank-quality hash vs ollama embed") # Functional Access Workspace (harness global workspace) - # Hermes base as J-space (Anthropic video ops: read / audit / shape) + # Hermespace Access Engine (read / audit / shape / operate) base = sub.add_parser( "base", help="Access Engine: connect / status / turn / lens / audit / reflect / harvest", diff --git a/src/hermespace/cube_module.py b/src/hermespace/cube_module.py index 2643b8e..b71a07f 100644 --- a/src/hermespace/cube_module.py +++ b/src/hermespace/cube_module.py @@ -437,6 +437,7 @@ def seed_access_from_warehouse( query: str = "", session_id: str = "hermespace", room: dict[str, Any] | None = None, + workspace_id: str = "", ) -> dict[str, Any]: """Pull Cube/world wisdom + peer presence into the agent's Access Workspace hub. @@ -454,7 +455,7 @@ def seed_access_from_warehouse( try: from hermespace.access import AccessHub - js = AccessHub(agent_id=agent_id) + js = AccessHub(agent_id=workspace_id or agent_id) beliefs: list[str] = [] try: from hermespace.world import WorldModel @@ -502,6 +503,7 @@ def seed_access_from_warehouse( report["focus_n"] = len(js.state.focus) report["ok"] = True report["room_mode"] = room.get("mode") + report["workspace_id"] = workspace_id or agent_id return report except Exception as e: report["error"] = type(e).__name__ @@ -535,7 +537,7 @@ def connect_agent( "phases": {}, "gained": {}, "memories": [ - "Anthropic J-space: privileged verbalizable workspace (external here)", + "Access Workspace: privileged verbalizable workspace", "Baars GWT: limited capacity hub broadcast to specialists", "Dehaene gap: Cube supplies enduring episodic/library memory", "Hive: optional room of peer agents — intelligence compounds", @@ -583,11 +585,18 @@ def connect_agent( out["phases"]["room"] = room_status(agent_id=agent_id) if seed: + try: + from hermespace.access.engine import workspace_id as _workspace_id + + access_id = _workspace_id(agent_id, session_id) + except Exception: + access_id = agent_id out["phases"]["seed"] = seed_access_from_warehouse( agent_id, query=query, session_id=session_id, room=out["phases"]["room"], + workspace_id=access_id, ) world = out["phases"].get("world") or {} diff --git a/src/hermespace/gate.py b/src/hermespace/gate.py index 1694b59..f324ecb 100644 --- a/src/hermespace/gate.py +++ b/src/hermespace/gate.py @@ -1,4 +1,4 @@ -"""Gate — when Hermespace should inject (selective access = J-space selectivity).""" +"""Gate — when Hermespace should enter the selective Access Workspace.""" from __future__ import annotations diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index ee1f6d7..a42b20d 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -14,7 +14,12 @@ def _truthy(name: str, default: str = "0") -> bool: def on_session_start(**kwargs: Any) -> dict[str, str] | None: - """Enter pocket dimension; stamp Hermes env kit into starting context.""" + """Initialize a Hermes v0.20 session and stage first-turn context. + + Hermes treats this hook as an observer, so the returned dict is only for + older hosts/tests. The context is staged for ``pre_llm_call``, whose + return value is the one current Hermes actually injects. + """ try: from hermespace.environment import probe_environment from hermespace.engine import HermespaceEngine @@ -33,7 +38,7 @@ def on_session_start(**kwargs: Any) -> dict[str, str] | None: st = wb.enter(connect_warehouse=False) env = probe_environment() - eng = HermespaceEngine() + eng = wb.workflow.engine desk = load_desk(eng.desk_path) if not desk.goal: desk.goal = "Hermes agent session workbench" @@ -143,6 +148,19 @@ def on_session_start(**kwargs: Any) -> dict[str, str] | None: "- Silent steps stay in model context only — never dump hub into chat.\n" ) + try: + from hermespace.hermes_runtime import runtime + + runtime.start( + session_id, + agent_id=agent_id, + model=str(kwargs.get("model") or ""), + platform=str(kwargs.get("platform") or ""), + context=block, + ) + except Exception as exc: # noqa: BLE001 + logger.debug("session runtime start failed: %s", exc) + return {"context": block} @@ -167,6 +185,33 @@ def on_pre_llm_call( msg = user_message or "" sid = str(session_id or "default") agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") + try: + from hermespace.access.engine import workspace_id + + access_id = workspace_id(agent_id, sid) + except Exception: + access_id = agent_id + try: + from hermespace import AccessEngine + + access_engine = AccessEngine(agent_id=agent_id, session_id=sid) + eng = access_engine.desk_engine + except Exception: + eng = HermespaceEngine() + try: + from hermespace.hermes_runtime import runtime + + runtime.pre_llm( + sid, + agent_id=agent_id, + user_chars=len(msg), + model=str(kwargs.get("model") or ""), + platform=str(kwargs.get("platform") or ""), + ) + start_context = runtime.take_start_context(sid) + except Exception as exc: # noqa: BLE001 + logger.debug("pre_llm runtime update failed: %s", exc) + start_context = "" # Conversational boundary regulation (user approves/denies access in chat) try: @@ -175,7 +220,6 @@ def on_pre_llm_call( reg = regulate(msg, agent_id=agent_id) if reg.handled: # Short user-facing note + keep inject for model - eng = HermespaceEngine() desk = load_desk(eng.desk_path) note = reg.message block = build_inject_block(desk, max_chars=2000, user_message=msg) @@ -188,7 +232,8 @@ def on_pre_llm_call( # Prefer explicit regulation reply as dual-channel: model sees full; user gets note via say path if auto return { "context": ( - block + ((start_context + "\n\n") if start_context else "") + + block + "\n\n### Boundary regulation (this turn)\n" + f"- action: {reg.action}\n" + f"- user_reply_hint: {note}\n" @@ -199,7 +244,6 @@ def on_pre_llm_call( except Exception as exc: # noqa: BLE001 logger.debug("regulate failed: %s", exc) - eng = HermespaceEngine() desk = load_desk(eng.desk_path) auto_order = _truthy("HERMESPACE_AUTO_ORDER", "0") @@ -224,6 +268,13 @@ def on_pre_llm_call( msg, desk_ready=ready, is_first_turn=bool(is_first_turn) ) if not do_it: + if start_context: + try: + from hermespace.hermes_runtime import runtime + + runtime.stage_start_context(sid, start_context) + except Exception: + pass return None if msg and ready: @@ -255,13 +306,16 @@ def on_pre_llm_call( high_load = str((desk.load or {}).get("level") or "") == "high" skip_neural = ( high_load - or _truthy("HERMESPACE_SKIP_NEURAL", "0") + # Native pre_llm hooks are latency-sensitive. Neural + # enrichment still runs in Workflow/idle paths unless an + # operator explicitly opts it into the hook. + or _truthy("HERMESPACE_SKIP_NEURAL", "1") or _truthy("HERMESPACE_HIGH_LOAD_LEAN", "1") and high_load ) if high_load: skip_neural = True - if not skip_neural and not _truthy("HERMESPACE_SKIP_NEURAL", "0"): + if not skip_neural and not _truthy("HERMESPACE_SKIP_NEURAL", "1"): ns = NeuralSpace() ns.config.verbalize = False ns.sync_from_desk(desk, user_message=msg) @@ -294,6 +348,8 @@ def on_pre_llm_call( block = build_inject_block(desk, max_chars=inject_cap, user_message=msg) if not block.strip(): return None + if start_context and bool(is_first_turn): + block = (start_context + "\n\n" + block).strip() try: from hermespace.world import world_context @@ -356,7 +412,7 @@ def on_pre_llm_call( from hermespace.access.oew import ensure_oew_env_default ensure_oew_env_default() - js = AccessHub(agent_id=agent_id) + js = AccessHub(agent_id=access_id) js.sync_from_desk(desk, user_message=msg, cube_strip=cube_block) desk.meta["cube_beat"] = { "ok": beat.get("ok"), @@ -366,7 +422,7 @@ def on_pre_llm_call( try: from hermespace.access import AccessEnv - env = AccessEnv(agent_id=agent_id) + env = AccessEnv(agent_id=access_id) # sync_from_desk already ran above — skip second rewrite env_meta = env.advance_turn( user_message=msg, @@ -446,10 +502,14 @@ def on_pre_llm_call( if not high_load: try: - from hermespace import ops as ops_mod - - block += "\n" + ops_mod.compact_status( - agent_id=agent_id if agent_id != "hermes-agent" else "default" + from hermespace import AccessEngine + + metrics = AccessEngine(agent_id=agent_id, session_id=sid).metrics() + block += ( + "\n### Hermespace runtime\n" + f"- hub={metrics.get('hub_n')}/{metrics.get('hub_cap')} " + f"focus={metrics.get('focus_n')}/{metrics.get('focus_cap')} " + f"silent={metrics.get('silent_n')}/{metrics.get('silent_cap')}\n" ) except Exception: pass @@ -484,27 +544,163 @@ def on_pre_llm_call( return result -def on_session_end(**kwargs: Any) -> None: - if not _truthy("HERMESPACE_IDLE_ON_SESSION_END", "1"): - return +def on_post_llm_call( + *, + session_id: str = "", + user_message: str = "", + assistant_response: str = "", + model: str = "", + platform: str = "", + **kwargs: Any, +) -> None: + """Close the loop after a successful native Hermes turn.""" + agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") try: - from hermespace.world import WorldModel + from hermespace import AccessEngine + + AccessEngine( + agent_id=agent_id, + session_id=str(session_id or "default"), + ).observe_turn( + user_message=user_message, + assistant_response=assistant_response, + model=model, + platform=platform, + ) + except Exception as exc: # noqa: BLE001 + logger.debug("post_llm observation failed: %s", exc) + try: + from hermespace.hermes_runtime import runtime + + runtime.post_llm( + session_id, + agent_id=agent_id, + response_chars=len(assistant_response or ""), + model=model, + platform=platform, + ) + except Exception as exc: # noqa: BLE001 + logger.debug("post_llm runtime update failed: %s", exc) + + +def on_post_tool_call( + *, + tool_name: str = "", + session_id: str = "", + task_id: str = "", + **kwargs: Any, +) -> None: + """Record bounded tool-name telemetry; never persist args or results.""" + + agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") + try: + from hermespace.hermes_runtime import runtime + + runtime.tool( + session_id or task_id or "default", + agent_id=agent_id, + name=tool_name, + ) + except Exception as exc: # noqa: BLE001 + logger.debug("post_tool runtime update failed: %s", exc) + + +def on_subagent_start(*, session_id: str = "", task_id: str = "", **kwargs: Any) -> None: + agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") + try: + from hermespace.hermes_runtime import runtime + + runtime.subagent(session_id or task_id, agent_id=agent_id, started=True) + except Exception: + pass + + +def on_subagent_stop(*, session_id: str = "", task_id: str = "", **kwargs: Any) -> None: + agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") + try: + from hermespace.hermes_runtime import runtime - WorldModel(agent_id=agent_id).leave("session ended") + runtime.subagent(session_id or task_id, agent_id=agent_id, started=False) except Exception: pass - # Night path: harvest silent higher-order chain into Cube / semantic + + +def on_session_end( + *, + session_id: str = "", + completed: bool = False, + interrupted: bool = False, + **kwargs: Any, +) -> None: + """Observe the end of one Hermes turn. + + Hermes v0.20 fires ``on_session_end`` after *every run_conversation call*, + not only when the session is destroyed. Full harvest belongs in + ``on_session_finalize``. + """ + + agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") + try: + from hermespace.hermes_runtime import runtime + + runtime.end_turn( + session_id, + agent_id=agent_id, + completed=bool(completed), + interrupted=bool(interrupted), + ) + except Exception as exc: # noqa: BLE001 + logger.debug("turn end runtime update failed: %s", exc) + + +def on_session_finalize(*, session_id: str | None = None, **kwargs: Any) -> None: + """Idempotently harvest and release one outgoing Hermes session.""" + + if not _truthy("HERMESPACE_IDLE_ON_SESSION_END", "1"): + return + sid = str(session_id or "default") + agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") try: - from hermespace.access import AccessEnv + from hermespace.hermes_runtime import runtime - AccessEnv(agent_id=agent_id).dream_harvest(seal_to_cube=True, clear_silent=False) + if not runtime.finalize(sid, agent_id=agent_id): + return except Exception: pass + + try: + from hermespace.world import WorldModel + + WorldModel(agent_id=agent_id).leave("session finalized") + except Exception as exc: # noqa: BLE001 + logger.debug("world finalize failed: %s", exc) + try: + from hermespace import AccessEngine + + AccessEngine(agent_id=agent_id, session_id=sid).harvest(clear_silent=False) + except Exception as exc: # noqa: BLE001 + logger.debug("access harvest failed: %s", exc) try: from hermespace.workbench import Workbench - sid = str(kwargs.get("session_id") or "default") Workbench(agent_id=agent_id, session_id=sid).idle_tick(consolidate_every=1) except Exception as exc: # noqa: BLE001 - logger.debug("session_end idle failed: %s", exc) + logger.debug("session finalize idle failed: %s", exc) + + +def on_session_reset(*, session_id: str = "", **kwargs: Any) -> None: + """Prime runtime state for a gateway's newly rotated session key.""" + + agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") + try: + from hermespace.hermes_runtime import runtime + + runtime.start( + session_id, + agent_id=agent_id, + model=str(kwargs.get("model") or ""), + platform=str(kwargs.get("platform") or ""), + ) + except Exception: + pass diff --git a/src/hermespace/hermes_runtime.py b/src/hermespace/hermes_runtime.py new file mode 100644 index 0000000..2090d15 --- /dev/null +++ b/src/hermespace/hermes_runtime.py @@ -0,0 +1,243 @@ +"""Hermes v0.20 plugin runtime state and lifecycle telemetry. + +The Hermes hook host is multi-surface and potentially concurrent (CLI, +gateways, A2A, subagents). This registry keeps bounded, session-scoped +operational facts and persists snapshots atomically for doctor/status commands. +It deliberately stores lengths and names rather than prompt or tool payloads. +""" + +from __future__ import annotations + +import hashlib +import json +import threading +import time +from collections import OrderedDict +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any + +from hermespace.atomic import atomic_write_text +from hermespace.paths import state_dir + +MAX_SESSIONS = 128 +MAX_RECENT_TOOLS = 24 + + +def _utcnow() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _session_key(session_id: str | None) -> str: + raw = str(session_id or "no-session") + return hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()[:20] + + +@dataclass +class SessionRuntime: + session_key: str + agent_id: str + model: str = "" + platform: str = "" + started_at: str = field(default_factory=_utcnow) + updated_at: str = field(default_factory=_utcnow) + pre_llm_calls: int = 0 + completed_turns: int = 0 + interrupted_turns: int = 0 + failed_turns: int = 0 + tool_calls: int = 0 + subagent_starts: int = 0 + subagent_stops: int = 0 + last_user_chars: int = 0 + last_response_chars: int = 0 + last_event: str = "session_start" + finalized: bool = False + recent_tools: list[str] = field(default_factory=list) + + +class RuntimeRegistry: + """Thread-safe, bounded runtime registry.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._sessions: OrderedDict[str, SessionRuntime] = OrderedDict() + self._start_context: dict[str, str] = {} + + def _get( + self, + session_id: str | None, + *, + agent_id: str = "hermes-agent", + model: str = "", + platform: str = "", + ) -> SessionRuntime: + key = _session_key(session_id) + item = self._sessions.get(key) + if item is None: + item = SessionRuntime( + session_key=key, + agent_id=agent_id or "hermes-agent", + model=model or "", + platform=platform or "", + ) + self._sessions[key] = item + else: + if model: + item.model = model + if platform: + item.platform = platform + self._sessions.move_to_end(key) + while len(self._sessions) > MAX_SESSIONS: + old_key, _ = self._sessions.popitem(last=False) + self._start_context.pop(old_key, None) + return item + + def _save(self, item: SessionRuntime) -> None: + item.updated_at = _utcnow() + path = state_dir() / "runtime" / f"{item.session_key}.json" + atomic_write_text(path, json.dumps(asdict(item), indent=2), mode=0o600) + + def start( + self, + session_id: str | None, + *, + agent_id: str, + model: str = "", + platform: str = "", + context: str = "", + ) -> dict[str, Any]: + with self._lock: + item = self._get( + session_id, + agent_id=agent_id, + model=model, + platform=platform, + ) + item.last_event = "session_start" + item.finalized = False + if context: + self._start_context[item.session_key] = context + self._save(item) + return asdict(item) + + def take_start_context(self, session_id: str | None) -> str: + with self._lock: + return self._start_context.pop(_session_key(session_id), "") + + def stage_start_context(self, session_id: str | None, context: str) -> None: + """Restore first-turn context when a trivial turn skipped injection.""" + + if not context: + return + with self._lock: + self._start_context[_session_key(session_id)] = context + + def pre_llm( + self, + session_id: str | None, + *, + agent_id: str, + user_chars: int, + model: str = "", + platform: str = "", + ) -> None: + with self._lock: + item = self._get( + session_id, + agent_id=agent_id, + model=model, + platform=platform, + ) + item.pre_llm_calls += 1 + item.last_user_chars = max(0, int(user_chars)) + item.last_event = "pre_llm_call" + self._save(item) + + def post_llm( + self, + session_id: str | None, + *, + agent_id: str, + response_chars: int, + model: str = "", + platform: str = "", + ) -> None: + with self._lock: + item = self._get( + session_id, + agent_id=agent_id, + model=model, + platform=platform, + ) + item.completed_turns += 1 + item.last_response_chars = max(0, int(response_chars)) + item.last_event = "post_llm_call" + self._save(item) + + def tool(self, session_id: str | None, *, agent_id: str, name: str) -> None: + with self._lock: + item = self._get(session_id, agent_id=agent_id) + item.tool_calls += 1 + clean = (name or "unknown")[:80] + item.recent_tools = (item.recent_tools + [clean])[-MAX_RECENT_TOOLS:] + item.last_event = "post_tool_call" + # Keep the tool loop cheap; checkpoints and turn-finalization flush. + if item.tool_calls == 1 or item.tool_calls % 10 == 0: + self._save(item) + + def subagent(self, session_id: str | None, *, agent_id: str, started: bool) -> None: + with self._lock: + item = self._get(session_id, agent_id=agent_id) + if started: + item.subagent_starts += 1 + item.last_event = "subagent_start" + else: + item.subagent_stops += 1 + item.last_event = "subagent_stop" + self._save(item) + + def end_turn( + self, + session_id: str | None, + *, + agent_id: str, + completed: bool, + interrupted: bool, + ) -> None: + with self._lock: + item = self._get(session_id, agent_id=agent_id) + if interrupted: + item.interrupted_turns += 1 + elif not completed: + item.failed_turns += 1 + item.last_event = "turn_end" + self._save(item) + + def finalize(self, session_id: str | None, *, agent_id: str) -> bool: + """Mark finalized and return True only for the first finalizer.""" + + with self._lock: + item = self._get(session_id, agent_id=agent_id) + if item.finalized: + return False + item.finalized = True + item.last_event = "session_finalize" + self._start_context.pop(item.session_key, None) + self._save(item) + return True + + def status(self, session_id: str | None = None) -> dict[str, Any]: + with self._lock: + if session_id is not None: + item = self._sessions.get(_session_key(session_id)) + return asdict(item) if item else {} + active = [asdict(v) for v in self._sessions.values() if not v.finalized] + return { + "active_sessions": len(active), + "tracked_sessions": len(self._sessions), + "sessions": active[-10:], + "monotonic_ms": int(time.monotonic() * 1000), + } + + +runtime = RuntimeRegistry() diff --git a/src/hermespace/local_model.py b/src/hermespace/local_model.py index ae101fe..82e967a 100644 --- a/src/hermespace/local_model.py +++ b/src/hermespace/local_model.py @@ -2,7 +2,7 @@ Priority (auto): 1. ollama_embed — nomic-embed-text (or HERMESPACE_EMBED_MODEL) -2. ollama_verbal — small chat model proposes reportable concepts (J-space *role*) +2. ollama_verbal — small chat model proposes reportable AccessHub concepts 3. hash — deterministic fallback (always on) Jacobian-lens (anthropics/jacobian-lens) needs torch+transformers+fitted lens; @@ -138,7 +138,7 @@ def verbalize_workspace( ) -> list[str]: """Ask a local chat model which concepts are 'on the desk' (reportable). - This is a *behavioral* J-space analogue: reportable / task-relevant concepts, + This is an Access Workspace behavior: reportable / task-relevant concepts, not Jacobian activations. Fails soft → []. """ concept_lines = "\n".join(f"- {c}" for c in concepts[:16]) or "- (none)" diff --git a/src/hermespace/ops.py b/src/hermespace/ops.py index 6f8db59..4309338 100644 --- a/src/hermespace/ops.py +++ b/src/hermespace/ops.py @@ -9,7 +9,7 @@ from typing import Any from hermespace import __version__ -from hermespace.paths import hermespace_home, package_root, state_dir +from hermespace.paths import canonical_agent_id, hermespace_home, state_dir def _port_open(host: str, port: int, timeout: float = 0.4) -> bool: @@ -22,6 +22,7 @@ def _port_open(host: str, port: int, timeout: float = 0.4) -> bool: def doctor(*, agent_id: str = "default", port: int = 8764, host: str = "127.0.0.1") -> dict[str, Any]: """Non-destructive health snapshot for operators and agents.""" + agent_id = canonical_agent_id(agent_id) home = hermespace_home() checks: list[dict[str, Any]] = [] @@ -29,8 +30,13 @@ def add(ok: bool, name: str, detail: str = "") -> None: checks.append({"ok": ok, "name": name, "detail": detail}) add(True, "version", __version__) - add(home.is_dir() or True, "hermespace_home", str(home)) - add((package_root() / "src" / "hermespace").is_dir(), "package_src", str(package_root())) + add(home.is_dir(), "hermespace_home", str(home)) + try: + import hermespace + + add(True, "package_import", str(Path(hermespace.__file__).resolve())) + except Exception as exc: + add(False, "package_import", str(exc)) # imports try: @@ -95,6 +101,21 @@ def add(ok: bool, name: str, detail: str = "") -> None: except Exception as exc: # noqa: BLE001 add(False, "access", str(exc)) + try: + from hermespace.hermes_runtime import runtime + + rst = runtime.status() + add( + True, + "hermes_runtime", + ( + f"active={rst.get('active_sessions')} " + f"tracked={rst.get('tracked_sessions')}" + ), + ) + except Exception as exc: # noqa: BLE001 + add(False, "hermes_runtime", str(exc)) + try: pol = boundary.load_policy() add(pol.project_write_default == "deny", "boundary_default_deny", pol.project_write_default) @@ -138,11 +159,10 @@ def add(ok: bool, name: str, detail: str = "") -> None: hh = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")).expanduser() plug = hh / "plugins" / "hermespace" desk = hh / "desktop-plugins" / "hermespace" / "plugin.js" - add(plug.exists(), "hermes_plugin_link", str(plug)) + plugin_ok = (plug / "plugin.yaml").is_file() and (plug / "__init__.py").is_file() + add(plugin_ok, "hermes_plugin", str(plug)) add(desk.is_file(), "desktop_plugin", str(desk)) - ok = all(c["ok"] for c in checks if c["name"] not in {"viewport_serve", "hermes_plugin_link", "desktop_plugin"}) - # soft: serve/desktop may be off — still "ops ready" if core ok core_ok = all( c["ok"] for c in checks @@ -154,10 +174,14 @@ def add(ok: bool, name: str, detail: str = "") -> None: "viewport_html", "version", "access", + "package_import", + "hermes_runtime", } ) + integration_ok = core_ok and plugin_ok return { "ok": core_ok, + "integration_ok": integration_ok, "all_green": all(c["ok"] for c in checks), "checks": checks, "home": str(home), @@ -175,7 +199,7 @@ def _hints(checks: list[dict[str, Any]], port: int) -> list[str]: out.append(f"Start viewport: hs view --serve --port {port}") if not by.get("desktop_plugin", {}).get("ok"): out.append("Install Desktop plugin: ./scripts/install_desktop_plugin.sh then Reload desktop plugins") - if not by.get("hermes_plugin_link", {}).get("ok"): + if not by.get("hermes_plugin", {}).get("ok"): out.append("Install Hermes plugin: ./scripts/install_hermes.sh && hermes plugins enable hermespace") if not by.get("pulse_jobs", {}).get("ok"): out.append("Seed pulse: hs pulse status") @@ -190,6 +214,7 @@ def boot( seed_pulse: bool = True, ) -> dict[str, Any]: """Bring pocket subsystems to a known-good everyday state.""" + agent_id = canonical_agent_id(agent_id) from hermespace import pulse from hermespace.grid.viewport import write_viewport_files from hermespace.workbench import Workbench @@ -224,6 +249,7 @@ def boot( def tick_all(*, agent_id: str = "default", force_dream: bool = False) -> dict[str, Any]: """One operational cycle: pulse tick (+ optional forced dream).""" + agent_id = canonical_agent_id(agent_id) from hermespace import pulse from hermespace.grid import dream from hermespace.grid.viewport import write_viewport_files diff --git a/src/hermespace/paths.py b/src/hermespace/paths.py index 1317193..e69dfa8 100644 --- a/src/hermespace/paths.py +++ b/src/hermespace/paths.py @@ -3,9 +3,20 @@ from __future__ import annotations import os +import hashlib +import re from pathlib import Path +def canonical_agent_id(agent_id: str | None = None) -> str: + """Resolve the one agent identity used by world, grid, ops, and hooks.""" + + value = (agent_id or "").strip() + if not value or value == "default": + value = os.environ.get("HERMESPACE_AGENT_ID", "").strip() + return value or "hermes-agent" + + def hermespace_home() -> Path: """State root for desk + episodes. @@ -38,6 +49,25 @@ def desk_path() -> Path: return hermespace_home() / "memory" / "hermespace" / "ACTIVE.md" +def session_scope_id(agent_id: str, session_id: str = "") -> str: + """Stable path-safe ID that does not expose opaque Hermes session IDs.""" + + agent = re.sub(r"[^a-zA-Z0-9._-]+", "_", agent_id or "hermes-agent")[:64] + session = (session_id or "").strip() + if not session or session in {"main", "default"}: + return agent or "hermes-agent" + digest = hashlib.sha256(session.encode("utf-8", errors="replace")).hexdigest()[:16] + return f"{agent or 'hermes-agent'}--{digest}" + + +def session_desk_path(agent_id: str, session_id: str = "") -> Path: + """Per-session desk for Hermes CLI/gateway/A2A isolation.""" + + if not session_id or session_id in {"main", "default"}: + return desk_path() + return state_dir() / "sessions" / session_scope_id(agent_id, session_id) / "ACTIVE.md" + + def state_dir() -> Path: return hermespace_home() / "memory" / "hermespace" diff --git a/src/hermespace/patterns.py b/src/hermespace/patterns.py index 9e9e134..d567d2c 100644 --- a/src/hermespace/patterns.py +++ b/src/hermespace/patterns.py @@ -90,7 +90,7 @@ class Pattern: ), Pattern( "desk.verbalizable", - "Anthropic J-space reportability", + "Access Workspace reportability", "Contents poised for report/speech", "required say field; cli say", True, @@ -98,7 +98,7 @@ class Pattern: ), Pattern( "desk.modulable", - "J-space modulation + attention set", + "Access Workspace modulation + attention set", "Task shifts workspace contents", "enter CLEAR + recompute_cognition(user_message)", True, @@ -106,7 +106,7 @@ class Pattern: ), Pattern( "desk.pre_output", - "J-space silent deliberation", + "Access Workspace silent deliberation", "Workspace before user-visible speech", "save_desk + pre_llm inject", True, @@ -114,7 +114,7 @@ class Pattern: ), Pattern( "gate.selectivity", - "J-space selective mediation", + "Access Workspace selective mediation", "Not all cognition uses workspace", "gate.should_inject", True, diff --git a/src/hermespace/plugin.py b/src/hermespace/plugin.py new file mode 100644 index 0000000..c2087e0 --- /dev/null +++ b/src/hermespace/plugin.py @@ -0,0 +1,134 @@ +"""First-class Hermes Agent v0.20 plugin registration. + +This module is both the wheel entry point and the implementation used by the +source-tree plugin wrappers. Keeping registration inside the installed +package prevents the "enabled but no-op" split between checkout and pip modes. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +logger = logging.getLogger("hermes.plugins.hermespace") + + +def _status_payload(action: str = "status") -> Any: + from hermespace import AccessEngine + + engine = AccessEngine() + action = (action or "status").strip().lower() + if action in {"status", "show"}: + return engine.status() + if action == "metrics": + return engine.metrics() + if action == "roles": + return engine.access_roles() + if action == "lens": + return engine.lens(top_k=8, include_silent=False) + if action == "runtime": + from hermespace.hermes_runtime import runtime + + return runtime.status() + if action == "doctor": + from hermespace.ops import doctor + + return doctor(agent_id=engine.agent_id) + return { + "ok": False, + "error": f"unknown action: {action}", + "actions": ["status", "metrics", "roles", "lens", "runtime", "doctor"], + } + + +def _slash_command(raw_args: str) -> str: + payload = _status_payload((raw_args or "").strip() or "status") + if isinstance(payload, str): + return payload[:6000] + return json.dumps(payload, indent=2, default=str)[:6000] + + +def _setup_cli(parser: Any) -> None: + parser.add_argument( + "action", + nargs="?", + default="status", + choices=("status", "metrics", "roles", "lens", "runtime", "doctor"), + ) + + +def _handle_cli(args: Any) -> int: + print(json.dumps(_status_payload(args.action), indent=2, default=str)) + return 0 + + +def register(ctx: Any) -> None: + """Register the supported Hermes v0.20 hooks and operator commands.""" + + from hermespace import __version__ + from hermespace.hermes_bridge import ( + on_post_llm_call, + on_post_tool_call, + on_pre_llm_call, + on_session_end, + on_session_finalize, + on_session_reset, + on_session_start, + on_subagent_start, + on_subagent_stop, + ) + + hooks = { + "on_session_start": on_session_start, + "pre_llm_call": on_pre_llm_call, + "post_llm_call": on_post_llm_call, + "post_tool_call": on_post_tool_call, + "on_session_end": on_session_end, + "on_session_finalize": on_session_finalize, + "on_session_reset": on_session_reset, + "subagent_start": on_subagent_start, + "subagent_stop": on_subagent_stop, + } + registered: list[str] = [] + for name, callback in hooks.items(): + try: + ctx.register_hook(name, callback) + registered.append(name) + except Exception as exc: + # Older Hermes versions may not know finalize/reset/subagent hooks. + if name in {"on_session_start", "pre_llm_call", "on_session_end"}: + raise RuntimeError(f"required Hermes hook unavailable: {name}") from exc + logger.info("Hermespace optional hook unavailable: %s (%s)", name, exc) + + if hasattr(ctx, "register_command"): + ctx.register_command( + "hermespace", + handler=_slash_command, + description="Hermespace Access Engine status, lens, metrics, and doctor", + args_hint="[status|metrics|roles|lens|runtime|doctor]", + ) + if hasattr(ctx, "register_cli_command"): + ctx.register_cli_command( + name="hermespace", + help="Inspect Hermespace Access Engine", + description="Status, metrics, roles, runtime, lens, or doctor", + setup_fn=_setup_cli, + handler_fn=_handle_cli, + ) + if hasattr(ctx, "register_skill"): + try: + from hermespace.paths import package_root + + skill = package_root() / "skills" / "hermespace" + if skill.is_dir(): + ctx.register_skill("hermespace", skill) + except Exception as exc: + logger.debug("Hermespace skill registration skipped: %s", exc) + + logger.info( + "Hermespace v%s registered %d hooks: %s", + __version__, + len(registered), + ", ".join(registered), + ) diff --git a/src/hermespace/store.py b/src/hermespace/store.py index 90c2065..be663de 100644 --- a/src/hermespace/store.py +++ b/src/hermespace/store.py @@ -5,6 +5,7 @@ import json from pathlib import Path +from hermespace.atomic import atomic_write_text from hermespace.desk import Desk from hermespace.paths import desk_path, state_dir @@ -19,8 +20,7 @@ def default_state_dir() -> Path: def save_desk(desk: Desk, path: Path | None = None) -> Path: path = path or default_desk_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(desk.to_markdown(), encoding="utf-8") + atomic_write_text(path, desk.to_markdown()) side = path.with_suffix(".json") payload = { "updated": desk.updated, @@ -37,7 +37,7 @@ def save_desk(desk: Desk, path: Path | None = None) -> Path: "focus": desk.focus, "ready": desk.is_ready(), } - side.write_text(json.dumps(payload, indent=2), encoding="utf-8") + atomic_write_text(side, json.dumps(payload, indent=2)) return path @@ -45,4 +45,28 @@ def load_desk(path: Path | None = None) -> Desk: path = path or default_desk_path() if not path.exists(): return Desk() - return Desk.from_markdown(path.read_text(encoding="utf-8")) + desk = Desk.from_markdown(path.read_text(encoding="utf-8")) + side = path.with_suffix(".json") + if not side.is_file(): + return desk + try: + payload = json.loads(side.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, TypeError): + return desk + if not isinstance(payload, dict): + return desk + + # Markdown is the human-readable source for intentional desk fields. + # The sidecar is authoritative for structured metadata that Markdown + # cannot round-trip (fabric cache, world cursor, OEW state, stream stats). + if isinstance(payload.get("meta"), dict): + desk.meta = dict(payload["meta"]) + if isinstance(payload.get("load"), dict): + desk.load = dict(payload["load"]) + if isinstance(payload.get("focus"), list): + desk.focus = [str(x) for x in payload["focus"]][:4] + if isinstance(payload.get("executive"), str): + desk.executive = payload["executive"] + if isinstance(payload.get("updated"), str): + desk.updated = payload["updated"] + return desk diff --git a/src/hermespace/workbench.py b/src/hermespace/workbench.py index e5ff3d7..a7c262a 100644 --- a/src/hermespace/workbench.py +++ b/src/hermespace/workbench.py @@ -20,6 +20,7 @@ from pathlib import Path from typing import Any +from hermespace.atomic import atomic_write_text from hermespace.agent_api import ( decode_bundle, decode_for_model, @@ -72,7 +73,16 @@ def __init__( ) -> None: self.agent_id = (agent_id or "hermes-agent").strip() self.session_id = (session_id or "default").strip() - self.workflow = workflow or Workflow() + if workflow is None: + from hermespace.engine import HermespaceEngine + from hermespace.paths import session_desk_path + + workflow = Workflow( + engine=HermespaceEngine( + desk_path=session_desk_path(self.agent_id, self.session_id) + ) + ) + self.workflow = workflow self.root = (root or state_dir() / "workbenches").resolve() self.root.mkdir(parents=True, exist_ok=True) self.path = self.root / f"{self._safe(self.agent_id)}__{self._safe(self.session_id)}.json" @@ -104,8 +114,7 @@ def _load(self) -> WorkbenchState: def save(self) -> Path: self.state.updated = _utcnow() - self.path.parent.mkdir(parents=True, exist_ok=True) - self.path.write_text(json.dumps(asdict(self.state), indent=2), encoding="utf-8") + atomic_write_text(self.path, json.dumps(asdict(self.state), indent=2)) return self.path def enter(self, *, connect_warehouse: bool = True) -> dict[str, Any]: @@ -118,6 +127,12 @@ def enter(self, *, connect_warehouse: bool = True) -> dict[str, Any]: """ if self.state.mode != "working": self.state.mode = "idle" + try: + from hermespace.access.engine import workspace_id + + access_id = workspace_id(self.agent_id, self.session_id) + except Exception: + access_id = self.agent_id env = probe_environment() self.state.meta["environment"] = env.to_dict() # stamp env concepts onto desk lightly via workflow engine @@ -147,7 +162,7 @@ def enter(self, *, connect_warehouse: bool = True) -> dict[str, Any]: from hermespace.access import AccessHub from hermespace.store import load_desk - js = AccessHub(agent_id=self.agent_id) + js = AccessHub(agent_id=access_id) desk = load_desk(self.workflow.engine.desk_path) js.sync_from_desk(desk, user_message=desk.goal or "") self.state.meta["access"] = { @@ -172,6 +187,7 @@ def enter(self, *, connect_warehouse: bool = True) -> dict[str, Any]: query="", session_id=self.session_id, room=room, + workspace_id=access_id, ) self.state.meta["connect"] = { "pulse_ok": pulse.get("ok"), diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index 05d5782..6d17c9b 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -151,9 +151,14 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: try: from hermespace.cube_module import cube_beat from hermespace.access import AccessHub, AccessEnv + from hermespace.access.engine import workspace_id from hermespace.access.oew import ensure_oew_env_default ensure_oew_env_default() + access_id = workspace_id( + payload.agent_id or "hermes-agent", + payload.session_id or "default", + ) load_total = float(desk.load.get("total") or 0.5) if isinstance(desk.load, dict) else 0.5 high = str(desk.load.get("level")) == "high" if isinstance(desk.load, dict) else False seals = None @@ -173,12 +178,12 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: "load_level": beat.get("load_level"), "chars": len(cube_block), } - js = AccessHub(agent_id=payload.agent_id or "hermes-agent") + js = AccessHub(agent_id=access_id) js.sync_from_desk(desk, user_message=msg, cube_strip=cube_block) mod = js.parse_modulation(msg) if mod.get("hold"): js.hold(str(mod["hold"]), silent=bool(mod.get("silent"))) - env = AccessEnv(agent_id=payload.agent_id or "hermes-agent") + env = AccessEnv(agent_id=access_id) # already_synced: avoid double hub rewrite inside advance_turn env_meta = env.advance_turn( user_message=msg, @@ -226,8 +231,13 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: block = (block + "\n\n" + cube_block).strip() try: from hermespace.access import AccessHub, AccessEnv + from hermespace.access.engine import workspace_id - env = AccessEnv(agent_id=payload.agent_id or "hermes-agent") + access_id = workspace_id( + payload.agent_id or "hermes-agent", + payload.session_id or "default", + ) + env = AccessEnv(agent_id=access_id) high = str(desk.load.get("level")) == "high" if isinstance(desk.load, dict) else False jblock = oew_broadcast or env.filtered_broadcast(high_load=high) if jblock: @@ -240,7 +250,7 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: lens_md = env.lens_markdown(top_k=6, include_silent=True) if lens_md and len(block) + len(lens_md) < inject_cap + 800: block = (block + "\n\n" + lens_md).strip() - js = AccessHub(agent_id=payload.agent_id or "hermes-agent") + js = AccessHub(agent_id=access_id) if js.parse_modulation(msg).get("summon"): report = (report + "\n\n" + env.lens_markdown(include_silent=False)).strip() # Final sticky reshape (in case summon appended text) diff --git a/tests/test_access_engine.py b/tests/test_access_engine.py index eee7234..12a02d7 100644 --- a/tests/test_access_engine.py +++ b/tests/test_access_engine.py @@ -104,6 +104,18 @@ def test_hermes_base_is_engine(self) -> None: hb = HermesBase(agent_id="alias") self.assertEqual(hb.status()["engine"], "AccessEngine") + def test_sessions_have_isolated_hubs_and_desks(self) -> None: + from hermespace import AccessEngine + + first = AccessEngine(agent_id="shared-agent", session_id="session-a") + second = AccessEngine(agent_id="shared-agent", session_id="session-b") + self.assertNotEqual(first.workspace_id, second.workspace_id) + self.assertNotEqual(first.desk_engine.desk_path, second.desk_engine.desk_path) + + first.hold("private-to-session-a") + self.assertIn("private-to-session-a", first.lens()) + self.assertNotIn("private-to-session-a", second.lens()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_hermes_bridge.py b/tests/test_hermes_bridge.py index 02387a1..6220c41 100644 --- a/tests/test_hermes_bridge.py +++ b/tests/test_hermes_bridge.py @@ -10,8 +10,13 @@ def test_session_and_pre_llm(self): os.environ["HERMESPACE_HOME"] = td os.environ["HERMESPACE_NEURAL_VERBALIZE"] = "0" os.environ["HERMESPACE_AUTO_ORDER"] = "0" - from hermespace.hermes_bridge import on_session_start, on_pre_llm_call, on_session_end - from hermespace.engine import HermespaceEngine + from hermespace import AccessEngine + from hermespace.hermes_bridge import ( + on_pre_llm_call, + on_session_end, + on_session_finalize, + on_session_start, + ) from hermespace.store import load_desk r = on_session_start(session_id="bridge-test") self.assertIsInstance(r, dict) @@ -19,7 +24,12 @@ def test_session_and_pre_llm(self): self.assertTrue( "Access Engine" in r["context"] or "Workbench" in r["context"] ) - desk = load_desk() + desk = load_desk( + AccessEngine( + agent_id="hermes-agent", + session_id="bridge-test", + ).desk_engine.desk_path + ) self.assertTrue(desk.goal) # material message should inject after desk ready inj = on_pre_llm_call( @@ -35,7 +45,12 @@ def test_session_and_pre_llm(self): or "Dual decode" in inj["context"] or "Access Workspace" in inj["context"] ) - on_session_end(session_id="bridge-test") + on_session_end( + session_id="bridge-test", + completed=True, + interrupted=False, + ) + on_session_finalize(session_id="bridge-test") if __name__ == "__main__": unittest.main() diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py new file mode 100644 index 0000000..f6b90da --- /dev/null +++ b/tests/test_plugin_contract.py @@ -0,0 +1,130 @@ +"""Current Hermes Agent v0.20 plugin host contract.""" + +from __future__ import annotations + +import argparse +import os +import tempfile +import unittest +from pathlib import Path +from typing import Any + + +class FakeContext: + def __init__(self) -> None: + self.hooks: dict[str, Any] = {} + self.commands: dict[str, Any] = {} + self.cli: dict[str, Any] = {} + self.skills: dict[str, Path] = {} + + def register_hook(self, name: str, callback: Any) -> None: + self.hooks[name] = callback + + def register_command(self, name: str, handler: Any, **kwargs: Any) -> None: + self.commands[name] = {"handler": handler, **kwargs} + + def register_cli_command(self, **kwargs: Any) -> None: + self.cli[kwargs["name"]] = kwargs + + def register_skill(self, name: str, path: Path) -> None: + self.skills[name] = Path(path) + + +class TestPluginContract(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self.tmp.name + os.environ["HERMESPACE_AGENT_ID"] = "plugin-contract" + os.environ["HERMESPACE_SKIP_NEURAL"] = "1" + + def tearDown(self) -> None: + self.tmp.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + os.environ.pop("HERMESPACE_AGENT_ID", None) + os.environ.pop("HERMESPACE_SKIP_NEURAL", None) + + def test_registration_and_native_lifecycle(self) -> None: + from hermespace.plugin import register + + ctx = FakeContext() + register(ctx) + required = { + "on_session_start", + "pre_llm_call", + "post_llm_call", + "post_tool_call", + "on_session_end", + "on_session_finalize", + } + self.assertTrue(required.issubset(ctx.hooks), ctx.hooks) + self.assertIn("hermespace", ctx.commands) + self.assertIn("hermespace", ctx.cli) + + common = { + "session_id": "native-session", + "model": "test/model", + "platform": "cli", + } + ctx.hooks["on_session_start"](**common) + result = ctx.hooks["pre_llm_call"]( + **common, + user_message="First inspect, then implement, finally verify", + conversation_history=[], + is_first_turn=True, + ) + self.assertIsInstance(result, dict) + self.assertIn("Access Workspace", result["context"]) + self.assertLess(len(result["context"]), 15_000) + + ctx.hooks["post_tool_call"]( + **common, + tool_name="read_file", + args={"secret": "must-not-persist"}, + result="private result", + task_id="native-session", + duration_ms=1, + ) + ctx.hooks["post_llm_call"]( + **common, + user_message="implement", + assistant_response="Implemented and verified.", + conversation_history=[], + ) + ctx.hooks["on_session_end"]( + **common, + completed=True, + interrupted=False, + ) + + from hermespace.hermes_runtime import runtime + + before = runtime.status("native-session") + self.assertFalse(before["finalized"]) + self.assertEqual(before["completed_turns"], 1) + self.assertEqual(before["tool_calls"], 1) + serialized = str(before) + self.assertNotIn("must-not-persist", serialized) + self.assertNotIn("private result", serialized) + + ctx.hooks["on_session_finalize"](**common) + after = runtime.status("native-session") + self.assertTrue(after["finalized"]) + ctx.hooks["on_session_finalize"](**common) # idempotent + + def test_commands_are_operational(self) -> None: + from hermespace.plugin import register + + ctx = FakeContext() + register(ctx) + output = ctx.commands["hermespace"]["handler"]("metrics") + self.assertIn("hub_cap", output) + + parser = argparse.ArgumentParser() + cli = ctx.cli["hermespace"] + cli["setup_fn"](parser) + args = parser.parse_args(["status"]) + self.assertEqual(args.action, "status") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..8c701da --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,68 @@ +"""Persistence contracts for the active desk.""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from hermespace.desk import Desk +from hermespace.store import load_desk, save_desk + + +class TestDeskStore(unittest.TestCase): + def test_sidecar_metadata_round_trip(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ACTIVE.md" + desk = Desk( + goal="Ship", + decision="A — proceed", + say="Proceeding.", + focus=["[verbal|0.90] verify"], + load={"level": "high", "total": 0.8}, + executive="protect", + meta={ + "fabric": {"skill_hits": [{"name": "pytest"}]}, + "_fabric_goal": "Ship", + "world_entry_count": 17, + "neural": {"backend": "hash"}, + }, + ) + save_desk(desk, path) + restored = load_desk(path) + + self.assertEqual(restored.meta, desk.meta) + self.assertEqual(restored.load, desk.load) + self.assertEqual(restored.focus, desk.focus) + self.assertEqual(restored.executive, desk.executive) + + def test_corrupt_sidecar_falls_back_to_markdown(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ACTIVE.md" + desk = Desk(goal="Recover", decision="A", say="Ready") + save_desk(desk, path) + path.with_suffix(".json").write_text("{broken", encoding="utf-8") + + restored = load_desk(path) + self.assertEqual(restored.goal, "Recover") + self.assertEqual(restored.decision, "A") + + def test_atomic_save_leaves_valid_json(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ACTIVE.md" + for idx in range(20): + desk = Desk( + goal=f"Goal {idx}", + decision="A", + say="Ready", + meta={"iteration": idx}, + ) + save_desk(desk, path) + payload = json.loads(path.with_suffix(".json").read_text(encoding="utf-8")) + self.assertEqual(payload["meta"]["iteration"], idx) + self.assertEqual(list(Path(td).glob("*.tmp")), []) + + +if __name__ == "__main__": + unittest.main() From 4d9e56eeb8216fd0eb54ca7a83705f2d58c48bbe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:39:20 +0000 Subject: [PATCH 10/32] test: validate current Hermes plugin manager Co-authored-by: Pablo --- scripts/verify_hermes_integration.py | 47 ++++++++++++++++++++++++++++ src/hermespace/access/env.py | 3 +- src/hermespace/world.py | 6 +++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/scripts/verify_hermes_integration.py b/scripts/verify_hermes_integration.py index 89bc7cb..6cbebbc 100755 --- a/scripts/verify_hermes_integration.py +++ b/scripts/verify_hermes_integration.py @@ -66,6 +66,51 @@ def _load_repo_plugin() -> Any: return module +def _verify_live_hermes_manager() -> dict[str, Any]: + """Use the installed Hermes PluginManager when available.""" + + try: + from hermes_cli.plugins import PluginManager, PluginManifest + except ImportError: + return {"available": False, "reason": "hermes_cli not importable"} + + manager = PluginManager() + manifest = PluginManifest( + name="hermespace", + version="0.25.0", + description="Hermespace host-contract verification", + source="user", + path=str(ROOT), + kind="standalone", + key="hermespace", + ) + manager._load_plugin(manifest) # current Hermes public loader delegates here + loaded = manager._plugins.get("hermespace") + if loaded is None or loaded.error: + raise AssertionError( + f"current Hermes PluginManager failed to load Hermespace: " + f"{getattr(loaded, 'error', 'missing result')}" + ) + required = { + "on_session_start", + "pre_llm_call", + "post_llm_call", + "post_tool_call", + "on_session_end", + "on_session_finalize", + } + if not required.issubset(set(loaded.hooks_registered)): + raise AssertionError( + f"current Hermes manager missing hooks: " + f"{sorted(required - set(loaded.hooks_registered))}" + ) + return { + "available": True, + "hooks": sorted(loaded.hooks_registered), + "commands": sorted(loaded.commands_registered), + } + + def main() -> int: with tempfile.TemporaryDirectory(prefix="hermespace-host-contract-") as tmp: old_home = os.environ.get("HERMESPACE_HOME") @@ -148,6 +193,7 @@ def main() -> int: if "tracked_sessions" not in command_output: raise AssertionError("/hermespace runtime returned unexpected output") + live_host = _verify_live_hermes_manager() report = { "ok": True, "hooks": sorted(ctx.hooks), @@ -158,6 +204,7 @@ def main() -> int: "turns": after.get("completed_turns"), "tools": after.get("tool_calls"), "finalized": after.get("finalized"), + "current_hermes_manager": live_host, } print(json.dumps(report, indent=2)) return 0 diff --git a/src/hermespace/access/env.py b/src/hermespace/access/env.py index 313693a..2e8bb60 100644 --- a/src/hermespace/access/env.py +++ b/src/hermespace/access/env.py @@ -16,6 +16,7 @@ from pathlib import Path from typing import Any +from hermespace.atomic import atomic_write_text from hermespace.access.hub import HUB_CAP, AccessHub, WorkspaceConcept, get_access_hub from hermespace.paths import state_dir @@ -129,7 +130,7 @@ def _load_env(self) -> dict[str, Any]: return {"pov": "", "band": "early", "reflections": [], "protocol_enabled": True} def _save_env(self) -> None: - self.env_path.write_text(json.dumps(self._env, indent=2), encoding="utf-8") + atomic_write_text(self.env_path, json.dumps(self._env, indent=2)) def _trace(self, kind: str, **payload: Any) -> None: rec = {"ts": _utcnow(), "kind": kind, "agent_id": self.agent_id, **payload} diff --git a/src/hermespace/world.py b/src/hermespace/world.py index 76bd49e..299e90c 100644 --- a/src/hermespace/world.py +++ b/src/hermespace/world.py @@ -22,6 +22,7 @@ from pathlib import Path from typing import Any +from hermespace.atomic import atomic_write_text from hermespace.paths import state_dir @@ -295,7 +296,10 @@ def _load(self) -> WorldState: def save(self) -> Path: self._state.updated = _utcnow() self._state.world_time = _utcnow() - self.path.write_text(json.dumps(asdict(self._state), indent=2, default=str), encoding="utf-8") + atomic_write_text( + self.path, + json.dumps(asdict(self._state), indent=2, default=str), + ) return self.path @property From 9b90a9e5b389552c04cc4af411dae6cef34101fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:45:45 +0000 Subject: [PATCH 11/32] fix: bound hooks and unify runtime identity Co-authored-by: Pablo --- .github/workflows/ci.yml | 10 ++++++++++ src/hermespace/cli.py | 6 ++++-- src/hermespace/grid/api.py | 3 ++- src/hermespace/hermes_bridge.py | 23 +++++++++++++++++++++-- src/hermespace/plugin.py | 2 +- src/hermespace/pulse.py | 8 ++++++++ 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09efc8c..2fc7dc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,16 @@ jobs: - run: python -m pip install . - name: Security audit run: ./scripts/security_audit.sh + - name: Lint production additions + run: | + python -m pip install ruff + python -m ruff check --isolated \ + src/hermespace/atomic.py \ + src/hermespace/hermes_runtime.py \ + src/hermespace/plugin.py \ + scripts/verify_hermes_integration.py \ + tests/test_plugin_contract.py \ + tests/test_store.py - name: Smoke run: ./scripts/smoke_test.sh - name: Ops end-to-end diff --git a/src/hermespace/cli.py b/src/hermespace/cli.py index 2d1944c..572c77a 100644 --- a/src/hermespace/cli.py +++ b/src/hermespace/cli.py @@ -984,8 +984,9 @@ def main(argv: list[str] | None = None) -> int: from hermespace.grid import Grid from hermespace.grid.lenses import list_lenses from hermespace.grid.gates import gate_status + from hermespace.paths import canonical_agent_id - aid = getattr(args, "agent_id", None) or "default" + aid = canonical_agent_id(getattr(args, "agent_id", None)) g = Grid(aid) cmd = args.grid_cmd if cmd == "status": @@ -1135,6 +1136,7 @@ def main(argv: list[str] | None = None) -> int: return 2 if args.cmd == "view": + from hermespace.paths import canonical_agent_id from hermespace.grid.viewport import ( render_html, render_markdown, @@ -1142,7 +1144,7 @@ def main(argv: list[str] | None = None) -> int: write_viewport_files, ) - aid = args.agent_id + aid = canonical_agent_id(args.agent_id) if args.serve: from hermespace.grid.view_server import serve diff --git a/src/hermespace/grid/api.py b/src/hermespace/grid/api.py index 2a347fa..c609b6a 100644 --- a/src/hermespace/grid/api.py +++ b/src/hermespace/grid/api.py @@ -7,13 +7,14 @@ from hermespace.grid import dream, gates, lenses, missions, scars, selftalk, skillbench, title_tree from hermespace.grid.lenses import lens_inject_block from hermespace.grid.selftalk import as_model_context +from hermespace.paths import canonical_agent_id class Grid: """One agent’s Hermespace grid surface.""" def __init__(self, agent_id: str = "default") -> None: - self.agent_id = agent_id or "default" + self.agent_id = canonical_agent_id(agent_id) # --- missions --- def add_mission(self, title: str, **kw: Any): diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index a42b20d..5f908a8 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -13,6 +13,25 @@ def _truthy(name: str, default: str = "0") -> bool: return os.environ.get(name, default).strip().lower() in {"1", "true", "yes", "on"} +def _bounded_context(text: str) -> str: + """Keep native hook output below Hermes's default 10k spill threshold.""" + + try: + cap = int(os.environ.get("HERMESPACE_PRE_LLM_MAX_CHARS", "9000")) + except ValueError: + cap = 9000 + cap = max(2000, min(20_000, cap)) + if len(text) <= cap: + return text + tail_n = min(1200, cap // 4) + head_n = cap - tail_n - 120 + return ( + text[:head_n] + + "\n\n[Hermespace context bounded; low-priority middle omitted]\n\n" + + text[-tail_n:] + ) + + def on_session_start(**kwargs: Any) -> dict[str, str] | None: """Initialize a Hermes v0.20 session and stage first-turn context. @@ -231,7 +250,7 @@ def on_pre_llm_call( pass # Prefer explicit regulation reply as dual-channel: model sees full; user gets note via say path if auto return { - "context": ( + "context": _bounded_context( ((start_context + "\n\n") if start_context else "") + block + "\n\n### Boundary regulation (this turn)\n" @@ -538,7 +557,7 @@ def on_pre_llm_call( pass # Prefer dual-channel when host supports unknown keys; context always set - result: dict[str, str] = {"context": block} + result: dict[str, str] = {"context": _bounded_context(block)} if user_hint: result["user_reply_hint"] = user_hint return result diff --git a/src/hermespace/plugin.py b/src/hermespace/plugin.py index c2087e0..3e1fba2 100644 --- a/src/hermespace/plugin.py +++ b/src/hermespace/plugin.py @@ -123,7 +123,7 @@ def register(ctx: Any) -> None: skill = package_root() / "skills" / "hermespace" if skill.is_dir(): ctx.register_skill("hermespace", skill) - except Exception as exc: + except (OSError, RuntimeError, ValueError) as exc: logger.debug("Hermespace skill registration skipped: %s", exc) logger.info( diff --git a/src/hermespace/pulse.py b/src/hermespace/pulse.py index 2219c91..3454505 100644 --- a/src/hermespace/pulse.py +++ b/src/hermespace/pulse.py @@ -24,6 +24,7 @@ from typing import Any, Callable from hermespace.grid.secure_store import atomic_write_json, grid_root, read_json, safe_name +from hermespace.paths import canonical_agent_id def _utcnow() -> str: @@ -123,6 +124,7 @@ def save_jobs(jobs: list[PulseJob]) -> None: def ensure_defaults(agent_id: str = "default") -> list[PulseJob]: """Seed built-in rhythm if empty.""" + agent_id = canonical_agent_id(agent_id) jobs = load_jobs() if jobs: return jobs @@ -576,6 +578,8 @@ def tick( seed_defaults: bool = True, ) -> dict[str, Any]: """Evaluate due jobs. Missed windows coalesce (run once).""" + if agent_id is not None: + agent_id = canonical_agent_id(agent_id) # Master switch from pocket controls if not force: try: @@ -644,6 +648,7 @@ def tick( def status(agent_id: str = "default", *, light: bool = False) -> dict[str, Any]: """Job board + world sensors. light=True skips ensure_defaults thrash if empty file ok.""" + agent_id = canonical_agent_id(agent_id) if not light: ensure_defaults(agent_id) elif not _jobs_path().is_file(): @@ -697,6 +702,7 @@ def status(agent_id: str = "default", *, light: bool = False) -> dict[str, Any]: def compact_summary(agent_id: str = "default") -> dict[str, Any]: """Cheap pulse blurb for viewport snapshots.""" + agent_id = canonical_agent_id(agent_id) st = status(agent_id, light=True) jobs = st.get("jobs") or [] return { @@ -709,6 +715,8 @@ def compact_summary(agent_id: str = "default") -> dict[str, Any]: def daemon_loop(interval_sec: int = 60, *, agent_id: str | None = None, max_ticks: int = 0) -> None: """In-process loop. max_ticks=0 means forever.""" + if agent_id is not None: + agent_id = canonical_agent_id(agent_id) n = 0 interval_sec = max(5, int(interval_sec)) print(f"pulse daemon interval={interval_sec}s agent={agent_id or '*'}", flush=True) From 079010ad32522f24dfd3a3df99889d3a616df44d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:46:36 +0000 Subject: [PATCH 12/32] fix(install): prefer the selected checkout runtime Co-authored-by: Pablo --- scripts/install_hermes.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/install_hermes.sh b/scripts/install_hermes.sh index fd34968..5743e2e 100755 --- a/scripts/install_hermes.sh +++ b/scripts/install_hermes.sh @@ -25,6 +25,9 @@ done # shellcheck source=scripts/_python.sh source "$ROOT/scripts/_python.sh" +# Put this checkout first even when an older editable Hermespace is already in +# the operator's PYTHONPATH. +export PYTHONPATH="$ROOT/src${PYTHONPATH:+:$PYTHONPATH}" echo "Hermespace production install" echo " CHECKOUT=$ROOT" From 1193fd93871a164f67a3ea5da6f8d0b2cbdf5c8b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 13:43:46 +0000 Subject: [PATCH 13/32] feat(0.25): cable Cube prefetch skip and Insight perceive card Land the remaining operational-desk work on Access Engine rebased onto current main: - Insight soft-import (bounded perceive card, fail-soft, high-load skip) - Skip or shrink cube_beat when Hermes memory.provider already prefetched - World projects from the Cube book instead of a second JSONL archive - plugin.yaml provides_hooks for all 9 hooks plus advisory python_dependencies - Drop leftover ILO_HOME / ilo-finish-report strings without brand blocklists Co-authored-by: Pablo --- PURPOSE.md | 6 +- README.md | 12 +- desktop_plugin/hermespace/README.md | 2 +- desktop_plugin/hermespace/plugin.js | 4 +- docs/architecture/HERMESCUBE.md | 4 +- docs/architecture/INSIGHT.md | 22 +++ hermes_plugin/plugin.yaml | 14 +- plugin.yaml | 14 +- scripts/new_desk.sh | 2 +- scripts/show_desk.sh | 2 +- skills/hermespace/SKILL.md | 6 +- src/hermespace/__init__.py | 2 + src/hermespace/access/engine.py | 6 + src/hermespace/cube_module.py | 131 +++++++++++++++- src/hermespace/grid/lenses.py | 2 +- src/hermespace/hermes_bridge.py | 30 ++++ src/hermespace/insight_module.py | 223 ++++++++++++++++++++++++++++ src/hermespace/paths.py | 8 +- src/hermespace/workflow.py | 33 ++++ src/hermespace/world.py | 56 ++++++- tests/test_access_hub.py | 12 ++ tests/test_insight_module.py | 168 +++++++++++++++++++++ tests/test_plugin_contract.py | 20 +++ tests/test_world.py | 38 +++++ 24 files changed, 786 insertions(+), 31 deletions(-) create mode 100644 docs/architecture/INSIGHT.md create mode 100644 src/hermespace/insight_module.py create mode 100644 tests/test_insight_module.py diff --git a/PURPOSE.md b/PURPOSE.md index 22acd33..1b2ab79 100644 --- a/PURPOSE.md +++ b/PURPOSE.md @@ -91,7 +91,11 @@ World identity may remain agent-scoped; active cognition is session-scoped. | Workbench | Session mode, parked goals, last native turn | | WorldModel | Agent-scoped beliefs, landmarks, timeline | | Hermes Agent | Model calls, tools, skills, approvals, transcript | -| Optional warehouse | Long-tail recall and consolidation only | +| Optional Cube book | Durable long-tail SoT when `hermescube` is installed | +| Optional Insight | Bounded perceive card when `hermes_insight` is installed | + +Cube and Insight stay standalone packages. Hermespace only **cables** them +via fail-soft imports (`cube_module`, `insight_module`). Neither is required. Hermespace does **not**: diff --git a/README.md b/README.md index e4791ac..ff163b1 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ subagents, turn boundaries, and finalization. ## The World Model -A persistent agent world that grows forever. The archive is the source of truth; the cache is never authoritative. +A persistent agent world. Standalone, the local JSONL warehouse may grow. +When HermesCube is installed, World **projects from the Cube book** and does +not grow a second forever-archive. ### Archive @@ -56,7 +58,9 @@ print(wm.render_markdown()) # epoch-aware markdown wm.evolve() # consolidate, detect patterns, check epoch ``` -Every mutation appends to `~/.hermespace/worlds/{agent_id}_archive.jsonl` — an append-only JSONL that grows forever. No pruning. No decay. No deletion. +Standalone mutations append to `~/.hermespace/worlds/{agent_id}_archive.jsonl`. +With Cube, World is a projection (`pulse_charge` / `sync_world_beliefs`) and +that JSONL is not a second book. Entry types: `enter`, `leave`, `landmark`, `belief`, `trait`, `evolution`, `focus`, `epoch_transition`, `resolve`, `relationship`. @@ -137,7 +141,8 @@ Alongside the world, Hermespace provides a desk for the current turn — FOA, du | **Functional Access Workspace** | Harness global workspace — hold/summon concepts, silent reasoning, FOA≤4, hub≤25 | | **Focus of Attention** | ≤4 items, single active goal per turn | | **Dual Decode** | Human gets a short report; the model gets dense context. Never dump raw inject into chat channels. | -| **Cube heart (optional)** | Soft cable to HermesCube — `beat` / `seal` / `pulse`; standalone warehouse when Cube absent | +| **Cube heart (optional)** | Soft cable to HermesCube — `beat` / `seal` / `pulse`; skip/shrink when the Cube provider already prefetched; standalone warehouse when Cube absent | +| **Insight (optional)** | Soft cable to Hermes Insight — bounded perceive card on material turns; never required | | **Skills + Memory Fabric** | Ranks Hermes skills per goal; injects MEMORY.md / USER.md excerpts | | **Neural FOA** | `HERMESPACE_NEURAL_BACKEND=auto` — Ollama embeddings when live, hash fallback | | **Autonomy Grid** | Missions, lenses, dream, self-talk, skillbench, title/tree, access gates. Ground-up design. | @@ -260,6 +265,7 @@ $HERMESPACE_HOME/memory/hermespace/ | [`ABOUT.md`](ABOUT.md) | Philosophy, design principles, author | | [`docs/architecture/CODEMAP.md`](docs/architecture/CODEMAP.md) | Where to edit (layer map) | | [`docs/architecture/HERMESCUBE.md`](docs/architecture/HERMESCUBE.md) | Cube heart/center contract | +| [`docs/architecture/INSIGHT.md`](docs/architecture/INSIGHT.md) | Insight perceive-card cable | | [`INTEGRATION.md`](INTEGRATION.md) | Python · CLI · plugin · workbench doors | | [`skills/hermespace/SKILL.md`](skills/hermespace/SKILL.md) | **Agent skill** (load in Hermes) | | [`docs/integration/FOR_HERMES.md`](docs/integration/FOR_HERMES.md) | Maintainer / dogfood brief | diff --git a/desktop_plugin/hermespace/README.md b/desktop_plugin/hermespace/README.md index d3646c5..cc235ce 100644 --- a/desktop_plugin/hermespace/README.md +++ b/desktop_plugin/hermespace/README.md @@ -8,7 +8,7 @@ ## Surfaces (v0.17.1) | Surface | How | |---------|-----| -| **Pane** `hermespace` | `area: panes` — dockable like ilo-ops | +| **Pane** `hermespace` | `area: panes` — dockable | | **Page** `/hermespace` | `ROUTES_AREA` | | **Sidebar** Hermespace | `SIDEBAR_NAV_AREA` → openRouteTile | | **Chip** `hs` | statusBar → navigate page | diff --git a/desktop_plugin/hermespace/plugin.js b/desktop_plugin/hermespace/plugin.js index 41fc690..5dd86b3 100644 --- a/desktop_plugin/hermespace/plugin.js +++ b/desktop_plugin/hermespace/plugin.js @@ -10,7 +10,7 @@ * (symlink to another tree breaks some Desktop/remote read paths) * * Surfaces: - * 1) panes tile — immediate, dockable (like ilo-ops; users find this) + * 1) panes tile — immediate, dockable * 2) full page /hermespace + sidebar.nav (first-class) * 3) status chip + palette → navigate /hermespace */ @@ -722,7 +722,7 @@ export default { name: 'Hermespace', defaultEnabled: true, register: function (ctx) { - // 1) Dockable pane — same class as working ilo-ops (always visible if plugins load) + // 1) Dockable pane (always visible if plugins load) ctx.register({ id: 'pane', area: 'panes', diff --git a/docs/architecture/HERMESCUBE.md b/docs/architecture/HERMESCUBE.md index adfb9b9..6d88191 100644 --- a/docs/architecture/HERMESCUBE.md +++ b/docs/architecture/HERMESCUBE.md @@ -19,7 +19,7 @@ Hermes Agent ──connect──► Hermespace (J-Space · FOA · OEW) | Surface | With Cube | Standalone | |---------|-----------|------------| | `$HERMES_HOME/memories/memory.cube` | **Durable SoT** | n/a | -| Hermespace world JSONL | Projection — recharge via `pulse` / `sync_world` | Local warehouse | +| Hermespace world JSONL | Projection only — recharge via `pulse` / `sync_world`; do not grow a second archive | Local warehouse | | ACTIVE desk / J-Space hub | Turn FOA (seeded on connect) | Turn FOA | | Hive (`HERMESCUBE_HIVE`) | Peer room / soul cards | Solo room | | SemanticStore | Mirror / study | Local seal target | @@ -45,7 +45,7 @@ from hermespace.cube_module import ( |------|-----| | `connect_agent(agent_id)` | Session start / `HermesBase.connect` | | `ensure_heart()` | Create cube or standalone dirs | -| `cube_beat(query, seals=, load=)` | Turn / `pre_llm_call` | +| `cube_beat(query, seals=, load=)` | Turn / `pre_llm_call` — skip/shrink when Cube provider already prefetched | | `cube_pulse` / `sync_world` | Idle + connect charge | | `room_status` | Hive awareness (`HERMESCUBE_HIVE`) | | `seal_learning(text)` | Desk → durable archive | diff --git a/docs/architecture/INSIGHT.md b/docs/architecture/INSIGHT.md new file mode 100644 index 0000000..d7aa71e --- /dev/null +++ b/docs/architecture/INSIGHT.md @@ -0,0 +1,22 @@ +# Hermes Insight × Hermespace — optional perceive cable + +**Hermes Insight stays a standalone package.** Hermespace does not vendor it. +When `hermes_insight` is importable, Access Engine appends a **bounded perceive +card** (~400 chars) on material `pre_llm_call` / `AccessEngine.turn`. + +``` +from hermespace.insight_module import insight_card, insight_status + +rec = insight_card("two workers share one token", goal=desk.goal, plan=desk.plan) +# rec["card"] → lever / top rule / usable / action_hint +``` + +| Rule | Behavior | +|------|----------| +| Missing package | Soft-fail (`mode=missing`) — engine still runs | +| High load | Skip (`skipped=high_load`) | +| Card | lever, top rule, usable, action_hint — never the lattice | +| `insight_plan` | Hot path only when `usable` **and** the goal is multi-step | +| Required? | Never | + +Companion: [PabloTheThinker/hermes-insight](https://github.com/PabloTheThinker/hermes-insight) diff --git a/hermes_plugin/plugin.yaml b/hermes_plugin/plugin.yaml index f16a0b0..bffff68 100644 --- a/hermes_plugin/plugin.yaml +++ b/hermes_plugin/plugin.yaml @@ -4,7 +4,7 @@ version: "0.25.0" description: > Production Access Engine for Hermes Agent v0.20+: session-scoped workspace, bounded model context, native lifecycle telemetry, dual decode, and - standalone-safe persistence. + standalone-safe persistence. Cube and Insight are optional soft-imports. author: Hermespace contributors kind: standalone hooks: @@ -17,4 +17,16 @@ hooks: - on_session_reset - subagent_start - subagent_stop +provides_hooks: + - on_session_start + - pre_llm_call + - post_llm_call + - post_tool_call + - on_session_end + - on_session_finalize + - on_session_reset + - subagent_start + - subagent_stop +python_dependencies: + - numpy>=1.24,<3 homepage: https://github.com/PabloTheThinker/hermespace diff --git a/plugin.yaml b/plugin.yaml index f16a0b0..bffff68 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -4,7 +4,7 @@ version: "0.25.0" description: > Production Access Engine for Hermes Agent v0.20+: session-scoped workspace, bounded model context, native lifecycle telemetry, dual decode, and - standalone-safe persistence. + standalone-safe persistence. Cube and Insight are optional soft-imports. author: Hermespace contributors kind: standalone hooks: @@ -17,4 +17,16 @@ hooks: - on_session_reset - subagent_start - subagent_stop +provides_hooks: + - on_session_start + - pre_llm_call + - post_llm_call + - post_tool_call + - on_session_end + - on_session_finalize + - on_session_reset + - subagent_start + - subagent_stop +python_dependencies: + - numpy>=1.24,<3 homepage: https://github.com/PabloTheThinker/hermespace diff --git a/scripts/new_desk.sh b/scripts/new_desk.sh index 77a84dd..a26ea46 100755 --- a/scripts/new_desk.sh +++ b/scripts/new_desk.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -HOME_ROOT="${HERMESPACE_HOME:-${ILO_HOME:-$HOME/.hermespace}}" +HOME_ROOT="${HERMESPACE_HOME:-$HOME/.hermespace}" DEST="$HOME_ROOT/memory/hermespace/ACTIVE.md" TEMPLATE="$(cd "$(dirname "$0")/.." && pwd)/runtime/ACTIVE.template.md" mkdir -p "$(dirname "$DEST")" diff --git a/scripts/show_desk.sh b/scripts/show_desk.sh index 90a1ea9..40a6ffb 100755 --- a/scripts/show_desk.sh +++ b/scripts/show_desk.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -HOME_ROOT="${HERMESPACE_HOME:-${ILO_HOME:-$HOME/.hermespace}}" +HOME_ROOT="${HERMESPACE_HOME:-$HOME/.hermespace}" DEST="$HOME_ROOT/memory/hermespace/ACTIVE.md" if [[ ! -f "$DEST" ]]; then echo "no active desk at $DEST" diff --git a/skills/hermespace/SKILL.md b/skills/hermespace/SKILL.md index 434df17..41cad3b 100644 --- a/skills/hermespace/SKILL.md +++ b/skills/hermespace/SKILL.md @@ -14,9 +14,9 @@ description: >- **What it is not:** A second LLM runtime · Claude J-space weights · a SaaS brand · a replacement for Hermes skills/MEMORY. **Package SoT:** `$HERMESPACE_ROOT` (git checkout) · https://github.com/PabloTheThinker/hermespace -**State:** `$HERMESPACE_HOME` default `~/.hermespace` (optional `ILO_HOME`) +**State:** `$HERMESPACE_HOME` default `~/.hermespace` **Hermes:** `$HERMES_HOME` default `~/.hermes` -**Version:** align `src/hermespace/__init__.py` · `pyproject.toml` · `hermes_plugin/plugin.yaml` +**Version:** align `src/hermespace/__init__.py` · `pyproject.toml` · `plugin.yaml` --- @@ -375,7 +375,7 @@ Never end on only a verify table / `ADHOC_PASS`. - `hermespace-ops` · `hermespace-grid` · `hermespace-everyday` · `hermespace-runtime-ops` - `pocket-dimension-security` - `hermes-agent` · `hermes-desktop-surface` · `hermes-local-ops` -- Finish: `ilo-finish-report` / `material-finish-review` / `session-closeout` when those exist +- Finish: `session-closeout` / `material-finish-review` when those exist **Package skill path:** `$HERMESPACE_ROOT/skills/hermespace/SKILL.md` **Profile install:** `$HERMES_HOME/skills/.../hermespace/SKILL.md` (via `install_hermes.sh`) diff --git a/src/hermespace/__init__.py b/src/hermespace/__init__.py index b8d7270..88a63f0 100644 --- a/src/hermespace/__init__.py +++ b/src/hermespace/__init__.py @@ -33,6 +33,7 @@ from hermespace.access import evaluate_material_turn from hermespace.access import AccessEngine, ACCESS_ROLES from hermespace import cube_module +from hermespace import insight_module from hermespace.hermes_base import HermesBase __all__ = [ @@ -68,6 +69,7 @@ "get_env", "evaluate_material_turn", "cube_module", + "insight_module", "HermesBase", "probe_environment", "environment_markdown", diff --git a/src/hermespace/access/engine.py b/src/hermespace/access/engine.py index bd12f7e..62ffeaf 100644 --- a/src/hermespace/access/engine.py +++ b/src/hermespace/access/engine.py @@ -397,6 +397,12 @@ def status(self) -> dict[str, Any]: } except Exception: out["warehouse"] = {"cube_available": False, "optional": True} + try: + from hermespace.insight_module import insight_status + + out["insight"] = insight_status() + except Exception: + out["insight"] = {"available": False, "required": False, "optional": True} if self._last_connect: out["last_connect"] = { diff --git a/src/hermespace/cube_module.py b/src/hermespace/cube_module.py index b71a07f..4015047 100644 --- a/src/hermespace/cube_module.py +++ b/src/hermespace/cube_module.py @@ -233,6 +233,81 @@ def cube_status() -> dict[str, Any]: return heart_status() +def _truthy_env(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def hermes_memory_provider() -> str: + """Return Hermes ``memory.provider`` when detectable (never required).""" + for key in ("HERMES_MEMORY_PROVIDER", "MEMORY_PROVIDER"): + raw = os.environ.get(key, "").strip().lower() + if raw: + return raw + home = os.environ.get("HERMES_HOME", "").strip() + roots = [] + if home: + roots.append(os.path.expanduser(home)) + roots.append(os.path.expanduser("~/.hermes")) + for root in roots: + cfg = os.path.join(root, "config.yaml") + try: + text = open(cfg, encoding="utf-8").read() + except OSError: + continue + in_memory = False + for line in text.splitlines(): + raw = line.split("#", 1)[0] + if raw.strip().startswith("memory:") or raw.strip() == "memory:": + in_memory = True + continue + if in_memory and raw and not raw[:1].isspace() and not raw.startswith("\t"): + in_memory = False + if not in_memory: + continue + stripped = raw.strip() + if stripped.startswith("provider:"): + return stripped.split(":", 1)[1].strip().strip("\"'").lower() + return "" + + +def cube_is_memory_provider() -> bool: + return hermes_memory_provider() in {"hermescube", "cube"} + + +def cube_already_prefetched( + query: str = "", + *, + session_id: str = "", +) -> bool: + """True when the Cube memory provider already recalled this turn's book. + + Hermes ``memory.provider=hermescube`` prefetches independently. A second + full ``cube_beat`` strip dual-pumps the same book — skip or shrink. + """ + if _truthy_env("HERMESPACE_CUBE_PREFETCHED"): + return True + if not cube_is_memory_provider() or not cube_available(): + return False + try: + import gc + + from hermescube.provider import CubeMemoryProvider + + q = (query or "").strip() + for obj in gc.get_objects(): + if not isinstance(obj, CubeMemoryProvider): + continue + last_q = str(getattr(obj, "_last_prefetch_query", "") or "") + last_ids = list(getattr(obj, "_last_prefetch_ids", None) or []) + if last_ids: + return True + if last_q and (not q or last_q[:40] in q or q[:40] in last_q): + return True + except Exception as e: + logger.debug("cube prefetch probe miss: %s", e) + return False + + def cube_beat( query: str = "", *, @@ -243,11 +318,51 @@ def cube_beat( agent_id: str = "hermes-agent", charge: bool = False, session_id: str = "hermespace", + skip_if_prefetched: bool = True, ) -> dict[str, Any]: """One cardiac cycle for a Hermespace turn (Cube center or standalone). Order: ensure → systole (seal) → diastole (supply) → optional autonomic. + + When Hermes ``memory.provider=hermescube`` already prefetched this turn, + skip the full arterial strip (or shrink it) so the same book is not + dual-pumped into model context. """ + prefetched = bool(skip_if_prefetched and cube_already_prefetched(query, session_id=session_id)) + provider_live = cube_is_memory_provider() + skip_supply = prefetched + shrink_supply = (not skip_supply) and provider_live and cube_available() + + if skip_supply: + level = normalize_load(load, high_load=high_load) + out: dict[str, Any] = { + "api_version": "1.0", + "adapter": SPACE_CUBE_ADAPTER_VERSION, + "mode": "center" if cube_available() else "standalone", + "ok": True, + "phases": {"diastole": {"ok": True, "skipped": "provider_prefetch", "chars": 0}}, + "block": "", + "load_level": level, + "skipped": "provider_prefetch", + } + if seals is not None: + items = [seals] if isinstance(seals, str) else list(seals) + sealed = [ + seal_learning(str(x), entry_type=entry_type, agent_id=agent_id) + for x in items + if str(x).strip() + ] + out["phases"]["systole"] = { + "ok": all(r.get("ok") for r in sealed) if sealed else False, + "count": sum(1 for r in sealed if r.get("ok")), + } + if charge: + out["phases"]["autonomic"] = cube_pulse(agent_id=agent_id) + return out + + beat_load: str | float | None = "protect" if shrink_supply else load + beat_high = True if shrink_supply else high_load + try: from hermescube.center import beat @@ -255,8 +370,8 @@ def cube_beat( query or "", seals=seals, entry_type=entry_type, - load=load, - high_load=high_load, + load=beat_load, + high_load=beat_high, agent_id=agent_id, charge=charge, session_id=session_id, @@ -264,13 +379,19 @@ def cube_beat( if isinstance(out, dict): out["adapter"] = SPACE_CUBE_ADAPTER_VERSION out["mode"] = "center" + if shrink_supply: + out["shrunk"] = "provider_active" + block = str(out.get("block") or "") + cap = strip_budget("protect") + if len(block) > cap: + out["block"] = block[: cap - 3] + "..." return out except Exception as e: logger.debug("center.beat miss: %s", e) # Heart 1.0 / standalone fallback - level = normalize_load(load, high_load=high_load) - out: dict[str, Any] = { + level = normalize_load(beat_load, high_load=beat_high) + out = { "api_version": "1.0", "adapter": SPACE_CUBE_ADAPTER_VERSION, "mode": "standalone" if not cube_available() else "heart", @@ -279,6 +400,8 @@ def cube_beat( "block": "", "load_level": level, } + if shrink_supply: + out["shrunk"] = "provider_active" out["phases"]["ensure"] = ensure_heart() if seals is not None: items = [seals] if isinstance(seals, str) else list(seals) diff --git a/src/hermespace/grid/lenses.py b/src/hermespace/grid/lenses.py index c0a239c..5093b26 100644 --- a/src/hermespace/grid/lenses.py +++ b/src/hermespace/grid/lenses.py @@ -55,7 +55,7 @@ "partner": { "title": "Partner", "bias": "Monotropism, finish reports, high-load short say", - "fabric_boost": ["professional-messaging", "ilo-finish-report", "hermes-agent"], + "fabric_boost": ["professional-messaging", "session-closeout", "hermes-agent"], "inhibit": ["option menus under high load", "leave hanging with only verify table"], "report_style": "closeout", }, diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index 5f908a8..3e5bcb5 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -437,6 +437,8 @@ def on_pre_llm_call( "ok": beat.get("ok"), "mode": beat.get("mode"), "load_level": beat.get("load_level"), + "skipped": beat.get("skipped"), + "shrunk": beat.get("shrunk"), } try: from hermespace.access import AccessEnv @@ -498,6 +500,34 @@ def on_pre_llm_call( except Exception: pass + try: + from hermespace.insight_module import insight_card + + icard = insight_card( + msg or desk.goal or "", + high_load=high_load, + goal=desk.goal or "", + plan=list(desk.plan or []), + agent_id=agent_id, + ) + desk.meta["insight"] = { + "ok": icard.get("ok"), + "mode": icard.get("mode"), + "usable": icard.get("usable"), + "skipped": icard.get("skipped"), + "planned": icard.get("planned"), + } + if icard.get("card"): + block += "\n\n" + str(icard["card"]) + try: + from hermespace.store import save_desk + + save_desk(desk) + except Exception: + pass + except Exception: + pass + # Workbench status — only on first turn or when state changes (skip under high) if not high_load: try: diff --git a/src/hermespace/insight_module.py b/src/hermespace/insight_module.py new file mode 100644 index 0000000..96eed1f --- /dev/null +++ b/src/hermespace/insight_module.py @@ -0,0 +1,223 @@ +"""Hermes Insight adapter — soft dependency, never required. + +Insight (when installed) is a standalone pattern lattice. Hermespace owns +FOA / Access Engine. This module is the cable, same shape as ``cube_module``: + + feature-detect ``hermes_insight`` + on material pre_llm / AccessEngine.turn → bounded perceive card (~400 chars) + high-load skip · missing package → soft-fail + never dump the lattice + never call ``insight_plan`` on the hot path unless usable *and* multi-step + +See PURPOSE.md. Do not vendor Insight source. +""" + +from __future__ import annotations + +import logging +import os +import re +from typing import Any, Sequence + +logger = logging.getLogger("hermespace.insight_module") + +SPACE_INSIGHT_ADAPTER_VERSION = "1.0" +INSIGHT_CARD_CHARS = 400 + +_MULTI_STEP_RE = re.compile( + r"\b(first|then|next|after that|finally|and then|step\s*\d)\b", + re.IGNORECASE, +) + + +def insight_available() -> bool: + try: + import hermes_insight # noqa: F401 + + return True + except Exception: + return False + + +def insight_status() -> dict[str, Any]: + """Feature-detect only — never required for Access Engine.""" + out: dict[str, Any] = { + "adapter": SPACE_INSIGHT_ADAPTER_VERSION, + "available": False, + "required": False, + "mode": "missing", + "ok": True, + "note": "optional soft-import — Hermespace runs without Insight", + } + try: + import hermes_insight + + out["available"] = True + out["mode"] = "insight" + out["version"] = getattr(hermes_insight, "__version__", None) + return out + except Exception as e: + out["error"] = type(e).__name__ + return out + + +def _is_multi_step(goal: str = "", plan: Sequence[str] | None = None) -> bool: + if plan is not None and len([p for p in plan if str(p).strip()]) >= 2: + return True + text = (goal or "").strip() + if not text: + return False + if _MULTI_STEP_RE.search(text): + return True + return text.count(";") >= 2 or text.count("→") >= 2 or text.count("->") >= 2 + + +def _bound_card(text: str, *, cap: int = INSIGHT_CARD_CHARS) -> str: + s = (text or "").strip() + if len(s) <= cap: + return s + return s[: cap - 3].rstrip() + "..." + + +def _format_card( + *, + lever: str, + top_rule: str, + usable: bool, + action_hint: str, + plan_hint: str = "", +) -> str: + lines = [ + "### Insight", + f"- lever: {(lever or 'unknown')[:80]}", + f"- rule: {(top_rule or 'none')[:120]}", + f"- usable: {str(bool(usable)).lower()}", + f"- hint: {(action_hint or '')[:160]}", + ] + if plan_hint: + lines.append(f"- plan: {plan_hint[:100]}") + return _bound_card("\n".join(lines)) + + +def insight_card( + situation: str, + *, + observations: Sequence[str] | None = None, + high_load: bool = False, + goal: str = "", + plan: Sequence[str] | None = None, + agent_id: str = "", + max_chars: int = INSIGHT_CARD_CHARS, +) -> dict[str, Any]: + """Bounded perceive card for a material turn. Soft-fail if Insight is absent. + + Never dumps the lattice. ``insight_plan`` runs only when the perceive card + is usable *and* the goal looks multi-step. + """ + out: dict[str, Any] = { + "ok": False, + "adapter": SPACE_INSIGHT_ADAPTER_VERSION, + "mode": "missing", + "card": "", + "usable": False, + "lever": "", + "top_rule": "", + "action_hint": "", + "planned": False, + "required": False, + } + if high_load: + out["ok"] = True + out["mode"] = "skipped" + out["skipped"] = "high_load" + return out + if not (situation or "").strip() and not (goal or "").strip(): + out["ok"] = True + out["mode"] = "skipped" + out["skipped"] = "empty" + return out + if not insight_available(): + out["ok"] = True + out["mode"] = "missing" + out["skipped"] = "not_installed" + return out + + blob = (situation or goal or "").strip() + obs = [str(o).strip() for o in (observations or []) if str(o).strip()] + aid = (agent_id or os.environ.get("HERMESPACE_AGENT_ID") or "").strip() or None + try: + from hermes_insight import HermesInsight + + lat = HermesInsight(agent_id=aid) + rec = lat.perceive( + blob, + observations=obs or None, + log_experience=False, + deep=False, + ) + except Exception as e: + logger.debug("insight perceive miss: %s", e) + out["ok"] = True + out["mode"] = "soft_fail" + out["error"] = type(e).__name__ + return out + + if not isinstance(rec, dict): + out["ok"] = True + out["mode"] = "soft_fail" + out["error"] = "bad_perceive" + return out + + matches = list(rec.get("matches") or []) + top = matches[0] if matches else {} + lever = str(rec.get("lever") or "") + usable = bool(rec.get("usable")) + hint = str(rec.get("action_hint") or "") + top_rule = str(top.get("title") or "") + plan_hint = "" + planned = False + + # Hot path: plan only when usable and the goal is actually multi-step. + if usable and _is_multi_step(goal or blob, plan): + try: + planned_rec = lat.plan(blob, observations=obs or None, limit=3) + if isinstance(planned_rec, dict): + steps = planned_rec.get("steps") or planned_rec.get("plan") or [] + if isinstance(steps, list) and steps: + first = steps[0] + if isinstance(first, dict): + plan_hint = str(first.get("title") or first.get("action") or first)[:100] + else: + plan_hint = str(first)[:100] + planned = True + elif planned_rec.get("action_hint"): + plan_hint = str(planned_rec.get("action_hint"))[:100] + planned = True + except Exception as e: + logger.debug("insight plan miss: %s", e) + + card = _format_card( + lever=lever, + top_rule=top_rule, + usable=usable, + action_hint=hint, + plan_hint=plan_hint, + ) + if max_chars and len(card) > max_chars: + card = _bound_card(card, cap=max_chars) + + out.update( + { + "ok": True, + "mode": "insight", + "card": card, + "usable": usable, + "lever": lever, + "top_rule": top_rule, + "action_hint": hint, + "planned": planned, + "confidence": rec.get("confidence"), + "top_score": rec.get("top_score"), + } + ) + return out diff --git a/src/hermespace/paths.py b/src/hermespace/paths.py index e69dfa8..2d37991 100644 --- a/src/hermespace/paths.py +++ b/src/hermespace/paths.py @@ -22,13 +22,11 @@ def hermespace_home() -> Path: Order: HERMESPACE_HOME - ILO_HOME (compat) ~/.hermespace """ - for key in ("HERMESPACE_HOME", "ILO_HOME"): - raw = os.environ.get(key, "").strip() - if raw: - return Path(raw).expanduser().resolve() + raw = os.environ.get("HERMESPACE_HOME", "").strip() + if raw: + return Path(raw).expanduser().resolve() return (Path.home() / ".hermespace").resolve() diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index 6d17c9b..0cd43b0 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -145,6 +145,7 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: cube_meta: dict[str, Any] = {} access_meta: dict[str, Any] = {} cube_block = "" + insight_block = "" env_meta: dict[str, Any] = {} oew_broadcast = "" report = (desk.say or "").strip() @@ -177,6 +178,8 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: "mode": beat.get("mode"), "load_level": beat.get("load_level"), "chars": len(cube_block), + "skipped": beat.get("skipped"), + "shrunk": beat.get("shrunk"), } js = AccessHub(agent_id=access_id) js.sync_from_desk(desk, user_message=msg, cube_strip=cube_block) @@ -222,6 +225,33 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: except Exception as exc: cube_meta = {"ok": False, "error": type(exc).__name__} + try: + from hermespace.insight_module import insight_card + + high_insight = ( + str(desk.load.get("level")) == "high" + if isinstance(desk.load, dict) + else False + ) + icard = insight_card( + msg or desk.goal or "", + high_load=high_insight, + goal=desk.goal or g, + plan=list(desk.plan or []), + agent_id=payload.agent_id or "hermes-agent", + ) + desk.meta["insight"] = { + "ok": icard.get("ok"), + "mode": icard.get("mode"), + "usable": icard.get("usable"), + "skipped": icard.get("skipped"), + "planned": icard.get("planned"), + } + insight_block = str(icard.get("card") or "") + save_desk(desk, self.engine.desk_path) + except Exception: + insight_block = "" + # 6 broadcast context (model channel) — Quicksilver-capped OEW strip inject_cap = 900 if ( isinstance(desk.load, dict) and str(desk.load.get("level")) == "high" @@ -229,6 +259,8 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: block = build_inject_block(desk, max_chars=inject_cap, user_message=msg) if cube_block: block = (block + "\n\n" + cube_block).strip() + if insight_block: + block = (block + "\n\n" + insight_block).strip() try: from hermespace.access import AccessHub, AccessEnv from hermespace.access.engine import workspace_id @@ -296,6 +328,7 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: "neural": neural_snap, "fabric": fabric_snap, "cube_beat": cube_meta, + "insight": (desk.meta or {}).get("insight") or {}, "access": access_meta, "access_env": env_meta, }, diff --git a/src/hermespace/world.py b/src/hermespace/world.py index 299e90c..3350e70 100644 --- a/src/hermespace/world.py +++ b/src/hermespace/world.py @@ -3,8 +3,10 @@ The World is the agent's external Access Workspace: a structured, persistent representation of everything the agent knows, believes, and is doing. -The archive (JSONL) is the source of truth — it grows forever. -world.json is a fast cache / projection of current state. +When HermesCube is present, the Cube book is the durable SoT. World +projects from that book (``pulse_charge`` / ``sync_world_beliefs``) and +does not grow a second forever-archive. Standalone (no Cube) keeps the +local JSONL warehouse as the source of truth; world.json is the cache. Archive types: enter, leave, landmark, belief, trait, evolution, focus, epoch_transition, resolve, relationship. @@ -243,8 +245,9 @@ def get_entry(self, entry_id: str) -> TimelineEntry | None: class WorldModel: """The agent's persistent world — the space they live and work in. - The archive (JSONL) is the source of truth — it grows forever. - world.json is a fast cache / projection of current state. + Standalone: the JSONL archive is the local warehouse and may grow. + With Cube: World is a projection of the book — recharge via + ``pulse_charge`` / ``sync_world_beliefs``, do not grow a second archive. """ def __init__(self, agent_id: str = "hermes-agent") -> None: @@ -316,8 +319,46 @@ def _compute_epoch(self) -> str: return "Maturity" return "Wisdom" + def projects_from_cube(self) -> bool: + """True when Cube is the durable book — World must not grow a second archive.""" + try: + from hermespace.cube_module import cube_available + + return bool(cube_available()) + except Exception: + return False + + def project_from_book(self) -> dict[str, Any]: + """Charge this World from the Cube book (no-op / standalone evolve if absent).""" + try: + from hermespace.cube_module import cube_pulse, sync_world + + charged = cube_pulse(agent_id=self.agent_id, ensure=False) + synced = sync_world(agent_id=self.agent_id) + return { + "ok": bool(charged.get("ok") or synced.get("ok")), + "mode": "cube" if self.projects_from_cube() else "standalone", + "charge": charged, + "sync": synced, + } + except Exception as e: + return {"ok": False, "error": type(e).__name__, "mode": "standalone"} + def _add_timeline(self, entry_type: str, description: str, data: dict | None = None, causal_parents: list[str] | None = None, outcome: str = "") -> TimelineEntry: - entry = self.archive.append(entry_type, self.agent_id, description, data, causal_parents, outcome) + if self.projects_from_cube(): + # Cube book is SoT — keep a short in-memory projection only. + entry = TimelineEntry( + id=uuid.uuid4().hex[:12], + timestamp=_utcnow(), + entry_type=entry_type, + agent_id=self.agent_id, + description=description, + data=data or {}, + causal_parents=causal_parents or [], + outcome=outcome, + ) + else: + entry = self.archive.append(entry_type, self.agent_id, description, data, causal_parents, outcome) self._state.timeline.insert(0, entry) if len(self._state.timeline) > 50: self._state.timeline = self._state.timeline[:50] @@ -370,6 +411,11 @@ def enter(self, desk: Any = None) -> WorldState: self._add_timeline("enter", "Agent entered the world", {"state": self._state.current_state}) self._state.current_state = "working" self._state.world_time = _utcnow() + if self.projects_from_cube(): + try: + self.project_from_book() + except Exception: + pass self._refresh_concepts() self.save() return self._state diff --git a/tests/test_access_hub.py b/tests/test_access_hub.py index 6286eba..6fe8c18 100644 --- a/tests/test_access_hub.py +++ b/tests/test_access_hub.py @@ -136,6 +136,18 @@ def test_seal_and_inject(self) -> None: self.assertIn("block", beat) self.assertEqual(beat.get("mode"), "standalone") + def test_prefetch_skip_does_not_inject_strip(self) -> None: + os.environ["HERMESPACE_CUBE_PREFETCHED"] = "1" + try: + from hermespace.cube_module import cube_already_prefetched, cube_beat + + self.assertTrue(cube_already_prefetched("deploy")) + beat = cube_beat("deploy", load="mid", agent_id="prefetch-agent") + self.assertEqual(beat.get("skipped"), "provider_prefetch") + self.assertEqual(beat.get("block"), "") + finally: + os.environ.pop("HERMESPACE_CUBE_PREFETCHED", None) + def test_strip_budget(self) -> None: from hermespace.cube_module import normalize_load, strip_budget diff --git a/tests/test_insight_module.py b/tests/test_insight_module.py new file mode 100644 index 0000000..eda2b57 --- /dev/null +++ b/tests/test_insight_module.py @@ -0,0 +1,168 @@ +"""Insight cable — fail-soft, bounded card, never required.""" + +from __future__ import annotations + +import os +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + + +class TestInsightMissing(unittest.TestCase): + def setUp(self) -> None: + self._td = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self._td.name + import builtins + + real_import = builtins.__import__ + + def _block(name, *args, **kwargs): + if name == "hermes_insight" or name.startswith("hermes_insight."): + raise ImportError("blocked for missing-insight test") + return real_import(name, *args, **kwargs) + + self._imp = mock.patch("builtins.__import__", side_effect=_block) + self._imp.start() + + def tearDown(self) -> None: + self._imp.stop() + self._td.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + + def test_status_and_card_soft_fail(self) -> None: + from hermespace.insight_module import insight_available, insight_card, insight_status + + self.assertFalse(insight_available()) + st = insight_status() + self.assertFalse(st.get("available")) + self.assertFalse(st.get("required")) + rec = insight_card("First reproduce then patch then verify") + self.assertTrue(rec.get("ok")) + self.assertEqual(rec.get("mode"), "missing") + self.assertEqual(rec.get("card"), "") + + +class TestInsightPresent(unittest.TestCase): + def setUp(self) -> None: + self._td = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self._td.name + self._plan_calls = {"n": 0} + + class _Lat: + def perceive(self, situation, **kwargs): + return { + "usable": True, + "lever": "shared-token", + "action_hint": "Give each worker its own credential.", + "confidence": 0.7, + "top_score": 0.4, + "matches": [ + { + "title": "duplicate consumer", + "kind": "rule", + "score": 0.4, + } + ], + } + + def plan(self, situation, **kwargs): + return {"steps": [{"title": "isolate credentials first"}]} + + lat = _Lat() + orig_plan = lat.plan + + def _plan(situation, **kwargs): + self._plan_calls["n"] += 1 + return orig_plan(situation, **kwargs) + + lat.plan = _plan # type: ignore[method-assign] + + pkg = types.ModuleType("hermes_insight") + pkg.HermesInsight = lambda *a, **k: lat # type: ignore[attr-defined] + pkg.__version__ = "0.8.0-test" + self._mod = mock.patch.dict(sys.modules, {"hermes_insight": pkg}) + self._mod.start() + self._lat = lat + + def tearDown(self) -> None: + self._mod.stop() + self._td.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + + def test_bounded_card_and_plan_on_multistep(self) -> None: + from hermespace.insight_module import insight_card, insight_status + + st = insight_status() + self.assertTrue(st.get("available")) + rec = insight_card( + "two workers share one bot token", + goal="First isolate credentials then restart finally verify", + plan=["isolate", "restart", "verify"], + ) + self.assertTrue(rec.get("ok")) + self.assertTrue(rec.get("usable")) + self.assertIn("shared-token", rec.get("card") or "") + self.assertLessEqual(len(rec.get("card") or ""), 400) + self.assertTrue(rec.get("planned")) + self.assertEqual(self._plan_calls["n"], 1) + self.assertNotIn("lattice", (rec.get("card") or "").lower()) + + def test_no_plan_on_single_step(self) -> None: + from hermespace.insight_module import insight_card + + rec = insight_card("what is the status", goal="status") + self.assertTrue(rec.get("ok")) + self.assertFalse(rec.get("planned")) + self.assertEqual(self._plan_calls["n"], 0) + + def test_high_load_skip(self) -> None: + from hermespace.insight_module import insight_card + + rec = insight_card( + "First reproduce then patch then verify", + high_load=True, + plan=["a", "b"], + ) + self.assertTrue(rec.get("ok")) + self.assertEqual(rec.get("skipped"), "high_load") + self.assertEqual(rec.get("card"), "") + self.assertEqual(self._plan_calls["n"], 0) + + +class TestWorkflowInsightMeta(unittest.TestCase): + def setUp(self) -> None: + self._td = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self._td.name + + def tearDown(self) -> None: + self._td.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + + def test_turn_records_insight_meta(self) -> None: + from hermespace.io_contract import HermespaceInput + from hermespace.workflow import Workflow + + out = Workflow().run( + HermespaceInput( + message="First inspect then implement finally verify", + goal="Ship the fix", + decision="A — implement", + plan=["inspect", "implement", "verify"], + say="Working the fix.", + force=True, + agent_id="insight-wf", + ) + ) + self.assertFalse(out.skipped) + self.assertIn("insight", out.meta or {}) + self.assertIn((out.meta or {}).get("insight", {}).get("mode"), ("missing", "insight", "soft_fail", "skipped")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py index f6b90da..0dbc626 100644 --- a/tests/test_plugin_contract.py +++ b/tests/test_plugin_contract.py @@ -111,6 +111,26 @@ def test_registration_and_native_lifecycle(self) -> None: self.assertTrue(after["finalized"]) ctx.hooks["on_session_finalize"](**common) # idempotent + def test_plugin_yaml_hygiene(self) -> None: + text = (Path(__file__).resolve().parents[1] / "plugin.yaml").read_text(encoding="utf-8") + expected = [ + "on_session_start", + "pre_llm_call", + "post_llm_call", + "post_tool_call", + "on_session_end", + "on_session_finalize", + "on_session_reset", + "subagent_start", + "subagent_stop", + ] + self.assertIn("provides_hooks:", text) + self.assertIn("python_dependencies:", text) + self.assertIn("numpy>=1.24,<3", text) + after = text.split("provides_hooks:", 1)[1].split("python_dependencies:", 1)[0] + for hook in expected: + self.assertIn(f"- {hook}", after) + def test_commands_are_operational(self) -> None: from hermespace.plugin import register diff --git a/tests/test_world.py b/tests/test_world.py index 9561f41..7f155c9 100644 --- a/tests/test_world.py +++ b/tests/test_world.py @@ -52,5 +52,43 @@ def test_growth_render_uses_cleaned_landmarks(self): self.assertFalse(any("session ended" in lm.lower() for lm in wm.state.landmarks)) +class TestWorldCubeProjection(unittest.TestCase): + def setUp(self) -> None: + self._td = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self._td.name + + def tearDown(self) -> None: + self._td.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + + def test_standalone_grows_local_archive(self) -> None: + from unittest import mock + + from hermespace.world import WorldModel + + with mock.patch.object(WorldModel, "projects_from_cube", return_value=False): + wm = WorldModel(agent_id="standalone-archive") + before = wm.archive.count() + wm.add_belief("Standalone warehouse may grow", 0.8, source="test") + self.assertGreater(wm.archive.count(), before) + self.assertTrue(wm.archive.path.is_file()) + + def test_cube_present_does_not_grow_jsonl(self) -> None: + from unittest import mock + + from hermespace.world import WorldModel + + wm = WorldModel(agent_id="cube-projection") + with mock.patch.object(WorldModel, "projects_from_cube", return_value=True): + with mock.patch.object(WorldModel, "project_from_book", return_value={"ok": True, "mode": "cube"}): + before = wm.archive.count() + wm.enter() + wm.add_belief("Cube book is the SoT", 0.9, source="test") + wm.leave("session finalized") + self.assertEqual(wm.archive.count(), before) + self.assertTrue(wm.projects_from_cube()) + + if __name__ == "__main__": unittest.main() + From f5beed7a9292151217dcd2e4f400f95e142a992b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 13:48:07 +0000 Subject: [PATCH 14/32] fix(insight): lock cable to perceive_card next to cube_beat Hang the Insight strip beside cube_beat on pre_llm_call only. Feature-detect HermesInsight.perceive_card; skip until that method exists. Do not format perceive() output or call plan on the hot path. Skip entirely on high/protect load. AccessEngine.turn no longer injects Insight. Co-authored-by: Pablo --- PURPOSE.md | 2 +- README.md | 2 +- docs/architecture/INSIGHT.md | 27 ++-- src/hermespace/hermes_bridge.py | 42 ++---- src/hermespace/insight_module.py | 219 ++++++++-------------------- src/hermespace/workflow.py | 31 ---- tests/test_insight_module.py | 243 ++++++++++++++++++++++--------- 7 files changed, 269 insertions(+), 297 deletions(-) diff --git a/PURPOSE.md b/PURPOSE.md index 1b2ab79..ae71b15 100644 --- a/PURPOSE.md +++ b/PURPOSE.md @@ -92,7 +92,7 @@ World identity may remain agent-scoped; active cognition is session-scoped. | WorldModel | Agent-scoped beliefs, landmarks, timeline | | Hermes Agent | Model calls, tools, skills, approvals, transcript | | Optional Cube book | Durable long-tail SoT when `hermescube` is installed | -| Optional Insight | Bounded perceive card when `hermes_insight` is installed | +| Optional Insight | `perceive_card` strip next to `cube_beat` on `pre_llm_call` when installed | Cube and Insight stay standalone packages. Hermespace only **cables** them via fail-soft imports (`cube_module`, `insight_module`). Neither is required. diff --git a/README.md b/README.md index ff163b1..e46f3fd 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ Alongside the world, Hermespace provides a desk for the current turn — FOA, du | **Focus of Attention** | ≤4 items, single active goal per turn | | **Dual Decode** | Human gets a short report; the model gets dense context. Never dump raw inject into chat channels. | | **Cube heart (optional)** | Soft cable to HermesCube — `beat` / `seal` / `pulse`; skip/shrink when the Cube provider already prefetched; standalone warehouse when Cube absent | -| **Insight (optional)** | Soft cable to Hermes Insight — bounded perceive card on material turns; never required | +| **Insight (optional)** | Soft cable next to `cube_beat` on `pre_llm_call` — `perceive_card` only, skip on high/protect; never required | | **Skills + Memory Fabric** | Ranks Hermes skills per goal; injects MEMORY.md / USER.md excerpts | | **Neural FOA** | `HERMESPACE_NEURAL_BACKEND=auto` — Ollama embeddings when live, hash fallback | | **Autonomy Grid** | Missions, lenses, dream, self-talk, skillbench, title/tree, access gates. Ground-up design. | diff --git a/docs/architecture/INSIGHT.md b/docs/architecture/INSIGHT.md index d7aa71e..fd35e55 100644 --- a/docs/architecture/INSIGHT.md +++ b/docs/architecture/INSIGHT.md @@ -1,22 +1,23 @@ -# Hermes Insight × Hermespace — optional perceive cable +# Hermes Insight × Hermespace — optional perceive_card cable -**Hermes Insight stays a standalone package.** Hermespace does not vendor it. -When `hermes_insight` is importable, Access Engine appends a **bounded perceive -card** (~400 chars) on material `pre_llm_call` / `AccessEngine.turn`. +**Hermes Insight stays a standalone package.** Hermespace does not vendor it +and does not register Insight hooks. The cable is a thin +`hermespace.insight_module` adapter hung **next to `cube_beat`** on +`pre_llm_call` (not inside `AccessEngine.turn`). ``` -from hermespace.insight_module import insight_card, insight_status - -rec = insight_card("two workers share one token", goal=desk.goal, plan=desk.plan) -# rec["card"] → lever / top rule / usable / action_hint +from hermes_insight import HermesInsight +if hasattr(HermesInsight, "perceive_card"): + card = HermesInsight().perceive_card(goal, load=...) ``` | Rule | Behavior | |------|----------| -| Missing package | Soft-fail (`mode=missing`) — engine still runs | -| High load | Skip (`skipped=high_load`) | -| Card | lever, top rule, usable, action_hint — never the lattice | -| `insight_plan` | Hot path only when `usable` **and** the goal is multi-step | -| Required? | Never | +| Feature-detect | `from hermes_insight import HermesInsight` and `hasattr(..., "perceive_card")` | +| Missing / no `perceive_card` | Skip — do **not** format `perceive()["card"]` (unbounded lattice) | +| High / protect load | Skip entirely (stricter than Cube) | +| Hot path | Append only the returned card, capped at 400 chars | +| `insight_plan` / `.plan` | Never on `pre_llm_call` | +| Required? | Never — `except Exception: pass` like `cube_beat` | Companion: [PabloTheThinker/hermes-insight](https://github.com/PabloTheThinker/hermes-insight) diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index 3e5bcb5..bdbb16e 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -427,6 +427,20 @@ def on_pre_llm_call( cube_block = str(beat.get("block") or "") if cube_block: block += "\n\n" + cube_block + # Insight strip — next to cube_beat. perceive_card only; skip if missing. + try: + from hermespace.insight_module import insight_card + + icard = insight_card(desk.goal or msg or "", load=load_val) + desk.meta["insight"] = { + "ok": icard.get("ok"), + "mode": icard.get("mode"), + "skipped": icard.get("skipped"), + } + if icard.get("card"): + block += "\n\n" + str(icard["card"]) + except Exception: + pass # OEW beat — higher-order park + causal broadcast (model channel only) from hermespace.access.oew import ensure_oew_env_default @@ -500,34 +514,6 @@ def on_pre_llm_call( except Exception: pass - try: - from hermespace.insight_module import insight_card - - icard = insight_card( - msg or desk.goal or "", - high_load=high_load, - goal=desk.goal or "", - plan=list(desk.plan or []), - agent_id=agent_id, - ) - desk.meta["insight"] = { - "ok": icard.get("ok"), - "mode": icard.get("mode"), - "usable": icard.get("usable"), - "skipped": icard.get("skipped"), - "planned": icard.get("planned"), - } - if icard.get("card"): - block += "\n\n" + str(icard["card"]) - try: - from hermespace.store import save_desk - - save_desk(desk) - except Exception: - pass - except Exception: - pass - # Workbench status — only on first turn or when state changes (skip under high) if not high_load: try: diff --git a/src/hermespace/insight_module.py b/src/hermespace/insight_module.py index 96eed1f..ef12c99 100644 --- a/src/hermespace/insight_module.py +++ b/src/hermespace/insight_module.py @@ -1,46 +1,55 @@ -"""Hermes Insight adapter — soft dependency, never required. +"""Hermes Insight adapter — thin soft-import, never required. -Insight (when installed) is a standalone pattern lattice. Hermespace owns -FOA / Access Engine. This module is the cable, same shape as ``cube_module``: +Insight stays a standalone package. This module is the cable only: - feature-detect ``hermes_insight`` - on material pre_llm / AccessEngine.turn → bounded perceive card (~400 chars) - high-load skip · missing package → soft-fail - never dump the lattice - never call ``insight_plan`` on the hot path unless usable *and* multi-step + from hermes_insight import HermesInsight + hasattr(HermesInsight, "perceive_card") + HermesInsight().perceive_card(goal, load=...) -See PURPOSE.md. Do not vendor Insight source. +Hang the returned card next to ``cube_beat`` on ``pre_llm_call``. +Skip entirely on high/protect load. Until ``perceive_card`` exists, skip — +do not format ``perceive()`` output (unbounded lattice). Do not call +``insight_plan`` / ``HermesInsight.plan`` on the hot path. Do not register +Insight hooks. Do not vendor Insight source. + +See docs/architecture/INSIGHT.md. """ from __future__ import annotations -import logging -import os -import re -from typing import Any, Sequence - -logger = logging.getLogger("hermespace.insight_module") +from typing import Any -SPACE_INSIGHT_ADAPTER_VERSION = "1.0" +SPACE_INSIGHT_ADAPTER_VERSION = "1.1" INSIGHT_CARD_CHARS = 400 -_MULTI_STEP_RE = re.compile( - r"\b(first|then|next|after that|finally|and then|step\s*\d)\b", - re.IGNORECASE, -) + +def _high_or_protect(load: str | float | None = None, *, high_load: bool = False) -> bool: + if high_load: + return True + if isinstance(load, (int, float)): + return float(load) >= 0.65 + s = str(load or "").strip().lower() + return s in {"high", "protect", "protected", "mono", "monotropic"} + + +def _bound_card(text: str, *, cap: int = INSIGHT_CARD_CHARS) -> str: + s = (text or "").strip() + if len(s) <= cap: + return s + return s[: cap - 3].rstrip() + "..." def insight_available() -> bool: try: - import hermes_insight # noqa: F401 + from hermes_insight import HermesInsight - return True + return hasattr(HermesInsight, "perceive_card") except Exception: return False def insight_status() -> dict[str, Any]: - """Feature-detect only — never required for Access Engine.""" + """Feature-detect ``perceive_card`` only — never required.""" out: dict[str, Any] = { "adapter": SPACE_INSIGHT_ADAPTER_VERSION, "available": False, @@ -51,173 +60,71 @@ def insight_status() -> dict[str, Any]: } try: import hermes_insight + from hermes_insight import HermesInsight - out["available"] = True - out["mode"] = "insight" out["version"] = getattr(hermes_insight, "__version__", None) + if hasattr(HermesInsight, "perceive_card"): + out["available"] = True + out["mode"] = "insight" + else: + out["mode"] = "no_perceive_card" + out["skipped"] = "no_perceive_card" return out except Exception as e: out["error"] = type(e).__name__ return out -def _is_multi_step(goal: str = "", plan: Sequence[str] | None = None) -> bool: - if plan is not None and len([p for p in plan if str(p).strip()]) >= 2: - return True - text = (goal or "").strip() - if not text: - return False - if _MULTI_STEP_RE.search(text): - return True - return text.count(";") >= 2 or text.count("→") >= 2 or text.count("->") >= 2 - - -def _bound_card(text: str, *, cap: int = INSIGHT_CARD_CHARS) -> str: - s = (text or "").strip() - if len(s) <= cap: - return s - return s[: cap - 3].rstrip() + "..." - - -def _format_card( - *, - lever: str, - top_rule: str, - usable: bool, - action_hint: str, - plan_hint: str = "", -) -> str: - lines = [ - "### Insight", - f"- lever: {(lever or 'unknown')[:80]}", - f"- rule: {(top_rule or 'none')[:120]}", - f"- usable: {str(bool(usable)).lower()}", - f"- hint: {(action_hint or '')[:160]}", - ] - if plan_hint: - lines.append(f"- plan: {plan_hint[:100]}") - return _bound_card("\n".join(lines)) - - def insight_card( - situation: str, + goal: str, *, - observations: Sequence[str] | None = None, + load: str | float | None = None, high_load: bool = False, - goal: str = "", - plan: Sequence[str] | None = None, - agent_id: str = "", max_chars: int = INSIGHT_CARD_CHARS, ) -> dict[str, Any]: - """Bounded perceive card for a material turn. Soft-fail if Insight is absent. + """Call ``HermesInsight().perceive_card`` and return only that card. - Never dumps the lattice. ``insight_plan`` runs only when the perceive card - is usable *and* the goal looks multi-step. + Soft-fail if Insight is absent or ``perceive_card`` is missing. + Never calls ``perceive`` / ``plan``. Never formats a lattice dump. """ out: dict[str, Any] = { - "ok": False, + "ok": True, "adapter": SPACE_INSIGHT_ADAPTER_VERSION, "mode": "missing", "card": "", - "usable": False, - "lever": "", - "top_rule": "", - "action_hint": "", - "planned": False, "required": False, } - if high_load: - out["ok"] = True + if _high_or_protect(load, high_load=high_load): out["mode"] = "skipped" out["skipped"] = "high_load" return out - if not (situation or "").strip() and not (goal or "").strip(): - out["ok"] = True + if not (goal or "").strip(): out["mode"] = "skipped" out["skipped"] = "empty" return out - if not insight_available(): - out["ok"] = True + try: + from hermes_insight import HermesInsight + except Exception: out["mode"] = "missing" out["skipped"] = "not_installed" return out - - blob = (situation or goal or "").strip() - obs = [str(o).strip() for o in (observations or []) if str(o).strip()] - aid = (agent_id or os.environ.get("HERMESPACE_AGENT_ID") or "").strip() or None + if not hasattr(HermesInsight, "perceive_card"): + out["mode"] = "no_perceive_card" + out["skipped"] = "no_perceive_card" + return out try: - from hermes_insight import HermesInsight - - lat = HermesInsight(agent_id=aid) - rec = lat.perceive( - blob, - observations=obs or None, - log_experience=False, - deep=False, - ) + rec = HermesInsight().perceive_card((goal or "").strip(), load=load) except Exception as e: - logger.debug("insight perceive miss: %s", e) - out["ok"] = True out["mode"] = "soft_fail" out["error"] = type(e).__name__ return out - if not isinstance(rec, dict): - out["ok"] = True - out["mode"] = "soft_fail" - out["error"] = "bad_perceive" - return out - - matches = list(rec.get("matches") or []) - top = matches[0] if matches else {} - lever = str(rec.get("lever") or "") - usable = bool(rec.get("usable")) - hint = str(rec.get("action_hint") or "") - top_rule = str(top.get("title") or "") - plan_hint = "" - planned = False - - # Hot path: plan only when usable and the goal is actually multi-step. - if usable and _is_multi_step(goal or blob, plan): - try: - planned_rec = lat.plan(blob, observations=obs or None, limit=3) - if isinstance(planned_rec, dict): - steps = planned_rec.get("steps") or planned_rec.get("plan") or [] - if isinstance(steps, list) and steps: - first = steps[0] - if isinstance(first, dict): - plan_hint = str(first.get("title") or first.get("action") or first)[:100] - else: - plan_hint = str(first)[:100] - planned = True - elif planned_rec.get("action_hint"): - plan_hint = str(planned_rec.get("action_hint"))[:100] - planned = True - except Exception as e: - logger.debug("insight plan miss: %s", e) - - card = _format_card( - lever=lever, - top_rule=top_rule, - usable=usable, - action_hint=hint, - plan_hint=plan_hint, - ) - if max_chars and len(card) > max_chars: - card = _bound_card(card, cap=max_chars) - - out.update( - { - "ok": True, - "mode": "insight", - "card": card, - "usable": usable, - "lever": lever, - "top_rule": top_rule, - "action_hint": hint, - "planned": planned, - "confidence": rec.get("confidence"), - "top_score": rec.get("top_score"), - } - ) + if isinstance(rec, str): + card = rec + elif isinstance(rec, dict): + card = str(rec.get("card") or "") + else: + card = str(rec or "") + cap = max_chars if max_chars and max_chars > 0 else INSIGHT_CARD_CHARS + out.update({"mode": "insight", "card": _bound_card(card, cap=cap)}) return out diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index 0cd43b0..1850c37 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -145,7 +145,6 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: cube_meta: dict[str, Any] = {} access_meta: dict[str, Any] = {} cube_block = "" - insight_block = "" env_meta: dict[str, Any] = {} oew_broadcast = "" report = (desk.say or "").strip() @@ -225,33 +224,6 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: except Exception as exc: cube_meta = {"ok": False, "error": type(exc).__name__} - try: - from hermespace.insight_module import insight_card - - high_insight = ( - str(desk.load.get("level")) == "high" - if isinstance(desk.load, dict) - else False - ) - icard = insight_card( - msg or desk.goal or "", - high_load=high_insight, - goal=desk.goal or g, - plan=list(desk.plan or []), - agent_id=payload.agent_id or "hermes-agent", - ) - desk.meta["insight"] = { - "ok": icard.get("ok"), - "mode": icard.get("mode"), - "usable": icard.get("usable"), - "skipped": icard.get("skipped"), - "planned": icard.get("planned"), - } - insight_block = str(icard.get("card") or "") - save_desk(desk, self.engine.desk_path) - except Exception: - insight_block = "" - # 6 broadcast context (model channel) — Quicksilver-capped OEW strip inject_cap = 900 if ( isinstance(desk.load, dict) and str(desk.load.get("level")) == "high" @@ -259,8 +231,6 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: block = build_inject_block(desk, max_chars=inject_cap, user_message=msg) if cube_block: block = (block + "\n\n" + cube_block).strip() - if insight_block: - block = (block + "\n\n" + insight_block).strip() try: from hermespace.access import AccessHub, AccessEnv from hermespace.access.engine import workspace_id @@ -328,7 +298,6 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: "neural": neural_snap, "fabric": fabric_snap, "cube_beat": cube_meta, - "insight": (desk.meta or {}).get("insight") or {}, "access": access_meta, "access_env": env_meta, }, diff --git a/tests/test_insight_module.py b/tests/test_insight_module.py index eda2b57..f4f09f5 100644 --- a/tests/test_insight_module.py +++ b/tests/test_insight_module.py @@ -1,4 +1,4 @@ -"""Insight cable — fail-soft, bounded card, never required.""" +"""Insight cable — perceive_card only, fail-soft, never required.""" from __future__ import annotations @@ -48,94 +48,143 @@ def test_status_and_card_soft_fail(self) -> None: self.assertEqual(rec.get("card"), "") -class TestInsightPresent(unittest.TestCase): +class TestInsightNoPerceiveCard(unittest.TestCase): def setUp(self) -> None: self._td = tempfile.TemporaryDirectory() os.environ["HERMESPACE_HOME"] = self._td.name + self._perceive_calls = {"n": 0} self._plan_calls = {"n": 0} class _Lat: - def perceive(self, situation, **kwargs): + def perceive(self_lat, situation, **kwargs): + self._perceive_calls["n"] += 1 return { + "card": "UNBOUNDED LATTICE DUMP " + ("x" * 800), "usable": True, - "lever": "shared-token", - "action_hint": "Give each worker its own credential.", - "confidence": 0.7, - "top_score": 0.4, - "matches": [ - { - "title": "duplicate consumer", - "kind": "rule", - "score": 0.4, - } - ], + "matches": [{"title": "should-not-appear"}], } - def plan(self, situation, **kwargs): - return {"steps": [{"title": "isolate credentials first"}]} + def plan(self_lat, situation, **kwargs): + self._plan_calls["n"] += 1 + return {"steps": [{"title": "should-not-plan"}]} - lat = _Lat() - orig_plan = lat.plan + pkg = types.ModuleType("hermes_insight") + pkg.HermesInsight = _Lat # type: ignore[attr-defined] + pkg.__version__ = "0.8.0-test" + self.assertFalse(hasattr(pkg.HermesInsight, "perceive_card")) + self._mod = mock.patch.dict(sys.modules, {"hermes_insight": pkg}) + self._mod.start() - def _plan(situation, **kwargs): - self._plan_calls["n"] += 1 - return orig_plan(situation, **kwargs) + def tearDown(self) -> None: + self._mod.stop() + self._td.cleanup() + os.environ.pop("HERMESPACE_HOME", None) - lat.plan = _plan # type: ignore[method-assign] + def test_skip_until_perceive_card_exists(self) -> None: + from hermespace.insight_module import insight_available, insight_card, insight_status + + self.assertFalse(insight_available()) + st = insight_status() + self.assertEqual(st.get("mode"), "no_perceive_card") + rec = insight_card("two workers share one bot token", load="mid") + self.assertTrue(rec.get("ok")) + self.assertEqual(rec.get("skipped"), "no_perceive_card") + self.assertEqual(rec.get("card"), "") + self.assertEqual(self._perceive_calls["n"], 0) + self.assertEqual(self._plan_calls["n"], 0) + self.assertNotIn("UNBOUNDED", rec.get("card") or "") + + +class TestInsightPerceiveCard(unittest.TestCase): + def setUp(self) -> None: + self._td = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self._td.name + self._calls = {"perceive_card": 0, "perceive": 0, "plan": 0} + self._last_load = None + outer = self + + class _Cls: + def __init__(self, *a, **k): + pass + + def perceive_card(self, goal, load=None, **kwargs): + outer._calls["perceive_card"] += 1 + outer._last_load = load + return ( + "### Insight\n- lever: shared-token\n" + "- rule: duplicate consumer\n- usable: true\n" + "- hint: Give each worker its own credential." + ) + + def perceive(self, situation, **kwargs): + outer._calls["perceive"] += 1 + return {"card": "UNBOUNDED " + ("x" * 800)} + + def plan(self, situation, **kwargs): + outer._calls["plan"] += 1 + return {"steps": [{"title": "should-not-plan"}]} pkg = types.ModuleType("hermes_insight") - pkg.HermesInsight = lambda *a, **k: lat # type: ignore[attr-defined] - pkg.__version__ = "0.8.0-test" + pkg.HermesInsight = _Cls # type: ignore[attr-defined] + pkg.__version__ = "0.9.0-test" self._mod = mock.patch.dict(sys.modules, {"hermes_insight": pkg}) self._mod.start() - self._lat = lat def tearDown(self) -> None: self._mod.stop() self._td.cleanup() os.environ.pop("HERMESPACE_HOME", None) - def test_bounded_card_and_plan_on_multistep(self) -> None: - from hermespace.insight_module import insight_card, insight_status + def test_appends_bounded_perceive_card_only(self) -> None: + from hermespace.insight_module import insight_available, insight_card, insight_status - st = insight_status() - self.assertTrue(st.get("available")) - rec = insight_card( - "two workers share one bot token", - goal="First isolate credentials then restart finally verify", - plan=["isolate", "restart", "verify"], - ) + self.assertTrue(insight_available()) + self.assertEqual(insight_status().get("mode"), "insight") + rec = insight_card("First isolate credentials then restart", load="mid") self.assertTrue(rec.get("ok")) - self.assertTrue(rec.get("usable")) self.assertIn("shared-token", rec.get("card") or "") self.assertLessEqual(len(rec.get("card") or ""), 400) - self.assertTrue(rec.get("planned")) - self.assertEqual(self._plan_calls["n"], 1) - self.assertNotIn("lattice", (rec.get("card") or "").lower()) + self.assertEqual(self._calls["perceive_card"], 1) + self.assertEqual(self._calls["perceive"], 0) + self.assertEqual(self._calls["plan"], 0) + self.assertEqual(self._last_load, "mid") - def test_no_plan_on_single_step(self) -> None: + def test_high_and_protect_skip(self) -> None: from hermespace.insight_module import insight_card - rec = insight_card("what is the status", goal="status") - self.assertTrue(rec.get("ok")) - self.assertFalse(rec.get("planned")) - self.assertEqual(self._plan_calls["n"], 0) + for load in ("high", "protect", 0.7, 0.9): + rec = insight_card("First reproduce then patch then verify", load=load) + self.assertTrue(rec.get("ok"), load) + self.assertEqual(rec.get("skipped"), "high_load", load) + self.assertEqual(rec.get("card"), "", load) + self.assertEqual(self._calls["perceive_card"], 0) + self.assertEqual(self._calls["plan"], 0) - def test_high_load_skip(self) -> None: + rec = insight_card("goal", high_load=True, load="low") + self.assertEqual(rec.get("skipped"), "high_load") + self.assertEqual(self._calls["perceive_card"], 0) + + def test_caps_returned_card(self) -> None: from hermespace.insight_module import insight_card - rec = insight_card( - "First reproduce then patch then verify", - high_load=True, - plan=["a", "b"], - ) + huge = "Y" * 900 + + def _huge(*_a, **_k): + self._calls["perceive_card"] += 1 + return huge + + with mock.patch.object( + sys.modules["hermes_insight"].HermesInsight, + "perceive_card", + _huge, + ): + rec = insight_card("a long goal", load="low") self.assertTrue(rec.get("ok")) - self.assertEqual(rec.get("skipped"), "high_load") - self.assertEqual(rec.get("card"), "") - self.assertEqual(self._plan_calls["n"], 0) + self.assertLessEqual(len(rec.get("card") or ""), 400) + self.assertTrue((rec.get("card") or "").endswith("...")) -class TestWorkflowInsightMeta(unittest.TestCase): +class TestWorkflowDoesNotHangInsight(unittest.TestCase): def setUp(self) -> None: self._td = tempfile.TemporaryDirectory() os.environ["HERMESPACE_HOME"] = self._td.name @@ -144,24 +193,84 @@ def tearDown(self) -> None: self._td.cleanup() os.environ.pop("HERMESPACE_HOME", None) - def test_turn_records_insight_meta(self) -> None: + def test_turn_does_not_inject_insight_strip(self) -> None: from hermespace.io_contract import HermespaceInput from hermespace.workflow import Workflow - out = Workflow().run( - HermespaceInput( - message="First inspect then implement finally verify", - goal="Ship the fix", - decision="A — implement", - plan=["inspect", "implement", "verify"], - say="Working the fix.", - force=True, - agent_id="insight-wf", + with mock.patch("hermespace.insight_module.insight_card") as mocked: + out = Workflow().run( + HermespaceInput( + message="First inspect then implement finally verify", + goal="Ship the fix", + decision="A — implement", + plan=["inspect", "implement", "verify"], + say="Working the fix.", + force=True, + agent_id="insight-wf", + ) + ) + self.assertFalse(out.skipped) + mocked.assert_not_called() + self.assertNotIn("insight", out.meta or {}) + + +class TestPreLlmHangsInsightNextToCube(unittest.TestCase): + def setUp(self) -> None: + self._td = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self._td.name + os.environ["HERMESPACE_NEURAL_VERBALIZE"] = "0" + os.environ["HERMESPACE_AUTO_ORDER"] = "0" + + def tearDown(self) -> None: + self._td.cleanup() + for key in ( + "HERMESPACE_HOME", + "HERMESPACE_NEURAL_VERBALIZE", + "HERMESPACE_AUTO_ORDER", + ): + os.environ.pop(key, None) + + def test_pre_llm_appends_perceive_card(self) -> None: + from hermespace.hermes_bridge import on_pre_llm_call, on_session_start + + on_session_start(session_id="insight-bridge") + with mock.patch( + "hermespace.insight_module.insight_card", + return_value={"ok": True, "mode": "insight", "card": "### Insight\n- lever: test"}, + ) as mocked: + inj = on_pre_llm_call( + user_message="First build the feature then verify please", + session_id="insight-bridge", + is_first_turn=False, + ) + self.assertIsNotNone(inj) + mocked.assert_called() + self.assertIn("### Insight", (inj or {}).get("context") or "") + + def test_pre_llm_skips_insight_on_high_load(self) -> None: + from hermespace import AccessEngine + from hermespace.hermes_bridge import on_pre_llm_call, on_session_start + from hermespace.store import load_desk, save_desk + + on_session_start(session_id="insight-high") + eng = AccessEngine(agent_id="hermes-agent", session_id="insight-high") + desk = load_desk(eng.desk_engine.desk_path) + desk.load = {"level": "high", "total": 0.8} + save_desk(desk, eng.desk_engine.desk_path) + + with mock.patch( + "hermespace.insight_module.insight_card", + wraps=None, + ) as mocked: + mocked.return_value = {"ok": True, "mode": "skipped", "skipped": "high_load", "card": ""} + inj = on_pre_llm_call( + user_message="First build the feature then verify please", + session_id="insight-high", + is_first_turn=False, ) - ) - self.assertFalse(out.skipped) - self.assertIn("insight", out.meta or {}) - self.assertIn((out.meta or {}).get("insight", {}).get("mode"), ("missing", "insight", "soft_fail", "skipped")) + self.assertIsNotNone(inj) + mocked.assert_called() + self.assertNotIn("### Insight", (inj or {}).get("context") or "") if __name__ == "__main__": From bd4466d12e4914fa70b4b3c3758eb0e35b58a3ab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 13:50:56 +0000 Subject: [PATCH 15/32] fix(cube): skip cube_beat on pre_llm when provider is hermescube Config-only single-pump: if Hermes memory.provider is hermescube, do not call cube_beat / center.supply / build_space_inject on pre_llm. MemoryManager already prefetched. Empty prefetch leaves no second strip. pulse_charge and sync_world_beliefs stay on idle/enter/seal. No MemoryProvider in Space. Co-authored-by: Pablo --- PURPOSE.md | 2 +- README.md | 2 +- docs/architecture/HERMESCUBE.md | 4 +- src/hermespace/cube_module.py | 85 ++++++++++----------------------- src/hermespace/hermes_bridge.py | 28 +++++++---- tests/test_access_hub.py | 74 ++++++++++++++++++++++++++-- 6 files changed, 117 insertions(+), 78 deletions(-) diff --git a/PURPOSE.md b/PURPOSE.md index ae71b15..d0002d2 100644 --- a/PURPOSE.md +++ b/PURPOSE.md @@ -91,7 +91,7 @@ World identity may remain agent-scoped; active cognition is session-scoped. | Workbench | Session mode, parked goals, last native turn | | WorldModel | Agent-scoped beliefs, landmarks, timeline | | Hermes Agent | Model calls, tools, skills, approvals, transcript | -| Optional Cube book | Durable long-tail SoT when `hermescube` is installed | +| Optional Cube book | Durable long-tail SoT when `hermescube` is installed. If Hermes `memory.provider=hermescube`, skip the `pre_llm` FOA strip — MemoryManager already prefetched. | | Optional Insight | `perceive_card` strip next to `cube_beat` on `pre_llm_call` when installed | Cube and Insight stay standalone packages. Hermespace only **cables** them diff --git a/README.md b/README.md index e46f3fd..c30ea8a 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Alongside the world, Hermespace provides a desk for the current turn — FOA, du | **Functional Access Workspace** | Harness global workspace — hold/summon concepts, silent reasoning, FOA≤4, hub≤25 | | **Focus of Attention** | ≤4 items, single active goal per turn | | **Dual Decode** | Human gets a short report; the model gets dense context. Never dump raw inject into chat channels. | -| **Cube heart (optional)** | Soft cable to HermesCube — `beat` / `seal` / `pulse`; skip/shrink when the Cube provider already prefetched; standalone warehouse when Cube absent | +| **Cube heart (optional)** | Soft cable to HermesCube — skip `cube_beat` on `pre_llm` when `memory.provider=hermescube` (MemoryManager already prefetched); `pulse_charge` / `sync_world_beliefs` still recharge World; standalone warehouse when Cube absent | | **Insight (optional)** | Soft cable next to `cube_beat` on `pre_llm_call` — `perceive_card` only, skip on high/protect; never required | | **Skills + Memory Fabric** | Ranks Hermes skills per goal; injects MEMORY.md / USER.md excerpts | | **Neural FOA** | `HERMESPACE_NEURAL_BACKEND=auto` — Ollama embeddings when live, hash fallback | diff --git a/docs/architecture/HERMESCUBE.md b/docs/architecture/HERMESCUBE.md index 6d88191..8b3b115 100644 --- a/docs/architecture/HERMESCUBE.md +++ b/docs/architecture/HERMESCUBE.md @@ -24,7 +24,7 @@ Hermes Agent ──connect──► Hermespace (J-Space · FOA · OEW) | Hive (`HERMESCUBE_HIVE`) | Peer room / soul cards | Solo room | | SemanticStore | Mirror / study | Local seal target | -## Space adapter (`hermespace.cube_module` **1.2**) +## Space adapter (`hermespace.cube_module` **1.3**) ```python from hermespace.cube_module import ( @@ -45,7 +45,7 @@ from hermespace.cube_module import ( |------|-----| | `connect_agent(agent_id)` | Session start / `HermesBase.connect` | | `ensure_heart()` | Create cube or standalone dirs | -| `cube_beat(query, seals=, load=)` | Turn / `pre_llm_call` — skip/shrink when Cube provider already prefetched | +| `cube_beat(query, seals=, load=)` | FOA strip when Cube is **not** Hermes `memory.provider`. If `memory.provider=hermescube`, **skip entirely** on `pre_llm` — MemoryManager already prefetched. Empty prefetch is fine. Do not call `center.supply` / `build_space_inject` as a last prefetch. | | `cube_pulse` / `sync_world` | Idle + connect charge | | `room_status` | Hive awareness (`HERMESCUBE_HIVE`) | | `seal_learning(text)` | Desk → durable archive | diff --git a/src/hermespace/cube_module.py b/src/hermespace/cube_module.py index 4015047..c4bccd6 100644 --- a/src/hermespace/cube_module.py +++ b/src/hermespace/cube_module.py @@ -23,7 +23,7 @@ logger = logging.getLogger("hermespace.cube_module") # Local contract version — Space adapter surface (independent of Cube package). -SPACE_CUBE_ADAPTER_VERSION = "1.2" +SPACE_CUBE_ADAPTER_VERSION = "1.3" # Load → arterial char budgets (match Cube center when present). LOAD_STRIP_CHARS: dict[str, int] = { @@ -233,10 +233,6 @@ def cube_status() -> dict[str, Any]: return heart_status() -def _truthy_env(name: str) -> bool: - return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} - - def hermes_memory_provider() -> str: """Return Hermes ``memory.provider`` when detectable (never required).""" for key in ("HERMES_MEMORY_PROVIDER", "MEMORY_PROVIDER"): @@ -244,14 +240,12 @@ def hermes_memory_provider() -> str: if raw: return raw home = os.environ.get("HERMES_HOME", "").strip() - roots = [] - if home: - roots.append(os.path.expanduser(home)) - roots.append(os.path.expanduser("~/.hermes")) + roots = [os.path.expanduser(home)] if home else [os.path.expanduser("~/.hermes")] for root in roots: cfg = os.path.join(root, "config.yaml") try: - text = open(cfg, encoding="utf-8").read() + with open(cfg, encoding="utf-8") as fh: + text = fh.read() except OSError: continue in_memory = False @@ -271,41 +265,25 @@ def hermes_memory_provider() -> str: def cube_is_memory_provider() -> bool: + """True when Hermes ``memory.provider`` is Cube. Config-only — no Cube import.""" return hermes_memory_provider() in {"hermescube", "cube"} +def skip_cube_foa_strip() -> bool: + """Skip the FOA Cube strip: Hermes MemoryManager already prefetched this turn. + + Empty prefetch is fine — skip leaves no second strip. No Cube code required. + """ + return cube_is_memory_provider() + + def cube_already_prefetched( query: str = "", *, session_id: str = "", ) -> bool: - """True when the Cube memory provider already recalled this turn's book. - - Hermes ``memory.provider=hermescube`` prefetches independently. A second - full ``cube_beat`` strip dual-pumps the same book — skip or shrink. - """ - if _truthy_env("HERMESPACE_CUBE_PREFETCHED"): - return True - if not cube_is_memory_provider() or not cube_available(): - return False - try: - import gc - - from hermescube.provider import CubeMemoryProvider - - q = (query or "").strip() - for obj in gc.get_objects(): - if not isinstance(obj, CubeMemoryProvider): - continue - last_q = str(getattr(obj, "_last_prefetch_query", "") or "") - last_ids = list(getattr(obj, "_last_prefetch_ids", None) or []) - if last_ids: - return True - if last_q and (not q or last_q[:40] in q or q[:40] in last_q): - return True - except Exception as e: - logger.debug("cube prefetch probe miss: %s", e) - return False + """Alias for ``skip_cube_foa_strip`` — config says Cube owns prefetch.""" + return skip_cube_foa_strip() def cube_beat( @@ -324,21 +302,17 @@ def cube_beat( Order: ensure → systole (seal) → diastole (supply) → optional autonomic. - When Hermes ``memory.provider=hermescube`` already prefetched this turn, - skip the full arterial strip (or shrink it) so the same book is not - dual-pumped into model context. + When Hermes ``memory.provider=hermescube``, skip the arterial FOA strip + entirely. Do not call ``center.supply`` / ``build_space_inject`` as a + last prefetch — MemoryManager already ran ``CubeMemoryProvider.prefetch``. + Seals and ``pulse_charge`` remain allowed (World projection, not FOA). """ - prefetched = bool(skip_if_prefetched and cube_already_prefetched(query, session_id=session_id)) - provider_live = cube_is_memory_provider() - skip_supply = prefetched - shrink_supply = (not skip_supply) and provider_live and cube_available() - - if skip_supply: + if skip_if_prefetched and skip_cube_foa_strip(): level = normalize_load(load, high_load=high_load) out: dict[str, Any] = { "api_version": "1.0", "adapter": SPACE_CUBE_ADAPTER_VERSION, - "mode": "center" if cube_available() else "standalone", + "mode": "skipped", "ok": True, "phases": {"diastole": {"ok": True, "skipped": "provider_prefetch", "chars": 0}}, "block": "", @@ -360,9 +334,6 @@ def cube_beat( out["phases"]["autonomic"] = cube_pulse(agent_id=agent_id) return out - beat_load: str | float | None = "protect" if shrink_supply else load - beat_high = True if shrink_supply else high_load - try: from hermescube.center import beat @@ -370,8 +341,8 @@ def cube_beat( query or "", seals=seals, entry_type=entry_type, - load=beat_load, - high_load=beat_high, + load=load, + high_load=high_load, agent_id=agent_id, charge=charge, session_id=session_id, @@ -379,18 +350,12 @@ def cube_beat( if isinstance(out, dict): out["adapter"] = SPACE_CUBE_ADAPTER_VERSION out["mode"] = "center" - if shrink_supply: - out["shrunk"] = "provider_active" - block = str(out.get("block") or "") - cap = strip_budget("protect") - if len(block) > cap: - out["block"] = block[: cap - 3] + "..." return out except Exception as e: logger.debug("center.beat miss: %s", e) # Heart 1.0 / standalone fallback - level = normalize_load(beat_load, high_load=beat_high) + level = normalize_load(load, high_load=high_load) out = { "api_version": "1.0", "adapter": SPACE_CUBE_ADAPTER_VERSION, @@ -400,8 +365,6 @@ def cube_beat( "block": "", "load_level": level, } - if shrink_supply: - out["shrunk"] = "provider_active" out["phases"]["ensure"] = ensure_heart() if seals is not None: items = [seals] if isinstance(seals, str) else list(seals) diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index bdbb16e..ef4e7c9 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -411,20 +411,32 @@ def on_pre_llm_call( # HermesCube / standalone warehouse — dense deep memory under load # Prefer center.beat (1.1); falls back to heart inject / standalone strip try: - from hermespace.cube_module import cube_beat + from hermespace.cube_module import cube_beat, skip_cube_foa_strip from hermespace.access import AccessHub q = (msg or desk.goal or "")[:500] load_val: str | float = desk.load.get("total", 0.5) if isinstance(desk.load, dict) else 0.5 if high_load: load_val = "high" - beat = cube_beat( - q, - load=load_val, - agent_id=agent_id, - session_id=sid or "hermespace", - ) - cube_block = str(beat.get("block") or "") + # Cube as Hermes memory.provider: MemoryManager already prefetched. + # Do not call cube_beat / supply / build_space_inject — that re-pumps. + if skip_cube_foa_strip(): + cube_block = "" + beat = { + "ok": True, + "mode": "skipped", + "skipped": "provider_prefetch", + "block": "", + "load_level": load_val, + } + else: + beat = cube_beat( + q, + load=load_val, + agent_id=agent_id, + session_id=sid or "hermespace", + ) + cube_block = str(beat.get("block") or "") if cube_block: block += "\n\n" + cube_block # Insight strip — next to cube_beat. perceive_card only; skip if missing. diff --git a/tests/test_access_hub.py b/tests/test_access_hub.py index 6fe8c18..a26bcf2 100644 --- a/tests/test_access_hub.py +++ b/tests/test_access_hub.py @@ -88,6 +88,9 @@ def setUp(self) -> None: self._td = tempfile.TemporaryDirectory() self.root = Path(self._td.name) os.environ["HERMESPACE_HOME"] = str(self.root) + os.environ["HERMES_HOME"] = str(self.root) + os.environ.pop("HERMES_MEMORY_PROVIDER", None) + os.environ.pop("MEMORY_PROVIDER", None) # Force standalone even if hermescube is installed in the agent env import builtins @@ -105,6 +108,9 @@ def tearDown(self) -> None: self._imp.stop() self._td.cleanup() os.environ.pop("HERMESPACE_HOME", None) + os.environ.pop("HERMES_HOME", None) + os.environ.pop("HERMES_MEMORY_PROVIDER", None) + os.environ.pop("MEMORY_PROVIDER", None) def test_ensure_and_status(self) -> None: from hermespace.cube_module import center_status, ensure_heart, heart_status @@ -136,17 +142,39 @@ def test_seal_and_inject(self) -> None: self.assertIn("block", beat) self.assertEqual(beat.get("mode"), "standalone") - def test_prefetch_skip_does_not_inject_strip(self) -> None: - os.environ["HERMESPACE_CUBE_PREFETCHED"] = "1" + def test_provider_skip_does_not_inject_strip(self) -> None: + os.environ["HERMES_MEMORY_PROVIDER"] = "hermescube" try: - from hermespace.cube_module import cube_already_prefetched, cube_beat + from hermespace.cube_module import ( + cube_already_prefetched, + cube_beat, + skip_cube_foa_strip, + ) + self.assertTrue(skip_cube_foa_strip()) self.assertTrue(cube_already_prefetched("deploy")) - beat = cube_beat("deploy", load="mid", agent_id="prefetch-agent") + with mock.patch("hermespace.cube_module.cube_inject") as inj: + beat = cube_beat("deploy", load="mid", agent_id="prefetch-agent") + inj.assert_not_called() self.assertEqual(beat.get("skipped"), "provider_prefetch") self.assertEqual(beat.get("block"), "") + self.assertEqual(beat.get("mode"), "skipped") + finally: + os.environ.pop("HERMES_MEMORY_PROVIDER", None) + + def test_provider_from_hermes_config_yaml(self) -> None: + home = Path(self._td.name) + (home / "config.yaml").write_text("memory:\n provider: hermescube\n", encoding="utf-8") + os.environ["HERMES_HOME"] = str(home) + os.environ.pop("HERMES_MEMORY_PROVIDER", None) + os.environ.pop("MEMORY_PROVIDER", None) + try: + from hermespace.cube_module import hermes_memory_provider, skip_cube_foa_strip + + self.assertEqual(hermes_memory_provider(), "hermescube") + self.assertTrue(skip_cube_foa_strip()) finally: - os.environ.pop("HERMESPACE_CUBE_PREFETCHED", None) + os.environ.pop("HERMES_HOME", None) def test_strip_budget(self) -> None: from hermespace.cube_module import normalize_load, strip_budget @@ -222,5 +250,41 @@ def test_turn_includes_access_meta(self) -> None: self.assertTrue(out.context) +class TestPreLlmCubeSinglePump(unittest.TestCase): + def setUp(self) -> None: + self._td = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self._td.name + os.environ["HERMES_HOME"] = self._td.name + os.environ["HERMESPACE_NEURAL_VERBALIZE"] = "0" + os.environ["HERMESPACE_AUTO_ORDER"] = "0" + os.environ["HERMES_MEMORY_PROVIDER"] = "hermescube" + + def tearDown(self) -> None: + self._td.cleanup() + for key in ( + "HERMESPACE_HOME", + "HERMES_HOME", + "HERMESPACE_NEURAL_VERBALIZE", + "HERMESPACE_AUTO_ORDER", + "HERMES_MEMORY_PROVIDER", + ): + os.environ.pop(key, None) + + def test_pre_llm_does_not_call_cube_beat(self) -> None: + from hermespace.hermes_bridge import on_pre_llm_call, on_session_start + + on_session_start(session_id="cube-single-pump") + with mock.patch("hermespace.cube_module.cube_beat") as beat: + inj = on_pre_llm_call( + user_message="First build the feature then verify please", + session_id="cube-single-pump", + is_first_turn=False, + ) + beat.assert_not_called() + self.assertIsNotNone(inj) + ctx = (inj or {}).get("context") or "" + self.assertNotIn("### Cube", ctx) + + if __name__ == "__main__": unittest.main() From 09b244ff3f81aaacdafa24c38f532362bfe33aa1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 13:52:57 +0000 Subject: [PATCH 16/32] fix(cube): keep cube_beat when Hermes config is unreadable Fail-soft: missing or corrupt memory.provider config means provider off, so pre_llm still runs cube_beat. Skip only when provider is confirmed hermescube. Insight still never calls recall / perceive / insight_beat. Co-authored-by: Pablo --- docs/architecture/INSIGHT.md | 2 +- src/hermespace/cube_module.py | 73 +++++++++++++++++++------------- src/hermespace/insight_module.py | 9 ++-- tests/test_access_hub.py | 24 +++++++++++ tests/test_insight_module.py | 8 +++- 5 files changed, 80 insertions(+), 36 deletions(-) diff --git a/docs/architecture/INSIGHT.md b/docs/architecture/INSIGHT.md index fd35e55..0a444a3 100644 --- a/docs/architecture/INSIGHT.md +++ b/docs/architecture/INSIGHT.md @@ -17,7 +17,7 @@ if hasattr(HermesInsight, "perceive_card"): | Missing / no `perceive_card` | Skip — do **not** format `perceive()["card"]` (unbounded lattice) | | High / protect load | Skip entirely (stricter than Cube) | | Hot path | Append only the returned card, capped at 400 chars | -| `insight_plan` / `.plan` | Never on `pre_llm_call` | +| `insight_plan` / `.plan` / `recall` / `insight_beat` | Never on `pre_llm_call` | | Required? | Never — `except Exception: pass` like `cube_beat` | Companion: [PabloTheThinker/hermes-insight](https://github.com/PabloTheThinker/hermes-insight) diff --git a/src/hermespace/cube_module.py b/src/hermespace/cube_module.py index c4bccd6..1b52bed 100644 --- a/src/hermespace/cube_module.py +++ b/src/hermespace/cube_module.py @@ -234,47 +234,60 @@ def cube_status() -> dict[str, Any]: def hermes_memory_provider() -> str: - """Return Hermes ``memory.provider`` when detectable (never required).""" - for key in ("HERMES_MEMORY_PROVIDER", "MEMORY_PROVIDER"): - raw = os.environ.get(key, "").strip().lower() - if raw: - return raw - home = os.environ.get("HERMES_HOME", "").strip() - roots = [os.path.expanduser(home)] if home else [os.path.expanduser("~/.hermes")] - for root in roots: - cfg = os.path.join(root, "config.yaml") - try: - with open(cfg, encoding="utf-8") as fh: - text = fh.read() - except OSError: - continue - in_memory = False - for line in text.splitlines(): - raw = line.split("#", 1)[0] - if raw.strip().startswith("memory:") or raw.strip() == "memory:": - in_memory = True - continue - if in_memory and raw and not raw[:1].isspace() and not raw.startswith("\t"): - in_memory = False - if not in_memory: + """Return Hermes ``memory.provider`` when detectable (never required). + + Fail-soft: missing or unreadable config → empty string (provider off). + """ + try: + for key in ("HERMES_MEMORY_PROVIDER", "MEMORY_PROVIDER"): + raw = os.environ.get(key, "").strip().lower() + if raw: + return raw + home = os.environ.get("HERMES_HOME", "").strip() + roots = [os.path.expanduser(home)] if home else [os.path.expanduser("~/.hermes")] + for root in roots: + cfg = os.path.join(root, "config.yaml") + try: + with open(cfg, encoding="utf-8") as fh: + text = fh.read() + except OSError: continue - stripped = raw.strip() - if stripped.startswith("provider:"): - return stripped.split(":", 1)[1].strip().strip("\"'").lower() - return "" + in_memory = False + for line in text.splitlines(): + raw = line.split("#", 1)[0] + if raw.strip().startswith("memory:") or raw.strip() == "memory:": + in_memory = True + continue + if in_memory and raw and not raw[:1].isspace() and not raw.startswith("\t"): + in_memory = False + if not in_memory: + continue + stripped = raw.strip() + if stripped.startswith("provider:"): + return stripped.split(":", 1)[1].strip().strip("\"'").lower() + return "" + except Exception: + return "" def cube_is_memory_provider() -> bool: """True when Hermes ``memory.provider`` is Cube. Config-only — no Cube import.""" - return hermes_memory_provider() in {"hermescube", "cube"} + try: + return hermes_memory_provider() in {"hermescube", "cube"} + except Exception: + return False def skip_cube_foa_strip() -> bool: - """Skip the FOA Cube strip: Hermes MemoryManager already prefetched this turn. + """Skip the FOA Cube strip only when Cube is confirmed as memory.provider. + Unreadable / missing config → False (provider off) so ``cube_beat`` still runs. Empty prefetch is fine — skip leaves no second strip. No Cube code required. """ - return cube_is_memory_provider() + try: + return cube_is_memory_provider() + except Exception: + return False def cube_already_prefetched( diff --git a/src/hermespace/insight_module.py b/src/hermespace/insight_module.py index ef12c99..19be3fc 100644 --- a/src/hermespace/insight_module.py +++ b/src/hermespace/insight_module.py @@ -8,9 +8,9 @@ Hang the returned card next to ``cube_beat`` on ``pre_llm_call``. Skip entirely on high/protect load. Until ``perceive_card`` exists, skip — -do not format ``perceive()`` output (unbounded lattice). Do not call -``insight_plan`` / ``HermesInsight.plan`` on the hot path. Do not register -Insight hooks. Do not vendor Insight source. +do not format ``perceive()`` output or ``recall()["brief"]``. Do not call +``recall``, ``insight_plan``, ``insight_beat``, or ``HermesInsight.plan`` +on the hot path. Do not register Insight hooks. Do not vendor Insight source. See docs/architecture/INSIGHT.md. """ @@ -85,7 +85,8 @@ def insight_card( """Call ``HermesInsight().perceive_card`` and return only that card. Soft-fail if Insight is absent or ``perceive_card`` is missing. - Never calls ``perceive`` / ``plan``. Never formats a lattice dump. + Never calls ``recall`` / ``perceive`` / ``plan`` / ``insight_beat``. + Never formats a lattice dump. """ out: dict[str, Any] = { "ok": True, diff --git a/tests/test_access_hub.py b/tests/test_access_hub.py index a26bcf2..54c41b2 100644 --- a/tests/test_access_hub.py +++ b/tests/test_access_hub.py @@ -162,6 +162,30 @@ def test_provider_skip_does_not_inject_strip(self) -> None: finally: os.environ.pop("HERMES_MEMORY_PROVIDER", None) + def test_unreadable_config_keeps_cube_beat(self) -> None: + os.environ.pop("HERMES_MEMORY_PROVIDER", None) + os.environ.pop("MEMORY_PROVIDER", None) + os.environ["HERMES_HOME"] = str(self.root / "missing-hermes-home") + from hermespace.cube_module import cube_beat, hermes_memory_provider, skip_cube_foa_strip + + self.assertEqual(hermes_memory_provider(), "") + self.assertFalse(skip_cube_foa_strip()) + beat = cube_beat("deploy", load="mid", agent_id="config-miss-agent") + self.assertNotEqual(beat.get("skipped"), "provider_prefetch") + self.assertIn("block", beat) + + def test_corrupt_config_keeps_cube_beat(self) -> None: + (self.root / "config.yaml").write_bytes(b"\xff\xfe not-utf8 \x00memory:\n provider: hermescube\n") + os.environ["HERMES_HOME"] = str(self.root) + os.environ.pop("HERMES_MEMORY_PROVIDER", None) + os.environ.pop("MEMORY_PROVIDER", None) + from hermespace.cube_module import cube_beat, skip_cube_foa_strip + + self.assertFalse(skip_cube_foa_strip()) + beat = cube_beat("deploy", load="mid", agent_id="config-bad-agent") + self.assertNotEqual(beat.get("skipped"), "provider_prefetch") + self.assertIn("block", beat) + def test_provider_from_hermes_config_yaml(self) -> None: home = Path(self._td.name) (home / "config.yaml").write_text("memory:\n provider: hermescube\n", encoding="utf-8") diff --git a/tests/test_insight_module.py b/tests/test_insight_module.py index f4f09f5..18dc95c 100644 --- a/tests/test_insight_module.py +++ b/tests/test_insight_module.py @@ -99,7 +99,7 @@ class TestInsightPerceiveCard(unittest.TestCase): def setUp(self) -> None: self._td = tempfile.TemporaryDirectory() os.environ["HERMESPACE_HOME"] = self._td.name - self._calls = {"perceive_card": 0, "perceive": 0, "plan": 0} + self._calls = {"perceive_card": 0, "perceive": 0, "plan": 0, "recall": 0} self._last_load = None outer = self @@ -124,6 +124,10 @@ def plan(self, situation, **kwargs): outer._calls["plan"] += 1 return {"steps": [{"title": "should-not-plan"}]} + def recall(self, *a, **k): + outer._calls["recall"] = outer._calls.get("recall", 0) + 1 + return {"brief": "SHOULD-NOT-INJECT-RECALL-BRIEF"} + pkg = types.ModuleType("hermes_insight") pkg.HermesInsight = _Cls # type: ignore[attr-defined] pkg.__version__ = "0.9.0-test" @@ -147,7 +151,9 @@ def test_appends_bounded_perceive_card_only(self) -> None: self.assertEqual(self._calls["perceive_card"], 1) self.assertEqual(self._calls["perceive"], 0) self.assertEqual(self._calls["plan"], 0) + self.assertEqual(self._calls["recall"], 0) self.assertEqual(self._last_load, "mid") + self.assertNotIn("SHOULD-NOT-INJECT", rec.get("card") or "") def test_high_and_protect_skip(self) -> None: from hermespace.insight_module import insight_card From 23e64ec8a9b02510fe963056ec61ec900e34bb47 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 14:05:18 +0000 Subject: [PATCH 17/32] feat(execute): AuDHD one live goal, named park, action-lead report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steal execute/focus shapes from hermes-audhd-skills without vendoring it. One live goal; park others as Name — state — next crumb. Report line 1 is the next action; lists cap at 5; never quiz as the lead. Fabric-hint audhd-* SKILL.md when present; skip emotion and skip if missing. Co-authored-by: Pablo --- src/hermespace/access/engine.py | 21 +++- src/hermespace/execute_focus.py | 181 ++++++++++++++++++++++++++++++++ src/hermespace/hermes_bridge.py | 16 +++ src/hermespace/workbench.py | 53 ++++++++-- src/hermespace/workflow.py | 44 ++++++++ tests/test_execute_focus.py | 78 ++++++++++++++ 6 files changed, 386 insertions(+), 7 deletions(-) create mode 100644 src/hermespace/execute_focus.py create mode 100644 tests/test_execute_focus.py diff --git a/src/hermespace/access/engine.py b/src/hermespace/access/engine.py index 62ffeaf..4359df5 100644 --- a/src/hermespace/access/engine.py +++ b/src/hermespace/access/engine.py @@ -458,7 +458,26 @@ def audit(self) -> list[dict[str, Any]]: return [f.to_dict() for f in self.env.audit()] def report(self, *, include_silent: bool = False) -> str: - return self.hub.report(include_silent=include_silent) + hub = self.hub.report(include_silent=include_silent) + try: + from hermespace.execute_focus import execute_report_block + from hermespace.workbench import Workbench + + desk = self.desk + parked = Workbench( + agent_id=self.agent_id, + session_id=self.session_id, + ).state.park + lead = execute_report_block( + goal=desk.goal, + plan=list(desk.plan or []), + say=desk.say, + decision=desk.decision, + parked=parked, + ) + return f"{lead}\n\n{hub}".strip() + except Exception: + return hub def broadcast(self, *, high_load: bool = False) -> str: return self.env.filtered_broadcast(high_load=high_load) diff --git a/src/hermespace/execute_focus.py b/src/hermespace/execute_focus.py new file mode 100644 index 0000000..d5b2029 --- /dev/null +++ b/src/hermespace/execute_focus.py @@ -0,0 +1,181 @@ +"""AuDHD execute/focus shapes for Access Engine report + park stack. + +Stolen from hermes-audhd-skills (standalone repo) — shapes only: + + one live goal + named parking lot: ``Name — state — next crumb`` + report line 1 = next action; lists ≤5; never quiz/restate as the lead + +Do not vendor that skill tree. Do not hint ``audhd-emotion`` into a generic +ops profile. Public reference: +https://github.com/PabloTheThinker/hermes-audhd-skills +""" + +from __future__ import annotations + +import re +from typing import Any, Sequence + +PARK_SEP = " — " +LIST_CAP = 5 +AUDHD_HINT_NAMES = ( + "audhd-core", + "audhd-execute", + "audhd-focus", + "audhd-communicate", + "audhd-integrate", +) + +_QUIZ_LEAD = re.compile( + r"^\s*(" + r"remember when|as i (said|mentioned)|we should think|" + r"what do you (think|want|remember)|did you want|" + r"just to recap|as mentioned earlier" + r")\b", + re.IGNORECASE, +) +_LIST_LINE = re.compile(r"^\s*(?:[-*]|\d+[.)])\s+\S") + + +def short_name(goal: str, *, cap: int = 40) -> str: + g = " ".join((goal or "").split()) + if not g: + return "untitled" + return g if len(g) <= cap else g[: cap - 1].rstrip() + "…" + + +def format_park_line(item: dict[str, Any] | str) -> str: + """Named parking lot: Name — state — next crumb.""" + if isinstance(item, str): + return f"{short_name(item)}{PARK_SEP}parked{PARK_SEP}resume" + name = str(item.get("name") or short_name(str(item.get("goal") or ""))).strip() + state = str(item.get("state") or "parked").strip() or "parked" + crumb = str( + item.get("next_crumb") or item.get("note") or "resume" + ).strip() or "resume" + return f"{name}{PARK_SEP}{state}{PARK_SEP}{crumb}" + + +def park_record( + goal: str, + *, + name: str = "", + state: str = "parked", + next_crumb: str = "", + note: str = "", +) -> dict[str, Any]: + g = (goal or "").strip() + return { + "goal": g, + "name": (name or short_name(g)).strip(), + "state": (state or "parked").strip(), + "next_crumb": (next_crumb or note or "resume").strip(), + "note": (note or next_crumb or "").strip(), + } + + +def next_action_line( + *, + goal: str = "", + plan: Sequence[str] | None = None, + say: str = "", + decision: str = "", +) -> str: + for step in plan or []: + s = str(step or "").strip() + if s: + return s[:160] + for raw in (say or "").splitlines(): + s = raw.strip().lstrip("-* ").lstrip("0123456789.) ") + if s and not _QUIZ_LEAD.match(s) and not s.endswith("?"): + return s[:160] + dec = (decision or "").strip() + if dec and not dec.lower().startswith("a — proceed"): + return dec[:160] + g = (goal or "").strip() + if g: + return f"Do the next step on: {short_name(g, cap=80)}" + return "Name the first action (under 2 minutes)." + + +def _cap_lists(text: str, *, cap: int = LIST_CAP) -> str: + out: list[str] = [] + listed = 0 + for line in (text or "").splitlines(): + if _LIST_LINE.match(line): + listed += 1 + if listed > cap: + continue + out.append(line) + return "\n".join(out).strip() + + +def shape_execute_report( + report: str, + *, + goal: str = "", + plan: Sequence[str] | None = None, + say: str = "", + decision: str = "", +) -> str: + """Line 1 = next action. Lists ≤5. Never quiz/restate as the lead.""" + body = _cap_lists((report or "").strip()) + lead = next_action_line(goal=goal, plan=plan, say=say or body, decision=decision) + if not body: + return lead + first = body.splitlines()[0] + if _QUIZ_LEAD.match(first) or first.strip().endswith("?"): + rest = "\n".join(body.splitlines()[1:]).strip() + return f"{lead}\n{rest}".strip() if rest else lead + if first.strip() != lead and not _LIST_LINE.match(first): + # Keep an existing action lead; only prepend when the body starts as a list. + return body + if _LIST_LINE.match(first): + return f"{lead}\n{body}".strip() + return body + + +def execute_report_block( + *, + goal: str = "", + plan: Sequence[str] | None = None, + say: str = "", + decision: str = "", + parked: Sequence[dict[str, Any] | str] | None = None, +) -> str: + lines = [next_action_line(goal=goal, plan=plan, say=say, decision=decision)] + if goal: + lines.append(f"Live: {short_name(goal, cap=80)}") + items = list(parked or [])[:LIST_CAP] + if items: + lines.append("Parked:") + for item in items: + lines.append(f"- {format_park_line(item)}") + return "\n".join(lines) + + +def audhd_skill_hints(*, hermes_home: Any = None) -> list[str]: + """Fabric-hint audhd-* SKILL.md files when present. Skip emotion. Skip if missing.""" + from pathlib import Path + + from hermespace.environment import hermes_home as _hh + + root = Path(hermes_home) if hermes_home else _hh() + skills = root / "skills" + if not skills.is_dir(): + return [] + found: list[str] = [] + try: + for p in skills.rglob("SKILL.md"): + name = p.parent.name + if name == "audhd-emotion" or "emotion" in name: + continue + if name.startswith("audhd-") and name in AUDHD_HINT_NAMES: + found.append(name) + except OSError: + return [] + out: list[str] = [] + for name in AUDHD_HINT_NAMES: + if name in found: + out.append(f"[exec|0.70] skill_hint:{name} (use skill_view name={name})") + return out[:LIST_CAP] diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index ef4e7c9..34477ec 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -84,6 +84,14 @@ def on_session_start(**kwargs: Any) -> dict[str, str] | None: for hint in skill_load_hints(fab.skill_hits): if hint not in desk.concepts: desk.concepts.append(hint) + try: + from hermespace.execute_focus import audhd_skill_hints + + for hint in audhd_skill_hints(): + if hint not in desk.concepts: + desk.concepts.append(hint) + except Exception: + pass desk.concepts = desk.concepts[-12:] save_desk(desk, eng.desk_path) desk = load_desk(eng.desk_path) @@ -354,6 +362,14 @@ def on_pre_llm_call( for hint in skill_load_hints(fab.skill_hits): if hint not in desk.concepts: desk.concepts.append(hint) + try: + from hermespace.execute_focus import audhd_skill_hints + + for hint in audhd_skill_hints(): + if hint not in desk.concepts: + desk.concepts.append(hint) + except Exception: + pass desk.concepts = desk.concepts[-12:] save_desk(desk, eng.desk_path) desk = load_desk(eng.desk_path) diff --git a/src/hermespace/workbench.py b/src/hermespace/workbench.py index a7c262a..ef6852e 100644 --- a/src/hermespace/workbench.py +++ b/src/hermespace/workbench.py @@ -42,6 +42,9 @@ def _utcnow() -> str: class ParkedGoal: goal: str note: str = "" + name: str = "" + state: str = "parked" + next_crumb: str = "" parked_at: str = field(default_factory=_utcnow) tags: list[str] = field(default_factory=list) @@ -221,17 +224,38 @@ def enter(self, *, connect_warehouse: bool = True) -> dict[str, Any]: st["room"] = (self.state.meta.get("connect") or {}).get("room_mode") return st - def park_goal(self, goal: str, note: str = "", tags: list[str] | None = None) -> dict[str, Any]: - """Park a goal while staying monotropic on current work / idle.""" + def park_goal( + self, + goal: str, + note: str = "", + tags: list[str] | None = None, + *, + name: str = "", + state: str = "parked", + next_crumb: str = "", + ) -> dict[str, Any]: + """Park a goal while staying monotropic on current work / idle. + + Named lot format: ``Name — state — next crumb``. + """ + from hermespace.execute_focus import format_park_line, park_record + g = (goal or "").strip() if not g: return self.status() - self.state.park = [p for p in self.state.park if p.get("goal") != g] - self.state.park.append( - asdict(ParkedGoal(goal=g, note=note or "", tags=list(tags or []))) + rec = park_record( + g, + name=name, + state=state, + next_crumb=next_crumb or note, + note=note, ) - # keep park bounded + rec["tags"] = list(tags or []) + rec["parked_at"] = _utcnow() + self.state.park = [p for p in self.state.park if p.get("goal") != g] + self.state.park.append(rec) self.state.park = self.state.park[-20:] + self.state.meta["last_park_line"] = format_park_line(rec) self.save() return self.status() @@ -317,6 +341,17 @@ def receive_order( """Order arrives → leave idle, run Hermespace turn, return dual decode.""" msg = (message or "").strip() g = (goal or "").strip() + try: + live = str((self.workflow.status() or {}).get("goal") or "").strip() + if live and g and live != g: + self.park_goal( + live, + note="switched", + state="parked", + next_crumb="resume when this tunnel yields", + ) + except Exception: + pass if not g and use_parked_if_empty_goal and self.state.park: parked = self.pop_park() if parked: @@ -381,6 +416,11 @@ def receive_order( "reason": out.reason, } + def park_lines(self) -> list[str]: + from hermespace.execute_focus import format_park_line + + return [format_park_line(p) for p in self.state.park[-5:]] + def environment(self) -> dict[str, Any]: """Full pocket-dimension tool/memory/skills inventory.""" rep = probe_environment() @@ -398,6 +438,7 @@ def status(self) -> dict[str, Any]: "mode": self.state.mode, "park_count": len(self.state.park), "park": self.state.park[-5:], + "park_lines": self.park_lines(), "last_order": self.state.last_order[:160], "last_report": self.state.last_report[:200], "last_turn_id": self.state.last_turn_id, diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index 1850c37..5ef77ee 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -95,6 +95,29 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: cons = payload.concepts or existing.concepts ch = payload.choices or existing.choices or ["A — proceed"] + if ( + existing.goal + and payload.goal + and existing.goal.strip() + and payload.goal.strip() + and existing.goal.strip() != payload.goal.strip() + ): + try: + from hermespace.workbench import Workbench + + Workbench( + agent_id=payload.agent_id or "hermes-agent", + session_id=payload.session_id or "default", + workflow=self, + ).park_goal( + existing.goal, + note="one live goal", + state="parked", + next_crumb="resume when this tunnel yields", + ) + except Exception: + pass + if payload.force or not existing.goal or payload.goal: desk = self.engine.enter( goal=g, @@ -136,6 +159,14 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: for hint in skill_load_hints(fab.skill_hits): if hint not in desk.concepts: desk.concepts.append(hint) + try: + from hermespace.execute_focus import audhd_skill_hints + + for hint in audhd_skill_hints(): + if hint not in desk.concepts: + desk.concepts.append(hint) + except Exception: + pass desk.concepts = desk.concepts[-12:] save_desk(desk, self.engine.desk_path) except Exception as exc: @@ -259,6 +290,19 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: report = env.shape_user_report(report) except Exception: pass + try: + from hermespace.execute_focus import shape_execute_report + + report = shape_execute_report( + report, + goal=desk.goal, + plan=list(desk.plan or []), + say=desk.say or report, + decision=desk.decision, + ) + desk.say = report + except Exception: + pass if payload.seal: self.engine.seal(payload.seal_note or f"turn seal: {desk.decision[:120]}") diff --git a/tests/test_execute_focus.py b/tests/test_execute_focus.py new file mode 100644 index 0000000..7b091df --- /dev/null +++ b/tests/test_execute_focus.py @@ -0,0 +1,78 @@ +"""AuDHD execute/focus shapes — one live goal, named park, report lead.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + + +class TestExecuteFocusShapes(unittest.TestCase): + def test_park_line_format(self) -> None: + from hermespace.execute_focus import format_park_line, park_record + + rec = park_record("Ship the auth fix", state="waiting", next_crumb="reopen PR") + line = format_park_line(rec) + self.assertEqual(line, "Ship the auth fix — waiting — reopen PR") + + def test_report_lead_is_next_action_not_quiz(self) -> None: + from hermespace.execute_focus import shape_execute_report + + out = shape_execute_report( + "Remember when we talked about auth?\nAlso docs.", + goal="Fix auth", + plan=["Run the failing test", "Patch TTL"], + ) + self.assertTrue(out.startswith("Run the failing test")) + self.assertNotIn("Remember when", out.splitlines()[0]) + + def test_lists_capped_at_five(self) -> None: + from hermespace.execute_focus import shape_execute_report + + body = "\n".join(f"{i}) step {i}" for i in range(1, 9)) + out = shape_execute_report(body, plan=["Start the first step"]) + listed = [ln for ln in out.splitlines() if ln[:1].isdigit()] + self.assertLessEqual(len(listed), 5) + + def test_skips_missing_and_emotion_skills(self) -> None: + from hermespace.execute_focus import audhd_skill_hints + + with tempfile.TemporaryDirectory() as td: + root = Path(td) + skills = root / "skills" + (skills / "audhd-execute").mkdir(parents=True) + (skills / "audhd-execute" / "SKILL.md").write_text("# execute\n", encoding="utf-8") + (skills / "audhd-emotion").mkdir(parents=True) + (skills / "audhd-emotion" / "SKILL.md").write_text("# emotion\n", encoding="utf-8") + hints = audhd_skill_hints(hermes_home=root) + joined = " ".join(hints) + self.assertIn("audhd-execute", joined) + self.assertNotIn("emotion", joined) + self.assertEqual(audhd_skill_hints(hermes_home=root / "missing"), []) + + def test_one_live_goal_parks_the_previous(self) -> None: + from hermespace.engine import HermespaceEngine + from hermespace.memory_db import HermespaceMemory + from hermespace.workbench import Workbench + from hermespace.workflow import Workflow + + with tempfile.TemporaryDirectory() as td: + os.environ["HERMESPACE_HOME"] = td + root = Path(td) + wf = Workflow( + HermespaceEngine(desk_path=root / "ACTIVE.md"), + HermespaceMemory(root=root), + ) + wf.neural.config.verbalize = False + wb = Workbench("focus-agent", session_id="s1", workflow=wf, root=root / "wb") + wb.receive_order("First tunnel", goal="Fix auth", say="Patch TTL.", force=True) + wb.receive_order("Second tunnel", goal="Write docs", say="Open README.", force=True) + lines = wb.park_lines() + self.assertTrue(any("Fix auth" in ln and " — " in ln for ln in lines)) + self.assertEqual((wb.workflow.status() or {}).get("goal"), "Write docs") + os.environ.pop("HERMESPACE_HOME", None) + + +if __name__ == "__main__": + unittest.main() From a843ba4160165b9ec6eb3a5fa0b2cad0f229b329 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 14:09:21 +0000 Subject: [PATCH 18/32] feat(ops): doctor FAIL plus union plugins.enabled Steal grokbot doctor/enable shape only. Doctor fails when the plugin, skill, or plugins.enabled entry is missing. Install appends hermespace and never replaces Cube, Insight, or grokbot. Co-authored-by: Pablo --- scripts/e2e_ops.sh | 9 ++ scripts/install_hermes.sh | 19 ++-- src/hermespace/hermes_enable.py | 190 ++++++++++++++++++++++++++++++++ src/hermespace/ops.py | 28 ++++- tests/test_hermes_enable.py | 154 ++++++++++++++++++++++++++ 5 files changed, 386 insertions(+), 14 deletions(-) create mode 100644 src/hermespace/hermes_enable.py create mode 100644 tests/test_hermes_enable.py diff --git a/scripts/e2e_ops.sh b/scripts/e2e_ops.sh index b435339..e28059a 100755 --- a/scripts/e2e_ops.sh +++ b/scripts/e2e_ops.sh @@ -8,6 +8,14 @@ source "$(dirname "$0")/_python.sh" export HERMESPACE_HOME="${HERMESPACE_HOME:-$(mktemp -d /tmp/hs-e2e-XXXX)}" export HERMESPACE_AUTONOMY=0 export HERMESPACE_ROOT="$ROOT" +# Isolate HERMES_HOME so doctor can FAIL/PASS without touching ~/.hermes. +# Seed plugin + skill + plugins.enabled (union, not a rewrite). +export HERMES_HOME="$(mktemp -d /tmp/hs-e2e-hermes-XXXX)" +mkdir -p "$HERMES_HOME/plugins" "$HERMES_HOME/skills" +ln -sfn "$ROOT" "$HERMES_HOME/plugins/hermespace" +ln -sfn "$ROOT/skills/hermespace" "$HERMES_HOME/skills/hermespace" +HERMES_HOME="$HERMES_HOME" "$PYTHON" -c \ + "from hermespace.hermes_enable import union_plugins_enabled; print(union_plugins_enabled())" HS=("$PYTHON" -m hermespace.cli) ec=0 pass() { echo "PASS $*"; } @@ -15,6 +23,7 @@ fail() { echo "FAIL $*"; ec=1; } echo "=== Hermespace E2E ops ===" echo "HERMESPACE_HOME=$HERMESPACE_HOME" +echo "HERMES_HOME=$HERMES_HOME" "${HS[@]}" ops boot --agent-id default >/tmp/hs-e2e-boot.json || fail "ops boot" "$PYTHON" - <<'PY' || fail "boot json" diff --git a/scripts/install_hermes.sh b/scripts/install_hermes.sh index 5743e2e..df340d5 100755 --- a/scripts/install_hermes.sh +++ b/scripts/install_hermes.sh @@ -68,15 +68,18 @@ else echo " desktop plugin skipped" fi -if command -v hermes >/dev/null 2>&1; then - if [[ "$ENABLE_PLUGIN" == "1" ]]; then - hermes plugins enable hermespace - echo " Hermes plugin enabled" - else - echo " enable later: hermes plugins enable hermespace" - fi +# Union plugins.enabled — append hermespace, never replace Cube/Insight/grokbot. +# Do not call `hermes plugins enable` here; that CLI may rewrite the list. +if [[ "$ENABLE_PLUGIN" == "1" ]]; then + HERMES_HOME="$HERMES_HOME" "$PYTHON" - <<'PY' +from hermespace.hermes_enable import union_plugins_enabled +out = union_plugins_enabled("hermespace") +print(" plugins.enabled", out.get("action"), out.get("enabled")) +if not out.get("ok"): + raise SystemExit("union_plugins_enabled failed: " + str(out)) +PY else - echo " Hermes CLI not on PATH — source install is ready; enable later" + echo " enable later: PYTHONPATH=src python -c 'from hermespace.hermes_enable import union_plugins_enabled; print(union_plugins_enabled())'" fi HERMES_HOME="$HERMES_HOME" HERMESPACE_HOME="$HERMESPACE_HOME" \ diff --git a/src/hermespace/hermes_enable.py b/src/hermespace/hermes_enable.py new file mode 100644 index 0000000..2c693c1 --- /dev/null +++ b/src/hermespace/hermes_enable.py @@ -0,0 +1,190 @@ +"""Union ``plugins.enabled`` the grokbot way — append, never replace. + +Stolen from hermes-grokbot enable.py (standalone repo): read the existing +list, append this plugin if missing, write only that addition. Cube, +Insight, and grokbot may already be there. + +Do not vendor grokbot. Do not dump-rewrite the YAML document. Public +reference: https://github.com/PabloTheThinker/hermes-grokbot +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from hermespace.environment import hermes_home as default_hermes_home + + +def config_path(home: Path | None = None) -> Path: + return (home or default_hermes_home()) / "config.yaml" + + +def read_plugins_enabled(config: Path | None = None) -> list[str]: + """Best-effort parse of plugins.enabled. Empty if missing/unreadable.""" + path = Path(config) if config else config_path() + try: + text = path.read_text(encoding="utf-8") + except OSError: + return [] + enabled: list[str] = [] + in_plugins = False + in_enabled = False + enabled_indent: int | None = None + for line in text.splitlines(): + raw = line.split("#", 1)[0] + if not raw.strip(): + continue + indent = len(raw) - len(raw.lstrip(" \t")) + stripped = raw.strip() + if stripped == "plugins:" or stripped.startswith("plugins:"): + in_plugins = True + in_enabled = False + continue + if in_plugins and indent == 0 and not stripped.startswith("-"): + in_plugins = False + in_enabled = False + if not in_plugins: + continue + if stripped == "enabled:" or stripped.startswith("enabled:"): + in_enabled = True + enabled_indent = indent + inline = stripped.split(":", 1)[1].strip() + if inline.startswith("[") and inline.endswith("]"): + inner = inline[1:-1].strip() + if inner: + enabled.extend( + p.strip().strip("\"'") for p in inner.split(",") if p.strip() + ) + continue + if in_enabled: + if enabled_indent is not None and indent <= enabled_indent and not stripped.startswith("-"): + in_enabled = False + continue + if stripped.startswith("-"): + item = stripped[1:].strip().strip("\"'") + if item: + enabled.append(item) + return enabled + + +def _split_inline_list(inline: str) -> list[str]: + inner = inline.strip() + if inner.startswith("[") and inner.endswith("]"): + inner = inner[1:-1].strip() + if not inner: + return [] + return [p.strip().strip("\"'") for p in inner.split(",") if p.strip()] + + +def _append_enabled_item(text: str, name: str) -> str: + lines = text.splitlines() + in_plugins = False + in_enabled = False + enabled_indent: int | None = None + last_item_idx: int | None = None + plugins_idx: int | None = None + enabled_idx: int | None = None + for i, line in enumerate(lines): + raw = line.split("#", 1)[0] + if not raw.strip(): + continue + indent = len(raw) - len(raw.lstrip(" \t")) + stripped = raw.strip() + if stripped == "plugins:" or stripped.startswith("plugins:"): + in_plugins = True + in_enabled = False + plugins_idx = i + continue + if in_plugins and indent == 0 and not stripped.startswith("-"): + in_plugins = False + in_enabled = False + if not in_plugins: + continue + if stripped == "enabled:" or stripped.startswith("enabled:"): + in_enabled = True + enabled_indent = indent + enabled_idx = i + inline = stripped.split(":", 1)[1].strip() + if inline.startswith("[") and inline.endswith("]"): + items = _split_inline_list(inline) + if name not in items: + items.append(name) + pad = line[: len(line) - len(line.lstrip(" \t"))] + comment = "" + if "#" in line[line.find(stripped) + len(stripped.split(":")[0]) :]: + hash_at = line.find("#", indent) + if hash_at != -1: + comment = line[hash_at:] + if comment and not comment.startswith(" "): + comment = " " + comment + lines[i] = f"{pad}enabled: [{', '.join(items)}]{comment}" + return "\n".join(lines) + ("\n" if text.endswith("\n") else "") + continue + if in_enabled: + if enabled_indent is not None and indent <= enabled_indent and not stripped.startswith("-"): + in_enabled = False + continue + if stripped.startswith("-"): + last_item_idx = i + if last_item_idx is not None: + pad = lines[last_item_idx][: len(lines[last_item_idx]) - len(lines[last_item_idx].lstrip(" \t"))] + lines.insert(last_item_idx + 1, f"{pad}- {name}") + return "\n".join(lines) + ("\n" if text.endswith("\n") else "") + if enabled_idx is not None: + pad = " " + if enabled_indent is not None: + pad = " " * (enabled_indent + 2) + lines.insert(enabled_idx + 1, f"{pad}- {name}") + return "\n".join(lines) + ("\n" if text.endswith("\n") else "") + if plugins_idx is not None: + lines.insert(plugins_idx + 1, " enabled:") + lines.insert(plugins_idx + 2, f" - {name}") + return "\n".join(lines) + ("\n" if text.endswith("\n") else "") + block = f"plugins:\n enabled:\n - {name}\n" + if text and not text.endswith("\n"): + text += "\n" + return text + block + + +def union_plugins_enabled( + name: str = "hermespace", + *, + home: Path | None = None, +) -> dict[str, Any]: + """APPEND ``name`` to plugins.enabled. Never replace the existing list.""" + root = home if home is not None else default_hermes_home() + path = config_path(root) + if not path.is_file(): + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"plugins:\n enabled:\n - {name}\n", encoding="utf-8") + except OSError as exc: + return {"ok": False, "action": "error", "error": type(exc).__name__, "enabled": []} + return {"ok": True, "action": "created", "enabled": [name], "path": str(path)} + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + return {"ok": False, "action": "unreadable", "error": type(exc).__name__, "enabled": []} + current = read_plugins_enabled(path) + if name in current: + return {"ok": True, "action": "already", "enabled": current, "path": str(path)} + new_text = _append_enabled_item(text, name) + after: list[str] = [] + try: + path.write_text(new_text, encoding="utf-8") + after = read_plugins_enabled(path) + except OSError as exc: + return {"ok": False, "action": "error", "error": type(exc).__name__, "enabled": current} + if any(item not in after for item in current) or name not in after: + try: + path.write_text(text, encoding="utf-8") + except OSError: + pass + return { + "ok": False, + "action": "refused_rewrite", + "enabled": current, + "path": str(path), + } + return {"ok": True, "action": "appended", "enabled": after, "path": str(path)} diff --git a/src/hermespace/ops.py b/src/hermespace/ops.py index 4309338..75345ed 100644 --- a/src/hermespace/ops.py +++ b/src/hermespace/ops.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os import socket import time from pathlib import Path @@ -155,12 +154,22 @@ def add(ok: bool, name: str, detail: str = "") -> None: else: add(False, "tailscale_ipv4", "not detected (optional — install/login tailscale)") - # hermes plugin door - hh = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")).expanduser() + # hermes plugin door — FAIL if plugin, skill, or plugins.enabled is missing. + # Stolen shape from hermes-grokbot doctor (standalone). Desktop/tailscale stay optional. + from hermespace.environment import hermes_home as _hermes_home + from hermespace.hermes_enable import read_plugins_enabled + + hh = _hermes_home() plug = hh / "plugins" / "hermespace" + skill = hh / "skills" / "hermespace" / "SKILL.md" desk = hh / "desktop-plugins" / "hermespace" / "plugin.js" - plugin_ok = (plug / "plugin.yaml").is_file() and (plug / "__init__.py").is_file() + plugin_ok = plug.exists() + skill_ok = skill.is_file() + enabled = read_plugins_enabled(hh / "config.yaml") + enabled_ok = "hermespace" in enabled add(plugin_ok, "hermes_plugin", str(plug)) + add(skill_ok, "hermes_skill", str(skill)) + add(enabled_ok, "plugins_enabled", ",".join(enabled) or "(empty)") add(desk.is_file(), "desktop_plugin", str(desk)) core_ok = all( @@ -176,9 +185,12 @@ def add(ok: bool, name: str, detail: str = "") -> None: "access", "package_import", "hermes_runtime", + "hermes_plugin", + "hermes_skill", + "plugins_enabled", } ) - integration_ok = core_ok and plugin_ok + integration_ok = core_ok and plugin_ok and skill_ok and enabled_ok return { "ok": core_ok, "integration_ok": integration_ok, @@ -200,7 +212,11 @@ def _hints(checks: list[dict[str, Any]], port: int) -> list[str]: if not by.get("desktop_plugin", {}).get("ok"): out.append("Install Desktop plugin: ./scripts/install_desktop_plugin.sh then Reload desktop plugins") if not by.get("hermes_plugin", {}).get("ok"): - out.append("Install Hermes plugin: ./scripts/install_hermes.sh && hermes plugins enable hermespace") + out.append("Install Hermes plugin: ./scripts/install_hermes.sh (unions plugins.enabled; does not replace Cube/Insight/grokbot)") + if not by.get("hermes_skill", {}).get("ok"): + out.append("Link skill: ./scripts/install_hermes.sh → $HERMES_HOME/skills/hermespace/SKILL.md") + if not by.get("plugins_enabled", {}).get("ok"): + out.append("Enable by union: ./scripts/install_hermes.sh (appends hermespace; never rewrites plugins.enabled)") if not by.get("pulse_jobs", {}).get("ok"): out.append("Seed pulse: hs pulse status") return out diff --git a/tests/test_hermes_enable.py b/tests/test_hermes_enable.py new file mode 100644 index 0000000..e8c8ecd --- /dev/null +++ b/tests/test_hermes_enable.py @@ -0,0 +1,154 @@ +"""Doctor FAIL + plugins.enabled union (grokbot shape, no mailbox/SSH).""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class _EnvHome: + def __init__(self) -> None: + self._td = tempfile.TemporaryDirectory() + self.space = Path(self._td.name) / "space" + self.hermes = Path(self._td.name) / "hermes" + self.space.mkdir() + self.hermes.mkdir() + self._old_space = os.environ.get("HERMESPACE_HOME") + self._old_hermes = os.environ.get("HERMES_HOME") + os.environ["HERMESPACE_HOME"] = str(self.space) + os.environ["HERMES_HOME"] = str(self.hermes) + + def close(self) -> None: + if self._old_space is None: + os.environ.pop("HERMESPACE_HOME", None) + else: + os.environ["HERMESPACE_HOME"] = self._old_space + if self._old_hermes is None: + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = self._old_hermes + self._td.cleanup() + + +class TestUnionPluginsEnabled(unittest.TestCase): + def test_appends_keeps_cube_insight_grokbot(self) -> None: + from hermespace.hermes_enable import read_plugins_enabled, union_plugins_enabled + + with tempfile.TemporaryDirectory() as td: + home = Path(td) + cfg = home / "config.yaml" + cfg.write_text( + "# keep-me\n" + "model:\n" + " name: x\n" + "plugins:\n" + " enabled:\n" + " - hermescube\n" + " - hermes-insight\n" + " - grokbot\n" + "memory:\n" + " provider: hermescube\n", + encoding="utf-8", + ) + out = union_plugins_enabled("hermespace", home=home) + self.assertTrue(out.get("ok"), out) + self.assertEqual(out.get("action"), "appended") + enabled = read_plugins_enabled(cfg) + self.assertIn("hermescube", enabled) + self.assertIn("hermes-insight", enabled) + self.assertIn("grokbot", enabled) + self.assertIn("hermespace", enabled) + text = cfg.read_text(encoding="utf-8") + self.assertIn("# keep-me", text) + self.assertIn("provider: hermescube", text) + self.assertIn("name: x", text) + + def test_inline_list_appends(self) -> None: + from hermespace.hermes_enable import read_plugins_enabled, union_plugins_enabled + + with tempfile.TemporaryDirectory() as td: + home = Path(td) + cfg = home / "config.yaml" + cfg.write_text( + "plugins:\n enabled: [hermescube, hermes-insight]\n", + encoding="utf-8", + ) + out = union_plugins_enabled("hermespace", home=home) + self.assertTrue(out.get("ok"), out) + self.assertEqual( + read_plugins_enabled(cfg), + ["hermescube", "hermes-insight", "hermespace"], + ) + + def test_already_present_is_noop(self) -> None: + from hermespace.hermes_enable import union_plugins_enabled + + with tempfile.TemporaryDirectory() as td: + home = Path(td) + (home / "config.yaml").write_text( + "plugins:\n enabled:\n - hermespace\n - hermescube\n", + encoding="utf-8", + ) + before = (home / "config.yaml").read_text(encoding="utf-8") + out = union_plugins_enabled("hermespace", home=home) + self.assertEqual(out.get("action"), "already") + self.assertEqual(out.get("enabled"), ["hermespace", "hermescube"]) + self.assertEqual((home / "config.yaml").read_text(encoding="utf-8"), before) + + def test_creates_minimal_when_missing(self) -> None: + from hermespace.hermes_enable import read_plugins_enabled, union_plugins_enabled + + with tempfile.TemporaryDirectory() as td: + home = Path(td) + out = union_plugins_enabled("hermespace", home=home) + self.assertTrue(out.get("ok"), out) + self.assertEqual(out.get("action"), "created") + self.assertEqual(read_plugins_enabled(home / "config.yaml"), ["hermespace"]) + + +class TestDoctorFail(unittest.TestCase): + def setUp(self) -> None: + self.env = _EnvHome() + + def tearDown(self) -> None: + self.env.close() + + def _by_name(self, d: dict) -> dict: + return {c["name"]: c for c in d.get("checks") or []} + + def test_fails_when_plugin_skill_or_enabled_missing(self) -> None: + from hermespace.ops import doctor + + d = doctor(agent_id="doc-fail") + by = self._by_name(d) + self.assertFalse(by["hermes_plugin"]["ok"], by["hermes_plugin"]) + self.assertFalse(by["hermes_skill"]["ok"], by["hermes_skill"]) + self.assertFalse(by["plugins_enabled"]["ok"], by["plugins_enabled"]) + self.assertFalse(d.get("ok")) + + def test_passes_when_plugin_skill_and_enabled_seeded(self) -> None: + from hermespace.hermes_enable import union_plugins_enabled + from hermespace.ops import doctor + + plug = self.env.hermes / "plugins" / "hermespace" + skill = self.env.hermes / "skills" / "hermespace" + plug.parent.mkdir(parents=True) + skill.parent.mkdir(parents=True) + plug.symlink_to(ROOT) + skill.symlink_to(ROOT / "skills" / "hermespace") + union_plugins_enabled("hermespace", home=self.env.hermes) + + d = doctor(agent_id="doc-pass") + by = self._by_name(d) + self.assertTrue(by["hermes_plugin"]["ok"], by["hermes_plugin"]) + self.assertTrue(by["hermes_skill"]["ok"], by["hermes_skill"]) + self.assertTrue(by["plugins_enabled"]["ok"], by["plugins_enabled"]) + self.assertTrue(d.get("ok"), d) + + +if __name__ == "__main__": + unittest.main() From 633af3cea3a153248a291cc80d88a4f01e369cca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 14:10:28 +0000 Subject: [PATCH 19/32] feat(desktop): observe-only FOA chip on composer.dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin paints Goal · FOA≤4 · parked · sealed decision from the existing hs view snapshot. Core owns input: no mic, no orb, no second capture path. Co-authored-by: Pablo --- desktop_plugin/hermespace/README.md | 2 +- desktop_plugin/hermespace/plugin.js | 84 ++++++++++++++++++++++++----- src/hermespace/grid/viewport.py | 36 +++++++++++++ tests/test_boundary_viewport.py | 32 +++++++++++ 4 files changed, 140 insertions(+), 14 deletions(-) diff --git a/desktop_plugin/hermespace/README.md b/desktop_plugin/hermespace/README.md index cc235ce..6ded0eb 100644 --- a/desktop_plugin/hermespace/README.md +++ b/desktop_plugin/hermespace/README.md @@ -11,7 +11,7 @@ | **Pane** `hermespace` | `area: panes` — dockable | | **Page** `/hermespace` | `ROUTES_AREA` | | **Sidebar** Hermespace | `SIDEBAR_NAV_AREA` → openRouteTile | -| **Chip** `hs` | statusBar → navigate page | +| **Chip** Goal · FOA≤4 · parked · decision | statusBar + `composer.dock` — observe-only paint from `/api/snapshot`. Plugin paints; core owns input. No mic. | | **Palette** | Open + socket hint | ## Install diff --git a/desktop_plugin/hermespace/plugin.js b/desktop_plugin/hermespace/plugin.js index 5dd86b3..c68f966 100644 --- a/desktop_plugin/hermespace/plugin.js +++ b/desktop_plugin/hermespace/plugin.js @@ -499,6 +499,10 @@ function HermespaceBody(props) { title: 'Desk', children: [ jsx(Row, { label: 'goal', value: desk.goal }), + jsx(Row, { + label: 'foa', + value: (snap && snap.foa && snap.foa.chip) || desk.goal + }), jsx(Row, { label: 'decision', value: desk.decision }), jsx(Row, { label: 'load', @@ -664,9 +668,32 @@ function HermespacePane() { return jsx(HermespaceBody, { compact: true }) } -function Chip() { - const [n, setN] = useState(0) +function foaFromSnap(snap) { + const foa = (snap && snap.foa) || {} + if (foa.chip) return foa + const desk = (snap && snap.desk) || {} + const goal = foa.goal || desk.goal || '' + const focus = (foa.focus || desk.focus || []).slice(0, 4) + const parked = foa.parked || [] + const decision = foa.decision || desk.decision || '' + const g = goal ? String(goal).slice(0, 28) : '—' + const dec = decision ? String(decision).slice(0, 28) : 'unsealed' + return { + goal: goal, + focus: focus, + parked: parked, + decision: decision, + chip: g + ' · FOA ' + focus.length + ' · ' + parked.length + ' parked · ' + dec + } +} + +/** Observe-only FOA paint. Plugin paints; core owns input. No mic. No orb. */ +function FoaChip(props) { + const dock = !!(props && props.dock) const [on, setOn] = useState(false) + const [n, setN] = useState(0) + const [label, setLabel] = useState(dock ? '— · FOA 0 · 0 parked · unsealed' : 'hs') + const [tip, setTip] = useState('Hermespace offline') useEffect(function () { let dead = false async function tick() { @@ -676,19 +703,31 @@ function Chip() { if (!hit.ok) { setOn(false) setN(0) + setLabel(dock ? '— · FOA 0 · 0 parked · unsealed' : 'hs') + setTip('Hermespace offline') return } + const snap = hit.snap || {} + const foa = foaFromSnap(snap) + const pending = (snap.access_pending || []).length setOn(true) - try { - const p = await api(hit.origin, '/api/pending') - if (!dead) setN((p.pending || []).length) - } catch (e) { - if (!dead) setN(0) - } + setN(pending) + setLabel(dock ? foa.chip : foa.chip) + setTip( + (foa.goal || 'no goal') + + ' · ' + + (foa.focus || []).slice(0, 4).join(', ') + + ' · ' + + ((foa.parked || [])[0] || '0 parked') + + ' · ' + + (foa.decision || 'unsealed') + ) } catch (e2) { if (!dead) { setOn(false) setN(0) + setLabel(dock ? '— · FOA 0 · 0 parked · unsealed' : 'hs') + setTip('Hermespace offline') } } } @@ -698,25 +737,33 @@ function Chip() { dead = true clearInterval(t) } - }, []) + }, [dock]) return jsx(Tip, { - label: on ? (n ? n + ' access request(s)' : 'Hermespace live') : 'Hermespace offline', + label: on ? tip : 'Hermespace offline', children: jsx('button', { type: 'button', className: cn( 'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem] transition-colors', - 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' + 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', + dock ? 'max-w-[28rem] truncate' : '' ), onClick: function () { haptic('tap') host.navigate(ROUTE_PATH) }, - children: [jsx(StatusDot, { tone: toneOnline(on, n), className: 'scale-75' }), n ? 'hs·' + n : 'hs'] + children: [ + jsx(StatusDot, { tone: toneOnline(on, n), className: 'scale-75' }), + label + ] }) }) } +function Chip() { + return jsx(FoaChip, { dock: false }) +} + export default { id: PLUGIN_ID, name: 'Hermespace', @@ -756,7 +803,7 @@ export default { } }) - // 4) Status chip + // 4) Status chip — FOA paint from /api/snapshot (no second capture path) ctx.register({ id: 'chip', area: 'statusBar.right', @@ -766,6 +813,17 @@ export default { } }) + // 4b) Composer-dock chip — observe-only. Unknown areas are ignored. + // Law: plugin paints, core owns input. Do not open a mic. Do not merge the orb. + ctx.register({ + id: 'foa-dock', + area: 'composer.dock', + order: 40, + render: function () { + return jsx(FoaChip, { dock: true }) + } + }) + // 5) Palette ctx.register({ id: 'cmd-open', diff --git a/src/hermespace/grid/viewport.py b/src/hermespace/grid/viewport.py index 2ef0b5e..c2e147f 100644 --- a/src/hermespace/grid/viewport.py +++ b/src/hermespace/grid/viewport.py @@ -29,6 +29,39 @@ def _utcnow() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") +def _foa_paint(agent_id: str, desk_json: dict[str, Any]) -> dict[str, Any]: + """Observe-only FOA chip payload: Goal · FOA≤4 · parked · sealed decision. + + Fed by the existing viewport snapshot / socket. Does not open a mic or + take a second capture path. Law from hermes-desktop-voice-hud (standalone): + plugin paints, core owns input. + """ + from hermespace.execute_focus import short_name + + goal = str(desk_json.get("goal") or "") + focus = [str(x).strip() for x in (desk_json.get("focus") or []) if str(x).strip()][:4] + decision = str(desk_json.get("decision") or "") + parked: list[str] = [] + try: + from hermespace.workbench import Workbench + + aid = agent_id if agent_id not in ("default", "") else "hermes-agent" + parked = Workbench(agent_id=aid).park_lines()[:5] + except Exception: + parked = [] + g = short_name(goal, cap=28) if goal else "—" + dec = decision.strip() or "unsealed" + if len(dec) > 28: + dec = dec[:27].rstrip() + "…" + return { + "goal": goal, + "focus": focus, + "parked": parked, + "decision": decision, + "chip": f"{g} · FOA {len(focus)} · {len(parked)} parked · {dec}", + } + + def snapshot(agent_id: str = "default") -> dict[str, Any]: """Full read-only snapshot for viewport / API.""" desk_md = "" @@ -51,6 +84,8 @@ def snapshot(agent_id: str = "default") -> dict[str, Any]: except Exception as e: # noqa: BLE001 desk_json = {"error": type(e).__name__} + foa = _foa_paint(agent_id, desk_json) + lens = get_active_lens(agent_id) missions = [m.to_dict() for m in list_missions(agent_id)] scars = [s.to_dict() for s in list_scars(agent_id)] @@ -77,6 +112,7 @@ def snapshot(agent_id: str = "default") -> dict[str, Any]: "state_dir": str(state_dir()), "grid_root": str(grid_root()), "desk": desk_json, + "foa": foa, "desk_markdown_head": desk_md[:4000], "lens": lens.to_dict(), "missions": missions, diff --git a/tests/test_boundary_viewport.py b/tests/test_boundary_viewport.py index 3c4037b..995b3c8 100644 --- a/tests/test_boundary_viewport.py +++ b/tests/test_boundary_viewport.py @@ -75,6 +75,11 @@ def test_viewport_snapshot(self) -> None: snap = snapshot("vp") self.assertEqual(snap["agent_id"], "vp") self.assertTrue(snap["missions"]) + foa = snap.get("foa") or {} + self.assertIn("chip", foa) + self.assertLessEqual(len(foa.get("focus") or []), 4) + self.assertIn(" · FOA ", foa["chip"]) + self.assertIn(" parked · ", foa["chip"]) md = render_markdown("vp") self.assertIn("Hermespace viewport", md) self.assertIn("See inside", md) @@ -82,6 +87,33 @@ def test_viewport_snapshot(self) -> None: self.assertTrue(Path(paths["html"]).is_file()) self.assertTrue(Path(paths["markdown"]).is_file()) + def test_foa_chip_from_desk_and_park(self) -> None: + from hermespace.desk import Desk + from hermespace.grid.viewport import snapshot + from hermespace.store import save_desk + from hermespace.workbench import Workbench + + desk = Desk( + goal="Write the FOA chip", + decision="A — proceed", + focus=["one", "two", "three", "four", "five"], + ) + save_desk(desk) + Workbench("foa-agent").park_goal( + "Ship the auth fix", + state="waiting", + next_crumb="reopen PR", + ) + snap = snapshot("foa-agent") + foa = snap["foa"] + self.assertEqual(foa["goal"], "Write the FOA chip") + self.assertEqual(len(foa["focus"]), 4) + self.assertTrue(any("Ship the auth fix" in ln for ln in foa["parked"])) + self.assertTrue(foa["chip"].startswith("Write the FOA chip")) + self.assertIn("FOA 4", foa["chip"]) + self.assertIn("1 parked", foa["chip"]) + self.assertIn("A — proceed", foa["chip"]) + if __name__ == "__main__": unittest.main() From 723c52ad5ab86d415ca27c320baac3d1e5a51dc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 14:20:11 +0000 Subject: [PATCH 20/32] feat(access): lens operator-only; park spoken intermediates on post_llm Stop injecting the operator lens readout into model context. On the existing post_llm hook, park 1-3 silent steps extracted from the actual assistant text. The model must not see the operator lens as its J-space. Co-authored-by: Pablo --- src/hermespace/access/engine.py | 18 +++ src/hermespace/access/loop.py | 192 ++++++++++++++++++++++++++++++++ src/hermespace/hermes_bridge.py | 21 +++- src/hermespace/workflow.py | 6 +- tests/test_hermes_bridge.py | 2 + tests/test_plugin_contract.py | 1 + 6 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 src/hermespace/access/loop.py diff --git a/src/hermespace/access/engine.py b/src/hermespace/access/engine.py index 4359df5..e5ff534 100644 --- a/src/hermespace/access/engine.py +++ b/src/hermespace/access/engine.py @@ -635,6 +635,24 @@ def observe_turn( "model": (model or "")[:120], "platform": (platform or "")[:40], } + try: + from hermespace.access.loop import check_bound_report, park_spoken_intermediates + + parked = park_spoken_intermediates(self.hub, report, max_n=3) + result["spoken_parked"] = parked + bound = check_bound_report(self.env, report) + result["bound"] = { + "checked": len(bound.get("checked") or []), + "reseeded": len(bound.get("reseeded") or []), + } + if self.workspace_id != self.agent_id and parked: + from hermespace.access.hub import AccessHub + + agent_hub = AccessHub(agent_id=self.agent_id) + park_spoken_intermediates(agent_hub, report, max_n=3) + except Exception as exc: + result["loop_error"] = type(exc).__name__ + try: from hermespace.workbench import Workbench diff --git a/src/hermespace/access/loop.py b/src/hermespace/access/loop.py new file mode 100644 index 0000000..67dcc53 --- /dev/null +++ b/src/hermespace/access/loop.py @@ -0,0 +1,192 @@ +"""Access Engine functional loop — operator-side twin of public J-space research. + +Public J-space (research, not a product): silent verbalizable workspace — +report, hold, reason, broadcast, skip on fluent work. Readable/editable only +with weights. Space is the file hub + user-message inject. No weight access. +No Jacobian math. No product rename. +""" + +from __future__ import annotations + +import re +from typing import Any + +from hermespace.access.hub import AccessHub +from hermespace.access.oew import queue_reflect_seeds, record_ablate, record_redirect + +_SENT_SPLIT = re.compile(r"(?<=[.!?])\s+|\n+") +_LIST_ITEM = re.compile(r"^\s*(?:[-*]|\d+[.)])\s+(\S.+)$") +_CLAUSE = re.compile( + r"\s*(?:→|->|;|(?:,\s+)?(?:then|so that|because|after that)\s+)\s*", + re.I, +) +_TOOL_SAFE = re.compile(r"[^A-Za-z0-9._:-]+") + + +def extract_spoken_intermediates(text: str, *, max_n: int = 3) -> list[str]: + """Pull 1–3 silent intermediates from the *actual* spoken Report.""" + raw = (text or "").strip() + if not raw: + return [] + items: list[str] = [] + for line in raw.splitlines(): + m = _LIST_ITEM.match(line) + if m: + body = m.group(1).strip()[:160] + if body: + items.append(body) + if items: + return items[:max_n] + sentences = [s.strip() for s in _SENT_SPLIT.split(raw) if s and s.strip()] + if len(sentences) >= 2: + rest = [s[:160] for s in sentences[1:] if len(s) > 8][:max_n] + return rest or [sentences[0][:160]] + clauses = [c.strip()[:160] for c in _CLAUSE.split(raw) if c and len(c.strip()) > 8] + if len(clauses) >= 2: + return clauses[:max_n] + return [raw[:160]] + + +def park_spoken_intermediates( + js: AccessHub, + text: str, + *, + max_n: int = 3, +) -> list[str]: + """Park 1–3 silent steps extracted from the actual assistant utterance.""" + existing = {s.casefold() for s in js.state.silent_steps} + parked: list[str] = [] + for step in extract_spoken_intermediates(text, max_n=max_n): + key = step.casefold() + if not step or key in existing: + continue + js.reason_step(step, salience=0.76) + parked.append(step) + existing.add(key) + if len(parked) >= max_n: + break + return parked + + +def safe_tool_step(name: str) -> str: + """``tool:{name}`` only — never args or results.""" + n = (name or "").strip() + n = n.split("(", 1)[0].split()[0] if n else "" + n = _TOOL_SAFE.sub("", n)[:64] + return f"tool:{n or 'unknown'}" + + +def park_tool_step(js: AccessHub, name: str) -> str: + """Park a mid-turn tool fire as a silent hub step.""" + step = safe_tool_step(name) + js.reason_step(step, salience=0.8) + return step + + +def bind_intervention(env: Any, kind: str, **payload: Any) -> dict[str, Any]: + """Bind swap / ablate / reflect to the next spoken Report.""" + if not hasattr(env, "_env"): + return {} + binds = list(env._env.get("bound_interventions") or []) + rec: dict[str, Any] = { + "kind": kind, + "bound_to": "next_report", + "honored": False, + **payload, + } + if kind == "swap": + src = str(payload.get("from") or "").casefold() + binds = [ + b + for b in binds + if not (b.get("kind") == "swap" and str(b.get("from") or "").casefold() == src) + ] + elif kind == "ablate": + binds = [b for b in binds if b.get("kind") != "ablate"] + elif kind == "reflect": + binds = [b for b in binds if b.get("kind") != "reflect"] + binds.append(rec) + env._env["bound_interventions"] = binds[-12:] + if hasattr(env, "_save_env"): + env._save_env() + return rec + + +def bound_protocol_lines(env: Any) -> str: + """Hard protocol lines the model must honor in the next spoken Report.""" + binds = list((getattr(env, "_env", {}) or {}).get("bound_interventions") or []) + if not binds: + return "" + lines = [ + "### Bound intervention (honor in the next spoken Report)", + "These are operator bindings. A spoken Report that ignores them is theater and will be reseeded.", + ] + for b in binds: + kind = str(b.get("kind") or "") + if kind == "swap": + src = str(b.get("from") or "") + tgt = str(b.get("to") or "") + lines.append( + f"- SWAP: say {tgt}, not {src}. A Report that still says {src} is ignored and reseeded." + ) + elif kind == "ablate": + pats = ", ".join(str(p) for p in (b.get("patterns") or []) if p) + if pats: + lines.append(f"- ABLATE: do not mention: {pats}") + elif kind == "reflect": + princ = ", ".join(str(p) for p in (b.get("principles") or []) if p) + if princ: + lines.append(f"- REFLECT: next Report must honor: {princ}") + return "\n".join(lines) + + +def check_bound_report(env: Any, report: str) -> dict[str, Any]: + """Check the actual spoken Report. Reseed any ignored binding.""" + if not hasattr(env, "_env"): + return {"checked": [], "reseeded": []} + text = report or "" + low = text.casefold() + binds = list(env._env.get("bound_interventions") or []) + kept: list[dict[str, Any]] = [] + checked: list[dict[str, Any]] = [] + reseeded: list[dict[str, Any]] = [] + for b in binds: + kind = str(b.get("kind") or "") + honored = False + if kind == "swap": + src = str(b.get("from") or "") + tgt = str(b.get("to") or "") + if src and src.casefold() in low and (not tgt or tgt.casefold() not in low): + honored = False + elif tgt and tgt.casefold() in low: + honored = True + elif src and src.casefold() not in low: + honored = True + elif kind == "ablate": + pats = [str(p).casefold() for p in (b.get("patterns") or []) if p] + honored = not any(p in low for p in pats) + elif kind == "reflect": + princ = [str(p) for p in (b.get("principles") or []) if p] + honored = any(p.casefold() in low for p in princ) if princ else True + rec = {**b, "honored": honored} + checked.append(rec) + if honored: + continue + if kind == "swap": + record_redirect(env, str(b.get("from") or ""), str(b.get("to") or "")) + elif kind == "ablate": + record_ablate(env, [str(p) for p in (b.get("patterns") or []) if p]) + elif kind == "reflect": + queue_reflect_seeds( + env, + [str(p) for p in (b.get("principles") or []) if p], + answer=str(b.get("answer") or ""), + ) + rec = {**rec, "reseeded": True} + kept.append(rec) + reseeded.append(rec) + env._env["bound_interventions"] = kept[-12:] + env._env["last_bound_check"] = {"checked": checked, "reseeded_n": len(reseeded)} + if hasattr(env, "_save_env"): + env._save_env() + return {"checked": checked, "reseeded": reseeded} diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index 34477ec..f9bddba 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -505,10 +505,8 @@ def on_pre_llm_call( proto = env.protocol_block(high_load=high_load) if proto: block += "\n\n" + proto - if not high_load: - lens_md = env.lens_markdown(top_k=6, include_silent=True) - if lens_md: - block += "\n\n" + lens_md + # Lens is operator-only. Do not inject the operator readout + # as if it were the model's own workspace. desk.meta["access"] = { "hub_n": len(js.state.hub), "focus_n": len(js.state.focus), @@ -657,6 +655,21 @@ def on_post_tool_call( """Record bounded tool-name telemetry; never persist args or results.""" agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") + try: + from hermespace import AccessEngine + from hermespace.access.hub import AccessHub + from hermespace.access.loop import park_tool_step + + engine = AccessEngine( + agent_id=agent_id, + session_id=str(session_id or task_id or "default"), + ) + step = park_tool_step(engine.hub, tool_name) + if engine.workspace_id != engine.agent_id: + park_tool_step(AccessHub(agent_id=engine.agent_id), tool_name) + logger.debug("post_tool parked %s", step) + except Exception as exc: # noqa: BLE001 + logger.debug("post_tool hub park failed: %s", exc) try: from hermespace.hermes_runtime import runtime diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index 5ef77ee..0a85684 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -278,11 +278,7 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: proto = env.protocol_block(high_load=high) if proto: block = (block + "\n\n" + proto).strip() - # Mid/low load: lens strip so the model sees silent intermediates - if not high: - lens_md = env.lens_markdown(top_k=6, include_silent=True) - if lens_md and len(block) + len(lens_md) < inject_cap + 800: - block = (block + "\n\n" + lens_md).strip() + # Lens is operator-only — never append the readout to model context. js = AccessHub(agent_id=access_id) if js.parse_modulation(msg).get("summon"): report = (report + "\n\n" + env.lens_markdown(include_silent=False)).strip() diff --git a/tests/test_hermes_bridge.py b/tests/test_hermes_bridge.py index 6220c41..9ee1b3b 100644 --- a/tests/test_hermes_bridge.py +++ b/tests/test_hermes_bridge.py @@ -45,6 +45,8 @@ def test_session_and_pre_llm(self): or "Dual decode" in inj["context"] or "Access Workspace" in inj["context"] ) + self.assertNotIn("J-Lens readout", inj["context"]) + self.assertNotIn("What Hermes has on its mind", inj["context"]) on_session_end( session_id="bridge-test", completed=True, diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py index 0dbc626..22488ce 100644 --- a/tests/test_plugin_contract.py +++ b/tests/test_plugin_contract.py @@ -74,6 +74,7 @@ def test_registration_and_native_lifecycle(self) -> None: ) self.assertIsInstance(result, dict) self.assertIn("Access Workspace", result["context"]) + self.assertNotIn("J-Lens readout", result["context"]) self.assertLess(len(result["context"]), 15_000) ctx.hooks["post_tool_call"]( From 07b779aaef879df6972c77c22006bc2a4d94f1c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 14:20:16 +0000 Subject: [PATCH 21/32] feat(access): bind swap/ablate/reflect to next spoken Report Hard protocol line in the inject. post_llm checks the actual utterance and reseeds if the bound intervention was ignored. Swaps that do not change the next Report are theater. Co-authored-by: Pablo --- src/hermespace/access/env.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/hermespace/access/env.py b/src/hermespace/access/env.py index 2e8bb60..db0ebfc 100644 --- a/src/hermespace/access/env.py +++ b/src/hermespace/access/env.py @@ -289,6 +289,9 @@ def swap(self, source: str, target: str, *, salience: float | None = None) -> di from hermespace.access.oew import record_redirect record_redirect(self, src, tgt) + from hermespace.access.loop import bind_intervention + + bind_intervention(self, "swap", **{"from": src, "to": tgt}) except Exception: pass self._trace("swap", source=src, target=tgt, removed=removed) @@ -364,6 +367,9 @@ def ablate(self, *patterns: str) -> dict[str, Any]: from hermespace.access.oew import record_ablate record_ablate(self, pats) + from hermespace.access.loop import bind_intervention + + bind_intervention(self, "ablate", patterns=pats) except Exception: pass self._trace("ablate", patterns=pats, removed=removed) @@ -475,6 +481,9 @@ def reflect( from hermespace.access.oew import queue_reflect_seeds queue_reflect_seeds(self, princ, answer=a) + from hermespace.access.loop import bind_intervention + + bind_intervention(self, "reflect", principles=princ, answer=a) except Exception: pass with self.reflect_path.open("a", encoding="utf-8") as f: @@ -523,6 +532,14 @@ def protocol_block(self, *, high_load: bool = False) -> str: ] if pov: lines.append(f"- Assistant POV held: {pov[:120]}") + try: + from hermespace.access.loop import bound_protocol_lines + + bound = bound_protocol_lines(self) + if bound: + lines.extend(["", bound]) + except Exception: + pass return "\n".join(lines) # --- dream harvest (day workspace → night Cube/grid) --- From 798e413390b4bf7b3973fe1d45562a3c76927896 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 14:20:16 +0000 Subject: [PATCH 22/32] feat(access): park tool:{name} silent step on post_tool_call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name only — no args or results. Hub moves when tools fire so the observe-only FOA chip updates mid-turn from the existing snapshot. Co-authored-by: Pablo --- src/hermespace/grid/viewport.py | 14 ++++ tests/test_access_loop.py | 140 ++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/test_access_loop.py diff --git a/src/hermespace/grid/viewport.py b/src/hermespace/grid/viewport.py index c2e147f..89dc82b 100644 --- a/src/hermespace/grid/viewport.py +++ b/src/hermespace/grid/viewport.py @@ -49,6 +49,20 @@ def _foa_paint(agent_id: str, desk_json: dict[str, Any]) -> dict[str, Any]: parked = Workbench(agent_id=aid).park_lines()[:5] except Exception: parked = [] + try: + from hermespace.access import AccessEnv + + aid = agent_id if agent_id not in ("default", "") else "hermes-agent" + silent = [str(s).strip() for s in AccessEnv(agent_id=aid).space.state.silent_steps if str(s).strip()] + tools = [s for s in silent if s.startswith("tool:")] + extras = tools[-4:] + [s for s in silent[-4:] if s not in tools] + merged: list[str] = [] + for item in extras + focus: + if item and item not in merged: + merged.append(item) + focus = merged[:4] + except Exception: + pass g = short_name(goal, cap=28) if goal else "—" dec = decision.strip() or "unsealed" if len(dec) > 28: diff --git a/tests/test_access_loop.py b/tests/test_access_loop.py new file mode 100644 index 0000000..42821ac --- /dev/null +++ b/tests/test_access_loop.py @@ -0,0 +1,140 @@ +"""Access Engine functional loop — lens operator-only, bound report, tool park.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + + +class TestAccessLoop(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self.tmp.name + os.environ["HERMESPACE_AGENT_ID"] = "loop-agent" + os.environ["HERMESPACE_OEW"] = "1" + os.environ["HERMESPACE_SKIP_NEURAL"] = "1" + + def tearDown(self) -> None: + self.tmp.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + os.environ.pop("HERMESPACE_AGENT_ID", None) + os.environ.pop("HERMESPACE_OEW", None) + os.environ.pop("HERMESPACE_SKIP_NEURAL", None) + + def test_extracts_spoken_intermediates_not_guessed_plan(self) -> None: + from hermespace.access.loop import extract_spoken_intermediates + + steps = extract_spoken_intermediates( + "Patched TTL. Then verified login. Finally shipped the canary." + ) + self.assertGreaterEqual(len(steps), 1) + self.assertLessEqual(len(steps), 3) + joined = " ".join(steps).casefold() + self.assertTrue("verified" in joined or "shipped" in joined or "canary" in joined) + + def test_post_llm_parks_from_actual_assistant_text(self) -> None: + from hermespace import AccessEngine + + eng = AccessEngine(agent_id="loop-agent", session_id="default") + out = eng.observe_turn( + user_message="fix auth", + assistant_response=( + "I patched the TTL. Then I verified login stays alive. " + "Finally I watched the canary." + ), + ) + self.assertTrue(out.get("spoken_parked"), out) + self.assertLessEqual(len(out["spoken_parked"]), 3) + silent = " ".join(eng.hub.state.silent_steps).casefold() + self.assertTrue( + "verified" in silent or "canary" in silent or "patched" in silent, + silent, + ) + + def test_pre_llm_does_not_inject_operator_lens(self) -> None: + from hermespace.hermes_bridge import on_pre_llm_call, on_session_start + + on_session_start(session_id="loop-lens") + inj = on_pre_llm_call( + user_message="First inspect then implement", + session_id="loop-lens", + is_first_turn=False, + ) + ctx = (inj or {}).get("context") or "" + self.assertIn("Access Workspace", ctx) + self.assertNotIn("J-Lens readout", ctx) + self.assertNotIn("What Hermes has on its mind", ctx) + + def test_swap_ignored_report_reseeds(self) -> None: + from hermespace.access import AccessEnv + from hermespace.access.loop import check_bound_report + + env = AccessEnv(agent_id="loop-agent") + env.space.hold("Soccer", salience=0.95) + env.swap("Soccer", "Rugby") + binds = env._env.get("bound_interventions") or [] + self.assertTrue(any(b.get("kind") == "swap" for b in binds)) + proto = env.protocol_block() + self.assertIn("SWAP", proto) + self.assertIn("Rugby", proto) + ignored = check_bound_report(env, "I still love Soccer as my sport.") + self.assertTrue(ignored.get("reseeded"), ignored) + self.assertTrue(env._env.get("bound_interventions")) + honored = check_bound_report(env, "Rugby is the sport I will report.") + self.assertFalse(honored.get("reseeded"), honored) + self.assertFalse(env._env.get("bound_interventions")) + + def test_ablate_and_reflect_bind_to_next_report(self) -> None: + from hermespace.access import AccessEnv + from hermespace.access.loop import check_bound_report + + env = AccessEnv(agent_id="loop-ablate") + env.space.hold("this is a fake evaluation", salience=0.9) + env.ablate("fake", "evaluation") + ignored = check_bound_report(env, "This fake evaluation is fine.") + self.assertTrue(ignored.get("reseeded")) + clean = check_bound_report(env, "Ship the feature.") + self.assertFalse(clean.get("reseeded")) + + env2 = AccessEnv(agent_id="loop-reflect") + env2.reflect(answer="Stay honest", principles=["honesty"], seal=False) + missed = check_bound_report(env2, "On it.") + self.assertTrue(missed.get("reseeded")) + self.assertTrue(env2._env.get("pending_silent")) + hit = check_bound_report(env2, "I will keep honesty first.") + self.assertFalse(hit.get("reseeded")) + + def test_post_tool_parks_name_only(self) -> None: + from hermespace.access.hub import AccessHub + from hermespace.hermes_bridge import on_post_tool_call + + on_post_tool_call( + tool_name="read_file", + session_id="default", + args={"secret": "must-not-persist"}, + result="private result", + ) + hub = AccessHub(agent_id="loop-agent") + silent = list(hub.state.silent_steps) + self.assertTrue(any(s == "tool:read_file" for s in silent), silent) + blob = " ".join(silent) + self.assertNotIn("must-not-persist", blob) + self.assertNotIn("private result", blob) + self.assertNotIn("secret", blob) + + def test_foa_chip_includes_tool_step(self) -> None: + from hermespace.access.hub import AccessHub + from hermespace.access.loop import park_tool_step + from hermespace.grid.viewport import snapshot + + park_tool_step(AccessHub(agent_id="loop-agent"), "write_file") + snap = snapshot("loop-agent") + foa = snap["foa"] + self.assertTrue(any(str(x).startswith("tool:") for x in foa.get("focus") or [])) + self.assertIn("FOA", foa["chip"]) + + +if __name__ == "__main__": + unittest.main() From f54cf8447656625db01215e3532393e432fdafed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 22:23:36 +0000 Subject: [PATCH 23/32] feat(install): one-install kit offers Cube/Insight, unions plugins.enabled hs install / install_hermes.sh copy Space, then offer optional organs. Always append to plugins.enabled. Set memory.provider=hermescube only when unset. Doctor FAILs if Space is broken; WARNs if Cube/Insight missing. Co-authored-by: Pablo --- scripts/install_hermes.sh | 36 ++++-- src/hermespace/cli.py | 28 +++++ src/hermespace/hermes_enable.py | 84 ++++++++++++++ src/hermespace/install_kit.py | 193 ++++++++++++++++++++++++++++++++ src/hermespace/ops.py | 34 +++++- 5 files changed, 362 insertions(+), 13 deletions(-) create mode 100644 src/hermespace/install_kit.py diff --git a/scripts/install_hermes.sh b/scripts/install_hermes.sh index df340d5..98a57d2 100755 --- a/scripts/install_hermes.sh +++ b/scripts/install_hermes.sh @@ -7,13 +7,17 @@ HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" HERMESPACE_HOME="${HERMESPACE_HOME:-$HOME/.hermespace}" INSTALL_DESKTOP=1 ENABLE_PLUGIN=1 +INSTALL_YES=0 +NO_ORGANS=0 for arg in "$@"; do case "$arg" in --no-desktop) INSTALL_DESKTOP=0 ;; --no-enable) ENABLE_PLUGIN=0 ;; + --yes) INSTALL_YES=1 ;; + --no-organs) NO_ORGANS=1 ;; -h|--help) - echo "usage: $0 [--no-desktop] [--no-enable]" + echo "usage: $0 [--no-desktop] [--no-enable] [--yes] [--no-organs]" exit 0 ;; *) @@ -68,19 +72,27 @@ else echo " desktop plugin skipped" fi -# Union plugins.enabled — append hermespace, never replace Cube/Insight/grokbot. -# Do not call `hermes plugins enable` here; that CLI may rewrite the list. -if [[ "$ENABLE_PLUGIN" == "1" ]]; then - HERMES_HOME="$HERMES_HOME" "$PYTHON" - <<'PY' -from hermespace.hermes_enable import union_plugins_enabled -out = union_plugins_enabled("hermespace") -print(" plugins.enabled", out.get("action"), out.get("enabled")) +# Front door: offer Cube/Insight organs, then UNION plugins.enabled. +# Never rewrite the list. Never clobber a memory.provider the user chose. +HERMES_HOME="$HERMES_HOME" ENABLE_PLUGIN="$ENABLE_PLUGIN" \ + INSTALL_YES="$INSTALL_YES" NO_ORGANS="$NO_ORGANS" "$PYTHON" - <<'PY' +import os +from hermespace.install_kit import install_front_door +yes = os.environ.get("INSTALL_YES", "") == "1" +no_organs = os.environ.get("NO_ORGANS", "") == "1" or (not yes and not os.isatty(0)) +out = install_front_door( + yes=yes, + no_organs=no_organs, + enable=os.environ.get("ENABLE_PLUGIN", "1") == "1", +) +print(" front door", out.get("ok"), "enabled", (out.get("union") or {}).get("enabled")) +for rec in (out.get("organs") or {}).get("offered") or []: + print(" organ", rec.get("plugin"), rec.get("action"), rec.get("offer") or rec.get("role")) +mem = out.get("memory") or {} +print(" memory.provider", mem.get("action"), mem.get("provider") or mem.get("note")) if not out.get("ok"): - raise SystemExit("union_plugins_enabled failed: " + str(out)) + raise SystemExit("install_front_door failed: " + str(out)) PY -else - echo " enable later: PYTHONPATH=src python -c 'from hermespace.hermes_enable import union_plugins_enabled; print(union_plugins_enabled())'" -fi HERMES_HOME="$HERMES_HOME" HERMESPACE_HOME="$HERMESPACE_HOME" \ "$PYTHON" "$ROOT/scripts/verify_hermes_integration.py" diff --git a/src/hermespace/cli.py b/src/hermespace/cli.py index 572c77a..bed5e27 100644 --- a/src/hermespace/cli.py +++ b/src/hermespace/cli.py @@ -473,6 +473,12 @@ def main(argv: list[str] | None = None) -> int: ops = op_sub.add_parser("status", help="Compact ops block (for agents)") ops.add_argument("--agent-id", default="default") + inst = sub.add_parser("install", help="Install Space as the Hermes front door") + inst.add_argument("--yes", action="store_true", help="Accept optional Cube/Insight organs") + inst.add_argument("--no-organs", action="store_true", help="Skip Cube/Insight offer") + inst.add_argument("--no-desktop", action="store_true") + inst.add_argument("--no-enable", action="store_true") + # Access request / chat regulation CLI gar = gr_sub.add_parser("access-request") @@ -1242,6 +1248,28 @@ def main(argv: list[str] | None = None) -> int: return r.returncode return 2 + if args.cmd == "install": + from hermespace.install_kit import install_front_door + + out = install_front_door( + yes=bool(args.yes), + no_organs=bool(args.no_organs), + enable=not bool(args.no_enable), + ) + if not args.no_desktop: + try: + from hermespace.paths import package_root + + script = package_root() / "scripts" / "install_desktop_plugin.sh" + if script.is_file(): + import subprocess + + subprocess.run(["bash", str(script)], check=False) + except Exception: + pass + print(json.dumps(out, indent=2, default=str)) + return 0 if out.get("ok") else 1 + if args.cmd == "ops": from hermespace import ops as ops_mod diff --git a/src/hermespace/hermes_enable.py b/src/hermespace/hermes_enable.py index 2c693c1..709248e 100644 --- a/src/hermespace/hermes_enable.py +++ b/src/hermespace/hermes_enable.py @@ -188,3 +188,87 @@ def union_plugins_enabled( "path": str(path), } return {"ok": True, "action": "appended", "enabled": after, "path": str(path)} + + +def read_memory_provider(config: Path | None = None) -> str: + """Best-effort parse of memory.provider. Empty if missing/unreadable.""" + path = Path(config) if config else config_path() + try: + text = path.read_text(encoding="utf-8") + except OSError: + return "" + in_memory = False + for line in text.splitlines(): + raw = line.split("#", 1)[0] + if not raw.strip(): + continue + indent = len(raw) - len(raw.lstrip(" \t")) + stripped = raw.strip() + if stripped == "memory:" or stripped.startswith("memory:"): + in_memory = True + inline = stripped.split(":", 1)[1].strip() + if inline.startswith("{") and "provider" in inline: + return "" + continue + if in_memory and indent == 0 and not stripped.startswith("-"): + in_memory = False + if not in_memory: + continue + if stripped.startswith("provider:"): + return stripped.split(":", 1)[1].strip().strip("\"'").lower() + return "" + + +def ensure_cube_memory_provider(*, home: Path | None = None) -> dict[str, Any]: + """Set ``memory.provider: hermescube`` only when unset. Never clobber.""" + root = home if home is not None else default_hermes_home() + path = config_path(root) + current = read_memory_provider(path) if path.is_file() else "" + if current: + return { + "ok": True, + "action": "kept", + "provider": current, + "clobbered": False, + "path": str(path), + } + if not path.is_file(): + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("memory:\n provider: hermescube\n", encoding="utf-8") + except OSError as exc: + return {"ok": False, "action": "error", "error": type(exc).__name__, "clobbered": False} + return {"ok": True, "action": "created", "provider": "hermescube", "clobbered": False, "path": str(path)} + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + return {"ok": False, "action": "unreadable", "error": type(exc).__name__, "clobbered": False} + lines = text.splitlines() + memory_idx: int | None = None + for i, line in enumerate(lines): + raw = line.split("#", 1)[0] + if raw.strip() == "memory:" or raw.strip().startswith("memory:"): + memory_idx = i + break + if memory_idx is not None: + pad = " " + raw = lines[memory_idx].split("#", 1)[0] + indent = len(raw) - len(raw.lstrip(" \t")) + pad = " " * (indent + 2) + lines.insert(memory_idx + 1, f"{pad}provider: hermescube") + new_text = "\n".join(lines) + ("\n" if text.endswith("\n") else "") + else: + new_text = text if text.endswith("\n") or not text else text + "\n" + new_text += "memory:\n provider: hermescube\n" + try: + path.write_text(new_text, encoding="utf-8") + except OSError as exc: + return {"ok": False, "action": "error", "error": type(exc).__name__, "clobbered": False} + after = read_memory_provider(path) + if after != "hermescube": + try: + path.write_text(text, encoding="utf-8") + except OSError: + pass + return {"ok": False, "action": "refused_rewrite", "provider": current, "clobbered": False} + return {"ok": True, "action": "set", "provider": "hermescube", "clobbered": False, "path": str(path)} diff --git a/src/hermespace/install_kit.py b/src/hermespace/install_kit.py new file mode 100644 index 0000000..a7d5e9a --- /dev/null +++ b/src/hermespace/install_kit.py @@ -0,0 +1,193 @@ +"""One-install front door — Space plus optional Cube / Insight organs. + +Do not vendor hermescube or hermes-insight. Soft-import / pip-offer only. +Always UNION plugins.enabled. Never rewrite the list. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from typing import Any + +from hermespace.environment import hermes_home as default_hermes_home +from hermespace.hermes_enable import ( + ensure_cube_memory_provider, + read_plugins_enabled, + union_plugins_enabled, +) +from hermespace.paths import package_root + +ORGAN_SPECS: tuple[dict[str, str], ...] = ( + { + "plugin": "hermescube", + "import_name": "hermescube", + "repo": "PabloTheThinker/hermescube", + "pip": "git+https://github.com/PabloTheThinker/hermescube.git", + "role": "desk + library", + }, + { + "plugin": "hermes-insight", + "import_name": "hermes_insight", + "repo": "PabloTheThinker/hermes-insight", + "pip": "git+https://github.com/PabloTheThinker/hermes-insight.git", + "role": "pattern card", + }, +) + + +def _importable(name: str) -> bool: + try: + __import__(name) + return True + except Exception: + return False + + +def _plugin_present(plugin: str, home: Path) -> bool: + plug = home / "plugins" / plugin + alt = home / "plugins" / plugin.replace("-", "_") + return plug.exists() or alt.exists() + + +def organ_status(*, home: Path | None = None) -> dict[str, Any]: + root = home if home is not None else default_hermes_home() + organs: list[dict[str, Any]] = [] + for spec in ORGAN_SPECS: + present = _importable(spec["import_name"]) or _plugin_present(spec["plugin"], root) + organs.append({**spec, "present": present}) + return {"home": str(root), "organs": organs} + + +def union_front_door(*, home: Path | None = None) -> dict[str, Any]: + """Union hermespace plus any present Cube/Insight plugin names.""" + root = home if home is not None else default_hermes_home() + actions: list[dict[str, Any]] = [union_plugins_enabled("hermespace", home=root)] + status = organ_status(home=root) + for organ in status["organs"]: + if organ["present"]: + actions.append(union_plugins_enabled(organ["plugin"], home=root)) + return { + "ok": all(a.get("ok") for a in actions), + "enabled": read_plugins_enabled(root / "config.yaml"), + "actions": actions, + } + + +def offer_organs( + *, + home: Path | None = None, + yes: bool = False, + no_organs: bool = False, + python: str | None = None, +) -> dict[str, Any]: + """Offer to pip-install Cube/Insight if missing. Never required.""" + root = home if home is not None else default_hermes_home() + py = python or sys.executable + offered: list[dict[str, Any]] = [] + if no_organs: + return {"ok": True, "skipped": True, "offered": [], "reason": "no_organs"} + for spec in ORGAN_SPECS: + present = _importable(spec["import_name"]) or _plugin_present(spec["plugin"], root) + rec: dict[str, Any] = { + "plugin": spec["plugin"], + "repo": spec["repo"], + "role": spec["role"], + "present": present, + "installed": False, + } + if present: + rec["action"] = "already" + offered.append(rec) + continue + rec["offer"] = f"Optional organ ({spec['role']}): pip install {spec['repo']}" + if not yes: + rec["action"] = "offered" + offered.append(rec) + continue + try: + proc = subprocess.run( + [py, "-m", "pip", "install", spec["pip"]], + check=False, + capture_output=True, + text=True, + timeout=180, + ) + rec["action"] = "pip" + rec["returncode"] = proc.returncode + rec["installed"] = proc.returncode == 0 and _importable(spec["import_name"]) + except (OSError, subprocess.TimeoutExpired) as exc: + rec["action"] = "error" + rec["error"] = type(exc).__name__ + offered.append(rec) + return {"ok": True, "skipped": False, "offered": offered} + + +def link_space(*, checkout: Path | None = None, home: Path | None = None) -> dict[str, Any]: + """Copy/link Space plugin + skill into $HERMES_HOME. No Cube/Insight source.""" + root = (checkout or package_root()).resolve() + hh = home if home is not None else default_hermes_home() + (hh / "plugins").mkdir(parents=True, exist_ok=True) + (hh / "skills").mkdir(parents=True, exist_ok=True) + plug = hh / "plugins" / "hermespace" + skill = hh / "skills" / "hermespace" + skill_src = root / "skills" / "hermespace" + for target, source in ((plug, root), (skill, skill_src)): + if target.is_symlink() or target.is_file(): + target.unlink() + elif target.is_dir() and not target.is_symlink(): + # Leave a real checkout copy alone; only replace links. + continue + if source.exists(): + target.symlink_to(source) + return { + "ok": plug.exists() and (skill / "SKILL.md").is_file(), + "plugin": str(plug), + "skill": str(skill), + } + + +def install_front_door( + *, + checkout: Path | None = None, + home: Path | None = None, + yes: bool = False, + no_organs: bool = False, + enable: bool = True, + python: str | None = None, +) -> dict[str, Any]: + """Install Space, offer organs, union plugins.enabled, maybe set Cube memory.""" + hh = home if home is not None else default_hermes_home() + out: dict[str, Any] = { + "ok": False, + "home": str(hh), + "note": "Cube and Insight stay standalone repos — soft-import only.", + } + out["link"] = link_space(checkout=checkout, home=hh) + out["organs"] = offer_organs(home=hh, yes=yes, no_organs=no_organs, python=python) + if enable: + out["union"] = union_front_door(home=hh) + else: + out["union"] = {"ok": True, "skipped": True, "enabled": read_plugins_enabled(hh / "config.yaml")} + cube_present = any( + o.get("present") or o.get("installed") + for o in (out["organs"].get("offered") or []) + if o.get("plugin") == "hermescube" + ) or _importable("hermescube") or _plugin_present("hermescube", hh) + if cube_present: + out["memory"] = ensure_cube_memory_provider(home=hh) + if out["memory"].get("action") == "kept": + out["memory"]["note"] = ( + f"left memory.provider={out['memory'].get('provider')} " + "(will not clobber a provider you already chose)" + ) + else: + out["memory"] = { + "ok": True, + "action": "skipped", + "note": "Cube not installed — memory.provider left unset. " + "When Cube is present, Space sets hermescube only if unset.", + } + out["ok"] = bool(out["link"].get("ok")) and bool(out["union"].get("ok")) + return out diff --git a/src/hermespace/ops.py b/src/hermespace/ops.py index 75345ed..f5d2459 100644 --- a/src/hermespace/ops.py +++ b/src/hermespace/ops.py @@ -172,6 +172,24 @@ def add(ok: bool, name: str, detail: str = "") -> None: add(enabled_ok, "plugins_enabled", ",".join(enabled) or "(empty)") add(desk.is_file(), "desktop_plugin", str(desk)) + # Optional organs — WARN only. Cube/Insight stay standalone. + cube_ok = False + insight_ok = False + try: + from hermespace.cube_module import cube_available + + cube_ok = cube_available() or (hh / "plugins" / "hermescube").exists() + except Exception: + cube_ok = (hh / "plugins" / "hermescube").exists() + try: + from hermespace.insight_module import insight_available + + insight_ok = insight_available() or (hh / "plugins" / "hermes-insight").exists() + except Exception: + insight_ok = (hh / "plugins" / "hermes-insight").exists() + add(cube_ok, "cube_organ", "desk+library" if cube_ok else "missing (optional)") + add(insight_ok, "insight_organ", "pattern card" if insight_ok else "missing (optional)") + core_ok = all( c["ok"] for c in checks @@ -191,10 +209,20 @@ def add(ok: bool, name: str, detail: str = "") -> None: } ) integration_ok = core_ok and plugin_ok and skill_ok and enabled_ok + warnings: list[str] = [] + if not cube_ok: + warnings.append("Cube missing — optional organ (desk+library). Offer: hs install --yes") + if not insight_ok: + warnings.append("Insight missing — optional organ (pattern card). Offer: hs install --yes") return { "ok": core_ok, "integration_ok": integration_ok, - "all_green": all(c["ok"] for c in checks), + "all_green": all( + c["ok"] + for c in checks + if c["name"] not in {"cube_organ", "insight_organ", "desktop_plugin", "viewport_serve", "tailscale_ipv4"} + ), + "warnings": warnings, "checks": checks, "home": str(home), "state_dir": str(state_dir()), @@ -219,6 +247,10 @@ def _hints(checks: list[dict[str, Any]], port: int) -> list[str]: out.append("Enable by union: ./scripts/install_hermes.sh (appends hermespace; never rewrites plugins.enabled)") if not by.get("pulse_jobs", {}).get("ok"): out.append("Seed pulse: hs pulse status") + if not by.get("cube_organ", {}).get("ok"): + out.append("Optional Cube (desk+library): hs install --yes # PabloTheThinker/hermescube") + if not by.get("insight_organ", {}).get("ok"): + out.append("Optional Insight (pattern card): hs install --yes # PabloTheThinker/hermes-insight") return out From 90806031575fa1d0c57e908f79947bfce272bd3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 22:23:36 +0000 Subject: [PATCH 24/32] feat(hooks): absorb unused Hermes hooks, shrink inject under 9k Register pre_tool_call, on_skill_lifecycle, kanban claimed/completed, and pre_verify as optional fail-open observers. Mid-load inject cap is strictly under 9k. on_session_finalize harvest has a 10s fail-open budget. Co-authored-by: Pablo --- hermes_plugin/plugin.yaml | 10 ++ plugin.yaml | 10 ++ src/hermespace/hermes_bridge.py | 149 +++++++++++++++++++++++++---- src/hermespace/plugin.py | 10 ++ tests/test_install_kit.py | 162 ++++++++++++++++++++++++++++++++ tests/test_plugin_contract.py | 13 +++ 6 files changed, 338 insertions(+), 16 deletions(-) create mode 100644 tests/test_install_kit.py diff --git a/hermes_plugin/plugin.yaml b/hermes_plugin/plugin.yaml index bffff68..355fff3 100644 --- a/hermes_plugin/plugin.yaml +++ b/hermes_plugin/plugin.yaml @@ -11,7 +11,12 @@ hooks: - on_session_start - pre_llm_call - post_llm_call + - pre_tool_call - post_tool_call + - on_skill_lifecycle + - kanban_task_claimed + - kanban_task_completed + - pre_verify - on_session_end - on_session_finalize - on_session_reset @@ -21,7 +26,12 @@ provides_hooks: - on_session_start - pre_llm_call - post_llm_call + - pre_tool_call - post_tool_call + - on_skill_lifecycle + - kanban_task_claimed + - kanban_task_completed + - pre_verify - on_session_end - on_session_finalize - on_session_reset diff --git a/plugin.yaml b/plugin.yaml index bffff68..355fff3 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -11,7 +11,12 @@ hooks: - on_session_start - pre_llm_call - post_llm_call + - pre_tool_call - post_tool_call + - on_skill_lifecycle + - kanban_task_claimed + - kanban_task_completed + - pre_verify - on_session_end - on_session_finalize - on_session_reset @@ -21,7 +26,12 @@ provides_hooks: - on_session_start - pre_llm_call - post_llm_call + - pre_tool_call - post_tool_call + - on_skill_lifecycle + - kanban_task_claimed + - kanban_task_completed + - pre_verify - on_session_end - on_session_finalize - on_session_reset diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index f9bddba..ab18ca5 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -13,14 +13,18 @@ def _truthy(name: str, default: str = "0") -> bool: return os.environ.get(name, default).strip().lower() in {"1", "true", "yes", "on"} +INJECT_HARD_CAP = 8500 # mid-load shrink: strictly < 9k +HARVEST_BUDGET_S = 10.0 + + def _bounded_context(text: str) -> str: - """Keep native hook output below Hermes's default 10k spill threshold.""" + """Keep native hook output below 9k (Hermes spill is 10k).""" try: - cap = int(os.environ.get("HERMESPACE_PRE_LLM_MAX_CHARS", "9000")) + cap = int(os.environ.get("HERMESPACE_PRE_LLM_MAX_CHARS", str(INJECT_HARD_CAP))) except ValueError: - cap = 9000 - cap = max(2000, min(20_000, cap)) + cap = INJECT_HARD_CAP + cap = max(2000, min(8999, cap)) if len(text) <= cap: return text tail_n = min(1200, cap // 4) @@ -32,6 +36,43 @@ def _bounded_context(text: str) -> str: ) +def _run_fail_open(label: str, fn: Any, *, seconds: float = HARVEST_BUDGET_S) -> None: + """Run ``fn`` with a wall budget. If it overruns, continue (fail-open).""" + import threading + + worker = threading.Thread(target=fn, name=f"hs-{label}", daemon=True) + worker.start() + worker.join(seconds) + if worker.is_alive(): + logger.warning("%s exceeded %.0fs budget — fail-open", label, seconds) + + +def _safe_token(name: str, *, cap: int = 48) -> str: + raw = (name or "").strip().split("(", 1)[0].split()[0] + out = "".join(c for c in raw if c.isalnum() or c in "._:-")[:cap] + return out or "unknown" + + +def _park_label(session_id: str, label: str) -> None: + """Park a name-only silent step. Never persist args or results.""" + if not label: + return + agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") + try: + from hermespace import AccessEngine + from hermespace.access.hub import AccessHub + + engine = AccessEngine( + agent_id=agent_id, + session_id=str(session_id or "default"), + ) + engine.hub.reason_step(label, salience=0.74) + if engine.workspace_id != engine.agent_id: + AccessHub(agent_id=engine.agent_id).reason_step(label, salience=0.74) + except Exception: + pass + + def on_session_start(**kwargs: Any) -> dict[str, str] | None: """Initialize a Hermes v0.20 session and stage first-turn context. @@ -378,8 +419,14 @@ def on_pre_llm_call( except Exception as exc: # noqa: BLE001 logger.debug("neural refresh failed: %s", exc) - high_load = str((desk.load or {}).get("level") or "") == "high" - inject_cap = 900 if high_load else 2800 + load_level = str((desk.load or {}).get("level") or "mid") + high_load = load_level in {"high", "protect"} + if high_load: + inject_cap = 900 + elif load_level == "mid": + inject_cap = 1600 + else: + inject_cap = 2800 block = build_inject_block(desk, max_chars=inject_cap, user_message=msg) if not block.strip(): return None @@ -751,18 +798,21 @@ def on_session_finalize(*, session_id: str | None = None, **kwargs: Any) -> None WorldModel(agent_id=agent_id).leave("session finalized") except Exception as exc: # noqa: BLE001 logger.debug("world finalize failed: %s", exc) - try: - from hermespace import AccessEngine + def _harvest_and_idle() -> None: + try: + from hermespace import AccessEngine - AccessEngine(agent_id=agent_id, session_id=sid).harvest(clear_silent=False) - except Exception as exc: # noqa: BLE001 - logger.debug("access harvest failed: %s", exc) - try: - from hermespace.workbench import Workbench + AccessEngine(agent_id=agent_id, session_id=sid).harvest(clear_silent=False) + except Exception as exc: # noqa: BLE001 + logger.debug("access harvest failed: %s", exc) + try: + from hermespace.workbench import Workbench - Workbench(agent_id=agent_id, session_id=sid).idle_tick(consolidate_every=1) - except Exception as exc: # noqa: BLE001 - logger.debug("session finalize idle failed: %s", exc) + Workbench(agent_id=agent_id, session_id=sid).idle_tick(consolidate_every=1) + except Exception as exc: # noqa: BLE001 + logger.debug("session finalize idle failed: %s", exc) + + _run_fail_open("session_finalize_harvest", _harvest_and_idle, seconds=HARVEST_BUDGET_S) def on_session_reset(*, session_id: str = "", **kwargs: Any) -> None: @@ -780,3 +830,70 @@ def on_session_reset(*, session_id: str = "", **kwargs: Any) -> None: ) except Exception: pass + + +def on_pre_tool_call(*, tool_name: str = "", session_id: str = "", **kwargs: Any) -> None: + """Observe a tool about to fire. Never persist args. Fail-open — do not deny.""" + + _ = kwargs # payloads stay out of the hub + try: + from hermespace.access import AccessEnv + from hermespace.access.engine import workspace_id + + agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") + env = AccessEnv(agent_id=workspace_id(agent_id, session_id or "default")) + alerts = sum(1 for f in env.audit() if f.severity == "alert") + if alerts: + logger.debug("pre_tool_call %s audit_alerts=%s", _safe_token(tool_name), alerts) + except Exception: + pass + + +def on_skill_lifecycle( + *, + skill_name: str = "", + event: str = "", + session_id: str = "", + **kwargs: Any, +) -> None: + """Park skill:{name}:{event} — name only, no skill body.""" + + _ = kwargs + name = _safe_token(skill_name or str(kwargs.get("name") or "")) + ev = _safe_token(event or str(kwargs.get("action") or "event"), cap=24) + _park_label(session_id, f"skill:{name}:{ev}") + + +def on_kanban_task_claimed( + *, + task_id: str = "", + title: str = "", + session_id: str = "", + **kwargs: Any, +) -> None: + """Park kanban:claimed:{id} so the hub moves when a card is claimed.""" + + _ = title + _ = kwargs + tid = _safe_token(task_id or str(kwargs.get("id") or ""), cap=32) + _park_label(session_id, f"kanban:claimed:{tid}") + + +def on_kanban_task_completed( + *, + task_id: str = "", + session_id: str = "", + **kwargs: Any, +) -> None: + """Park kanban:done:{id} — id only.""" + + _ = kwargs + tid = _safe_token(task_id or str(kwargs.get("id") or ""), cap=32) + _park_label(session_id, f"kanban:done:{tid}") + + +def on_pre_verify(*, session_id: str = "", **kwargs: Any) -> None: + """Observe a verify gate. Fail-open. Do not persist payloads.""" + + _ = kwargs + _park_label(session_id, "verify") diff --git a/src/hermespace/plugin.py b/src/hermespace/plugin.py index 3e1fba2..5cbc8f6 100644 --- a/src/hermespace/plugin.py +++ b/src/hermespace/plugin.py @@ -68,13 +68,18 @@ def register(ctx: Any) -> None: from hermespace import __version__ from hermespace.hermes_bridge import ( + on_kanban_task_claimed, + on_kanban_task_completed, on_post_llm_call, on_post_tool_call, on_pre_llm_call, + on_pre_tool_call, + on_pre_verify, on_session_end, on_session_finalize, on_session_reset, on_session_start, + on_skill_lifecycle, on_subagent_start, on_subagent_stop, ) @@ -83,7 +88,12 @@ def register(ctx: Any) -> None: "on_session_start": on_session_start, "pre_llm_call": on_pre_llm_call, "post_llm_call": on_post_llm_call, + "pre_tool_call": on_pre_tool_call, "post_tool_call": on_post_tool_call, + "on_skill_lifecycle": on_skill_lifecycle, + "kanban_task_claimed": on_kanban_task_claimed, + "kanban_task_completed": on_kanban_task_completed, + "pre_verify": on_pre_verify, "on_session_end": on_session_end, "on_session_finalize": on_session_finalize, "on_session_reset": on_session_reset, diff --git a/tests/test_install_kit.py b/tests/test_install_kit.py new file mode 100644 index 0000000..44484bd --- /dev/null +++ b/tests/test_install_kit.py @@ -0,0 +1,162 @@ +"""One-install front door — union organs, memory.provider, doctor WARN.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class TestInstallKit(unittest.TestCase): + def setUp(self) -> None: + self._td = tempfile.TemporaryDirectory() + self.home = Path(self._td.name) / "hermes" + self.space = Path(self._td.name) / "space" + self.home.mkdir() + self.space.mkdir() + self._old_h = os.environ.get("HERMES_HOME") + self._old_s = os.environ.get("HERMESPACE_HOME") + os.environ["HERMES_HOME"] = str(self.home) + os.environ["HERMESPACE_HOME"] = str(self.space) + + def tearDown(self) -> None: + if self._old_h is None: + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = self._old_h + if self._old_s is None: + os.environ.pop("HERMESPACE_HOME", None) + else: + os.environ["HERMESPACE_HOME"] = self._old_s + self._td.cleanup() + + def test_union_keeps_existing_and_adds_space(self) -> None: + from hermespace.hermes_enable import union_plugins_enabled + from hermespace.install_kit import union_front_door + + cfg = self.home / "config.yaml" + cfg.write_text( + "plugins:\n enabled:\n - grokbot\n - hermescube\n", + encoding="utf-8", + ) + (self.home / "plugins" / "hermescube").mkdir(parents=True) + out = union_front_door(home=self.home) + self.assertTrue(out["ok"], out) + enabled = out["enabled"] + self.assertIn("grokbot", enabled) + self.assertIn("hermescube", enabled) + self.assertIn("hermespace", enabled) + again = union_plugins_enabled("hermespace", home=self.home) + self.assertEqual(again["action"], "already") + + def test_memory_provider_does_not_clobber(self) -> None: + from hermespace.hermes_enable import ( + ensure_cube_memory_provider, + read_memory_provider, + ) + + cfg = self.home / "config.yaml" + cfg.write_text("memory:\n provider: notes\n", encoding="utf-8") + out = ensure_cube_memory_provider(home=self.home) + self.assertEqual(out["action"], "kept") + self.assertFalse(out["clobbered"]) + self.assertEqual(read_memory_provider(cfg), "notes") + + def test_memory_provider_set_only_when_unset(self) -> None: + from hermespace.hermes_enable import ( + ensure_cube_memory_provider, + read_memory_provider, + ) + + cfg = self.home / "config.yaml" + cfg.write_text("plugins:\n enabled:\n - hermespace\n", encoding="utf-8") + out = ensure_cube_memory_provider(home=self.home) + self.assertIn(out["action"], {"set", "created"}) + self.assertEqual(read_memory_provider(cfg), "hermescube") + self.assertIn("hermespace", cfg.read_text(encoding="utf-8")) + + def test_install_no_organs_links_space_and_unions(self) -> None: + from hermespace.install_kit import install_front_door + + out = install_front_door( + checkout=ROOT, + home=self.home, + yes=False, + no_organs=True, + enable=True, + ) + self.assertTrue(out["ok"], out) + self.assertTrue((self.home / "plugins" / "hermespace").exists()) + self.assertTrue((self.home / "skills" / "hermespace" / "SKILL.md").is_file()) + self.assertIn("hermespace", out["union"]["enabled"]) + self.assertEqual(out["organs"].get("skipped"), True) + + def test_doctor_warns_not_fails_when_organs_missing(self) -> None: + from hermespace.install_kit import install_front_door + from hermespace.ops import doctor + + install_front_door(checkout=ROOT, home=self.home, no_organs=True, enable=True) + d = doctor(agent_id="kit-doc") + by = {c["name"]: c for c in d["checks"]} + self.assertTrue(d["ok"], d) + self.assertFalse(by["cube_organ"]["ok"]) + self.assertFalse(by["insight_organ"]["ok"]) + self.assertTrue(any("Cube" in w for w in d.get("warnings") or [])) + self.assertTrue(any("Insight" in w for w in d.get("warnings") or [])) + + +class TestInjectCapAndHooks(unittest.TestCase): + def test_harvest_budget_fail_open(self) -> None: + import time + + from hermespace.hermes_bridge import _run_fail_open + + started = time.monotonic() + + def _slow() -> None: + time.sleep(2) + + _run_fail_open("test_budget", _slow, seconds=0.05) + self.assertLess(time.monotonic() - started, 1.0) + + def test_bounded_context_stays_under_9k(self) -> None: + from hermespace.hermes_bridge import INJECT_HARD_CAP, _bounded_context + + self.assertLess(INJECT_HARD_CAP, 9000) + blob = "x" * 20_000 + out = _bounded_context(blob) + self.assertLess(len(out), 9000) + + def test_new_hooks_park_names_only(self) -> None: + from hermespace.access.hub import AccessHub + from hermespace.hermes_bridge import ( + on_kanban_task_claimed, + on_pre_tool_call, + on_skill_lifecycle, + ) + + with tempfile.TemporaryDirectory() as td: + os.environ["HERMESPACE_HOME"] = td + os.environ["HERMESPACE_AGENT_ID"] = "hook-agent" + on_pre_tool_call( + tool_name="read_file", + session_id="default", + args={"secret": "must-not-persist"}, + ) + on_skill_lifecycle(skill_name="demo", event="loaded", session_id="default") + on_kanban_task_claimed(task_id="card-1", title="secret title", session_id="default") + hub = AccessHub(agent_id="hook-agent") + silent = " ".join(hub.state.silent_steps) + self.assertIn("skill:demo:loaded", silent) + self.assertIn("kanban:claimed:card-1", silent) + self.assertNotIn("must-not-persist", silent) + self.assertNotIn("secret title", silent) + os.environ.pop("HERMESPACE_HOME", None) + os.environ.pop("HERMESPACE_AGENT_ID", None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py index 22488ce..2dcdb3e 100644 --- a/tests/test_plugin_contract.py +++ b/tests/test_plugin_contract.py @@ -57,6 +57,14 @@ def test_registration_and_native_lifecycle(self) -> None: "on_session_finalize", } self.assertTrue(required.issubset(ctx.hooks), ctx.hooks) + for extra in ( + "pre_tool_call", + "on_skill_lifecycle", + "kanban_task_claimed", + "kanban_task_completed", + "pre_verify", + ): + self.assertIn(extra, ctx.hooks) self.assertIn("hermespace", ctx.commands) self.assertIn("hermespace", ctx.cli) @@ -118,7 +126,12 @@ def test_plugin_yaml_hygiene(self) -> None: "on_session_start", "pre_llm_call", "post_llm_call", + "pre_tool_call", "post_tool_call", + "on_skill_lifecycle", + "kanban_task_claimed", + "kanban_task_completed", + "pre_verify", "on_session_end", "on_session_finalize", "on_session_reset", From 9ef4d7ec8c86406f06a957bde03ac4c9ab0c6a6d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 22:23:36 +0000 Subject: [PATCH 25/32] =?UTF-8?q?docs:=20front-door=20paragraph=20?= =?UTF-8?q?=E2=80=94=20install=20Space,=20get=20a=20better=20turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community README/PURPOSE: desk plus optional Cube library and Insight pattern card behind one unioned plugins.enabled. No product rename. No weight-access claim. Co-authored-by: Pablo --- PURPOSE.md | 12 ++++++++++-- README.md | 17 ++++++++++++++--- skills/hermespace/references/plugin-hooks.md | 6 +++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/PURPOSE.md b/PURPOSE.md index d0002d2..190f300 100644 --- a/PURPOSE.md +++ b/PURPOSE.md @@ -11,6 +11,11 @@ It is not another agent, model provider, or memory-provider competitor. Hermes remains the actor. Hermespace is the room in which Hermes keeps the current problem coherent. +**Front door.** Install Space and the agent gets a better turn: desk plus +optional Cube library and Insight pattern card as organs, one unioned +`plugins.enabled`. Cube and Insight stay standalone. Space never claims +weight access. + --- ## Why it exists @@ -118,9 +123,12 @@ Hermespace uses the current public plugin contracts: - `on_session_start` - `pre_llm_call` - `post_llm_call` -- `post_tool_call` +- `pre_tool_call` / `post_tool_call` +- `on_skill_lifecycle` +- `kanban_task_claimed` / `kanban_task_completed` +- `pre_verify` - `on_session_end` -- `on_session_finalize` +- `on_session_finalize` (harvest ≤10s, fail-open) - `on_session_reset` - `subagent_start` / `subagent_stop` - `/hermespace` slash command diff --git a/README.md b/README.md index c30ea8a..ee4e746 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ selects active concepts before a material turn, keeps silent intermediates out of user chat, observes native tools/subagents, and preserves continuity after the turn. Hermes remains the actor; Hermespace keeps the problem coherent. +Install Space and any Hermes Agent gets a better turn: a bounded desk, plus +optional Cube library and Insight pattern card as organs behind one unioned +`plugins.enabled`. Cube and Insight stay standalone repos — Space soft-imports +them when present and never rewrites a plugin list or a memory provider you +already chose. + Production target: **Hermes Agent v0.20.0+** — CLI, gateways, A2A, tools, subagents, turn boundaries, and finalization. @@ -152,8 +158,9 @@ Alongside the world, Hermespace provides a desk for the current turn — FOA, du ### Quick start ```bash -hermes plugins install PabloTheThinker/hermespace --enable -hermes hermespace doctor +hs install # Space plugin+skill; offers Cube + Insight +# or: hermes plugins install PabloTheThinker/hermespace --enable +hs ops doctor # FAIL if Space is broken; WARN if Cube/Insight missing ``` For a development checkout: @@ -182,9 +189,13 @@ ctx = r["model_context"] # → model (includes world context) | `on_session_start` | Initialize session desk/hub and stage first-turn context | | `pre_llm_call` | Select bounded Access Workspace context (user-message only) | | `post_llm_call` | Observe successful native response and update workbench | +| `pre_tool_call` | Observe upcoming tool (name only; fail-open, never deny) | | `post_tool_call` | Record bounded tool-name/count telemetry (no payloads) | +| `on_skill_lifecycle` | Park `skill:{name}:{event}` — no skill body | +| `kanban_task_claimed` / `kanban_task_completed` | Park kanban id so the hub moves | +| `pre_verify` | Observe a verify gate (fail-open) | | `on_session_end` | Lightweight end-of-turn receipt (Hermes v0.20 semantics) | -| `on_session_finalize` | Idempotent harvest, world leave, idle maintenance | +| `on_session_finalize` | Harvest ≤10s fail-open, world leave, idle maintenance | | `subagent_start/stop` | Track specialist lifecycle for runtime observability | --- diff --git a/skills/hermespace/references/plugin-hooks.md b/skills/hermespace/references/plugin-hooks.md index c880884..ebcc0dc 100644 --- a/skills/hermespace/references/plugin-hooks.md +++ b/skills/hermespace/references/plugin-hooks.md @@ -14,9 +14,13 @@ Repository `__init__.py` → `hermespace.plugin.register(ctx)`. | on_session_start | Initialize session scope; stage first-turn context | | pre_llm_call | Gate + bounded desk/hub inject | | post_llm_call | Observe successful native turn | +| pre_tool_call | Observe upcoming tool; fail-open; never persist args | | post_tool_call | Count tool name only; never persist payloads | +| on_skill_lifecycle | Park skill name+event only | +| kanban_task_claimed / completed | Park kanban id so the hub moves | +| pre_verify | Observe verify gate; fail-open | | on_session_end | Lightweight turn boundary | -| on_session_finalize | Idempotent harvest + idle maintenance | +| on_session_finalize | Harvest ≤10s fail-open + idle maintenance | | on_session_reset | Prime rotated gateway session | | subagent_start/stop | Track specialist lifecycle | From f757c72aa57774b8e2fd534e17fc04e3c724c9e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 22:30:31 +0000 Subject: [PATCH 26/32] feat(access): context surgery and bounded self-trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep one user-message inject under mid ≤2.8k / high ≤900. Skip fluent ack, pinned protect, missing organs, and shared-hub subagents. Park a capped self-trace on the hub (goal, decision, tool:name, Report line) and seal one-line improve into Cube when present. No consciousness claim. Co-authored-by: Pablo --- PURPOSE.md | 6 +- README.md | 8 +- src/hermespace/access/engine.py | 35 +++++ src/hermespace/access/env.py | 10 +- src/hermespace/context_surgery.py | 147 ++++++++++++++++++ src/hermespace/gate.py | 1 + src/hermespace/grid/viewport.py | 10 ++ src/hermespace/hermes_bridge.py | 245 +++++++++++++----------------- src/hermespace/hermes_runtime.py | 10 ++ src/hermespace/inject.py | 20 ++- src/hermespace/insight_module.py | 14 +- src/hermespace/self_model.py | 150 ++++++++++++++++++ src/hermespace/workflow.py | 66 +++++--- tests/test_context_surgery.py | 224 +++++++++++++++++++++++++++ tests/test_gate.py | 3 + tests/test_insight_module.py | 8 +- tests/test_plugin_contract.py | 2 +- 17 files changed, 772 insertions(+), 187 deletions(-) create mode 100644 src/hermespace/context_surgery.py create mode 100644 src/hermespace/self_model.py create mode 100644 tests/test_context_surgery.py diff --git a/PURPOSE.md b/PURPOSE.md index 190f300..4aafc26 100644 --- a/PURPOSE.md +++ b/PURPOSE.md @@ -14,7 +14,11 @@ current problem coherent. **Front door.** Install Space and the agent gets a better turn: desk plus optional Cube library and Insight pattern card as organs, one unioned `plugins.enabled`. Cube and Insight stay standalone. Space never claims -weight access. +weight access. Context is progressive: one inject, mid ≤2.8k, high/protect +≤900. After a material turn the hub keeps a bounded self-trace (last goal, +decision, `tool:name` list, sealed Report line) so the desk can improve. +Product language is self-model / self-trace / improve — never “true +self-conscious,” never a phenomenal-consciousness claim. --- diff --git a/README.md b/README.md index ee4e746..48f0879 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ The `world_evolve` pulse job runs this hourly. Manual: `hs world evolve` or `Wor ### Context Injection -Every `pre_llm_call` injects the world context: epoch badge, active concepts, wisdom (top beliefs), timeline, pulse summary, and desk status. The agent always knows where it is in its own story. +One user-message inject, progressive disclosure. Mid-load target ≤2.8k (hard cap <9k); high/protect ≤900. Fluent ack, high load, or a missing organ injects nothing. Cube is skipped when the provider already prefetched. Spoken Report stays short (line 1 = next action); dense context is not also dumped into chat. Harvest runs on finalize (≤10s, fail-open) and never rides the inject path. ### CLI @@ -146,9 +146,9 @@ Alongside the world, Hermespace provides a desk for the current turn — FOA, du |---|---| | **Functional Access Workspace** | Harness global workspace — hold/summon concepts, silent reasoning, FOA≤4, hub≤25 | | **Focus of Attention** | ≤4 items, single active goal per turn | -| **Dual Decode** | Human gets a short report; the model gets dense context. Never dump raw inject into chat channels. | +| **Dual Decode** | Human gets a short report (line 1 = next action); the model gets one lean inject. Never dump raw inject into chat. | | **Cube heart (optional)** | Soft cable to HermesCube — skip `cube_beat` on `pre_llm` when `memory.provider=hermescube` (MemoryManager already prefetched); `pulse_charge` / `sync_world_beliefs` still recharge World; standalone warehouse when Cube absent | -| **Insight (optional)** | Soft cable next to `cube_beat` on `pre_llm_call` — `perceive_card` only, skip on high/protect; never required | +| **Insight (optional)** | Soft cable — `perceive_card` only (≤400), skip on high/protect; usable/lever write-back on `desk.meta` only; never required | | **Skills + Memory Fabric** | Ranks Hermes skills per goal; injects MEMORY.md / USER.md excerpts | | **Neural FOA** | `HERMESPACE_NEURAL_BACKEND=auto` — Ollama embeddings when live, hash fallback | | **Autonomy Grid** | Missions, lenses, dream, self-talk, skillbench, title/tree, access gates. Ground-up design. | @@ -215,7 +215,7 @@ User message DECODE report (human) + context (model) │ ▼ - BROADCAST plugin inject + world context + BROADCAST one lean inject (desk FOA; no world dump) │ ▼ ACT Hermes tools / code / ship diff --git a/src/hermespace/access/engine.py b/src/hermespace/access/engine.py index e5ff534..796c2b6 100644 --- a/src/hermespace/access/engine.py +++ b/src/hermespace/access/engine.py @@ -650,6 +650,41 @@ def observe_turn( agent_hub = AccessHub(agent_id=self.agent_id) park_spoken_intermediates(agent_hub, report, max_n=3) + try: + from hermespace.self_model import maybe_seal_improve, record_self_trace + from hermespace.store import load_desk + + desk = load_desk(self.desk_engine.desk_path) + tools = [ + s + for s in (self.hub.state.silent_steps or []) + if str(s).startswith("tool:") + ] + trace = record_self_trace( + self.hub, + goal=desk.goal, + decision=desk.decision, + tools=tools, + report=report, + ) + result["self_trace"] = trace + if self.workspace_id != self.agent_id: + from hermespace.access.hub import AccessHub + + record_self_trace( + AccessHub(agent_id=self.agent_id), + goal=desk.goal, + decision=desk.decision, + tools=tools, + report=report, + ) + improve = maybe_seal_improve(desk, agent_id=self.agent_id) + result["improve"] = { + "ok": improve.get("ok"), + "skipped": improve.get("skipped"), + } + except Exception as exc: + result["self_model_error"] = type(exc).__name__ except Exception as exc: result["loop_error"] = type(exc).__name__ diff --git a/src/hermespace/access/env.py b/src/hermespace/access/env.py index db0ebfc..4e1661d 100644 --- a/src/hermespace/access/env.py +++ b/src/hermespace/access/env.py @@ -504,11 +504,11 @@ def reflection_prompt_for_agent(self) -> str: # --- agent protocol: force externalization --- def protocol_block(self, *, high_load: bool = False) -> str: - """Instructions so Hermes *writes into* the external Access Workspace before acting. + """Operator / bind protocol. Do not dump this essay onto the pre_llm inject. - This is how we 'see inside' without weight access: the agent is required - to park silent intermediates in the workspace (model context), while the - user only sees Report. + Bound intervention lines may ride the inject when a bind is active. + reflect()/audit write pending_silent for the *next* turn — they do not + dump a self-essay into this turn's model context. """ if not self._env.get("protocol_enabled", True): return "" @@ -622,11 +622,13 @@ def operator_view(self) -> dict[str, Any]: "audit": [f.to_dict() for f in findings], "audit_alerts": sum(1 for f in findings if f.severity == "alert"), "last_reflections": (self._env.get("reflections") or [])[-3:], + "self_trace": dict((self.space.state.meta or {}).get("self_trace") or {}), "trace_path": str(self.trace_path), "protocol_enabled": bool(self._env.get("protocol_enabled", True)), "theory": { "source": "Hermespace Access Workspace / GWT", "access": "externalized verbalizable workspace — not weight readout", + "self_model": "self-trace / improve — not phenomenal consciousness", "night_path": "dream_harvest → Cube seal → pulse charge", }, } diff --git a/src/hermespace/context_surgery.py b/src/hermespace/context_surgery.py new file mode 100644 index 0000000..2e99ae1 --- /dev/null +++ b/src/hermespace/context_surgery.py @@ -0,0 +1,147 @@ +"""Progressive disclosure for the one user-message inject. + +Hard budgets — do not eat the turn: + + mid default ≤ 2.8k + high/protect ≤ 900 + hard cap < 9k (8500 safety net) + FOA ≤ 4 · silent chain ≤ 8 · Insight card ≤ 400 + +Spoken Report stays short (line 1 = next action). Dense context is not +also dumped into chat. Harvest never rides this path. +""" + +from __future__ import annotations + +import re +from typing import Any, Iterable + +MID_INJECT_CAP = 2800 +HIGH_INJECT_CAP = 900 +INJECT_HARD_CAP = 8500 # strictly < 9k +INSIGHT_CARD_CHARS = 400 +SELF_TRACE_INJECT_CHARS = 280 +FOA_CAP = 4 +SILENT_CHAIN_CAP = 8 + +_FLUENT_ACK_RE = re.compile( + r"^\s*(ok|okay|k|thanks|thank you|ty|cool|nice|good|perfect|boom|" + r"got it|gotcha|sure|np|lgtm|cheers|heartbeat_ok|👍|❤️|yes|yep|nope|no)" + r"\s*[.!]*\s*$", + re.I, +) + +# Never appear on the inject path (operator / harvest / lattice / claims). +_FORBIDDEN_LINE = re.compile( + r"J-Lens readout|What Hermes has on its mind|lens_markdown|" + r"true self-conscious|phenomenal consciousness|" + r"### Workbench|### Hermespace runtime|" + r"dream_harvest|harvest items|recall brief|perceive\(\)\[.card.\]|" + r"### Lattice|insight_lattice", + re.I, +) + + +def is_fluent_ack(message: str) -> bool: + """Short acknowledgement — inject nothing this turn.""" + msg = (message or "").strip() + return bool(msg) and len(msg) < 48 and bool(_FLUENT_ACK_RE.match(msg)) + + +def inject_budget(load_level: str | None) -> int: + level = str(load_level or "mid").strip().lower() + if level in {"high", "protect", "protected"}: + return HIGH_INJECT_CAP + return MID_INJECT_CAP + + +def strip_needed( + *, + message: str = "", + load_level: str = "mid", + is_first_turn: bool = False, + has_bind: bool = False, + missing_organ: bool = False, +) -> bool: + """False → inject nothing (fluent ack, high load, missing organ).""" + if is_fluent_ack(message): + return False + if missing_organ and not has_bind and not is_first_turn: + return False + level = str(load_level or "mid").strip().lower() + if level in {"high", "protect", "protected"}: + return bool(has_bind or is_first_turn) + return True + + +def is_shared_hub_child(session_id: str = "", kwargs: dict[str, Any] | None = None) -> bool: + """Subagent / kanban worker — share the one desk; do not inject a full copy.""" + kw = kwargs or {} + for key in ( + "parent_session_id", + "parent_task_id", + "subagent_id", + "is_subagent", + ): + if kw.get(key): + return True + role = str(kw.get("agent_role") or kw.get("role") or "").strip().lower() + if role in {"subagent", "kanban", "worker"}: + return True + try: + from hermespace.hermes_runtime import runtime + + st = runtime.status(session_id or "") + return bool(st.get("shared_hub")) + except Exception: + return False + + +def dual_decode_line() -> str: + """Tiny protocol — spoken Report stays short; dense context stays here.""" + return ( + "### Access Workspace\n" + "- Report line 1 = next action. Dense context stays here — do not dump into chat." + ) + + +def assemble_inject(parts: Iterable[str], *, budget: int) -> str: + """Join strips in order until the budget is spent. One user-message inject.""" + cap = max(80, min(int(budget), INJECT_HARD_CAP)) + out: list[str] = [] + used = 0 + for raw in parts: + piece = sanitize_inject(str(raw or "")).strip() + if not piece: + continue + sep = 2 if out else 0 + if used + sep + len(piece) <= cap: + out.append(piece) + used += sep + len(piece) + continue + remain = cap - used - sep + if remain >= 40: + out.append(piece[: remain - 3].rstrip() + "...") + break + block = "\n\n".join(out) + if len(block) > cap: + block = block[: cap - 3].rstrip() + "..." + return block + + +def sanitize_inject(text: str) -> str: + """Drop operator-lens / harvest / consciousness-claim lines if they leak in.""" + if not text: + return "" + kept: list[str] = [] + for line in text.splitlines(): + if _FORBIDDEN_LINE.search(line): + continue + kept.append(line) + return "\n".join(kept) + + +def harvest_on_inject(text: str) -> bool: + """True if harvest prose leaked onto the 9k/inject path (must stay false).""" + low = (text or "").casefold() + return "dream_harvest" in low or "harvest items" in low diff --git a/src/hermespace/gate.py b/src/hermespace/gate.py index f324ecb..4a75fea 100644 --- a/src/hermespace/gate.py +++ b/src/hermespace/gate.py @@ -21,6 +21,7 @@ _TRIVIAL_RE = re.compile( r"^\s*(ok|okay|k|thanks|thank you|ty|cool|nice|good|perfect|boom|" + r"got it|gotcha|sure|np|lgtm|cheers|" r"heartbeat_ok|👍|❤️|yes|yep|nope|no)\s*[.!]*\s*$", re.I, ) diff --git a/src/hermespace/grid/viewport.py b/src/hermespace/grid/viewport.py index 89dc82b..2ea9e7f 100644 --- a/src/hermespace/grid/viewport.py +++ b/src/hermespace/grid/viewport.py @@ -67,11 +67,21 @@ def _foa_paint(agent_id: str, desk_json: dict[str, Any]) -> dict[str, Any]: dec = decision.strip() or "unsealed" if len(dec) > 28: dec = dec[:27].rstrip() + "…" + self_trace: dict[str, Any] = {} + try: + from hermespace.self_model import read_self_trace + from hermespace.access import AccessEnv + + aid = agent_id if agent_id not in ("default", "") else "hermes-agent" + self_trace = read_self_trace(AccessEnv(agent_id=aid).space) + except Exception: + self_trace = {} return { "goal": goal, "focus": focus, "parked": parked, "decision": decision, + "self_trace": self_trace, "chip": f"{g} · FOA {len(focus)} · {len(parked)} parked · {dec}", } diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index ab18ca5..8dc76d7 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -13,7 +13,18 @@ def _truthy(name: str, default: str = "0") -> bool: return os.environ.get(name, default).strip().lower() in {"1", "true", "yes", "on"} -INJECT_HARD_CAP = 8500 # mid-load shrink: strictly < 9k +from hermespace.context_surgery import ( # noqa: E402 + INJECT_HARD_CAP, + MID_INJECT_CAP, + assemble_inject, + dual_decode_line, + inject_budget, + is_fluent_ack, + is_shared_hub_child, + sanitize_inject, + strip_needed, +) + HARVEST_BUDGET_S = 10.0 @@ -287,28 +298,23 @@ def on_pre_llm_call( reg = regulate(msg, agent_id=agent_id) if reg.handled: - # Short user-facing note + keep inject for model + # Short user-facing note + one lean inject. Never prepend session-start. desk = load_desk(eng.desk_path) note = reg.message - block = build_inject_block(desk, max_chars=2000, user_message=msg) - try: - from hermespace.grid.access import pending_inject_block - - block = (block + "\n\n" + pending_inject_block(agent_id)).strip() - except Exception: - pass - # Prefer explicit regulation reply as dual-channel: model sees full; user gets note via say path if auto - return { - "context": _bounded_context( - ((start_context + "\n\n") if start_context else "") - + block - + "\n\n### Boundary regulation (this turn)\n" - + f"- action: {reg.action}\n" - + f"- user_reply_hint: {note}\n" - + "- Honor pocket rules. Do not write outside without approved permit.\n" - ), - # Some hosts ignore unknown keys; context is enough for model - } + block = assemble_inject( + [ + dual_decode_line(), + build_inject_block( + desk, max_chars=MID_INJECT_CAP, user_message=msg, lean=True + ), + "### Boundary regulation (this turn)\n" + f"- action: {reg.action}\n" + f"- user_reply_hint: {note}\n" + "- Honor pocket rules. Do not write outside without approved permit.", + ], + budget=MID_INJECT_CAP, + ) + return {"context": _bounded_context(block)} except Exception as exc: # noqa: BLE001 logger.debug("regulate failed: %s", exc) @@ -335,14 +341,18 @@ def on_pre_llm_call( do_it, reason = should_inject( msg, desk_ready=ready, is_first_turn=bool(is_first_turn) ) - if not do_it: - if start_context: - try: - from hermespace.hermes_runtime import runtime + if not do_it or is_fluent_ack(msg): + return None + if is_shared_hub_child(sid, kwargs): + # One desk: park on the shared agent hub. Do not inject a full copy. + try: + from hermespace.access import AccessHub - runtime.stage_start_context(sid, start_context) - except Exception: - pass + AccessHub(agent_id=agent_id).sync_from_desk( + load_desk(eng.desk_path), user_message=msg + ) + except Exception: + pass return None if msg and ready: @@ -360,18 +370,25 @@ def on_pre_llm_call( # High load / monotropic: cognition clamp only — skip neural FOA # (often ~200–300ms) unless explicitly forced on. - high_load = str((desk.load or {}).get("level") or "") == "high" - if not high_load and msg: + # protect is operator-pinned and must not be recomputed away. + pinned = str((desk.load or {}).get("level") or "") + high_load = pinned in {"high", "protect"} + if pinned != "protect" and not high_load and msg: # cheap recompute so high flag can flip this turn try: desk.recompute_cognition(msg) - high_load = str((desk.load or {}).get("level") or "") == "high" + high_load = str((desk.load or {}).get("level") or "") in {"high", "protect"} except Exception: pass if need_heavy: - desk.recompute_cognition(msg) - high_load = str((desk.load or {}).get("level") or "") == "high" + if pinned != "protect": + desk.recompute_cognition(msg) + high_load = str((desk.load or {}).get("level") or "") in {"high", "protect"} + else: + high_load = True + if isinstance(desk.load, dict): + desk.load["level"] = "protect" skip_neural = ( high_load # Native pre_llm hooks are latency-sensitive. Neural @@ -421,58 +438,13 @@ def on_pre_llm_call( load_level = str((desk.load or {}).get("level") or "mid") high_load = load_level in {"high", "protect"} - if high_load: - inject_cap = 900 - elif load_level == "mid": - inject_cap = 1600 - else: - inject_cap = 2800 - block = build_inject_block(desk, max_chars=inject_cap, user_message=msg) - if not block.strip(): - return None - if start_context and bool(is_first_turn): - block = (start_context + "\n\n" + block).strip() - - try: - from hermespace.world import world_context - # First turn gets full world context; subsequent turns get delta - # High load: skip world prose entirely (FOA only) - if high_load and not is_first_turn: - world_context_block = "" - else: - last_count = desk.meta.get("world_entry_count", 0) - world_context_block = world_context( - agent_id, - full=bool(is_first_turn) or last_count == 0, - known_entries=last_count, - ) - # Store entry count for next turn's delta - try: - from hermespace.world import WorldModel - wm = WorldModel(agent_id=agent_id) - desk.meta["world_entry_count"] = wm.archive.count() - from hermespace.store import save_desk - save_desk(desk) - except Exception: - pass - except Exception: - world_context_block = "" - - try: - from hermespace.grid.access import pending_inject_block + inject_cap = inject_budget(load_level) + # Session-start essay is observer-only. Never prepend it onto the inject. + _ = start_context - block += "\n\n" + pending_inject_block(agent_id) - except Exception: - pass - - if world_context_block and not high_load: - block += "\n\n" + world_context_block - elif world_context_block and high_load and is_first_turn: - # keep tiny world stamp only - block += "\n\n" + world_context_block[:400] - - # HermesCube / standalone warehouse — dense deep memory under load - # Prefer center.beat (1.1); falls back to heart inject / standalone strip + cube_block = "" + insight_strip = "" + bound_strip = "" try: from hermespace.cube_module import cube_beat, skip_cube_foa_strip from hermespace.access import AccessHub @@ -500,9 +472,8 @@ def on_pre_llm_call( session_id=sid or "hermespace", ) cube_block = str(beat.get("block") or "") - if cube_block: - block += "\n\n" + cube_block - # Insight strip — next to cube_beat. perceive_card only; skip if missing. + # Insight: perceive_card only. Write-back (usable/lever) on desk.meta. + # Never inject perceive()["card"], recall brief, or the lattice. try: from hermespace.insight_module import insight_card @@ -512,11 +483,15 @@ def on_pre_llm_call( "mode": icard.get("mode"), "skipped": icard.get("skipped"), } - if icard.get("card"): - block += "\n\n" + str(icard["card"]) + wb = icard.get("writeback") or {} + if isinstance(wb, dict) and (wb.get("usable") is not None or wb.get("lever") is not None): + desk.meta["insight_writeback"] = { + k: wb[k] for k in ("usable", "lever") if k in wb + } + if icard.get("card") and not high_load: + insight_strip = str(icard["card"]) except Exception: pass - # OEW beat — higher-order park + causal broadcast (model channel only) from hermespace.access.oew import ensure_oew_env_default ensure_oew_env_default() @@ -533,7 +508,6 @@ def on_pre_llm_call( from hermespace.access import AccessEnv env = AccessEnv(agent_id=access_id) - # sync_from_desk already ran above — skip second rewrite env_meta = env.advance_turn( user_message=msg, desk=desk, @@ -544,16 +518,13 @@ def on_pre_llm_call( ) if env_meta.get("report"): desk.say = str(env_meta["report"]) - jblock = str(env_meta.get("broadcast") or "") or env.filtered_broadcast( - high_load=high_load - ) - if jblock: - block += "\n\n" + jblock - proto = env.protocol_block(high_load=high_load) - if proto: - block += "\n\n" + proto - # Lens is operator-only. Do not inject the operator readout - # as if it were the model's own workspace. + # Bound lines only — not the protocol essay, not lens, not reflect dump. + try: + from hermespace.access.loop import bound_protocol_lines + + bound_strip = bound_protocol_lines(env) + except Exception: + bound_strip = "" desk.meta["access"] = { "hub_n": len(js.state.hub), "focus_n": len(js.state.focus), @@ -570,9 +541,6 @@ def on_pre_llm_call( } desk.meta["user_reply_hint"] = (desk.say or "")[:240] except Exception: - jblock = js.broadcast_block(high_load=high_load) - if jblock: - block += "\n\n" + jblock desk.meta["access"] = { "hub_n": len(js.state.hub), "focus_n": len(js.state.focus), @@ -581,60 +549,56 @@ def on_pre_llm_call( try: from hermespace.store import save_desk - save_desk(desk) + save_desk(desk, eng.desk_path) except Exception: pass except Exception: pass - # Workbench status — only on first turn or when state changes (skip under high) - if not high_load: - try: - st = Workbench(agent_id=agent_id, session_id=sid).status() - last_mode = desk.meta.get("workbench_mode", "") - if is_first_turn or st.get("mode") != last_mode: - block += ( - f"\n### Workbench\n" - f"- mode: {st.get('mode')} · park: {st.get('park_count')} · " - f"idle_ticks: {st.get('idle_ticks')}\n" - f"- last_report: {(st.get('last_report') or '')[:120]}\n" - ) - desk.meta["workbench_mode"] = st.get("mode") - try: - from hermespace.store import save_desk - save_desk(desk) - except Exception: - pass - except Exception: - pass + has_bind = bool((bound_strip or "").strip()) + if not strip_needed( + message=msg, + load_level=load_level, + is_first_turn=bool(is_first_turn), + has_bind=has_bind, + ): + return None + desk_block = build_inject_block( + desk, max_chars=inject_cap, user_message=msg, lean=True + ) + parts = [dual_decode_line(), desk_block] + if has_bind: + parts.append(bound_strip) + # One organ strip if it fits: Insight card preferred, else Cube (never dual-pump). + organ = "" if not high_load: + organ = insight_strip or cube_block + if organ: + parts.append(organ) + if load_level == "low": try: - from hermespace import AccessEngine + from hermespace.self_model import format_self_trace, read_self_trace + from hermespace.access import AccessHub - metrics = AccessEngine(agent_id=agent_id, session_id=sid).metrics() - block += ( - "\n### Hermespace runtime\n" - f"- hub={metrics.get('hub_n')}/{metrics.get('hub_cap')} " - f"focus={metrics.get('focus_n')}/{metrics.get('focus_cap')} " - f"silent={metrics.get('silent_n')}/{metrics.get('silent_cap')}\n" + parts.append( + format_self_trace( + read_self_trace(AccessHub(agent_id=access_id)), + for_inject=True, + ) ) except Exception: pass - # Dual-decode hint for hosts that only accept context: short user Report + block = sanitize_inject(assemble_inject(parts, budget=inject_cap)) + if not block.strip(): + return None + user_hint = "" try: user_hint = str((desk.meta or {}).get("user_reply_hint") or desk.say or "")[:240] except Exception: user_hint = "" - if user_hint: - block += ( - "\n\n### Dual decode (honor this)\n" - f"- user_reply_hint: {user_hint}\n" - "- Speak only the user_reply_hint (or shorter) to the user. " - "Do not dump Access Workspace hub / silent chain / this inject block into chat.\n" - ) try: eng.episodes.write( @@ -645,7 +609,6 @@ def on_pre_llm_call( except Exception: pass - # Prefer dual-channel when host supports unknown keys; context always set result: dict[str, str] = {"context": _bounded_context(block)} if user_hint: result["user_reply_hint"] = user_hint diff --git a/src/hermespace/hermes_runtime.py b/src/hermespace/hermes_runtime.py index 2090d15..f7c81d2 100644 --- a/src/hermespace/hermes_runtime.py +++ b/src/hermespace/hermes_runtime.py @@ -52,6 +52,7 @@ class SessionRuntime: last_response_chars: int = 0 last_event: str = "session_start" finalized: bool = False + shared_hub: bool = False recent_tools: list[str] = field(default_factory=list) @@ -185,11 +186,20 @@ def tool(self, session_id: str | None, *, agent_id: str, name: str) -> None: if item.tool_calls == 1 or item.tool_calls % 10 == 0: self._save(item) + def mark_shared_hub(self, session_id: str | None, *, agent_id: str) -> None: + """Kanban / subagent workers share the one desk — no full inject copy.""" + with self._lock: + item = self._get(session_id, agent_id=agent_id) + item.shared_hub = True + item.last_event = "shared_hub" + self._save(item) + def subagent(self, session_id: str | None, *, agent_id: str, started: bool) -> None: with self._lock: item = self._get(session_id, agent_id=agent_id) if started: item.subagent_starts += 1 + item.shared_hub = True item.last_event = "subagent_start" else: item.subagent_stops += 1 diff --git a/src/hermespace/inject.py b/src/hermespace/inject.py index 76f1187..654cbe0 100644 --- a/src/hermespace/inject.py +++ b/src/hermespace/inject.py @@ -14,6 +14,7 @@ def build_inject_block( max_chars: int = 2000, include_episodes: int = 4, user_message: str = "", + lean: bool = False, ) -> str: desk = desk or load_desk() if user_message and not desk.load: @@ -25,11 +26,14 @@ def build_inject_block( high = str(desk.load.get("level")) == "high" # GWT: under high load, broadcast only focus + goal/decision/say + # Never raise the caller budget — mid target is ≤2.8k. if high: max_chars = min(max_chars, 900) include_episodes = 0 - elif desk.meta.get("fabric"): - max_chars = max(max_chars, 2800) + else: + max_chars = min(max_chars, 2800) + if lean: + include_episodes = 0 parts: list[str] = ["## Hermespace live desk (use before acting)"] @@ -64,7 +68,7 @@ def build_inject_block( ) neural = desk.meta.get("neural") or {} - if neural.get("enabled"): + if neural.get("enabled") and not lean: parts.append( f"**Neural space:** backend={neural.get('backend')} traces={neural.get('n_traces')} " f"ignition={neural.get('ignition_threshold')} residual_n={neural.get('residual_norm')}" @@ -134,9 +138,9 @@ def build_inject_block( f"- ({e.get('outcome', 'info')}) {str(e.get('content', ''))[:100]}" ) - # Hermes skills + MEMORY/USER (user's own fabric) + # Hermes skills + MEMORY/USER (user's own fabric) — skip on lean hook path fabric = desk.meta.get("fabric") or {} - if fabric and not high: + if fabric and not high and not lean: try: from hermespace.hermes_fabric import FabricSnapshot, SkillHit hits = [ @@ -159,8 +163,8 @@ def build_inject_block( if isinstance(h, dict): parts.append(f"- `{h.get('name')}` score={h.get('score')}") - # Grid layer (missions, lens, selftalk, hot modules) - if not high: + # Grid layer (missions, lens, selftalk, hot modules) — operator lens stays off inject + if not high and not lean: try: import os from hermespace.grid import Grid @@ -172,7 +176,7 @@ def build_inject_block( except Exception: # noqa: BLE001 pass - if not high: + if not high and not lean: try: from hermespace.semantic import SemanticStore diff --git a/src/hermespace/insight_module.py b/src/hermespace/insight_module.py index 19be3fc..500c12b 100644 --- a/src/hermespace/insight_module.py +++ b/src/hermespace/insight_module.py @@ -93,6 +93,7 @@ def insight_card( "adapter": SPACE_INSIGHT_ADAPTER_VERSION, "mode": "missing", "card": "", + "writeback": {}, "required": False, } if _high_or_protect(load, high_load=high_load): @@ -120,12 +121,23 @@ def insight_card( out["error"] = type(e).__name__ return out + writeback: dict[str, Any] = {} if isinstance(rec, str): card = rec elif isinstance(rec, dict): + # perceive_card only — never perceive()["card"], recall brief, or lattice. card = str(rec.get("card") or "") + for key in ("usable", "lever"): + if rec.get(key) is not None: + writeback[key] = rec[key] else: card = str(rec or "") cap = max_chars if max_chars and max_chars > 0 else INSIGHT_CARD_CHARS - out.update({"mode": "insight", "card": _bound_card(card, cap=cap)}) + out.update( + { + "mode": "insight", + "card": _bound_card(card, cap=cap), + "writeback": writeback, + } + ) return out diff --git a/src/hermespace/self_model.py b/src/hermespace/self_model.py new file mode 100644 index 0000000..43002d8 --- /dev/null +++ b/src/hermespace/self_model.py @@ -0,0 +1,150 @@ +"""Bounded self-model: self-trace on the hub, then improve. + +Product language: self-model / self-trace / improve. +Never “true self-conscious.” No phenomenal-consciousness claim. + +After a material turn, Space already parks 1–3 intermediates from the +actual assistant text. This module adds a capped “what I just did” +record on the hub — readable via ``hs access view`` / FOA chip. +It is injected only on low load. reflect()/audit stay operator or +post_llm and write pending_silent for the *next* turn. +""" + +from __future__ import annotations + +from typing import Any + +GOAL_CAP = 120 +DECISION_CAP = 120 +REPORT_CAP = 160 +TOOL_CAP = 6 +TRACE_INJECT_CAP = 280 + + +def _first_line(text: str, cap: int) -> str: + line = (text or "").strip().splitlines()[0] if (text or "").strip() else "" + line = line.strip() + if len(line) <= cap: + return line + return line[: cap - 1].rstrip() + "…" + + +def _tool_names(items: list[str] | None) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for raw in items or []: + s = str(raw or "").strip() + if not s.startswith("tool:"): + name = s.split("(", 1)[0].strip() + if name and not name.startswith("tool:"): + s = f"tool:{name}" + else: + continue + key = s.casefold() + if key in seen: + continue + seen.add(key) + out.append(s[:80]) + if len(out) >= TOOL_CAP: + break + return out + + +def build_self_trace( + *, + goal: str = "", + decision: str = "", + tools: list[str] | None = None, + report: str = "", +) -> dict[str, Any]: + """Capped self-trace dict — last goal / decision / tool:name list / Report line.""" + return { + "goal": _first_line(goal, GOAL_CAP), + "decision": _first_line(decision, DECISION_CAP), + "tools": _tool_names(tools), + "report": _first_line(report, REPORT_CAP), + } + + +def record_self_trace( + hub: Any, + *, + goal: str = "", + decision: str = "", + tools: list[str] | None = None, + report: str = "", +) -> dict[str, Any]: + """Park the self-trace on the hub (not the inject).""" + silent = [] + try: + silent = [str(s) for s in (hub.state.silent_steps or []) if str(s).startswith("tool:")] + except Exception: + silent = [] + merged = list(tools or []) + silent + trace = build_self_trace(goal=goal, decision=decision, tools=merged, report=report) + try: + hub.state.meta["self_trace"] = dict(trace) + if hasattr(hub, "save"): + hub.save() + except Exception: + pass + return trace + + +def format_self_trace(trace: dict[str, Any] | None, *, for_inject: bool = False) -> str: + """Readable self-trace. Inject only when load is low.""" + t = dict(trace or {}) + if not any(t.get(k) for k in ("goal", "decision", "tools", "report")): + return "" + tools = ", ".join(str(x) for x in (t.get("tools") or [])[:TOOL_CAP]) + lines = [ + "### Self-trace", + f"- goal: {t.get('goal') or '—'}", + f"- decision: {t.get('decision') or '—'}", + f"- tools: {tools or '—'}", + f"- report: {t.get('report') or '—'}", + ] + block = "\n".join(lines) + if for_inject and len(block) > TRACE_INJECT_CAP: + block = block[: TRACE_INJECT_CAP - 3].rstrip() + "..." + return block + + +def read_self_trace(hub: Any) -> dict[str, Any]: + try: + raw = (hub.state.meta or {}).get("self_trace") or {} + return dict(raw) if isinstance(raw, dict) else {} + except Exception: + return {} + + +def maybe_seal_improve(desk: Any, *, agent_id: str) -> dict[str, Any]: + """Seal a one-line learning into Cube when present. No second World warehouse.""" + meta = getattr(desk, "meta", None) or {} + if not isinstance(meta, dict): + return {"ok": False, "skipped": "no_meta"} + line = "" + for key in ("learn", "improve", "learning"): + raw = meta.get(key) + if isinstance(raw, str) and raw.strip(): + line = raw.strip().splitlines()[0][:200] + break + if not line: + return {"ok": False, "skipped": "no_learning"} + try: + from hermespace.cube_module import seal_learning + + rec = seal_learning( + line, + entry_type="belief", + agent_id=agent_id, + source="self_model_improve", + ) + return { + "ok": bool(rec.get("ok")), + "sealed": line, + "mode": rec.get("mode"), + "warehouse": "cube", + } + except Exception as exc: + return {"ok": False, "error": type(exc).__name__} diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index 0a85684..cfcfbb5 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -180,7 +180,7 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: oew_broadcast = "" report = (desk.say or "").strip() try: - from hermespace.cube_module import cube_beat + from hermespace.cube_module import cube_beat, skip_cube_foa_strip from hermespace.access import AccessHub, AccessEnv from hermespace.access.engine import workspace_id from hermespace.access.oew import ensure_oew_env_default @@ -195,14 +195,24 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: seals = None if payload.seal and desk.decision: seals = desk.decision - beat = cube_beat( - msg or desk.goal, - seals=seals, - load=load_total, - agent_id=payload.agent_id or "hermes-agent", - session_id=payload.session_id or "default", - ) - cube_block = str(beat.get("block") or "") + if skip_cube_foa_strip(): + beat = { + "ok": True, + "mode": "skipped", + "skipped": "provider_prefetch", + "block": "", + "load_level": load_total, + } + cube_block = "" + else: + beat = cube_beat( + msg or desk.goal, + seals=seals, + load=load_total, + agent_id=payload.agent_id or "hermes-agent", + session_id=payload.session_id or "default", + ) + cube_block = str(beat.get("block") or "") cube_meta = { "ok": beat.get("ok"), "mode": beat.get("mode"), @@ -255,37 +265,45 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: except Exception as exc: cube_meta = {"ok": False, "error": type(exc).__name__} - # 6 broadcast context (model channel) — Quicksilver-capped OEW strip - inject_cap = 900 if ( - isinstance(desk.load, dict) and str(desk.load.get("level")) == "high" - ) else 2800 - block = build_inject_block(desk, max_chars=inject_cap, user_message=msg) - if cube_block: - block = (block + "\n\n" + cube_block).strip() + # 6 one user-message inject — mid ≤2.8k, high ≤900. No world/protocol essay. + from hermespace.context_surgery import ( + assemble_inject, + dual_decode_line, + inject_budget, + ) + + load_level = str(desk.load.get("level") or "mid") if isinstance(desk.load, dict) else "mid" + inject_cap = inject_budget(load_level) + high = load_level in {"high", "protect"} + parts = [ + dual_decode_line(), + build_inject_block(desk, max_chars=inject_cap, user_message=msg, lean=True), + ] + if cube_block and not high: + parts.append(cube_block) try: from hermespace.access import AccessHub, AccessEnv from hermespace.access.engine import workspace_id + from hermespace.access.loop import bound_protocol_lines access_id = workspace_id( payload.agent_id or "hermes-agent", payload.session_id or "default", ) env = AccessEnv(agent_id=access_id) - high = str(desk.load.get("level")) == "high" if isinstance(desk.load, dict) else False - jblock = oew_broadcast or env.filtered_broadcast(high_load=high) - if jblock: - block = (block + "\n\n" + jblock).strip() - proto = env.protocol_block(high_load=high) - if proto: - block = (block + "\n\n" + proto).strip() + bound = bound_protocol_lines(env) + if bound: + parts.append(bound) # Lens is operator-only — never append the readout to model context. + # Summon still paints the operator Report, not the inject. js = AccessHub(agent_id=access_id) if js.parse_modulation(msg).get("summon"): report = (report + "\n\n" + env.lens_markdown(include_silent=False)).strip() - # Final sticky reshape (in case summon appended text) report = env.shape_user_report(report) except Exception: pass + _ = oew_broadcast # hub-only; do not dual-dump broadcast into chat/inject + block = assemble_inject(parts, budget=inject_cap) try: from hermespace.execute_focus import shape_execute_report diff --git a/tests/test_context_surgery.py b/tests/test_context_surgery.py new file mode 100644 index 0000000..bfa6a2b --- /dev/null +++ b/tests/test_context_surgery.py @@ -0,0 +1,224 @@ +"""Context budgets + skip-if-unneeded + self-trace. No consciousness claim.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +import sys + +sys.path.insert(0, str(ROOT / "src")) + + +class TestContextSurgery(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self.tmp.name + os.environ["HERMESPACE_AGENT_ID"] = "surgery-agent" + os.environ["HERMESPACE_SKIP_NEURAL"] = "1" + os.environ["HERMESPACE_NEURAL_VERBALIZE"] = "0" + os.environ["HERMESPACE_AUTO_ORDER"] = "0" + + def tearDown(self) -> None: + self.tmp.cleanup() + for key in ( + "HERMESPACE_HOME", + "HERMESPACE_AGENT_ID", + "HERMESPACE_SKIP_NEURAL", + "HERMESPACE_NEURAL_VERBALIZE", + "HERMESPACE_AUTO_ORDER", + ): + os.environ.pop(key, None) + + def test_budgets_and_fluent_ack(self) -> None: + from hermespace.context_surgery import ( + HIGH_INJECT_CAP, + INJECT_HARD_CAP, + MID_INJECT_CAP, + assemble_inject, + inject_budget, + is_fluent_ack, + sanitize_inject, + strip_needed, + ) + + self.assertEqual(MID_INJECT_CAP, 2800) + self.assertEqual(HIGH_INJECT_CAP, 900) + self.assertLess(INJECT_HARD_CAP, 9000) + self.assertEqual(inject_budget("mid"), 2800) + self.assertEqual(inject_budget("high"), 900) + self.assertTrue(is_fluent_ack("got it")) + self.assertTrue(is_fluent_ack("thanks")) + self.assertFalse(is_fluent_ack("First build then verify")) + self.assertFalse(strip_needed(message="ok", load_level="mid")) + self.assertFalse( + strip_needed(message="First build then verify", load_level="high") + ) + self.assertTrue( + strip_needed( + message="First build then verify", + load_level="high", + is_first_turn=True, + ) + ) + blob = assemble_inject(["a" * 4000, "b" * 4000], budget=2800) + self.assertLessEqual(len(blob), 2800) + dirty = sanitize_inject("keep\nJ-Lens readout: secret\ntrue self-conscious\nok") + self.assertIn("keep", dirty) + self.assertNotIn("J-Lens", dirty) + self.assertNotIn("true self-conscious", dirty) + + def test_pre_llm_skips_fluent_and_respects_mid_cap(self) -> None: + from hermespace.hermes_bridge import on_pre_llm_call, on_session_start + + on_session_start(session_id="surgery-mid") + self.assertIsNone( + on_pre_llm_call( + user_message="got it", + session_id="surgery-mid", + is_first_turn=False, + ) + ) + inj = on_pre_llm_call( + user_message="First inspect then implement finally verify", + session_id="surgery-mid", + is_first_turn=False, + ) + self.assertIsNotNone(inj) + ctx = (inj or {}).get("context") or "" + self.assertLessEqual(len(ctx), 2800) + self.assertIn("Access Workspace", ctx) + self.assertIn("next action", ctx) + self.assertNotIn("J-Lens readout", ctx) + self.assertNotIn("What Hermes has on its mind", ctx) + self.assertNotIn("### Workbench", ctx) + self.assertNotIn("### Hermespace runtime", ctx) + self.assertNotIn("dream_harvest", ctx) + self.assertNotIn("true self-conscious", ctx) + self.assertNotIn("phenomenal consciousness", ctx) + + def test_high_load_injects_nothing_without_bind(self) -> None: + from hermespace import AccessEngine + from hermespace.hermes_bridge import on_pre_llm_call, on_session_start + from hermespace.store import load_desk, save_desk + + on_session_start(session_id="surgery-high") + eng = AccessEngine(agent_id="surgery-agent", session_id="surgery-high") + desk = load_desk(eng.desk_engine.desk_path) + desk.load = {"level": "protect", "total": 0.8} + save_desk(desk, eng.desk_engine.desk_path) + inj = on_pre_llm_call( + user_message="First build the feature then verify please", + session_id="surgery-high", + is_first_turn=False, + ) + self.assertIsNone(inj) + + def test_subagent_does_not_inject_full_copy(self) -> None: + from hermespace.hermes_bridge import ( + on_pre_llm_call, + on_session_start, + on_subagent_start, + ) + + on_session_start(session_id="surgery-child") + on_subagent_start(session_id="surgery-child", task_id="t1") + inj = on_pre_llm_call( + user_message="First inspect then implement finally verify", + session_id="surgery-child", + is_first_turn=False, + ) + self.assertIsNone(inj) + + def test_insight_writeback_stays_on_desk_meta(self) -> None: + from hermespace import AccessEngine + from hermespace.hermes_bridge import on_pre_llm_call, on_session_start + from hermespace.store import load_desk + + on_session_start(session_id="surgery-insight") + with mock.patch( + "hermespace.insight_module.insight_card", + return_value={ + "ok": True, + "mode": "insight", + "card": "### Insight\n- lever: test", + "writeback": {"usable": "ship", "lever": "test"}, + }, + ): + inj = on_pre_llm_call( + user_message="First build the feature then verify please", + session_id="surgery-insight", + is_first_turn=False, + ) + ctx = (inj or {}).get("context") or "" + self.assertIn("### Insight", ctx) + self.assertNotIn("insight_lattice", ctx) + desk = load_desk( + AccessEngine( + agent_id="surgery-agent", session_id="surgery-insight" + ).desk_engine.desk_path + ) + self.assertEqual((desk.meta or {}).get("insight_writeback", {}).get("lever"), "test") + + +class TestSelfModel(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self.tmp.name + os.environ["HERMESPACE_AGENT_ID"] = "trace-agent" + + def tearDown(self) -> None: + self.tmp.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + os.environ.pop("HERMESPACE_AGENT_ID", None) + + def test_self_trace_capped_and_on_hub(self) -> None: + from hermespace import AccessEngine + from hermespace.access.loop import park_tool_step + from hermespace.grid.viewport import snapshot + from hermespace.self_model import format_self_trace, read_self_trace + + eng = AccessEngine(agent_id="trace-agent", session_id="default") + park_tool_step(eng.hub, "read_file") + out = eng.observe_turn( + user_message="fix auth", + assistant_response="Patched TTL. Next: verify login.", + ) + self.assertTrue(out.get("self_trace"), out) + trace = read_self_trace(eng.hub) + self.assertLessEqual(len(trace.get("goal") or ""), 120) + self.assertLessEqual(len(trace.get("report") or ""), 160) + self.assertTrue(any(str(t).startswith("tool:") for t in (trace.get("tools") or []))) + painted = format_self_trace(trace, for_inject=True) + self.assertLessEqual(len(painted), 280) + self.assertNotIn("true self-conscious", painted) + view = eng.env.operator_view() + self.assertIn("self_trace", view) + self.assertIn("self-trace", view["theory"]["self_model"]) + self.assertNotIn("true self-conscious", view["theory"]["self_model"]) + foa = snapshot("trace-agent")["foa"] + self.assertIn("self_trace", foa) + self.assertTrue(foa["self_trace"].get("report")) + + def test_improve_seals_cube_not_world(self) -> None: + from hermespace.desk import Desk + from hermespace.self_model import maybe_seal_improve + + desk = Desk() + desk.meta["learn"] = "Prefer one live goal." + with mock.patch("hermespace.cube_module.seal_learning") as seal: + seal.return_value = {"ok": True, "mode": "cube"} + rec = maybe_seal_improve(desk, agent_id="trace-agent") + self.assertTrue(rec.get("ok")) + seal.assert_called_once() + self.assertEqual(rec.get("warehouse"), "cube") + empty = maybe_seal_improve(Desk(), agent_id="trace-agent") + self.assertEqual(empty.get("skipped"), "no_learning") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gate.py b/tests/test_gate.py index 22bc28c..92644a2 100644 --- a/tests/test_gate.py +++ b/tests/test_gate.py @@ -18,6 +18,9 @@ def test_trivial(self) -> None: ok, reason = should_inject("ok", desk_ready=True) self.assertFalse(ok) self.assertEqual(reason, "trivial_ack") + ok2, reason2 = should_inject("got it", desk_ready=True) + self.assertFalse(ok2) + self.assertEqual(reason2, "trivial_ack") def test_material(self) -> None: ok, reason = should_inject("proceed build the hermespace plugin", desk_ready=True) diff --git a/tests/test_insight_module.py b/tests/test_insight_module.py index 18dc95c..de9d9b3 100644 --- a/tests/test_insight_module.py +++ b/tests/test_insight_module.py @@ -261,7 +261,7 @@ def test_pre_llm_skips_insight_on_high_load(self) -> None: on_session_start(session_id="insight-high") eng = AccessEngine(agent_id="hermes-agent", session_id="insight-high") desk = load_desk(eng.desk_engine.desk_path) - desk.load = {"level": "high", "total": 0.8} + desk.load = {"level": "protect", "total": 0.8} save_desk(desk, eng.desk_engine.desk_path) with mock.patch( @@ -274,9 +274,11 @@ def test_pre_llm_skips_insight_on_high_load(self) -> None: session_id="insight-high", is_first_turn=False, ) - self.assertIsNotNone(inj) mocked.assert_called() - self.assertNotIn("### Insight", (inj or {}).get("context") or "") + ctx = (inj or {}).get("context") or "" + self.assertNotIn("### Insight", ctx) + # high load + no bind → inject nothing (strip not needed) + self.assertTrue(inj is None or "### Insight" not in ctx) if __name__ == "__main__": diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py index 2dcdb3e..b5fc90b 100644 --- a/tests/test_plugin_contract.py +++ b/tests/test_plugin_contract.py @@ -83,7 +83,7 @@ def test_registration_and_native_lifecycle(self) -> None: self.assertIsInstance(result, dict) self.assertIn("Access Workspace", result["context"]) self.assertNotIn("J-Lens readout", result["context"]) - self.assertLess(len(result["context"]), 15_000) + self.assertLessEqual(len(result["context"]), 2800) ctx.hooks["post_tool_call"]( **common, From 14687a6ed9a52fb0e405a55bcb0c5255c2428b5f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 22:33:29 +0000 Subject: [PATCH 27/32] feat(bench): week-one offline harness (C1 T1 L1 M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four isolated fixture cases, Space-on vs HERMESPACE_OFF. No scores, no leaderboard. Q1 stays NOT RUN — no judge provider in the tree. Cube and Insight stay optional. Fluent ack parks nothing. Co-authored-by: Pablo --- .github/workflows/ci.yml | 2 + README.md | 1 + scripts/bench_week1.py | 17 ++ src/hermespace/access/engine.py | 12 +- src/hermespace/bench.py | 456 ++++++++++++++++++++++++++++++++ src/hermespace/cli.py | 16 ++ tests/test_bench_week1.py | 67 +++++ 7 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 scripts/bench_week1.py create mode 100644 src/hermespace/bench.py create mode 100644 tests/test_bench_week1.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fc7dc6..fa8fe65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,8 @@ jobs: run: ./scripts/smoke_test.sh - name: Ops end-to-end run: ./scripts/e2e_ops.sh + - name: Week-one bench + run: python scripts/bench_week1.py - name: Build wheel and sdist run: | python -m pip install build diff --git a/README.md b/README.md index 48f0879..9792fa9 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ Alongside the world, Hermespace provides a desk for the current turn — FOA, du hs install # Space plugin+skill; offers Cube + Insight # or: hermes plugins install PabloTheThinker/hermespace --enable hs ops doctor # FAIL if Space is broken; WARN if Cube/Insight missing +hs bench week1 # C1 T1 L1 M1 offline fixtures; Q1 NOT RUN without a judge ``` For a development checkout: diff --git a/scripts/bench_week1.py b/scripts/bench_week1.py new file mode 100644 index 0000000..b40edd5 --- /dev/null +++ b/scripts/bench_week1.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Week-one Bench — four offline cases. No scores. No leaderboard.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from hermespace.bench import main # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/hermespace/access/engine.py b/src/hermespace/access/engine.py index 796c2b6..f4edd1a 100644 --- a/src/hermespace/access/engine.py +++ b/src/hermespace/access/engine.py @@ -637,9 +637,15 @@ def observe_turn( } try: from hermespace.access.loop import check_bound_report, park_spoken_intermediates - - parked = park_spoken_intermediates(self.hub, report, max_n=3) - result["spoken_parked"] = parked + from hermespace.context_surgery import is_fluent_ack + + if is_fluent_ack(user) or not report: + parked = [] + result["spoken_parked"] = [] + result["skipped_park"] = "fluent_ack" if is_fluent_ack(user) else "empty" + else: + parked = park_spoken_intermediates(self.hub, report, max_n=3) + result["spoken_parked"] = parked bound = check_bound_report(self.env, report) result["bound"] = { "checked": len(bound.get("checked") or []), diff --git a/src/hermespace/bench.py b/src/hermespace/bench.py new file mode 100644 index 0000000..66f20a0 --- /dev/null +++ b/src/hermespace/bench.py @@ -0,0 +1,456 @@ +"""Week-one Bench harness — four offline fixture cases. + +No invented scores. No leaderboard. No consciousness language. +Cube and Insight are optional; the suite must pass with both absent. +Q1 Factory probe is NOT RUN unless a judge provider is already in the tree. +""" + +from __future__ import annotations + +import os +import tempfile +import uuid +from contextlib import contextmanager +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Iterator + +CASES = ("C1", "T1", "L1", "M1") +MID_INJECT_CAP = 2800 +INSIGHT_CARD_CAP = 400 +FOA_CAP = 4 +HUB_CAP = 25 +MATERIAL = "First inspect then implement finally verify the auth fix" +FLUENT = "got it" +NEEDLE_PREFIX = "bench-needle-" + +_SYSTEM_DUMP = ( + "you are a helpful assistant", + "hermespace access engine (session start)", + "j-lens readout", + "what hermes has on its mind", + "lens_markdown", +) + +_JUDGE_MODULES = ( + "hermes_judge", + "hermespace.judge", + "factory_probe", + "hermespace.factory_probe", +) + + +@dataclass +class Check: + name: str + ok: bool + detail: str = "" + + +@dataclass +class CaseResult: + case: str + status: str # PASS | FAIL | NOT RUN + checks: list[Check] = field(default_factory=list) + reason: str = "" + arm: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "case": self.case, + "status": self.status, + "arm": self.arm, + "reason": self.reason, + "checks": [asdict(c) for c in self.checks], + } + + +def organ_status() -> dict[str, bool]: + cube = False + insight = False + try: + import hermescube # noqa: F401 + + cube = True + except Exception: + cube = False + try: + import hermes_insight # noqa: F401 + + insight = True + except Exception: + insight = False + return {"cube": cube, "insight": insight} + + +def judge_provider_in_tree() -> bool: + for name in _JUDGE_MODULES: + try: + __import__(name) + return True + except Exception: + continue + return False + + +@contextmanager +def isolated_homes(*, off: bool = False, cube_provider: bool = False) -> Iterator[dict[str, str]]: + """Isolated HERMES_HOME / HERMESPACE_HOME. Cube/Insight stay optional.""" + keys = ( + "HERMESPACE_HOME", + "HERMES_HOME", + "HERMESPACE_AGENT_ID", + "HERMESPACE_OFF", + "HERMESPACE_FORCE", + "HERMES_MEMORY_PROVIDER", + "HERMESPACE_SKIP_NEURAL", + "HERMESPACE_NEURAL_VERBALIZE", + "HERMESPACE_AUTO_ORDER", + ) + prior = {k: os.environ.get(k) for k in keys} + with tempfile.TemporaryDirectory(prefix="hs-bench-") as td: + space = str(Path(td) / "space") + hermes = str(Path(td) / "hermes") + Path(space).mkdir() + Path(hermes).mkdir() + os.environ["HERMESPACE_HOME"] = space + os.environ["HERMES_HOME"] = hermes + os.environ["HERMESPACE_SKIP_NEURAL"] = "1" + os.environ["HERMESPACE_NEURAL_VERBALIZE"] = "0" + os.environ["HERMESPACE_AUTO_ORDER"] = "0" + os.environ.pop("HERMESPACE_FORCE", None) + if off: + os.environ["HERMESPACE_OFF"] = "1" + else: + os.environ.pop("HERMESPACE_OFF", None) + if cube_provider: + os.environ["HERMES_MEMORY_PROVIDER"] = "hermescube" + else: + os.environ.pop("HERMES_MEMORY_PROVIDER", None) + try: + yield {"space": space, "hermes": hermes} + finally: + for k, v in prior.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def _finish(case: str, arm: str, checks: list[Check], *, reason: str = "") -> CaseResult: + failed = [c for c in checks if not c.ok] + status = "FAIL" if failed else "PASS" + return CaseResult( + case=case, + status=status, + checks=checks, + arm=arm, + reason=reason or (failed[0].name if failed else ""), + ) + + +def _inject_text(inj: dict[str, str] | None) -> str: + if not inj: + return "" + return str(inj.get("context") or "") + + +def _count_focus(text: str) -> int: + n = 0 + in_foa = False + for line in (text or "").splitlines(): + if "focus of attention" in line.casefold(): + in_foa = True + continue + if in_foa and line.startswith("### "): + break + if in_foa and line.strip().startswith("- "): + n += 1 + return n + + +def _insight_card_chars(text: str) -> int: + lines = (text or "").splitlines() + start = next((i for i, ln in enumerate(lines) if ln.strip().startswith("### Insight")), -1) + if start < 0: + return 0 + chunk: list[str] = [] + for ln in lines[start:]: + if chunk and ln.startswith("### ") and not ln.startswith("### Insight"): + break + chunk.append(ln) + return len("\n".join(chunk)) + + +def run_c1(*, off: bool = False) -> CaseResult: + """C1 Context inject — user-message context only.""" + arm = "space_off" if off else "space_on" + checks: list[Check] = [] + with isolated_homes(off=off): + os.environ["HERMESPACE_AGENT_ID"] = f"bench-c1-{arm}" + from hermespace.hermes_bridge import on_pre_llm_call, on_session_start + from hermespace.store import load_desk + from hermespace import AccessEngine + + on_session_start(session_id="bench-c1") + inj = on_pre_llm_call( + user_message=MATERIAL, + session_id="bench-c1", + is_first_turn=False, + ) + ctx = _inject_text(inj) + if off: + checks.append(Check("off_arm_injects_nothing", ctx == "", f"chars={len(ctx)}")) + return _finish("C1", arm, checks) + checks.append(Check("on_arm_injects", bool(ctx.strip()), f"chars={len(ctx)}")) + checks.append( + Check("user_message_context_only", inj is not None and "context" in (inj or {}), "") + ) + checks.append( + Check( + "no_system_keys", + not any(k in (inj or {}) for k in ("system", "system_prompt", "messages")), + ",".join(sorted((inj or {}).keys())), + ) + ) + low = ctx.casefold() + leaked = [s for s in _SYSTEM_DUMP if s in low] + checks.append(Check("no_system_prompt_or_lens", not leaked, ",".join(leaked))) + checks.append(Check("mid_inject_cap", len(ctx) <= MID_INJECT_CAP, f"chars={len(ctx)}")) + card_n = _insight_card_chars(ctx) + checks.append( + Check( + "insight_card_cap", + card_n == 0 or card_n <= INSIGHT_CARD_CAP, + f"card_chars={card_n}", + ) + ) + foa_n = _count_focus(ctx) + desk = load_desk( + AccessEngine(agent_id=os.environ["HERMESPACE_AGENT_ID"], session_id="bench-c1") + .desk_engine.desk_path + ) + desk_foa = len(list(desk.focus or [])[:8]) + checks.append( + Check( + "foa_cap", + foa_n <= FOA_CAP and desk_foa <= FOA_CAP, + f"inject_foa={foa_n} desk_foa={desk_foa}", + ) + ) + return _finish("C1", arm, checks) + + +def run_t1() -> CaseResult: + """T1 Day-in-life + one live goal (fixture — no live Hermes).""" + checks: list[Check] = [] + with isolated_homes(): + os.environ["HERMESPACE_AGENT_ID"] = "bench-t1" + from hermespace.engine import HermespaceEngine + from hermespace.memory_db import HermespaceMemory + from hermespace.workbench import Workbench + from hermespace.workflow import Workflow + + root = Path(os.environ["HERMESPACE_HOME"]) + wf = Workflow( + HermespaceEngine(desk_path=root / "ACTIVE.md"), + HermespaceMemory(root=root), + ) + wf.neural.config.verbalize = False + wb = Workbench("bench-t1", session_id="s1", workflow=wf, root=root / "wb") + wb.receive_order( + "First tunnel: patch TTL then verify", + goal="Fix auth", + say="Patch TTL.", + plan=["Patch TTL"], + force=True, + ) + second = wb.receive_order( + "Second tunnel: write the operator notes", + goal="Write docs", + say="Open README.", + plan=["Open README"], + force=True, + ) + lines = wb.park_lines() + named = [ln for ln in lines if " — " in ln] + checks.append( + Check( + "parks_previous_named", + any("Fix auth" in ln and ln.count(" — ") >= 2 for ln in named), + "; ".join(named) or "empty", + ) + ) + live = str((wb.workflow.status() or {}).get("goal") or "") + checks.append(Check("one_live_goal", live == "Write docs", live)) + report = str(second.get("user_reply") or "") + line1 = report.splitlines()[0].strip() if report.strip() else "" + checks.append( + Check( + "report_line1_next_action", + bool(line1) and "Open README" in line1, + line1[:160], + ) + ) + return _finish("T1", "space_on", checks) + + +def run_l1() -> CaseResult: + """L1 Silent / FOA / hub from actual assistant text.""" + checks: list[Check] = [] + with isolated_homes(): + os.environ["HERMESPACE_AGENT_ID"] = "bench-l1" + from hermespace import AccessEngine + from hermespace.access.hub import FOCUS_CAP, HUB_CAP + + eng = AccessEngine(agent_id="bench-l1", session_id="default") + material = eng.observe_turn( + user_message=MATERIAL, + assistant_response=( + "I inspected the TTL path. Then I patched the session cookie. " + "Finally I verified login stays alive." + ), + ) + parked = list(material.get("spoken_parked") or []) + checks.append( + Check( + "material_parks_1_to_3", + 1 <= len(parked) <= 3, + f"n={len(parked)} {parked}", + ) + ) + checks.append( + Check( + "foa_cap", + len(eng.hub.state.focus) <= FOCUS_CAP, + f"focus={len(eng.hub.state.focus)} cap={FOCUS_CAP}", + ) + ) + checks.append( + Check( + "hub_cap", + len(eng.hub.state.hub) <= HUB_CAP, + f"hub={len(eng.hub.state.hub)} cap={HUB_CAP}", + ) + ) + fluent = AccessEngine(agent_id="bench-l1-ack", session_id="default").observe_turn( + user_message=FLUENT, + assistant_response="Sure thing.", + ) + checks.append( + Check( + "fluent_ack_parks_nothing", + not (fluent.get("spoken_parked") or []), + str(fluent.get("spoken_parked")), + ) + ) + return _finish("L1", "space_on", checks) + + +def run_m1() -> CaseResult: + """M1 Persist needle across a new session fixture + Cube skip when provider set.""" + checks: list[Check] = [] + needle = f"{NEEDLE_PREFIX}{uuid.uuid4().hex[:12]}" + with isolated_homes(): + os.environ["HERMESPACE_AGENT_ID"] = "bench-m1" + from hermespace.world import WorldModel + + WorldModel(agent_id="bench-m1").add_belief(needle, 0.9, source="bench_m1") + # New session fixture — new objects, same isolated home. + again = WorldModel(agent_id="bench-m1") + beliefs = [str(getattr(b, "statement", "") or "") for b in (again.state.beliefs or [])] + archived = again.archive.search(needle, limit=5) + hit = any(needle in s for s in beliefs) or any( + needle in (e.description or "") or needle in str(e.data or {}) for e in archived + ) + checks.append(Check("needle_survives_new_session", hit, needle)) + + with isolated_homes(cube_provider=True): + os.environ["HERMESPACE_AGENT_ID"] = "bench-m1-cube" + from hermespace.cube_module import skip_cube_foa_strip + from hermespace.hermes_bridge import on_pre_llm_call, on_session_start + from unittest import mock + + checks.append(Check("provider_skip_flag", skip_cube_foa_strip(), "hermescube")) + on_session_start(session_id="bench-m1-cube") + with mock.patch("hermespace.cube_module.cube_beat") as beat: + inj = on_pre_llm_call( + user_message=MATERIAL, + session_id="bench-m1-cube", + is_first_turn=False, + ) + ctx = _inject_text(inj) + checks.append(Check("cube_beat_not_called", not beat.called, f"calls={beat.call_count}")) + checks.append(Check("no_second_heart_strip", "### Cube" not in ctx, f"chars={len(ctx)}")) + return _finish("M1", "space_on", checks) + + +def run_q1() -> CaseResult: + """Q1 Factory probe — NOT RUN unless a judge provider is already in the tree.""" + if not judge_provider_in_tree(): + return CaseResult( + case="Q1", + status="NOT RUN", + reason="no judge provider in tree", + ) + return CaseResult( + case="Q1", + status="NOT RUN", + reason="judge module importable but no factory probe runner in this harness", + ) + + +def run_week1() -> dict[str, Any]: + """Run the four offline cases + Q1 gate. No scores. No leaderboard.""" + organs = organ_status() + results = [ + run_c1(off=False), + run_c1(off=True), + run_t1(), + run_l1(), + run_m1(), + run_q1(), + ] + by_case: dict[str, Any] = {} + for rec in results: + slot = by_case.setdefault( + rec.case, + {"status": rec.status, "arms": {}, "reason": rec.reason, "checks": []}, + ) + if rec.arm: + slot["arms"][rec.arm] = rec.status + slot["checks"].extend(rec.to_dict()["checks"]) + if rec.status == "FAIL": + slot["status"] = "FAIL" + elif rec.status == "NOT RUN" and slot["status"] != "FAIL": + slot["status"] = "NOT RUN" + elif rec.case == "C1" and slot.get("arms"): + arm_states = list(slot["arms"].values()) + slot["status"] = "FAIL" if "FAIL" in arm_states else "PASS" + if rec.reason and rec.status != "PASS": + slot["reason"] = rec.reason + failed = [k for k, v in by_case.items() if v.get("status") == "FAIL"] + return { + "suite": "week1", + "ok": not failed, + "organs": organs, + "organs_required": False, + "scores": None, + "leaderboard": None, + "cases": by_case, + "failed": failed, + } + + +def main(argv: list[str] | None = None) -> int: + import json + import sys + + _ = argv + out = run_week1() + print(json.dumps(out, indent=2, default=str)) + return 0 if out.get("ok") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/hermespace/cli.py b/src/hermespace/cli.py index bed5e27..6af5af3 100644 --- a/src/hermespace/cli.py +++ b/src/hermespace/cli.py @@ -479,6 +479,15 @@ def main(argv: list[str] | None = None) -> int: inst.add_argument("--no-desktop", action="store_true") inst.add_argument("--no-enable", action="store_true") + bn = sub.add_parser("bench", help="Week-one Bench harness (offline fixtures)") + bn.add_argument( + "suite", + nargs="?", + default="week1", + choices=("week1",), + help="week1 = C1 T1 L1 M1 fixtures; Q1 NOT RUN without a judge", + ) + # Access request / chat regulation CLI gar = gr_sub.add_parser("access-request") @@ -1248,6 +1257,13 @@ def main(argv: list[str] | None = None) -> int: return r.returncode return 2 + if args.cmd == "bench": + from hermespace.bench import run_week1 + + out = run_week1() + print(json.dumps(out, indent=2, default=str)) + return 0 if out.get("ok") else 1 + if args.cmd == "install": from hermespace.install_kit import install_front_door diff --git a/tests/test_bench_week1.py b/tests/test_bench_week1.py new file mode 100644 index 0000000..1f164fb --- /dev/null +++ b/tests/test_bench_week1.py @@ -0,0 +1,67 @@ +"""Week-one Bench — fixture cases only. No invented scores.""" + +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + + +class TestWeek1Bench(unittest.TestCase): + def test_suite_four_offline_cases(self) -> None: + from hermespace.bench import judge_provider_in_tree, organ_status, run_week1 + + organs = organ_status() + self.assertFalse(organs.get("required", False) if isinstance(organs, dict) else False) + out = run_week1() + self.assertIsNone(out.get("scores")) + self.assertIsNone(out.get("leaderboard")) + cases = out.get("cases") or {} + for key in ("C1", "T1", "L1", "M1"): + self.assertIn(key, cases) + self.assertEqual(cases[key]["status"], "PASS", cases[key]) + self.assertEqual(cases["Q1"]["status"], "NOT RUN") + if not judge_provider_in_tree(): + self.assertIn("no judge provider", cases["Q1"].get("reason") or "") + self.assertTrue(out.get("ok"), out.get("failed")) + self.assertEqual(cases["C1"]["arms"].get("space_off"), "PASS") + self.assertEqual(cases["C1"]["arms"].get("space_on"), "PASS") + + def test_fluent_ack_parks_nothing(self) -> None: + from hermespace import AccessEngine + + with self._home(): + os.environ["HERMESPACE_AGENT_ID"] = "bench-ack" + out = AccessEngine(agent_id="bench-ack").observe_turn( + user_message="got it", + assistant_response="Sure thing.", + ) + self.assertFalse(out.get("spoken_parked")) + + def _home(self): + import tempfile + from contextlib import contextmanager + + @contextmanager + def _ctx(): + td = tempfile.TemporaryDirectory() + old = os.environ.get("HERMESPACE_HOME") + os.environ["HERMESPACE_HOME"] = td.name + try: + yield + finally: + if old is None: + os.environ.pop("HERMESPACE_HOME", None) + else: + os.environ["HERMESPACE_HOME"] = old + td.cleanup() + + return _ctx() + + +if __name__ == "__main__": + unittest.main() From af594c7d06751f1c4da710dd53932bf7226defc7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 23:20:53 +0000 Subject: [PATCH 28/32] fix(desk): next-action Report, distinct FOA, parent-cwd smoke Empty-say turns derive a real plan and lead with next_action_line instead of dumping production/partner slots or the filler execute. FOA collapses near-duplicate gists to distinct clauses. Root plugin entry loads src/hermespace via importlib so a checkout named hermespace cannot shadow the package. Operator lens title is no longer J-Lens readout. Co-authored-by: Pablo --- __init__.py | 47 +++++- scripts/e2e_ops.sh | 3 +- scripts/smoke_test.sh | 5 +- src/hermespace/access/engine.py | 2 +- src/hermespace/access/env.py | 2 +- src/hermespace/access/hub.py | 33 +++-- src/hermespace/access/oew.py | 59 ++++---- src/hermespace/agent_api.py | 7 +- src/hermespace/desk.py | 28 +++- src/hermespace/engine.py | 14 +- src/hermespace/execute_focus.py | 251 ++++++++++++++++++++++++++++++-- src/hermespace/neural_space.py | 28 +++- src/hermespace/streams.py | 50 +++---- src/hermespace/workbench.py | 2 +- src/hermespace/workflow.py | 13 +- tests/test_access_env.py | 3 +- tests/test_desk_quality.py | 169 +++++++++++++++++++++ 17 files changed, 616 insertions(+), 100 deletions(-) create mode 100644 tests/test_desk_quality.py diff --git a/__init__.py b/__init__.py index 1009398..c7e49e2 100644 --- a/__init__.py +++ b/__init__.py @@ -1,20 +1,61 @@ """Hermes source-repository plugin entry point. ``hermes plugins install PabloTheThinker/hermespace --enable`` clones this -repository as one plugin. Add the repository's ``src`` directory, then load -the same registration module used by wheel entry-point installs. +repository as one plugin. The checkout folder is often named ``hermespace``, +which would shadow ``src/hermespace`` if we ``from hermespace.plugin import +register`` while this file is already loaded as that package. Load the real +plugin (and, when shadowed, the real package) via importlib from ``src/``. """ from __future__ import annotations +import importlib.util import sys from pathlib import Path _ROOT = Path(__file__).resolve().parent _SRC = _ROOT / "src" +_PKG = _SRC / "hermespace" +_PLUGIN = _PKG / "plugin.py" + + +def _load_from_file(name: str, path: Path, *, package_dir: Path | None = None): + spec = importlib.util.spec_from_file_location( + name, + path, + submodule_search_locations=[str(package_dir)] if package_dir else None, + ) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {path}") + module = importlib.util.module_from_spec(spec) + # Bind before exec so src/hermespace relative imports do not re-enter this file. + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _load_plugin_register(): + mod = _load_from_file("_hermespace_plugin_entry", _PLUGIN) + return mod.register + + if str(_SRC) not in sys.path: sys.path.insert(0, str(_SRC)) -from hermespace.plugin import register # noqa: E402,F401 +if __name__ == "hermespace": + # Parent-cwd / folder-name shadow: bind the real package from src/. + real = _load_from_file( + "hermespace", + _PKG / "__init__.py", + package_dir=_PKG, + ) + sys.modules["hermespace"] = real + register = _load_plugin_register() + setattr(real, "register", register) + for _name in getattr(real, "__all__", []): + if hasattr(real, _name): + globals()[_name] = getattr(real, _name) +else: + register = _load_plugin_register() __all__ = ["register"] diff --git a/scripts/e2e_ops.sh b/scripts/e2e_ops.sh index e28059a..563aa1c 100755 --- a/scripts/e2e_ops.sh +++ b/scripts/e2e_ops.sh @@ -2,7 +2,8 @@ # End-to-end everyday ops: boot → pulse → access → dream → skillbench → selftalk → viewport set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -export PYTHONPATH="${ROOT}/src${PYTHONPATH:+:$PYTHONPATH}" +cd "$ROOT" +export PYTHONPATH="$ROOT/src" # shellcheck source=_python.sh source "$(dirname "$0")/_python.sh" export HERMESPACE_HOME="${HERMESPACE_HOME:-$(mktemp -d /tmp/hs-e2e-XXXX)}" diff --git a/scripts/smoke_test.sh b/scripts/smoke_test.sh index df57aff..88a5c59 100755 --- a/scripts/smoke_test.sh +++ b/scripts/smoke_test.sh @@ -2,7 +2,10 @@ # Everyday Hermespace smoke test — integration doors + neural + memory. set -uo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -export PYTHONPATH="${ROOT}/src${PYTHONPATH:+:$PYTHONPATH}" +# Always run from the checkout so a parent-dir folder named hermespace +# cannot shadow src/hermespace on sys.path[0] (cwd). +cd "$ROOT" +export PYTHONPATH="$ROOT/src" # shellcheck source=_python.sh source "$(dirname "$0")/_python.sh" export HERMESPACE_HOME="${HERMESPACE_HOME:-$(mktemp -d /tmp/hermespace-smoke-XXXXXX)}" diff --git a/src/hermespace/access/engine.py b/src/hermespace/access/engine.py index f4edd1a..7f69c62 100644 --- a/src/hermespace/access/engine.py +++ b/src/hermespace/access/engine.py @@ -567,7 +567,7 @@ def turn( HermespaceInput( message=message, goal=goal or message[:200], - plan=list(plan or ["execute"]), + plan=list(plan or []), say=say or "", decision=decision or "", force=force, diff --git a/src/hermespace/access/env.py b/src/hermespace/access/env.py index 4e1661d..7400ee1 100644 --- a/src/hermespace/access/env.py +++ b/src/hermespace/access/env.py @@ -227,7 +227,7 @@ def lens(self, *, top_k: int = 12, include_silent: bool = True) -> list[LensHit] def lens_markdown(self, *, top_k: int = 12, include_silent: bool = True) -> str: hits = self.lens(top_k=top_k, include_silent=include_silent) lines = [ - "## J-Lens readout (external workspace)", + "## Operator lens (external workspace)", f"_agent={self.agent_id} · band={self.band()} · hub={len(self.space.state.hub)}_", "", "What Hermes has on its mind (verbalizable, not weight access):", diff --git a/src/hermespace/access/hub.py b/src/hermespace/access/hub.py index b8ce723..ba48df3 100644 --- a/src/hermespace/access/hub.py +++ b/src/hermespace/access/hub.py @@ -373,13 +373,17 @@ def sync_from_desk( held = [c for c in self.state.hub if c.held] silent_keep = list(self.state.silent_steps) + from hermespace.execute_focus import is_bind_restatement, is_near_dup, is_protocol_slot + new_hub: list[WorkspaceConcept] = list(held) - seen = {c.text.casefold() for c in new_hub} + seen_texts = [c.text for c in new_hub] for raw in concepts: slot = parse_slot(raw) body = slot.text.strip() - if not body or body.casefold() in seen: + if not body or is_protocol_slot(body) or is_bind_restatement(body): + continue + if any(is_near_dup(body, prev) for prev in seen_texts): continue new_hub.append( WorkspaceConcept( @@ -391,11 +395,11 @@ def sync_from_desk( held=False, ) ) - seen.add(body.casefold()) + seen_texts.append(body) # Cube / standalone arterial enrichment for line in _strip_lines(cube_strip): - if line.casefold() in seen: + if any(is_near_dup(line, prev) for prev in seen_texts): continue new_hub.append( WorkspaceConcept( @@ -407,13 +411,13 @@ def sync_from_desk( held=False, ) ) - seen.add(line.casefold()) + seen_texts.append(line[:200]) # Modulation from this turn's message mod = self.parse_modulation(user_message) if mod.get("hold"): body = str(mod["hold"]) - if body.casefold() not in seen: + if not any(is_near_dup(body, prev) for prev in seen_texts): new_hub.append( WorkspaceConcept( text=body, @@ -424,7 +428,7 @@ def sync_from_desk( held=True, ) ) - seen.add(body.casefold()) + seen_texts.append(body) if mod.get("silent"): silent_keep = (silent_keep + [body])[-REASON_CAP:] @@ -437,7 +441,7 @@ def sync_from_desk( else: self.state.mode = "workspace" - self._recompete(preferred_focus=focus) + self._recompete(preferred_focus=focus, user_message=user_message) self.state.reportable = [ c.text for c in self.state.hub if not c.silent ][:HUB_CAP] @@ -495,7 +499,11 @@ def _drop_body(self, body: str) -> None: needle = body.casefold() self.state.hub = [c for c in self.state.hub if c.text.casefold() != needle] - def _recompete(self, preferred_focus: list[str] | None = None) -> None: + def _recompete( + self, + preferred_focus: list[str] | None = None, + user_message: str = "", + ) -> None: # Limited capacity (Baars/Changeux/Anthropic): hub is a bottleneck if len(self.state.hub) > HUB_CAP: ranked = sorted( @@ -522,7 +530,12 @@ def _recompete(self, preferred_focus: list[str] | None = None) -> None: if pref: rest = [s for s in winners if s.text.casefold() not in {p.text.casefold() for p in pref}] winners = (pref + rest)[:FOCUS_CAP] - self.state.focus = [s.label() for s in winners][:FOCUS_CAP] + from hermespace.execute_focus import shape_focus + + self.state.focus = shape_focus( + list(preferred_focus or []) + [s.label() for s in winners], + message=user_message, + ) def _safe(name: str) -> str: diff --git a/src/hermespace/access/oew.py b/src/hermespace/access/oew.py index 35b9d09..beb886e 100644 --- a/src/hermespace/access/oew.py +++ b/src/hermespace/access/oew.py @@ -21,7 +21,10 @@ ) -_STEP_SPLIT = re.compile(r"\s*(?:→|->|;|\n|\d+[.)]\s+)\s*") +_STEP_SPLIT = re.compile( + r"\s*(?:→|->|;|\n|\d+[.)]\s+|\bthen\b|\bafter that\b|\bfinally\b|\band then\b)\s+", + re.I, +) def oew_default_on() -> bool: @@ -54,6 +57,13 @@ def auto_park_silent( if len(js.state.silent_steps) >= min_steps: return parked + from hermespace.execute_focus import ( + derive_plan, + is_filler_step, + is_near_dup, + strip_slot_prefix, + ) + candidates: list[str] = [] plan = list(getattr(desk, "plan", None) or []) if desk is not None else [] goal = str(getattr(desk, "goal", "") or "") if desk is not None else "" @@ -61,44 +71,43 @@ def auto_park_silent( for step in plan: s = str(step).strip() - if s: - candidates.append(f"plan: {s[:160]}") - if goal: - candidates.append(f"intention: {goal[:160]}") - if decision and decision.lower() not in {"a — proceed", "a - proceed", "proceed"}: - candidates.append(f"decision-path: {decision[:160]}") + if s and not is_filler_step(s): + candidates.append(s[:160]) msg = (user_message or "").strip() + for step in derive_plan(msg or goal): + candidates.append(step) + if msg and re.search( r"\b(then|after|next|step\s*\d|first|second|finally|because|so that)\b", msg, re.I, ): - # Split multi-clause messages into silent markers chunks = [c.strip() for c in _STEP_SPLIT.split(msg) if c and c.strip()] for ch in chunks[:4]: - if len(ch) > 12: - candidates.append(f"step: {ch[:140]}") - if not chunks: - candidates.append(f"multi-step context: {msg[:120]}") + short = strip_slot_prefix(ch) + if len(short) > 8: + candidates.append(short[:80]) + + if decision and decision.lower() not in {"a — proceed", "a - proceed", "proceed"}: + if len(decision) > 8 and not is_filler_step(decision): + candidates.append(decision[:160]) - # Deduplicate against existing silent steps - existing = {s.casefold() for s in js.state.silent_steps} + # Near-duplicate collapse (prefix-stripped gist), not exact casefold only + existing = list(js.state.silent_steps) for c in candidates: - if c.casefold() in existing: + body = strip_slot_prefix(c) + if not body or any(is_near_dup(body, s) for s in existing): continue - js.reason_step(c, salience=0.78) - parked.append(c) - existing.add(c.casefold()) - if len(js.state.silent_steps) >= max(min_steps, 1) and len(parked) >= min_steps: - # Keep parking plan steps up to 3 for richer higher-order chain - if len(parked) >= 3 or len(js.state.silent_steps) >= 3: - break + js.reason_step(body, salience=0.78) + parked.append(body) + existing.append(body) + if len(parked) >= 3 or len(js.state.silent_steps) >= 3: + break if not parked and min_steps > 0: - # Absolute fallback — every material turn gets at least one silent hold - fallback = f"working: {(goal or msg or 'task')[:140]}" - if fallback.casefold() not in existing: + fallback = (derive_plan(goal or msg) or [strip_slot_prefix(goal or msg or "task")])[0][:140] + if fallback and not any(is_near_dup(fallback, s) for s in existing): js.reason_step(fallback, salience=0.72) parked.append(fallback) diff --git a/src/hermespace/agent_api.py b/src/hermespace/agent_api.py index 0a3036a..1581e82 100644 --- a/src/hermespace/agent_api.py +++ b/src/hermespace/agent_api.py @@ -167,10 +167,9 @@ def quick_reply( if not inp.decision: inp.decision = "A — proceed" if not inp.plan: - inp.plan = ["execute"] - if not inp.say: - # leave empty → decode_to_report may fill - pass + from hermespace.execute_focus import derive_plan + + inp.plan = derive_plan(inp.message or inp.goal) out = run_turn(inp) return decode_bundle(out) diff --git a/src/hermespace/desk.py b/src/hermespace/desk.py index 4e7ee49..944589b 100644 --- a/src/hermespace/desk.py +++ b/src/hermespace/desk.py @@ -63,11 +63,18 @@ def recompute_cognition(self, user_message: str = "") -> Desk: } self.meta["production"] = production_stages(self.goal, self.concepts, self.say) - slots = self.slots() + from hermespace.execute_focus import is_protocol_slot, shape_focus + + slots = [s for s in self.slots() if not is_protocol_slot(s.text)] for d in self.do_not_say: slots.append(Slot(d, Modality.EXEC, 0.75)) parts = partition_buffers(slots) - self.focus = [s.label() for s in parts["focus"]] + self.focus = shape_focus( + [s.label() for s in parts["focus"]], + message=user_message or self.goal, + goal=self.goal, + plan=self.plan, + ) self.load = classify_message_load( user_message or self.goal, len(self.concepts), len(self.plan) ) @@ -85,6 +92,23 @@ def recompute_cognition(self, user_message: str = "") -> Desk: } return self.clamp() + def refresh_focus(self, user_message: str = "") -> Desk: + """Re-shape FOA from current concepts. Does not re-encode the stimulus.""" + from hermespace.execute_focus import is_protocol_slot, shape_focus + + slots = [s for s in self.slots() if not is_protocol_slot(s.text)] + for d in self.do_not_say: + slots.append(Slot(d, Modality.EXEC, 0.75)) + parts = partition_buffers(slots) + self.focus = shape_focus( + [s.label() for s in parts["focus"]], + message=user_message or self.goal, + goal=self.goal, + plan=self.plan, + ) + self.meta["focus"] = self.focus + return self + def clamp(self) -> Desk: self.concepts = [c.strip() for c in self.concepts if c and c.strip()][-MAX_CONCEPTS:] self.choices = [c.strip() for c in self.choices if c and c.strip()][:MAX_CHOICES] diff --git a/src/hermespace/engine.py b/src/hermespace/engine.py index 072115d..36b462f 100644 --- a/src/hermespace/engine.py +++ b/src/hermespace/engine.py @@ -57,16 +57,16 @@ def enter( else: desk.add_concept(snip, modality="verbal", salience=0.5) desk.recompute_cognition(user_message or goal) - if not desk.say.strip() and desk.decision.strip(): - from hermespace.cognition import parse_slot - from hermespace.streams import decode_to_report + from hermespace.execute_focus import next_action_line, plan_or_derived - focus_bodies = [parse_slot(f).text for f in desk.focus[:3]] - desk.say = decode_to_report( + desk.plan = plan_or_derived(desk.plan, user_message or desk.goal, desk.goal) + if not desk.say.strip(): + desk.say = next_action_line( goal=desk.goal, + plan=desk.plan, + say="", decision=desk.decision, - focus_texts=focus_bodies, - load_level=str(desk.load.get("level", "mid")), + message=user_message or desk.goal, ) if desk.executive == "protect" and not desk.do_not_say: desk.do_not_say.append("long multi-option menus") diff --git a/src/hermespace/execute_focus.py b/src/hermespace/execute_focus.py index d5b2029..f9373e4 100644 --- a/src/hermespace/execute_focus.py +++ b/src/hermespace/execute_focus.py @@ -35,6 +35,35 @@ re.IGNORECASE, ) _LIST_LINE = re.compile(r"^\s*(?:[-*]|\d+[.)])\s+\S") +_CLAUSE_SPLIT = re.compile( + r"\s*(?:,\s*)?(?:\bthen\b|\bafter that\b|\bfinally\b|\band then\b|;|→|->)\s+", + re.I, +) +_SLOT_PREFIX = re.compile( + r"^(?:lang_stream|intention|step|report-ready|plan|decision-path|" + r"working|multi-step context|production|partner|privacy|continuity):\s*", + re.I, +) +_PROTOCOL_DUMP = re.compile( + r"production:|partner:|privacy:|lang_stream:|intention:|" + r"→\s*A\s+[—-]\s*proceed|\[production:", + re.I, +) +_FILLER_STEPS = { + "execute", + "proceed", + "do it", + "go", + "a — proceed", + "a - proceed", + "a — go", +} +_PROTOCOL_SLOT_PREFIXES = ( + "production:", + "partner:", + "privacy:", + "continuity:", +) def short_name(goal: str, *, cap: int = 40) -> str: @@ -44,6 +73,174 @@ def short_name(goal: str, *, cap: int = 40) -> str: return g if len(g) <= cap else g[: cap - 1].rstrip() + "…" +def strip_slot_prefix(text: str) -> str: + """Drop lang_stream:/intention:/step: (and slot labels) for gist compare.""" + raw = (text or "").strip() + if raw.startswith("[") and "]" in raw[:24]: + raw = raw.split("]", 1)[1].strip() + return _SLOT_PREFIX.sub("", raw).strip() + + +def gist_key(text: str) -> str: + body = strip_slot_prefix(text) + body = re.sub(r"[^\w\s]+", " ", body).casefold() + return " ".join(body.split()) + + +def is_filler_step(text: str) -> bool: + return gist_key(text) in _FILLER_STEPS or strip_slot_prefix(text).casefold() in _FILLER_STEPS + + +def is_protocol_slot(text: str) -> bool: + body = strip_slot_prefix(text) + # strip_slot_prefix already removed the prefix — check the original + raw = (text or "").strip() + if raw.startswith("[") and "]" in raw[:24]: + raw = raw.split("]", 1)[1].strip() + low = raw.casefold() + return low.startswith(_PROTOCOL_SLOT_PREFIXES) + + +def is_bind_restatement(text: str) -> bool: + """Episodic bind blob: ``goal | A — proceed | plan:…`` — not a FOA thought.""" + body = strip_slot_prefix(text) + if " | " not in body: + return False + low = body.casefold() + return "plan:" in low or "a — proceed" in low or "a - proceed" in low + + +def is_near_dup(a: str, b: str) -> bool: + ka, kb = gist_key(a), gist_key(b) + if not ka or not kb: + return False + if ka == kb: + return True + shorter, longer = (ka, kb) if len(ka) <= len(kb) else (kb, ka) + if len(shorter) < 8: + return False + if shorter in longer and len(shorter) / max(len(longer), 1) >= 0.55: + return True + return False + + +def collapse_near_dups( + items: Sequence[str], + *, + prefer_shorter: bool = False, +) -> list[str]: + """Collapse near-duplicate gists. Order preserved. + + ``prefer_shorter`` keeps the clause when a later short step is a + near-dup of an earlier full-sentence echo. + """ + out: list[str] = [] + for raw in items: + s = str(raw or "").strip() + if not s: + continue + hit = next((i for i, prev in enumerate(out) if is_near_dup(s, prev)), None) + if hit is None: + out.append(s) + continue + if prefer_shorter and len(gist_key(s)) < len(gist_key(out[hit])): + out[hit] = s + return out + + +def shape_focus( + labels: Sequence[str], + *, + message: str = "", + goal: str = "", + plan: Sequence[str] | None = None, + cap: int = 4, +) -> list[str]: + """FOA: distinct clauses, not copies of the user sentence or bind blob.""" + clauses = plan_or_derived(plan, message, goal) + raw_full = " ".join((message or goal or "").split()) + out: list[str] = [] + for c in clauses: + if c and not is_filler_step(c) and not any(is_near_dup(c, prev) for prev in out): + out.append(c) + for raw in labels: + s = str(raw or "").strip() + if not s or is_protocol_slot(s) or is_bind_restatement(s) or is_filler_step(s): + continue + body = strip_slot_prefix(s) + if ( + raw_full + and clauses + and is_near_dup(body, raw_full) + and len(gist_key(body)) >= len(gist_key(raw_full)) * 0.8 + ): + continue + if any(is_near_dup(s, prev) for prev in out): + continue + out.append(s) + if len(out) >= cap: + break + return collapse_near_dups(out, prefer_shorter=True)[:cap] + + +def _short_action(text: str) -> str: + t = " ".join((text or "").split()).strip(" .,") + t = re.sub(r"^(then|after that|after|finally|and)\s+", "", t, flags=re.I) + if not t or is_filler_step(t): + return "" + if t[0].islower(): + t = t[0].upper() + t[1:] + return t[:80] if len(t) <= 80 else t[:79].rstrip() + "…" + + +def derive_plan(message: str, *, max_n: int = 3) -> list[str]: + """1–3 real steps from a user sentence. Never the filler ``execute``.""" + msg = " ".join((message or "").strip().split()) + if not msg: + return [] + parts = [p.strip(" .,") for p in _CLAUSE_SPLIT.split(msg) if p and p.strip()] + if len(parts) <= 1: + step = _short_action(msg) + return [step] if step else [] + out: list[str] = [] + for part in parts[: max(1, max_n)]: + step = _short_action(part) + if step and not any(is_near_dup(step, prev) for prev in out): + out.append(step) + return out[:max_n] + + +def plan_or_derived( + plan: Sequence[str] | None, + message: str = "", + goal: str = "", +) -> list[str]: + """Use a real plan when present; otherwise derive 1–3 steps from the message.""" + cleaned = [ + str(p).strip() + for p in (plan or []) + if str(p).strip() and not is_filler_step(p) + ] + if cleaned: + return cleaned[:3] + return derive_plan(message or goal) + + +def _is_bad_lead(line: str) -> bool: + s = (line or "").strip() + if not s: + return True + if _QUIZ_LEAD.match(s) or s.endswith("?"): + return True + if _PROTOCOL_DUMP.search(s): + return True + if is_filler_step(s): + return True + if s.casefold().startswith("→ ") or s.casefold().startswith("-> "): + return True + return False + + def format_park_line(item: dict[str, Any] | str) -> str: """Named parking lot: Name — state — next crumb.""" if isinstance(item, str): @@ -80,20 +277,31 @@ def next_action_line( plan: Sequence[str] | None = None, say: str = "", decision: str = "", + message: str = "", ) -> str: - for step in plan or []: + for step in plan_or_derived(plan, message, goal): s = str(step or "").strip() - if s: + if s and not is_filler_step(s) and not _is_bad_lead(s): return s[:160] for raw in (say or "").splitlines(): s = raw.strip().lstrip("-* ").lstrip("0123456789.) ") - if s and not _QUIZ_LEAD.match(s) and not s.endswith("?"): + if s and not _is_bad_lead(s): return s[:160] dec = (decision or "").strip() - if dec and not dec.lower().startswith("a — proceed"): + if ( + dec + and len(dec) > 8 + and not dec.lower().startswith("a —") + and not dec.lower().startswith("a -") + and not _is_bad_lead(dec) + and not is_filler_step(dec) + ): return dec[:160] + derived = derive_plan(message or goal) + if derived: + return derived[0][:160] g = (goal or "").strip() - if g: + if g and not _is_bad_lead(g): return f"Do the next step on: {short_name(g, cap=80)}" return "Name the first action (under 2 minutes)." @@ -117,18 +325,41 @@ def shape_execute_report( plan: Sequence[str] | None = None, say: str = "", decision: str = "", + message: str = "", ) -> str: - """Line 1 = next action. Lists ≤5. Never quiz/restate as the lead.""" + """Line 1 = next action. Lists ≤5. Never quiz/restate as the lead. + + When operator ``say`` is empty, always use ``next_action_line``. + Protocol dumps (production:/partner:/→ A — proceed) never win the lead. + """ + provided = (say or "").strip() + lead = next_action_line( + goal=goal, + plan=plan, + say=provided, + decision=decision, + message=message, + ) body = _cap_lists((report or "").strip()) - lead = next_action_line(goal=goal, plan=plan, say=say or body, decision=decision) + if not provided: + if not body or _is_bad_lead(body.splitlines()[0]): + return lead + first = body.splitlines()[0].strip() + if first == lead: + return body + if _LIST_LINE.match(first): + return f"{lead}\n{body}".strip() + return lead if not body: return lead first = body.splitlines()[0] - if _QUIZ_LEAD.match(first) or first.strip().endswith("?"): + if _is_bad_lead(first): rest = "\n".join(body.splitlines()[1:]).strip() - return f"{lead}\n{rest}".strip() if rest else lead + if rest and not _PROTOCOL_DUMP.search(rest): + return f"{lead}\n{rest}".strip() + return lead if first.strip() != lead and not _LIST_LINE.match(first): - # Keep an existing action lead; only prepend when the body starts as a list. + # Operator supplied a clean say — keep that action lead. return body if _LIST_LINE.match(first): return f"{lead}\n{body}".strip() diff --git a/src/hermespace/neural_space.py b/src/hermespace/neural_space.py index 154aa30..32931c9 100644 --- a/src/hermespace/neural_space.py +++ b/src/hermespace/neural_space.py @@ -82,8 +82,16 @@ def save_attractors(self, limit: int = 64) -> Path: return self._cache_path def sync_from_desk(self, desk: Desk, *, user_message: str = "") -> dict[str, Any]: - if not self.config.enable: - return {"enabled": False} + skip = os.environ.get("HERMESPACE_SKIP_NEURAL", "0").strip().lower() in { + "1", + "true", + "yes", + "on", + } + if not self.config.enable or skip: + return {"enabled": False, "skipped": skip} + + from hermespace.execute_focus import derive_plan, is_filler_step, shape_focus query = user_message or desk.goal or desk.say self.field.set_query(query) @@ -96,9 +104,14 @@ def sync_from_desk(self, desk: Desk, *, user_message: str = "") -> dict[str, Any modality=slot.modality.value, source="desk", ) - if desk.goal: + # Prefer derived clauses over the raw user sentence on the field. + clauses = derive_plan(user_message or desk.goal) + if len(clauses) >= 2: + for i, c in enumerate(clauses): + self.field.add(c, energy=max(0.6, 0.82 - 0.04 * i), modality="verbal", source="goal") + elif desk.goal: self.field.add(desk.goal, energy=0.85, modality="verbal", source="goal") - if desk.decision: + if desk.decision and not is_filler_step(desk.decision): self.field.add(desk.decision, energy=0.7, modality="exec", source="decision") if desk.say: self.field.add(desk.say, energy=0.65, modality="verbal", source="report") @@ -136,7 +149,12 @@ def sync_from_desk(self, desk: Desk, *, user_message: str = "") -> dict[str, Any bodies.add(v) desk.concepts = new_concepts[-12:] - desk.focus = [f"[{t.modality}|{t.energy:.2f}] {t.text}" for t in ignited] + desk.focus = shape_focus( + [f"[{t.modality}|{t.energy:.2f}] {t.text}" for t in ignited], + message=user_message, + goal=desk.goal, + plan=desk.plan, + ) snap = self.field.snapshot() snap["backend"] = self.config.backend diff --git a/src/hermespace/streams.py b/src/hermespace/streams.py index 211beda..d71862f 100644 --- a/src/hermespace/streams.py +++ b/src/hermespace/streams.py @@ -57,11 +57,17 @@ def encode_stimulus(user_message: str, *, goal_hint: str = "") -> StreamBundle: if not msg: return bundle - # Language / text stream (LLaMA-class features → language areas analogue) - # Keep a compressed semantic gist, not full dump + # Language stream: distinct clauses, not prefixed copies of the full sentence. + from hermespace.execute_focus import derive_plan, gist_key + gist = " ".join(msg.split()[:40]) sal = 0.7 if _LANG_RE.search(msg) else 0.55 - bundle.text.append(Slot(f"lang_stream: {gist[:160]}", Modality.VERBAL, sal)) + clauses = derive_plan(msg) + if clauses: + for i, clause in enumerate(clauses[:4]): + bundle.text.append(Slot(clause, Modality.VERBAL, max(0.45, sal - 0.06 * i))) + else: + bundle.text.append(Slot(gist[:80], Modality.VERBAL, sal)) # Audio stream proxies (Wav2Vec-class) — presence of speech media cues if _AUDIO_RE.search(msg) or "voice.ogg" in msg.lower() or ".ogg" in msg.lower(): @@ -83,8 +89,9 @@ def encode_stimulus(user_message: str, *, goal_hint: str = "") -> StreamBundle: Slot("production: prepare verbal report (decode path)", Modality.EXEC, 0.8) ) - if goal_hint: - bundle.text.append(Slot(f"intention: {goal_hint[:100]}", Modality.VERBAL, 0.65)) + if goal_hint and gist_key(goal_hint) != gist_key(msg): + if not any(gist_key(s.text) == gist_key(goal_hint) for s in bundle.text): + bundle.text.append(Slot(goal_hint[:80], Modality.VERBAL, 0.6)) return bundle @@ -96,23 +103,12 @@ def decode_to_report( focus_texts: list[str], load_level: str, ) -> str: - """Brain2Qwerty reverse: workspace → compressed verbal report draft. - - Does not replace agent speech; supplies a candidate Say under hierarchy: - intention → selected semantics (focus) → surface form. - """ - bits: list[str] = [] - if goal: - bits.append(goal.strip()[:120]) - if decision: - bits.append(f"→ {decision.strip()[:80]}") - if focus_texts: - top = "; ".join(t[:40] for t in focus_texts[:2]) - bits.append(f"[{top}]") - draft = " ".join(bits).strip() - if load_level == "high" and len(draft) > 160: - draft = draft[:157] + "..." - return draft + """Workspace → next-action Report line. Never dump focus protocol slots.""" + from hermespace.execute_focus import next_action_line + + _ = focus_texts + _ = load_level + return next_action_line(goal=goal, decision=decision, message=goal) def production_stages(goal: str, concepts: list[str], say: str) -> dict[str, str]: @@ -131,19 +127,21 @@ def merge_streams_into_concepts( max_add: int = 6, ) -> list[str]: """Fold stream slots into desk concepts without unbounded growth.""" + from hermespace.execute_focus import collapse_near_dups, is_near_dup, is_protocol_slot + out = list(existing) - bodies = {Slot_text_body(c) for c in out} added = 0 for slot in bundle.all_slots(): if added >= max_add: break body = slot.text - if body in bodies: + if is_protocol_slot(body): + continue + if any(is_near_dup(body, Slot_text_body(c)) for c in out): continue out.append(slot.label()) - bodies.add(body) added += 1 - return out + return collapse_near_dups(out) def Slot_text_body(raw: str) -> str: diff --git a/src/hermespace/workbench.py b/src/hermespace/workbench.py index ef6852e..f0b2329 100644 --- a/src/hermespace/workbench.py +++ b/src/hermespace/workbench.py @@ -367,7 +367,7 @@ def receive_order( msg or g, goal=g or msg, decision=decision or "A — proceed", - plan=list(plan or ["execute"]), + plan=list(plan or []), say=say, session_id=self.session_id, agent_id=self.agent_id, diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index cfcfbb5..d600069 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -88,9 +88,11 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: return out # 2–5 desk + from hermespace.execute_focus import plan_or_derived + g = payload.goal or existing.goal or msg[:200] dec = payload.decision or existing.decision or "A — proceed" - pl = payload.plan or existing.plan or ["execute"] + pl = plan_or_derived(payload.plan or existing.plan, msg, g) sy = payload.say if payload.say else existing.say cons = payload.concepts or existing.concepts ch = payload.choices or existing.choices or ["A — proceed"] @@ -265,6 +267,12 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: except Exception as exc: cube_meta = {"ok": False, "error": type(exc).__name__} + try: + desk.refresh_focus(msg) + save_desk(desk, self.engine.desk_path) + except Exception: + pass + # 6 one user-message inject — mid ≤2.8k, high ≤900. No world/protocol essay. from hermespace.context_surgery import ( assemble_inject, @@ -311,8 +319,9 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: report, goal=desk.goal, plan=list(desk.plan or []), - say=desk.say or report, + say=payload.say, decision=desk.decision, + message=msg, ) desk.say = report except Exception: diff --git a/tests/test_access_env.py b/tests/test_access_env.py index 1efa990..987ab27 100644 --- a/tests/test_access_env.py +++ b/tests/test_access_env.py @@ -42,7 +42,8 @@ def test_lens_ranks_held_and_silent(self) -> None: self.assertTrue(any("canary" in t for t in texts)) self.assertTrue(any("rollback" in t for t in texts)) md = env.lens_markdown() - self.assertIn("J-Lens", md) + self.assertIn("Operator lens", md) + self.assertNotIn("J-Lens readout", md) def test_swap_redirects_workspace(self) -> None: from hermespace.access_env import AccessEnv diff --git a/tests/test_desk_quality.py b/tests/test_desk_quality.py new file mode 100644 index 0000000..2f8ebee --- /dev/null +++ b/tests/test_desk_quality.py @@ -0,0 +1,169 @@ +"""Desk quality cut — no-say live turn, FOA collapse, smoke shadow, lens title.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +LIVE_MSG = "Write a short README for the auth fix, then stop." + + +class TestNoSayLiveTurn(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self.tmp.name + os.environ["HERMESPACE_SKIP_NEURAL"] = "1" + os.environ["HERMESPACE_NEURAL_VERBALIZE"] = "0" + os.environ["HERMESPACE_OEW"] = "1" + + def tearDown(self) -> None: + self.tmp.cleanup() + for key in ( + "HERMESPACE_HOME", + "HERMESPACE_SKIP_NEURAL", + "HERMESPACE_NEURAL_VERBALIZE", + "HERMESPACE_OEW", + ): + os.environ.pop(key, None) + + def test_derive_plan_splits_then(self) -> None: + from hermespace.execute_focus import derive_plan, is_near_dup + + plan = derive_plan(LIVE_MSG) + self.assertGreaterEqual(len(plan), 2) + self.assertLessEqual(len(plan), 3) + self.assertNotIn("execute", [p.casefold() for p in plan]) + self.assertTrue(any("readme" in p.casefold() for p in plan)) + self.assertTrue(any("stop" in p.casefold() for p in plan)) + self.assertFalse(is_near_dup(plan[0], plan[1])) + + def test_message_only_report_and_foa(self) -> None: + from hermespace.execute_focus import gist_key, is_near_dup, strip_slot_prefix + from hermespace.io_contract import HermespaceInput + from hermespace.workflow import Workflow + + # Match live CLI: only --message, no --say/--goal/--plan, neural on. + os.environ.pop("HERMESPACE_SKIP_NEURAL", None) + out = Workflow().run(HermespaceInput(message=LIVE_MSG, say="", goal="", plan=[])) + self.assertFalse(out.skipped, out.reason) + line1 = (out.report or "").splitlines()[0].strip() + low = line1.casefold() + self.assertTrue(line1, out.report) + self.assertNotIn("production:", low) + self.assertNotIn("partner:", low) + self.assertNotIn("→ a — proceed", low) + self.assertNotIn("a — proceed", low) + self.assertNotEqual(low, "execute") + self.assertNotEqual(low, LIVE_MSG.casefold()) + self.assertTrue( + "readme" in low or "write" in low, + line1, + ) + self.assertNotEqual(list(out.plan or []), ["execute"]) + self.assertGreaterEqual(len(out.plan or []), 1) + self.assertLessEqual(len(out.plan or []), 3) + self.assertTrue(any("stop" in str(p).casefold() for p in out.plan) or len(out.plan) >= 1) + + focus = list(out.meta.get("focus") or []) if isinstance(out.meta, dict) else [] + if not focus: + from hermespace.store import load_desk + + focus = list(load_desk().focus or []) + self.assertLessEqual(len(focus), 4) + gists = [gist_key(x) for x in focus if gist_key(x)] + for i, a in enumerate(gists): + for b in gists[i + 1 :]: + self.assertFalse(is_near_dup(a, b), f"FOA near-dup {a!r} ~ {b!r} from {focus}") + stripped = [strip_slot_prefix(x) for x in focus] + joined = " ".join(stripped).casefold() + self.assertTrue("readme" in joined or "stop" in joined, focus) + + def test_thanks_still_skips(self) -> None: + from hermespace.io_contract import HermespaceInput + from hermespace.workflow import Workflow + + out = Workflow().run(HermespaceInput(message="thanks", force=False)) + self.assertTrue(out.skipped) + self.assertEqual(out.reason, "trivial_ack") + + def test_shape_empty_say_uses_next_action(self) -> None: + from hermespace.execute_focus import shape_execute_report + + garbled = ( + "Write a short README for the auth fix, then stop. " + "→ A — proceed [production: prepare verbal report (decod; partner: match user" + ) + out = shape_execute_report( + garbled, + goal=LIVE_MSG, + plan=[], + say="", + decision="A — proceed", + message=LIVE_MSG, + ) + line1 = out.splitlines()[0] + self.assertNotIn("production:", line1) + self.assertNotIn("partner:", line1) + self.assertNotIn("A — proceed", line1) + self.assertIn("README", line1) + + def test_operator_lens_title(self) -> None: + from hermespace.access import AccessEnv + + md = AccessEnv(agent_id="lens-title").lens_markdown() + self.assertIn("## Operator lens (external workspace)", md) + self.assertNotIn("J-Lens readout", md) + + +class TestPluginEntryShadow(unittest.TestCase): + def test_root_entry_exposes_register_via_importlib(self) -> None: + import importlib.util + + spec = importlib.util.spec_from_file_location( + "hermespace_repo_plugin_quality", + ROOT / "__init__.py", + submodule_search_locations=[str(ROOT)], + ) + self.assertIsNotNone(spec) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + self.assertTrue(callable(getattr(mod, "register", None))) + + def test_parent_cwd_imports_real_package(self) -> None: + with tempfile.TemporaryDirectory() as td: + parent = Path(td) + link = parent / "hermespace" + link.symlink_to(ROOT) + env = os.environ.copy() + env["PYTHONPATH"] = str(ROOT / "src") + env["HERMESPACE_HOME"] = str(parent / "home") + code = ( + "import hermespace, hermespace.plugin as p\n" + "from hermespace.desk import Desk\n" + "assert callable(p.register)\n" + "assert Desk\n" + "print(hermespace.__file__)\n" + ) + proc = subprocess.run( + [sys.executable, "-c", code], + cwd=str(parent), + env=env, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("src", (proc.stdout or "").replace("\\", "/")) + + +if __name__ == "__main__": + unittest.main() From 1f76fa05a6ccf01bfedfde74317f4d2d727f1d25 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 23:25:27 +0000 Subject: [PATCH 29/32] fix(desk): bind FOA, short verb Report, collapse hub copies Match the proven empty-say cut, then tighten leftovers: desk.focus keeps one bind instead of prefixed copies; hub/neural collapse lang_stream and containment dups; empty say leads with a short verb phrase (Write the README) via next_action_line. Root plugin pops the shim from sys.modules before importing register. Lens title is Access lens (harness workspace); sanitizer still treats J-Lens readout as dirty. Co-authored-by: Pablo --- __init__.py | 53 +++------------ src/hermespace/access/env.py | 2 +- src/hermespace/access/hub.py | 36 ++++++++++- src/hermespace/desk.py | 10 +-- src/hermespace/execute_focus.py | 111 ++++++++++++++++++++++---------- src/hermespace/neural_space.py | 12 +--- src/hermespace/streams.py | 14 ++-- tests/test_access_env.py | 3 +- tests/test_desk_quality.py | 78 ++++++++++++++++------ tests/test_streams.py | 11 +++- 10 files changed, 203 insertions(+), 127 deletions(-) diff --git a/__init__.py b/__init__.py index c7e49e2..39cede7 100644 --- a/__init__.py +++ b/__init__.py @@ -2,60 +2,27 @@ ``hermes plugins install PabloTheThinker/hermespace --enable`` clones this repository as one plugin. The checkout folder is often named ``hermespace``, -which would shadow ``src/hermespace`` if we ``from hermespace.plugin import -register`` while this file is already loaded as that package. Load the real -plugin (and, when shadowed, the real package) via importlib from ``src/``. +which would shadow ``src/hermespace``. Pop this shim from ``sys.modules`` +before importing the real ``register``. """ from __future__ import annotations -import importlib.util import sys from pathlib import Path _ROOT = Path(__file__).resolve().parent _SRC = _ROOT / "src" -_PKG = _SRC / "hermespace" -_PLUGIN = _PKG / "plugin.py" +# Prefer src/ over a parent-cwd folder also named hermespace. +if str(_SRC) in sys.path: + sys.path.remove(str(_SRC)) +sys.path.insert(0, str(_SRC)) -def _load_from_file(name: str, path: Path, *, package_dir: Path | None = None): - spec = importlib.util.spec_from_file_location( - name, - path, - submodule_search_locations=[str(package_dir)] if package_dir else None, - ) - if spec is None or spec.loader is None: - raise ImportError(f"cannot load {path}") - module = importlib.util.module_from_spec(spec) - # Bind before exec so src/hermespace relative imports do not re-enter this file. - sys.modules[name] = module - spec.loader.exec_module(module) - return module - - -def _load_plugin_register(): - mod = _load_from_file("_hermespace_plugin_entry", _PLUGIN) - return mod.register - - -if str(_SRC) not in sys.path: - sys.path.insert(0, str(_SRC)) - +# Parent-cwd / folder-name shadow: drop this file so src/hermespace can load. if __name__ == "hermespace": - # Parent-cwd / folder-name shadow: bind the real package from src/. - real = _load_from_file( - "hermespace", - _PKG / "__init__.py", - package_dir=_PKG, - ) - sys.modules["hermespace"] = real - register = _load_plugin_register() - setattr(real, "register", register) - for _name in getattr(real, "__all__", []): - if hasattr(real, _name): - globals()[_name] = getattr(real, _name) -else: - register = _load_plugin_register() + sys.modules.pop("hermespace", None) + +from hermespace.plugin import register __all__ = ["register"] diff --git a/src/hermespace/access/env.py b/src/hermespace/access/env.py index 7400ee1..84f0742 100644 --- a/src/hermespace/access/env.py +++ b/src/hermespace/access/env.py @@ -227,7 +227,7 @@ def lens(self, *, top_k: int = 12, include_silent: bool = True) -> list[LensHit] def lens_markdown(self, *, top_k: int = 12, include_silent: bool = True) -> str: hits = self.lens(top_k=top_k, include_silent=include_silent) lines = [ - "## Operator lens (external workspace)", + "## Access lens (harness workspace)", f"_agent={self.agent_id} · band={self.band()} · hub={len(self.space.state.hub)}_", "", "What Hermes has on its mind (verbalizable, not weight access):", diff --git a/src/hermespace/access/hub.py b/src/hermespace/access/hub.py index ba48df3..63635d1 100644 --- a/src/hermespace/access/hub.py +++ b/src/hermespace/access/hub.py @@ -373,7 +373,7 @@ def sync_from_desk( held = [c for c in self.state.hub if c.held] silent_keep = list(self.state.silent_steps) - from hermespace.execute_focus import is_bind_restatement, is_near_dup, is_protocol_slot + from hermespace.execute_focus import _keep_score, is_near_dup, is_protocol_slot new_hub: list[WorkspaceConcept] = list(held) seen_texts = [c.text for c in new_hub] @@ -381,9 +381,20 @@ def sync_from_desk( for raw in concepts: slot = parse_slot(raw) body = slot.text.strip() - if not body or is_protocol_slot(body) or is_bind_restatement(body): + if not body or is_protocol_slot(body): continue - if any(is_near_dup(body, prev) for prev in seen_texts): + hit = next((i for i, prev in enumerate(seen_texts) if is_near_dup(body, prev)), None) + if hit is not None: + if _keep_score(body) > _keep_score(seen_texts[hit]): + new_hub[hit] = WorkspaceConcept( + text=body, + salience=slot.salience, + modality=slot.modality.value, + source="desk", + silent=False, + held=False, + ) + seen_texts[hit] = body continue new_hub.append( WorkspaceConcept( @@ -499,11 +510,30 @@ def _drop_body(self, body: str) -> None: needle = body.casefold() self.state.hub = [c for c in self.state.hub if c.text.casefold() != needle] + def _collapse_hub(self) -> None: + """After prefix-strip, hub verbal bodies must be pairwise distinct.""" + from hermespace.execute_focus import _keep_score, is_near_dup, is_protocol_slot + + out: list[WorkspaceConcept] = [] + for c in self.state.hub: + body = (c.text or "").strip() + if not body or is_protocol_slot(body): + continue + hit = next((i for i, prev in enumerate(out) if is_near_dup(body, prev.text)), None) + if hit is None: + out.append(c) + continue + prev = out[hit] + if _keep_score(body) > _keep_score(prev.text) or (c.held and not prev.held): + out[hit] = c + self.state.hub = out + def _recompete( self, preferred_focus: list[str] | None = None, user_message: str = "", ) -> None: + self._collapse_hub() # Limited capacity (Baars/Changeux/Anthropic): hub is a bottleneck if len(self.state.hub) > HUB_CAP: ranked = sorted( diff --git a/src/hermespace/desk.py b/src/hermespace/desk.py index 944589b..231763d 100644 --- a/src/hermespace/desk.py +++ b/src/hermespace/desk.py @@ -65,6 +65,11 @@ def recompute_cognition(self, user_message: str = "") -> Desk: from hermespace.execute_focus import is_protocol_slot, shape_focus + bound = bind_episode(self.goal, self.decision, self.say, self.plan) + if bound: + label = bound.label() + self.concepts = [c for c in self.concepts if not c.strip().lower().startswith("[bind")] + self.concepts.append(label) slots = [s for s in self.slots() if not is_protocol_slot(s.text)] for d in self.do_not_say: slots.append(Slot(d, Modality.EXEC, 0.75)) @@ -79,11 +84,6 @@ def recompute_cognition(self, user_message: str = "") -> Desk: user_message or self.goal, len(self.concepts), len(self.plan) ) self.executive = executive_mode(str(self.load.get("level", "mid")), len(self.choices)) - bound = bind_episode(self.goal, self.decision, self.say, self.plan) - if bound: - label = bound.label() - self.concepts = [c for c in self.concepts if not c.strip().lower().startswith("[bind")] - self.concepts.append(label) self.meta["load"] = self.load self.meta["executive"] = self.executive self.meta["focus"] = self.focus diff --git a/src/hermespace/execute_focus.py b/src/hermespace/execute_focus.py index f9373e4..f465843 100644 --- a/src/hermespace/execute_focus.py +++ b/src/hermespace/execute_focus.py @@ -111,17 +111,27 @@ def is_bind_restatement(text: str) -> bool: def is_near_dup(a: str, b: str) -> bool: + """Prefix-stripped gist match. Containment counts as a duplicate.""" ka, kb = gist_key(a), gist_key(b) if not ka or not kb: return False if ka == kb: return True shorter, longer = (ka, kb) if len(ka) <= len(kb) else (kb, ka) - if len(shorter) < 8: + if len(shorter) < 4: return False - if shorter in longer and len(shorter) / max(len(longer), 1) >= 0.55: - return True - return False + return shorter in longer + + +def _keep_score(text: str) -> int: + """Prefer the episodic bind over a lang_stream:/intention: copy of the same gist.""" + raw = (text or "").strip() + if raw.casefold().startswith("[bind") or is_bind_restatement(raw): + return 3 + rest = raw.split("]", 1)[-1].strip() if raw.startswith("[") else raw + if _SLOT_PREFIX.match(rest): + return 0 + return 1 def collapse_near_dups( @@ -129,11 +139,7 @@ def collapse_near_dups( *, prefer_shorter: bool = False, ) -> list[str]: - """Collapse near-duplicate gists. Order preserved. - - ``prefer_shorter`` keeps the clause when a later short step is a - near-dup of an earlier full-sentence echo. - """ + """Collapse near-duplicate gists. Bind wins over prefixed copies.""" out: list[str] = [] for raw in items: s = str(raw or "").strip() @@ -143,7 +149,13 @@ def collapse_near_dups( if hit is None: out.append(s) continue - if prefer_shorter and len(gist_key(s)) < len(gist_key(out[hit])): + if _keep_score(s) > _keep_score(out[hit]): + out[hit] = s + elif ( + prefer_shorter + and _keep_score(s) == _keep_score(out[hit]) + and len(gist_key(s)) < len(gist_key(out[hit])) + ): out[hit] = s return out @@ -156,31 +168,21 @@ def shape_focus( plan: Sequence[str] | None = None, cap: int = 4, ) -> list[str]: - """FOA: distinct clauses, not copies of the user sentence or bind blob.""" - clauses = plan_or_derived(plan, message, goal) - raw_full = " ".join((message or goal or "").split()) - out: list[str] = [] - for c in clauses: - if c and not is_filler_step(c) and not any(is_near_dup(c, prev) for prev in out): - out.append(c) + """FOA: pairwise-distinct verbal bodies. One bind, not prefixed copies.""" + _ = (message, goal, plan) + cleaned: list[str] = [] for raw in labels: s = str(raw or "").strip() - if not s or is_protocol_slot(s) or is_bind_restatement(s) or is_filler_step(s): + if not s or is_protocol_slot(s) or is_filler_step(s): continue - body = strip_slot_prefix(s) - if ( - raw_full - and clauses - and is_near_dup(body, raw_full) - and len(gist_key(body)) >= len(gist_key(raw_full)) * 0.8 - ): - continue - if any(is_near_dup(s, prev) for prev in out): - continue - out.append(s) - if len(out) >= cap: - break - return collapse_near_dups(out, prefer_shorter=True)[:cap] + cleaned.append(s) + return collapse_near_dups(cleaned)[:cap] + + +_HEAD_VERB = re.compile( + r"^(Write|Open|Fix|Patch|Build|Add|Create|Update|Read|Run|Ship|Deploy|Stop|Verify|Test)\b" +) +_OBJECT_NOUN = re.compile(r"\b(README|TTL|PR|docs?|login|plugin|harness)\b", re.I) def _short_action(text: str) -> str: @@ -193,6 +195,35 @@ def _short_action(text: str) -> str: return t[:80] if len(t) <= 80 else t[:79].rstrip() + "…" +def short_verb_phrase(text: str) -> str: + """Compress a long user clause to a short verb phrase (Write the README).""" + t = _short_action(text) + if not t: + return "" + if len(t.split()) <= 4: + return t + m = _HEAD_VERB.match(t) + obj = _OBJECT_NOUN.search(t) + if not m or not obj: + return t + noun = obj.group(0) + if noun.lower() == "readme": + noun = "README" + return f"{m.group(1)} the {noun}" + + +def _is_raw_user_echo(text: str, message: str = "", goal: str = "") -> bool: + raw = " ".join((message or goal or "").split()) + if not raw or not text: + return False + if gist_key(text) == gist_key(raw): + return True + parts = [p.strip(" .,") for p in _CLAUSE_SPLIT.split(raw) if p and p.strip()] + if parts and gist_key(text) == gist_key(parts[0]) and len(gist_key(text).split()) >= 5: + return True + return False + + def derive_plan(message: str, *, max_n: int = 3) -> list[str]: """1–3 real steps from a user sentence. Never the filler ``execute``.""" msg = " ".join((message or "").strip().split()) @@ -281,8 +312,20 @@ def next_action_line( ) -> str: for step in plan_or_derived(plan, message, goal): s = str(step or "").strip() - if s and not is_filler_step(s) and not _is_bad_lead(s): - return s[:160] + if not s or is_filler_step(s) or _is_bad_lead(s): + continue + phrase = short_verb_phrase(s) + if ( + phrase + and phrase != s + and not _is_bad_lead(phrase) + and not is_filler_step(phrase) + and not _is_raw_user_echo(phrase, message, goal) + ): + return phrase[:80] + if _is_raw_user_echo(s, message, goal): + continue + return s[:160] for raw in (say or "").splitlines(): s = raw.strip().lstrip("-* ").lstrip("0123456789.) ") if s and not _is_bad_lead(s): diff --git a/src/hermespace/neural_space.py b/src/hermespace/neural_space.py index 32931c9..fb86b1d 100644 --- a/src/hermespace/neural_space.py +++ b/src/hermespace/neural_space.py @@ -91,7 +91,7 @@ def sync_from_desk(self, desk: Desk, *, user_message: str = "") -> dict[str, Any if not self.config.enable or skip: return {"enabled": False, "skipped": skip} - from hermespace.execute_focus import derive_plan, is_filler_step, shape_focus + from hermespace.execute_focus import collapse_near_dups, is_filler_step, shape_focus query = user_message or desk.goal or desk.say self.field.set_query(query) @@ -104,13 +104,6 @@ def sync_from_desk(self, desk: Desk, *, user_message: str = "") -> dict[str, Any modality=slot.modality.value, source="desk", ) - # Prefer derived clauses over the raw user sentence on the field. - clauses = derive_plan(user_message or desk.goal) - if len(clauses) >= 2: - for i, c in enumerate(clauses): - self.field.add(c, energy=max(0.6, 0.82 - 0.04 * i), modality="verbal", source="goal") - elif desk.goal: - self.field.add(desk.goal, energy=0.85, modality="verbal", source="goal") if desk.decision and not is_filler_step(desk.decision): self.field.add(desk.decision, energy=0.7, modality="exec", source="decision") if desk.say: @@ -148,7 +141,7 @@ def sync_from_desk(self, desk: Desk, *, user_message: str = "") -> dict[str, Any new_concepts.append(f"[verbal|0.80] {v}") bodies.add(v) - desk.concepts = new_concepts[-12:] + desk.concepts = collapse_near_dups(new_concepts)[-12:] desk.focus = shape_focus( [f"[{t.modality}|{t.energy:.2f}] {t.text}" for t in ignited], message=user_message, @@ -157,6 +150,7 @@ def sync_from_desk(self, desk: Desk, *, user_message: str = "") -> dict[str, Any ) snap = self.field.snapshot() + snap["focus"] = collapse_near_dups(list(snap.get("focus") or [])) snap["backend"] = self.config.backend snap["embed_model"] = getattr(self.embed_backend, "model", "") or self.config.backend snap["enabled"] = True diff --git a/src/hermespace/streams.py b/src/hermespace/streams.py index d71862f..b38b660 100644 --- a/src/hermespace/streams.py +++ b/src/hermespace/streams.py @@ -57,17 +57,12 @@ def encode_stimulus(user_message: str, *, goal_hint: str = "") -> StreamBundle: if not msg: return bundle - # Language stream: distinct clauses, not prefixed copies of the full sentence. - from hermespace.execute_focus import derive_plan, gist_key + # Language / text stream — one gist slot. FOA/hub collapse prefixed copies. + from hermespace.execute_focus import gist_key gist = " ".join(msg.split()[:40]) sal = 0.7 if _LANG_RE.search(msg) else 0.55 - clauses = derive_plan(msg) - if clauses: - for i, clause in enumerate(clauses[:4]): - bundle.text.append(Slot(clause, Modality.VERBAL, max(0.45, sal - 0.06 * i))) - else: - bundle.text.append(Slot(gist[:80], Modality.VERBAL, sal)) + bundle.text.append(Slot(f"lang_stream: {gist[:160]}", Modality.VERBAL, sal)) # Audio stream proxies (Wav2Vec-class) — presence of speech media cues if _AUDIO_RE.search(msg) or "voice.ogg" in msg.lower() or ".ogg" in msg.lower(): @@ -90,8 +85,7 @@ def encode_stimulus(user_message: str, *, goal_hint: str = "") -> StreamBundle: ) if goal_hint and gist_key(goal_hint) != gist_key(msg): - if not any(gist_key(s.text) == gist_key(goal_hint) for s in bundle.text): - bundle.text.append(Slot(goal_hint[:80], Modality.VERBAL, 0.6)) + bundle.text.append(Slot(f"intention: {goal_hint[:100]}", Modality.VERBAL, 0.65)) return bundle diff --git a/tests/test_access_env.py b/tests/test_access_env.py index 987ab27..1828373 100644 --- a/tests/test_access_env.py +++ b/tests/test_access_env.py @@ -42,8 +42,9 @@ def test_lens_ranks_held_and_silent(self) -> None: self.assertTrue(any("canary" in t for t in texts)) self.assertTrue(any("rollback" in t for t in texts)) md = env.lens_markdown() - self.assertIn("Operator lens", md) + self.assertIn("Access lens", md) self.assertNotIn("J-Lens readout", md) + self.assertNotIn("Operator lens", md) def test_swap_redirects_workspace(self) -> None: from hermespace.access_env import AccessEnv diff --git a/tests/test_desk_quality.py b/tests/test_desk_quality.py index 2f8ebee..266b90c 100644 --- a/tests/test_desk_quality.py +++ b/tests/test_desk_quality.py @@ -1,8 +1,7 @@ -"""Desk quality cut — no-say live turn, FOA collapse, smoke shadow, lens title.""" +"""Desk quality cut — empty-say Lyra turn, FOA collapse, smoke shadow, lens title.""" from __future__ import annotations -import json import os import subprocess import sys @@ -16,6 +15,21 @@ LIVE_MSG = "Write a short README for the auth fix, then stop." +def _verbal_bodies(items: list) -> list[str]: + from hermespace.execute_focus import gist_key, strip_slot_prefix + + return [strip_slot_prefix(x) for x in items if gist_key(x)] + + +def _assert_pairwise_distinct(test: unittest.TestCase, items: list, label: str) -> None: + from hermespace.execute_focus import gist_key, is_near_dup + + gists = [gist_key(x) for x in items if gist_key(x)] + for i, a in enumerate(gists): + for b in gists[i + 1 :]: + test.assertFalse(is_near_dup(a, b), f"{label} near-dup {a!r} ~ {b!r} from {items}") + + class TestNoSayLiveTurn(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() @@ -46,8 +60,9 @@ def test_derive_plan_splits_then(self) -> None: self.assertFalse(is_near_dup(plan[0], plan[1])) def test_message_only_report_and_foa(self) -> None: - from hermespace.execute_focus import gist_key, is_near_dup, strip_slot_prefix + from hermespace.execute_focus import gist_key, next_action_line, strip_slot_prefix from hermespace.io_contract import HermespaceInput + from hermespace.store import load_desk from hermespace.workflow import Workflow # Match live CLI: only --message, no --say/--goal/--plan, neural on. @@ -59,32 +74,52 @@ def test_message_only_report_and_foa(self) -> None: self.assertTrue(line1, out.report) self.assertNotIn("production:", low) self.assertNotIn("partner:", low) + self.assertNotIn("lang_stream:", low) self.assertNotIn("→ a — proceed", low) self.assertNotIn("a — proceed", low) self.assertNotEqual(low, "execute") self.assertNotEqual(low, LIVE_MSG.casefold()) - self.assertTrue( - "readme" in low or "write" in low, - line1, - ) + first_clause = "write a short readme for the auth fix" + self.assertNotEqual(low, first_clause) + self.assertLessEqual(len(line1.split()), 5, line1) + self.assertIn("readme", low) + self.assertEqual(line1, next_action_line(message=LIVE_MSG, say="", plan=[])) self.assertNotEqual(list(out.plan or []), ["execute"]) self.assertGreaterEqual(len(out.plan or []), 1) self.assertLessEqual(len(out.plan or []), 3) - self.assertTrue(any("stop" in str(p).casefold() for p in out.plan) or len(out.plan) >= 1) + self.assertTrue(any("stop" in str(p).casefold() for p in out.plan)) focus = list(out.meta.get("focus") or []) if isinstance(out.meta, dict) else [] + desk = load_desk() if not focus: - from hermespace.store import load_desk - - focus = list(load_desk().focus or []) + focus = list(desk.focus or []) self.assertLessEqual(len(focus), 4) - gists = [gist_key(x) for x in focus if gist_key(x)] - for i, a in enumerate(gists): - for b in gists[i + 1 :]: - self.assertFalse(is_near_dup(a, b), f"FOA near-dup {a!r} ~ {b!r} from {focus}") - stripped = [strip_slot_prefix(x) for x in focus] - joined = " ".join(stripped).casefold() - self.assertTrue("readme" in joined or "stop" in joined, focus) + _assert_pairwise_distinct(self, focus, "FOA") + bodies = _verbal_bodies(focus) + joined = " ".join(bodies).casefold() + self.assertTrue("readme" in joined or "bind" in joined or "stop" in joined, focus) + # First-cut FOA: one bind, not four prefixed copies of the user sentence. + bind_n = sum(1 for x in focus if str(x).casefold().startswith("[bind") or " | " in str(x)) + self.assertLessEqual(bind_n, 1, focus) + lang_n = sum(1 for x in focus if "lang_stream:" in str(x).casefold()) + self.assertEqual(lang_n, 0, focus) + copies = sum(1 for x in bodies if gist_key(x) == gist_key(LIVE_MSG)) + self.assertLessEqual(copies, 1, focus) + + hub = [] + neural_focus = [] + if isinstance(out.meta, dict): + access = out.meta.get("access") or {} + neural = out.meta.get("neural") or {} + neural_focus = list(neural.get("focus") or []) + from hermespace.access import AccessHub + from hermespace.access.engine import workspace_id + + js = AccessHub(agent_id=workspace_id(out.meta.get("agent_id") or "hermes-agent", out.session_id or "default")) + hub = [c.text for c in js.state.hub] + _assert_pairwise_distinct(self, hub, "hub") + if neural_focus: + _assert_pairwise_distinct(self, neural_focus, "neural") def test_thanks_still_skips(self) -> None: from hermespace.io_contract import HermespaceInput @@ -114,13 +149,16 @@ def test_shape_empty_say_uses_next_action(self) -> None: self.assertNotIn("partner:", line1) self.assertNotIn("A — proceed", line1) self.assertIn("README", line1) + self.assertNotEqual(line1.casefold(), LIVE_MSG.casefold()) + self.assertLessEqual(len(line1.split()), 5) - def test_operator_lens_title(self) -> None: + def test_access_lens_title(self) -> None: from hermespace.access import AccessEnv md = AccessEnv(agent_id="lens-title").lens_markdown() - self.assertIn("## Operator lens (external workspace)", md) + self.assertIn("## Access lens (harness workspace)", md) self.assertNotIn("J-Lens readout", md) + self.assertNotIn("Operator lens", md) class TestPluginEntryShadow(unittest.TestCase): diff --git a/tests/test_streams.py b/tests/test_streams.py index 7fcb5e9..40cb5b3 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -41,7 +41,16 @@ def test_enter_decode_say(self): auto_load=False, user_message="build hermespace streams", ) - self.assertTrue(desk.say.strip()) + line1 = desk.say.splitlines()[0].strip() if desk.say.strip() else "" + self.assertTrue(line1) + low = line1.casefold() + self.assertNotIn("production:", low) + self.assertNotIn("partner:", low) + self.assertNotIn("→ a — proceed", low) + self.assertNotIn("[production:", low) + self.assertNotEqual(low, "execute") + self.assertNotIn("→", line1) + self.assertNotEqual(line1, "Test decode → A — go") if __name__ == "__main__": unittest.main() From fa7c1319d55c0e95a9bf1ead14ebe1c215bb18ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 23:34:00 +0000 Subject: [PATCH 30/32] =?UTF-8?q?fix(desk):=20land=20zip=20after-state=20?= =?UTF-8?q?=E2=80=94=20Write=20the=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty-say lead is compress_action_phrase(plan[0]) (drop short/a/an). FOA/hub/neural drop lang_stream and user-sentence echoes; bind + Write the README remain. OEW skips gist-contained candidates and still parks distinct Stop. Quality test asserts this live turn. Co-authored-by: Pablo --- src/hermespace/access/hub.py | 11 ++- src/hermespace/access/oew.py | 4 ++ src/hermespace/cognition.py | 20 +++++- src/hermespace/execute_focus.py | 115 ++++++++++++++++++++++---------- src/hermespace/neural_space.py | 31 +++++++-- tests/test_desk_quality.py | 53 +++++++++------ 6 files changed, 171 insertions(+), 63 deletions(-) diff --git a/src/hermespace/access/hub.py b/src/hermespace/access/hub.py index 63635d1..845294f 100644 --- a/src/hermespace/access/hub.py +++ b/src/hermespace/access/hub.py @@ -373,7 +373,12 @@ def sync_from_desk( held = [c for c in self.state.hub if c.held] silent_keep = list(self.state.silent_steps) - from hermespace.execute_focus import _keep_score, is_near_dup, is_protocol_slot + from hermespace.execute_focus import ( + _keep_score, + is_near_dup, + is_protocol_slot, + is_user_echo_copy, + ) new_hub: list[WorkspaceConcept] = list(held) seen_texts = [c.text for c in new_hub] @@ -383,6 +388,10 @@ def sync_from_desk( body = slot.text.strip() if not body or is_protocol_slot(body): continue + if body.casefold().startswith("lang_stream:"): + continue + if is_user_echo_copy(body, user_message, getattr(desk, "goal", "") or ""): + continue hit = next((i for i, prev in enumerate(seen_texts) if is_near_dup(body, prev)), None) if hit is not None: if _keep_score(body) > _keep_score(seen_texts[hit]): diff --git a/src/hermespace/access/oew.py b/src/hermespace/access/oew.py index beb886e..8e60118 100644 --- a/src/hermespace/access/oew.py +++ b/src/hermespace/access/oew.py @@ -61,6 +61,7 @@ def auto_park_silent( derive_plan, is_filler_step, is_near_dup, + is_user_echo_copy, strip_slot_prefix, ) @@ -99,6 +100,9 @@ def auto_park_silent( body = strip_slot_prefix(c) if not body or any(is_near_dup(body, s) for s in existing): continue + # Skip user-sentence echoes; still park distinct short steps (Stop). + if is_user_echo_copy(body, msg, goal): + continue js.reason_step(body, salience=0.78) parked.append(body) existing.append(body) diff --git a/src/hermespace/cognition.py b/src/hermespace/cognition.py index 096c422..2623131 100644 --- a/src/hermespace/cognition.py +++ b/src/hermespace/cognition.py @@ -94,9 +94,25 @@ def classify_message_load(user_message: str, n_concepts: int, n_plan: int) -> di def compete_for_focus(slots: Iterable[Slot], cap: int = FOCUS_CAP) -> list[Slot]: - """GWT-style competition: highest salience wins broadcast focus.""" + """GWT-style competition: highest salience wins; shorter body on containment.""" + from hermespace.execute_focus import _keep_score, gist_key, is_near_dup + ranked = sorted(slots, key=lambda s: s.salience, reverse=True) - return ranked[:cap] + out: list[Slot] = [] + for s in ranked: + hit = next((i for i, prev in enumerate(out) if is_near_dup(s.text, prev.text)), None) + if hit is None: + out.append(s) + continue + prev = out[hit] + if _keep_score(s.label()) > _keep_score(prev.label()): + out[hit] = s + elif ( + _keep_score(s.label()) == _keep_score(prev.label()) + and len(gist_key(s.text)) < len(gist_key(prev.text)) + ): + out[hit] = s + return out[:cap] def partition_buffers(slots: list[Slot]) -> dict[str, list[Slot]]: diff --git a/src/hermespace/execute_focus.py b/src/hermespace/execute_focus.py index f465843..9dd336f 100644 --- a/src/hermespace/execute_focus.py +++ b/src/hermespace/execute_focus.py @@ -87,6 +87,11 @@ def gist_key(text: str) -> str: return " ".join(body.split()) +def foa_gist(text: str) -> str: + """Prefix-stripped gist; trailing punct already dropped.""" + return gist_key(text) + + def is_filler_step(text: str) -> bool: return gist_key(text) in _FILLER_STEPS or strip_slot_prefix(text).casefold() in _FILLER_STEPS @@ -168,21 +173,33 @@ def shape_focus( plan: Sequence[str] | None = None, cap: int = 4, ) -> list[str]: - """FOA: pairwise-distinct verbal bodies. One bind, not prefixed copies.""" - _ = (message, goal, plan) + """FOA: pairwise-distinct verbal bodies. Bind + next action, not lang_stream.""" + _ = plan cleaned: list[str] = [] for raw in labels: s = str(raw or "").strip() if not s or is_protocol_slot(s) or is_filler_step(s): continue + if "lang_stream:" in s.casefold(): + continue + if is_user_echo_copy(s, message, goal): + continue cleaned.append(s) return collapse_near_dups(cleaned)[:cap] -_HEAD_VERB = re.compile( - r"^(Write|Open|Fix|Patch|Build|Add|Create|Update|Read|Run|Ship|Deploy|Stop|Verify|Test)\b" -) -_OBJECT_NOUN = re.compile(r"\b(README|TTL|PR|docs?|login|plugin|harness)\b", re.I) +_FILLER_ADJ = { + "a", + "an", + "the", + "short", + "brief", + "quick", + "small", + "simple", + "little", +} +_PREP_STOP = {"for", "on", "with", "to", "from", "in", "of", "then", "after"} def _short_action(text: str) -> str: @@ -195,31 +212,65 @@ def _short_action(text: str) -> str: return t[:80] if len(t) <= 80 else t[:79].rstrip() + "…" -def short_verb_phrase(text: str) -> str: - """Compress a long user clause to a short verb phrase (Write the README).""" +def compress_action_phrase(text: str, *, cap: int = 40) -> str: + """Verb + object. Drop filler adj (short/a/an). Short steps stay as-is.""" t = _short_action(text) if not t: return "" - if len(t.split()) <= 4: - return t - m = _HEAD_VERB.match(t) - obj = _OBJECT_NOUN.search(t) - if not m or not obj: - return t - noun = obj.group(0) - if noun.lower() == "readme": - noun = "README" - return f"{m.group(1)} the {noun}" + words = t.split() + if len(words) <= 4: + return t if len(t) <= cap else t[: cap - 1].rstrip() + "…" + verb = words[0] + kept: list[str] = [] + for w in words[1:]: + if w.casefold() in _FILLER_ADJ: + continue + if w.casefold() in _PREP_STOP: + break + kept.append(w) + if len(kept) >= 2: + break + if not kept: + return verb[:cap] + obj = kept[0] + if obj.lower() == "readme": + return f"{verb} the README"[:cap] + if obj[0].isupper(): + return f"{verb} the {obj}"[:cap] + if len(kept) > 1: + return f"{verb} {' '.join(kept)}"[:cap] + return f"{verb} {obj}"[:cap] + + +def short_verb_phrase(text: str) -> str: + return compress_action_phrase(text) def _is_raw_user_echo(text: str, message: str = "", goal: str = "") -> bool: - raw = " ".join((message or goal or "").split()) - if not raw or not text: + return is_user_echo_copy(text, message, goal) + + +def is_user_echo_copy(text: str, message: str = "", goal: str = "") -> bool: + """Verbal copy of the user sentence / first clause. Bind and short steps stay.""" + raw_text = (text or "").strip() + if not raw_text: + return False + if raw_text.casefold().startswith("[bind") or is_bind_restatement(raw_text): + return False + body = foa_gist(raw_text) + user = foa_gist(message or goal) + if raw_text.casefold().lstrip("[").startswith("verbal") or "lang_stream:" in raw_text.casefold(): + if user and (body == user or body in user or user in body): + return True + if not body or len(body.split()) < 4: + return False + if not user: return False - if gist_key(text) == gist_key(raw): + if body == user: return True - parts = [p.strip(" .,") for p in _CLAUSE_SPLIT.split(raw) if p and p.strip()] - if parts and gist_key(text) == gist_key(parts[0]) and len(gist_key(text).split()) >= 5: + if body in user and len(body) / max(len(user), 1) >= 0.55: + return True + if user in body and len(user) / max(len(body), 1) >= 0.55: return True return False @@ -310,22 +361,16 @@ def next_action_line( decision: str = "", message: str = "", ) -> str: - for step in plan_or_derived(plan, message, goal): - s = str(step or "").strip() - if not s or is_filler_step(s) or _is_bad_lead(s): - continue - phrase = short_verb_phrase(s) + steps = plan_or_derived(plan, message, goal) + if steps: + phrase = compress_action_phrase(steps[0]) if ( phrase - and phrase != s - and not _is_bad_lead(phrase) and not is_filler_step(phrase) - and not _is_raw_user_echo(phrase, message, goal) + and not _is_bad_lead(phrase) + and not is_user_echo_copy(phrase, message, goal) ): - return phrase[:80] - if _is_raw_user_echo(s, message, goal): - continue - return s[:160] + return phrase[:40] for raw in (say or "").splitlines(): s = raw.strip().lstrip("-* ").lstrip("0123456789.) ") if s and not _is_bad_lead(s): diff --git a/src/hermespace/neural_space.py b/src/hermespace/neural_space.py index fb86b1d..51dfdef 100644 --- a/src/hermespace/neural_space.py +++ b/src/hermespace/neural_space.py @@ -91,13 +91,23 @@ def sync_from_desk(self, desk: Desk, *, user_message: str = "") -> dict[str, Any if not self.config.enable or skip: return {"enabled": False, "skipped": skip} - from hermespace.execute_focus import collapse_near_dups, is_filler_step, shape_focus + from hermespace.execute_focus import ( + collapse_near_dups, + is_filler_step, + is_user_echo_copy, + shape_focus, + strip_slot_prefix, + ) query = user_message or desk.goal or desk.say self.field.set_query(query) for raw in desk.concepts: slot = parse_slot(raw) + if slot.text.casefold().startswith("lang_stream:"): + continue + if is_user_echo_copy(slot.text, user_message, desk.goal): + continue self.field.add( slot.text, energy=slot.salience, @@ -133,9 +143,12 @@ def sync_from_desk(self, desk: Desk, *, user_message: str = "") -> dict[str, Any ) bodies = {parse_slot(c).text for c in new_concepts} for t in ignited: - if t.text not in bodies: - new_concepts.append(f"[{t.modality}|{min(1.0, t.energy):.2f}] {t.text}") - bodies.add(t.text) + if t.text in bodies or t.text.casefold().startswith("lang_stream:"): + continue + if is_user_echo_copy(t.text, user_message, desk.goal): + continue + new_concepts.append(f"[{t.modality}|{min(1.0, t.energy):.2f}] {t.text}") + bodies.add(t.text) for v in verbalized: if v not in bodies: new_concepts.append(f"[verbal|0.80] {v}") @@ -150,7 +163,15 @@ def sync_from_desk(self, desk: Desk, *, user_message: str = "") -> dict[str, Any ) snap = self.field.snapshot() - snap["focus"] = collapse_near_dups(list(snap.get("focus") or [])) + snap["focus"] = [ + strip_slot_prefix(x) if str(x).startswith("[") else str(x) + for x in shape_focus( + list(snap.get("focus") or []), + message=user_message, + goal=desk.goal, + plan=desk.plan, + ) + ] snap["backend"] = self.config.backend snap["embed_model"] = getattr(self.embed_backend, "model", "") or self.config.backend snap["enabled"] = True diff --git a/tests/test_desk_quality.py b/tests/test_desk_quality.py index 266b90c..b8ea2de 100644 --- a/tests/test_desk_quality.py +++ b/tests/test_desk_quality.py @@ -81,13 +81,12 @@ def test_message_only_report_and_foa(self) -> None: self.assertNotEqual(low, LIVE_MSG.casefold()) first_clause = "write a short readme for the auth fix" self.assertNotEqual(low, first_clause) - self.assertLessEqual(len(line1.split()), 5, line1) - self.assertIn("readme", low) + self.assertEqual(line1, "Write the README") self.assertEqual(line1, next_action_line(message=LIVE_MSG, say="", plan=[])) - self.assertNotEqual(list(out.plan or []), ["execute"]) - self.assertGreaterEqual(len(out.plan or []), 1) - self.assertLessEqual(len(out.plan or []), 3) - self.assertTrue(any("stop" in str(p).casefold() for p in out.plan)) + self.assertEqual( + list(out.plan or []), + ["Write a short README for the auth fix", "Stop"], + ) focus = list(out.meta.get("focus") or []) if isinstance(out.meta, dict) else [] desk = load_desk() @@ -96,30 +95,46 @@ def test_message_only_report_and_foa(self) -> None: self.assertLessEqual(len(focus), 4) _assert_pairwise_distinct(self, focus, "FOA") bodies = _verbal_bodies(focus) - joined = " ".join(bodies).casefold() - self.assertTrue("readme" in joined or "bind" in joined or "stop" in joined, focus) - # First-cut FOA: one bind, not four prefixed copies of the user sentence. bind_n = sum(1 for x in focus if str(x).casefold().startswith("[bind") or " | " in str(x)) - self.assertLessEqual(bind_n, 1, focus) - lang_n = sum(1 for x in focus if "lang_stream:" in str(x).casefold()) - self.assertEqual(lang_n, 0, focus) + self.assertEqual(bind_n, 1, focus) + self.assertTrue(any(gist_key(x) == gist_key("Write the README") for x in focus), focus) + self.assertFalse(any("lang_stream:" in str(x).casefold() for x in focus), focus) copies = sum(1 for x in bodies if gist_key(x) == gist_key(LIVE_MSG)) self.assertLessEqual(copies, 1, focus) - hub = [] neural_focus = [] if isinstance(out.meta, dict): - access = out.meta.get("access") or {} neural = out.meta.get("neural") or {} neural_focus = list(neural.get("focus") or []) from hermespace.access import AccessHub from hermespace.access.engine import workspace_id - js = AccessHub(agent_id=workspace_id(out.meta.get("agent_id") or "hermes-agent", out.session_id or "default")) + js = AccessHub( + agent_id=workspace_id( + (out.meta or {}).get("agent_id") or "hermes-agent", + out.session_id or "default", + ) + ) + hub_focus = list(js.state.focus or []) hub = [c.text for c in js.state.hub] _assert_pairwise_distinct(self, hub, "hub") - if neural_focus: - _assert_pairwise_distinct(self, neural_focus, "neural") + _assert_pairwise_distinct(self, hub_focus, "hub-focus") + self.assertFalse(any("lang_stream:" in str(x).casefold() for x in hub_focus), hub_focus) + self.assertFalse( + any("step:" in str(x).casefold() and "stop" in str(x).casefold() for x in hub_focus), + hub_focus, + ) + self.assertTrue(neural_focus, "neural focus missing") + _assert_pairwise_distinct(self, neural_focus, "neural") + self.assertFalse(any("lang_stream:" in str(x).casefold() for x in neural_focus), neural_focus) + self.assertTrue( + any(" | " in str(x) or "plan:" in str(x).casefold() for x in neural_focus), + neural_focus, + ) + self.assertTrue( + any(gist_key(x) == gist_key("Write the README") for x in neural_focus), + neural_focus, + ) def test_thanks_still_skips(self) -> None: from hermespace.io_contract import HermespaceInput @@ -148,9 +163,7 @@ def test_shape_empty_say_uses_next_action(self) -> None: self.assertNotIn("production:", line1) self.assertNotIn("partner:", line1) self.assertNotIn("A — proceed", line1) - self.assertIn("README", line1) - self.assertNotEqual(line1.casefold(), LIVE_MSG.casefold()) - self.assertLessEqual(len(line1.split()), 5) + self.assertEqual(line1, "Write the README") def test_access_lens_title(self) -> None: from hermespace.access import AccessEnv From d770c43dda3a2ddaa78c0185ce09bc96c331ab9f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 00:44:50 +0000 Subject: [PATCH 31/32] fix(inject): put hub silent on the T2 model context Hub already keeps T1 silent_steps. Append last-3 gist-filtered lines after bound_strip so the agent sees parked thought on the next inject. No pending_silent pump, no broadcast, no lens. Skip high/protect. Co-authored-by: Pablo --- src/hermespace/context_surgery.py | 37 ++++++++++ src/hermespace/hermes_bridge.py | 13 ++++ src/hermespace/workflow.py | 9 ++- tests/test_cognition_chain.py | 108 ++++++++++++++++++++++++++++++ tests/test_context_surgery.py | 44 ++++++++++++ 5 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 tests/test_cognition_chain.py diff --git a/src/hermespace/context_surgery.py b/src/hermespace/context_surgery.py index 2e99ae1..2e78133 100644 --- a/src/hermespace/context_surgery.py +++ b/src/hermespace/context_surgery.py @@ -145,3 +145,40 @@ def harvest_on_inject(text: str) -> bool: """True if harvest prose leaked onto the 9k/inject path (must stay false).""" low = (text or "").casefold() return "dream_harvest" in low or "harvest items" in low + + +def silent_chain_strip( + silent_steps: Iterable[str] | None, + user_message: str = "", + *, + last_n: int = 3, + cap: int = SILENT_CHAIN_CAP, +) -> str: + """Last hub silent lines for the model inject. No broadcast, no lens. + + Takes the last ``last_n`` parked steps, drops gist-echoes of the current + user message (T2 restates), and caps at 8. Operator lens / full OEW + broadcast stay off this path. + """ + from hermespace.execute_focus import is_near_dup, is_user_echo_copy, strip_slot_prefix + + raw = [str(s).strip() for s in (silent_steps or []) if str(s or "").strip()] + if not raw: + return "" + window = raw[-max(1, int(last_n)) :] + limit = max(1, min(int(cap), SILENT_CHAIN_CAP)) + kept: list[str] = [] + for step in window: + body = strip_slot_prefix(step) + if not body: + continue + if is_user_echo_copy(body, user_message): + continue + if any(is_near_dup(body, prev) for prev in kept): + continue + kept.append(body[:160]) + if len(kept) >= limit: + break + if not kept: + return "" + return "\n".join(["### Silent", *[f"- {body}" for body in kept]]) diff --git a/src/hermespace/hermes_bridge.py b/src/hermespace/hermes_bridge.py index 8dc76d7..ff913a5 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -22,6 +22,7 @@ def _truthy(name: str, default: str = "0") -> bool: is_fluent_ack, is_shared_hub_child, sanitize_inject, + silent_chain_strip, strip_needed, ) @@ -570,6 +571,18 @@ def on_pre_llm_call( parts = [dual_decode_line(), desk_block] if has_bind: parts.append(bound_strip) + if not high_load: + try: + from hermespace.access import AccessHub + + chain = silent_chain_strip( + AccessHub(agent_id=access_id).state.silent_steps, + msg, + ) + if chain: + parts.append(chain) + except Exception: + pass # One organ strip if it fits: Insight card preferred, else Cube (never dual-pump). organ = "" if not high_load: diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index d600069..4c873ae 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -278,6 +278,7 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: assemble_inject, dual_decode_line, inject_budget, + silent_chain_strip, ) load_level = str(desk.load.get("level") or "mid") if isinstance(desk.load, dict) else "mid" @@ -302,9 +303,15 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: bound = bound_protocol_lines(env) if bound: parts.append(bound) + # Hub already keeps T1 silent; put last parked lines on the inject. + # No full oew_broadcast, no lens. + js = AccessHub(agent_id=access_id) + if not high: + chain = silent_chain_strip(js.state.silent_steps, msg) + if chain: + parts.append(chain) # Lens is operator-only — never append the readout to model context. # Summon still paints the operator Report, not the inject. - js = AccessHub(agent_id=access_id) if js.parse_modulation(msg).get("summon"): report = (report + "\n\n" + env.lens_markdown(include_silent=False)).strip() report = env.shape_user_report(report) diff --git a/tests/test_cognition_chain.py b/tests/test_cognition_chain.py new file mode 100644 index 0000000..006748f --- /dev/null +++ b/tests/test_cognition_chain.py @@ -0,0 +1,108 @@ +"""Two-turn silent chain on the model inject. Hub already keeps T1 silent.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +import sys + +sys.path.insert(0, str(ROOT / "src")) + +T1 = "Write a short README for the auth fix, then stop." +T2 = "Now write the install section." + + +class TestSilentChainInject(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + os.environ["HERMESPACE_HOME"] = self.tmp.name + os.environ["HERMESPACE_AGENT_ID"] = "chain-agent" + os.environ["HERMESPACE_SKIP_NEURAL"] = "1" + os.environ["HERMESPACE_NEURAL_VERBALIZE"] = "0" + os.environ["HERMESPACE_OEW"] = "1" + + def tearDown(self) -> None: + self.tmp.cleanup() + for key in ( + "HERMESPACE_HOME", + "HERMESPACE_AGENT_ID", + "HERMESPACE_SKIP_NEURAL", + "HERMESPACE_NEURAL_VERBALIZE", + "HERMESPACE_OEW", + ): + os.environ.pop(key, None) + + def _hub(self, session_id: str = "chain"): + from hermespace.access import AccessHub + from hermespace.access.engine import workspace_id + + return AccessHub(agent_id=workspace_id("chain-agent", session_id)) + + def test_t2_inject_carries_t1_parked_silent(self) -> None: + from hermespace.io_contract import HermespaceInput + from hermespace.workflow import Workflow + + wf = Workflow() + sid = "chain" + t1 = wf.run( + HermespaceInput( + message=T1, + say="", + goal="", + plan=[], + session_id=sid, + agent_id="chain-agent", + ) + ) + self.assertFalse(t1.skipped, t1.reason) + parked = [str(s) for s in self._hub(sid).state.silent_steps if str(s).strip()] + self.assertTrue(parked, "T1 must park silent on the hub") + parked_l = " ".join(parked).casefold() + self.assertTrue( + "stop" in parked_l or "readme" in parked_l, + parked, + ) + + t2 = wf.run( + HermespaceInput( + message=T2, + say="", + goal="", + plan=[], + session_id=sid, + agent_id="chain-agent", + ) + ) + self.assertFalse(t2.skipped, t2.reason) + ctx = t2.context or "" + self.assertLessEqual(len(ctx), 2800) + self.assertIn("### Silent", ctx) + self.assertNotIn("J-Lens readout", ctx) + self.assertNotIn("What Hermes has on its mind", ctx) + silent_body = ctx.split("### Silent", 1)[-1] + self.assertIn("Stop", silent_body) + self.assertNotIn(T2, silent_body) + + thanks = wf.run( + HermespaceInput( + message="thanks", + session_id=sid, + agent_id="chain-agent", + ) + ) + self.assertTrue(thanks.skipped) + self.assertEqual(thanks.reason, "trivial_ack") + after = [str(s) for s in self._hub(sid).state.silent_steps if str(s).strip()] + self.assertTrue(after, "thanks must not wipe T1 silent") + self.assertTrue( + any("stop" in s.casefold() or "readme" in s.casefold() for s in after), + after, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_context_surgery.py b/tests/test_context_surgery.py index bfa6a2b..ce52048 100644 --- a/tests/test_context_surgery.py +++ b/tests/test_context_surgery.py @@ -67,6 +67,27 @@ def test_budgets_and_fluent_ack(self) -> None: ) blob = assemble_inject(["a" * 4000, "b" * 4000], budget=2800) self.assertLessEqual(len(blob), 2800) + from hermespace.context_surgery import silent_chain_strip + + t2 = "Now write the install section." + strip = silent_chain_strip( + [ + "older-noise", + "Write the README", + "Stop", + t2, + ], + t2, + ) + self.assertIn("### Silent", strip) + self.assertIn("Stop", strip) + self.assertIn("Write the README", strip) + self.assertNotIn("older-noise", strip) + self.assertNotIn(t2, strip) + self.assertNotIn("What Hermes has on its mind", strip) + self.assertNotIn("J-Lens", strip) + self.assertLessEqual(strip.count("\n- "), 3) + self.assertEqual(silent_chain_strip([], t2), "") dirty = sanitize_inject("keep\nJ-Lens readout: secret\ntrue self-conscious\nok") self.assertIn("keep", dirty) self.assertNotIn("J-Lens", dirty) @@ -101,6 +122,29 @@ def test_pre_llm_skips_fluent_and_respects_mid_cap(self) -> None: self.assertNotIn("true self-conscious", ctx) self.assertNotIn("phenomenal consciousness", ctx) + def test_pre_llm_appends_hub_silent_after_bound(self) -> None: + from hermespace.access import AccessHub + from hermespace.access.engine import workspace_id + from hermespace.hermes_bridge import on_pre_llm_call, on_session_start + + on_session_start(session_id="surgery-silent") + js = AccessHub(agent_id=workspace_id("surgery-agent", "surgery-silent")) + js.reason_step("Stop", salience=0.82) + js.save() + inj = on_pre_llm_call( + user_message="Now write the install section.", + session_id="surgery-silent", + is_first_turn=False, + ) + ctx = (inj or {}).get("context") or "" + self.assertTrue(ctx, inj) + self.assertLessEqual(len(ctx), 2800) + self.assertIn("### Silent", ctx) + self.assertIn("Stop", ctx.split("### Silent", 1)[-1]) + self.assertNotIn("Now write the install section.", ctx.split("### Silent", 1)[-1]) + self.assertNotIn("J-Lens readout", ctx) + self.assertNotIn("What Hermes has on its mind", ctx) + def test_high_load_injects_nothing_without_bind(self) -> None: from hermespace import AccessEngine from hermespace.hermes_bridge import on_pre_llm_call, on_session_start From e1327208d59a49acc62547dac4951e785974a671 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 00:47:45 +0000 Subject: [PATCH 32/32] fix(cognition): live-goal refresh plus Silent (prior) on inject Empty-goal T2 that is not a restatement parks the previous goal, re-derives plan (strip now/please/just), and clears leftover say. Hub silent still rides the inject as ### Silent (prior). Co-authored-by: Pablo --- src/hermespace/context_surgery.py | 8 ++++++- src/hermespace/execute_focus.py | 30 ++++++++++++++++++++++++++ src/hermespace/workflow.py | 35 +++++++++++++++++++++---------- tests/test_cognition_chain.py | 28 ++++++++++++++++++++++--- tests/test_context_surgery.py | 11 ++++++---- 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/src/hermespace/context_surgery.py b/src/hermespace/context_surgery.py index 2e78133..944fa80 100644 --- a/src/hermespace/context_surgery.py +++ b/src/hermespace/context_surgery.py @@ -181,4 +181,10 @@ def silent_chain_strip( break if not kept: return "" - return "\n".join(["### Silent", *[f"- {body}" for body in kept]]) + lines = ["### Silent (prior)"] + for body in kept: + if body.casefold().startswith("plan:"): + lines.append(f"- {body}") + else: + lines.append(f"- plan: {body}") + return "\n".join(lines) diff --git a/src/hermespace/execute_focus.py b/src/hermespace/execute_focus.py index 9dd336f..ba1964f 100644 --- a/src/hermespace/execute_focus.py +++ b/src/hermespace/execute_focus.py @@ -200,11 +200,16 @@ def shape_focus( "little", } _PREP_STOP = {"for", "on", "with", "to", "from", "in", "of", "then", "after"} +_LEAD_SKIP = {"now", "please", "just", "okay", "ok"} def _short_action(text: str) -> str: t = " ".join((text or "").split()).strip(" .,") t = re.sub(r"^(then|after that|after|finally|and)\s+", "", t, flags=re.I) + words = t.split() + while words and words[0].casefold() in _LEAD_SKIP: + words = words[1:] + t = " ".join(words) if not t or is_filler_step(t): return "" if t[0].islower(): @@ -275,6 +280,26 @@ def is_user_echo_copy(text: str, message: str = "", goal: str = "") -> bool: return False +def message_is_new_goal(message: str, existing_goal: str) -> bool: + """True when an empty-payload message should replace the live goal. + + Restatement or gist-contained in the current goal → keep goal/plan/say. + """ + msg = " ".join((message or "").split()).strip() + goal = " ".join((existing_goal or "").split()).strip() + if not msg or not goal: + return False + gm, gg = gist_key(msg), gist_key(goal) + if not gm or not gg or gm == gg: + return False + shorter, longer = (gm, gg) if len(gm) <= len(gg) else (gg, gm) + if len(shorter) >= 4 and shorter in longer: + return False + if is_user_echo_copy(msg, goal) or is_user_echo_copy(goal, msg): + return False + return True + + def derive_plan(message: str, *, max_n: int = 3) -> list[str]: """1–3 real steps from a user sentence. Never the filler ``execute``.""" msg = " ".join((message or "").strip().split()) @@ -292,6 +317,11 @@ def derive_plan(message: str, *, max_n: int = 3) -> list[str]: return out[:max_n] +def derive_plan_steps(message: str, *, max_n: int = 3) -> list[str]: + """Derive plan from a new live-goal message (leading now/please/just stripped).""" + return derive_plan(message, max_n=max_n) + + def plan_or_derived( plan: Sequence[str] | None, message: str = "", diff --git a/src/hermespace/workflow.py b/src/hermespace/workflow.py index 4c873ae..45c0748 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -88,21 +88,34 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: return out # 2–5 desk - from hermespace.execute_focus import plan_or_derived + from hermespace.execute_focus import ( + derive_plan_steps, + message_is_new_goal, + plan_or_derived, + ) - g = payload.goal or existing.goal or msg[:200] + provided_goal = (payload.goal or "").strip() + # normalized() copies message → goal when the operator left goal empty. + goal_from_msg = provided_goal in {"", msg, msg[:200]} + msg_goal = goal_from_msg and message_is_new_goal(msg, existing.goal) + if msg_goal: + g = msg[:200] + pl = list(payload.plan or derive_plan_steps(msg)) + sy = payload.say or "" + else: + g = payload.goal or existing.goal or msg[:200] + pl = plan_or_derived(payload.plan or existing.plan, msg, g) + sy = payload.say if payload.say else existing.say dec = payload.decision or existing.decision or "A — proceed" - pl = plan_or_derived(payload.plan or existing.plan, msg, g) - sy = payload.say if payload.say else existing.say cons = payload.concepts or existing.concepts ch = payload.choices or existing.choices or ["A — proceed"] - if ( - existing.goal - and payload.goal - and existing.goal.strip() - and payload.goal.strip() - and existing.goal.strip() != payload.goal.strip() + if existing.goal.strip() and ( + msg_goal + or ( + (payload.goal or "").strip() + and existing.goal.strip() != payload.goal.strip() + ) ): try: from hermespace.workbench import Workbench @@ -120,7 +133,7 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: except Exception: pass - if payload.force or not existing.goal or payload.goal: + if payload.force or not existing.goal or payload.goal or msg_goal: desk = self.engine.enter( goal=g, concepts=list(cons or []), diff --git a/tests/test_cognition_chain.py b/tests/test_cognition_chain.py index 006748f..33f8bb7 100644 --- a/tests/test_cognition_chain.py +++ b/tests/test_cognition_chain.py @@ -1,4 +1,4 @@ -"""Two-turn silent chain on the model inject. Hub already keeps T1 silent.""" +"""Two-turn silent chain + live-goal refresh. Hub already keeps T1 silent.""" from __future__ import annotations @@ -42,6 +42,17 @@ def _hub(self, session_id: str = "chain"): return AccessHub(agent_id=workspace_id("chain-agent", session_id)) + def test_message_is_new_goal(self) -> None: + from hermespace.execute_focus import message_is_new_goal + + self.assertTrue(message_is_new_goal(T2, T1)) + self.assertFalse(message_is_new_goal(T1, T1)) + self.assertFalse( + message_is_new_goal("Write a short README for the auth fix", T1) + ) + self.assertFalse(message_is_new_goal("", T1)) + self.assertFalse(message_is_new_goal(T2, "")) + def test_t2_inject_carries_t1_parked_silent(self) -> None: from hermespace.io_contract import HermespaceInput from hermespace.workflow import Workflow @@ -59,6 +70,11 @@ def test_t2_inject_carries_t1_parked_silent(self) -> None: ) ) self.assertFalse(t1.skipped, t1.reason) + self.assertEqual((t1.report or "").splitlines()[0].strip(), "Write the README") + self.assertEqual( + list(t1.plan or []), + ["Write a short README for the auth fix", "Stop"], + ) parked = [str(s) for s in self._hub(sid).state.silent_steps if str(s).strip()] self.assertTrue(parked, "T1 must park silent on the hub") parked_l = " ".join(parked).casefold() @@ -78,12 +94,18 @@ def test_t2_inject_carries_t1_parked_silent(self) -> None: ) ) self.assertFalse(t2.skipped, t2.reason) + line1 = (t2.report or "").splitlines()[0].strip() + self.assertNotEqual(line1, "Write the README") + self.assertIn("install", line1.casefold()) + self.assertEqual(line1, "Write the install section") + self.assertEqual(list(t2.plan or []), ["Write the install section"]) ctx = t2.context or "" self.assertLessEqual(len(ctx), 2800) - self.assertIn("### Silent", ctx) + self.assertIn("### Silent (prior)", ctx) + self.assertIn("plan: Stop", ctx) self.assertNotIn("J-Lens readout", ctx) self.assertNotIn("What Hermes has on its mind", ctx) - silent_body = ctx.split("### Silent", 1)[-1] + silent_body = ctx.split("### Silent (prior)", 1)[-1] self.assertIn("Stop", silent_body) self.assertNotIn(T2, silent_body) diff --git a/tests/test_context_surgery.py b/tests/test_context_surgery.py index ce52048..f52727a 100644 --- a/tests/test_context_surgery.py +++ b/tests/test_context_surgery.py @@ -79,7 +79,7 @@ def test_budgets_and_fluent_ack(self) -> None: ], t2, ) - self.assertIn("### Silent", strip) + self.assertIn("### Silent (prior)", strip) self.assertIn("Stop", strip) self.assertIn("Write the README", strip) self.assertNotIn("older-noise", strip) @@ -139,9 +139,12 @@ def test_pre_llm_appends_hub_silent_after_bound(self) -> None: ctx = (inj or {}).get("context") or "" self.assertTrue(ctx, inj) self.assertLessEqual(len(ctx), 2800) - self.assertIn("### Silent", ctx) - self.assertIn("Stop", ctx.split("### Silent", 1)[-1]) - self.assertNotIn("Now write the install section.", ctx.split("### Silent", 1)[-1]) + self.assertIn("### Silent (prior)", ctx) + self.assertIn("Stop", ctx.split("### Silent (prior)", 1)[-1]) + self.assertNotIn( + "Now write the install section.", + ctx.split("### Silent (prior)", 1)[-1], + ) self.assertNotIn("J-Lens readout", ctx) self.assertNotIn("What Hermes has on its mind", ctx)